Rozszerzanie przeglądarki za pomocą WebAssembly

WebAssembly pozwala nam rozszerzać przeglądarkę o nowe funkcje. Z tego artykułu dowiesz się, jak przenosić dekoder wideo AV1 i odtwarzać filmy AV1 w dowolnej nowoczesnej przeglądarce.

Alex Danilo

Jedną z największych zalet WebAssembly jest możliwość eksperymentowania z nowymi funkcjami i wdrażania nowych pomysłów jeszcze przed udostępnieniem tych funkcji przez przeglądarkę (jeśli w ogóle zostaną one udostępnione). Możesz traktować WebAssembly jako mechanizm polyfill o wysokiej wydajności, w którym funkcje piszesz w języku C/C++ lub Rust zamiast w JavaScript.

Dzięki obfitości kodu dostępnego do przenoszenia można wykonywać w przeglądarce czynności, które nie były możliwe do wykonania przed pojawieniem się WebAssembly.

W tym artykule znajdziesz przykładowy opis tego, jak za pomocą dotychczasowego kodu źródłowego kodeka wideo AV1 utworzyć dla niego zewnętrzną osłonę i przetestować ją w przeglądarce. Podpowiemy Ci też, jak stworzyć zestaw testowy do debugowania osłony. Pełny kod źródłowy tego przykładu jest dostępny w repozytorium github.com/GoogleChromeLabs/wasm-av1.

Pobierz jeden z tych 2 testowych plików wideo z szybkością 24 fps i przetestuj go na naszej prezentacji.

Wybieranie interesującej bazy kodu

Od kilku lat obserwujemy, że duży odsetek ruchu w internecie stanowią dane wideo. Według szacunków Cisco to nawet 80%. Oczywiście dostawcy przeglądarek i witryn z filmami doskonale wiedzą, że użytkownicy chcą ograniczyć ilość danych zużywaną przez wszystkie te treści wideo. Kluczem do tego jest oczywiście lepsza kompresja. Jak można się spodziewać, prowadzone są liczne badania nad kompresją wideo nowej generacji, których celem jest zmniejszenie obciążenia danych związanego z przesyłaniem wideo przez Internet.

Alliance for Open Media pracuje nad nowym algorytmem kompresji wideo o nazwie AV1, który ma znacznie zmniejszać rozmiar danych wideo. W przyszłości przeglądarki będą obsługiwać format AV1 natywnie, ale na szczęście kod źródłowy kompresora i dekompresora jest otwarty, co czyni go idealnym kandydatem do skompilowania w standardzie WebAssembly, abyśmy mogli eksperymentować z nim w przeglądarce.

Obraz z filmu z króliczkiem

Dostosowywanie do użycia w przeglądarce

Aby wprowadzić kod do przeglądarki, musimy najpierw zapoznać się z dotychczasowym kodem i powiedzieć, jak działa interfejs API. Przy pierwszym spojrzeniu na ten kod uwagę zwracają 2 rzeczy:

  1. Drzewo źródłowe jest tworzone za pomocą narzędzia cmake;
  2. Istnieje wiele przykładów, które zakładają pewien rodzaj interfejsu opartego na plikach.

Wszystkie przykłady, które są kompilowane domyślnie, można uruchomić w wierszu poleceń. Prawdopodobnie dotyczy to wielu innych baz kodu dostępnych w społeczności. Interfejs, który zamierzamy zbudować, aby działał w przeglądarce, może być przydatny dla wielu innych narzędzi wiersza poleceń.

Używanie cmake do kompilowania kodu źródłowego

Na szczęście autorzy AV1 eksperymentowali z Emscriptenem, czyli pakietem SDK, którego użyjemy do skompilowania wersji WebAssembly. W katalogu głównym repozytorium AV1 plik CMakeLists.txt zawiera te reguły kompilacji:

if(EMSCRIPTEN)
add_preproc_definition
(_POSIX_SOURCE)
append_link_flag_to_target
("inspect" "-s TOTAL_MEMORY=402653184")
append_link_flag_to_target
("inspect" "-s MODULARIZE=1")
append_link_flag_to_target
("inspect"
                           
"-s EXPORT_NAME=\"\'DecoderModule\'\"")
append_link_flag_to_target
("inspect" "--memory-init-file 0")

if("${CMAKE_BUILD_TYPE}" STREQUAL "")
   
# Default to -O3 when no build type is specified.
    append_compiler_flag
("-O3")
endif
()
em_link_post_js
(inspect "${AOM_ROOT}/tools/inspect-post.js")
endif
()

Zestaw narzędzi Emscripten może generować dane wyjściowe w 2 formatach: asm.js i WebAssembly. Będziemy kierować reklamy na WebAssembly, ponieważ generuje on mniejsze dane wyjściowe i może działać szybciej. Te istniejące reguły kompilacji mają na celu skompilowanie wersji asm.js biblioteki na potrzeby aplikacji inspekcyjnej, która służy do przeglądania zawartości pliku wideo. W naszym przypadku potrzebujemy danych wyjściowych WebAssembly, więc dodajemy te wiersze tuż przed zamykającym endif()oświadczeniem w powyższych regułach.

# Force generation of Wasm instead of asm.js
append_link_flag_to_target
("inspect" "-s WASM=1")
append_compiler_flag
("-s WASM=1")

Kompilowanie za pomocą narzędzia cmake oznacza najpierw wygenerowanie Makefiles przez uruchomienie samego narzędzia cmake, a potem uruchomienie polecenia make, które wykona etap kompilacji. Ponieważ używamy Emscripten, musimy użyć narzędzia kompilatora Emscripten, a nie domyślnego kompilatora hosta. Aby to osiągnąć, użyj funkcji Emscripten.cmake, która jest częścią pakietu Emscripten SDK, a jej ścieżkę przekazując jako parametr do funkcji cmake. Poniżej podajemy wiersz poleceń, którego używamy do generowania plików Makefile:

cmake path/to/aom \
 
-DENABLE_CCACHE=1 -DAOM_TARGET_CPU=generic -DENABLE_DOCS=0 \
 
-DCONFIG_ACCOUNTING=1 -DCONFIG_INSPECTION=1 -DCONFIG_MULTITHREAD=0 \
 
-DCONFIG_RUNTIME_CPU_DETECT=0 -DCONFIG_UNIT_TESTS=0
 
-DCONFIG_WEBM_IO=0 \
 
-DCMAKE_TOOLCHAIN_FILE=path/to/emsdk-portable/.../Emscripten.cmake

Parametr path/to/aom powinien zawierać pełną ścieżkę do lokalizacji plików źródłowych biblioteki AV1. Parametr path/to/emsdk-portable/…/Emscripten.cmake musi mieć ustawioną ścieżkę do pliku opisu łańcucha narzędzi Emscripten.cmake.

Dla wygody lokalizujemy ten plik za pomocą skryptu powłoki:

#!/bin/sh
EMCC_LOC
=`which emcc`
EMSDK_LOC
=`echo $EMCC_LOC | sed 's?/emscripten/[0-9.]*/emcc??'`
EMCMAKE_LOC
=`find $EMSDK_LOC -name Emscripten.cmake -print`
echo $EMCMAKE_LOC

Jeśli spojrzysz na Makefile najwyższego poziomu w tym projekcie, zobaczysz, jak skrypt jest używany do konfigurowania kompilacji.

Po zakończeniu konfiguracji wystarczy wywołać make, aby utworzyć całe drzewo źródłowe, w tym próbki, ale przede wszystkim wygenerować libaom.a, który zawiera skompilowany dekoder wideo gotowy do wdrożenia w naszym projekcie.

projektowanie interfejsu API do interakcji z biblioteką;

Gdy zbudujemy bibliotekę, musimy ustalić, jak z nią pracować, aby przesyłać do niej skompresowane dane wideo, a potem odczytywać ramki wideo, które możemy wyświetlić w przeglądarce.

W drzewie kodu AV1 dobrym punktem wyjścia jest przykładowy dekoder wideo, który znajdziesz w pliku [simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c). Dekoder odczytuje plik IVF i odkoduje go w serię obrazów, które reprezentują klatki w filmie.

Interfejs jest implementowany w pliku źródłowym [decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c).

Ponieważ nasza przeglądarka nie może odczytywać plików z systemu plików, musimy zaprojektować interfejs, który pozwoli nam abstrahować od operacji wejścia/wyjścia, aby móc zbudować coś podobnego do przykładowego dekodera, który pobiera dane do naszej biblioteki AV1.

W wierszu poleceń operacje wejścia/wyjścia na pliki są nazywane interfejsem strumienia, więc możemy zdefiniować własny interfejs, który wygląda jak operacje wejścia/wyjścia na strumieniu, i stworzyć w podstawowej implementacji dowolne elementy.

Nasz interfejs definiujemy w ten sposób:

DATA_Source *DS_open(const char *what);
size_t      DS_read
(DATA_Source *ds,
                   
unsigned char *buf, size_t bytes);
int         DS_empty(DATA_Source *ds);
void        DS_close(DATA_Source *ds);
// Helper function for blob support
void        DS_set_blob(DATA_Source *ds, void *buf, size_t len);

Funkcje open/read/empty/close są bardzo podobne do zwykłych operacji wejścia/wyjścia z pliku, co pozwala nam łatwo mapować je na operacje wejścia/wyjścia z pliku w aplikacji wiersza poleceń lub implementować je w inny sposób podczas uruchamiania w przeglądarce. Typ DATA_Source jest nieprzejrzysty z poziomu JavaScriptu i służy tylko do tworzenia interfejsu. Pamiętaj, że tworzenie interfejsu API, który ściśle przestrzega semantyki plików, ułatwia ponowne użycie wielu innych baz kodu, które mają być używane z wiersza poleceń (np. diff, sed itp.).

Musimy też zdefiniować funkcję pomocniczą o nazwie DS_set_blob, która łączy surowe dane binarne z funkcjami wejścia/wyjścia strumienia. Dzięki temu blob może być „odczytany” tak, jakby był strumieniem (czyli tak, jakby był odczytywany sekwencyjnie).

Nasza przykładowa implementacja umożliwia odczyt przekazanego bloba tak, jakby był on sekwencyjnie odczytywanym źródłem danych. Kod referencyjny znajdziesz w pliku blob-api.c. Cała implementacja wygląda tak:

struct DATA_Source {
   
void        *ds_Buf;
    size_t      ds_Len
;
    size_t      ds_Pos
;
};

DATA_Source
*
DS_open
(const char *what) {
    DATA_Source    
*ds;

    ds
= malloc(sizeof *ds);
   
if (ds != NULL) {
        memset
(ds, 0, sizeof *ds);
   
}
   
return ds;
}

size_t
DS_read
(DATA_Source *ds, unsigned char *buf, size_t bytes) {
   
if (DS_empty(ds) || buf == NULL) {
       
return 0;
   
}
   
if (bytes > (ds->ds_Len - ds->ds_Pos)) {
        bytes
= ds->ds_Len - ds->ds_Pos;
   
}
    memcpy
(buf, &ds->ds_Buf[ds->ds_Pos], bytes);
    ds
->ds_Pos += bytes;

   
return bytes;
}

int
DS_empty
(DATA_Source *ds) {
   
return ds->ds_Pos >= ds->ds_Len;
}

void
DS_close
(DATA_Source *ds) {
    free
(ds);
}

void
DS_set_blob
(DATA_Source *ds, void *buf, size_t len) {
    ds
->ds_Buf = buf;
    ds
->ds_Len = len;
    ds
->ds_Pos = 0;
}

Tworzenie zestawu testów do testowania poza przeglądarką

Jedną ze sprawdzonych metod inżynierii oprogramowania jest tworzenie testów jednostkowych kodu w połączeniu z testami integracji.

Podczas kompilowania za pomocą WebAssembly w przeglądarce warto utworzyć test jednostkowy dla interfejsu kodu, z którym pracujemy, aby móc debugować poza przeglądarką i testować utworzony interfejs.

W tym przykładzie emulowaliśmy interfejs API oparty na strumieniu jako interfejs biblioteki AV1. Dlatego logiczne jest tworzenie zestawu testowego, który można wykorzystać do tworzenia wersji interfejsu API, który działa na wierszu poleceń i zajmuje się rzeczywistymi operacjami wejścia/wyjścia z pliku, implementując je w ramach interfejsu API DATA_Source.

Kod wejścia/wyjścia strumienia w naszym zestawie testowym jest prosty i wygląda tak:

DATA_Source *
DS_open
(const char *what) {
   
return (DATA_Source *)fopen(what, "rb");
}

size_t
DS_read
(DATA_Source *ds, unsigned char *buf, size_t bytes) {
   
return fread(buf, 1, bytes, (FILE *)ds);
}

int
DS_empty
(DATA_Source *ds) {
   
return feof((FILE *)ds);
}

void
DS_close
(DATA_Source *ds) {
    fclose
((FILE *)ds);
}

Dzięki abstrakcji interfejsu strumienia możemy skompilować moduł WebAssembly, aby używać binarnych danych blob w przeglądarce, oraz interfejs do prawdziwych plików, gdy kompilujemy kod do testowania z wiersza poleceń. Kod testowy znajdziesz w przykładowym pliku źródłowym test.c.

Wdrożenie mechanizmu buforowania dla wielu klatek wideo

Podczas odtwarzania filmu często buforuje się kilka klatek, aby zapewnić płynność odtwarzania. W naszym przypadku wystarczy nam bufor 10 klatek wideo, więc przed rozpoczęciem odtwarzania załadujemy 10 klatek. Następnie za każdym razem, gdy wyświetla się klatka, będziemy próbować odkodować kolejną, aby zachować pełny bufor. Dzięki temu masz pewność, że klatki są dostępne z wyprzedzeniem, co pomaga zatrzymać zacinanie filmu.

W naszym prostym przykładzie cały skompresowany film jest dostępny do odczytu, więc buforowanie nie jest konieczne. Jeśli jednak chcemy rozszerzyć interfejs danych źródłowych, aby obsługiwał przesyłanie strumieniowe danych z serwera, musimy wdrożyć mechanizm buforowania.

Kod w decode-av1.c do odczytywania klatek danych wideo z biblioteki AV1 i przechowywania ich w buforze:

void
AVX_Decoder_run
(AVX_Decoder *ad) {
   
...
   
// Try to decode an image from the compressed stream, and buffer
   
while (ad->ad_NumBuffered < NUM_FRAMES_BUFFERED) {
        ad
->ad_Image = aom_codec_get_frame(&ad->ad_Codec,
                                           
&ad->ad_Iterator);
       
if (ad->ad_Image == NULL) {
           
break;
       
}
       
else {
            buffer_frame
(ad);
       
}
   
}


Zdecydowaliśmy, że bufor będzie zawierać 10 klatek wideo, co jest tylko arbitralnym wyborem. Buforowanie większej liczby klatek oznacza dłuższe oczekiwanie na rozpoczęcie odtwarzania filmu, a buforowanie zbyt małej liczby klatek może spowodować zacinanie się odtwarzania. W implementacji w natywnej przeglądarce buforowanie klatek jest znacznie bardziej złożone niż w tym przypadku.

Przenoszenie klatek wideo na stronę za pomocą WebGL

Ramki wideo, które zostały zbuforowane, muszą być wyświetlane na naszej stronie. Ponieważ są to dynamiczne treści wideo, chcemy, aby można było je przesłać jak najszybciej. W tym celu użyjemy WebGL.

WebGL pozwala nam pobrać obraz, np. kadr z filmu, i użyć go jako tekstury, która zostanie nałożona na jakąś geometrię. W świecie WebGL wszystko składa się z trójkątów. W naszym przypadku możemy użyć wygodnej wbudowanej funkcji WebGL o nazwie gl.TRIANGLE_FAN.

Wystąpił jednak drobny problem. Tekstury WebGL powinny być obrazami RGB, z jednym bajtem na kanał kolorów. Dane wyjściowe dekodera AV1 to obrazy w tzw. formacie YUV, w których domyślne dane wyjściowe mają 16 bitów na kanał, a każda wartość U lub V odpowiada 4 pikselom na rzeczywistym obrazie wyjściowym. Oznacza to, że musimy przekonwertować obraz na kolory, zanim przekażemy go do wyświetlenia w WebGL.

W tym celu wdrożymy funkcję AVX_YUV_to_RGB(), którą znajdziesz w pliku źródłowym yuv-to-rgb.c. Ta funkcja konwertuje dane wyjściowe dekodera AV1 na dane, które możemy przekazać do WebGL. Pamiętaj, że gdy wywołujemy tę funkcję z JavaScriptu, musimy się upewnić, że pamięć, do której zapisujemy przekonwertowany obraz, została przydzielona w pamięci modułu WebAssembly – w przeciwnym razie nie będzie ona miała do niej dostępu. Funkcja pobierania obrazu z modułu WebAssembly i malowania go na ekranie:

function show_frame(af) {
   
if (rgb_image != 0) {
       
// Convert The 16-bit YUV to 8-bit RGB
        let buf
= Module._AVX_Video_Frame_get_buffer(af);
       
Module._AVX_YUV_to_RGB(rgb_image, buf, WIDTH, HEIGHT);
       
// Paint the image onto the canvas
        drawImageToCanvas
(new Uint8Array(Module.HEAPU8.buffer,
                rgb_image
, 3 * WIDTH * HEIGHT), WIDTH, HEIGHT);
   
}
}

Funkcję drawImageToCanvas(), która implementuje malowanie WebGL, znajdziesz w pliku źródłowym draw-image.js.

dalsze działania i wnioski

Wypróbowanie demo na 2 testowych plikach(nagrywanych z częstotliwością 24 kl./s) pozwoliło nam uzyskać kilka informacji:

  1. Można tworzyć złożone bazy kodu, które będą działać wydajnie w przeglądarce dzięki WebAssembly.
  2. Zaawansowane dekodowanie wideo, które wymaga dużej mocy obliczeniowej procesora, jest możliwe dzięki WebAssembly.

Istnieją jednak pewne ograniczenia: implementacja działa na głównym wątku, a w ramach tego wątku przeplatamy wyświetlanie i dekodowanie wideo. Przeniesienie dekodowania do web workera może zapewnić płynniejsze odtwarzanie, ponieważ czas dekodowania klatek zależy w dużym stopniu od zawartości danej klatki i czasami może być dłuższy niż zakładany.

Kompilacja do WebAssembly korzysta z konfiguracji AV1 dla ogólnego typu procesora. Jeśli skompilujemy natywny kod na linii poleceń na potrzeby ogólnego procesora, zobaczymy podobne obciążenie procesora podczas dekodowania filmu jak w przypadku wersji WebAssembly. Jednak biblioteka dekodera AV1 zawiera też implementacje SIMD, które działają nawet 5 razy szybciej. Grupa W3C WebAssembly pracuje obecnie nad rozszerzeniem standardu o proste instrukcje SIMD, co znacznie przyspieszy dekodowanie. Wtedy dekodowanie filmów w jakości 4K HD w czasie rzeczywistym za pomocą dekodera wideo WebAssembly będzie całkowicie możliwe.

W każdym razie przykładowy kod może być przydatny jako przewodnik, który pomoże przenieść istniejące narzędzie wiersza poleceń na moduł WebAssembly. Pokazuje też, co jest możliwe w internecie już dziś.

Środki

Dziękujemy Jeffowi Posnickowi, Ericowi Bidelmanowi i Thomasowi Steinerowi za cenne opinie i komentarze.