Filtros experimentales de cámara web con FFmpeg.wasm y WebCodecs
Este DevTip muestra un filtro de cámara web que usa FFmpeg.wasm, objetos
VideoFrame de WebCodecs e insertable streams de MediaStreamTrack. Convierte
cada frame mediante archivos PNG, por lo que es un experimento para frames pequeños y tasas de
frames bajas, no una garantía de rendimiento en tiempo real. Para una vista previa en escala de
grises en producción, Canvas o WebGL suele ser una ruta de procesamiento más adecuada.
Introducción al procesamiento de video en tiempo real en los navegadores
Hasta hace poco, la manipulación compleja de video exigía infraestructura del lado del servidor. Ahora los navegadores pueden decodificar, transformar y volver a codificar frames localmente mediante potentes API. El procesamiento del lado del cliente reduce la latencia, mantiene privado el material sin procesar al procesarlo en el dispositivo del usuario y reduce los costos del back-end.
Descripción general de FFmpeg y WebAssembly
FFmpeg es un conjunto de herramientas multimedia completo y probado en la práctica que decodifica, codifica, multiplexa, desmultiplexa, transmite, filtra y reproduce casi cualquier formato multimedia creado. WebAssembly (Wasm) es un formato de instrucciones binarias que permite que el código escrito en lenguajes como C/C++ se ejecute en el navegador a una velocidad casi nativa. Al compilar FFmpeg a Wasm (proyectos como ffmpeg.wasm se encargan de esta tarea compleja), desbloqueas las potentes capacidades de línea de comandos de FFmpeg directamente en JavaScript.
¿Cómo se puede usar FFmpeg directamente en los navegadores?
- Carga la compilación Wasm de FFmpeg en tu aplicación web.
- Usa la API proporcionada para escribir archivos de entrada (como frames de video o videos existentes) en el sistema de archivos virtual en memoria de FFmpeg.
- Ejecuta comandos de FFmpeg con
ffmpeg.exec()usando los argumentos de CLI habituales (p. ej.,-vf 'hue=s=0'para escala de grises). - Vuelve a leer los archivos de salida procesados desde el sistema de archivos virtual.
¿Cuáles son las ventajas de usar WebAssembly para el procesamiento de video?
WebAssembly ofrece una velocidad de ejecución casi nativa para tareas de alto costo computacional, como la codificación y el filtrado de video; brinda un rendimiento más consistente entre distintos navegadores en comparación con las implementaciones en JavaScript, y permite usar bibliotecas de C/C++ maduras y con muchas funciones, como FFmpeg, sin necesidad de viajes de ida y vuelta al servidor para procesar.
Configurar FFmpeg en el navegador con WebAssembly
Primero, instala los paquetes necesarios:
npm install @ffmpeg/ffmpeg@0.12.15 @ffmpeg/util@0.12.2
Usa un bundler de navegador con soporte para workers de módulo. Carga explícitamente el núcleo de un solo hilo:
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { toBlobURL } from '@ffmpeg/util'
const ffmpeg = new FFmpeg()
async function loadFFmpeg() {
if (ffmpeg.loaded) return
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.10/dist/esm'
const coreURL = await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript')
try {
const wasmURL = await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')
try {
await ffmpeg.load({ coreURL, wasmURL })
} finally {
URL.revokeObjectURL(wasmURL)
}
} finally {
URL.revokeObjectURL(coreURL)
}
}
Introducción a la API de WebCodecs
La API de WebCodecs proporciona acceso de bajo nivel a los códecs
multimedia integrados del navegador (codificadores y decodificadores). En lugar de tratar un
MediaStream como un objeto opaco que se canaliza hacia un elemento
<video>, WebCodecs te permite trabajar con fragmentos de audio individuales
(AudioData) y frames de video (VideoFrame). Puedes
decodificar streams comprimidos, acceder a los datos sin procesar de los frames (píxeles) y
manipularlos, y volver a codificar los frames en un formato comprimido o mostrarlos.
¿Cómo mejora la API de WebCodecs el procesamiento de video en tiempo real?
- Acceso directo a los frames: permite manipular directamente los datos sin procesar de los frames de video y elimina soluciones alternativas ineficientes, como dibujar los frames en un canvas, exportarlos como imagen (PNG/JPEG), procesarlos y volver a dibujarlos.
- Codificación y decodificación eficientes: aprovecha el pipeline multimedia del navegador, que a menudo cuenta con aceleración por hardware, para codificar y decodificar de forma eficiente.
- Integración con streams: se integra sin problemas con la API de Streams, lo que permite crear pipelines de procesamiento eficientes con gestión de contrapresión integrada.
Compatibilidad con navegadores
La compatibilidad con WebCodecs por sí sola no basta para este ejemplo. También usa las API
MediaStreamTrackProcessor y MediaStreamTrackGenerator expuestas en window y descritas en
la guía de insertable streams de Chrome. Otros navegadores y el
estándar en evolución exponen API o contextos de worker distintos. El ejemplo comprueba los
constructores que realmente usa antes de solicitar una cámara; no infieras la compatibilidad a
partir del soporte para VideoEncoder o VideoDecoder.
El @ffmpeg/core de un solo hilo que se usa aquí requiere WebAssembly y soporte
para workers, pero no SharedArrayBuffer.
Requisitos de seguridad
Usa HTTPS o localhost para el acceso a la cámara y WebCodecs. Si cambias a
@ffmpeg/core-mt, proporciona su recurso de worker adicional y habilita el aislamiento
entre orígenes para SharedArrayBuffer:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Estas cabeceras garantizan que tu página esté suficientemente aislada, algo necesario por razones de
seguridad cuando se usan funciones potentes como SharedArrayBuffer. La mayoría de las
plataformas de hosting y de los frameworks ofrecen formas de configurar estas cabeceras (p. ej.,
mediante nginx.conf, netlify.toml, el
vercel.json de Vercel o la configuración de tu hosting). El ejemplo de un solo
hilo no necesita estas cabeceras de aislamiento.
Implementar un filtro experimental con FFmpeg y WebCodecs
A continuación se muestra un ejemplo conceptual de cómo aplicar un filtro de escala de grises a un stream de cámara web con FFmpeg.wasm y WebCodecs. Nota: este enfoque concreto implica convertir cada frame a PNG, procesarlo con FFmpeg y luego volver a convertirlo. Aunque funciona, esta ida y vuelta no es eficiente para el filtrado en tiempo real de alta resolución o con FPS altos. Los métodos más eficientes implicarían manipular los píxeles directamente con la API de Canvas o WebGL, o quizá técnicas más avanzadas de FFmpeg.wasm si estuvieran disponibles para el acceso directo a la memoria.
async function startFilteredStream(videoEl) {
if (
typeof MediaStreamTrackProcessor !== 'function' ||
typeof MediaStreamTrackGenerator !== 'function' ||
typeof VideoFrame !== 'function' ||
typeof OffscreenCanvas !== 'function'
) {
throw new Error('This browser does not support the required webcam processing APIs')
}
await loadFFmpeg()
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 320 }, height: { ideal: 240 }, frameRate: { ideal: 5 } },
})
const inputTrack = stream.getVideoTracks()[0]
const abortController = new AbortController()
let generator
let finished
const stop = () => {
abortController.abort()
for (const track of stream.getTracks()) track.stop()
generator?.stop()
ffmpeg.terminate()
videoEl.srcObject = null
}
try {
const processor = new MediaStreamTrackProcessor({ track: inputTrack })
generator = new MediaStreamTrackGenerator({ kind: 'video' })
const transformer = new TransformStream({
async transform(frame, controller) {
let bitmap
let newFrame
try {
// Draw frame to an OffscreenCanvas for conversion
const { displayWidth: w, displayHeight: h } = frame
const canvas = new OffscreenCanvas(w, h)
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas 2D is unavailable')
ctx.drawImage(frame, 0, 0, w, h)
// Convert canvas to PNG Blob, then ArrayBuffer
const blob = await canvas.convertToBlob({ type: 'image/png' })
const arrayBuffer = await blob.arrayBuffer()
// Write input PNG to FFmpeg's virtual filesystem
const inputFilename = 'in.png'
const outputFilename = 'out.png'
await ffmpeg.writeFile(inputFilename, new Uint8Array(arrayBuffer))
// Execute FFmpeg command (grayscale filter)
// Note: This is the performance bottleneck
const code = await ffmpeg.exec([
'-i',
inputFilename,
'-vf',
'hue=s=0',
'-frames:v',
'1',
outputFilename,
])
if (code !== 0) throw new Error(`FFmpeg failed with exit code ${code}`)
// Read the processed PNG file
const outputData = await ffmpeg.readFile(outputFilename)
if (!(outputData instanceof Uint8Array) || outputData.length === 0) {
throw new Error('FFmpeg produced no image')
}
// Create an ImageBitmap from the output PNG data
const outputBlob = new Blob([outputData], { type: 'image/png' })
bitmap = await createImageBitmap(outputBlob)
// Create a new VideoFrame with the processed bitmap
newFrame = new VideoFrame(bitmap, {
timestamp: frame.timestamp,
...(frame.duration === null ? {} : { duration: frame.duration }),
})
// Enqueue the new frame into the output stream
controller.enqueue(newFrame)
newFrame = undefined // The output stream owns the enqueued frame.
} finally {
newFrame?.close()
bitmap?.close()
frame.close()
await Promise.allSettled([ffmpeg.deleteFile('in.png'), ffmpeg.deleteFile('out.png')])
}
},
})
// Pipe the processor through the transformer to the generator
finished = processor.readable
.pipeThrough(transformer)
.pipeTo(generator.writable, { signal: abortController.signal })
.catch((error) => {
if (!abortController.signal.aborted) console.error('Filter pipeline failed:', error)
})
.finally(stop)
// Display the output stream in the video element
videoEl.srcObject = new MediaStream([generator])
videoEl.muted = true
await videoEl.play()
return async () => {
stop()
await finished
}
} catch (error) {
stop()
await finished
throw error
}
}
// Provide start/stop buttons and a <video muted playsinline> element.
const videoElement = document.querySelector('video')
const startButton = document.getElementById('start')
const stopButton = document.getElementById('stop')
let stopFiltering
stopButton.disabled = true
startButton.onclick = async () => {
startButton.disabled = true
try {
stopFiltering = await startFilteredStream(videoElement)
stopButton.disabled = false
} catch (error) {
console.error('Could not start the filter:', error)
startButton.disabled = false
}
}
stopButton.onclick = async () => {
stopButton.disabled = true
await stopFiltering?.()
stopFiltering = undefined
startButton.disabled = false
}
window.addEventListener('pagehide', () => stopFiltering?.(), { once: true })
Consideraciones de rendimiento y consejos de optimización
- Ten en cuenta los límites de los workers: FFmpeg.wasm ya ejecuta su núcleo en un worker. Las conversiones a PNG, el manejo de los frames y las transferencias de datos siguen teniendo un costo; perfila todo el pipeline.
- Gestión de recursos: llama siempre a
frame.close()en los objetosVideoFramecuando termines de usarlos para liberar con rapidez la memoria y los recursos de GPU subyacentes. No hacerlo puede provocar fugas de memoria y degradación del rendimiento. - Minimiza las copias de datos: la ida y vuelta de conversión a PNG del ejemplo es ineficiente.
Para filtros más simples (brillo, contraste, saturación, transformaciones básicas de color), usa
la API de Canvas 2D o WebGL directamente sobre los datos de
VideoFramepara obtener un rendimiento mucho mejor.ctx.drawImage(frame, ...)es eficiente. - Controla la tasa de frames y la resolución: si el procesamiento es demasiado lento, considera
solicitar una resolución o una tasa de frames más bajas a
getUserMedia, o descartar frames de forma selectiva dentro delTransformStream. - Monitorea el estado del pipeline: gestiona la promesa que devuelve
pipeToy detén los tracks de la cámara cuando algo falle. Mide el tiempo de procesamiento de los frames; los bloqueos de los streams no miden el rendimiento del procesamiento. - Aceleración por hardware: WebCodecs suele usar aceleración por hardware para codificar y decodificar. Asegúrate de que tu lógica de procesamiento no se convierta en el cuello de botella que anule estas ventajas.
Conclusión y posibles casos de uso
Combinar WebAssembly (mediante FFmpeg.wasm) y la API de WebCodecs abre posibilidades interesantes para manipular video del lado del cliente directamente en el navegador. Aunque el filtrado en tiempo real con operaciones complejas de FFmpeg frame por frame enfrenta desafíos de rendimiento con los métodos actuales, las tecnologías subyacentes son potentes. Entre las posibles aplicaciones están:
- Aplicar filtros simples (brillo, contraste) o superposiciones en aplicaciones de videochat.
- Implementar fondos virtuales (aunque suelen encajar mejor con modelos de ML especializados).
- Crear herramientas ligeras de edición de video del lado del cliente para recortar o aplicar efectos simples.
- Desenfocar o enmascarar el contenido de video antes de la subida para preservar la privacidad.
¿Cuáles son los casos de uso prácticos de los filtros de video en tiempo real en aplicaciones web? Van desde mejorar la experiencia de usuario en aplicaciones de comunicación hasta habilitar herramientas creativas novedosas y mejorar la privacidad, todo mientras se reduce la carga del servidor.
Aunque el procesamiento del lado del cliente avanza con rapidez, la codificación y la manipulación de video complejas y de alta calidad a gran escala todavía suelen beneficiarse de soluciones robustas del lado del servidor. Si necesitas un procesamiento de video confiable y escalable en producción, considera servicios como la plataforma de encoding de video de Transloadit, impulsada por Robots versátiles como 🤖 /video/encode.
¡Experimenta con WebCodecs y FFmpeg.wasm para ver qué puedes crear directamente en el navegador!
