Filtros experimentales de cámara web con FFmpeg.wasm y WebCodecs
Crea una vista previa local de la cámara web que ejecute el filtro de escala de grises de FFmpeg en el navegador. Verás la vista previa de la cámara y un lienzo filtrado, con controles para detener y reiniciar el procesamiento. Cada fotograma pequeño hace un recorrido de ida y vuelta en PNG por FFmpeg.wasm, así que espera un experimento con un uso intensivo de CPU y pocos fotogramas por segundo.
Sigue un fotograma a través del filtro
El recorrido es: video de la cámara → VideoFrame → PNG → sistema de archivos en memoria de FFmpeg → PNG en escala de grises →
lienzo. El filtro hue=s=0 de FFmpeg elimina la saturación.
La aplicación espera a que termine cada tarea antes de solicitar otro fotograma; no pone en cola
cada fotograma de la cámara para procesarlo. Cada resultado finaliza su worker de FFmpeg, por lo que
el siguiente fotograma vuelve a cargar el núcleo.
Llamar repetidamente a exec() con la misma versión fijada del núcleo puede provocar un error de memoria de WebAssembly en este
bucle. Usar una ejecución por worker evita ese fallo, a costa de trabajo adicional de inicio.
WebAssembly nos permite reutilizar los filtros de FFmpeg localmente, pero no hace que este pipeline sea tan rápido como FFmpeg nativo. Las preguntas frecuentes de ffmpeg.wasm advierten explícitamente sobre esa diferencia de rendimiento. La codificación, la decodificación y las copias de PNG añaden más trabajo.
Aquí, WebCodecs proporciona una captura VideoFrame
del video. La cerramos después de dibujar sus píxeles. No usamos VideoEncoder ni
VideoDecoder, y este ejemplo no afirma utilizar aceleración por hardware. La salida es una vista previa en un lienzo;
no produce una grabación ni un MediaStream filtrado para una videollamada.
Compatibilidad con navegadores
Este tutorial se probó en Chromium 145.0.7632.6 sobre Linux con una cámara sintética. No se probó una
cámara web física ni otros navegadores. El código comprueba VideoFrame, OffscreenCanvas y
requestVideoFrameCallback()
antes de abrir la cámara.
Esta versión toma capturas de un video en reproducción en lugar de usar MediaStreamTrackProcessor o
MediaStreamTrackGenerator. Esas API de flujos insertables tienen
una exposición incompatible en ventanas y workers entre navegadores.
Un callback de fotograma basta para esta vista previa secuencial.
Requisitos de seguridad
Abre la página a través de localhost. El acceso a la cámara requiere un
contexto seguro y el permiso del usuario;
HTTPS es obligatorio cuando se sirve desde un origen remoto normal. No abras index.html directamente.
Usamos @ffmpeg/core, de un solo hilo, servido desde el mismo servidor local que la página. No
requiere SharedArrayBuffer ni encabezados de aislamiento entre orígenes. La compilación independiente @ffmpeg/core-mt
necesita memoria compartida, aislamiento entre orígenes y un archivo de worker adicional; cambiar a
ella queda fuera de este tutorial. «De un solo hilo» describe el núcleo de FFmpeg: el código
contenedor sigue ejecutándolo en un Web Worker.
Crea el proyecto local
Necesitas una terminal compatible con Bash, Node.js 22.12 o posterior y Corepack disponible para ejecutar Yarn. La configuración probada usó Node.js 26.8.1, Yarn 4.12.0 y Vite 8.3.0. Vite se encarga de los módulos y los workers; su guía de instalación manual explica el punto de entrada HTML.
Ejecuta esto desde un directorio padre donde no exista webcam-filter. La cadena && se detiene si
falla un paso, y mkdir se niega a sobrescribir un proyecto existente. Continúa solo si todo el bloque
se ejecuta correctamente. Los archivos del núcleo se copian del paquete con versión fijada, de modo
que el navegador no obtiene un motor desde una CDN de terceros.
mkdir webcam-filter &&
cd webcam-filter &&
printf '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}\n' > package.json &&
printf 'nodeLinker: node-modules\n' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn add --exact @ffmpeg/ffmpeg@0.12.15 @ffmpeg/core@0.12.10 &&
corepack yarn add --dev --exact vite@8.3.0 &&
mkdir -p public/ffmpeg &&
cp node_modules/@ffmpeg/core/dist/esm/ffmpeg-core.{js,wasm} public/ffmpeg/
En webcam-filter, crea vite.config.ts. Excluir el código contenedor de FFmpeg del
preempaquetado de dependencias mantiene la URL de su worker de módulo vinculada al paquete.
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: { exclude: ['@ffmpeg/ffmpeg'] },
})
Crea index.html en el mismo directorio:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:," />
<title>FFmpeg webcam experiment</title>
</head>
<body>
<h1>FFmpeg webcam experiment</h1>
<button id="start" type="button">Start camera</button>
<button id="stop" type="button" disabled>Stop</button>
<p id="status" role="status">Ready.</p>
<figure>
<figcaption>Camera</figcaption>
<video id="camera" aria-label="Camera preview" width="320" height="240" muted playsinline></video>
</figure>
<figure>
<figcaption>FFmpeg grayscale</figcaption>
<canvas id="output" aria-label="Grayscale preview" width="320" height="240"></canvas>
</figure>
<script type="module" src="/main.ts"></script>
</body>
</html>
Procesa un fotograma a la vez
Crea main.ts. Cada inicio tiene su propio contenedor de FFmpeg y flujo de cámara. Stop borra la sesión actual
inmediatamente; las promesas posteriores deben seguir perteneciendo a esa sesión para poder
actualizar la vista previa. Esto importa cuando el permiso, la carga del motor o la decodificación
del PNG se completan después de Stop.
import { FFmpeg } from '@ffmpeg/ffmpeg'
const startButton = document.querySelector('#start')
const stopButton = document.querySelector('#stop')
const status = document.querySelector('#status')
const camera = document.querySelector('#camera')
const output = document.querySelector('#output')
if (
!(startButton instanceof HTMLButtonElement) ||
!(stopButton instanceof HTMLButtonElement) ||
!(status instanceof HTMLElement) ||
!(camera instanceof HTMLVideoElement) ||
!(output instanceof HTMLCanvasElement)
) {
throw new Error('Missing demo elements')
}
const outputContext = output.getContext('2d')
if (!outputContext) throw new Error('Canvas 2D is unavailable')
interface Session {
ffmpeg: FFmpeg
canvas: OffscreenCanvas
stream?: MediaStream
callbackId?: number
}
let current: Session | undefined
const loadFFmpeg = async (session: Session): Promise<void> => {
if (session.ffmpeg.loaded) return
await session.ffmpeg.load({
coreURL: new URL('/ffmpeg/ffmpeg-core.js', location.href).href,
wasmURL: new URL('/ffmpeg/ffmpeg-core.wasm', location.href).href,
})
}
const stop = (message = 'Stopped.'): void => {
const session = current
if (!session) return
current = undefined
if (session.callbackId !== undefined) {
camera.cancelVideoFrameCallback(session.callbackId)
}
session.ffmpeg.terminate()
for (const track of session.stream?.getTracks() ?? []) track.stop()
camera.pause()
camera.srcObject = null
outputContext.clearRect(0, 0, output.width, output.height)
status.textContent = message
startButton.disabled = false
stopButton.disabled = true
}
const schedule = (session: Session): void => {
if (current !== session) return
session.callbackId = camera.requestVideoFrameCallback((_now, metadata) => {
session.callbackId = undefined
void filterFrame(session, metadata.mediaTime)
})
}
const filterFrame = async (session: Session, mediaTime: number): Promise<void> => {
try {
if (current !== session) return
const context = session.canvas.getContext('2d')
if (!context) throw new Error('Canvas 2D is unavailable')
const frame = new VideoFrame(camera, { timestamp: Math.round(mediaTime * 1_000_000) })
try {
context.drawImage(frame, 0, 0, 320, 240)
} finally {
frame.close()
}
const blob = await session.canvas.convertToBlob({ type: 'image/png' })
const bytes = new Uint8Array(await blob.arrayBuffer())
if (current !== session) return
await loadFFmpeg(session)
if (current !== session) return
await session.ffmpeg.writeFile('in.png', bytes)
const code = await session.ffmpeg.exec([
'-y', '-i', 'in.png', '-vf', 'hue=s=0', '-frames:v', '1', '-update', '1', 'out.png',
])
if (code !== 0) throw new Error('FFmpeg failed')
const data = await session.ffmpeg.readFile('out.png')
if (!(data instanceof Uint8Array) || data.length === 0) {
throw new Error('FFmpeg produced no image')
}
const bitmap = await createImageBitmap(
new Blob([new Uint8Array(data)], { type: 'image/png' }),
)
try {
if (current !== session) return
outputContext.drawImage(bitmap, 0, 0)
status.textContent = 'Filtering…'
} finally {
bitmap.close()
}
// This per-frame experiment uses one exec() per worker.
session.ffmpeg.terminate()
schedule(session)
} catch {
if (current === session) stop('Filter failed. Start again to retry.')
}
}
startButton.onclick = async () => {
if (current) return
if (
!navigator.mediaDevices?.getUserMedia ||
typeof VideoFrame !== 'function' ||
typeof OffscreenCanvas !== 'function' ||
typeof createImageBitmap !== 'function' ||
typeof camera.requestVideoFrameCallback !== 'function' ||
typeof Worker !== 'function' ||
typeof WebAssembly !== 'object'
) {
status.textContent = 'Required browser APIs are unavailable. Use a supported browser on localhost.'
return
}
const session: Session = { ffmpeg: new FFmpeg(), canvas: new OffscreenCanvas(320, 240) }
current = session
startButton.disabled = true
stopButton.disabled = false
status.textContent = 'Loading FFmpeg…'
let failureMessage = 'Could not load FFmpeg. Check the local server and engine files, then retry.'
try {
await loadFFmpeg(session)
if (current !== session) return
status.textContent = 'Requesting camera…'
failureMessage = 'Could not open camera. Check camera permission and availability, then retry.'
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: { width: { ideal: 320 }, height: { ideal: 240 }, frameRate: { ideal: 5 } },
})
// getUserMedia has no abort signal; release a stream granted after Stop.
if (current !== session) {
for (const track of stream.getTracks()) track.stop()
return
}
session.stream = stream
const track = stream.getVideoTracks()[0]
if (!track) throw new Error('No video track')
track.addEventListener('ended', () => {
if (current === session) stop('Camera ended. Start again to retry.')
}, { once: true })
camera.srcObject = stream
await camera.play()
if (current !== session) return
status.textContent = 'Waiting for a frame…'
schedule(session)
} catch {
if (current === session) stop(failureMessage)
}
}
stopButton.onclick = () => stop()
window.addEventListener('pagehide', () => stop())
Las dimensiones de la cámara y la tasa de fotogramas son preferencias, no garantías. Dibujar en el
lienzo fijo escala cada entrada a 320 × 240, incluso si la cámara proporciona algo más grande o con
una relación de aspecto distinta. La opción -update 1 escribe una sola imagen de salida, y -y permite reemplazar el
archivo con ese nombre temporal. Finalizar el worker después de cada fotograma, al pulsar Stop o
ante un fallo libera su sistema de archivos en memoria. La aplicación no guarda imágenes en disco.
VideoFrame.close() y
ImageBitmap.close() liberan los recursos de imagen que creamos.
Ejecuta y detén la vista previa
Desde webcam-filter, inicia el servidor en primer plano:
corepack yarn vite --host 127.0.0.1
Abre la URL local que muestra Vite. Haz clic en Start camera y concede acceso a la cámara. Después de Loading FFmpeg… y Requesting camera…, el estado pasa a ser Filtering… cuando aparece el primer fotograma en escala de grises. Sostén algo colorido frente a la cámara para comparar las dos vistas previas. Los fotogramas permanecen en el navegador; esta aplicación no los sube ni los graba.
Haz clic en Stop para detener las pistas de cámara de esta aplicación, finalizar su worker de FFmpeg y borrar las vistas previas. Start camera vuelve a estar disponible, incluso si detienes el proceso durante la inicialización. Los navegadores no proporcionan una API para cerrar una solicitud pendiente de permiso de cámara. Si lo concedes después de detener el proceso, el flujo tardío se detiene sin actualizar la página. Salir de la página también ejecuta la limpieza. Detén el servidor de la terminal con Ctrl+C cuando termines.
Si se deniega el permiso, el error visible te pide que compruebes el permiso y la disponibilidad de
la cámara. Permite el acceso en la configuración del sitio del navegador y haz clic en Start camera
de nuevo. Si falta un archivo del motor, se produce un error de carga antes de solicitar la cámara;
comprueba que ambos archivos existan en public/ffmpeg. Un error de procesamiento detiene la sesión y muestra
Filter failed. Start again to retry.. Si la cámara deja de transmitir, el
mensaje es Camera ended. Start again to retry..
Decide si FFmpeg es adecuado para tu vista previa
Este bucle omite fotogramas de la cámara mientras FFmpeg está ocupado. Reducir la tasa de fotogramas solicitada puede reducir el trabajo, pero solicitar cinco fotogramas por segundo no garantiza cinco fotogramas filtrados por segundo. Mide todo el recorrido de ida y vuelta del PNG en el hardware de destino antes de aumentar las dimensiones o añadir más filtros. Un núcleo multihilo no elimina las conversiones de imágenes ni las copias de datos.
Para una vista previa de cámara en escala de grises, aplicar un filtro de Canvas 2D o un shader de WebGL evita ese recorrido de ida y vuelta por PNG y el sistema de archivos. Conserva esta versión con FFmpeg.wasm para aprender a usar la API basada en archivos o probar un filtro de FFmpeg con capturas pequeñas. La grabación, el audio y un flujo filtrado para WebRTC requieren una ruta de salida diferente.
