Browser mit WebAssembly erweitern

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

Alex Danilo

Eines der besten an WebAssembly ist die Möglichkeit, mit neuen Funktionen zu experimentieren und neue Ideen zu implementieren, bevor der Browser diese Funktionen nativ (falls überhaupt) liefert. Sie können sich die Verwendung von WebAssembly als einen Hochleistungs-Polyfill-Mechanismus vorstellen, bei dem Sie Ihre Funktion in C/C++ oder Rust anstatt in JavaScript schreiben.

Da eine Fülle von vorhandenem Code für die Mitnahme zur Verfügung steht, ist es möglich, Vorgänge im Browser auszuführen, die mit der Einführung von WebAssembly nicht realisierbar waren.

In diesem Artikel wird anhand eines Beispiels erläutert, wie Sie den vorhandenen AV1-Video-Codec-Quellcode verwenden, einen Wrapper dafür erstellen und diesen in Ihrem Browser ausprobieren. Außerdem erhalten Sie Tipps zum Erstellen eines Test-Harnischs zum Debuggen des Wrappers. Den vollständigen Quellcode für dieses Beispiel finden Sie unter github.com/GoogleChromeLabs/wasm-av1.

Lade eine dieser beiden Testvideodateien mit 24 fps herunter und probiere sie in unserer Demo aus.

Eine interessante Codebasis auswählen

Seit einigen Jahren besteht ein großer Prozentsatz des Traffics im Web aus Videodaten. Cisco schätzt ihn sogar auf bis zu 80 %. Browseranbieter und Videowebsites sind sich natürlich des Wunschs bewusst, den Datenverbrauch für all diese Videoinhalte zu reduzieren. Der Schlüssel dafür ist natürlich eine bessere Komprimierung. Wie zu erwarten ist, wird viel über die Videokomprimierung der nächsten Generation erforscht, um die Belastung durch den Versand von Videos über das Internet zu reduzieren.

Derzeit arbeitet die Alliance for Open Media an einem Videokomprimierungsschema der nächsten Generation namens AV1, das eine deutliche Verkleinerung der Videodaten verspricht. Wir gehen davon aus, dass Browser in Zukunft native Unterstützung für AV1 bereitstellen werden, aber zum Glück ist der Quellcode für den Kompressor und den Dekomprimierungor Open Source, was sich ideal für den Versuch eignet, ihn in WebAssembly zu kompilieren, damit wir ihn im Browser testen können.

Bild eines Hasenfilms.

Sich an die Verwendung im Browser anpassen

Um diesen Code in den Browser zu laden, müssen wir uns zuerst mit dem vorhandenen Code vertraut machen, um die API zu verstehen. Beim ersten Betrachten dieses Codes stechen zwei Dinge hervor:

  1. Die Quellstruktur wird mit dem Tool cmake erstellt.
  2. Bei einigen Beispielen wird von einer Art dateibasierter Schnittstelle ausgegangen.

Alle standardmäßig erstellten Beispiele können in der Befehlszeile ausgeführt werden. Dies gilt wahrscheinlich auch für viele andere Codebasen, die in der Community verfügbar sind. Die Schnittstelle, mit der wir sie im Browser ausführen, könnte also für viele andere Befehlszeilentools nützlich sein.

cmake zum Erstellen des Quellcodes verwenden

Glücklicherweise haben die AV1-Autoren mit Emscripten experimentiert, dem SDK, mit dem wir unsere WebAssembly-Version erstellen. Im Stammverzeichnis des AV1-Repositorys enthält die Datei CMakeLists.txt diese 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 Ausgabe in zwei Formaten generieren: asm.js und WebAssembly. Wir nehmen WebAssembly als Ziel vor, da es eine kleinere Ausgabe generiert und schneller ausgeführt werden kann. Mit diesen vorhandenen Build-Regeln soll eine asm.js-Version der Bibliothek zur Verwendung in einer Inspector-Anwendung kompiliert werden, die den Inhalt einer Videodatei überprüft. Da wir die WebAssembly-Ausgabe benötigen, fügen wir diese Zeilen direkt vor der schließenden endif()-Anweisung in den obigen Regeln ein.

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

Beim Erstellen mit cmake wird zuerst ein Makefiles durch Ausführung von cmake generiert. Anschließend wird der Befehl make ausgeführt, mit dem der Kompilierungsschritt durchgeführt wird. Da wir Emscripten verwenden, müssen wir die Emscripten-Compiler-Toolchain und nicht den Standard-Host-Compiler verwenden. Dazu wird Emscripten.cmake verwendet, das Teil des Emscripten SDK ist, und dessen Pfad als Parameter an cmake übergeben. 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 des Speicherorts der Quelldateien der AV1-Bibliothek festgelegt werden. Der Parameter path/to/emsdk-portable/…/Emscripten.cmake muss auf den Pfad für die Toolchain-Beschreibungsdatei 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

In der obersten Makefile-Ebene für dieses Projekt sehen Sie, wie dieses Skript zur Konfiguration des Builds verwendet wird.

Nachdem die Einrichtung nun abgeschlossen ist, rufen wir einfach make auf. Dadurch wird der gesamte Quellbaum einschließlich Beispielen erstellt, aber vor allem libaom.a, der den kompilierten Videodecoder enthält, den wir in unser Projekt einbinden können.

API für die Schnittstelle zur Bibliothek entwerfen

Nachdem wir unsere Bibliothek erstellt haben, müssen wir überlegen, wie wir sie nutzen, um komprimierte Videodaten an sie zu senden und dann Video-Frames zurückzulesen, die wir im Browser anzeigen können.

Ein Blick in den AV1-Codebaum ist ein guter Ausgangspunkt für einen Videodecoder, der sich in der Datei [simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c) befindet. Dieser Decodierer liest eine IVF-Datei 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 das Beispieldecoder erstellen können, um Daten in unsere AV1-Bibliothek zu laden.

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

Unsere Schnittstelle ist so definiert:

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 normalen Datei-E/A-Vorgängen sehr, sodass wir sie einfach der Datei-E/A für eine Befehlszeilenanwendung zuordnen 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 das Erstellen einer API, die der Dateisemantik genau entspricht, die Wiederverwendung in vielen anderen Codebasis vereinfacht, die über eine Befehlszeile verwendet werden soll (z.B. diff, sed usw.).

Außerdem müssen wir eine Hilfsfunktion namens DS_set_blob definieren, die binäre Rohdaten an unsere Stream-E/A-Funktionen bindet. Dadurch kann das Blob wie ein Stream gelesen werden, d.h., es sieht wie eine sequenziell gelesene Datei aus.

Unsere Beispielimplementierung ermöglicht das Lesen des übergebenen Blobs so, als wäre es eine sequenziell gelesene Datenquelle. Den Referenzcode finden Sie 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-Harnisch für Tests außerhalb des Browsers erstellen

Eine der Best Practices in der Softwareentwicklung ist das Erstellen von Unittests für Code in Verbindung mit Integrationstests.

Wenn Sie WebAssembly im Browser verwenden, ist es sinnvoll, eine Form von Einheitentest für die Schnittstelle zu dem Code zu erstellen, mit dem wir arbeiten. So können wir Fehler außerhalb des Browsers beheben und auch die von uns erstellte Schnittstelle testen.

In diesem Beispiel haben wir eine streambasierte API als Schnittstelle zur AV1-Bibliothek emuliert. Daher ist es logisch sinnvoll, einen Test-Harnisch zu erstellen, mit dem Sie eine Version unserer API erstellen können, die in der Befehlszeile ausgeführt wird und Datei-E/A im Hintergrund ausführt, indem Sie die Datei-E/A selbst unter unserer DATA_Source API implementieren.

Der Stream-E/A-Code für unseren Test-Harnisch ist unkompliziert 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);
}

Wenn wir die Stream-Schnittstelle abstrahieren, können wir unser WebAssembly-Modul erstellen, das Binärdaten-Blobs im Browser verwendet und eine Schnittstelle zu echten Dateien herstellt, wenn wir den zu testenden Code über die Befehlszeile erstellen. Den Test-Harnischcode finden Sie in der Beispielquelldatei test.c.

Implementierung eines Puffermechanismus für mehrere Videoframes

Bei der Wiedergabe von Videos werden einige Frames häufig zwischengespeichert, um eine reibungslosere Wiedergabe zu ermöglichen. Wir implementieren einfach einen Puffer von 10 Video-Frames, also müssen 10 Frames vor der Wiedergabe gepuffert werden. Dann versuchen wir jedes Mal, wenn ein Frame angezeigt wird, einen weiteren Frame zu decodieren, damit der Puffer voll bleibt. Dadurch wird sichergestellt, dass Frames im Voraus verfügbar sind, um das Ruckeln des Videos zu verhindern.

Bei unserem einfachen Beispiel steht das gesamte komprimierte Video zum Lesen zur Verfügung, sodass die Zwischenspeicherung nicht wirklich erforderlich ist. Wenn wir die Quelldatenschnittstelle jedoch erweitern möchten, um die Streamingeingabe von einem Server zu unterstützen, muss ein Puffermechanismus vorhanden sein.

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

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 Zwischenspeicher 10 Videoframes enthält. Dies ist nur eine beliebige Wahl. Wenn mehr Frames zwischengespeichert werden, dauert es länger, bis die Wiedergabe des Videos beginnt. Wenn zu wenige Frames gepuffert werden, kann dies zu Verzögerungen bei der Wiedergabe führen. In einer nativen Browserimplementierung ist die Zwischenspeicherung von Frames wesentlich komplexer als diese Implementierung.

Videoframes mit WebGL auf die Seite einfügen

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

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

Es gibt jedoch ein kleineres Problem. WebGL-Texturen müssen RGB-Bilder sein, ein Byte pro Farbkanal. Die Ausgabe von unserem AV1-Decoder ist Bilder im sogenannten YUV-Format, wobei die Standardausgabe 16 Bit pro Kanal hat und jeder U- oder V-Wert 4 Pixeln im eigentlichen Ausgabebild entspricht. Das bedeutet, dass wir das Bild farblich konvertieren müssen, bevor es zur Darstellung an WebGL übergeben werden kann.

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, muss der Arbeitsspeicher, in den wir das konvertierte Bild schreiben, im Arbeitsspeicher des WebAssembly-Moduls zugewiesen sein. Andernfalls kann er nicht darauf zugreifen. Die Funktion, um ein Bild aus dem WebAssembly-Modul abzurufen und auf den Bildschirm zu übertragen, lautet:

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(), mit der das WebGL-Gemälde implementiert wird, finden Sie in der Quelldatei draw-image.js.

Zukünftige Arbeiten und Erkenntnisse

Wenn wir unsere Demo mit zwei Testdateien testen, die als Video mit 24 f.p.s aufgezeichnet wurden, ergeben sich folgende Erkenntnisse:

  1. Es ist durchaus möglich, mit WebAssembly eine komplexe Codebasis aufzubauen, die dann leistungsfähig im Browser ausgeführt wird.
  2. Mit WebAssembly ist etwas so CPU-intensiv wie eine erweiterte Videodecodierung.

Es gibt jedoch einige Einschränkungen: Die Implementierung wird komplett im Hauptthread ausgeführt und die Painting- und Videodecodierung wird auf diesem einzelnen Thread verschränkt. Die Auslagerung der Decodierung in einen Web Worker könnte eine reibungslosere Wiedergabe ermöglichen, da die Dauer zum Decodieren der Frames stark vom Inhalt des Frames abhängt und manchmal mehr Zeit in Anspruch nehmen kann, als wir geplant haben.

Bei der Kompilierung in WebAssembly wird die AV1-Konfiguration für einen generischen CPU-Typ verwendet. Wenn wir für eine generische CPU nativ in der Befehlszeile kompilieren, stellen wir eine ähnliche CPU-Last zum Decodieren des Videos wie bei der WebAssembly-Version fest. Die AV1-Decoderbibliothek enthält jedoch auch SIMD-Implementierungen, die bis zu fünfmal schneller ausgeführt werden. Die WebAssembly Community Group arbeitet derzeit daran, den Standard um SIMD-Primitive zu erweitern. Sollte dies geschehen, wird die Decodierung stark beschleunigt. In diesem Fall besteht die Möglichkeit, 4K-HD-Videos mit einem WebAssembly-Videodecoder in Echtzeit zu decodieren.

Auf jeden Fall ist der Beispielcode als Leitfaden für die Portierung eines vorhandenen Befehlszeilen-Dienstprogramms als WebAssembly-Modul nützlich und zeigt, was bereits im Web möglich ist.

Guthaben

Wir danken Jeff Posnick, Eric Bidelman und Thomas Steiner für die wertvollen Rezensionen und das Feedback.