Przetwarzanie wideo za pomocą kodeków WebCodecs

manipulowanie komponentami strumienia wideo;

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

Nowoczesne technologie internetowe oferują wiele sposobów na pracę z filmami. Media Stream API, Media Recording API, Media Source APIWebRTC API tworzą bogaty zestaw narzędzi do nagrywania, przesyłania i odtwarzania strumieni wideo. Te interfejsy API nie pozwalają programistom internetowym pracować z poszczególnymi komponentami strumienia wideo, takimi jak ramki i niezmieszane fragmenty zakodowanego pliku wideo lub audio, ale umożliwiają wykonywanie pewnych ogólnych zadań. Aby uzyskać dostęp do tych podstawowych komponentów na niskim poziomie, deweloperzy używali WebAssembly do wprowadzania kodków wideo i dźwięku do przeglądarki. Jednak z uwagi na to, że nowoczesne przeglądarki korzystają już z różnych kodeków (które często są przyspieszane przez sprzęt), przepakowywanie ich, ponieważ WebAssembly wydaje się marnowaniem zasobów ludzkiej i komputerowej.

WebCodecs API eliminuje tę nieefektywność, dając programistom możliwość korzystania z komponentów multimedialnych, które są już obecne w przeglądarce. Oto najważniejsze kwestie:

  • Dekodery audio i wideo
  • Kodeki wideo i audio
  • Nieedytowane klatki wideo
  • Dekodery obrazów

Interfejs WebCodecs API jest przydatny w przypadku aplikacji internetowych, które wymagają pełnej kontroli nad sposobem przetwarzania treści multimedialnych, takich jak edytory wideo, konferencje wideo, strumieniowanie wideo itp.

Proces przetwarzania filmu

Ramki są kluczowym elementem przetwarzania wideo. W związku z tym w WebCodecs większość klas zużywa lub generuje klatki. Kodery wideo przekształcają klatki w zakodowane segmenty. Dekodery wideo działają w odwrotny sposób.

Ponadto VideoFrame współpracuje z innymi interfejsami API w sieci, ponieważ jest to obiekt typu CanvasImageSource i ma konstruktor, który akceptuje CanvasImageSource. Można go więc używać w funkcjach takich jak drawImage()texImage2D(). Możesz też utworzyć go na podstawie obrazów, bitmap, elementów wideo i innych klatek wideo.

Interfejs WebCodecs API dobrze współpracuje z klasami z interfejsu Insertable Streams API, które łączą WebCodecs z ścieżkami strumienia danych multimedialnych.

  • Funkcja MediaStreamTrackProcessor dzieli ścieżki multimedialne na pojedyncze klatki.
  • MediaStreamTrackGenerator tworzy ścieżkę multimedialną na podstawie strumienia klatek.

WebCodecs i procesy internetowe

Zgodnie z projektem interfejs WebCodecs API wykonuje wszystkie ciężkie zadania asynchronicznie i poza wątkiem głównym. Jednak ponieważ funkcje wywoływane po utworzeniu ramki lub fragmentu mogą być wywoływane wielokrotnie na sekundę, mogą one zaśmiecać główny wątek i w ten sposób zmniejszać responsywność witryny. Dlatego lepiej jest przenieść obsługę poszczególnych klatek i zakodowanych fragmentów do instancji roboczej przeglądarki.

W tym celu umożliwia ReadableStream w wygodny sposób na automatyczne przenoszenie wszystkich klatek ze ścieżki multimedialnej do instancji roboczej. Na przykład MediaStreamTrackProcessor może służyć do uzyskania ReadableStream dla ścieżki strumienia multimediów pochodzącego z kamery internetowej. Następnie strumień jest przekazywany do web workera, gdzie ramki są odczytywane pojedynczo i wstawiane do kolejki VideoEncoder.

Dzięki HTMLCanvasElement.transferControlToOffscreen renderowanie można przeprowadzać poza wątkiem głównym. Jeśli jednak okaże się, że korzystanie z tych narzędzi wysokiego poziomu jest niewygodne, VideoFrame można przenosić między pracownikami.

WebCodecs w praktyce

Kodowanie

Ścieżka z Canvasa lub ImageBitmap do sieci lub pamięci.
Ścieżka z Canvas lub ImageBitmap do sieci lub miejsca na dane

Wszystko zaczyna się od VideoFrame. Klatki wideo można tworzyć na 3 sposoby.

  • Z źródła obrazu, takiego jak kanwa, mapa bitowa obrazu lub element wideo.

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • Użyj MediaStreamTrackProcessor, aby pobrać ramki z 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;
    }
    
  • Utwórz ramkę z binarnej reprezentacji piksela w 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);
    

Niezależnie od tego, skąd pochodzą, ramki mogą być kodowane w obiektach EncodedVideoChunk za pomocą VideoEncoder.

Przed kodowaniem obiektowi VideoEncoder należy przekazać 2 obiekty JavaScript:

  • Inicjalizacja słownika z 2 funkcjami do obsługi zakodowanych fragmentów i błędów. Te funkcje są definiowane przez programistę i nie można ich zmienić po przekazaniu do konstruktora VideoEncoder.
  • Obiekt konfiguracji kodera, który zawiera parametry wyjściowego strumienia wideo. Te parametry możesz później zmienić, wywołując funkcję configure().

Jeśli przeglądarka nie obsługuje konfiguracji, metoda configure() zwróci wartość NotSupportedError. Zalecamy wywołanie metody statycznej VideoEncoder.isConfigSupported() z konfiguracją, aby sprawdzić, czy jest ona obsługiwana, i odczekać na obietnicę.

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

Po skonfigurowaniu koder jest gotowy do przyjmowania klatek za pomocą metody encode(). Zarówno configure(), jak i encode() wracają natychmiast bez czekania na zakończenie działania. Umożliwia to umieszczanie w kolejce kilku klatek do zakodowania w tym samym czasie, a encodeQueueSize pokazuje, ile żądań oczekuje w kolejce na zakończenie poprzednich kodowań. Błędy są zgłaszane albo przez natychmiastowe rzucenie wyjątku, jeśli argumenty lub kolejność wywołań metody naruszają kontrakt interfejsu API, albo przez wywołanie funkcji zwracającej wartością error() w przypadku problemów napotkanych podczas implementacji kodeka. Jeśli kodowanie zakończy się pomyślnie, wywoływana jest funkcja wywołania zwrotnego output() z nowym zakodowanym fragmentem jako argumentem. Kolejną ważną rzeczą jest to, że ramki muszą być informowane, kiedy nie są już potrzebne, przez wywołanie funkcji 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();
  }
}

Na koniec należy zakończyć kodowanie, pisząc funkcję, która będzie obsługiwać fragmenty zakodowanego filmu po ich wyjściu z enkodera. Zwykle ta funkcja polega na wysyłaniu fragmentów danych przez sieć lub zmiatania ich w kontenerze multimediów na potrzeby przechowywania.

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

Jeśli w którymś momencie będziesz musiał sprawdzić, czy wszystkie oczekujące żądania kodowania zostały zrealizowane, możesz zadzwonić pod numer flush() i poczekać na obietnicę.

await encoder.flush();

Dekodowanie

Ścieżka z sieci lub pamięci do Canvas lub ImageBitmap.
Ścieżka z sieci lub miejsca na dane do Canvas lub ImageBitmap.

Konfigurowanie VideoDecoder jest podobne do konfigurowania VideoEncoder: podczas tworzenia dekodera są przekazywane 2 funkcje, a parametry kodeka są przekazywane do configure().

Zestaw parametrów kodeka różni się w zależności od kodeka. Na przykład kodek H.264 może wymagać bloba binarnego AVCC, chyba że jest zakodowany w tak zwanym formacie załącznika 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.
}

Po zainicjowaniu dekodera możesz zacząć podawać mu obiekty EncodedVideoChunk. Aby utworzyć fragment, będziesz potrzebować:

  • BufferSource zakodowanych danych wideo
  • sygnatura czasowa początku fragmentu w mikrosekundach (czas trwania pierwszej zakodowanej klatki w danym fragmencie)
  • typ fragmentu:
    • key jeśli fragment można zdekodować niezależnie od poprzednich fragmentów.
    • delta jeśli fragment może zostać zdekodowany dopiero po dekodowaniu co najmniej jednego poprzedniego fragmentu.

Wszystkie informacje o raportowaniu błędów i niesynchronizowanym charakterze metod kodera są również prawdziwe w przypadku dekodera.

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

Teraz pokażę, jak można wyświetlić na stronie świeżo zdekodowaną ramkę. Lepiej jest zadbać o to, aby wywołanie zwrotne wyjścia dekodera (handleFrame()) było szybkie. W przykładzie poniżej dodaje on tylko jeden kadr do kolejki klatek gotowych do renderowania. Renderowanie odbywa się osobno i składa się z 2 etapów:

  1. Oczekiwanie na odpowiedni moment na wyświetlenie klatki.
  2. Rysowanie ramki na płótnie.

Gdy klatka nie będzie już potrzebna, wywołaj close(), aby zwolnić bazową pamięć, zanim nastąpi czyszczenie pamięci. Zmniejszy to średnią ilość pamięci używanej przez aplikację internetową.

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

Wskazówki dla programistów

Aby wyświetlać dzienniki multimediów i debugować WebCodecs, użyj panelu multimediów w Narzędziach deweloperskich w Chrome.

Zrzut ekranu z panelem multimediów do debugowania WebCodecs
Panel multimediów w Narzędziach deweloperskich w Chrome do debugowania WebCodecs.

Prezentacja

Demonstracja poniżej pokazuje, jak wyglądają klatki animacji z kanwy:

  • MediaStreamTrackProcessor zarejestrował film w formacie ReadableStream z szybkością 25 FPS
  • przeniesiono do zadania internetowego.
  • zakodowany w formacie wideo H.264;
  • dekodowany ponownie w sekwencję klatek wideo.
  • i wyrenderowany na drugim płótnie za pomocą transferControlToOffscreen()

Inne wersje demonstracyjne

Zobacz też inne wersje demonstracyjne:

Korzystanie z interfejsu WebCodecs API

Wykrywanie cech

Aby sprawdzić obsługę WebCodecs:

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

Pamiętaj, że interfejs WebCodecs API jest dostępny tylko w zabezpieczonym kontekście, więc wykrywanie nie powiedzie się, jeśli self.isSecureContext ma wartość fałsz.

Prześlij opinię

Zespół Chrome chce poznać Twoją opinię na temat interfejsu WebCodecs API.

Prześlij informacje o projektowaniu interfejsu API

Czy jest coś, co nie działa w interfejsie API zgodnie z oczekiwaniami? A może brakuje metod lub właściwości, których potrzebujesz, aby zrealizować swój pomysł? Masz pytanie lub komentarz dotyczący modelu zabezpieczeń? Zgłoś problem ze specyfikacją w odpowiednim repozytorium GitHub lub dodaj uwagi do istniejącego problemu.

Zgłaszanie problemów z implementacją

Czy znalazłeś/znalazłaś błąd w implementacji Chrome? Czy implementacja różni się od specyfikacji? Zgłoś błąd na stronie new.crbug.com. Pamiętaj, aby podać jak najwięcej szczegółów i proste instrukcje odtworzenia błędu. W polu Składniki wpisz Blink>Media>WebCodecs. Usługa Glitch świetnie nadaje się do szybkiego i łatwego udostępniania poprawek.

Pokaż wsparcie dla interfejsu API

Zamierzasz używać interfejsu WebCodecs API? Wasza pomoc publiczna pomaga zespołowi Chrome ustalać priorytety funkcji i pokazuje innym dostawcom przeglądarek, jak ważne jest, aby wspierać te funkcje.

Wyślij e-maila na adres media-dev@chromium.org lub wyślij tweeta do @ChromiumDev z użyciem hashtaga #WebCodecs i poinformuj nas, gdzie i jak go używasz.

Baner powitalny, Denise Jans na kanale Unsplash.