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.

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.
-
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.
-
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.
- Comando:
-
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
SharedArrayBuffernã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):
-ssantes de-i input ...quando não precisa de precisão ao frame; caso contrário-i input -ssdepois. - Legendas: mantenha texto em ficheiros sidecar
.srtou.vttsempre 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.