Odkryj zaawansowane przetwarzanie wideo w przeglądarce. Naucz się bezpośredniego dostępu i manipulacji surowymi danymi płaszczyzn VideoFrame za pomocą API WebCodecs, aby tworzyć niestandardowe efekty i analizy.
Dostęp do płaszczyzn VideoFrame w WebCodecs: Dogłębna analiza manipulacji surowymi danymi wideo
Przez lata wysokowydajne przetwarzanie wideo w przeglądarce internetowej wydawało się odległym marzeniem. Programiści byli często ograniczeni do możliwości elementu <video> i API Canvas 2D, które, choć potężne, wprowadzały wąskie gardła wydajności i ograniczały dostęp do surowych danych wideo. Pojawienie się API WebCodecs fundamentalnie zmieniło ten krajobraz, zapewniając niskopoziomowy dostęp do wbudowanych w przeglądarkę kodeków multimedialnych. Jedną z jego najbardziej rewolucyjnych funkcji jest możliwość bezpośredniego dostępu i manipulacji surowymi danymi poszczególnych klatek wideo za pomocą obiektu VideoFrame.
Ten artykuł jest kompleksowym przewodnikiem dla programistów, którzy chcą wyjść poza proste odtwarzanie wideo. Zgłębimy zawiłości dostępu do płaszczyzn VideoFrame, wyjaśnimy pojęcia takie jak przestrzenie kolorów i układ pamięci, a także przedstawimy praktyczne przykłady, które umożliwią tworzenie nowej generacji aplikacji wideo w przeglądarce, od filtrów w czasie rzeczywistym po zaawansowane zadania z zakresu wizji komputerowej.
Wymagania wstępne
Aby w pełni wykorzystać ten przewodnik, powinieneś mieć solidne zrozumienie:
- Nowoczesny JavaScript: Włączając programowanie asynchroniczne (
async/await, Promises). - Podstawowe pojęcia wideo: Znajomość terminów takich jak klatki, rozdzielczość i kodeki jest pomocna.
- API przeglądarek: Doświadczenie z API takimi jak Canvas 2D czy WebGL będzie korzystne, ale nie jest bezwzględnie wymagane.
Zrozumienie klatek wideo, przestrzeni kolorów i płaszczyzn
Zanim zagłębimy się w API, musimy najpierw zbudować solidny model myślowy tego, jak faktycznie wyglądają dane klatki wideo. Cyfrowe wideo to sekwencja nieruchomych obrazów, czyli klatek. Każda klatka to siatka pikseli, a każdy piksel ma kolor. Sposób przechowywania tego koloru jest definiowany przez przestrzeń kolorów i format pikseli.
RGBA: Ojczysty język sieci
Większość programistów internetowych zna model kolorów RGBA. Każdy piksel jest reprezentowany przez cztery składniki: czerwony (Red), zielony (Green), niebieski (Blue) i alfa (przezroczystość). Dane są zazwyczaj przechowywane w pamięci w sposób przeplatany (interleaved), co oznacza, że wartości R, G, B i A dla pojedynczego piksela są przechowywane kolejno:
[R1, G1, B1, A1, R2, G2, B2, A2, ...]
W tym modelu cały obraz jest przechowywany w jednym, ciągłym bloku pamięci. Możemy to sobie wyobrazić jako posiadanie pojedynczej "płaszczyzny" danych.
YUV: Język kompresji wideo
Jednak kodeki wideo rzadko pracują bezpośrednio z RGBA. Preferują przestrzenie kolorów YUV (a dokładniej, Y'CbCr). Model ten dzieli informacje o obrazie na:
- Y (Luma): Informacja o jasności lub skali szarości. Ludzkie oko jest najbardziej wrażliwe na zmiany luminancji.
- U (Cb) i V (Cr): Informacje o chrominancji lub różnicy kolorów. Ludzkie oko jest mniej wrażliwe na szczegóły kolorów niż na szczegóły jasności.
To rozdzielenie jest kluczem do wydajnej kompresji. Zmniejszając rozdzielczość składników U i V – technika zwana podpróbkowaniem chrominancji (chroma subsampling) – możemy znacznie zmniejszyć rozmiar pliku przy minimalnej zauważalnej utracie jakości. Prowadzi to do planarnych formatów pikseli, w których składniki Y, U i V są przechowywane w oddzielnych blokach pamięci, czyli "płaszczyznach".
Powszechnym formatem jest I420 (rodzaj YUV 4:2:0), gdzie na każdy blok pikseli 2x2 przypadają cztery próbki Y, ale tylko jedna próbka U i jedna V. Oznacza to, że płaszczyzny U i V mają połowę szerokości i połowę wysokości płaszczyzny Y.
Zrozumienie tej różnicy jest kluczowe, ponieważ WebCodecs daje bezpośredni dostęp do właśnie tych płaszczyzn, dokładnie tak, jak dostarcza je dekoder.
Obiekt VideoFrame: Twoja brama do danych pikseli
Centralnym elementem tej układanki jest obiekt VideoFrame. Reprezentuje on pojedynczą klatkę wideo i zawiera nie tylko dane pikseli, ale także ważne metadane.
Kluczowe właściwości VideoFrame
format: Ciąg znaków wskazujący format pikseli (np. 'I420', 'NV12', 'RGBA').codedWidth/codedHeight: Pełne wymiary klatki przechowywane w pamięci, włączając wszelkie dopełnienie (padding) wymagane przez kodek.displayWidth/displayHeight: Wymiary, które powinny być używane do wyświetlania klatki.timestamp: Sygnatura czasowa prezentacji klatki w mikrosekundach.duration: Czas trwania klatki w mikrosekundach.
Magiczna metoda: copyTo()
Główną metodą dostępu do surowych danych pikseli jest videoFrame.copyTo(destination, options). Ta asynchroniczna metoda kopiuje dane płaszczyzn klatki do dostarczonego bufora.
destination:ArrayBufferlub tablica typowana (jakUint8Array) wystarczająco duża, aby pomieścić dane.options: Obiekt, który określa, które płaszczyzny mają być skopiowane i jaki jest ich układ w pamięci. Jeśli zostanie pominięty, kopiuje wszystkie płaszczyzny do jednego ciągłego bufora.
Metoda zwraca Promise, który jest rozwiązywany z tablicą obiektów PlaneLayout, po jednym dla każdej płaszczyzny w klatce. Każdy obiekt PlaneLayout zawiera dwie kluczowe informacje:
offset: Przesunięcie w bajtach, od którego zaczynają się dane tej płaszczyzny w buforze docelowym.stride: Liczba bajtów między początkiem jednego wiersza pikseli a początkiem następnego wiersza dla tej płaszczyzny.
Kluczowe pojęcie: Stride kontra szerokość
To jedno z najczęstszych źródeł nieporozumień dla programistów, którzy dopiero zaczynają programowanie grafiki niskiego poziomu. Nie można zakładać, że każdy wiersz danych pikseli jest ciasno upakowany jeden za drugim.
- Szerokość (Width) to liczba pikseli w wierszu obrazu.
- Stride (zwany również pitch lub line step) to liczba bajtów w pamięci od początku jednego wiersza do początku następnego.
Często stride będzie większy niż szerokość * bajty_na_piksel. Dzieje się tak, ponieważ pamięć jest często dopełniana (padded) w celu dopasowania do granic sprzętowych (np. granic 32- lub 64-bajtowych) w celu szybszego przetwarzania przez procesor lub kartę graficzną. Zawsze musisz używać stride do obliczania adresu pamięci piksela w określonym wierszu.
Ignorowanie stride prowadzi do przekrzywionych lub zniekształconych obrazów i nieprawidłowego dostępu do danych.
Praktyczny przykład 1: Dostęp i wyświetlanie płaszczyzny skali szarości
Zacznijmy od prostego, ale potężnego przykładu. Większość wideo w sieci jest kodowana w formacie YUV, takim jak I420. Płaszczyzna 'Y' jest w rzeczywistości kompletną reprezentacją obrazu w skali szarości. Możemy wyodrębnić tylko tę płaszczyznę i wyrenderować ją na elemencie canvas.
async function displayGrayscale(videoFrame) {
// Zakładamy, że videoFrame jest w formacie YUV, takim jak 'I420' lub 'NV12'.
if (!videoFrame.format.startsWith('I4')) {
console.error('Ten przykład wymaga planarnego formatu YUV 4:2:0.');
videoFrame.close();
return;
}
const yPlaneInfo = videoFrame.layout[0]; // Płaszczyzna Y jest zawsze pierwsza.
// Utwórz bufor do przechowywania danych tylko płaszczyzny Y.
const yPlaneData = new Uint8Array(yPlaneInfo.stride * videoFrame.codedHeight);
// Skopiuj płaszczyznę Y do naszego bufora.
await videoFrame.copyTo(yPlaneData, {
rect: { x: 0, y: 0, width: videoFrame.codedWidth, height: videoFrame.codedHeight },
layout: [yPlaneInfo]
});
// Teraz yPlaneData zawiera surowe piksele w skali szarości.
// Musimy to wyrenderować. Utworzymy bufor RGBA dla elementu canvas.
const canvas = document.getElementById('my-canvas');
canvas.width = videoFrame.displayWidth;
canvas.height = videoFrame.displayHeight;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(canvas.width, canvas.height);
// Iteruj po pikselach canvas i wypełnij je danymi z płaszczyzny Y.
for (let y = 0; y < videoFrame.displayHeight; y++) {
for (let x = 0; x < videoFrame.displayWidth; x++) {
// Ważne: Użyj stride, aby znaleźć poprawny indeks źródłowy!
const yIndex = y * yPlaneInfo.stride + x;
const luma = yPlaneData[yIndex];
// Oblicz indeks docelowy w buforze RGBA ImageData.
const rgbaIndex = (y * canvas.width + x) * 4;
imageData.data[rgbaIndex] = luma; // Czerwony
imageData.data[rgbaIndex + 1] = luma; // Zielony
imageData.data[rgbaIndex + 2] = luma; // Niebieski
imageData.data[rgbaIndex + 3] = 255; // Alfa
}
}
ctx.putImageData(imageData, 0, 0);
// KRYTYCZNE: Zawsze zamykaj VideoFrame, aby zwolnić jego pamięć.
videoFrame.close();
}
Ten przykład podkreśla kilka kluczowych kroków: identyfikację poprawnego układu płaszczyzny, alokację bufora docelowego, użycie copyTo do wyodrębnienia danych oraz poprawne iterowanie po danych z użyciem stride w celu skonstruowania nowego obrazu.
Praktyczny przykład 2: Manipulacja w miejscu (filtr sepii)
Teraz wykonajmy bezpośrednią manipulację danymi. Filtr sepii to klasyczny efekt, który jest łatwy do zaimplementowania. W tym przykładzie łatwiej jest pracować z klatką RGBA, którą można uzyskać z elementu canvas lub kontekstu WebGL.
async function applySepiaFilter(videoFrame) {
// Ten przykład zakłada, że klatka wejściowa jest w formacie 'RGBA' lub 'BGRA'.
if (videoFrame.format !== 'RGBA' && videoFrame.format !== 'BGRA') {
console.error('Przykład z filtrem sepii wymaga klatki RGBA.');
videoFrame.close();
return null;
}
// Zaalokuj bufor do przechowywania danych pikseli.
const frameDataSize = videoFrame.allocationSize();
const frameData = new Uint8Array(frameDataSize);
await videoFrame.copyTo(frameData);
const layout = videoFrame.layout[0]; // RGBA to pojedyncza płaszczyzna
// Teraz zmanipuluj dane w buforze.
for (let y = 0; y < videoFrame.codedHeight; y++) {
for (let x = 0; x < videoFrame.codedWidth; x++) {
const pixelIndex = y * layout.stride + x * 4; // 4 bajty na piksel (R,G,B,A)
const r = frameData[pixelIndex];
const g = frameData[pixelIndex + 1];
const b = frameData[pixelIndex + 2];
const tr = 0.393 * r + 0.769 * g + 0.189 * b;
const tg = 0.349 * r + 0.686 * g + 0.168 * b;
const tb = 0.272 * r + 0.534 * g + 0.131 * b;
frameData[pixelIndex] = Math.min(255, tr);
frameData[pixelIndex + 1] = Math.min(255, tg);
frameData[pixelIndex + 2] = Math.min(255, tb);
// Alfa (frameData[pixelIndex + 3]) pozostaje bez zmian.
}
}
// Utwórz *nową* klatkę VideoFrame ze zmodyfikowanymi danymi.
const newFrame = new VideoFrame(frameData, {
format: videoFrame.format,
codedWidth: videoFrame.codedWidth,
codedHeight: videoFrame.codedHeight,
timestamp: videoFrame.timestamp,
duration: videoFrame.duration
});
// Nie zapomnij zamknąć oryginalnej klatki!
videoFrame.close();
return newFrame;
}
To demonstruje kompletny cykl odczytu-modyfikacji-zapisu: skopiuj dane, przeiteruj po nich używając stride, zastosuj transformację matematyczną do każdego piksela i skonstruuj nową klatkę VideoFrame z wynikowymi danymi. Ta nowa klatka może być następnie wyrenderowana na elemencie canvas, wysłana do VideoEncoder lub przekazana do kolejnego etapu przetwarzania.
Wydajność ma znaczenie: JavaScript kontra WebAssembly (WASM)
Iterowanie po milionach pikseli dla każdej klatki (klatka 1080p ma ponad 2 miliony pikseli, czyli 8 milionów punktów danych w RGBA) w JavaScript może być powolne. Chociaż nowoczesne silniki JS są niezwykle szybkie, w przypadku przetwarzania w czasie rzeczywistym wideo o wysokiej rozdzielczości (HD, 4K), takie podejście może łatwo przeciążyć główny wątek, prowadząc do zacinającego się doświadczenia użytkownika.
W tym miejscu WebAssembly (WASM) staje się niezbędnym narzędziem. WASM pozwala na uruchamianie kodu napisanego w językach takich jak C++, Rust czy Go z prędkością zbliżoną do natywnej wewnątrz przeglądarki. Przepływ pracy przy przetwarzaniu wideo staje się następujący:
- W JavaScript: Użyj
videoFrame.copyTo(), aby uzyskać surowe dane pikseli doArrayBuffer. - Przekaż do WASM: Przekaż odwołanie do tego bufora do swojego skompilowanego modułu WASM. Jest to bardzo szybka operacja, ponieważ nie wiąże się z kopiowaniem danych.
- W WASM (C++/Rust): Wykonaj swoje wysoce zoptymalizowane algorytmy przetwarzania obrazu bezpośrednio na buforze pamięci. Jest to o rzędy wielkości szybsze niż pętla w JavaScript.
- Powrót do JavaScript: Gdy WASM zakończy pracę, kontrola wraca do JavaScript. Możesz wtedy użyć zmodyfikowanego bufora, aby utworzyć nową klatkę
VideoFrame.
W przypadku każdej poważnej aplikacji do manipulacji wideo w czasie rzeczywistym — takiej jak wirtualne tła, wykrywanie obiektów czy złożone filtry — wykorzystanie WebAssembly nie jest tylko opcją; jest koniecznością.
Obsługa różnych formatów pikseli (np. I420, NV12)
Chociaż RGBA jest proste, najczęściej będziesz otrzymywać klatki w planarnych formatach YUV z VideoDecoder. Zobaczmy, jak obsłużyć w pełni planarny format, taki jak I420.
Klatka VideoFrame w formacie I420 będzie miała trzy deskryptory układu w swojej tablicy layout:
layout[0]: Płaszczyzna Y (luma). Wymiary tocodedWidthxcodedHeight.layout[1]: Płaszczyzna U (chroma). Wymiary tocodedWidth/2xcodedHeight/2.layout[2]: Płaszczyzna V (chroma). Wymiary tocodedWidth/2xcodedHeight/2.
Oto jak skopiować wszystkie trzy płaszczyzny do jednego bufora:
async function extractI420Planes(videoFrame) {
const totalSize = videoFrame.allocationSize({ format: 'I420' });
const allPlanesData = new Uint8Array(totalSize);
const layouts = await videoFrame.copyTo(allPlanesData);
// layouts to tablica 3 obiektów PlaneLayout
console.log('Y Plane Layout:', layouts[0]); // { offset: 0, stride: ... }
console.log('U Plane Layout:', layouts[1]); // { offset: ..., stride: ... }
console.log('V Plane Layout:', layouts[2]); // { offset: ..., stride: ... }
// Możesz teraz uzyskać dostęp do każdej płaszczyzny w buforze `allPlanesData`
// używając jej konkretnego offsetu i stride.
const yPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[0].offset,
layouts[0].stride * videoFrame.codedHeight
);
// Zauważ, że wymiary chrominancji są o połowę mniejsze!
const uPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[1].offset,
layouts[1].stride * (videoFrame.codedHeight / 2)
);
const vPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[2].offset,
layouts[2].stride * (videoFrame.codedHeight / 2)
);
console.log('Accessed Y plane size:', yPlaneView.byteLength);
console.log('Accessed U plane size:', uPlaneView.byteLength);
videoFrame.close();
}
Innym powszechnym formatem jest NV12, który jest semi-planarny. Ma dwie płaszczyzny: jedną dla Y i drugą, w której wartości U i V są przeplatane (np. [U1, V1, U2, V2, ...]). API WebCodecs obsługuje to w sposób przezroczysty; klatka VideoFrame w formacie NV12 będzie po prostu miała dwa układy w swojej tablicy layout.
Wyzwania i dobre praktyki
Praca na tak niskim poziomie jest potężna, ale wiąże się z obowiązkami.
Zarządzanie pamięcią jest najważniejsze
Obiekt VideoFrame przechowuje znaczną ilość pamięci, która często jest zarządzana poza stertą garbage collectora JavaScript. Jeśli jawnie nie zwolnisz tej pamięci, spowodujesz wyciek pamięci, który może doprowadzić do awarii karty przeglądarki.
Zawsze, ale to zawsze, wywołuj videoFrame.close(), gdy skończysz pracę z klatką.
Natura asynchroniczna
Cały dostęp do danych jest asynchroniczny. Architektura Twojej aplikacji musi prawidłowo obsługiwać przepływ Promises i async/await, aby uniknąć warunków wyścigu i zapewnić płynny potok przetwarzania.
Kompatybilność z przeglądarkami
WebCodecs to nowoczesne API. Chociaż jest obsługiwane we wszystkich głównych przeglądarkach, zawsze sprawdzaj jego dostępność i bądź świadomy wszelkich specyficznych dla dostawcy szczegółów implementacji lub ograniczeń. Używaj wykrywania funkcji przed próbą użycia API.
Podsumowanie: Nowa granica dla wideo w sieci
Możliwość bezpośredniego dostępu i manipulacji surowymi danymi płaszczyzn obiektu VideoFrame za pośrednictwem API WebCodecs to zmiana paradygmatu dla aplikacji multimedialnych opartych na sieci. Usuwa ona czarną skrzynkę elementu <video> i daje programistom szczegółową kontrolę, wcześniej zarezerwowaną dla aplikacji natywnych.
Rozumiejąc podstawy układu pamięci wideo — płaszczyzny, stride i formaty kolorów — oraz wykorzystując moc WebAssembly do operacji krytycznych pod względem wydajności, możesz teraz budować niezwykle zaawansowane narzędzia do przetwarzania wideo bezpośrednio w przeglądarce. Od gradacji kolorów w czasie rzeczywistym i niestandardowych efektów wizualnych po uczenie maszynowe po stronie klienta i analizę wideo, możliwości są ogromne. Era wysokowydajnego, niskopoziomowego wideo w sieci naprawdę się rozpoczęła.