Transcodificação em lote de vídeo via API: arquitetura, implementação e escala
Como construir um pipeline confiável de transcodificação em lote de vídeo via API. Fila, retentativas, tratamento de erros e implementação completa em Node.js.

Processar um vídeo é trivial. Processar dez mil vídeos sem perder o sono é outro problema completamente diferente. Seja migrando uma biblioteca de mídia, normalizando uploads de usuários, ou convertendo um arquivo morto para um codec novo, transcodificação (transcoding) em lote traz desafios que simplesmente não existem em escala pequena: gerenciamento de fila, limites de concorrência, recuperação de erros, controle de custo.
Este guia passa pelos padrões de arquitetura, detalhes de implementação e estratégias operacionais necessárias para construir um pipeline de transcodificação em lote pronto para produção.
Quando você precisa de transcodificação em lote
Transcodificação em lote não é apenas "rodar FFmpeg num loop". É uma carga de trabalho distinta com requisitos próprios. Os cenários mais comuns:
Backfill de plataforma UGC
Você lançou aceitando qualquer formato que o usuário enviasse. Agora precisa de cada vídeo em H.264 MP4 em três resoluções para streaming adaptativo. Isso são 50.000 arquivos de origem virando 150.000 saídas. Nosso guia de processamento de vídeo para plataformas UGC cobre esse pipeline em detalhes.
Migração de biblioteca de mídia
Mudando de armazenamento on-premise para cloud, ou trocando de provedor de CDN. Cada asset precisa ser re-codificado para bater com a nova spec de entrega.
Padronização de formato
Sua plataforma acumulou vídeos em dezenas de formatos — MKV, AVI, MOV, WMV, FLV. Os tickets de suporte se acumulam porque alguns não tocam no celular. Você precisa de um job único (ou recorrente) para normalizar tudo.
Arquivamento e compliance
Política regulatória ou interna exige codecs específicos, resoluções, ou marca d'água aplicada a todo conteúdo histórico.
Por que transcodificação em lote é difícil
Rodar ffmpeg num for-loop em um único servidor funciona para 20 arquivos. Não vai funcionar para 20.000. Os motivos:
Disputa por recursos
Transcodificação de vídeo é CPU-intensive. Um único encode 1080p prende 4-8 cores. Rodar 10 em paralelo numa máquina de 8 cores significa thrashing, OOM kills, e qualidade de saída degradada por timeouts.
Gerenciamento de fila
Você precisa rastrear quais arquivos estão pendentes, em processamento, completados, ou falhos. Sem uma fila apropriada, você acaba com processamento duplicado, jobs perdidos, ou um estado irrecuperável depois de um restart.
Tratamento de erro em escala
Em 10.000 arquivos, uma taxa de erro de 1% significa 100 falhas. Arquivos de origem corrompidos, incompatibilidade de codec, timeout de rede, disco cheio — cada modo de falha pede um tratamento diferente.
Teto de escala
Uma máquina única tem um número fixo de cores. Quando o backlog cresce, você precisa de scaling horizontal — múltiplos workers, distribuição de jobs, e agregação de resultados.
Padrões de arquitetura
Existem três abordagens comuns para transcodificação em lote. Cada uma faz trade-offs diferentes.
Padrão 1: Fila + pool de workers (self-hosted)
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Job DB │───>│ Queue │───>│ Worker 1 │
│ │ │ (Redis / │ │ (FFmpeg) │
│ │ │ RabbitMQ)│ ├──────────────┤
│ │ │ │───>│ Worker 2 │
│ │ │ │ │ (FFmpeg) │
│ │ │ │ ├──────────────┤
│ │ │ │───>│ Worker N │
└──────────┘ └───────────┘ └──────────────┘
Vantagens: controle total, sem custo por job, funciona offline.
Desvantagens: você gerencia servidores, versões do FFmpeg, scaling, monitoramento, e recuperação de falha. Provisionar workers consome tempo de engenharia. Precisa de um esforço dedicado de DevOps.
Padrão 2: Event-driven (Cloud Functions)
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ S3 │───>│ Lambda / │───>│ S3 Output │
│ Upload │ │ Cloud Fn │ │ Bucket │
│ Event │ │ (FFmpeg) │ │ │
└──────────┘ └───────────┘ └──────────────┘
Vantagens: escala automática conforme demanda, sem servidores ociosos, paga por invocação.
Desvantagens: limites de timeout da função (15 min no AWS Lambda), cold start (inicialização fria), suporte limitado a binários do FFmpeg, restrições de memória. Tranquilo para thumbnails, problemático para vídeos longos. Para um olhar mais profundo nessas restrições, veja FFmpeg em Serverless: desafios e soluções.
Padrão 3: API de transcodificação cloud
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Your │───>│ API │───>│ Webhook │
│ App │ │ Service │ │ Callback │
│ │ │ │ │ │
└──────────┘ └───────────┘ └──────────────┘
Vantagens: sem infra para gerenciar, escala para qualquer volume, tratamento de erros e retentativas (retries) embutidos, versão do FFmpeg consistente.
Desvantagens: custo por job, requer conectividade de rede, dados saem da sua infra.
Comparação
| Fator | Fila self-hosted | Cloud Functions | Cloud API |
|---|---|---|---|
| Tempo de setup | Dias-semanas | Horas | Minutos |
| Escala | Manual | Auto (com limites) | Auto |
| Duração máx do vídeo | Ilimitada | ~15 min | Ilimitada |
| Manutenção | Alta | Média | Nenhuma |
| Custo a 100 vídeos/dia | $$ (servidor) | $ | $ |
| Custo a 10.000 vídeos/dia | $$$ (cluster) | $$$ (invocações) | $$ |
| Tratamento de erro | Você constrói | Você constrói | Embutido |
Implementação: transcodificação em lote com Cloud API
Vamos construir um sistema completo de transcodificação em lote usando Node.js. A arquitetura é simples: ler uma lista de vídeos de origem, submeter como tarefas da API com concorrência controlada, rastrear progresso, e tratar falhas.
Setup do projeto
mkdir batch-transcode && cd batch-transcode
npm init -y
npm install p-limit
O processador em lote
// batch-transcode.js
import pLimit from "p-limit";
const API_BASE = "https://api.ffhub.io/v1";
const API_KEY = process.env.FFHUB_API_KEY;
const CONCURRENCY = 20; // max parallel jobs
const POLL_INTERVAL = 5000; // 5 seconds
const MAX_RETRIES = 3;
// 模拟视频源列表 — 实际场景从数据库或 S3 列表获取
function getVideoList() {
return Array.from({ length: 100 }, (_, i) => ({
id: `video-${String(i + 1).padStart(4, "0")}`,
url: `https://your-bucket.s3.amazonaws.com/raw/video-${i + 1}.mp4`,
}));
}
// 提交单个转码任务
async function submitTask(video) {
const response = await fetch(`${API_BASE}/tasks`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
command: `ffmpeg -i ${video.url} -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -movflags +faststart output.mp4`,
}),
});
if (!response.ok) {
throw new Error(`Submit failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data.task_id;
}
// 轮询任务状态直到完成或失败
async function waitForCompletion(taskId) {
while (true) {
const response = await fetch(`${API_BASE}/tasks/${taskId}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const task = await response.json();
if (task.status === "completed") {
return { success: true, output_url: task.output_url, duration: task.duration };
}
if (task.status === "failed") {
throw new Error(`Task failed: ${task.error}`);
}
// 仍在处理中,等待后继续轮询
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
}
}
// 带重试的单视频处理
async function processVideo(video, attempt = 1) {
try {
console.log(`[${video.id}] 提交任务 (尝试 ${attempt}/${MAX_RETRIES})`);
const taskId = await submitTask(video);
console.log(`[${video.id}] 任务已提交: ${taskId}`);
const result = await waitForCompletion(taskId);
console.log(`[${video.id}] 完成 — 耗时 ${result.duration}s`);
return { video, status: "completed", ...result };
} catch (error) {
if (attempt < MAX_RETRIES) {
const delay = Math.pow(2, attempt) * 1000; // 指数退避
console.warn(`[${video.id}] 失败,${delay / 1000}s 后重试: ${error.message}`);
await new Promise((resolve) => setTimeout(resolve, delay));
return processVideo(video, attempt + 1);
}
console.error(`[${video.id}] 最终失败: ${error.message}`);
return { video, status: "failed", error: error.message };
}
}
// 主函数 — 带并发限制的批量处理
async function main() {
const videos = getVideoList();
const limit = pLimit(CONCURRENCY);
console.log(`开始批量转码: ${videos.length} 个视频, 并发: ${CONCURRENCY}`);
const startTime = Date.now();
const results = await Promise.all(
videos.map((video) => limit(() => processVideo(video)))
);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const completed = results.filter((r) => r.status === "completed").length;
const failed = results.filter((r) => r.status === "failed").length;
console.log(`\n===== 批量处理完成 =====`);
console.log(`总计: ${videos.length} | 成功: ${completed} | 失败: ${failed}`);
console.log(`总耗时: ${elapsed}s`);
// 输出失败列表以便后续排查
if (failed > 0) {
console.log(`\n失败列表:`);
results
.filter((r) => r.status === "failed")
.forEach((r) => console.log(` ${r.video.id}: ${r.error}`));
}
}
main();
Rode:
FFHUB_API_KEY=your_key node batch-transcode.js
Decisões-chave de design
Por que p-limit em vez de Promise.all no array todo? Submeter 10.000 requisições simultâneas vai derrubar qualquer endpoint. p-limit garante que no máximo CONCURRENCY jobs rodem em paralelo, atuando como rate limiter no client.
Por que backoff exponencial? Falhas transientes (engasgos de rede, rate limits) frequentemente se resolvem sozinhas. Tentar de novo imediatamente só adiciona carga. Recuar exponencialmente — 2s, 4s, 8s — dá tempo do sistema se recuperar.
Por que polling em vez de webhooks aqui? Polling é mais simples para scripts em lote. Para sistemas em produção com web server, webhooks são mais eficientes — veja a próxima seção.
Arquitetura baseada em webhook (produção)
Para sistemas em produção, fazer polling em milhares de tarefas desperdiça banda. Use webhooks no lugar:
// Express webhook endpoint
app.post("/webhooks/transcode-complete", async (req, res) => {
const { task_id, status, output_url, error } = req.body;
// 更新数据库中的任务状态
await db.query(
`UPDATE transcode_jobs
SET status = $1, output_url = $2, error = $3, completed_at = NOW()
WHERE task_id = $4`,
[status, output_url, error, task_id]
);
// 如果失败且未超过重试次数,重新入队
if (status === "failed") {
const job = await db.query(
"SELECT * FROM transcode_jobs WHERE task_id = $1",
[task_id]
);
if (job.retry_count < MAX_RETRIES) {
await enqueueRetry(job);
}
}
res.sendStatus(200);
});
Ao submeter as tarefas, inclua a URL do webhook:
const response = await fetch(`${API_BASE}/tasks`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
command: `ffmpeg -i ${video.url} -c:v libx264 -crf 23 output.mp4`,
webhook_url: "https://your-app.com/webhooks/transcode-complete",
}),
});
Estratégias de tratamento de erro
Em escala de lote, erros são inevitáveis. Como categorizar e tratar:
| Tipo de erro | Exemplo | Estratégia |
|---|---|---|
| Transiente | Timeout de rede, 503 | Retentativa com backoff |
| Arquivo de origem | Arquivo corrompido, codec não suportado | Logar e pular, marcar para revisão |
| Configuração | Parâmetros do FFmpeg inválidos | Corrigir comando, reprocessar lote |
| Rate limit | 429 Too Many Requests | Reduzir concorrência, adicionar delay |
| Permanente | Arquivo não encontrado (404) | Logar, não tentar de novo |
Dead letter queue
Depois de MAX_RETRIES falhas, não apenas logue e esqueça. Empurre os jobs falhos para uma dead letter queue para revisão manual:
async function handlePermanentFailure(video, error) {
await db.query(
`INSERT INTO dead_letter_queue (video_id, source_url, error, failed_at)
VALUES ($1, $2, $3, NOW())`,
[video.id, video.url, error]
);
}
Acompanhamento de progresso
Para lotes grandes, você precisa de visibilidade. Um tracker de progresso minimalista:
class BatchProgress {
constructor(total) {
this.total = total;
this.completed = 0;
this.failed = 0;
this.startTime = Date.now();
}
update(status) {
if (status === "completed") this.completed++;
if (status === "failed") this.failed++;
const done = this.completed + this.failed;
const percent = ((done / this.total) * 100).toFixed(1);
const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(0);
const rate = (done / (elapsed || 1)).toFixed(1);
const eta = ((this.total - done) / (rate || 1)).toFixed(0);
process.stdout.write(
`\r[${percent}%] ${done}/${this.total} | ` +
`OK: ${this.completed} FAIL: ${this.failed} | ` +
`${rate}/s | ETA: ${eta}s `
);
}
}
Análise de custo em escala
Comparação realista de custo para um job único transcodificando 10.000 vídeos (média de 5 minutos cada, saída em 1080p H.264):
| Abordagem | Custo de compute | Tempo de engenharia | Tempo total | Custo total |
|---|---|---|---|---|
| Servidor único (16-core) | ~$200/mês | 2-3 dias setup | ~7 dias | $400+ |
| Cluster Kubernetes (40 cores) | ~$500/mês | 1-2 semanas setup | ~2 dias | $1.500+ |
| AWS Elastic Transcoder | ~$150 | 1-2 dias | ~4 horas | $300+ |
| Cloud FFmpeg API | ~$100 | 2-3 horas | ~2 horas | $150+ |
As opções self-hosted parecem mais baratas em compute bruto, mas o tempo de engenharia domina. O tempo de um engenheiro backend custa $80-150/hora. Dois dias de setup e debug facilmente ultrapassam o custo da API para um lote de 10.000 vídeos.
Para cargas recorrentes, a matemática muda. Se você processa 100.000 vídeos por mês, um cluster self-hosted amortiza os custos de setup. Abaixo desse limiar, uma abordagem baseada em API quase sempre vence no custo total. Para uma análise detalhada de build vs buy voltada para times de SaaS, veja nosso guia de processamento de vídeo para SaaS.
Dicas de otimização
1. Ordenar por tamanho de arquivo
Processe arquivos pequenos primeiro. Isso te dá vitórias rápidas e feedback antecipado se o seu comando do FFmpeg está correto antes de comprometer com encodes grandes.
videos.sort((a, b) => a.fileSize - b.fileSize);
2. Calibrar concorrência
Comece com 10-20 jobs concorrentes e monitore os tempos de resposta da API. Se você vê latência aumentando ou rate limiting, reduza a concorrência. Se os tempos de resposta estão estáveis, aumente gradualmente.
3. Use os presets apropriados
Para jobs em lote onde velocidade importa mais que tamanho de arquivo, use -preset fast ou -preset veryfast. A diferença de tamanho entre medium e fast é tipicamente 5-10%, mas a velocidade de encoding dobra.
4. Pular arquivos já processados
Mantenha um log ou tabela de arquivos processados. Verifique antes de submeter cada job para evitar pagar por trabalho duplicado:
async function shouldProcess(video) {
const existing = await db.query(
"SELECT 1 FROM completed_jobs WHERE video_id = $1",
[video.id]
);
return existing.rows.length === 0;
}
Conclusão
Transcodificação em lote é um problema resolvido se você escolher a arquitetura certa. Para a maioria dos times, a decisão depende de volume e frequência:
- Lote único abaixo de 10.000 vídeos: use uma cloud transcoding API. O código deste artigo funciona out of the box.
- Lote recorrente abaixo de 100.000/mês: API com integração via webhook e um banco para rastrear.
- Lote recorrente acima de 100.000/mês: considere híbrido — workers self-hosted para a carga base previsível, API para os picos.
A implementação Node.js mostrada aqui dá conta do caso comum. Troque pela sua origem de vídeo, ajuste o comando do FFmpeg, defina sua concorrência, e deixe rodar.
Se você quer pular a infraestrutura completamente, FFHub.io oferece uma API cloud de FFmpeg que cuida de scaling, retentativas, e gerenciamento de versões do FFmpeg — você só manda comandos e recebe resultados.
Artigos relacionados
- Processamento de vídeo para plataformas UGC - Guia completo do pipeline para lidar com vídeo enviado por usuário em escala
- Processamento de vídeo para SaaS - Framework de decisão build vs buy com análise de custo em cada estágio de crescimento
- Boas práticas de compressão de vídeo com FFmpeg - Otimize seus comandos FFmpeg para a melhor relação qualidade/tamanho em jobs em lote