12k
All articles

Procesamiento de Video en Tiempo Real con la API WebCodecs

Procesamiento de video WebCodecs con MediaStreamTrackProcessor, TransformStream y VideoTrackGenerator, más cierre de frames, backpressure, workers y soporte.

OpenReplay Team
OpenReplay Team
Procesamiento de Video en Tiempo Real con la API WebCodecs

Un pipeline de video con WebCodecs tiene tres partes: un MediaStreamTrackProcessor que convierte un MediaStreamTrack en un ReadableStream<VideoFrame>, un TransformStream donde se manipula cada fotograma, y un VideoTrackGenerator que convierte los fotogramas procesados de vuelta en un MediaStreamTrack que puede asignarse a un elemento <video>. VideoTrackGenerator es el nombre actual en la especificación; los ejemplos de Chromium aún utilizan el nombre anterior y no estándar MediaStreamTrackGenerator. A continuación se muestra el pipeline completo:

const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const track = stream.getVideoTracks()[0];

const processor = new MediaStreamTrackProcessor({ track });
const generator = new VideoTrackGenerator();

const grayscale = new TransformStream({
  async transform(frame, controller) {
    try {
      const canvas = new OffscreenCanvas(frame.displayWidth, frame.displayHeight);
      const ctx = canvas.getContext('2d');
      ctx.filter = 'grayscale(1)';
      ctx.drawImage(frame, 0, 0);
      controller.enqueue(new VideoFrame(canvas, {
        timestamp: frame.timestamp,
        duration: frame.duration,
      }));
    } finally {
      frame.close();
    }
  },
});

processor.readable.pipeThrough(grayscale).pipeTo(generator.writable);
videoEl.srcObject = new MediaStream([generator.track]);

Este artículo trata sobre las partes que todos los tutoriales existentes omiten: los modos de fallo. La versión del camino feliz mostrada anteriormente funciona hasta que deja de hacerlo — hasta que los fotogramas se filtran, la transformación se queda atrás, el codificador entra en un estado cerrado, o se publica código que asume que Safari no puede ejecutarlo. Cada uno de esos casos es un fallo real en producción con una causa específica y una solución específica, y eso es lo que se cubre en el resto de este artículo.

Puntos Clave

  • Un pipeline de WebCodecs sigue la estructura MediaStreamTrackProcessorTransformStreamVideoTrackGenerator; utilice el constructor de la especificación new MediaStreamTrackProcessor({ track }), no la forma posicional obsoleta.
  • Olvidar frame.close() agota los recursos de medios finitos de los que depende el pipeline; una vez agotados, la emisión de fotogramas se detiene, produciendo un video que tartamudea y luego se congela mientras el resto de la página permanece responsiva.
  • MediaStreamTrackProcessor no propaga la contrapresión hacia arriba en la cadena — cuando la transformación se queda atrás, el procesador descarta silenciosamente los fotogramas más antiguos en lugar de lanzar un error.
  • Realice todo el trabajo con VideoFrame en un único worker: un fotograma transferido entre workers se cierra automáticamente en el lado emisor, y volver a acceder a él lanza una excepción.
  • El soporte de WebCodecs se divide por interfaz — las interfaces principales VideoEncoder/VideoFrame están disponibles en Chrome 94+, Firefox 130+ y Safari 16.4+, mientras que MediaStreamTrackProcessor/VideoTrackGenerator van por detrás (Safari 18+, no soportado en Firefox).

Qué es WebCodecs y Por Qué el Pipeline Tiene Esta Forma

WebCodecs proporciona a JavaScript acceso directo a los códecs multimedia integrados en el navegador, frecuentemente acelerados por hardware, y a los fotogramas de video en bruto. Antes de su existencia, un MediaStream era opaco: se canalizaba hacia un elemento <video> y el navegador gestionaba todo lo que ocurría entre la captura y la visualización. WebCodecs abre ese pipeline. La interfaz VideoFrame expone los píxeles en bruto entre la captura y la codificación, que es exactamente donde necesita situarse un filtro, un fondo virtual o un codificador personalizado.

El motivo por el que el pipeline utiliza Streams es que los fotogramas decodificados en bruto son grandes (varios megabytes cada uno) y llegan rápido (25 o más por segundo), por lo que se necesita control de flujo y procesamiento incremental en lugar de almacenar todo en memoria. La API Streams de WHATWG fue diseñada precisamente para este tipo de procesamiento de fragmentos atómicos a través de cadenas de tuberías. MediaStreamTrackProcessor conecta una pista en vivo con un stream; el TransformStream es donde ocurre el trabajo por fotograma; y VideoTrackGenerator reconecta con una pista que el resto de la plataforma — <video>, RTCPeerConnection — comprende.

WebCodecs opera únicamente sobre streams sin contenedor. Si necesita leer o escribir MP4/ISOBMFF, deberá implementar su propia lógica de contenedor. El audio tiene una superficie paralela (AudioData, AudioEncoder) que este artículo no cubre; los patrones descritos a continuación son específicos para video.

Un Pipeline Funcional de Cámara → Filtro → Visualización

Un pipeline de filtro WebCodecs funcional captura con MediaStreamTrackProcessor, aplica el filtro dentro de un TransformStream usando Canvas2D directamente sobre el VideoFrame, y muestra el resultado a través de VideoTrackGenerator — la estructura mostrada en el bloque de código inicial. El movimiento clave de eficiencia es ctx.drawImage(frame, 0, 0)drawImage acepta un VideoFrame como fuente directamente, por lo que puede dibujar fotogramas en un canvas sin convertirlos manualmente a PNG ni crear un ImageBitmap intermedio.

Para un filtro de color con Canvas2D, la cadena ctx.filter es la opción más económica. Para cualquier cosa que requiera acceso a nivel de píxel — chroma key, convolución personalizada — utilice getImageData/putImageData:

const filter = new TransformStream({
  async transform(frame, controller) {
    try {
      const w = frame.displayWidth, h = frame.displayHeight;
      const canvas = new OffscreenCanvas(w, h);
      const ctx = canvas.getContext('2d', { willReadFrequently: true });
      ctx.drawImage(frame, 0, 0);

      const imageData = ctx.getImageData(0, 0, w, h);
      const px = imageData.data;
      for (let i = 0; i < px.length; i += 4) {
        const lum = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
        px[i] = px[i + 1] = px[i + 2] = lum;
      }
      ctx.putImageData(imageData, 0, 0);

      controller.enqueue(new VideoFrame(canvas, {
        timestamp: frame.timestamp,
        duration: frame.duration,
      }));
    } finally {
      frame.close();
    }
  },
});

Dos valores se transfieren del fotograma original al nuevo: timestamp y duration. El timestamp es la identidad del fotograma a lo largo de todo el pipeline — sobrevive a los ciclos de codificación/decodificación y es lo que se utiliza para medir la latencia más adelante. Omitirlo hace que los consumidores posteriores pierdan el orden de los fotogramas.

Para trabajo intensivo por píxel a resolución completa, la lectura con getImageData es el cuello de botella; WebGL o WebGPU (a través de importExternalTexture) mantienen el fotograma en la GPU y evitan por completo la lectura hacia la CPU. Use Canvas2D para transformaciones de color y composición simple; recurra a un camino GPU cuando el coste por píxel domine su presupuesto de fotograma.

El Ciclo de Vida de VideoFrame: Por Qué frame.close() es Obligatorio

Olvidar frame.close() no solo genera una fuga de memoria ordinaria — agota los recursos de medios finitos de los que depende el pipeline, y una vez agotados, la decodificación o la emisión de fotogramas se detiene porque no se puede asignar ni emitir ningún fotograma nuevo, produciendo el síntoma característico de un video que tartamudea progresivamente y luego se congela mientras el resto de la página permanece responsiva. VideoFrame.close() libera el recurso de medios subyacente que retiene el fotograma, y la especificación de WebCodecs es explícita en que estos recursos son finitos — los fotogramas respaldados por buffers de hardware provienen de un pool limitado, y una fuente no puede emitir un nuevo fotograma cuando el pool está lleno.

Por eso close() no es una limpieza opcional que pueda delegarse al recolector de basura. El recolector de basura no conoce el recurso de medios subyacente en su propio ciclo de ejecución, y para cuando se ejecuta, el pool ya está agotado. Cada VideoFrame que se lea del procesador, y cada uno que se construya, debe cerrarse exactamente una vez cuando ya no sea necesario.

El fallo no obvio ocurre en el camino de error. Si la transformación lanza una excepción después de leer un fotograma pero antes de cerrarlo, ese fotograma se filtra — y una transformación que falla en un fotograma generalmente falla en el siguiente, por lo que la fuga se acumula rápidamente. La solución es try/finally:

async transform(frame, controller) {
  try {
    // ...trabajo de filtrado que podría lanzar una excepción...
    controller.enqueue(newFrame);
  } finally {
    frame.close(); // se ejecuta tanto si el cuerpo lanzó una excepción como si no
  }
}

finally garantiza que frame.close() se ejecute tanto en el camino de éxito como en el de error. Este es el patrón más importante en un pipeline de WebCodecs.

Contrapresión: Por Qué las Transformaciones Lentas Descartan Fotogramas Silenciosamente

MediaStreamTrackProcessor no propaga la contrapresión hacia arriba en la cadena. Cuando el TransformStream se queda atrás, el procesador descarta silenciosamente los fotogramas más antiguos en lugar de ralentizar la cámara, y nunca verá un error — solo fotogramas perdidos. La consecuencia práctica: una transformación que tarda 50ms por fotograma en una fuente a 30fps (con un presupuesto de 33ms) no generará errores ni acumulará una cola indefinidamente. Simplemente se ejecutará a aproximadamente 20fps, descartando la diferencia de forma silenciosa. Puede detectar esto observando la cola del lado legible desde dentro de la transformación. TransformStreamDefaultController.desiredSize refleja el estado de contrapresión del lado legible — cuando se vuelve negativo, el lado legible ha superado su marca de nivel alto y el consumidor está por detrás:

const filter = new TransformStream({
  async transform(frame, controller) {
    try {
      if (controller.desiredSize !== null && controller.desiredSize < 0) {
        // El consumidor está por detrás. Descartar este fotograma intencionalmente
        // en lugar de seguir acumulando retraso.
        return;
      }
      // ...trabajo de filtrado...
      controller.enqueue(newFrame);
    } finally {
      frame.close();
    }
  },
});

Cuando se detecta contrapresión, hay dos palancas disponibles. Descartar intencionalmente — omitir el fotograma actual, como se muestra arriba, para que una cadencia deliberada reemplace la pérdida aleatoria y silenciosa. O reducir la entrada: solicitar una resolución o frecuencia de fotogramas menor desde getUserMedia mediante MediaTrackConstraints, o llamar a track.applyConstraints() para reducirla en tiempo de ejecución. Bajar la resolución reduce directamente el trabajo por píxel en cada fotograma y suele ser la solución más efectiva para un filtro limitado por CPU.

Workers: ¿Por Qué Hacer Todo el Trabajo con VideoFrame en un Único Worker?

Realice todo el trabajo con VideoFrame en un único worker. Cuando un VideoFrame se transfiere entre workers mediante postMessage, la referencia del lado emisor se cierra automáticamente, y cualquier intento de leerla o cerrarla de nuevo lanza una excepción — una condición de carrera silenciosa que es casi imposible de depurar entre las colas de mensajes de los workers. Los fotogramas dentro de streams transferidos se serializan, lo que los clona y requiere un cierre explícito en ambos lados. Mezclar ambos enfoques produce el fallo de cierre prematuro:

controller.enqueue(frame);
frame.close(); // Demasiado pronto — enqueue es asíncrono; el fotograma puede seguir en tránsito

Dado que controller.enqueue() es asíncrono respecto al worker consumidor, cerrar la referencia del emisor demasiado pronto causa fallos de serialización, mientras que no cerrarla nunca provoca la fuga y posterior congelación descrita anteriormente. Mantener toda la cadena MediaStreamTrackProcessorTransformStreamVideoTrackGenerator dentro de un único worker evita por completo el problema de propiedad. (Para enviar fragmentos codificados fuera del dispositivo — WebTransport, canales de datos — consulte la serie de pipelines de webrtcHacks; ese es un tema aparte.)

Cuando se pasa un fotograma a un worker — para alimentar el pipeline, no para dividirlo — transfiéralo explícitamente y deje de acceder a él en el lado emisor:

// Hilo principal
worker.postMessage({ frame }, { transfer: [frame] });
// `frame` queda inutilizado aquí. No lo lea ni lo cierre en el hilo principal.

Tras una transferencia, el worker receptor es el propietario del fotograma y es responsable de cerrarlo. El hilo emisor debe tratar su referencia como si ya no existiera.

Codificación para Transmisión o Grabación

Un VideoEncoder comprime objetos VideoFrame en bruto en objetos EncodedVideoChunk, entregados a través de un callback de salida para grabación o transmisión. Se configura con una cadena de códec, dimensiones, tasa de bits y frecuencia de fotogramas:

const chunks = [];
const encoder = new VideoEncoder({
  output: (chunk, metadata) => {
    // chunk.type es 'key' o 'delta'; chunk tiene timestamp, duration y byteLength
    chunks.push(chunk);
  },
  error: (e) => console.error('encoder error', e),
});

encoder.configure({
  codec: 'vp8',          // o por ejemplo 'avc1.42001f' para H.264 baseline
  width: 640,
  height: 480,
  bitrate: 1_000_000,
  framerate: 30,
});

El callback output proporciona un EncodedVideoChunk junto con metadatos opcionales; el fragmento incluye su type ('key' o 'delta'), timestamp, duration y los bytes codificados. Para las cadenas de códec, consulte el registro de códecs de WebCodecs y la guía de códecs de MDN en lugar de adivinar las cadenas de perfil AVC.

Solicite un keyframe con encoder.encode(frame, { keyFrame: true }) (nótese la F mayúscula) cuando necesite un fotograma intra, por ejemplo al inicio del stream, después de un salto o en un punto de recuperación — codificar cada fotograma como keyframe anula por completo la compresión entre fotogramas y aumentará significativamente la tasa de bits. La ortografía de la opción está documentada en la guía Using the WebCodecs API de MDN.

Recuperación desde un Codificador Cerrado

Cuando el callback de error de VideoEncoder se dispara y el codificador transiciona al estado 'closed', no puede reutilizarse. VideoEncoder.reset() existe para casos no terminales, pero la recuperación desde un codificador cerrado implica construir una nueva instancia y llamar a configure() de nuevo con los mismos parámetros. Compruebe el estado antes de cada llamada a encode() y reconstruya al detectar el estado cerrado:

function encodeFrame(frame, keyFrame = false) {
  if (encoder.state === 'closed') {
    encoder = makeEncoder();   // construir + configurar un nuevo VideoEncoder
  }
  if (encoder.state === 'configured') {
    encoder.encode(frame, { keyFrame });
  }
}

Proteger encode() con una comprobación de estado y un camino de reconstrucción es lo que mantiene una sesión de larga duración activa ante un error transitorio del códec.

Soporte en Navegadores en 2026

El soporte de WebCodecs se divide por interfaz, y tratarlo como un único número de versión es el error que cometen todos los tutoriales desactualizados. Las interfaces principales VideoEncoder/VideoFrame están ampliamente disponibles; las piezas de Insertable Streams — MediaStreamTrackProcessor y VideoTrackGenerator — siguen un calendario diferente y más lento.

InterfazChrome / EdgeFirefoxSafari
VideoEncoder / VideoFrame (WebCodecs principal)94+130+16.4+
MediaStreamTrackProcessor94+No soportado18+
VideoTrackGeneratorNo soportadoNo soportado18+
MediaStreamTrackGenerator (no estándar)94+No soportadoNo soportado

Verificado con los datos de compatibilidad de MDN para VideoEncoder y MediaStreamTrackProcessor. La advertencia genérica de que “Safari no soporta WebCodecs” que aparece en la mayoría de los tutoriales está tanto desactualizada como imprecisa: Safari incluye WebCodecs principal desde la versión 16.4, con soporte ampliado de códecs (incluyendo HEVC) en Safari 17.4. Lo que Safari y Firefox no tienen es la capa de captura/salida de Insertable Streams — por lo que el pipeline de cámara → filtro → visualización descrito anteriormente funciona hoy en Chromium y en Safari 18+ cuando se implementa en un worker dedicado, pero en Firefox puede codificar y decodificar fotogramas siempre que los obtenga de otra forma.

La conclusión práctica: detecte las características por interfaz, no por navegador. Compruebe window.MediaStreamTrackProcessor y window.VideoEncoder por separado, y tenga una alternativa basada en Canvas/requestVideoFrameCallback para la capa de captura donde falten las piezas de Insertable Streams.

Lista de Verificación para Depuración

Los tres modos de fallo en un pipeline de WebCodecs — fotogramas descartados, memoria desbordada y picos de latencia — tienen cada uno un síntoma distinto y un paso de diagnóstico directo.

SíntomaCausa probablePaso de diagnóstico
Tartamudeo progresivo seguido de congelación, el resto de la página sigue responsivaframe.close() faltante en algún camino → recursos de medios finitos agotadosAudite cada VideoFrame leído o construido para verificar exactamente un close(); confirme la cobertura con try/finally
Fotogramas faltantes, sin errores en la consolaTransformación lenta; el procesador descarta los fotogramas más antiguos silenciosamenteRegistre controller.desiredSize dentro de la transformación; si tiende a ser negativo, el consumidor está por detrás
La latencia aumenta con el tiempoFiltro por fotograma lento que consume el presupuesto de fotogramaMida la duración por paso; compárela con el presupuesto de su frecuencia de fotogramas (33ms a 30fps)
El codificador deja de producir fragmentosVideoEncoder entró en estado 'closed' tras un errorCompruebe encoder.state antes de cada encode(); reconstruya al detectar 'closed'

La firma de tartamudeo-seguido-de-congelación vale la pena reconocerla a primera vista. Las repeticiones de sesión de funcionalidades basadas en WebCodecs muestran este patrón de forma fiable: video fluido que se reproduce normalmente, luego comienza a perder fotogramas visiblemente, y finalmente se congela por completo mientras el resto de la interfaz permanece interactiva. Esa es la firma visible del agotamiento de recursos de medios finitos por fotogramas no cerrados — la repetición muestra el síntoma claramente, pero la causa es invisible sin saber que hay que buscar un fotograma no cerrado en algún lugar del código del pipeline.

Para medir la latencia real de extremo a extremo — desde la captura de la cámara hasta la visualización — codifique el timestamp del fotograma como una superposición de píxeles antes del pipeline y decodifíquelo desde la salida renderizada mediante requestVideoFrameCallback. Como punto de calibración, el benchmark de pipeline de webrtcHacks (marzo de 2023) reportó estos costes por fotograma:

PasoDuración
Eliminación de fondo22ms
Adición de superposición1ms
Codificación8ms
Decodificación1ms
Visualización38ms

Sus cifras variarán según el hardware y la complejidad del filtro. El resultado notable es que la visualización por sí sola representa ~38ms — el término dominante — lo que significa que un filtro que cabe cómodamente dentro del presupuesto de 30fps puede seguir sintiéndose con retraso si no se tiene en cuenta la cola de visualización. Mida el camino completo, no solo su transformación.

Conclusión

La estructura del pipeline de WebCodecs — MediaStreamTrackProcessorTransformStreamVideoTrackGenerator — es lo suficientemente pequeña como para caber en un único bloque de código, pero la diferencia entre una demo y una funcionalidad lista para producción reside enteramente en los modos de fallo: cerrar cada fotograma, detectar la contrapresión silenciosa, mantener toda la cadena en un único worker, recuperarse de un codificador cerrado, y detectar las características por interfaz en lugar de por navegador. Comience desde el ejemplo con try/finally al inicio de este artículo, añada la comprobación de desiredSize y la protección del estado del codificador, y tendrá un pipeline que sobrevive a los casos que los tutoriales del camino feliz nunca alcanzan.

Preguntas Frecuentes

¿Cuándo debo usar Canvas2D frente a WebGL o WebGPU para un filtro de WebCodecs?

Use Canvas2D para transformaciones de color y composición simple, donde las cadenas ctx.filter o bucles modestos de getImageData caben dentro del presupuesto de fotograma. Recurra a WebGL o WebGPU cuando el coste por píxel sea dominante, ya que mantienen el fotograma en la GPU mediante importExternalTexture y evitan la lectura hacia la CPU que fuerza getImageData. A resolución completa, esa lectura suele ser el cuello de botella, por lo que un camino GPU es la solución para trabajo intensivo por píxel como el chroma keying.

¿Por qué mis VideoFrames se cierran inesperadamente cuando los paso entre workers?

Transferir un VideoFrame entre workers mediante postMessage cierra automáticamente la referencia del lado emisor, por lo que cualquier intento de leerla o cerrarla de nuevo en el emisor lanza una excepción. Esto difiere de los fotogramas dentro de streams transferidos, que se serializan y clonan y requieren un cierre explícito en ambos lados. Para evitar la condición de carrera, mantenga todo el pipeline en un único worker, o tras una transferencia trate la referencia del emisor como inexistente y deje que el worker receptor sea el propietario y cierre el fotograma.

¿Funciona el pipeline de cámara-a-filtro-a-visualización en Firefox?

No completamente. Firefox 130 y versiones posteriores soportan las interfaces principales VideoEncoder y VideoFrame, pero no soporta la capa de captura y salida de Insertable Streams, lo que significa que MediaStreamTrackProcessor y VideoTrackGenerator no están disponibles. Puede codificar y decodificar fotogramas en Firefox, pero debe obtener los fotogramas de otra forma, como un Canvas con requestVideoFrameCallback. Detecte las características por interfaz comprobando window.MediaStreamTrackProcessor y window.VideoEncoder por separado en lugar de identificar el navegador.

¿Cuál es la diferencia entre VideoEncoder.reset() y reconstruir el codificador?

VideoEncoder.reset() gestiona casos no terminales, limpiando el trabajo pendiente en un codificador que aún es utilizable. No puede recuperar un codificador que ha transicionado al estado cerrado tras dispararse un error, porque un codificador cerrado no puede reconfigurarse ni reutilizarse. La recuperación desde el estado cerrado implica construir una nueva instancia de VideoEncoder y llamar a configure() de nuevo con los mismos parámetros. Compruebe encoder.state antes de cada encode() y reconstruya cuando indique el estado cerrado.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.