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 możliwości pracy z filmami. Interfejsów Media Stream API, Media record API, Media Source API i WebRTC API tworzy rozbudowany zestaw narzędzi do nagrywania, przesyłania i odtwarzania strumieni wideo. Podczas rozwiązywania niektórych ogólnych zadań, te interfejsy API nie pozwalają programistom internetowym na pracę z poszczególnymi komponentami strumienia wideo, takimi jak ramki i niezmodyfikowane fragmenty zakodowanego obrazu lub dźwięku. Aby uzyskać niskopoziomowy dostęp do tych podstawowych komponentów, programiści korzystają z WebAssembly, by zainstalować kodeki wideo i audio do przeglądarki. Nowoczesne przeglądarki zawierają jednak różne kodeki (często przyspieszane przez sprzęt), więc przepakowanie ich w taki sposób, że standard WebAssembly wydaje się marnować zasoby ludzkie i komputerowe.

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

  • Dekodery audio i wideo
  • Kodery audio i wideo
  • Nieprzetworzone klatki wideo
  • Dekodery obrazów

Interfejs WebCodecs API jest przydatny w aplikacjach 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

Klatki stanowią podstawę przetwarzania filmu. Dlatego w WebCodecs większość klas wykorzystuje lub tworzy ramki. Kodery wideo konwertują klatki na zakodowane fragmenty. Dekodery wideo działają odwrotnie.

Poza tym VideoFrame dobrze współpracuje z innymi interfejsami API – jest CanvasImageSource i ma konstruktor, który akceptuje CanvasImageSource. Można go więc używać w takich funkcjach jak drawImage() i texImage2D(). Można go również tworzyć z kanw, map bitowych, elementów wideo i innych klatek wideo.

Interfejs WebCodecs API działa dobrze w połączeniu z klasami z Insertable Streams API, które łączą WebCodecs ze ścieżkami strumienia multimediów.

  • MediaStreamTrackProcessor dzieli ścieżki multimediów na osobne klatki.
  • MediaStreamTrackGenerator tworzy ścieżkę multimediów ze strumienia ramek.

Kodeki internetowe i mechanizmy robocze

Interfejs WebCodecs API z założenia wykonuje całą pracę asynchronicznie i poza głównym wątkiem. Wywołania zwrotne ramek i fragmentów mogą być często wywoływane kilka razy na sekundę, co może powodować zaśmiecanie głównego wątku i sprawić, że witryna będzie mniej responsywna. Dlatego zaleca się przeniesienie obsługi poszczególnych klatek i zakodowanych fragmentów do mechanizmu Web Worker.

W tym celu dostępny jest ReadableStream, który umożliwia automatyczne przenoszenie wszystkich klatek pochodzących ze ścieżki multimediów do instancji roboczej. Za pomocą usługi MediaStreamTrackProcessor można na przykład uzyskać wartość ReadableStream dla ścieżki strumienia multimediów pochodzącej z kamery internetowej. Następnie strumień jest przekazywany do instancji roboczej internetowego, gdzie klatki są odczytywane jedna po drugiej i umieszczone w kolejce do elementu VideoEncoder.

HTMLCanvasElement.transferControlToOffscreen umożliwia renderowanie nawet poza wątkiem głównym. Jeśli jednak wszystkie ogólne narzędzia okazały się niewygodne, usługę VideoFrame można przenieść i może zostać przeniesiona między pracownikami.

Kodeki internetowe w praktyce

Kodowanie

Ścieżka z Canvas lub ImageBitmap do sieci lub miejsca na dane
Ścieżka z Canvas lub ImageBitmap do sieci lub miejsca na dane

Wszystko zaczyna się od VideoFrame. Istnieją 3 sposoby tworzenia klatek wideo.

  • Ze źródła obrazu, takiego jak obiekt canvas, bitmapa obrazu lub element wideo.

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • Użyj narzędzia MediaStreamTrackProcessor, aby pobrać klatki 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;
    }
    
  • Tworzenie ramki na podstawie jej reprezentacji piksela binarnego 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 można kodować do obiektów EncodedVideoChunk za pomocą VideoEncoder.

Przed kodowaniem VideoEncoder musi otrzymać 2 obiekty JavaScript:

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

Metoda configure() zgłosi NotSupportedError, jeśli konfiguracja nie jest obsługiwana przez przeglądarkę. Zachęcamy do wywołania metody statycznej VideoEncoder.isConfigSupported() z konfiguracją w celu wcześniejszego sprawdzenia, czy konfiguracja jest obsługiwana, i poczekania na jej 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 pierwotnej pracy. Pozwala na dodanie kilku klatek do kolejki kodowania jednocześnie, a encodeQueueSize pokazuje, ile żądań czeka w kolejce na zakończenie poprzedniego kodowania. Błędy są zgłaszane przez natychmiastowe zgłoszenie wyjątku (jeśli argumenty lub kolejność wywołań metody naruszają umowę interfejsu API), albo przez wywołanie wywołania zwrotnego error() w przypadku problemów, które wystąpiły w implementacji kodeka. Jeśli kodowanie zakończy się powodzeniem, wywołanie zwrotne output() zostanie wykonane z nowym zakodowanym fragmentem jako argumentem. Kolejną ważną wskazówką jest to, że ramki trzeba poinformować za pomocą wywołania close(), gdy nie są już potrzebne.

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, tworząc funkcję, która obsługuje fragmenty zakodowanego filmu wysyłane z kodera. Zwykle ta funkcja służy do wysyłania fragmentów danych przez sieć lub miksowania ich do kontenera 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 zechcesz sprawdzić, czy wszystkie oczekujące żądania kodowania zostały zrealizowane, możesz wywołać metodę flush() i poczekać na jej obietnicę.

await encoder.flush();

Dekodowanie

Ścieżka z sieci lub pamięci masowej do Canvas lub ImageBitmap.
Ścieżka z sieci lub pamięci masowej do Canvas lub ImageBitmap.

Konfigurowanie VideoDecoder przebiega podobnie jak w przypadku VideoEncoder: 2 funkcje są przekazywane podczas tworzenia dekodera, a parametry kodeka są przekazywane funkcji configure().

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

Po zainicjowaniu dekodera możesz zacząć dostarczać do niego obiekty EncodedVideoChunk. Aby utworzyć fragment, potrzebujesz:

  • BufferSource zakodowanych danych wideo
  • sygnatura czasowa rozpoczęcia fragmentu w mikrosekundach (czas multimediów pierwszej zakodowanej klatki we fragmencie).
  • jako typ fragmentu, a jedną z tych wartości:
    • key, jeśli fragment można odkodować niezależnie od poprzednich fragmentów
    • delta, jeśli fragment można zdekodować dopiero po odkodowaniu co najmniej 1 poprzedniego fragmentu

Poza tym wszystkie fragmenty wysyłane przez koder są gotowe do działania dekodera w niezmienionej postaci. Wszystkie powyższe informacje o raportowaniu błędów i asynchronicznym charakterze metod kodera są równie istotne w przypadku dekoderów.

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 możesz zobaczyć, jak może wyświetlić się na stronie świeżo zdekodowana ramka. Lepiej mieć pewność, że wyjściowe wywołanie zwrotne dekodera (handleFrame()) będzie szybko zwracane. W poniższym przykładzie dodaje ona tylko klatkę do kolejki ramek gotowych do renderowania. Renderowanie odbywa się oddzielnie i obejmuje 2 kroki:

  1. Czekam na odpowiedni moment, aby wyświetlić klatkę.
  2. Rysowanie ramki na płótnie.

Gdy ramka nie jest już potrzebna, wywołaj close(), aby zwolnić pamięć bazową, zanim trafi do niej moduł odśmiecania pamięci. Pozwoli to zmniejszyć ilość pamięci wykorzystywanej 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

Użyj panelu multimediów w Narzędziach deweloperskich w Chrome, aby wyświetlić dzienniki multimediów i debugować kod WebCodecs.

Zrzut ekranu przedstawiający panel multimediów do debugowania kodu WebCodecs
Panel multimediów w Narzędziach deweloperskich w Chrome do debugowania kodu WebCodecs.

Wersja demonstracyjna

Poniższy przykład pokazuje, jak wyglądają klatki animacji z obszaru roboczego:

  • zarejestrowany przy 25 kl./s w ReadableStream przez MediaStreamTrackProcessor
  • przeniesiono do instancji roboczej
  • zakodowane w formacie H.264
  • ponownie zakodowany w sekwencji klatek wideo
  • i wyrenderowano w drugim obszarze roboczym za pomocą transferControlToOffscreen()

Inne wersje demonstracyjne

Sprawdź też inne wersje demonstracyjne:

Korzystanie z interfejsu WebCodecs API

Wykrywanie funkcji

Aby sprawdzić obsługę WebCodecs:

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

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

Prześlij opinię

Zespół Chrome chce poznać Twoją opinię o korzystaniu z interfejsu WebCodecs API.

Opowiedz nam o projekcie 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 do realizacji swojego pomysłu? Masz pytanie lub komentarz na temat modelu zabezpieczeń? Zgłoś problem ze specyfikacją w odpowiednim repozytorium GitHub lub dodaj swoje uwagi do istniejącego problemu.

Zgłoś problem z implementacją

Czy wystąpił błąd związany z implementacją przeglądarki Chrome? A może implementacja różni się od specyfikacji? Zgłoś błąd na stronie new.crbug.com. Podaj jak najwięcej szczegółów, proste instrukcje odtwarzania i wpisz Blink>Media>WebCodecs w polu Komponenty. Usterki to świetny sposób na udostępnianie szybkich i łatwych replik.

Pokaż obsługę interfejsu API

Czy zamierzasz używać interfejsu WebCodecs API? Twoja publiczna pomoc pomaga zespołowi Chrome priorytetowo traktować funkcje i pokazuje innym dostawcom przeglądarek, jak ważne jest ich wsparcie.

Wyślij e-maile na adres media-dev@chromium.org lub wyślij tweeta na adres @ChromiumDev, używając hashtagu #WebCodecs, i daj nam znać, gdzie i jak używasz tego elementu.

Baner powitalny autorstwa Denise Jans w aplikacji Unsplash.