Processamento de vídeo para plataformas UGC: o guia completo de engenharia
Construa um pipeline robusto de processamento de vídeo para conteúdo gerado por usuário. Normalização de formato, thumbnails, multi-resolução e arquitetura escalável.

Usuário envia qualquer coisa. Um arquivo 4K ProRes de uma câmera DSLR. Uma gravação de tela vertical em WebM. Um AVI de 10 anos atrás de um celular com tampa. Um MOV com HEVC que só toca no Safari. Sua plataforma precisa aceitar tudo isso e entregar uma experiência de playback consistente em todo dispositivo e condição de rede.
Este guia cobre o pipeline completo de processamento de vídeo para plataformas UGC — do upload à entrega — com comandos FFmpeg práticos e padrões de arquitetura que escalam.
O pipeline de processamento de vídeo UGC
Toda plataforma que aceita vídeo enviado por usuário precisa de alguma versão deste pipeline:
┌─────────┐ ┌──────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐
│ Upload │──>│ Validate │──>│ Normalize │──>│ Generate │──>│ Deliver │
│ │ │ & Store │ │ Format │ │ Variants │ │ via CDN │
└─────────┘ └──────────┘ └────────────┘ └────────────┘ └──────────┘
│ │
┌─────┴─────┐ ┌────┴─────┐
│ Transcode │ │ Thumbnails│
│ to H.264 │ │ Previews │
│ MP4 │ │ Multi-res │
└───────────┘ └──────────┘
Cada estágio tem requisitos técnicos específicos. Vamos passar por eles.
Estágio 1: Upload e validação
Antes de gastar compute em transcodificação, valide o upload:
Checagens em nível de arquivo
// 上传验证中间件
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();
}
Probe do arquivo
Após o upload, use FFprobe para extrair metadados antes do processamento:
ffprobe -v quiet -print_format json -show_format -show_streams input.mp4
Isso te dá duração, resolução, codec, bitrate, e frame rate — tudo isso informa suas decisões de transcodificação.
// 使用 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);
}
Estágio 2: Normalização de formato
O objetivo: pegar qualquer formato de entrada e produzir uma saída consistente. Para a grande maioria das plataformas UGC, isso significa H.264 MP4 com áudio AAC.
Por que H.264 MP4?
| Fator | H.264 MP4 | H.265 | VP9/WebM | AV1 |
|---|---|---|---|---|
| Suporte em browser | 99%+ | ~80% | ~90% | ~70% |
| Suporte mobile | Universal | Só iOS | Android maioria | Limitado |
| Velocidade de encoding | Rápida | 2-3x mais lento | 3-5x mais lento | 10x mais lento |
| Decode em hardware | Universal | Dispositivos mais novos | Chrome/Android | Só os mais novos |
| Tamanho de arquivo (baseline) | 1x | 0.5x | 0.6x | 0.4x |
H.264 ganha em compatibilidade. Se sua audiência é global e inclui dispositivos mais antigos, é a única escolha segura como padrão. Você pode oferecer H.265 ou AV1 como progressive enhancement para clients capazes. Para um mergulho profundo nas configurações de compressão, veja nossas boas práticas de compressão de vídeo com FFmpeg.
O comando base de transcodificação
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
Por que cada flag importa:
-crf 23: Bom equilíbrio de qualidade e tamanho para conteúdo UGC-profile:v high -level 4.1: Compatibilidade máxima com dispositivos modernos-pix_fmt yuv420p: Necessário para playback em muitos dispositivos (algumas câmeras produzem yuv422p ou yuv444p)-movflags +faststart: Move os metadados para o início do arquivo para que o playback comece antes do download completo terminar — crítico para entrega web
Lidando com rotação
Vídeos de celular geralmente têm metadados de rotação em vez de pixels rotacionados fisicamente. O FFmpeg lida com isso automaticamente com -c:v libx264, mas se você precisa forçar:
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -vf "transpose=1" output.mp4
Limitando a resolução
Usuários enviam 4K, mas seu player vai no máximo até 1080p. Limite a resolução sem fazer upscale em vídeos menores:
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
Isso faz downscale de qualquer coisa maior que 1920x1080 deixando vídeos menores intocados, e garante que as dimensões sejam números pares (exigência do H.264).
Estágio 3: Geração de thumbnail e preview
Todo vídeo precisa pelo menos de uma thumbnail. A maioria das plataformas também gera previews animados para hover.
Thumbnail estática
Extrai um frame na marca dos 2 segundos (evita frames pretos de intros):
ffmpeg -i input.mp4 -ss 2 -frames:v 1 -q:v 2 thumbnail.jpg
Múltiplos candidatos a thumbnail
Extrai 5 frames igualmente espaçados e deixa o usuário escolher (ou use um scorer de qualidade de imagem para auto-selecionar):
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
Uma abordagem mais simples — extrair em timestamps específicos:
# 在 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
Preview animado (GIF ou WebP)
Um preview de 3-5 segundos em loop para 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
Para melhor qualidade e tamanho menor, use 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
Estágio 4: Saídas multi-resolução
Para streaming adaptativo, gere múltiplas resoluções a partir da fonte:
# 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
Note como o CRF aumenta e o bitrate de áudio diminui para resoluções mais baixas — resolução menor significa menos informação visual, então você pode comprimir mais agressivamente.
Ladder de resolução
| Resolução | CRF | Bitrate de vídeo (típico) | Bitrate de áudio | Uso alvo |
|---|---|---|---|---|
| 1080p | 23 | 3-5 Mbps | 128k | WiFi / banda larga |
| 720p | 23 | 1.5-3 Mbps | 96k | Mobile bom |
| 480p | 26 | 0.5-1.5 Mbps | 64k | Mobile lento |
| 360p | 28 | 0.3-0.7 Mbps | 48k | Conexão muito lenta |
Pipeline de processamento completo
Aqui vai uma implementação Node.js que orquestra o pipeline inteiro — do upload a todas as saídas:
// 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));
Arquitetura: do upload à entrega
A arquitetura completa de produção para uma plataforma UGC:
┌──────────┐ ┌──────────────┐ ┌──────────────────┐
│ 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) │
└──────────────┘ └──────────────────┘
Fluxo
- Client faz upload do vídeo cru para seu API server (ou direto para object storage via presigned URL)
- Sua API armazena o arquivo cru e cria um job de processamento
- Transcoding API recebe o comando FFmpeg com a URL da fonte, processa o vídeo
- Webhook notifica sua API quando o processamento termina, com URLs de saída
- Sua API atualiza o banco e disponibiliza o vídeo via CDN
Schema de banco
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 em escala
Benchmarks de tempo de processamento
| Fonte | Duração | Saída 1080p | Thumbnail | Pipeline total |
|---|---|---|---|---|
| Clip 1080p 30s | 30s | ~15s | <1s | ~25s |
| Vídeo 1080p 5min | 5min | ~90s | <1s | ~120s |
| Vídeo 4K 10min | 10min | ~300s | <1s | ~360s |
| Clip 720p 1min | 1min | ~20s | <1s | ~30s |
Esses são aproximados — tempos reais dependem do codec da fonte, bitrate, e complexidade.
Estratégias de scaling
Para 100 uploads/dia: processamento single-thread com uma fila simples. Até uma fila básica (em DB) é suficiente.
Para 1.000 uploads/dia: processamento concorrente com 5-10 jobs em paralelo. Adicione monitoramento e alerta para jobs travados.
Para 10.000+ uploads/dia: arquitetura async completa com webhooks, banco dedicado de tracking, dead letter queue para falhas, e workers com auto-scaling ou uma cloud API que cuida do scaling por você. Nosso guia de transcodificação em lote cobre essa arquitetura em detalhes.
Considerações de custo
Para uma plataforma social processando 5.000 uploads de vídeo por dia (média de 2 minutos cada):
- Self-hosted (servidores GPU dedicados): ~$2.000-5.000/mês de servidores + tempo de engenharia
- Cloud transcoding API: ~$500-1.500/mês dependendo dos variants de saída
- Híbrido: carga base em hardware dedicado, picos para cloud API
Pegadinhas que pegamos
1. Não tratar vídeos sem áudio ou silenciosos
Alguns uploads não têm stream de áudio. O FFmpeg vai dar erro se você tentar codificar áudio inexistente:
# 安全处理:如果没有音轨则跳过音频编码
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. Ignorar aspect ratio
Fazer scale para uma resolução fixa sem preservar aspect ratio produz vídeo esticado. Sempre use -2 para a dimensão livre ou force_original_aspect_ratio=decrease.
3. Sem timeout no processamento
Um arquivo corrompido pode fazer o FFmpeg travar indefinidamente. Sempre defina um timeout:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 600000); // 10 分钟超时
try {
await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
4. Armazenar só uma resolução
Se você só guarda o original, todo playback em conexão lenta vai bufferar. Se só guarda uma versão transcodificada, não consegue se adaptar às condições de rede. Guarde pelo menos 2-3 resoluções.
Conclusão
Um pipeline de processamento de vídeo UGC não precisa ser complicado, mas precisa ser confiável. A receita central é:
- Validar uploads cedo (formato, tamanho, duração)
- Normalizar para H.264 MP4 com configurações consistentes
- Gerar thumbnails e previews em paralelo com a transcodificação
- Sair em múltiplas resoluções para entrega adaptativa
- Entregar via CDN com cache headers apropriados
O código de pipeline mostrado neste artigo cobre todos esses passos. Para o compute de transcodificação, você pode rodar FFmpeg nos seus próprios servidores ou usar uma cloud API como o FFHub.io para evitar gerenciar a infra do FFmpeg.
Comece simples — uma única resolução com uma thumbnail — e adicione complexidade (multi-resolução, previews animados, packaging HLS) conforme sua plataforma cresce. Se sua plataforma é um produto SaaS, nosso guia de processamento de vídeo para SaaS cobre a decisão build-vs-buy e padrões de integração.
Artigos relacionados
- Transcodificação em lote de vídeo via API - Guia de arquitetura e implementação para processar milhares de vídeos com confiabilidade
- Processamento de vídeo para SaaS - Framework de decisão build vs buy quando vídeo é uma feature de apoio no seu produto
- Como converter formato de vídeo com FFmpeg - Guia amigável a iniciantes para os comandos de conversão de formato usados em pipelines UGC