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
SharedArrayBuffer
no 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):
-ss
antes de-i input ...
cuando no necesitas precisión cuadro a cuadro; de lo contrario-i input -ss
después. - Subtítulos: Mantén texto como archivos
.srt
o.vtt
cuando 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.