FFmpeg en el navegador: lanzamiento de Render Lab con DojoClip (análisis técnico)

Bienvenido a Render Lab, la serie en la que desglosamos la tecnología que mantiene a DojoClip veloz, privada y pensada para creadores. Este artículo va más allá del discurso general y entra en el cómo: FFmpeg compilado a WebAssembly (WASM), dónde brilla, dónde sufre y cómo lo combinamos con MediaRecorder y WebCodecs para crear un flujo híbrido de baja latencia.
TL;DR
- FFmpeg WASM iguala precisión y cobertura de funciones (filtros, recortes, remux) con privacidad local.
- WebCodecs ofrece la codificación y decodificación de menor latencia con ayuda de hardware, pero necesita un muxer.
- MediaRecorder es la captura en tiempo real más sencilla, ideal para previsualizaciones con control limitado.
- Trabajamos con un pipeline híbrido: WASM para transformaciones exactas y WebCodecs o MediaRecorder para previsualizaciones y exportaciones rápidas.
Conceptos (resumen en 2 minutos)
- Contenedor vs. códec: MP4, MKV y WebM son contenedores; H.264, H.265, VP9, AV1, AAC y Opus son códecs. Editar suele implicar decodificar, filtrar, codificar y luego multiplexar.
- Transcodificar vs. remultiplexar: La transcodificación vuelve a codificar (compromiso entre calidad y tamaño). El remux cambia el contenedor sin tocar los streams comprimidos (rápido y sin pérdidas).
- CFR vs. VFR: Velocidad de fotogramas constante vs. variable. La captura web suele ser VFR; la edición puede preferir CFR para búsquedas cuadro a cuadro.
- Fotogramas clave (IDR): Los cortes se alinean a fotogramas clave salvo que transcodifiques o apliques estrategias de smart-render.
- CRF y bitrate: Controles de calidad. Un CRF menor aumenta calidad y tamaño. El bitrate limita el ancho de banda para objetivos de streaming.
Arquitectura en un vistazo
[Entrada de archivos]
   └─▶ Sistema de archivos del navegador (OPFS / RAM)
         ├─▶ Worker FFmpeg WASM (filtros precisos, remux, waveform)
         ├─▶ WebCodecs (decodificación/codificación rápida; preview/export)
         └─▶ MediaRecorder (captura de canvas/pestaña en tiempo real)
                     ▼
               [Muxer] → MP4/WebM → Descarga / OPFS / Subida
¿Por qué WASM?
Trae la mayor parte del CLI de FFmpeg al navegador. Eso habilita recortes cuadro a cuadro, grafos de filtros complejos y operaciones de canales de audio manteniendo el procesamiento local para medios sensibles.
¿Por qué no usar solo WASM?
Las codificaciones largas presionan memoria y CPU, la carga inicial pesa varios megabytes y los threads o SIMD requieren cabeceras de aislamiento de origen cruzado.
¿Por qué también WebCodecs y MediaRecorder?
WebCodecs aprovecha los codificadores y decodificadores de la plataforma para ganar velocidad. MediaRecorder es trivial para capturas en vivo y proxies rápidos.
Desplegar FFmpeg WASM correctamente
1. Activa threads y SIMD (ganancias reales)
Threads más SIMD suelen aportar mejoras de 1,5 a 3 veces según la carga. Requieren cabeceras de aislamiento de origen cruzado.
// next.config.js (o 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. Carga el core en un worker
Evita bloquear el hilo principal y mantén aislada la memoria de FFmpeg.
// 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]);
};
// Lado UI
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. Guarda los intermedios grandes en OPFS
El Origin Private File System evita picos 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();
Tareas cotidianas (recetas FFmpeg listas para copiar)
Extracción de audio (bit a bit si ya es AAC):
ffmpeg -i input.mp4 -vn -acodec copy audio.m4a
Redimensionar y transcodificar con 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 cuadro a cuadro (re-codifica una ventana pequeña):
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
Waveform PNG para una timeline:
ffmpeg -i audio.m4a -lavfi showwavespic=s=1200x200:colors=white waveform.png
En WASM pasas los mismos argumentos a ffmpeg.run(...). Para remux puro (sin transcodificar) conserva -c copy para mantener calidad y velocidad.
WebCodecs: autopista rápida
WebCodecs ofrece acceso directo a los decodificadores y codificadores de la plataforma. Necesitas un muxer para envolver los chunks codificados en MP4 o 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 de muxing: WebCodecs entrega streams elementales. Usa una librería de muxer en el navegador para generar un archivo descargable. Para previews, Media Source Extensions puede transmitir chunks a una etiqueta video.
Comparado con FFmpeg WASM, la latencia de codificación suele ser menor y el uso de CPU más bajo, especialmente en dispositivos con aceleración por hardware.
MediaRecorder: proxies sin complicaciones
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();
// ... renderiza frames ...
recorder.stop();
Pros: Muy sencillo y perfecto para revisiones rápidas o proxies para redes sociales. Contras: Control limitado sobre GOP, CRF o filtros avanzados.
Cómo elegir el pipeline correcto
| Tarea | Precisión requerida | Latencia objetivo | Recomendado | 
|---|---|---|---|
| Extracción de audio, remux | Alta | Baja | FFmpeg WASM ( -c copy) | 
| Waveform o miniaturas | Alta | Baja | FFmpeg WASM (filtros) | 
| Cortes cuadro a cuadro | Alta | Media | FFmpeg WASM (re-codifica ventana pequeña) | 
| Previsualización en vivo o captura de pantalla | Media | Muy baja | MediaRecorder | 
| Exportaciones finales desde timeline en canvas | Media | Baja | WebCodecs + muxer | 
| Transcodificaciones largas offline | Media | Alta | Fallback nativo o servidor | 
Matemática de memoria (planifica antes de renderizar)
- Un frame de video decodificado equivale a ancho por alto por 1,5 bytes (YUV420p). Ejemplo: 1920x1080 son unos 3,1 MB por frame. Un buffer pequeño de 120 frames ronda los 372 MB.
- La heap de WASM puede dispararse durante filtros como remuestreadores o escaladores. Guarda intermedios en disco (OPFS) y haz streaming cuando sea posible.
- Audio: PCM estéreo de 48 kHz y 16 bits ocupa aproximadamente 192 KB por segundo. Cinco minutos equivalen a unos 57 MB si lo mantienes descomprimido en memoria.
Consejos prácticos
- Prefiere el remux (-c copy) frente a una transcodificación completa cuando puedas.
- Divide trabajos largos en segmentos, persiste en OPFS y concatena con FFmpeg.
- Para previews, usa WebCodecs y evita mantener demasiados frames sin comprimir en memoria JavaScript.
Benchmarks reproducibles (completa antes de publicar)
Ejecuta estas pruebas en un build de producción limpio. Registra dispositivo y versión de navegador.
- 
Extracción de audio (remux) - Entrada: MP4 (H.264 + AAC), 1 a 2 minutos.
- Comando: -i in.mp4 -vn -acodec copy out.m4a
- Métricas: Tiempo total (ms), pico de heap JS (MB), heap WASM (MB), tamaño de salida.
 
- 
Transcodificación proxy (4K a 1080p) - Comando: -vf scale=-2:1080 -c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 160k
- Compara: FFmpeg WASM vs. WebCodecs con bitrate y fps similares. Registra tiempo, porcentaje de CPU y frames perdidos.
 
- Comando: 
- 
Export desde timeline en canvas - Método A: WebCodecs + muxer MP4.
- Método B: Captura con MediaRecorder a 30 fps.
- Métricas: FPS de codificación, tiempo total, bitrate/tamaño de salida, calidad visual (SSIM/PSNR si puedes compararlo con un original).
 
Tabla plantilla (reemplaza N/A con valores medidos):
| Dispositivo / Navegador | Prueba | Pipeline | Tiempo (s) | CPU promedio (%) | Pico de memoria (MB) | Notas | 
|---|---|---|---|---|---|---|
| M2 Pro / Chrome 128 | Extracción audio | FFmpeg WASM | Threads + SIMD | |||
| M2 Pro / Chrome 128 | 4K a 1080p | FFmpeg WASM | Threads + SIMD | |||
| M2 Pro / Chrome 128 | 4K a 1080p | WebCodecs | H.264 hardware | |||
| Pixel 8 / Chrome | Export canvas | MediaRecorder | VP9 | 
Ayudas de medición
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; // Solo Chrome: usedJSHeapSize y totalJSHeapSize
Cómo manejar errores comunes
- Exportaciones en cámara lenta: Asegura timestamps crecientes al usar WebCodecs o codifica CFR a fps fijo. No dependas solo de requestAnimationFrame.
- Desfase A/V: Usa el audio como fuente de tiempo. Alinea el PTS de video a la línea temporal derivada del audio o remuestrea el audio.
- Frames verdes o variaciones de color: Asegura el formato de píxel (por ejemplo, RGBA a I420) y la información de espacio de color al multiplexar.
- Gaps en Safari: El soporte de WebCodecs es parcial; recurre a MediaRecorder o WASM con H.264.
- Threads deshabilitados: Si SharedArrayBufferno está disponible (sin COOP/COEP), carga el core WASM monohilo para evitar errores en tiempo de ejecución.
Nuestro híbrido en DojoClip (hoy)
- FFmpeg WASM para extracción de audio, waveforms/miniaturas, recortes precisos, remux y preparación de subtítulos.
- WebCodecs para previsualizaciones y exportaciones finales desde canvas cuando el navegador lo soporta.
- MediaRecorder para proxies instantáneos, videos de compartido rápido y demos con captura de pestañas.
- Almacenamiento: OPFS para intermedios y exportaciones reanudables; memoria para trabajos pequeños.
Publicaremos una segunda parte con tablas de benchmarks reales en múltiples dispositivos y navegadores una vez fijemos los parámetros de codificación actuales.
Apéndice A — valores seguros
- H.264 (web): -preset veryfast -crf 23 -maxrate 4M -bufsize 8M -pix_fmt yuv420p
- Audio: -c:a aac -b:a 160k(música),-b:a 96k(voz predominante)
- Buscar y luego transcodificar (más rápido): -ssantes de-i input ...cuando no necesitas precisión cuadro a cuadro; de lo contrario-i input -ssdespués.
- Subtítulos: Mantén texto como archivos .srto.vttcuando sea posible; quema subtítulos solo en entregables finales.
Apéndice B — mapa de capacidades (parcial)
| Capacidad | FFmpeg WASM | WebCodecs | MediaRecorder | 
|---|---|---|---|
| Grafos de filtros exactos | Sí | No | No | 
| Aceleración por hardware | No (CPU) | Sí | Sí | 
| Control preciso CFR/VFR | Sí | Sí | Limitado | 
| Captura en tiempo real | No | Sí (decode/encode) | Sí | 
| Mux MP4 integrado | Sí | No (requiere muxer) | No (suele ser WebM) | 
| Configuración más sencilla | Media | Media | Sí | 
¿Quieres probarlo?
Abre el Compresor de video, el Extractor de audio o el Estudio de subtítulos en DojoClip para ver esta pila en acción. Si eres desarrollador, clona nuestro harness de benchmarks (llega en el próximo post), ejecuta las pruebas y comparte tus métricas. Las agregaremos en una tabla pública para que los creadores tomen decisiones con datos.