Videoverarbeitung mit WebCodecs

Videostream-Komponenten manipulieren

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

Moderne Webtechnologien bieten zahlreiche Möglichkeiten, mit Video zu arbeiten. Die Media Stream API, die Media Recording API, die Media Source API und die WebRTC API bilden ein umfangreiches Tool-Set zum Aufzeichnen, Übertragen und Abspielen von Videostreams. Diese APIs eignen sich zwar für bestimmte allgemeine Aufgaben, aber Webentwickler können damit nicht mit einzelnen Komponenten eines Videostreams wie Frames und nicht demuxten Chunks von codiertem Video oder Audio arbeiten. Um Low-Level-Zugriff auf diese grundlegenden Komponenten zu erhalten, haben Entwickler WebAssembly verwendet, um Video- und Audio-Codecs in den Browser einzubinden. Da moderne Browser jedoch bereits eine Vielzahl von Codecs enthalten, die oft durch Hardware beschleunigt werden, erscheint es als Verschwendung von Personal- und Computerressourcen, sie als WebAssembly neu zu verpacken.

Die WebCodecs API beseitigt diese Ineffizienz, indem sie Programmierern die Möglichkeit bietet, Medienkomponenten zu verwenden, die bereits im Browser vorhanden sind. Im Detail:

  • Video- und Audiodekoder
  • Video- und Audioencoder
  • Unbearbeitete Videoframes
  • Bilddekoder

Die WebCodecs API ist nützlich für Webanwendungen, bei denen die Verarbeitung von Medieninhalten vollständig gesteuert werden muss, z. B. Video-Editoren, Videokonferenzen und Videostreaming.

Workflow für die Videoverarbeitung

Frames sind das Herzstück der Videoverarbeitung. Daher verbrauchen oder produzieren die meisten Klassen in WebCodecs Frames. Video-Encoder wandeln Frames in codierte Chunks um. Videodecoder machen das Gegenteil.

Außerdem lässt sich VideoFrame gut mit anderen Web-APIs kombinieren, da es sich um eine CanvasImageSource handelt und einen Konstruktor hat, der CanvasImageSource akzeptiert. Sie kann daher in Funktionen wie drawImage() und texImage2D() verwendet werden. Außerdem können sie aus Canvases, Bitmaps, Videoelementen und anderen Videoframes erstellt werden.

Die WebCodecs API funktioniert gut in Kombination mit den Klassen aus der Insertable Streams API, die WebCodecs mit Mediastream-Tracks verbinden.

  • MediaStreamTrackProcessor teilt Medientracks in einzelne Frames auf.
  • MediaStreamTrackGenerator erstellt einen Medientrack aus einem Frame-Stream.

WebCodecs und Webworker

Die WebCodecs API erledigt alle Aufgaben asynchron und außerhalb des Hauptthreads. Da Frame- und Chunk-Callbacks jedoch oft mehrmals pro Sekunde aufgerufen werden können, können sie den Hauptthread überlasten und so die Reaktionsfähigkeit der Website beeinträchtigen. Daher ist es besser, die Verarbeitung einzelner Frames und codierter Chunks in einen Webworker zu verschieben.

Dazu bietet ReadableStream eine praktische Möglichkeit, alle Frames, die aus einem Medientrack stammen, automatisch an den Worker zu übertragen. Mit MediaStreamTrackProcessor kann beispielsweise ein ReadableStream für einen Mediastream-Track abgerufen werden, der von der Webcam stammt. Danach wird der Stream an einen Webworker übertragen, in dem die Frames einzeln gelesen und in einer VideoEncoder-Warteschlange angeordnet werden.

Mit HTMLCanvasElement.transferControlToOffscreen kann sogar das Rendering außerhalb des Hauptthreads erfolgen. Wenn sich jedoch alle Tools auf höherer Ebene als unpraktisch erweisen, kann VideoFrame selbst übertragen und zwischen Mitarbeitern verschoben werden.

WebCodecs in Aktion

Codierung

Der Pfad von einem Canvas oder einer ImageBitmap zum Netzwerk oder zum Speicher
Der Pfad von einem Canvas oder ImageBitmap zum Netzwerk oder zum Speicher

Alles beginnt mit einer VideoFrame. Es gibt drei Möglichkeiten, Videoframes zu erstellen.

  • Aus einer Bildquelle wie einem Canvas, einer Bild-Bitmap oder einem Videoelement

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • Mit MediaStreamTrackProcessor Frames aus einer MediaStreamTrack abrufen

    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;
    }
    
  • Frame aus der binären Pixeldarstellung in einer BufferSource erstellen

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

Unabhängig von ihrer Herkunft können Frames mit einem VideoEncoder in EncodedVideoChunk-Objekte codiert werden.

Vor der Codierung müssen VideoEncoder zwei JavaScript-Objekte zugewiesen werden:

  • Wörterbuch mit zwei Funktionen zum Umgang mit codierten Chunks und Fehlern initialisieren. Diese Funktionen werden vom Entwickler definiert und können nicht mehr geändert werden, nachdem sie an den VideoEncoder-Konstruktor übergeben wurden.
  • Encoder-Konfigurationsobjekt, das Parameter für den Ausgabevideostream enthält. Sie können diese Parameter später ändern, indem Sie configure() aufrufen.

Die configure()-Methode löst NotSupportedError aus, wenn die Konfiguration vom Browser nicht unterstützt wird. Wir empfehlen, die statische Methode VideoEncoder.isConfigSupported() mit der Konfiguration aufzurufen, um vorher zu prüfen, ob die Konfiguration unterstützt wird, und auf das entsprechende Promise zu warten.

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

Nachdem der Encoder eingerichtet wurde, kann er Frames über die encode()-Methode akzeptieren. Sowohl configure() als auch encode() werden sofort zurückgegeben, ohne auf den Abschluss der eigentlichen Arbeit zu warten. So können mehrere Frames gleichzeitig zur Codierung in die Warteschlange gestellt werden. Mit encodeQueueSize wird angezeigt, wie viele Anfragen in der Warteschlange auf die Fertigstellung der vorherigen Codierungen warten. Fehler werden entweder durch sofortiges Auslösen einer Ausnahme gemeldet, wenn die Argumente oder die Reihenfolge der Methodenaufrufe gegen den API-Vertrag verstoßen, oder durch Aufrufen des error()-Callbacks bei Problemen bei der Codec-Implementierung. Wenn die Codierung erfolgreich abgeschlossen wurde, wird der output()-Callback mit einem neuen codierten Chunk als Argument aufgerufen. Ein weiteres wichtiges Detail ist, dass Frames durch Aufrufen von close() mitgeteilt werden müssen, wenn sie nicht mehr benötigt werden.

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

Jetzt ist es an der Zeit, den Codierungscode fertigzustellen. Schreiben Sie dazu eine Funktion, die die codierten Video-Chunks verarbeitet, sobald sie aus dem Encoder kommen. Normalerweise sendet diese Funktion Datenblöcke über das Netzwerk oder muxt sie zu einem Mediencontainer für die Speicherung zusammen.

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

Wenn Sie sich vergewissern möchten, dass alle ausstehenden Codierungsanfragen abgeschlossen wurden, können Sie flush() aufrufen und auf die Zusicherung warten.

await encoder.flush();

Dekodierung

Der Pfad vom Netzwerk oder Speicher zu einem Canvas oder einer ImageBitmap.
Der Pfad vom Netzwerk oder Speicher zu einem Canvas oder ImageBitmap.

Die Einrichtung eines VideoDecoder ähnelt der Einrichtung eines VideoEncoder: Beim Erstellen des Decoders werden zwei Funktionen übergeben und configure() werden Codec-Parameter zugewiesen.

Die Codec-Parameter variieren je nach Codec. Für den H.264-Codec ist beispielsweise ein binärer Blob von AVCC erforderlich, es sei denn, er ist im sogenannten Annex-B-Format (encoderConfig.avc = { format: "annexb" }) codiert.

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

Sobald der Decoder initialisiert ist, kannst du ihm EncodedVideoChunk-Objekte zuführen. Zum Erstellen eines Chunks benötigen Sie Folgendes:

  • BufferSource codierte Videodaten
  • Der Startzeitstempel des Chunks in Mikrosekunden (Medienzeit des ersten codierten Frames im Chunk)
  • den Typ des Chunks, einer der folgenden:
    • key, wenn der Block unabhängig von vorherigen Blöcken decodiert werden kann
    • delta, wenn der Block erst decodiert werden kann, nachdem ein oder mehrere vorherige Blöcke decodiert wurden

Außerdem sind alle vom Encoder gesendeten Chunks direkt für den Decoder bereit. Alles, was oben über die Fehlermeldung und die asynchrone Natur der Encoder-Methoden gesagt wurde, gilt auch für 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();

Jetzt ist es an der Zeit, zu zeigen, wie ein frisch decodierter Frame auf der Seite angezeigt werden kann. Es ist besser, dafür zu sorgen, dass der Decoder-Ausgabe-Callback (handleFrame()) schnell zurückgegeben wird. Im folgenden Beispiel wird der Warteschlange der Frames, die zum Rendern bereit sind, nur ein Frame hinzugefügt. Das Rendering erfolgt separat und besteht aus zwei Schritten:

  1. Wartet auf den richtigen Zeitpunkt, um den Frame anzuzeigen.
  2. Zeichnen Sie den Frame auf dem Canvas.

Wenn ein Frame nicht mehr benötigt wird, rufen Sie close() auf, um den zugrunde liegenden Arbeitsspeicher freizugeben, bevor der Garbage Collector ihn erreicht. Dadurch wird der durchschnittliche Arbeitsspeicherverbrauch der Webanwendung reduziert.

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

Tipps für Entwickler

Im Media Panel in den Chrome-Entwicklertools können Sie sich Medienprotokolle ansehen und WebCodecs beheben.

Screenshot des Media-Steuerfelds zum Debuggen von WebCodecs
Media Panel in den Chrome-Entwicklertools zum Debuggen von WebCodecs

Demo

In der folgenden Demo wird gezeigt, wie Animationsframes aus einem Canvas:

  • von MediaStreamTrackProcessor mit 25 fps in einer ReadableStream aufgenommen
  • an einen Webworker übertragen wird.
  • im H.264-Videoformat codiert
  • wieder in eine Sequenz von Videoframes decodiert.
  • und mit transferControlToOffscreen() auf dem zweiten Canvas gerendert.

Andere Demos

Sehen Sie sich auch unsere anderen Demos an:

WebCodecs API verwenden

Funktionserkennung

So prüfen Sie, ob WebCodecs unterstützt werden:

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

Die WebCodecs API ist nur in sicheren Kontexten verfügbar. Die Erkennung schlägt daher fehl, wenn self.isSecureContext falsch ist.

Feedback

Das Chrome-Team möchte mehr über Ihre Erfahrungen mit der WebCodecs API erfahren.

Informationen zum API-Design

Funktioniert die API nicht wie erwartet? Fehlen Methoden oder Eigenschaften, die Sie für die Implementierung Ihrer Idee benötigen? Haben Sie Fragen oder Kommentare zum Sicherheitsmodell? Reichen Sie ein Problem mit der Spezifikation im entsprechenden GitHub-Repository ein oder fügen Sie Ihre Gedanken zu einem vorhandenen Problem hinzu.

Problem mit der Implementierung melden

Haben Sie einen Fehler in der Chrome-Implementierung gefunden? Oder unterscheidet sich die Implementierung von der Spezifikation? Melden Sie den Fehler unter new.crbug.com. Geben Sie dabei so viele Details wie möglich an, eine einfache Anleitung zur Reproduktion und geben Sie Blink>Media>WebCodecs in das Feld Components ein. Glitch eignet sich hervorragend, um schnell und einfach Reproduktionen zu teilen.

Unterstützung für die API anzeigen

Beabsichtigen Sie, die WebCodecs API zu verwenden? Ihre öffentliche Unterstützung hilft dem Chrome-Team, Funktionen zu priorisieren, und zeigt anderen Browseranbietern, wie wichtig es ist, sie zu unterstützen.

Senden Sie eine E-Mail an media-dev@chromium.org oder einen Tweet an @ChromiumDev mit dem Hashtag #WebCodecs und teilen Sie uns mit, wo und wie Sie die Funktion verwenden.

Hero-Image von Denise Jans auf Unsplash.