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.

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?
| Faktor | H.264 MP4 | H.265 | VP9/WebM | AV1 |
|---|---|---|---|---|
| Browser-Support | 99%+ | ~80% | ~90% | ~70% |
| Mobile-Support | Universal | Nur iOS | Meist Android | Eingeschränkt |
| Encoding-Speed | Schnell | 2-3x langsamer | 3-5x langsamer | 10x langsamer |
| Hardware-Decode | Universal | Neuere Geräte | Chrome/Android | Nur neueste |
| Dateigröße (Baseline) | 1x | 0.5x | 0.6x | 0.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ösung | CRF | Video-Bitrate (typisch) | Audio-Bitrate | Zielgruppe |
|---|---|---|---|---|
| 1080p | 23 | 3-5 Mbps | 128k | WiFi / Breitband |
| 720p | 23 | 1.5-3 Mbps | 96k | Gutes Mobile |
| 480p | 26 | 0.5-1.5 Mbps | 64k | Langsames Mobile |
| 360p | 28 | 0.3-0.7 Mbps | 48k | Sehr 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
- Client lädt das Rohvideo zu deinem API-Server hoch (oder per Presigned URL direkt in den Object Storage)
- Deine API speichert die Rohdatei und erzeugt einen Verarbeitungsjob
- Transcoding API bekommt den FFmpeg-Command mit der Source-URL und verarbeitet das Video
- Webhook meldet sich bei deiner API, wenn die Verarbeitung fertig ist, mit Output-URLs
- 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
| Quelle | Dauer | 1080p Output | Thumbnail | Gesamt-Pipeline |
|---|---|---|---|---|
| 1080p 30s Clip | 30s | ~15s | <1s | ~25s |
| 1080p 5min Video | 5min | ~90s | <1s | ~120s |
| 4K 10min Video | 10min | ~300s | <1s | ~360s |
| 720p 1min Clip | 1min | ~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:
- Validate Uploads früh (Format, Größe, Dauer)
- Normalize auf H.264 MP4 mit konsistenten Settings
- Generate Thumbnails und Previews parallel zum Transcoding
- Output in mehreren Auflösungen für adaptives Ausliefern
- 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
- Batch Video Transcoding per API - Architektur- und Implementierungs-Guide für zuverlässige Massenverarbeitung
- Video Processing für SaaS - Build-vs-Buy-Framework, wenn Video ein Support-Feature ist
- Wie du Videoformat mit FFmpeg konvertierst - Einsteigerfreundlicher Guide zu Format-Konvertierungs-Commands für UGC-Pipelines