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 wykorzystać WebAssembly w ten sposób jako wysokowydajny mechanizm polyfill, który polega na pisaniu funkcji w języku C/C lub Rust, a nie w języku 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 pokazujemy, jak pobrać istniejący kod źródłowy kodeka wideo AV1, utworzyć jego otokę i wypróbować go w przeglądarce. Znajdziesz w nim też wskazówki ułatwiające tworzenie jarzma testowego do debugowania kodu. Pełny kod źródłowy tego przykładu jest dostępny na stronie github.com/GoogleChromeLabs/wasm-av1.

Pobierz jeden z tych 2 testowych plików wideo z szybkością 24 FPS i przetestuj je 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.

Tak się składa, że organizacja Alliance for Open Media pracuje nad systemem kompresji wideo nowej generacji o nazwie AV1, który ma na celu znaczne zmniejszenie rozmiaru 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 filmem o króliczkach.

Dostosowywanie do przeglądarki

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, w których każdy zakłada pewien rodzaj interfejsu opartego na plikach.

Wszystkie przykłady, które zostały skompilowane domyślnie, można uruchamiać w wierszu poleceń. Jest to prawdopodobnie możliwe w wielu innych bazach 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życie cmake do kompilacji kodu źródłowego

Na szczęście autorzy AV1 eksperymentowali z Emscripten – pakietem SDK, którego użyjemy do utworzenia 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ć aplikację WebAssembly, ponieważ generuje ona 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 krok 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ę przekażesz 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ę 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 do lokalizowania tego pliku używamy 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 poziom najwyższy Makefile tego projektu, zobaczysz, jak skrypt jest używany do konfigurowania kompilacji.

Po zakończeniu konfiguracji nazywamy ją make, który utworzy całe drzewo źródłowe, włącznie z próbkami, ale przede wszystkim wygeneruje libaom.a, który zawiera dekoder wideo skompilowany i gotowy do użycia w projekcie.

projektowanie interfejsu API do interakcji z biblioteką;

Gdy uda nam się zbudować bibliotekę, musimy opracować sposób jej obsługi, aby przesyłać do niej skompresowane dane wideo, a potem odczytywać klatki 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). Ten dekoder odczytuje plik IVF i dekoduje go na serię obrazów reprezentujących klatki filmu.

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.

Oto definicja naszego interfejsu:

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 opakowania 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 w wierszu 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 obiekt blob może być „odczytany” w taki sposób, jakby był to strumień (czyli wyglądał jak plik odczytywany sekwencyjnie).

Nasza przykładowa implementacja umożliwia odczyt przekazanego bloba tak, jakby był 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 do biblioteki AV1. Logicznie logicznie korzystnie jest więc stworzyć specjalny zestaw narzędzi testowych, za pomocą których możemy utworzyć wersję naszego interfejsu API, która działa z poziomu wiersza poleceń i obsługuje operacje wejścia-wyjścia plików, wdrażając w interfejsie API DATA_Source sam plik.

Kod wejścia-wyjścia strumienia dla jarzma testowego 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.

Wdrażanie mechanizmu buforowania wielu klatek wideo.

Podczas odtwarzania filmu często buforujemy kilka klatek, aby zwiększyć jego płynność. 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. Ta metoda zapewnia z wyprzedzeniem dostępność klatek, co pozwala zapobiec zacinaniu się filmu.

W naszym prostym przykładzie cały skompresowany film jest dostępny do odczytu, więc buforowanie nie jest tak naprawdę potrzebne. Jeśli jednak chcemy rozszerzyć interfejs danych źródłowych tak, aby obsługiwał dane przesyłane strumieniowo z serwera, potrzebny jest 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ższy czas oczekiwania na rozpoczęcie odtwarzania, a zbyt mało klatek może powodować wstrzymanie 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 było to możliwe jak najszybciej. W tym celu używamy 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 więc skorzystać z wbudowanej, wygodnej funkcji WebGL o nazwie gl.TRIANGLE_FAN.

Wystąpił jednak drobny problem. Tekstury WebGL powinny być w formacie RGB, z 1 bajtem na kanał kolorów. Dekoder AV1 generuje obrazy w tak zwanym formacie YUV, w którym domyślnie każde z tych danych to 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 implementujemy 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 przy wywoływaniu tej funkcji w JavaScripcie musimy mieć pewność, że pamięć, w której zapisujemy konwertowany obraz, jest przydzielona w pamięci modułu WebAssembly – w przeciwnym razie nie będzie 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óbuj naszą prezentację na 2 testowych plikach wideo (nagranych jako film z szybkością 24 klatek na sekundę). Oto kilka rzeczy:

  1. Stworzenie złożonej bazy kodu do wydajnego działania w przeglądarce przy użyciu WebAssembly jest całkowicie wykonalne.
  2. Zaawansowane dekodowanie wideo, które wymaga dużej mocy procesora, jest możliwe dzięki WebAssembly.

Istnieją jednak pewne ograniczenia: implementacja działa w wątku głównym, a malowanie i dekodowanie wideo przeplatają się w jednym wątku. 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ż przewidywany.

Kompilacja do WebAssembly korzysta z konfiguracji AV1 dla ogólnego typu procesora. Jeśli kompilujemy natywnie w wierszu poleceń procesora ogólne obciążenie procesora, które dekoduje filmy tak samo jak w wersji WebAssembly, biblioteka dekodera AV1 zawiera też implementacje SIMD, które działają nawet 5 razy szybciej. Organizacja WebAssembly Community Group pracuje nad rozszerzeniem standardu na elementy podstawowe SIMD, a gdy to się pojawi, ma szansę znacznie przyspieszyć dekodowanie. Wtedy będzie można dekodować filmy w jakości 4K w czasie rzeczywistym za pomocą dekodera wideo WebAssembly.

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

Środki

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