12k
All articles

Processamento de Vídeo em Tempo Real com a API WebCodecs

Processamento de vídeo WebCodecs com MediaStreamTrackProcessor, TransformStream e VideoTrackGenerator, além de frame.close, backpressure, workers e suporte.

OpenReplay Team
OpenReplay Team
Processamento de Vídeo em Tempo Real com a API WebCodecs

Um pipeline de vídeo com WebCodecs tem três partes: um MediaStreamTrackProcessor que converte uma MediaStreamTrack em um ReadableStream<VideoFrame>, um TransformStream onde você manipula cada frame, e um VideoTrackGenerator que converte os frames processados de volta em uma MediaStreamTrack que pode ser atribuída a um elemento <video>. VideoTrackGenerator é o nome atual na especificação; exemplos no Chromium ainda utilizam o nome antigo e não padronizado MediaStreamTrackGenerator. A seguir, o 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 artigo aborda exatamente o que todos os tutoriais existentes ignoram: os modos de falha. A versão do caminho feliz acima funciona — até que deixa de funcionar: até que frames vazem, a transformação fique para trás, o encoder entre em um estado fechado, ou você publique código assumindo que o Safari não consegue executá-lo. Cada um desses é uma falha real em produção, com uma causa específica e uma solução específica, e é isso que será abordado a seguir.

Principais Conclusões

  • Um pipeline WebCodecs segue o fluxo MediaStreamTrackProcessorTransformStreamVideoTrackGenerator; utilize o construtor conforme a especificação new MediaStreamTrackProcessor({ track }), e não a forma posicional obsoleta.
  • Esquecer frame.close() esgota os recursos de mídia finitos dos quais o pipeline depende; uma vez esgotados, a emissão de frames trava, produzindo um vídeo que engasga progressivamente e depois congela, enquanto o restante da página permanece responsivo.
  • MediaStreamTrackProcessor não propaga backpressure para upstream — quando sua transformação fica para trás, o processor descarta silenciosamente os frames mais antigos em vez de lançar um erro.
  • Realize todo o trabalho com VideoFrame em um único worker: um frame transferido entre workers é fechado automaticamente no lado remetente, e qualquer tentativa de acessá-lo novamente lança uma exceção.
  • O suporte ao WebCodecs varia por interface — as interfaces principais VideoEncoder/VideoFrame estão disponíveis no Chrome 94+, Firefox 130+ e Safari 16.4+, enquanto MediaStreamTrackProcessor/VideoTrackGenerator ficam para trás (Safari 18+, sem suporte no Firefox).

O que é WebCodecs e Por que o Pipeline tem Esta Estrutura

WebCodecs fornece ao JavaScript acesso direto aos codecs de mídia nativos do navegador — frequentemente com aceleração por hardware — e a frames de vídeo brutos. Antes disso, uma MediaStream era opaca: você a direcionava para um elemento <video> e o navegador gerenciava tudo entre a captura e a exibição. O WebCodecs abre esse pipeline. A interface VideoFrame expõe os pixels brutos entre a captura e a codificação, que é exatamente onde um filtro, um plano de fundo virtual ou um encoder personalizado precisa atuar.

O motivo pelo qual o pipeline utiliza Streams é que frames decodificados brutos são grandes (vários megabytes cada) e chegam rapidamente (25 ou mais por segundo), portanto você precisa de controle de fluxo e processamento incremental, em vez de armazenar tudo em memória. A Streams API do WHATWG foi projetada exatamente para esse tipo de processamento atômico de chunks por meio de cadeias de pipes. O MediaStreamTrackProcessor conecta uma track ao vivo a um stream; o TransformStream é onde ocorre o trabalho por frame; o VideoTrackGenerator reconecta ao restante da plataforma — <video>, RTCPeerConnection — que entende tracks.

O WebCodecs opera apenas em streams sem contêiner. Se você precisar ler ou gravar MP4/ISOBMFF, deverá fornecer sua própria lógica de contêiner. O áudio possui uma superfície paralela (AudioData, AudioEncoder) que não será abordada neste artigo; os padrões a seguir são específicos para vídeo.

Um Pipeline Funcional de Câmera → Filtro → Exibição

Um pipeline de filtro WebCodecs funcional captura com MediaStreamTrackProcessor, aplica filtros dentro de um TransformStream usando Canvas2D diretamente no VideoFrame, e exibe por meio do VideoTrackGenerator — a estrutura mostrada no bloco de código inicial. O movimento de eficiência fundamental é ctx.drawImage(frame, 0, 0)drawImage aceita um VideoFrame diretamente como fonte, permitindo que você desenhe frames em um canvas sem convertê-los manualmente para PNG ou criar um ImageBitmap intermediário.

Para um filtro de cor com Canvas2D, a string ctx.filter é o caminho mais barato. Para qualquer coisa que exija acesso a pixels individuais — chroma key, convolução personalizada — utilize 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();
    }
  },
});

Dois atributos são transportados do frame original para o novo: timestamp e duration. O timestamp é a identidade do frame ao longo de todo o pipeline — ele sobrevive a ciclos de codificação/decodificação e é o que você utiliza para medir a latência posteriormente. Descartá-lo faz com que os consumidores downstream percam a ordenação dos frames.

Para trabalho mais intenso por pixel em resolução completa, a leitura via getImageData é o gargalo; WebGL ou WebGPU (por meio de importExternalTexture) mantêm o frame na GPU e evitam completamente a leitura pela CPU. Use Canvas2D para transformações de cor e composição simples; recorra a um caminho via GPU quando o custo por pixel dominar seu orçamento de frame.

O Ciclo de Vida do VideoFrame: Por que frame.close() é Obrigatório

Esquecer frame.close() não apenas vaza memória comum — isso esgota os recursos de mídia finitos dos quais o pipeline depende, e uma vez esgotados, a decodificação ou a emissão de frames trava porque nenhum novo frame pode ser alocado ou emitido, produzindo o sintoma característico de um vídeo que engasga progressivamente e depois congela, enquanto o restante da página permanece responsivo. VideoFrame.close() libera o recurso de mídia subjacente que o frame ocupa, e a especificação WebCodecs é explícita ao afirmar que esses recursos são finitos — frames respaldados por buffers de hardware provêm de um pool limitado, e uma fonte não pode emitir um novo frame quando o pool está cheio.

É por isso que close() não é uma limpeza opcional que pode ser delegada ao coletor de lixo. O coletor de lixo não tem conhecimento do recurso de mídia subjacente em seu próprio ciclo, e quando ele é executado, o pool já está esgotado. Todo VideoFrame lido do processor, e todo aquele que você construir, deve ser fechado exatamente uma vez quando você terminar de utilizá-lo.

A falha não óbvia ocorre no caminho de erro. Se sua transformação lançar uma exceção após ler um frame, mas antes de fechá-lo, esse frame vaza — e uma transformação que lança exceção em um frame geralmente lança no próximo, fazendo o vazamento se acumular rapidamente. A solução é try/finally:

async transform(frame, controller) {
  try {
    // ...trabalho de filtro que pode lançar exceção...
    controller.enqueue(newFrame);
  } finally {
    frame.close(); // executado independentemente de o corpo ter lançado exceção ou não
  }
}

O finally garante que frame.close() seja executado tanto no caminho de sucesso quanto no de erro. Este é o padrão mais importante em um pipeline WebCodecs.

Backpressure: Por que Transformações Lentas Descartam Frames Silenciosamente

MediaStreamTrackProcessor não propaga backpressure para upstream. Quando seu TransformStream fica para trás, o processor descarta silenciosamente os frames mais antigos em vez de desacelerar a câmera, e você nunca verá um erro — apenas frames ausentes. A consequência prática: uma transformação que leva 50ms por frame em uma fonte de 30fps (orçamento de 33ms) não lançará erro nem enfileirará indefinidamente. Ela simplesmente rodará silenciosamente a cerca de 20fps, com a diferença sendo descartada. Você pode detectar isso monitorando a fila do lado legível de dentro da transformação. O TransformStreamDefaultController.desiredSize reflete o estado de backpressure do lado legível — quando fica negativo, o lado legível ultrapassou sua marca d’água máxima e o consumidor está atrasado:

const filter = new TransformStream({
  async transform(frame, controller) {
    try {
      if (controller.desiredSize !== null && controller.desiredSize < 0) {
        // O consumidor está atrasado. Descarte este frame intencionalmente
        // em vez de ficar ainda mais para trás.
        return;
      }
      // ...trabalho de filtro...
      controller.enqueue(newFrame);
    } finally {
      frame.close();
    }
  },
});

Ao detectar backpressure, você tem dois recursos. Descarte intencionalmente — ignore o frame atual, como acima, para que uma cadência deliberada substitua a perda aleatória silenciosa. Ou reduza a entrada: solicite uma resolução ou taxa de frames menor via getUserMedia usando MediaTrackConstraints, ou chame track.applyConstraints() para reduzir em tempo de execução. Diminuir a resolução reduz diretamente o trabalho por pixel em cada frame e geralmente é a correção mais eficaz para um filtro limitado pela CPU.

Workers: Por que Realizar Todo o Trabalho com VideoFrame em um Único Worker?

Realize todo o trabalho com VideoFrame em um único worker. Quando um VideoFrame é transferido entre workers via postMessage, a referência do lado remetente é fechada automaticamente, e qualquer tentativa de lê-lo ou fechá-lo novamente lança uma exceção — uma condição de corrida silenciosa que é quase impossível de depurar entre filas de mensagens de workers. Frames dentro de streams transferidos são serializados, o que os clona e exige fechamento explícito em ambos os lados. Misture os dois e você terá a falha de fechamento prematuro:

controller.enqueue(frame);
frame.close(); // Cedo demais — enqueue é assíncrono; o frame pode ainda estar em trânsito

Como controller.enqueue() é assíncrono em relação ao worker consumidor, fechar a referência do remetente cedo demais causa falhas de serialização, enquanto nunca fechá-la causa o vazamento seguido de congelamento descrito anteriormente. Mantenha toda a cadeia MediaStreamTrackProcessorTransformStreamVideoTrackGenerator dentro de um único worker e você evita completamente o problema de propriedade. (Para enviar chunks codificados para fora do dispositivo — WebTransport, canais de dados — consulte a série de pipelines do webrtcHacks; esse é um tópico à parte.)

Quando você precisar passar um frame para um worker — para alimentar o pipeline, não para dividi-lo — transfira-o explicitamente e pare de tocá-lo no lado remetente:

// Thread principal
worker.postMessage({ frame }, { transfer: [frame] });
// `frame` está agora inutilizável aqui. Não o leia nem o feche na thread principal.

Após uma transferência, o worker receptor é dono do frame e é responsável por fechá-lo. A thread remetente deve tratar sua referência como inexistente.

Codificação para Transmissão ou Gravação

Um VideoEncoder comprime objetos VideoFrame brutos em objetos EncodedVideoChunk, entregues por meio de um callback de saída para gravação ou transmissão. Configure-o com uma string de codec, dimensões, bitrate e taxa de frames:

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

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

O callback output fornece um EncodedVideoChunk mais metadados opcionais; o chunk carrega seu type ('key' ou 'delta'), timestamp, duration e os bytes codificados. Para strings de codec, consulte o registro de codecs do WebCodecs e o guia de codecs do MDN em vez de tentar adivinhar strings de perfil AVC.

Solicite um keyframe com encoder.encode(frame, { keyFrame: true }) (note o F maiúsculo) quando precisar de um intra frame, como no início de um stream, após um seek, ou em um ponto de recuperação — codificar cada frame como keyframe elimina completamente a compressão entre frames e aumentará significativamente seu bitrate. A grafia da opção está documentada no guia Using the WebCodecs API do MDN.

Recuperando-se de um Encoder Fechado

Quando o callback de erro do VideoEncoder é acionado e o encoder transita para o estado 'closed', ele não pode ser reutilizado. VideoEncoder.reset() existe para casos não terminais, mas a recuperação de um encoder fechado significa construir uma nova instância e chamar configure() novamente com os mesmos parâmetros. Verifique o estado antes de cada encode() e reconstrua quando fechado:

function encodeFrame(frame, keyFrame = false) {
  if (encoder.state === 'closed') {
    encoder = makeEncoder();   // constrói + configura um novo VideoEncoder
  }
  if (encoder.state === 'configured') {
    encoder.encode(frame, { keyFrame });
  }
}

Proteger encode() com uma verificação de estado e um caminho de reconstrução é o que mantém uma sessão de longa duração ativa após um erro transitório de codec.

Suporte dos Navegadores em 2026

O suporte ao WebCodecs varia por interface, e tratá-lo como um único número de versão é o erro que todo tutorial desatualizado comete. As interfaces principais VideoEncoder/VideoFrame estão amplamente disponíveis; as partes de Insertable Streams — MediaStreamTrackProcessor e VideoTrackGenerator — seguem um cronograma diferente e mais lento.

InterfaceChrome / EdgeFirefoxSafari
VideoEncoder / VideoFrame (WebCodecs principal)94+130+16.4+
MediaStreamTrackProcessor94+Sem suporte18+
VideoTrackGeneratorSem suporteSem suporte18+
MediaStreamTrackGenerator (não padronizado)94+Sem suporteSem suporte

Verificado com base nos dados de compatibilidade do MDN para VideoEncoder e MediaStreamTrackProcessor. A ressalva genérica de que “o Safari não suporta WebCodecs”, presente na maioria dos tutoriais, está desatualizada e é imprecisa: o Safari suporta o WebCodecs principal desde a versão 16.4, com suporte expandido a codecs (incluindo HEVC) no Safari 17.4. O que o Safari e o Firefox não possuem é a camada de captura/saída de Insertable Streams — portanto, o pipeline câmera → filtro → exibição descrito acima funciona hoje no Chromium e no Safari 18+ quando implementado em um worker dedicado, mas no Firefox você pode codificar e decodificar frames enquanto os obtém de outra forma.

A conclusão prática: detecte recursos por interface, não por navegador. Verifique window.MediaStreamTrackProcessor e window.VideoEncoder separadamente, e tenha um fallback com Canvas/requestVideoFrameCallback para a camada de captura onde as partes de Insertable Streams estiverem ausentes.

Lista de Verificação para Depuração

Os três modos de falha em um pipeline WebCodecs — frames descartados, memória descontrolada e picos de latência — têm cada um um sintoma distinto e uma etapa de diagnóstico direta.

SintomaCausa provávelEtapa de diagnóstico
Engasgos progressivos seguidos de congelamento, restante da página responsivoframe.close() ausente em algum caminho → recursos de mídia finitos esgotadosAudite cada VideoFrame lido ou construído para garantir exatamente um close(); confirme a cobertura com try/finally
Frames ausentes, sem erros no consoleTransformação lenta; processor descartando frames mais antigos silenciosamenteRegistre controller.desiredSize dentro da transformação; se tender para negativo, o consumidor está atrasado
Latência aumenta ao longo do tempoFiltro por pixel lento consumindo o orçamento de frameMeça a duração por etapa; compare com o orçamento da sua taxa de frames (33ms a 30fps)
Encoder para de produzir chunksVideoEncoder entrou no estado 'closed' após um erroVerifique encoder.state antes de cada encode(); reconstrua quando 'closed'

A assinatura de engasgo seguido de congelamento vale ser reconhecida de imediato. Replays de sessão de funcionalidades baseadas em WebCodecs expõem esse padrão de forma confiável: vídeo fluido que toca normalmente, depois começa a perder frames visivelmente, depois congela completamente enquanto o restante da interface permanece interativo. Essa é a assinatura visível do esgotamento de recursos de mídia finitos por frames não fechados — o replay mostra o sintoma claramente, mas a causa é invisível sem saber que se deve procurar um frame não fechado em algum lugar no código do pipeline.

Para medir a latência real de ponta a ponta — da captura pela câmera até a exibição — codifique o timestamp do frame como uma sobreposição de pixel antes do pipeline e decodifique-o a partir da saída renderizada via requestVideoFrameCallback. Como ponto de calibração, o benchmark de pipeline do webrtcHacks (março de 2023) reportou os seguintes custos por frame:

EtapaDuração
Remoção de plano de fundo22ms
Adição de sobreposição1ms
Codificação8ms
Decodificação1ms
Exibição38ms

Seus números variarão conforme o hardware e a complexidade do filtro. O resultado notável é que a exibição por si só representa ~38ms — o termo dominante — o que significa que um filtro que se encaixa confortavelmente no orçamento de 30fps ainda pode parecer lento se você não considerar a cauda de exibição. Meça o caminho completo, não apenas sua transformação.

Conclusão

A estrutura do pipeline WebCodecs — MediaStreamTrackProcessorTransformStreamVideoTrackGenerator — é pequena o suficiente para caber em um único bloco de código, mas a distância entre uma demonstração e uma funcionalidade publicável está inteiramente nos modos de falha: fechar cada frame, detectar backpressure silencioso, manter toda a cadeia em um único worker, recuperar-se de um encoder fechado e detectar recursos por interface em vez de por navegador. Comece com o exemplo try/finally no início deste artigo, adicione a verificação de desiredSize e a proteção de estado do encoder, e você terá um pipeline que sobrevive aos casos que os tutoriais de caminho feliz nunca alcançam.

Perguntas Frequentes

Quando devo usar Canvas2D versus WebGL ou WebGPU para um filtro WebCodecs?

Use Canvas2D para transformações de cor e composição simples, onde strings ctx.filter ou loops modestos de getImageData se encaixam no orçamento de frame. Recorra ao WebGL ou WebGPU quando o custo por pixel dominar, pois eles mantêm o frame na GPU via importExternalTexture e evitam a leitura pela CPU que getImageData força. Em resolução completa, essa leitura geralmente é o gargalo, portanto um caminho via GPU é a solução para trabalho intenso por pixel, como chroma keying.

Por que meus VideoFrames fecham inesperadamente quando os passo entre workers?

Transferir um VideoFrame entre workers via postMessage fecha automaticamente a referência do lado remetente, de modo que qualquer tentativa de lê-lo ou fechá-lo novamente no remetente lança uma exceção. Isso difere de frames dentro de streams transferidos, que são serializados e clonados e exigem fechamento explícito em ambos os lados. Para evitar a condição de corrida, mantenha todo o pipeline em um único worker, ou após uma transferência, trate a referência do remetente como inexistente e deixe o worker receptor ser o dono e fechar o frame.

O pipeline câmera → filtro → exibição funciona no Firefox?

Não completamente. O Firefox 130 e versões posteriores suportam as interfaces principais VideoEncoder e VideoFrame, mas não suportam a camada de captura e saída de Insertable Streams, o que significa que MediaStreamTrackProcessor e VideoTrackGenerator não estão disponíveis. Você pode codificar e decodificar frames no Firefox, mas deve obter os frames de outra forma, como um Canvas com requestVideoFrameCallback. Detecte recursos por interface verificando window.MediaStreamTrackProcessor e window.VideoEncoder separadamente, em vez de testar o navegador.

Qual é a diferença entre VideoEncoder.reset() e reconstruir o encoder?

VideoEncoder.reset() lida com casos não terminais, limpando o trabalho pendente em um encoder que ainda é utilizável. Ele não consegue recuperar um encoder que transitou para o estado fechado após um erro, porque um encoder fechado não pode ser reconfigurado ou reutilizado. A recuperação de um estado fechado significa construir uma nova instância de VideoEncoder e chamar configure() novamente com os mesmos parâmetros. Verifique encoder.state antes de cada encode() e reconstrua quando ele indicar closed.

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.