Videoverwerking met WebCodecs

Het manipuleren van componenten van een videostream.

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

Moderne webtechnologieën bieden talloze mogelijkheden om met video te werken. De Media Stream API , Media Recording API , Media Source API en WebRTC API vormen samen een uitgebreide set tools voor het opnemen, overdragen en afspelen van videostreams. Hoewel deze API's bepaalde taken op hoog niveau oplossen, stellen ze webprogrammeurs niet in staat om met individuele componenten van een videostream te werken, zoals frames en niet-gemuxte stukken gecodeerde video of audio. Om toegang te krijgen tot deze basiscomponenten op laag niveau, gebruiken ontwikkelaars WebAssembly om video- en audiocodecs in de browser te integreren. Maar aangezien moderne browsers al een verscheidenheid aan codecs bevatten (die vaak hardwarematig worden versneld), lijkt het herverpakken ervan als WebAssembly een verspilling van menselijke en computerbronnen.

De WebCodecs API elimineert deze inefficiëntie door programmeurs een manier te bieden om mediacomponenten te gebruiken die al in de browser aanwezig zijn. Concreet:

  • Video- en audiodecoders
  • Video- en audio-encoders
  • Onbewerkte videobeelden
  • Beelddecoders

De WebCodecs API is handig voor webapplicaties die volledige controle vereisen over de manier waarop mediacontent wordt verwerkt, zoals video-editors, videoconferenties, videostreaming, enzovoort.

Videobewerkingsworkflow

Beelden vormen de kern van videoverwerking. Daarom gebruiken of produceren de meeste klassen in WebCodecs beelden. Video-encoders zetten beelden om in gecodeerde blokken. Video-decoders doen het tegenovergestelde.

VideoFrame werkt ook goed samen met andere web-API's doordat het een CanvasImageSource is en een constructor heeft die CanvasImageSource accepteert. Het kan dus worden gebruikt in functies zoals drawImage() en texImage2D() . Bovendien kan het worden geconstrueerd uit canvases, bitmaps, video-elementen en andere videoframes.

De WebCodecs API werkt goed samen met de klassen van de Insertable Streams API , die WebCodecs verbinden met mediastreamsporen .

  • MediaStreamTrackProcessor splitst mediasporen op in afzonderlijke frames.
  • MediaStreamTrackGenerator maakt een mediaspoor aan vanuit een stroom frames.

WebCodecs en webworkers

De WebCodecs API voert, zoals bedoeld, alle zware taken asynchroon en buiten de hoofdthread uit. Omdat callbacks voor frames en chunks echter vaak meerdere keren per seconde worden aangeroepen, kunnen ze de hoofdthread belasten en de website daardoor minder responsief maken. Daarom is het beter om de verwerking van individuele frames en gecodeerde chunks naar een webworker te verplaatsen.

Om daarbij te helpen, biedt ReadableStream een ​​handige manier om automatisch alle frames van een mediatrack naar de worker over te dragen. Zo kan bijvoorbeeld MediaStreamTrackProcessor worden gebruikt om een ReadableStream te verkrijgen voor een mediastream van de webcamera. Daarna wordt de stream overgedragen naar een webworker waar de frames één voor één worden gelezen en in een VideoEncoder in de wachtrij worden geplaatst.

Met HTMLCanvasElement.transferControlToOffscreen kan zelfs het renderen buiten de hoofdthread plaatsvinden. Maar mochten al deze geavanceerde tools onhandig blijken, dan is VideoFrame zelf overdraagbaar en kan het tussen workers worden verplaatst.

WebCodecs in actie

Codering

Het pad van een Canvas of een ImageBitmap naar het netwerk of naar de opslag.
Het pad van een Canvas of een ImageBitmap naar het netwerk of naar de opslag.

Alles begint met een VideoFrame . Er zijn drie manieren om videoframes te maken.

  • Afkomstig van een beeldbron zoals een canvas, een bitmapafbeelding of een video-element.

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • Gebruik MediaStreamTrackProcessor om frames uit een MediaStreamTrack te halen.

    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;
    }
    
  • Maak een frame van de binaire pixelrepresentatie in een 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);
    

Ongeacht de herkomst kunnen frames met een VideoEncoder worden gecodeerd tot EncodedVideoChunk -objecten.

Voordat VideoEncoder kan coderen, heeft het twee JavaScript-objecten nodig:

  • Initialiseer een dictionary met twee functies voor het verwerken van gecodeerde chunks en fouten. Deze functies zijn door de ontwikkelaar gedefinieerd en kunnen niet meer worden gewijzigd nadat ze aan de VideoEncoder constructor zijn doorgegeven.
  • Het configuratieobject van de encoder bevat parameters voor de uitvoervideostream. U kunt deze parameters later wijzigen door de configure() aan te roepen.

De methode configure() genereert NotSupportedError als de configuratie niet door de browser wordt ondersteund. Het is raadzaam om de statische methode VideoEncoder.isConfigSupported() aan te roepen met de configuratie om vooraf te controleren of de configuratie wordt ondersteund en te wachten op de bevestiging.

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

Nadat de encoder is ingesteld, is deze klaar om frames te accepteren via encode() methode. Zowel configure() als encode() retourneren direct, zonder te wachten tot het daadwerkelijke werk is voltooid. Hierdoor kunnen meerdere frames tegelijkertijd in de wachtrij worden geplaatst voor codering, terwijl encodeQueueSize aangeeft hoeveel verzoeken er in de wachtrij staan ​​totdat eerdere coderingen zijn voltooid. Fouten worden gemeld door direct een uitzondering te gooien als de argumenten of de volgorde van methodeaanroepen het API-contract schenden, of door de error() callback aan te roepen voor problemen die zich voordoen in de codec-implementatie. Als de codering succesvol is voltooid, wordt de output() -callback aangeroepen met een nieuw gecodeerd fragment als argument. Een ander belangrijk detail is dat frames moeten worden aangegeven wanneer ze niet langer nodig zijn door close() aan te roepen.

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

Tot slot is het tijd om de code voor het coderen af ​​te ronden door een functie te schrijven die de gecodeerde videofragmenten verwerkt zodra ze uit de encoder komen. Normaal gesproken zou deze functie de datafragmenten via het netwerk versturen of ze in een mediacontainer multiplexen voor opslag.

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

Als je op een bepaald moment wilt controleren of alle lopende codeerverzoeken zijn voltooid, kun je flush() aanroepen en wachten op de promise.

await encoder.flush();

Ontcijfering

Het pad van het netwerk of de opslag naar een Canvas of een ImageBitmap.
Het pad van het netwerk of de opslag naar een Canvas of een ImageBitmap .

Het instellen van een VideoDecoder is vergelijkbaar met wat er voor de VideoEncoder is gedaan: er worden twee functies doorgegeven bij het aanmaken van de decoder, en codecparameters worden aan configure() gegeven.

De set codecparameters verschilt per codec. Zo kan de H.264-codec bijvoorbeeld een binaire blob van AVCC nodig hebben, tenzij deze is gecodeerd in het zogenaamde Annex B-formaat ( 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.
}

Zodra de decoder is geïnitialiseerd, kunt u deze voeden met EncodedVideoChunk -objecten. Om een ​​chunk te creëren, hebt u het volgende nodig:

  • Een BufferSource met gecodeerde videogegevens
  • De starttijdstempel van het blok in microseconden (mediatijd van het eerste gecodeerde frame in het blok)
  • Het type van het blok, een van de volgende:
    • key als het fragment onafhankelijk van voorgaande fragmenten kan worden gedecodeerd
    • delta als het blok pas kan worden gedecodeerd nadat een of meer voorgaande blokken zijn gedecodeerd.

Ook alle door de encoder gegenereerde datablokken zijn direct beschikbaar voor de decoder. Alles wat hierboven is gezegd over foutrapportage en het asynchrone karakter van de methoden van de encoder, geldt eveneens voor decoders.

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

Nu is het tijd om te laten zien hoe een zojuist gedecodeerd frame op de pagina kan worden weergegeven. Het is beter om ervoor te zorgen dat de callback-functie van de decoder ( handleFrame() ) snel terugkeert. In het onderstaande voorbeeld wordt alleen een frame toegevoegd aan de wachtrij met frames die klaar zijn om te worden weergegeven. Het renderen zelf gebeurt apart en bestaat uit twee stappen:

  1. Wachten op het juiste moment om het frame te laten zien.
  2. Het kader op het doek tekenen.

Zodra een frame niet meer nodig is, roept u close() aan om het onderliggende geheugen vrij te geven voordat de garbage collector het verwijdert. Dit vermindert de gemiddelde hoeveelheid geheugen die door de webapplicatie wordt gebruikt.

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

Ontwikkelaarstips

Gebruik het Mediapaneel in Chrome DevTools om medialogboeken te bekijken en WebCodecs te debuggen.

Schermafbeelding van het mediapaneel voor het debuggen van WebCodecs.
Het mediapaneel in Chrome DevTools voor het debuggen van WebCodecs.

Demo

De demo laat zien hoe animatieframes van een canvas eruitzien:

  • vastgelegd met 25 fps in een ReadableStream door MediaStreamTrackProcessor
  • overgedragen aan een webmedewerker
  • gecodeerd in H.264-videoformaat
  • opnieuw gedecodeerd tot een reeks videobeelden
  • en weergegeven op het tweede canvas met behulp van transferControlToOffscreen()

Andere demo's

Bekijk ook onze andere demo's:

De WebCodecs API gebruiken

Kenmerkdetectie

Om te controleren of WebCodecs wordt ondersteund:

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

Houd er rekening mee dat de WebCodecs API alleen beschikbaar is in beveiligde contexten , dus de detectie zal mislukken als self.isSecureContext onwaar is.

Leer meer

Als je nog niet bekend bent met WebCodecs, biedt WebCodecs Fundamentals diepgaande artikelen met veel voorbeelden om je te helpen meer te leren.

Feedback

Het Chrome-team wil graag meer horen over uw ervaringen met de WebCodecs API.

Vertel ons iets over het API-ontwerp.

Werkt er iets aan de API niet zoals je had verwacht? Of ontbreken er methoden of eigenschappen die je nodig hebt om je idee te implementeren? Heb je een vraag of opmerking over het beveiligingsmodel? Dien een specificatie-issue in op de bijbehorende GitHub-repository , of voeg je gedachten toe aan een bestaand issue.

Meld een probleem met de implementatie.

Heb je een bug gevonden in de implementatie van Chrome? Of wijkt de implementatie af van de specificatie? Meld een bug op new.crbug.com . Vermeld zoveel mogelijk details, eenvoudige instructies voor het reproduceren van het probleem en voer Blink>Media>WebCodecs in bij het veld Components .

Toon je steun voor de API

Ben je van plan de WebCodecs API te gebruiken? Jouw publieke steun helpt het Chrome-team bij het prioriteren van functies en laat andere browserleveranciers zien hoe belangrijk het is om ze te ondersteunen.

Stuur een e-mail naar media-dev@chromium.org of een tweet naar @ChromiumDev met de hashtag #WebCodecs en laat ons weten waar en hoe je het gebruikt.

Hoofdafbeelding door Denise Jans op Unsplash .