← All posts

UGC-Plattformen: Engineering-Guide für Video-Pipelines

Bau eine robuste Pipeline für User-Generated-Content: Format-Normalisierung, Thumbnail-Generierung, Multi-Resolution-Output und skalierbare Architektur.

FFHub·2026-05-11
UGC-Plattformen: Engineering-Guide für Video-Pipelines

User laden alles hoch. Eine 4K ProRes-Datei aus einer DSLR. Eine vertikale Bildschirmaufnahme als WebM. Ein 10 Jahre altes AVI vom Klapphandy. Ein MOV mit HEVC, das nur in Safari läuft. Deine Plattform muss alles davon akzeptieren und über jedes Gerät und jede Netzwerksituation eine konsistente Wiedergabe liefern.

Dieser Guide deckt die komplette Pipeline für UGC-Plattformen ab — vom Upload bis zur Auslieferung — mit konkreten FFmpeg-Commands und Architekturmustern, die skalieren.

Die UGC-Pipeline

Jede Plattform, die User-Uploads akzeptiert, braucht eine Variante davon:

┌─────────┐   ┌──────────┐   ┌────────────┐   ┌────────────┐   ┌──────────┐
│  Upload  │──>│ Validate │──>│ Normalize  │──>│ Generate   │──>│ Deliver  │
│          │   │ & Store  │   │ Format     │   │ Variants   │   │ via CDN  │
└─────────┘   └──────────┘   └────────────┘   └────────────┘   └──────────┘
                                    │                │
                              ┌─────┴─────┐    ┌────┴─────┐
                              │ Transcode │    │ Thumbnails│
                              │ to H.264  │    │ Previews  │
                              │ MP4       │    │ Multi-res  │
                              └───────────┘    └──────────┘

Jede Stage hat eigene technische Anforderungen. Gehen wir sie durch.

Stage 1: Upload und Validierung

Bevor du Compute aufs Transkodieren verbrennst, validier den Upload:

Datei-Checks

// 上传验证中间件
function validateUpload(req, res, next) {
  const file = req.file;

  // 文件大小限制
  const MAX_SIZE = 2 * 1024 * 1024 * 1024; // 2GB
  if (file.size > MAX_SIZE) {
    return res.status(413).json({ error: "File exceeds 2GB limit" });
  }

  // MIME 类型白名单
  const ALLOWED_TYPES = [
    "video/mp4", "video/quicktime", "video/x-msvideo",
    "video/webm", "video/x-matroska", "video/x-flv",
  ];
  if (!ALLOWED_TYPES.includes(file.mimetype)) {
    return res.status(415).json({ error: "Unsupported video format" });
  }

  next();
}

Datei mit FFprobe inspizieren

Nach dem Upload mit FFprobe Metadaten ziehen, bevor du verarbeitest:

ffprobe -v quiet -print_format json -show_format -show_streams input.mp4

Das gibt dir Dauer, Auflösung, Codec, Bitrate und Frame Rate — alles Infos, auf denen deine Transcoding-Entscheidungen aufbauen.

// 使用 FFprobe 获取视频元数据
async function probeVideo(fileUrl) {
  const response = await fetch("https://api.ffhub.io/v1/tasks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      command: `ffprobe -v quiet -print_format json -show_format -show_streams ${fileUrl}`,
    }),
  });
  const { task_id } = await response.json();
  // 等待完成后解析 JSON 输出
  return await waitAndGetResult(task_id);
}

Stage 2: Format-Normalisierung

Das Ziel: jeden Input akzeptieren und einen konsistenten Output produzieren. Für die große Mehrheit aller UGC-Plattformen heißt das H.264 MP4 mit AAC-Audio.

Warum H.264 MP4?

FaktorH.264 MP4H.265VP9/WebMAV1
Browser-Support99%+~80%~90%~70%
Mobile-SupportUniversalNur iOSMeist AndroidEingeschränkt
Encoding-SpeedSchnell2-3x langsamer3-5x langsamer10x langsamer
Hardware-DecodeUniversalNeuere GeräteChrome/AndroidNur neueste
Dateigröße (Baseline)1x0.5x0.6x0.4x

H.264 gewinnt bei der Kompatibilität. Wenn deine Zielgruppe global ist und ältere Geräte mit drinhängen, ist es der einzig sichere Default. H.265 oder AV1 kannst du als Progressive Enhancement für fähige Clients ausliefern. Tiefer Dive in die Komprimierungs-Settings: FFmpeg Video komprimieren — Best Practices.

Der Basis-Transcode-Command

ffmpeg -i input.mov \
  -c:v libx264 \
  -crf 23 \
  -preset medium \
  -profile:v high \
  -level 4.1 \
  -pix_fmt yuv420p \
  -c:a aac \
  -b:a 128k \
  -ar 44100 \
  -movflags +faststart \
  output.mp4

Warum jeder Flag wichtig ist:

  • -crf 23: Gute Balance aus Qualität und Größe für UGC-Inhalte
  • -profile:v high -level 4.1: Maximale Kompatibilität mit modernen Geräten
  • -pix_fmt yuv420p: Pflicht für Wiedergabe auf vielen Geräten (manche Kameras liefern yuv422p oder yuv444p)
  • -movflags +faststart: Schiebt die Metadaten an den Anfang der Datei, damit die Wiedergabe vor dem vollständigen Download starten kann — kritisch fürs Web

Rotation behandeln

Handy-Videos haben oft Rotations-Metadaten statt physisch rotierter Pixel. FFmpeg behandelt das mit -c:v libx264 automatisch, aber wenn du forcen musst:

ffmpeg -i input.mp4 -c:v libx264 -crf 23 -vf "transpose=1" output.mp4

Auflösung deckeln

User laden 4K hoch, aber dein Player macht bei 1080p Schluss. Auflösung deckeln, ohne kleinere Videos hochzuskalieren:

ffmpeg -i input.mp4 \
  -vf "scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2" \
  -c:v libx264 -crf 23 -preset medium \
  -c:a aac -b:a 128k \
  output.mp4

Das skaliert alles über 1920x1080 herunter und lässt kleinere Videos in Ruhe — und sorgt dafür, dass die Dimensionen gerade Zahlen sind (Pflicht bei H.264).

Stage 3: Thumbnail- und Preview-Generierung

Jedes Video braucht mindestens ein Thumbnail. Die meisten Plattformen erzeugen außerdem animierte Previews fürs Hover.

Statisches Thumbnail

Frame an der 2-Sekunden-Marke ziehen (vermeidet schwarze Frames aus Intros):

ffmpeg -i input.mp4 -ss 2 -frames:v 1 -q:v 2 thumbnail.jpg

Mehrere Thumbnail-Kandidaten

5 gleichmäßig verteilte Frames extrahieren und den User wählen lassen (oder per Bildqualitäts-Scorer auto-auswählen):

ffmpeg -i input.mp4 \
  -vf "select='not(mod(n\,floor($(ffprobe -v error -count_frames -select_streams v:0 -show_entries stream=nb_read_frames -of csv=p=0 input.mp4)/5)))',scale=640:-2" \
  -frames:v 5 -vsync vfr \
  thumb_%02d.jpg

Einfacher — an konkreten Timestamps:

# 在 1s, 25%, 50%, 75% 处分别截取缩略图
ffmpeg -i input.mp4 -ss 1 -frames:v 1 thumb_01.jpg
ffmpeg -i input.mp4 -ss 25% -frames:v 1 thumb_02.jpg
ffmpeg -i input.mp4 -ss 50% -frames:v 1 thumb_03.jpg
ffmpeg -i input.mp4 -ss 75% -frames:v 1 thumb_04.jpg

Animierter Preview (GIF oder WebP)

Eine 3-5 Sekunden Loop-Preview fürs Hover:

# 从第 5 秒开始截取 4 秒,生成 10fps 320px 宽的 GIF
ffmpeg -i input.mp4 -ss 5 -t 4 \
  -vf "fps=10,scale=320:-2:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" \
  preview.gif

Für bessere Qualität bei kleinerer Größe nimm WebP:

ffmpeg -i input.mp4 -ss 5 -t 4 \
  -vf "fps=10,scale=320:-2" \
  -c:v libwebp -lossless 0 -quality 50 -loop 0 \
  preview.webp

Stage 4: Multi-Resolution-Outputs

Für adaptives Streaming mehrere Auflösungen aus der Quelle erzeugen:

# 1080p
ffmpeg -i input.mp4 -vf "scale=-2:1080" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k output_1080p.mp4

# 720p
ffmpeg -i input.mp4 -vf "scale=-2:720" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 96k output_720p.mp4

# 480p
ffmpeg -i input.mp4 -vf "scale=-2:480" -c:v libx264 -crf 26 -preset medium -c:a aac -b:a 64k output_480p.mp4

# 360p
ffmpeg -i input.mp4 -vf "scale=-2:360" -c:v libx264 -crf 28 -preset medium -c:a aac -b:a 48k output_360p.mp4

Beachte: CRF steigt und Audio-Bitrate sinkt bei niedrigeren Auflösungen — weniger Auflösung heißt weniger visuelle Information, also kannst du aggressiver komprimieren.

Auflösungs-Leiter

AuflösungCRFVideo-Bitrate (typisch)Audio-BitrateZielgruppe
1080p233-5 Mbps128kWiFi / Breitband
720p231.5-3 Mbps96kGutes Mobile
480p260.5-1.5 Mbps64kLangsames Mobile
360p280.3-0.7 Mbps48kSehr langsame Verbindung

Vollständige Pipeline

Hier eine Node.js-Implementierung, die die gesamte Pipeline orchestriert — von Upload bis zu allen Outputs:

// ugc-pipeline.js
const API_BASE = "https://api.ffhub.io/v1";
const API_KEY = process.env.FFHUB_API_KEY;

// 提交 FFmpeg 任务并等待完成
async function runFFmpeg(command) {
  const res = await fetch(`${API_BASE}/tasks`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ command }),
  });
  const { task_id } = await res.json();
  return waitForTask(task_id);
}

async function waitForTask(taskId) {
  while (true) {
    const res = await fetch(`${API_BASE}/tasks/${taskId}`, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    });
    const task = await res.json();
    if (task.status === "completed") return task;
    if (task.status === "failed") throw new Error(task.error);
    await new Promise((r) => setTimeout(r, 3000));
  }
}

// UGC 视频处理主流程
async function processUGCVideo(sourceUrl, videoId) {
  console.log(`[${videoId}] 开始处理: ${sourceUrl}`);

  // 步骤 1: 探测源文件
  const probe = await runFFmpeg(
    `ffprobe -v quiet -print_format json -show_format -show_streams ${sourceUrl}`
  );
  const metadata = JSON.parse(probe.output);
  console.log(`[${videoId}] 源文件: ${metadata.format.duration}s, ${metadata.streams[0].width}x${metadata.streams[0].height}`);

  // 步骤 2: 标准化转码 + 缩略图(并行执行)
  const baseCommand = `-c:v libx264 -crf 23 -preset medium -profile:v high -pix_fmt yuv420p -c:a aac -b:a 128k -movflags +faststart`;

  const [normalized, thumbnail, preview] = await Promise.all([
    // 标准化为 1080p H.264 MP4
    runFFmpeg(
      `ffmpeg -i ${sourceUrl} -vf "scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2" ${baseCommand} output.mp4`
    ),
    // 缩略图
    runFFmpeg(
      `ffmpeg -i ${sourceUrl} -ss 2 -frames:v 1 -q:v 2 thumbnail.jpg`
    ),
    // 动画预览
    runFFmpeg(
      `ffmpeg -i ${sourceUrl} -ss 5 -t 4 -vf "fps=10,scale=320:-2" -c:v libwebp -lossless 0 -quality 50 -loop 0 preview.webp`
    ),
  ]);

  // 步骤 3: 生成低分辨率版本(并行)
  const [res720, res480] = await Promise.all([
    runFFmpeg(
      `ffmpeg -i ${sourceUrl} -vf "scale=-2:720" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 96k -movflags +faststart output_720p.mp4`
    ),
    runFFmpeg(
      `ffmpeg -i ${sourceUrl} -vf "scale=-2:480" -c:v libx264 -crf 26 -preset medium -c:a aac -b:a 64k -movflags +faststart output_480p.mp4`
    ),
  ]);

  return {
    videoId,
    outputs: {
      "1080p": normalized.output_url,
      "720p": res720.output_url,
      "480p": res480.output_url,
      thumbnail: thumbnail.output_url,
      preview: preview.output_url,
    },
  };
}

// 使用示例
processUGCVideo("https://your-bucket.s3.amazonaws.com/uploads/raw-video.mov", "vid_abc123")
  .then((result) => console.log("处理完成:", result))
  .catch((err) => console.error("处理失败:", err));

Architektur: Vom Upload bis zur Auslieferung

Hier die volle Production-Architektur einer UGC-Plattform:

┌──────────┐     ┌──────────────┐     ┌──────────────────┐
│  Client  │────>│  Your API    │────>│  Object Storage  │
│  Upload  │     │  Server      │     │  (S3 / R2)       │
└──────────┘     └──────┬───────┘     └────────┬─────────┘
                        │                      │
                        │ POST /tasks          │ source URL
                        v                      │
                 ┌──────────────┐              │
                 │  Transcoding │<─────────────┘
                 │  API         │
                 └──────┬───────┘
                        │
                        │ webhook callback
                        v
                 ┌──────────────┐     ┌──────────────────┐
                 │  Your API    │────>│  CDN              │
                 │  (webhook)   │     │  (CloudFront/CF) │
                 └──────────────┘     └──────────────────┘

Ablauf

  1. Client lädt das Rohvideo zu deinem API-Server hoch (oder per Presigned URL direkt in den Object Storage)
  2. Deine API speichert die Rohdatei und erzeugt einen Verarbeitungsjob
  3. Transcoding API bekommt den FFmpeg-Command mit der Source-URL und verarbeitet das Video
  4. Webhook meldet sich bei deiner API, wenn die Verarbeitung fertig ist, mit Output-URLs
  5. Deine API aktualisiert die DB und macht das Video per CDN verfügbar

Datenbank-Schema

CREATE TABLE videos (
  id          TEXT PRIMARY KEY,
  user_id     TEXT NOT NULL,
  title       TEXT,
  status      TEXT DEFAULT 'uploading',  -- uploading, processing, ready, failed
  source_url  TEXT,
  duration    REAL,
  width       INTEGER,
  height      INTEGER,
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  processed_at TIMESTAMPTZ
);

CREATE TABLE video_variants (
  id          TEXT PRIMARY KEY,
  video_id    TEXT NOT NULL,
  resolution  TEXT NOT NULL,    -- '1080p', '720p', '480p', 'thumbnail', 'preview'
  url         TEXT NOT NULL,
  file_size   BIGINT,
  codec       TEXT,
  bitrate     INTEGER,
  created_at  TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_videos_user ON videos(user_id);
CREATE INDEX idx_variants_video ON video_variants(video_id);

Performance im Maßstab

Verarbeitungszeit-Benchmarks

QuelleDauer1080p OutputThumbnailGesamt-Pipeline
1080p 30s Clip30s~15s<1s~25s
1080p 5min Video5min~90s<1s~120s
4K 10min Video10min~300s<1s~360s
720p 1min Clip1min~20s<1s~30s

Das sind Näherungswerte — tatsächliche Zeiten hängen von Source-Codec, Bitrate und Komplexität ab.

Skalierungs-Strategien

Bei 100 Uploads/Tag: Single-Threaded mit einer simplen Job-Queue. Selbst eine Basis-Queue (datenbankgestützt) reicht.

Bei 1.000 Uploads/Tag: Concurrent mit 5-10 parallelen Jobs. Monitoring und Alerting für hängende Jobs ergänzen.

Bei 10.000+ Uploads/Tag: Volle Async-Architektur mit Webhooks, dediziertem Job-Tracking-DB, Dead Letter Queue für Failures, auto-skalierende Worker oder eine Cloud API, die das für dich übernimmt. Unser Batch-Transcoding-Guide deckt diese Architektur im Detail ab.

Kosten-Überlegungen

Für eine Social-Plattform, die 5.000 Video-Uploads pro Tag verarbeitet (Durchschnitt 2 Minuten):

  • Self-Hosted (dedizierte GPU-Server): ~$2.000-5.000/Monat für Server + Engineering-Zeit
  • Cloud Transcoding API: ~$500-1.500/Monat je nach Output-Varianten
  • Hybrid: Grundlast auf dedizierter Hardware, Overflow zur Cloud API

Stolpersteine, in die wir getreten sind

1. Audio-only oder stumme Videos nicht handhaben

Manche Uploads haben keinen Audio-Stream. FFmpeg knallt, wenn du nicht-existierendes Audio encoden willst:

# 安全处理:如果没有音轨则跳过音频编码
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -c:a aac -b:a 128k output.mp4 2>&1 || \
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -an output.mp4

2. Aspect Ratio ignorieren

Auf eine feste Auflösung zu skalieren, ohne das Seitenverhältnis zu erhalten, produziert verzerrtes Video. Immer -2 für die freie Dimension oder force_original_aspect_ratio=decrease nutzen.

3. Kein Verarbeitungs-Timeout

Eine kaputte Datei kann FFmpeg endlos hängen lassen. Immer ein Timeout setzen:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 600000); // 10 分钟超时

try {
  await fetch(url, { signal: controller.signal });
} finally {
  clearTimeout(timeout);
}

4. Nur eine Auflösung speichern

Wenn du nur das Original speicherst, buffert jede Wiedergabe auf langsamer Verbindung. Wenn du nur eine transkodierte Version speicherst, kannst du nicht an Netzwerkverhältnisse anpassen. Speicher mindestens 2-3 Auflösungen.

Fazit

Eine UGC-Pipeline muss nicht kompliziert sein, aber sie muss zuverlässig sein. Das Grundrezept:

  1. Validate Uploads früh (Format, Größe, Dauer)
  2. Normalize auf H.264 MP4 mit konsistenten Settings
  3. Generate Thumbnails und Previews parallel zum Transcoding
  4. Output in mehreren Auflösungen für adaptives Ausliefern
  5. Deliver per CDN mit ordentlichen Cache-Headern

Der Pipeline-Code in diesem Artikel deckt alle diese Schritte ab. Für die Transcoding-Compute kannst du FFmpeg auf eigenen Servern laufen lassen oder eine Cloud API wie FFHub.io nutzen, um die FFmpeg-Infrastruktur komplett aus dem Weg zu räumen.

Fang einfach an — eine Auflösung mit Thumbnail — und füg Komplexität (Multi-Resolution, animierte Previews, HLS-Packaging) hinzu, wenn deine Plattform wächst. Wenn deine Plattform ein SaaS-Produkt ist, deckt unser Video-Processing-für-SaaS-Guide die Build-vs-Buy-Entscheidung und Integrationsmuster ab.

Verwandte Artikel

UGC-Plattformen: Engineering-Guide für Video-Pipelines | FFHub