Rozszerzanie przeglądarki za pomocą WebAssembly

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

Alex Danilo

Jedną z największych zalet WebAssembly jest eksperymentowanie z możliwością korzystania z nowych funkcji i wdrażanie nowych pomysłów, zanim przeglądarka wyśle te funkcje natywnie (o ile w ogóle). Korzystanie z WebAssembly można traktować jak skuteczny mechanizm polyfill, w którym swoją funkcję pisze się w języku C/C++ lub Rust zamiast w języku JavaScript.

Mając mnóstwo kodu dostępnego do przenoszenia, można robić w przeglądarce rzeczy, które nie działały, dopóki nie pojawiło się WebAssembly.

Ten artykuł pokazuje, jak pobrać istniejący kod źródłowy kodeka wideo AV1, utworzyć dla niego kodek i wypróbować go w przeglądarce, a także wskazówki, które pomogą stworzyć szkielet testowy do debugowania kodu. Pełny kod źródłowy tego przykładu znajdziesz na stronie github.com/GoogleChromeLabs/wasm-av1.

Pobierz jeden z tych 2 testowych plików wideo z szybkością 24 kl./s i wypróbuj je w naszej prezentacji.

Wybór interesującej bazy kodu

Od kilku lat obserwujemy, że duży odsetek ruchu w internecie to dane filmów. Cisco szacuje je aż na 80%. Użytkownicy przeglądarek i witryny z filmami są oczywiście świadomi chęci ograniczenia ilości danych wykorzystywanych przez te treści wideo. Kluczem jest oczywiście lepsza kompresja. Jak można by się spodziewać, przeprowadzono wiele badań nad kompresją wideo nowej generacji, której celem jest zmniejszenie ilości danych przesyłanych w internecie.

Jak się okazuje, organizacja Alliance for Open Media pracuje nad nowym schematem kompresji wideo o nazwie AV1, który ma na celu znaczne zmniejszenie rozmiaru danych wideo. W przyszłości oczekujemy, że przeglądarki będą obsługiwać natywny kod AV1, ale na szczęście kod źródłowy kompresora i dekompresora to open source, co sprawia, że idealnie nadaje się do skompilowania go w WebAssembly, co pozwoli nam z nim poeksperymentować w przeglądarce.

Grafika z królikiem.

Dostosowywanie do użytku w przeglądarce

Jedną z pierwszych rzeczy, jakie musimy zrobić, aby umieścić ten kod w przeglądarce, jest zapoznanie się z obecnym kodem i zrozumienie, jak wygląda interfejs API. Gdy patrzymy na ten kod po raz pierwszy, wyróżniają się 2 rzeczy:

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

Wszystkie utworzone domyślnie przykłady można uruchamiać z poziomu wiersza poleceń i prawdopodobnie dzieje się to w wielu innych bazach kodu dostępnych w społeczności. Interfejs, który stworzymy dla przeglądarki, może być przydatny dla wielu innych narzędzi wiersza poleceń.

Użycie usługi cmake do tworzenia kodu źródłowego

Na szczęście autorzy AV1 eksperymentowali z pakietem SDK Emscripten, którego zamierzamy użyć do utworzenia wersji WebAssembly. W katalogu głównym repozytorium AV1 plik CMakeLists.txtzawiera 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()

Łańcuch narzędzi Emscripten może generować dane wyjściowe w 2 formatach: asm.js i WebAssembly. Będziemy kierować reklamy na WebAssembly, ponieważ uzyska ono mniejsze dane wyjściowe i będzie działać szybciej. Te istniejące reguły kompilacji mają na celu kompilowanie wersji biblioteki asm.js do użycia w aplikacji inspektora używanej do sprawdzania zawartości pliku wideo. Do naszego użycia danych potrzebne są dane wyjściowe WebAssembly, więc dodajemy te wiersze tuż przed zamykającym poleceniem endif()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")

Kompilacja za pomocą funkcji cmake oznacza, że najpierw wygenerujesz część Makefiles przez uruchomienie polecenia cmake, a następnie uruchomienie polecenia make, które wykona krok kompilacji. Ponieważ używamy Emscripten, musimy używać łańcucha narzędzi kompilatora Emscripten, a nie domyślnego kompilatora hosta. Jest to możliwe dzięki użyciu Emscripten.cmake, który jest częścią pakietu SDK Emscripten, i przekazywania tej ścieżki jako parametru do cmake. Wiersz poleceń poniżej służy do generowania plików Makefiles:

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 być ustawiony na ścieżkę pliku z opisem łańcucha narzędzi Emscripten.cmake.

Dla ułatwienia do znalezienia 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 Makefile najwyższego poziomu w tym projekcie, możesz zobaczyć, jak ten skrypt jest używany do konfigurowania kompilacji.

Po zakończeniu konfiguracji wywołujemy po prostu polecenie make, które tworzy całe drzewo źródłowe wraz z przykładami. Najważniejszym elementem jest libaom.a, który zawiera skompilowany i gotowy do wykorzystania w projekcie dekoder wideo.

Zaprojektowanie interfejsu API służącego do podłączenia biblioteki

Po utworzeniu biblioteki musimy opracować sposób jej wykorzystania, by przesyłać do niej skompresowane dane wideo i odczytywać klatki filmów, które możemy wyświetlić w przeglądarce.

Dobrym punktem wyjścia jest przykładowy dekoder wideo znajdujący się w pliku [simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c). Dekoder odczytuje dane w pliku IVF i dekoduje go, tworząc serię obrazów, które reprezentują klatki filmu.

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

Ponieważ przeglądarka nie potrafi odczytywać plików z systemu plików, musimy zaprojektować interfejs, który pozwoli nam wyodrębnić operacje wejścia-wyjścia, możemy zbudować coś podobnego do przykładowego dekodera i wczytywać dane do biblioteki AV1.

Wejście-wyjście pliku w wierszu poleceń to tzw. interfejs strumienia. Możemy więc zdefiniować własny interfejs, który wygląda jak I/O strumienia, i stworzyć cokolwiek, co chcemy w podstawowej implementacji.

Nasz interfejs definiujemy jako:

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 wyglądają bardzo podobnie do zwykłych operacji wejścia-wyjścia plików, co pozwala je łatwo zmapować na operacje wejścia-wyjścia plików dla aplikacji wiersza poleceń lub zaimplementować je w inny sposób po uruchomieniu w przeglądarce. Typ DATA_Source jest nieprzezroczysty po stronie JavaScriptu i służy tylko do hermetyzacji interfejsu. Pamiętaj, że utworzenie interfejsu API zgodnego z semantyką pliku ułatwia jego ponowne wykorzystanie w wielu innych bazach kodu, które mają być używane z poziomu wiersza poleceń (np. diff, sed itp.).

Musimy też zdefiniować funkcję pomocniczą o nazwie DS_set_blob, która wiąże nieprzetworzone dane binarne z funkcjami wejścia-wyjścia strumienia. Dzięki temu obiekt blob może być „odczytywany” tak, jakby był to strumień (tzn. wyglądał jak plik odczytywany sekwencyjnie).

Nasza przykładowa implementacja umożliwia odczyt przekazywanych w blobach obiektów blob w taki sposób, jakby były to źródło danych z odczytem sekwencyjnym. Kod referencyjny znajduje się w pliku blob-api.c, a 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 jarzma testowego do testowania poza przeglądarką

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

Podczas tworzenia interfejsu WebAssembly w przeglądarce warto utworzyć jakiś rodzaj testu jednostkowego interfejsu z kodem, nad którym pracujemy, co pozwoli na debugowanie poza przeglądarką oraz testowanie naszego interfejsu.

W tym przykładzie emulujemy interfejs API oparty na strumieniu jako interfejs biblioteki AV1. Logicznym jest więc skonstruowanie jarzma testowego, za pomocą którego możemy zbudować wersję naszego interfejsu API, który będzie działać z poziomu wiersza poleceń i wykonywać operacje wejścia-wyjścia pliku bezpośrednio pod tym kątem, implementując sam plik–wejście-wyjście pod naszym interfejsem API DATA_Source.

Kod wejścia-wyjścia strumienia dla naszej wiązki testowej 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 wyodrębnieniu interfejsu strumienia możemy stworzyć moduł WebAssembly, który będzie używać blobów danych binarnych w przeglądarce oraz wyświetlać rzeczywiste pliki, gdy tworzymy kod do przetestowania z poziomu wiersza poleceń. Kod jarzma testowego można znaleźć w przykładowym pliku źródłowym test.c.

Stosowanie mechanizmu buforowania w przypadku wielu klatek wideo

Podczas odtwarzania filmu często buforuje kilka klatek, by zapewnić płynniejsze odtwarzanie. Do naszych celów wdrożymy bufor po 10 klatkach wideo, aby przed rozpoczęciem odtwarzania buforować 10 klatek. Przy każdym wyświetleniu klatki próbujemy zdekodować kolejną klatkę, aby bufor był pełny. Takie podejście daje pewność, że klatki są dostępne z wyprzedzeniem, co pomaga zatrzymać zacinanie się filmu.

W tym prostym przykładzie można odczytać cały skompresowany film, więc buforowanie nie jest konieczne. Jeśli jednak mamy rozszerzyć źródłowy interfejs danych o obsługę danych wejściowych z serwera przesyłanego strumieniowo, musimy wdrożyć mechanizm buforowania.

Kod w komponencie decode-av1.c służący do odczytywania klatek danych wideo z biblioteki AV1 i zapisywania w buforze w ten sposób:

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 może zawierać 10 klatek wideo, co jest wyborem dowolnym. Buforowanie większej liczby klatek oznacza dłuższy czas oczekiwania na rozpoczęcie odtwarzania, a zbyt mało buforowania może spowodować opóźnienie odtwarzania. W implementacji natywnej przeglądarki buforowanie ramek jest znacznie bardziej skomplikowane niż ta implementacja.

Umieszczanie klatek wideo na stronie za pomocą WebGL

Zbuforowane klatki filmu muszą być wyświetlane na naszej stronie. Są to dynamiczne treści wideo, więc chcemy, aby było to możliwe jak najszybciej. Użyjemy do tego WebGL.

WebGL umożliwia nam zrobienie zdjęcia, na przykład kadru filmu, użycie go jako tekstury, która jest nakładana na określone elementy geometryczne. W świecie WebGL element składa się z trójkątów. W naszym przypadku możemy użyć wbudowanej wygodnej funkcji WebGL o nazwie gl.TRIANGLE_FAN.

Wystąpił jednak drobny problem. Tekstury WebGL powinny być obrazami RGB, po jednym bajcie na kanał koloru. Dane wyjściowe z naszego dekodera AV1 to obrazy w tak zwanym formacie YUV, gdzie domyślnie 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 obraz musi zostać przekonwertowany na kolory, zanim przekażemy go do WebView w celu wyświetlenia.

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 z dekodera AV1 na coś, co możemy przekazać do WebGL. Pamiętaj, że gdy wywołujemy tę funkcję z JavaScriptu, musimy upewnić się, że pamięć, w której zapisujemy przekonwertowany obraz, została przydzielona w pamięci modułu WebAssembly – w przeciwnym razie nie będzie można uzyskać do niej dostępu. Funkcja pobierania obrazu z modułu WebAssembly i renderowania go na ekranie jest taka:

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

Funkcja drawImageToCanvas(), która implementuje malowanie WebGL, można znaleźć w pliku źródłowym draw-image.js.

Praca i wnioski w przyszłości

Przetestuj naszą prezentację na 2 testowych plikach filmów (nagranych jako filmy o rozdzielczości 24 kl./s.), aby dowiedzieć się kilku rzeczy:

  1. Stworzenie złożonej bazy kodu i wydajne działanie w przeglądarce przy użyciu WebAssembly jest w pełni możliwe.
  2. Dzięki zastosowaniu WebAssembly można wykonać coś tak bardzo obciążającego procesor, jak zaawansowane dekodowanie wideo.

Są jednak pewne ograniczenia: implementacja odbywa się w głównym wątku, a obrazy i dekodowanie wideo są przeplatane w tym jednym wątku. Przeniesienie dekodowania do instancji roboczej może zapewnić płynniejsze odtwarzanie, ponieważ czas dekodowania klatek zależy w dużej mierze od zawartości ramki i może czasem trwać dłużej, niż zakładaliśmy w budżecie.

Kompilacja do WebAssembly korzysta z konfiguracji AV1 dla ogólnego typu procesora. Jeśli skompilujemy natywnie w wierszu poleceń dla ogólnego procesora, obserwujemy podobny obciążenie procesora pod kątem dekodowania filmu jak w przypadku wersji WebAssembly, ale biblioteka dekodera AV1 zawiera również implementacje SIMD, które działają nawet 5 razy szybciej. Grupa społeczności WebAssembly pracuje obecnie nad rozszerzeniem tego standardu, aby obejmował prymitywy SIMD. W ramach tej zmiany prawdopodobnie znaczne przyspieszenie dekodowania. Jeśli tak się stanie, będzie można dekodować filmy 4K HD w czasie rzeczywistym przy użyciu dekodera wideo WebAssembly.

W każdym przypadku ten przykładowy kod pomaga przenieść dowolne istniejące narzędzie wiersza poleceń do pracy jako moduł WebAssembly i pokazuje, jakie możliwości są już dostępne w internecie.

Środki

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