Elaborazione video con WebCodecs

Manipolazione dei componenti dello stream video.

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

Le moderne tecnologie web offrono molti modi per lavorare con i video. Le API Media Stream, Media Recording, Media Source, e WebRTC costituiscono un ricco set di strumenti per la registrazione, il trasferimento e la riproduzione di stream video. Sebbene risolvano determinate attività di alto livello, queste API non consentono ai programmatori web di lavorare con i singoli componenti di uno stream video, come frame e blocchi non muxati di video o audio codificati. Per ottenere l'accesso di basso livello a questi componenti di base, gli sviluppatori hanno utilizzato WebAssembly per portare codec video e audio nel browser. Tuttavia, dato che i browser moderni sono già dotati di una varietà di codec (spesso accelerati dall'hardware), il loro riconfezionamento come WebAssembly sembra uno spreco di risorse umane e informatiche.

L'API WebCodecs elimina questa inefficienza fornendo ai programmatori un modo per utilizzare i componenti multimediali già presenti in nel browser. Nello specifico:

  • Decoder video e audio
  • Encoder video e audio
  • Frame video non elaborati
  • Decoder di immagini

L'API WebCodecs è utile per le applicazioni web che richiedono il controllo completo del modo in cui vengono elaborati i contenuti multimediali, come editor video, videoconferenze, streaming video e così via.

Workflow di elaborazione video

I frame sono l'elemento centrale dell'elaborazione video. Pertanto, in WebCodecs la maggior parte delle classi utilizza o produce frame. Gli encoder video convertono i frame in blocchi codificati. I decoder video fanno il contrario.

Inoltre, VideoFrame funziona bene con altre API web perché è un CanvasImageSource e ha un costruttore che accetta CanvasImageSource. Pertanto, può essere utilizzato in funzioni come drawImage() etexImage2D(). Inoltre, può essere costruito da canvas, bitmap, elementi video e altri frame video.

L'API WebCodecs funziona bene in tandem con le classi dell'API Insertable Streams che collegano WebCodecs alle tracce dello stream multimediale.

  • MediaStreamTrackProcessor suddivide le tracce multimediali in singoli frame.
  • MediaStreamTrackGenerator crea una traccia multimediale da uno stream di frame.

WebCodecs e web worker

Per progettazione, l'API WebCodecs esegue tutte le operazioni pesanti in modo asincrono e al di fuori del thread principale. Tuttavia, poiché i callback di frame e blocchi possono spesso essere chiamati più volte al secondo, potrebbero ingombrare il thread principale e quindi rendere il sito web meno reattivo. Pertanto, è preferibile spostare la gestione dei singoli frame e dei blocchi codificati in un web worker.

Per facilitare questa operazione, ReadableStream fornisce un modo pratico per trasferire automaticamente tutti i frame provenienti da una traccia multimediale al worker. Ad esempio, MediaStreamTrackProcessor può essere utilizzato per ottenere un ReadableStream per una traccia dello stream multimediale proveniente dalla webcam. Dopodiché, lo stream viene trasferito a un web worker in cui i frame vengono letti uno alla volta e messi in coda in un VideoEncoder.

Con HTMLCanvasElement.transferControlToOffscreen, anche il rendering può essere eseguito al di fuori del thread principale. Tuttavia, se tutti gli strumenti di alto livello si sono rivelati scomodi, VideoFrame stesso è trasferibile e può essere spostato tra i worker.

WebCodecs in azione

Codifica

Il percorso da una tela o da un'immagine bitmap alla rete o allo spazio di archiviazione
Il percorso da un Canvas o un ImageBitmap alla rete o allo spazio di archiviazione

Tutto inizia con un VideoFrame. Esistono tre modi per costruire i frame video.

  • Da un'origine immagine come un canvas, una bitmap immagine o un elemento video.

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • Utilizza MediaStreamTrackProcessor per estrarre i frame da un 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 frame dalla sua rappresentazione binaria dei pixel in un 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);
    

Indipendentemente dalla loro origine, i frame possono essere codificati in oggetti EncodedVideoChunk con un VideoEncoder.

Prima della codifica, a VideoEncoder devono essere forniti due oggetti JavaScript:

  • Dizionario di inizializzazione con due funzioni per la gestione di blocchi codificati ed errori. Queste funzioni sono definite dallo sviluppatore e non possono essere modificate dopo essere state passate al costruttore VideoEncoder.
  • Oggetto di configurazione dell'encoder, che contiene i parametri per lo stream video di output. Puoi modificare questi parametri in un secondo momento chiamando configure().

Il metodo configure() genererà NotSupportedError se la configurazione non è supportata dal browser. Ti consigliamo di chiamare il metodo statico VideoEncoder.isConfigSupported() con la configurazione per verificare in anticipo se la configurazione è supportata e attendere la relativa 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.
}

Una volta configurato l'encoder, è pronto ad accettare i frame tramite il metodo encode(). Sia configure() sia encode() vengono restituiti immediatamente senza attendere il completamento del lavoro effettivo. Consente di mettere in coda più frame per la codifica contemporaneamente, mentre encodeQueueSize mostra quante richieste sono in attesa nella coda per il completamento delle codifiche precedenti. Gli errori vengono segnalati generando immediatamente un'eccezione, nel caso in cui gli argomenti o l'ordine delle chiamate ai metodi violino il contratto API, oppure chiamando il callback error() per i problemi riscontrati nell'implementazione del codec. Se la codifica viene completata correttamente, viene chiamato il callback output() con un nuovo blocco codificato come argomento. Un altro dettaglio importante è che ai frame deve essere comunicato quando non sono più necessari chiamando 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();
  }
}

Infine, è il momento di completare il codice di codifica scrivendo una funzione che gestisce i blocchi di video codificati quando escono dall'encoder. In genere, questa funzione invia blocchi di dati sulla rete o li muxa in un container multimediale per l'archiviazione.

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 a un certo punto devi assicurarti che tutte le richieste di codifica in attesa siano state completate, puoi chiamare flush() e attendere la relativa promessa.

await encoder.flush();

Decodifica

Il percorso dalla rete o dallo spazio di archiviazione a un Canvas o un ImageBitmap.
Il percorso dalla rete o dallo spazio di archiviazione a un Canvas o un ImageBitmap.

La configurazione di un VideoDecoder è simile a quella eseguita per VideoEncoder: vengono passate due funzioni quando viene creato il decoder e i parametri del codec vengono forniti a configure().

Il set di parametri del codec varia da codec a codec. Ad esempio, il codec H.264 potrebbe richiedere un BLOB binario di AVCC, a meno che non sia codificato nel cosiddetto formato Annex 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 volta inizializzato il decoder, puoi iniziare a fornirgli oggetti EncodedVideoChunk. Per creare un blocco, devi avere:

  • Un BufferSource di dati video codificati
  • Il timestamp di inizio del blocco in microsecondi (tempo multimediale del primo frame codificato nel blocco)
  • Il tipo di blocco, uno dei seguenti:
    • key se il blocco può essere decodificato indipendentemente dai blocchi precedenti
    • delta se il blocco può essere decodificato solo dopo la decodifica di uno o più blocchi precedenti

Inoltre, tutti i blocchi emessi dall'encoder sono pronti per il decoder così come sono. Tutto ciò che è stato detto sopra sulla segnalazione degli errori e sulla natura asincrona dei metodi dell'encoder vale anche per i decoder.

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();

Ora è il momento di mostrare come un frame appena decodificato può essere visualizzato nella pagina. È meglio assicurarsi che il callback di output del decoder (handleFrame()) venga restituito rapidamente. Nell'esempio riportato di seguito, viene aggiunto solo un frame alla coda di frame pronti per il rendering. Il rendering avviene separatamente e prevede due passaggi:

  1. Attesa del momento giusto per mostrare il frame.
  2. Disegno del frame sul canvas.

Quando un frame non è più necessario, chiama close() per rilasciare la memoria sottostante prima che il garbage collector la raggiunga. In questo modo, la quantità media di memoria utilizzata dall'applicazione web verrà ridotta.

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

Suggerimenti per gli sviluppatori

Utilizza il riquadro Media in Chrome DevTools per visualizzare i log multimediali ed eseguire il debug di WebCodecs.

Screenshot del riquadro Media per il debug di WebCodecs
Riquadro Media in Chrome DevTools per il debug di WebCodecs.

Demo

La demo mostra come i frame di animazione di un canvas vengono:

  • Acquisiti a 25 fps in un ReadableStream da MediaStreamTrackProcessor
  • Trasferiti a un web worker
  • Codificati nel formato video H.264
  • Decodificati di nuovo in una sequenza di frame video
  • E sottoposti a rendering sul secondo canvas utilizzando transferControlToOffscreen()

Altre demo

Dai un'occhiata anche alle altre nostre demo:

Utilizzo dell'API WebCodecs

Rilevamento delle funzionalità

Per verificare il supporto di WebCodecs:

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

Tieni presente che l'API WebCodecs è disponibile solo in contesti sicuri, quindi il rilevamento non riuscirà se self.isSecureContext è false.

Scopri di più

Se non hai familiarità con WebCodecs, WebCodecs Fundamentals fornisce articoli approfonditi con molti esempi per aiutarti a saperne di più.

Feedback

Il team di Chrome vuole conoscere la tua esperienza con l'API WebCodecs.

Parlaci della progettazione dell'API

C'è qualcosa nell'API che non funziona come previsto? Oppure mancano metodi o proprietà che ti servono per implementare la tua idea? Hai una domanda o un commento sul modello di sicurezza? Invia un problema relativo alle specifiche nel repository GitHub corrispondente o aggiungi i tuoi pensieri a un problema esistente.

Segnala un problema relativo all'implementazione

Hai trovato un bug nell'implementazione di Chrome? Oppure l'implementazione è diversa dalle specifiche? Invia un bug all'indirizzo new.crbug.com. Assicurati di includere il maggior numero di dettagli possibile, istruzioni semplici per la riproduzione e inserisci Blink>Media>WebCodecs nella casella Componenti.

Mostra il tuo sostegno all'API

Intendi utilizzare l'API WebCodecs? Il tuo sostegno pubblico aiuta il team di Chrome a dare la priorità alle funzionalità e mostra ad altri fornitori di browser quanto sia fondamentale supportarle.

Invia un'email all'indirizzo media-dev@chromium.org o un tweet a @ChromiumDev utilizzando l'hashtag #WebCodecs e comunicaci dove e come lo utilizzi.