Processamento de vídeo com WebCodecs

Manipular os 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ídeo. A API Media Stream, a API Media Recording, a API Media Source e a API WebRTC são um conjunto de ferramentas avançadas para gravação, transferência e reprodução de 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 não multiplexados de vídeo ou áudio codificado. 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á incluem vários codecs (que geralmente são acelerados por hardware), reempacotá-los porque o WebAssembly parece ser um desperdício de recursos humanos e de computador.

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

  • Decodificadores de vídeo e áudio
  • 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 forma como o conteúdo de mídia é processado, como editores de vídeo, videoconferência, streaming de vídeo etc.

Fluxo de trabalho de processamento de vídeo

Os frames são a peça central do processamento de vídeo. No WebCodecs, a maioria das classes consome ou produz frames. Codificadores de vídeo convertem frames em blocos 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. Por isso, ele pode ser usado em funções como drawImage() e texImage2D(). Além disso, ele pode ser construído 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 o WebCodecs a faixas de streams de mídia.

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

WebCodecs e workers da Web

Por projeto, 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 bloco geralmente podem ser chamados várias vezes por segundo, eles podem sobrecarregar 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 provenientes de uma faixa de mídia para o worker. Por exemplo, MediaStreamTrackProcessor pode ser usado para receber um ReadableStream para uma faixa de streaming de mídia proveniente 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 é transferível e pode ser movido entre workers.

WebCodecs em ação

Codificação

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

Tudo começa com uma VideoFrame. Existem três maneiras de criar 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 um 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;
    }
    
  • Criar um frame a partir da representação de pixel binária 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 uma VideoEncoder.

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

  • Dicionário de inicialização 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 ao 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 uma NotSupportedError se a configuração não for compatível com o navegador. É recomendável chamar o método estático VideoEncoder.isConfigSupported() com a configuração para verificar com antecedência se ela é compatível 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 de configurar o codificador, ele estará pronto para aceitar frames usando o método encode(). configure() e encode() retornam imediatamente sem aguardar a conclusão do trabalho real. Ele permite que vários frames sejam colocados na fila para codificação ao mesmo tempo, enquanto encodeQueueSize mostra quantas solicitações estão aguardando na fila para que as codificações anteriores sejam concluídas. Os erros são informados com uma exceção gerada imediatamente, caso os argumentos ou a ordem das chamadas de método violem o contrato da API. Também é possível chamar o callback error() para problemas encontrados na implementação do codec. Se a codificação for concluída com êxito, o callback output() será chamado com um novo bloco codificado como argumento. Outro detalhe importante aqui é 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();
  }
}

Finalmente, é hora de terminar de codificar o código, escrevendo uma função que lida com partes do vídeo codificado à medida que saem do codificador. Normalmente, essa função estaria enviando blocos de dados pela rede ou fazendo multiplexação 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 garantir que todas as solicitações de codificação pendentes foram concluídas, chame flush() e aguarde a promessa.

await encoder.flush();

Decodificação

O caminho da rede ou do armazenamento para um Canvas ou um 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 VideoEncoder: duas funções são transmitidas quando o decodificador é criado e os parâmetros do codec são fornecidos para configure().

O conjunto de parâmetros de codec varia de acordo com o codec. Por exemplo, o codec H.264 pode precisar de um blob binário de AVCC, a menos que esteja codificado no 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 de inicializar o decodificador, será possível começar a alimentá-lo com objetos EncodedVideoChunk. Para criar um bloco, você precisa de:

  • Uma BufferSource de dados de vídeo codificados
  • o carimbo de data/hora de início do bloco em microssegundos (tempo de mídia do primeiro frame codificado no bloco)
  • o tipo da parte, que pode ser:
    • key se o bloco puder ser decodificado independentemente dos blocos anteriores
    • delta se o bloco só puder ser decodificado após um ou mais blocos anteriores terem sido decodificados

Além disso, os blocos emitidos pelo codificador estão prontos para o decodificador no estado em que estão. Tudo o que foi dito acima sobre relatórios de erros e a natureza assíncrona dos métodos do codificador também são válidos 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 exibido 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. Desenhar o 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 chegue a ele. Isso 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 Media Panel no Chrome DevTools para visualizar registros de mídia e depurar o WebCodecs.

Captura de tela do Media Panel para depuração do WebCodecs
Painel de mídia no Chrome DevTools para depuração do 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
  • transferidos para um Web worker
  • codificado no formato de vídeo H.264
  • decodificado novamente em uma sequência de quadros de vídeo
  • e renderizados 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 a compatibilidade com o WebCodecs:

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

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

Feedback

A equipe do Chrome quer saber mais sobre suas experiências com a API WebCodecs.

Fale sobre o design da API

Algo na API não funciona como você esperava? Ou há métodos ou propriedades ausentes que você precisa para implementar sua ideia? Tem alguma dúvida ou comentar sobre o modelo de segurança? Registre um problema específico no repositório do GitHub correspondente ou adicione suas ideias a um problema atual.

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 Componentes. O Glitch funciona muito bem para compartilhar repetições rápidas e fáceis.

Mostrar suporte à API

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

Envie e-mails para media-dev@chromium.org ou um tweet para @ChromiumDev usando a hashtag #WebCodecs e conte para nós onde e como você está usando a ferramenta.

Imagem principal por Denise Jans no Unsplash (links em inglês).