← All posts

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.

FFHub·2026-05-11
Transcodificação em lote de vídeo via API: arquitetura, implementação e escala

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

FatorFila self-hostedCloud FunctionsCloud API
Tempo de setupDias-semanasHorasMinutos
EscalaManualAuto (com limites)Auto
Duração máx do vídeoIlimitada~15 minIlimitada
ManutençãoAltaMédiaNenhuma
Custo a 100 vídeos/dia$$ (servidor)$$
Custo a 10.000 vídeos/dia$$$ (cluster)$$$ (invocações)$$
Tratamento de erroVocê constróiVocê constróiEmbutido

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 erroExemploEstratégia
TransienteTimeout de rede, 503Retentativa com backoff
Arquivo de origemArquivo corrompido, codec não suportadoLogar e pular, marcar para revisão
ConfiguraçãoParâmetros do FFmpeg inválidosCorrigir comando, reprocessar lote
Rate limit429 Too Many RequestsReduzir concorrência, adicionar delay
PermanenteArquivo 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):

AbordagemCusto de computeTempo de engenhariaTempo totalCusto total
Servidor único (16-core)~$200/mês2-3 dias setup~7 dias$400+
Cluster Kubernetes (40 cores)~$500/mês1-2 semanas setup~2 dias$1.500+
AWS Elastic Transcoder~$1501-2 dias~4 horas$300+
Cloud FFmpeg API~$1002-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

Transcodificação em lote de vídeo via API: arquitetura, implementação e escala | FFHub