← 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

// Middleware zur Upload-Validierung
function validateUpload(req, res, next) {
  const file = req.file;

  // Maximale Dateigröße
  const MAX_SIZE = 2 * 1024 * 1024 * 1024; // 2GB
  if (file.size > MAX_SIZE) {
    return res.status(413).json({ error: "File exceeds 2GB limit" });
  }

  // MIME-Type-Allowlist
  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.

// Video-Metadaten mit FFprobe auslesen
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();
  // Nach Abschluss die JSON-Ausgabe parsen
  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:

# Thumbnails bei 1s, 25%, 50% und 75% aufnehmen
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:

# Ab Sekunde 5 vier Sekunden nehmen und ein GIF mit 10fps und 320px Breite erzeugen
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-Task absenden und auf das Ergebnis warten
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 === "succeeded") return task;
    if (task.status === "failed") throw new Error(task.error);
    await new Promise((r) => setTimeout(r, 3000));
  }
}

// Haupt-Pipeline für UGC-Videos
async function processUGCVideo(sourceUrl, videoId) {
  console.log(`[${videoId}] Verarbeitung: ${sourceUrl}`);

  // Schritt 1: Quelldatei analysieren
  const probe = await runFFmpeg(
    `ffprobe -v quiet -print_format json -show_format -show_streams ${sourceUrl}`
  );
  const metadata = JSON.parse(probe.output);
  console.log(`[${videoId}] Quelle: ${metadata.format.duration}s, ${metadata.streams[0].width}x${metadata.streams[0].height}`);

  // Schritt 2: Normalisieren + Thumbnail (parallel)
  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([
    // Auf 1080p H.264 MP4 normalisieren
    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`
    ),
    // Thumbnail
    runFFmpeg(
      `ffmpeg -i ${sourceUrl} -ss 2 -frames:v 1 -q:v 2 thumbnail.jpg`
    ),
    // Animierte Vorschau
    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`
    ),
  ]);

  // Schritt 3: niedrigere Auflösungen erzeugen (parallel)
  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.outputs[0].url,
      "720p": res720.outputs[0].url,
      "480p": res480.outputs[0].url,
      thumbnail: thumbnail.outputs[0].url,
      preview: preview.outputs[0].url,
    },
  };
}

// Verwendung
processUGCVideo("https://your-bucket.s3.amazonaws.com/uploads/raw-video.mov", "vid_abc123")
  .then((result) => console.log("Fertig:", result))
  .catch((err) => console.error("Fehlgeschlagen:", 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:

# Sicher: Audio-Encoding überspringen, wenn keine Tonspur vorhanden ist
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 Minuten Timeout

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