Browser mit WebAssembly erweitern

Mit WebAssembly können wir den Browser um neue Funktionen erweitern. In diesem Artikel erfahren Sie, wie Sie den AV1-Videodecoder portieren und AV1-Videos in jedem modernen Browser abspielen können.

Alex Danilo

Eines der besten Dinge an WebAssembly ist die Möglichkeit, mit neuen Funktionen zu experimentieren und neue Ideen zu implementieren, bevor der Browser diese Funktionen nativ implementiert (falls überhaupt). Sie können WebAssembly auf diese Weise als leistungsstarken Polyfill-Mechanismus betrachten, bei dem Sie Ihre Funktion in C/C++ oder Rust statt in JavaScript schreiben.

Mit einer Fülle von vorhandenem Code für die Portierung ist es möglich, im Browser Vorgänge auszuführen, die erst mit WebAssembly realisierbar waren.

In diesem Artikel wird anhand eines Beispiels gezeigt, wie Sie den vorhandenen AV1-Videocodec-Quellcode verwenden, einen Wrapper dafür erstellen und ihn in Ihrem Browser testen. Außerdem erhalten Sie Tipps zum Erstellen eines Testharness zum Debuggen des Wrappers. Der vollständige Quellcode für das Beispiel ist unter github.com/GoogleChromeLabs/wasm-av1 verfügbar.

Lade eine dieser beiden Testvideos mit 24 fps herunter und teste sie in unserer Demo.

Eine interessante Codebasis auswählen

Seit einigen Jahren machen Videodaten einen großen Prozentsatz des Traffics im Web aus. Cisco schätzt diesen Anteil sogar auf 80 %. Browseranbieter und Videowebsites sind sich natürlich bewusst, dass der Datenverbrauch durch Videoinhalte reduziert werden soll. Der Schlüssel dazu ist natürlich eine bessere Komprimierung. Wie zu erwarten, wird viel an der Videokomprimierung der nächsten Generation geforscht, um die Datenübertragung von Videos über das Internet zu reduzieren.

Die Alliance for Open Media arbeitet an einem Videokomprimierungssystem der nächsten Generation namens AV1, mit dem sich die Größe von Videodaten erheblich reduzieren lässt. In Zukunft werden Browser voraussichtlich native Unterstützung für AV1 bieten. Glücklicherweise ist der Quellcode für den Kompressor und Dekompressor Open Source. Das macht ihn zu einem idealen Kandidaten für die Kompilierung in WebAssembly, damit wir damit im Browser experimentieren können.

Bild des Films „Bunny“

Für die Verwendung im Browser anpassen

Um diesen Code in den Browser einzubinden, müssen wir uns zuerst mit dem vorhandenen Code vertraut machen, um die API besser zu verstehen. Bei diesem Code fallen zwei Dinge auf:

  1. Der Quellbaum wird mit einem Tool namens cmake erstellt.
  2. Es gibt eine Reihe von Beispielen, die alle eine Art dateibasierte Schnittstelle voraussetzen.

Alle Beispiele, die standardmäßig erstellt werden, können in der Befehlszeile ausgeführt werden, was wahrscheinlich auch bei vielen anderen Codebasen der Community der Fall ist. Die Benutzeroberfläche, die wir erstellen, um das Tool im Browser auszuführen, könnte also für viele andere Befehlszeilentools nützlich sein.

cmake zum Erstellen des Quellcodes verwenden

Glücklicherweise haben die AV1-Entwickler mit Emscripten experimentiert, dem SDK, mit dem wir unsere WebAssembly-Version erstellen werden. Im Stammverzeichnis des AV1-Repositorys enthält die Datei CMakeLists.txt folgende Build-Regeln:

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()

Die Emscripten-Toolchain kann eine Ausgabe in zwei Formaten generieren: asm.js und WebAssembly. Wir werden WebAssembly als Ziel verwenden, da es eine kleinere Ausgabe liefert und schneller ausgeführt werden kann. Mit diesen vorhandenen Build-Regeln soll eine asm.js-Version der Bibliothek für die Verwendung in einer Inspektionsanwendung kompiliert werden, mit der der Inhalt einer Videodatei geprüft wird. Für unsere Zwecke benötigen wir WebAssembly-Ausgabe. Daher fügen wir diese Zeilen direkt vor der abschließenden endif()-Anweisung in den obigen Regeln hinzu.

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

Wenn Sie mit cmake erstellen, müssen Sie zuerst einige Makefiles generieren, indem Sie cmake selbst ausführen. Führen Sie dann den Befehl make aus, um den Kompilierungsschritt auszuführen. Da wir Emscripten nutzen, müssen wir die EMM-Compiler-Toolchain und nicht den Standard-Host-Compiler verwenden. Dazu verwenden Sie Emscripten.cmake, das Teil des Emscripten SDK ist, und übergeben den Pfad als Parameter an cmake selbst. Mit der folgenden Befehlszeile generieren wir die 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

Der Parameter path/to/aom sollte auf den vollständigen Pfad zum Speicherort der AV1-Bibliotheksquellendateien festgelegt werden. Der Parameter path/to/emsdk-portable/…/Emscripten.cmake muss auf den Pfad für die Beschreibungsdatei der Toolchain Emscripten.cmake festgelegt werden.

Der Einfachheit halber verwenden wir ein Shell-Skript, um diese Datei zu finden:

#!/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

Wenn Sie sich die oberste Makefile für dieses Projekt ansehen, sehen Sie, wie dieses Script zum Konfigurieren des Builds verwendet wird.

Nachdem die gesamte Einrichtung abgeschlossen ist, rufen wir einfach make auf. Dadurch wird der gesamte Quellbaum einschließlich der Samples erstellt. Vor allem wird aber libaom.a generiert, das den kompilierten Videodecoder enthält, der in unser Projekt eingebunden werden kann.

API für die Verbindung mit der Bibliothek entwerfen

Nachdem wir unsere Bibliothek erstellt haben, müssen wir herausfinden, wie wir mit ihr interagieren, um komprimierte Videodaten an sie zu senden und dann Videoframes abzuspielen, die wir im Browser anzeigen können.

Wenn Sie sich den AV1-Codebaum ansehen, ist ein Beispiel-Videodekoder in der Datei [simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c) ein guter Ausgangspunkt. Dieser Decoder liest eine IVF-Datei ein und decodiert sie in eine Reihe von Bildern, die die Frames im Video darstellen.

Wir implementieren unsere Schnittstelle in der Quelldatei [decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c).

Da unser Browser keine Dateien aus dem Dateisystem lesen kann, müssen wir eine Schnittstelle entwerfen, mit der wir unsere E/A abstrahieren können, damit wir etwas Ähnliches wie dem Beispieldecoder erstellen können, um Daten in unsere AV1-Bibliothek zu laden.

In der Befehlszeile ist Datei-E/A eine sogenannte Stream-Schnittstelle. Wir können also einfach unsere eigene Schnittstelle definieren, die wie Stream-E/A aussieht, und eine beliebige davon in der zugrunde liegenden Implementierung erstellen.

Wir definieren unsere Benutzeroberfläche so:

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

Die open/read/empty/close-Funktionen ähneln sehr den normalen Datei-I/O-Vorgängen, sodass wir sie leicht auf die Datei-I/O für eine Befehlszeilenanwendung abbilden oder auf andere Weise implementieren können, wenn sie in einem Browser ausgeführt werden. Der Typ DATA_Source ist von der JavaScript-Seite aus nicht transparent und dient nur dazu, die Schnittstelle zu kapseln. Beachten Sie, dass eine API, die genau der Dateisemantik folgt, sich leicht in vielen anderen Codebases wiederverwenden lässt, die über eine Befehlszeile verwendet werden sollen (z. B. diff, sed usw.).

Außerdem müssen wir eine Hilfsfunktion namens DS_set_blob definieren, die Roh-Binärdaten an unsere Stream-E/A-Funktionen bindet. So kann der Blob so gelesen werden, als wäre er ein Stream (d. h. als sequenziell gelesene Datei).

Unsere Beispielimplementierung ermöglicht das Lesen des übergebenen Blobs so, als wäre es eine sequenziell gelesene Datenquelle. Der Referenzcode befindet sich in der Datei blob-api.c. Die gesamte Implementierung sieht so aus:

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

Test-Harness zum Testen außerhalb des Browsers erstellen

Eine der Best Practices im Softwareentwicklungsprozess besteht darin, Unit-Tests für Code in Verbindung mit Integrationstests zu erstellen.

Wenn Sie mit WebAssembly im Browser entwickeln, ist es sinnvoll, eine Art von Unit-Test für die Schnittstelle zum Code zu erstellen, mit dem Sie arbeiten, damit Sie außerhalb des Browsers debuggen und die von Ihnen erstellte Benutzeroberfläche testen können.

In diesem Beispiel haben wir eine streambasierte API als Schnittstelle zur AV1-Bibliothek emuliert. Logischerweise ist es also sinnvoll, eine Testumgebung zu erstellen, mit der wir eine Version unserer API erstellen können, die in der Befehlszeile ausgeführt wird und im Hintergrund echte Datei-E/A-Vorgänge ausführt. Dazu implementieren Sie die Datei-E/A selbst unter unserer DATA_Source API.

Der Stream-E/A-Code für unseren Test-Harness ist einfach und sieht so aus:

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

Durch die Abstraktion der Stream-Schnittstelle können wir unser WebAssembly-Modul so erstellen, dass es im Browser binäre Daten-Blobs verwendet, und eine Schnittstelle zu echten Dateien herstellen, wenn wir den Code zur Ausführung über die Befehlszeile erstellen. Den Code für den Test-Harness finden Sie in der Beispiel-Quelldatei test.c.

Puffermechanismus für mehrere Videoframes implementieren

Bei der Videowiedergabe werden häufig einige Frames zwischengespeichert, um eine flüssigere Wiedergabe zu ermöglichen. Für unsere Zwecke implementieren wir einfach einen Puffer mit 10 Videoframes. Das bedeutet, dass 10 Frames vor Beginn der Wiedergabe im Puffer gespeichert werden. Jedes Mal, wenn ein Frame angezeigt wird, versuchen wir, einen weiteren Frame zu decodieren, damit der Puffer voll bleibt. Dieser Ansatz sorgt dafür, dass Frames im Voraus zur Verfügung stehen, um das Ruckeln des Videos zu stoppen.

In unserem einfachen Beispiel kann das gesamte komprimierte Video gelesen werden, sodass das Puffern nicht wirklich erforderlich ist. Wenn wir die Schnittstelle für Quelldaten jedoch erweitern möchten, um den Streaming-Eingang von einem Server zu unterstützen, ist der Pufferungsmechanismus erforderlich.

Der Code in decode-av1.c zum Lesen von Frames von Videodaten aus der AV1-Bibliothek und zum Speichern im Puffer sieht so aus:

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


Wir haben uns dafür entschieden, dass der Puffer 10 Videoframes enthalten soll. Dies ist eine willkürliche Wahl. Je mehr Frames zwischengespeichert werden, desto länger dauert es, bis die Wiedergabe des Videos beginnt. Wenn zu wenige Frames zwischengespeichert werden, kann es während der Wiedergabe zu Rucklern kommen. Bei einer nativen Browserimplementierung ist das Puffern von Frames viel komplexer als bei dieser Implementierung.

Videoframes mit WebGL auf die Seite bringen

Die Videoframes, die wir zwischengespeichert haben, müssen auf unserer Seite angezeigt werden. Da es sich um dynamische Videoinhalte handelt, möchten wir das so schnell wie möglich tun. Dazu verwenden wir WebGL.

Mit WebGL können wir ein Bild, z. B. einen Videoframe, als Textur verwenden, die auf eine Geometrie gemalt wird. In der WebGL-Welt besteht alles aus Dreiecken. In unserem Fall können wir also eine praktische integrierte Funktion von WebGL namens gl.TRIANGLE_FAN verwenden.

Es gibt jedoch ein kleines Problem. WebGL-Texturen müssen RGB-Bilder mit einem Byte pro Farbkanal sein. Die Ausgabe unseres AV1-Decoders sind Bilder im sogenannten YUV-Format, bei dem die Standardausgabe 16 Bit pro Kanal hat und jeder U- oder V-Wert 4 Pixeln im tatsächlichen Ausgabebild entspricht. Das bedeutet, dass wir das Bild in eine andere Farbvorlage umwandeln müssen, bevor wir es zur Anzeige an WebGL übergeben können.

Dazu implementieren wir die Funktion AVX_YUV_to_RGB(), die Sie in der Quelldatei yuv-to-rgb.c finden. Diese Funktion wandelt die Ausgabe des AV1-Decoders in etwas um, das wir an WebGL übergeben können. Wenn wir diese Funktion aus JavaScript aufrufen, müssen wir darauf achten, dass der Speicher, in den wir das konvertierte Bild schreiben, im Speicher des WebAssembly-Moduls zugewiesen wurde. Andernfalls kann es nicht darauf zugreifen. Mit der folgenden Funktion können Sie ein Bild aus dem WebAssembly-Modul abrufen und auf den Bildschirm übertragen:

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

Die Funktion drawImageToCanvas(), die die WebGL-Malerei implementiert, finden Sie in der Quelldatei draw-image.js.

Zukünftige Arbeit und Erkenntnisse

Wenn wir unsere Demo mit zwei Test-Video-dateien (aufgenommen mit 24 fps) ausprobieren, können wir einige Dinge feststellen:

  1. Es ist durchaus möglich, mit WebAssembly eine komplexe Codebasis zu erstellen, die leistungsstark im Browser ausgeführt wird.
  2. Auch CPU-intensive Aufgaben wie die erweiterte Videodekodierung sind mit WebAssembly möglich.

Es gibt jedoch einige Einschränkungen: Die gesamte Implementierung wird im Hauptthread ausgeführt und wir überlagern die Darstellung und Videodekodierung in diesem einzigen Thread. Die Auslagerung der Decodierung in einen Web Worker könnte eine reibungslose Wiedergabe ermöglichen, da die Zeit zum Decodieren der Frames stark vom Inhalt des Frames abhängt und manchmal mehr Zeit in Anspruch nimmt, als im Budget festgelegt ist.

Die Kompilierung in WebAssembly verwendet die AV1-Konfiguration für einen generischen CPU-Typ. Wenn wir nativ in der Befehlszeile für eine generische CPU kompilieren, sehen wir eine ähnliche CPU-Auslastung für die Videodekodierung wie bei der WebAssembly-Version. Die AV1-Dekodierungsbibliothek enthält jedoch auch SIMD-Implementierungen, die bis zu fünfmal schneller laufen. Die WebAssembly Community Group arbeitet derzeit daran, den Standard um SIMD-Primitive zu erweitern. Wenn das der Fall ist, wird die Dekodierung erheblich beschleunigt. Dann ist es möglich, 4K-HD-Videos in Echtzeit mit einem WebAssembly-Videodekoder zu decodieren.

Der Beispielcode ist in jedem Fall als Orientierungshilfe für die Portierung eines vorhandenen Befehlszeilendienstprogramms zur Ausführung als WebAssembly-Modul nützlich und zeigt, was bereits heute im Web möglich ist.

Gutschriften

Vielen Dank an Jeff Posnick, Eric Bidelman und Thomas Steiner für ihre wertvollen Rezensionen und ihr Feedback.