Processamento de vídeo com WebCodecs

Manipular componentes de stream de vídeo.

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

As tecnologias modernas da Web oferecem diversas maneiras de trabalhar com vídeos. A API Media Stream, a API Media Recording, a API Media Source e a API WebRTC formam um conjunto de ferramentas para gravar, transferir e reproduzir streams de vídeo. Ao resolver determinadas tarefas de alto nível, essas APIs não permitem que os programadores da Web trabalhem com componentes individuais de um stream de vídeo, como frames e blocos de vídeo ou áudio codificados sem separação. Para ter acesso de baixo nível a esses componentes básicos, os desenvolvedores têm usado o WebAssembly para trazer codecs de vídeo e áudio para o navegador. No entanto, como os navegadores modernos já vêm com vários codecs (que geralmente são acelerados pelo hardware), reempacotá-los como WebAssembly parece um desperdício de recursos humanos e computacionais.

A API WebCodecs elimina essa ineficiência oferecendo aos programadores uma maneira de usar componentes de mídia que já estão presentes no navegador. Especificamente:

  • Decodificadores de áudio e vídeo
  • Codificadores de áudio e vídeo
  • Frames de vídeo brutos
  • Decodificadores de imagem

A API WebCodecs é útil para aplicativos da Web que exigem controle total sobre a maneira como o conteúdo de mídia é processado, como editores de vídeo, videoconferências, streaming de vídeo etc.

Fluxo de trabalho de processamento de vídeo

Os frames são o ponto central do processamento de vídeo. Assim, em WebCodecs, a maioria das classes consome ou produz frames. Os codificadores de vídeo convertem frames em fragmentos codificados. Os decodificadores de vídeo fazem o contrário.

Além disso, a VideoFrame funciona bem com outras APIs da Web, sendo um CanvasImageSource e tendo um construtor que aceita CanvasImageSource. Assim, ele pode ser usado em funções como drawImage() e texImage2D(). Além disso, ele pode ser criado a partir de telas, bitmaps, elementos de vídeo e outros frames de vídeo.

A API WebCodecs funciona bem em conjunto com as classes da API Insertable Streams, que conectam WebCodecs a faixas de stream de mídia.

  • MediaStreamTrackProcessor divide as faixas de mídia em frames individuais.
  • MediaStreamTrackGenerator cria uma faixa de mídia a partir de um stream de frames.

WebCodecs e web workers

Por design, a API WebCodecs faz todo o trabalho pesado de forma assíncrona e fora da linha de execução principal. No entanto, como os callbacks de frame e de bloco podem ser chamados várias vezes por segundo, eles podem congestionar a linha de execução principal e tornar o site menos responsivo. Portanto, é preferível mover o processamento de frames individuais e blocos codificados para um web worker.

Para ajudar nisso, o ReadableStream oferece uma maneira conveniente de transferir automaticamente todos os frames de uma faixa de mídia para o worker. Por exemplo, MediaStreamTrackProcessor pode ser usado para receber um ReadableStream para uma faixa de transmissão de mídia vinda da webcam. Depois disso, o stream é transferido para um worker da Web em que os frames são lidos um por um e colocados na fila em um VideoEncoder.

Com HTMLCanvasElement.transferControlToOffscreen, até mesmo a renderização pode ser feita fora da linha de execução principal. No entanto, se todas as ferramentas de alto nível forem inconvenientes, o próprio VideoFrame será transferível e poderá ser movido entre workers.

WebCodecs em ação

Codificação

O caminho de uma tela ou de uma ImageBitmap para a rede ou para o armazenamento
O caminho de um Canvas ou ImageBitmap para a rede ou o armazenamento

Tudo começa com um VideoFrame. Há três maneiras de construir frames de vídeo.

  • De uma fonte de imagem, como uma tela, um bitmap de imagem ou um elemento de vídeo.

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • Usar MediaStreamTrackProcessor para extrair frames de uma 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;
    }
    
  • Crie um frame a partir da representação binária de pixels em um 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);
    

Não importa de onde eles vêm, os frames podem ser codificados em objetos EncodedVideoChunk com um VideoEncoder.

Antes da codificação, VideoEncoder precisa receber dois objetos JavaScript:

  • Inicialização do dicionário com duas funções para processar blocos e erros codificados. Essas funções são definidas pelo desenvolvedor e não podem ser alteradas depois de serem transmitidas para o construtor VideoEncoder.
  • Objeto de configuração do codificador, que contém parâmetros para o stream de vídeo de saída. É possível mudar esses parâmetros mais tarde chamando configure().

O método configure() vai gerar NotSupportedError se a configuração não tiver suporte do navegador. Recomendamos chamar o método estático VideoEncoder.isConfigSupported() com a configuração para verificar com antecedência se ela tem suporte e aguardar a promessa.

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.
}

Depois que o codificador é configurado, ele está pronto para aceitar frames pelo método encode(). configure() e encode() retornam imediatamente, sem aguardar a conclusão do trabalho. Ele permite que vários frames sejam enfileirados para codificação ao mesmo tempo, enquanto encodeQueueSize mostra quantos solicitações estão aguardando na fila para que as codificações anteriores sejam concluídas. Os erros são informados gerando uma exceção imediatamente, caso os argumentos ou a ordem das chamadas de método violem o contrato da API, ou chamando o callback error() para problemas encontrados na implementação do codec. Se a codificação for concluída, o callback output() será chamado com um novo bloco codificado como argumento. Outro detalhe importante é que os frames precisam ser informados quando não são mais necessários chamando 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 fim, é hora de terminar o código de codificação escrevendo uma função que processa fragmentos de vídeo codificado conforme eles saem do codificador. Normalmente, essa função envia blocos de dados pela rede ou os muxa em um contêiner de mídia para armazenamento.

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,
  });
}

Se em algum momento você precisar verificar se todos os pedidos de codificação pendentes foram concluídos, chame flush() e aguarde a promessa.

await encoder.flush();

Decodificação

O caminho da rede ou do armazenamento para uma tela ou uma ImageBitmap.
O caminho da rede ou do armazenamento para um Canvas ou um ImageBitmap.

A configuração de um VideoDecoder é semelhante ao que foi feito para o VideoEncoder: duas funções são transmitidas quando o decodificador é criado, e os parâmetros do codec são fornecidos a configure().

O conjunto de parâmetros do codec varia de acordo com o codec. Por exemplo, o codec H.264 pode precisar de um blob binário do AVCC, a menos que seja codificado no chamado formato 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.
}

Depois que o decodificador for inicializado, você poderá começar a alimentá-lo com objetos EncodedVideoChunk. Para criar um bloco, você precisa de:

  • Um BufferSource de dados de vídeo codificados
  • carimbo de data/hora de início do bloco em microssegundos (tempo de mídia do primeiro frame codificado do bloco)
  • o tipo do bloco, um dos seguintes:
    • key, se o fragmento puder ser decodificado independentemente dos fragmentos anteriores
    • delta, se o bloco só puder ser decodificado depois que um ou mais blocos anteriores forem decodificados

Além disso, todos os fragmentos emitidos pelo codificador estão prontos para o decodificador no estado em que se encontram. Tudo o que foi dito acima sobre a geração de relatórios de erros e a natureza assíncrona dos métodos do codificador também são verdadeiros para os 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();

Agora é hora de mostrar como um frame recém-decodificado pode ser mostrado na página. É melhor garantir que o callback de saída do decodificador (handleFrame()) seja retornado rapidamente. No exemplo abaixo, ela apenas adiciona um frame à fila de frames prontos para renderização. A renderização acontece separadamente e consiste em duas etapas:

  1. Aguardando o momento certo para mostrar o frame.
  2. Desenho do frame na tela.

Quando um frame não for mais necessário, chame close() para liberar a memória subjacente antes que o coletor de lixo a alcance. Isso vai reduzir a quantidade média de memória usada pelo aplicativo da 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);
}

Dicas para desenvolvedores

Use o Painel de mídia no Chrome DevTools para conferir os registros de mídia e depurar WebCodecs.

Captura de tela do painel de mídia para depuração de WebCodecs
Painel de mídia no Chrome DevTools para depurar WebCodecs.

Demonstração

A demonstração abaixo mostra como os frames de animação de uma tela são:

  • capturada a 25 fps em uma ReadableStream por MediaStreamTrackProcessor
  • transferido para um worker da Web
  • codificado em formato de vídeo H.264
  • decodificada novamente em uma sequência de frames de vídeo
  • e renderizada na segunda tela usando transferControlToOffscreen().

Outras demonstrações

Confira também nossas outras demonstrações:

Como usar a API WebCodecs

Detecção de recursos

Para verificar o suporte a WebCodecs:

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

A API WebCodecs só está disponível em contextos seguros. Portanto, a detecção vai falhar se self.isSecureContext for falso.

Feedback

A equipe do Chrome quer saber sobre sua experiência com a API WebCodecs.

Conte sobre o design da API

Há algo na API que não funciona como esperado? Ou há métodos ou propriedades ausentes que você precisa para implementar sua ideia? Tem uma pergunta ou comentário sobre o modelo de segurança? Envie um problema de especificação no repositório do GitHub correspondente ou adicione sua opinião a um problema existente.

Informar um problema com a implementação

Você encontrou um bug na implementação do Chrome? Ou a implementação é diferente da especificação? Registre um bug em new.crbug.com. Inclua o máximo de detalhes possível, instruções simples para reprodução e insira Blink>Media>WebCodecs na caixa Components. O Glitch é ótimo para compartilhar reprosagens rápidas e fáceis.

Mostrar suporte à API

Você planeja usar a API WebCodecs? Seu apoio público ajuda a equipe do Chrome a priorizar recursos e mostra a outros fornecedores de navegadores a importância de oferecer suporte a eles.

Envie e-mails para media-dev@chromium.org ou envie um tweet para @ChromiumDev usando a hashtag #WebCodecs e informe onde e como você está usando.

Imagem principal de Denise Jans no Unsplash.