FFmpeg no browser: lançar o Render Lab com a DojoClip (análise técnica aprofundada)

Como a DojoClip usa FFmpeg WebAssembly, MediaRecorder e WebCodecs. Arquitetura, exemplos de código, benchmarks reproduzíveis, contas de memória e um guia prático para escolher o pipeline certo.

Pansa Legrandrender lab
Diagrama do pipeline FFmpeg WebAssembly dentro da DojoClip

Bem-vindo ao Render Lab, a nossa série sobre a tecnologia que mantém a DojoClip rápida, privada e amiga dos criadores. Este artigo vai além da apresentação de alto nível e entra no “como”: FFmpeg compilado para WebAssembly (WASM), onde brilha, onde mostra limitações e como o combinamos com MediaRecorder e WebCodecs para um fluxo híbrido de baixa latência.


Resumo rápido

  • FFmpeg WASM significa precisão e cobertura funcional (filtros, trims, remux) com privacidade local.
  • WebCodecs oferece a menor latência, com encode e decode assistidos por hardware, mas precisa de um muxer.
  • MediaRecorder fornece a captura em tempo real mais simples, perfeita para previews, mas com menos controlo.
  • Na prática usamos um pipeline híbrido: WASM para transformações exatas, WebCodecs e MediaRecorder para previews e exportações rápidas.

Conceitos (primer de 2 minutos)

  • Contentor vs. codec: MP4, MKV e WebM são contentores; H.264, H.265, VP9, AV1, AAC e Opus são codecs. Editar costuma exigir decode, depois filtro, depois encode e por fim mux.
  • Transcode vs. remux: transcodificar volta a codificar o ficheiro (há compromisso entre qualidade e tamanho). Remux muda apenas o contentor sem tocar nos streams comprimidos (rápido e sem perdas).
  • CFR vs. VFR: frame rate constante vs. variável. Captura web costuma ser VFR; edição pode preferir CFR para busca frame a frame.
  • Keyframes (IDR): cortes e edições encaixam melhor em keyframes, a menos que transcodifique ou use estratégias de smart render.
  • CRF e bitrate: controlos de qualidade. CRF mais baixo aumenta qualidade e tamanho. O bitrate limita débito para destinos de streaming.

Arquitetura, num relance

[File Input]
   └─▶ Browser FS (OPFS / RAM)
         ├─▶ FFmpeg WASM worker (precision filters, remux, waveform)
         ├─▶ WebCodecs (fast decode/encode; preview/export)
         └─▶ MediaRecorder (real-time canvas/tab capture)
                     ▼
               [Muxer] → MP4/WebM → Download / OPFS / Upload

Porque usar WASM?

Porque traz grande parte da CLI do FFmpeg para dentro do browser. Isto desbloqueia cortes precisos ao frame, filter graphs complexos e operações de canais de áudio, mantendo o processamento local para media sensível.

Porque não usar só WASM?

Encodes longos forçam memória e CPU, o primeiro carregamento ocupa vários megabytes e threads ou SIMD exigem headers de isolamento cross-origin.

Porque usar também WebCodecs e MediaRecorder?

WebCodecs aproveita encoders e decoders da plataforma para ganhar velocidade. MediaRecorder é trivial para captura ao vivo e para proxies rápidos.


Como disponibilizar FFmpeg WASM da forma certa

1. Ativar Threads e SIMD (ganhos reais)

Threads com SIMD costumam trazer ganhos entre 1,5x e 3x, dependendo da carga. Exigem headers de isolamento cross-origin.

// next.config.js (or middleware/headers)
const securityHeaders = [
  { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
  { key: 'Cross-Origin-Embedder-Policy', value: 'require-corp' },
  { key: 'Cross-Origin-Resource-Policy', value: 'same-site' },
];

module.exports = {
  async headers() {
    return [{ source: '/:path*', headers: securityHeaders }];
  },
};

2. Carregar o core num worker

Evite bloquear a UI thread e mantenha a memória do FFmpeg isolada.

// ffmpeg.worker.ts
import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';
const ffmpeg = createFFmpeg({ log: true, corePath: '/wasm/ffmpeg-core.js' });

self.onmessage = async (event) => {
  const { name, file, args } = event.data;
  if (!ffmpeg.isLoaded()) {
    await ffmpeg.load();
  }
  ffmpeg.FS('writeFile', name, await fetchFile(file));
  await ffmpeg.run(...args);
  const out = ffmpeg.FS('readFile', 'out.bin');
  self.postMessage({ ok: true, data: out.buffer }, [out.buffer]);
};
// UI side
const worker = new Worker(new URL('./ffmpeg.worker.ts', import.meta.url));
worker.postMessage({
  name: 'in.mp4',
  file,
  args: ['-i', 'in.mp4', '-vn', '-acodec', 'copy', 'out.bin'],
});

worker.onmessage = ({ data }) => {
  const blob = new Blob([data.data], { type: 'audio/mp4' });
  download(blob, 'audio.m4a');
};

3. Guardar intermédios grandes em OPFS

O Origin Private File System evita picos enormes de RAM.

const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('clip.m4a', { create: true });
const writer = await fileHandle.createWritable();
await writer.write(blob);
await writer.close();

Tarefas do dia a dia (receitas FFmpeg prontas a copiar)

Extração de áudio (bit-exact se já estiver em AAC):

ffmpeg -i input.mp4 -vn -acodec copy audio.m4a

Downscale com transcode CRF (proxy H.264):

ffmpeg -i input_4k.mp4 -vf scale=-2:1080 -c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 160k out_1080p.mp4

Segmento preciso ao frame (re-encode de uma janela pequena):

ffmpeg -ss 00:00:12.300 -to 00:00:19.000 -i input.mp4 -c:v libx264 -crf 20 -pix_fmt yuv420p -c:a aac slice.mp4

PNG de waveform para timeline:

ffmpeg -i audio.m4a -lavfi showwavespic=s=1200x200:colors=white waveform.png

Em WASM passa exatamente os mesmos argumentos para ffmpeg.run(...). Para remux puro (sem transcode), mantenha -c copy para preservar qualidade e velocidade.


WebCodecs: a via rápida

WebCodecs dá-lhe acesso direto aos decoders e encoders da plataforma. Precisa de um muxer para empacotar os chunks codificados em MP4 ou WebM.

const fps = 30;
const encoder = new VideoEncoder({
  output: handleChunk,
  error: console.error,
});

encoder.configure({
  codec: 'avc1.42E01E',
  width: canvas.width,
  height: canvas.height,
  bitrate: 3_000_000,
  framerate: fps,
});

let t0 = performance.now();
let frameIndex = 0;
const track = canvas.captureStream(fps).getVideoTracks()[0];
const reader = new MediaStreamTrackProcessor({ track }).readable.getReader();

async function pump() {
  const { value: frame, done } = await reader.read();
  if (done) {
    await encoder.flush();
    muxer.finalize();
    return;
  }
  const timestamp = Math.floor((performance.now() - t0) * 1000);
  const videoFrame = new VideoFrame(frame, { timestamp });
  encoder.encode(videoFrame, { keyFrame: frameIndex % (fps * 2) === 0 });
  frameIndex += 1;
  videoFrame.close();
  frame.close();
  pump();
}

function handleChunk(chunk) {
  muxer.addVideoChunk(chunk);
}

Nota sobre muxing: o WebCodecs produz elementary streams. Use uma biblioteca de muxing no browser para gerar um ficheiro descarregável. Para previews, o Media Source Extensions pode alimentar um elemento video com esses chunks.

Comparado com FFmpeg WASM, a latência de encode costuma ser mais baixa e o uso de CPU mais pequeno, sobretudo em dispositivos com aceleração por hardware.


MediaRecorder: proxies sem complicações

const stream = canvas.captureStream(30);
const recorder = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=vp9' });
const chunks = [];

recorder.ondataavailable = (event) => {
  if (event.data.size) {
    chunks.push(event.data);
  }
};

recorder.onstop = () => {
  const blob = new Blob(chunks, { type: recorder.mimeType });
  download(blob, 'preview.webm');
};

recorder.start();
// ... render frames ...
recorder.stop();

Prós: simples e ótimo para revisões rápidas ou proxies para redes sociais. Contras: controlo limitado sobre GOP, CRF e filtragem avançada.


Como escolher o pipeline certo

Tarefa Precisão necessária Latência alvo Recomendado
Extração de áudio, remux Alta Baixa FFmpeg WASM (-c copy)
Waveform ou thumbnails Alta Baixa FFmpeg WASM (filtros)
Cortes precisos ao frame Alta Média FFmpeg WASM (pequena janela com re-encode)
Preview ao vivo ou screen capture Média Muito baixa MediaRecorder
Exportações finais de timelines em canvas Média Baixa WebCodecs + muxer
Transcodes longos offline Média Alta Fallback nativo ou servidor

Contas de memória (planeie antes de renderizar)

  • O tamanho de um frame de vídeo descodificado é aproximadamente largura × altura × 1,5 bytes (YUV420p). Exemplo: 1920×1080 corresponde a cerca de 3,1 MB por frame. Um buffer pequeno com 120 frames ronda os 372 MB.
  • O crescimento do heap WASM pode disparar durante filtros como scalers ou resamplers. Guarde intermédios em disco (OPFS) e faça streaming sempre que possível.
  • Áudio: PCM 48 kHz estéreo 16-bit ocupa cerca de 192 KB por segundo. Cinco minutos representam aproximadamente 57 MB se tudo ficar descomprimido em memória.

Dicas práticas

  • Prefira remux (-c copy) em vez de transcode completo sempre que puder.
  • Divida trabalhos longos em segmentos, persista em OPFS e depois concatene com FFmpeg.
  • Para previews, use WebCodecs para evitar manter muitos raw frames em memória JavaScript.

Benchmarks reproduzíveis (preencha antes de publicar)

Corra estes testes numa build de produção limpa. Registe também o dispositivo e a versão do browser.

  1. Extração de áudio (remux)

    • Input: MP4 (H.264 + AAC), 1 a 2 minutos.
    • Comando: -i in.mp4 -vn -acodec copy out.m4a
    • Métricas: tempo total (ms), pico de heap JS (MB), heap WASM (MB), tamanho final.
  2. Transcode proxy (4K para 1080p)

    • Comando: -vf scale=-2:1080 -c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 160k
    • Compare: FFmpeg WASM vs WebCodecs com bitrate e framerate semelhantes. Registe tempo, percentagem de CPU, dropped frames.
  3. Export de timeline em canvas

    • Método A: encode com WebCodecs + MP4 muxer.
    • Método B: captura com MediaRecorder a 30 fps.
    • Métricas: FPS de encode, tempo total, bitrate ou tamanho do output, qualidade visual (SSIM ou PSNR, se conseguir comparar com ground truth).

Tabela modelo (substitua N/A pelos valores medidos):

Device / Browser Teste Pipeline Tempo (s) CPU média (%) Pico Mem (MB) Notas
M2 Pro / Chrome 128 Audio extract FFmpeg WASM Threads + SIMD
M2 Pro / Chrome 128 4K to 1080p FFmpeg WASM Threads + SIMD
M2 Pro / Chrome 128 4K to 1080p WebCodecs H.264 por hardware
Pixel 8 / Chrome Canvas export MediaRecorder VP9

Helpers de medição

const perf = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.name, entry.duration);
  }
});

perf.observe({ entryTypes: ['measure'] });
const memory = performance.memory; // Chrome only: usedJSHeapSize and totalJSHeapSize

Como lidar com problemas comuns

  • Exports em câmara lenta: forneça timestamps monotonamente crescentes ao WebCodecs ou encode em CFR com FPS fixo. Evite depender apenas do timing de requestAnimationFrame.
  • Drift entre áudio e vídeo: mantenha o áudio como fonte principal de tempo. Faça clamp do PTS do vídeo à timeline do áudio ou faça resample do áudio para o rate do projeto.
  • Frames verdes ou desvios de cor: confirme que o pixel format (por exemplo, RGBA para I420) e a informação de color space estão definidos de forma consistente ao fazer mux.
  • Lacunas no Safari: o suporte a WebCodecs ainda é parcial; use fallback para MediaRecorder ou WASM H.264.
  • Threading desativado: se SharedArrayBuffer não estiver disponível (sem COOP/COEP), carregue o core WASM single-threaded para evitar erros de runtime.

O nosso híbrido na DojoClip (hoje)

  • FFmpeg WASM para extração de áudio, waveform e thumbnails, trims precisos, remux e preparação de legendas.
  • WebCodecs para previews de timelines em canvas e exportações finais onde houver suporte.
  • MediaRecorder para proxies instantâneos, vídeos rápidos de partilha e demos de captura de separador.
  • Armazenamento: OPFS para intermédios e exportações retomáveis; memória para trabalhos pequenos.

Vamos publicar um follow-up com tabelas reais de benchmark em vários dispositivos e browsers assim que fecharmos os parâmetros atuais dos encoders.


Apêndice A — Defaults seguros

  • H.264 (web): -preset veryfast -crf 23 -maxrate 4M -bufsize 8M -pix_fmt yuv420p
  • Áudio: -c:a aac -b:a 160k (música), -b:a 96k (conteúdo dominado por fala)
  • Seek depois e transcode (mais rápido): -ss antes de -i input ... quando não precisa de precisão ao frame; caso contrário -i input -ss depois.
  • Legendas: mantenha texto em ficheiros sidecar .srt ou .vtt sempre que possível; grave no vídeo apenas nos entregáveis finais.

Apêndice B — mapa parcial de funcionalidades

Capacidade FFmpeg WASM WebCodecs MediaRecorder
Filter graphs exatos Sim Não Não
Aceleração por hardware Não (CPU) Sim Sim
Controlo preciso de CFR/VFR Sim Sim Limitado
Captura em tempo real Não Sim (decode/encode) Sim
MP4 mux integrado Sim Não (precisa de muxer) Não (normalmente WebM)
Setup mais simples Médio Médio Sim

Quer experimentar?

Abra o Video Compressor, o Audio Extractor ou o Subtitle Studio na DojoClip para ver esta stack em ação. Se for programador, clone o nosso benchmark harness (no próximo artigo), corra os testes e partilhe os números. Vamos agregá-los numa tabela pública para ajudar criadores a tomar decisões mais informadas.