Procesamiento de video con WebCodecs

Manipulación de componentes de transmisión de video.

Eugene Zemtsov
Eugene Zemtsov

Las tecnologías web modernas brindan amplias formas de trabajar con video. API de Media Stream, API de Media Recording, API de Media Source y API de WebRTC se suman a un rico conjunto de herramientas para grabar, transferir y reproducir secuencias de video. Mientras resuelven ciertas tareas de alto nivel, estas API no permiten que los programadores web trabajen con componentes individuales de una transmisión de video, como cuadros y chunks (fragmentos) de audio o video codificados sin ser multiplexados. Para obtener acceso de bajo nivel a estos componentes básicos, los desarrolladores han estado utilizando WebAssembly para incorporar códecs de audio y video al navegador. Pero dado que los navegadores modernos ya vienen con una variedad de códecs (que a menudo son acelerados por hardware), volver a empaquetarlos como WebAssembly parece una pérdida de recursos humanos e informáticos.

La API de WebCodecs elimina esta ineficiencia al brindarles a los programadores una forma de usar componentes multimedia que ya están presentes en el navegador. Específicamente:

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

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

Estado actual

Paso Estado
1. Crear un explicador Completado
2. Crear borrador inicial de especificación Completado
3. Recopilar comentarios e iterar el diseño Completado
4. Prueba de origen Completo
5. Lanzamiento Chrome 94

Flujo de trabajo de procesamiento de video

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

Además, VideoFrame es compatible con otras API web al ser CanvasImageSource y por tener un constructor que acepta a CanvasImageSource. Por lo tanto, se puede usar en funciones como drawImage() y texImage2D(). También se puede construir a partir de canvas, mapas de bits, elementos de video y otros fotogramas de video.

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

  • MediaStreamTrackProcessor divide las pistas multimedia en fotogramas individuales.
  • MediaStreamTrackGenerator crea una pista de medios a partir de una secuencia de fotogramas.

WebCodecs y web workers

Por diseño, la API de WebCodecs hace todo el trabajo pesado de forma asincrónica y fuera del hilo principal. Pero dado que las retrollamadas de fotogramas y chunks a menudo se pueden llamar varias veces por segundo, pueden saturar el hilo principal y, por lo tanto, hacer que el sitio web sea menos responsivo. Por lo tanto, es preferible trasladar el manejo de fotogramas individuales y chunks codificados a un web worker.

Para ayudar a eso, ReadableStream proporciona una forma conveniente de transferir automáticamente todos los fotogramas provenientes de una pista de medios al web worker. Por ejemplo, MediaStreamTrackProcessor se puede utilizar para obtener un ReadableStream para una pista de transmisión multimedia procedente de la cámara web. Después de eso, la transmisión se transfiere a un web worker donde los fotogramas se leen uno por uno y se meten a la cola de VideoEncoder.

Con HTMLCanvasElement.transferControlToOffscreen se pueden renderizar fuera del hilo principal. Pero si todas las herramientas de alto nivel resultan inconvenientes, VideoFrame es transferible y se puede mover entre web workers.

WebCodecs en acción

Codificación

La ruta desde un Canvas o un ImageBitmap a la red o al almacenamiento
La ruta desde un Canvas o un ImageBitmap a la red o al almacenamiento

Todo comienza con un VideoFrame. Hay tres formas de construir fotogramas de video.

  • Desde una fuente de imagen como un canvas, un mapa de bits de imagen o un elemento de video.
const cnv = document.createElement('canvas');
// dibuja algo en el canvas
…
let frame_from_canvas = new VideoFrame(cnv, { timestamp: 0 });
  • Utiliza MediaStreamTrackProcessor para extraer fotogramas de un MediaStreamTrack
  const stream = await navigator.mediaDevices.getUserMedia({ … });
  const track = stream.getTracks()[0];

  const media_processor = new MediaStreamTrackProcessor(track);

  const reader = media_processor.readable.getReader();
  while (true) {
      const result = await reader.read();
      if (result.done)
        break;
      let frame_from_camera = result.value;
  }
  • Crea un fotograma a partir de su renderización de píxeles binarios en un BufferSource
  const pixelSize = 4;
  const init = {timestamp: 0, codedwidth: 320, codedHeight: 200, format: 'RGBA'};
  let data = new Uint8Array(init.codedwidth * init.codedHeight * pixelSize);
  for (let x = 0; x < init.codedwidth; x++) {
    for (let y = 0; y < init.codedHeight; y++) {
      let 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
    }
  }
  let frame = new VideoFrame(data, init);

No importa de dónde vengan, los fotogramas se pueden codificar en objetos de EncodedVideoChunk con un VideoEncoder.

Antes de codificar, VideoEncoder necesita dos objetos JavaScript:

  • Diccionario de inicio con dos funciones para manejar chunks codificados y errores. Estas funciones están definidas por el desarrollador y no se pueden cambiar después de ser pasadas al constructor VideoEncoder.
  • Objeto de configuración del codificador, que contiene parámetros para la transmisión de video de salida. Puedes cambiar estos parámetros más tarde llamando a configure().
const init = {
  output: handleChunk,
  error: (e) => {
    console.log(e.message);
  }
};

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

let encoder = new VideoEncoder(init);
encoder.configure(config);

Una vez que se hayas configurado el codificador, está listo para aceptar fotogramas mediante el método encode(). Tanto configure() como encode() regresan inmediatamente sin esperar a que se complete el trabajo. Esto permite que varios fotogramas entren a la cola para ser codificados al mismo tiempo, mientras que encodeQueueSize muestra cuántas solicitudes están esperando en la cola para que finalicen las codificaciones anteriores. Los errores se informan lanzando inmediatamente una excepción, en caso de que los argumentos o el orden de las llamadas al método infrinjan el contrato de la API, o haciendo una retrollamada de error() para los problemas encontrados en la implementación del códec. Si la codificación es completada correctamente, la retrollamada de output() se llama con un nuevo pedazo codificado como argumento. Otro detalle importante es que los fotogramas deben ser informados cuando ya no se necesitan llamando a close().

let frame_counter = 0;

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

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

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

Finalmente, es el momento de terminar de codificar el código escribiendo una función que maneje chunks de video codificado a medida que salen del codificador. Por lo general, esta función enviaría chunks de datos a través de la red o multiplexarlos en un contenedor de medios para su almacenamiento.

function handleChunk(chunk, metadata) {

  if (metadata.decoderConfig) {
    // El decodificador necesita ser configurado (o volver a ser configurado) con los nuevos parámetros
    // cuando la metadata tiene un nuevo decoderConfig.
    // Esto usualmente sucede en el principio o cuando el codificador tiene una nueva
    // especificación binaria de configuración de codec. (VideoDecoderConfig.description).
    fetch('/upload_extra_data',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/octet-stream' },
      body: metadata.decoderConfig.description
    });
  }

  // los bytes actuales de los datos codificados
  let chunkData = new Uint8Array(chunk.byteLength);
  chunk.copyTo(chunkData);

  let timestamp = chunk.timestamp;        // tiempo de los medios en microsegundos
  let is_key = chunk.type == 'key';       // también puede ser un "delta"
  fetch(`/upload_chunk?timestamp=${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 desde la red o el almacenamiento a un Canvas o ImageBitmap.
La ruta desde la red o el almacenamiento a un Canvas o un ImageBitmap.

La configuración de un VideoDecoder es similar a lo que se ha hecho para el VideoEncoder: se pasan dos funciones cuando se crea el decodificador y se pasan los parámetros del códec a configure().

El conjunto de parámetros del códec varía de un códec a otro. Por ejemplo, el códec H.264 puede necesitar un binary blob (blob binario) de avcC, a menos que esté codificado en el formato AnnexB (encoderConfig.avc = { format: "annexb" }).

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

const config = {
  codec: 'vp8',
  codedwidth: 640,
  codedHeight: 480
};

let decoder = new VideoDecoder(init);
decoder.configure(config);

Una vez que se inicializa el decodificador, puedes comenzar a alimentarlo con objetos EncodedVideoChunk. Para crear un chunk, necesitarás:

  • Un BufferSource de video codificados
  • La marca de tiempo de inicio del chunk en microsegundos (tiempo de medios del primer fotograma codificado en el chunk)
  • El tipo del chunk, cualquiera de los siguientes:
    • key si el chunk se puede decodificar independientemente de los chunks anteriores
    • delta si el chunk solo se puede decodificar después de que se hayan decodificado uno o más chunks anteriores

Además, cualquier chunk emitido por el codificador está listo para el decodificador. Todas las cosas mencionadas anteriormente sobre la notificación de errores y la naturaleza asincrónica de los métodos del codificador son igualmente válidas para los decodificadores.

let responses = await downloadVideoChunksFromServer(timestamp);
for (let i = 0; i < responses.length; i++) {
  let 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 el momento de mostrar cómo se puede mostrar un fotograma recién decodificado en la página. Es mejor asegurarse de que la retrollamada de salida del decodificador (handleFrame()) regrese rápidamente. En el siguiente ejemplo, solo agregas un fotograma a la cola de fotogramas listos para renderizar. El renderizado ocurre por separado y consta de dos pasos:

  1. Esperando el momento adecuado para mostrar el fotograma.
  2. Dibujando el fotograma en el canvas.

Una vez que ya no se necesita un fotograma, llamas a close() para liberar la memoria subyacente antes de que el recolector de basura llegue a él, esto reducirá la cantidad promedio de memoria utilizada por la aplicación web.

let cnv = document.getElementById('canvas_to_render');
let ctx = cnv.getContext('2d');
let ready_frames = [];
let underflow = true;
let time_base = 0;

function handleFrame(frame) {
  ready_frames.push(frame);
  if (underflow)
    setTimeout(render_frame, 0);
}

function delay(time_ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, time_ms);
  });
}

function calculateTimeTillNextFrame(timestamp) {
  if (time_base == 0)
    time_base = performance.now();
  let media_time = performance.now() - time_base;
  return Math.max(0, (timestamp / 1000) - media_time);
}

async function render_frame() {
  if (ready_frames.length == 0) {
    underflow = true;
    return;
  }
  let frame = ready_frames.shift();
  underflow = false;

  // En base al tiempo del fotograma calculado en cuanto tiemp   // o real espera
  // que se necesite para mostrar el siguiente fotograma.
  let time_till_next_frame = calculateTimeTillNextFrame(frame.timestamp);
  await delay(time_till_next_frame);
  ctx.drawImage(frame, 0, 0);
  frame.close();

  // Inmediatamente agenda el renderizaje del próximo fotogram//a
  setTimeout(render_frame, 0);
}

Demostración

La siguiente demostración muestra cómo son los fotogramas de animación de un canvas:

  • Capturado a 25 fps en un ReadableStream por MediaStreamTrackProcessor
  • Transferido a un web worker
  • Codificado en formato de video H.264
  • Decodificado de nuevo en una secuencia de fotogramas de video
  • Y renderizado en el segundo canvas usando transferControlToOffscreen()

Otras demostraciones

Consulta también nuestras otras demostraciones:

Usando la API de WebCodecs

Detección de características

Para comprobar la compatibilidad con WebCodecs:

if ('VideoEncoder' in window) {
  // La API de WebCodecs es compatible.
}

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.

Retroalimentación

El equipo de Chrome desea saber sobre tu experiencia con la API de WebCodecs.

Cuéntanos sobre el diseño de la API

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

Reportar un problema con la implementación

¿Encontraste un error con la implementación de Chrome? ¿O la implementación es diferente de la especificación? Presenta un error en new.crbug.com. Asegúrate de incluir todos los detalles que puedas y de agregar instrucciones simples para reproducir e ingresar Blink>Media>WebCodecs en el cuadro de Componentes. Glitch funciona muy bien para compartir reproducciones rápidas y fáciles.

Muestra tu apoyo a la API

¿Estás pensando en utilizar la API de WebCodecs? Tu apoyo público ayuda al equipo de Chrome a priorizar funciones y muestra a otros proveedores de navegadores lo importante que es brindarles soporte.

Envía un correo electrónico a media-dev@chromium.org o envía un tweet a [@ChromiumDev] [cr-dev-twitter] usando el hashtag de #WebCodecs y déjanos saber dónde y cómo lo estás usando.

Imagen de héroe de Denise Jans en Unsplash.