Procesamiento de videos con WebCodecs

Manipular componentes de transmisión de video por Internet

Eugene Zemtsov
Eugene Zemtsov
François Beaufort
François Beaufort

Las tecnologías web modernas proporcionan una gran variedad de formas de trabajar con video. La API de Media Stream, la API de Media Recording, la API de Media Source y la API de WebRTC se suman a un conjunto de herramientas enriquecido para grabar, transferir y reproducir transmisiones de video por Internet. Si bien resuelven ciertas tareas de alto nivel, estas APIs no permiten que los programadores web trabajen con componentes individuales de una transmisión de video por Internet, como fotogramas y fragmentos no combinados de video o audio codificados. Para obtener acceso de bajo nivel a estos componentes básicos, los desarrolladores usan WebAssembly para incorporar códecs de audio y video en el navegador. Sin embargo, dado que los navegadores modernos ya incluyen una variedad de códecs (que a menudo son acelerados por hardware), reempaquetarlos como WebAssembly parece un desperdicio de recursos humanos y informáticos.

La API de WebCodecs elimina esta ineficiencia, ya que les brinda a los programadores una forma de usar componentes multimedia que ya están presentes en el navegador. En particular, haz lo siguiente:

  • Decodificadores de audio y video
  • Codificadores de audio y video
  • Fotogramas de video sin procesar
  • Decodificadores de imágenes

La API de WebCodecs es útil para aplicaciones web que requieren control total sobre la forma en que se procesa el contenido multimedia, como editores de video, videoconferencias, transmisión de video por Internet, etcétera.

Flujo de trabajo del procesamiento de videos

Los fotogramas son el elemento central del procesamiento de video. Por lo tanto, en WebCodecs, la mayoría de las clases consumen o producen marcos. Los codificadores de video convierten los fotogramas en fragmentos codificados. Los decodificadores de video hacen lo contrario.

Además, VideoFrame funciona a la perfección con otras APIs web, ya que es un CanvasImageSource y tiene un constructor que acepta CanvasImageSource. Por lo tanto, se puede usar en funciones como drawImage() y texImage2D(). También se puede construir a partir de lienzos, mapas de bits, elementos de video y otros marcos de video.

La API de WebCodecs funciona bien en conjunto con las clases de la API de Insertable Streams, que conectan WebCodecs a las pistas de transmisión de contenido multimedia.

  • MediaStreamTrackProcessor divide las pistas multimedia en fotogramas individuales.
  • MediaStreamTrackGenerator crea una pista multimedia a partir de una transmisión de fotogramas.

WebCodecs y trabajadores web

Por diseño, la API de WebCodecs realiza todo el trabajo pesado de manera asíncrona y fuera del subproceso principal. Sin embargo, como las devoluciones de llamada de marco y fragmento a menudo se pueden llamar varias veces por segundo, podrían desordenar el subproceso principal y, por lo tanto, hacer que el sitio web sea menos responsivo. Por lo tanto, es preferible trasladar el control de marcos individuales y fragmentos codificados a un trabajador web.

Para ayudar con eso, ReadableStream proporciona una forma conveniente de transferir automáticamente todos los marcos provenientes de un segmento multimedia al trabajador. Por ejemplo, se puede usar MediaStreamTrackProcessor para obtener un ReadableStream para una pista de transmisión de contenido multimedia que proviene de la cámara web. Luego, la transmisión se transfiere a un trabajador web, en el que los marcos se leen uno por uno y se ponen en cola en un VideoEncoder.

Con HTMLCanvasElement.transferControlToOffscreen, incluso la renderización se puede realizar fuera del subproceso principal. Sin embargo, si todas las herramientas de alto nivel resultaron ser inconvenientes, VideoFrame en sí es transferible y se puede mover entre trabajadores.

WebCodecs en acción

Codificación

La ruta de acceso de un objeto Canvas o ImageBitmap a la red o al almacenamiento
La ruta de acceso de un objeto Canvas o ImageBitmap a la red o al almacenamiento

Todo comienza con un VideoFrame. Existen tres maneras de construir marcos de video.

  • Desde una fuente de imagen, como un lienzo, un mapa de bits de imagen o un elemento de video

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • Usa MediaStreamTrackProcessor para extraer fotogramas de un elemento MediaStreamTrack

    const stream = await navigator.mediaDevices.getUserMedia({…});
    const track = stream.getTracks()[0];
    
    const trackProcessor = new MediaStreamTrackProcessor(track);
    
    const reader = trackProcessor.readable.getReader();
    while (true) {
      const result = await reader.read();
      if (result.done) break;
      const frameFromCamera = result.value;
    }
    
  • Crea un fotograma a partir de su representación de píxeles binarios en un objeto BufferSource.

    const pixelSize = 4;
    const init = {
      timestamp: 0,
      codedWidth: 320,
      codedHeight: 200,
      format: "RGBA",
    };
    const data = new Uint8Array(init.codedWidth * init.codedHeight * pixelSize);
    for (let x = 0; x < init.codedWidth; x++) {
      for (let y = 0; y < init.codedHeight; y++) {
        const offset = (y * init.codedWidth + x) * pixelSize;
        data[offset] = 0x7f;      // Red
        data[offset + 1] = 0xff;  // Green
        data[offset + 2] = 0xd4;  // Blue
        data[offset + 3] = 0x0ff; // Alpha
      }
    }
    const frame = new VideoFrame(data, init);
    

Sin importar de dónde provengan, los marcos se pueden codificar en objetos EncodedVideoChunk con un VideoEncoder.

Antes de codificar, VideoEncoder necesita tener dos objetos JavaScript:

  • Diccionario de inicialización con dos funciones para controlar los fragmentos codificados y los errores. Estas funciones están definidas por el desarrollador y no se pueden cambiar después de pasarlas al constructor VideoEncoder.
  • El objeto de configuración del codificador, que contiene parámetros para la transmisión de video por Internet de salida. Puedes cambiar estos parámetros más adelante llamando a configure().

El método configure() arrojará una excepción NotSupportedError si el navegador no admite la configuración. Te recomendamos que llames al método estático VideoEncoder.isConfigSupported() con la configuración para verificar de antemano si la configuración es compatible y esperar su promesa.

const init = {
  output: handleChunk,
  error: (e) => {
    console.log(e.message);
  },
};

const config = {
  codec: "vp8",
  width: 640,
  height: 480,
  bitrate: 2_000_000, // 2 Mbps
  framerate: 30,
};

const { supported } = await VideoEncoder.isConfigSupported(config);
if (supported) {
  const encoder = new VideoEncoder(init);
  encoder.configure(config);
} else {
  // Try another config.
}

Una vez que se haya configurado el codificador, estará listo para aceptar fotogramas a través del método encode(). Tanto configure() como encode() se muestran de inmediato sin esperar a que se complete el trabajo real. Permite que varios fotogramas se pongan en cola para la codificación al mismo tiempo, mientras que encodeQueueSize muestra cuántas solicitudes están en espera en la cola para que finalicen las codificaciones anteriores. Los errores se informan arrojando una excepción de inmediato, en caso de que los argumentos o el orden de las llamadas al método infrinjan el contrato de la API, o bien llamando a la devolución de llamada error() para problemas encontrados en la implementación del códec. Si la codificación se completa correctamente, se llama a la devolución de llamada output() con un nuevo fragmento codificado como argumento. Otro detalle importante aquí es que se deben indicar los fotogramas cuando ya no se necesitan llamando a close().

let frameCounter = 0;

const track = stream.getVideoTracks()[0];
const trackProcessor = new MediaStreamTrackProcessor(track);

const reader = trackProcessor.readable.getReader();
while (true) {
  const result = await reader.read();
  if (result.done) break;

  const frame = result.value;
  if (encoder.encodeQueueSize > 2) {
    // Too many frames in flight, encoder is overwhelmed
    // let's drop this frame.
    frame.close();
  } else {
    frameCounter++;
    const keyFrame = frameCounter % 150 == 0;
    encoder.encode(frame, { keyFrame });
    frame.close();
  }
}

Por último, es el momento de escribir una función que controle fragmentos de los videos codificados a medida que salen del codificador para terminar de codificar. Por lo general, esta función sería enviar fragmentos de datos a través de la red o mezclarlos en un contenedor de contenido multimedia para su almacenamiento.

function handleChunk(chunk, metadata) {
  if (metadata.decoderConfig) {
    // Decoder needs to be configured (or reconfigured) with new parameters
    // when metadata has a new decoderConfig.
    // Usually it happens in the beginning or when the encoder has a new
    // codec specific binary configuration. (VideoDecoderConfig.description).
    fetch("/upload_extra_data", {
      method: "POST",
      headers: { "Content-Type": "application/octet-stream" },
      body: metadata.decoderConfig.description,
    });
  }

  // actual bytes of encoded data
  const chunkData = new Uint8Array(chunk.byteLength);
  chunk.copyTo(chunkData);

  fetch(`/upload_chunk?timestamp=${chunk.timestamp}&type=${chunk.type}`, {
    method: "POST",
    headers: { "Content-Type": "application/octet-stream" },
    body: chunkData,
  });
}

Si en algún momento necesitas asegurarte de que se hayan completado todas las solicitudes de codificación pendientes, puedes llamar a flush() y esperar su promesa.

await encoder.flush();

Decodificación

La ruta de acceso de la red o el almacenamiento a un lienzo o un ImageBitmap.
La ruta de acceso desde la red o el almacenamiento a Canvas o ImageBitmap.

Configurar un VideoDecoder es similar a lo que se hizo para VideoEncoder: se pasan dos funciones cuando se crea el decodificador y se proporcionan los parámetros del códec a configure().

El conjunto de parámetros del códec varía de códec a códec. Por ejemplo, el códec H.264 podría necesitar un BLOB binario de AVCC, a menos que esté codificado en el formato denominado Anexo B (encoderConfig.avc = { format: "annexb" }).

const init = {
  output: handleFrame,
  error: (e) => {
    console.log(e.message);
  },
};

const config = {
  codec: "vp8",
  codedWidth: 640,
  codedHeight: 480,
};

const { supported } = await VideoDecoder.isConfigSupported(config);
if (supported) {
  const decoder = new VideoDecoder(init);
  decoder.configure(config);
} else {
  // Try another config.
}

Una vez que se inicialice el decodificador, puedes comenzar a proporcionarle objetos EncodedVideoChunk. Para crear un bloque, necesitarás lo siguiente:

  • Un BufferSource de datos de video codificados
  • la marca de tiempo de inicio del bloque en microsegundos (tiempo multimedia del primer fotograma codificado en el bloque)
  • el tipo de bloque, uno de los siguientes:
    • key si el fragmento se puede decodificar independientemente de los fragmentos anteriores
    • delta si el fragmento solo se puede decodificar después de decodificar uno o más fragmentos anteriores

Además, cualquier fragmento que emite el codificador está listo para el decodificador tal como está. Todo lo que se mencionó antes sobre los informes de errores y la naturaleza asíncrona de los métodos del codificador también es válido para los decodificadores.

const responses = await downloadVideoChunksFromServer(timestamp);
for (let i = 0; i < responses.length; i++) {
  const chunk = new EncodedVideoChunk({
    timestamp: responses[i].timestamp,
    type: responses[i].key ? "key" : "delta",
    data: new Uint8Array(responses[i].body),
  });
  decoder.decode(chunk);
}
await decoder.flush();

Ahora es momento de mostrar cómo se puede mostrar un fotograma recién decodificado en la página. Es mejor asegurarse de que la devolución de llamada de salida del decodificador (handleFrame()) se muestre rápidamente. En el siguiente ejemplo, solo se agrega un fotograma a la cola de fotogramas listos para la renderización. La renderización se realiza por separado y consta de dos pasos:

  1. Esperando el momento adecuado para mostrar el fotograma.
  2. Dibujar el marco en el lienzo.

Una vez que ya no se necesita un fotograma, llama a close() para liberar memoria subyacente antes de que el recolector de elementos no utilizados llegue a él, lo que reducirá la cantidad promedio de memoria que usa la aplicación web.

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let pendingFrames = [];
let underflow = true;
let baseTime = 0;

function handleFrame(frame) {
  pendingFrames.push(frame);
  if (underflow) setTimeout(renderFrame, 0);
}

function calculateTimeUntilNextFrame(timestamp) {
  if (baseTime == 0) baseTime = performance.now();
  let mediaTime = performance.now() - baseTime;
  return Math.max(0, timestamp / 1000 - mediaTime);
}

async function renderFrame() {
  underflow = pendingFrames.length == 0;
  if (underflow) return;

  const frame = pendingFrames.shift();

  // Based on the frame's timestamp calculate how much of real time waiting
  // is needed before showing the next frame.
  const timeUntilNextFrame = calculateTimeUntilNextFrame(frame.timestamp);
  await new Promise((r) => {
    setTimeout(r, timeUntilNextFrame);
  });
  ctx.drawImage(frame, 0, 0);
  frame.close();

  // Immediately schedule rendering of the next frame
  setTimeout(renderFrame, 0);
}

Sugerencias para desarrolladores

Usa el panel multimedia en las Herramientas para desarrolladores de Chrome para ver los registros multimedia y depurar WebCodecs.

Captura de pantalla del panel Media para depurar WebCodecs
Panel Media en las Herramientas para desarrolladores de Chrome para depurar WebCodecs.

Demostración

La siguiente demostración muestra cómo funcionan los marcos de animación de un lienzo:

  • capturada a 25 FPS en un ReadableStream por MediaStreamTrackProcessor
  • transferidos a un trabajador web
  • codificado en formato de video H.264
  • decodificado de nuevo en una secuencia de fotogramas de video
  • y se renderiza en el segundo lienzo con transferControlToOffscreen()

Otras demostraciones

Echa también un vistazo a nuestras otras demostraciones:

Usa la API de WebCodecs

Detección de funciones

Para comprobar la compatibilidad con WebCodecs:

if ('VideoEncoder' in window) {
  // WebCodecs API is supported.
}

Ten en cuenta que la API de WebCodecs solo está disponible en contextos seguros, por lo que la detección fallará si self.isSecureContext es falso.

Comentarios

El equipo de Chrome quiere conocer tu experiencia con la API de WebCodecs.

Cuéntanos sobre el diseño de la API

¿Hay algo acerca de la API que no funciona como esperabas? ¿O faltan métodos o propiedades que necesitas para implementar tu idea? ¿Tienes alguna pregunta o comentario sobre el modelo de seguridad? Informa un problema de especificaciones en el repositorio de GitHub correspondiente o agrega tus ideas a un problema existente.

Informar un problema con la implementación

¿Encontraste un error en la implementación de Chrome? ¿La implementación es diferente de las especificaciones? Informa un error en new.crbug.com. Asegúrate de incluir tantos detalles como puedas, instrucciones simples para su reproducción y, luego, ingresa Blink>Media>WebCodecs en el cuadro Componentes. Glitch funciona muy bien para compartir repros rápidos y fáciles.

Muestra compatibilidad con la API

¿Planeas usar la API de WebCodecs? Tu asistencia pública ayuda al equipo de Chrome a priorizar funciones y le muestra a otros proveedores de navegadores la importancia de admitirlas.

Envía correos electrónicos a media-dev@chromium.org o envía un tweet a @ChromiumDev con el hashtag #WebCodecs y cuéntanos dónde y cómo lo usas.

Hero image de Denise Jans en Unsplash.