Mit WebAssembly können wir den Browser um neue Funktionen erweitern. In diesem Artikel wird beschrieben, wie Sie den AV1-Videodecoder portieren und AV1-Videos in jedem modernen Browser abspielen.
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.
Da es eine Vielzahl von Code gibt, der für die Portierung zur Verfügung steht, sind im Browser Dinge möglich, die bis zur Einführung von WebAssembly nicht 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 stellen wir fest, dass ein großer Prozentsatz des Traffics im Web aus Videodaten besteht. 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.
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:
- Der Quellbaum wird mit einem Tool namens
cmake
erstellt. - 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. Das gilt wahrscheinlich auch für viele andere Codebases, die in der Community verfügbar sind. Die Benutzeroberfläche, die wir erstellen, um das Tool im Browser auszuführen, könnte also für viele andere Befehlszeilentools nützlich sein.
Quellcode mit cmake
erstellen
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 konzentrieren uns auf WebAssembly, da es eine kleinere Ausgabe erzeugt 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 verwenden, müssen wir die Emscripten-Compiler-Toolchain anstelle des Standard-Host-Compilers 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 zum Speicherort der AV1-Bibliotheksquellendateien festgelegt werden. Der Parameter path/to/emsdk-portable/…/Emscripten.cmake
muss auf den Pfad zur toolchain-Beschreibungsdatei „Emscripten.cmake“ festgelegt werden.
Der Einfachheit halber verwenden wir ein Shell-Script, 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-Videodecoder 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 Art Schnittstelle entwerfen, mit der wir unsere E/A abstrahieren können, damit wir etwas Ähnliches wie den Beispiel-Decoder erstellen können, um Daten in unsere AV1-Bibliothek aufzunehmen.
In der Befehlszeile wird die Dateieingabe/‑ausgabe als Streamschnittstelle bezeichnet. Wir können also einfach eine eigene Schnittstelle definieren, die wie eine Stream-E/A aussieht, und in der zugrunde liegenden Implementierung alles nach Belieben 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 wie ein Stream gelesen werden, d.h., er sieht aus wie eine sequenziell gelesene Datei.
Mit unserer Beispielimplementierung kann der übergebene Blob so gelesen werden, als wäre es eine sequenziell lesbare 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. Daher ist es logisch, einen Test-Harness zu erstellen, mit dem wir eine Version unserer API erstellen können, die in der Befehlszeile ausgeführt wird und die tatsächliche Datei-E/A im Hintergrund durch Implementieren der Datei-E/A selbst unter unserer DATA_Source
API ausführt.
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 unseren 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. So sind Frames im Voraus verfügbar, um Ruckler im Video zu vermeiden.
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, den Puffer 10 Videoframes groß zu machen. Das ist eine willkürliche Entscheidung. 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. So rufen Sie ein Bild aus dem WebAssembly-Modul ab und zeichnen es auf dem Bildschirm:
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:
- Es ist durchaus möglich, mit WebAssembly eine komplexe Codebasis zu erstellen, die leistungsstark im Browser ausgeführt wird.
- 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 einzelnen Thread. Wenn wir die Dekodierung auf einen Webworker auslagern, kann die Wiedergabe flüssiger ablaufen, da die Zeit für die Dekodierung von Frames stark vom Inhalt des Frames abhängt und manchmal länger als geplant dauert.
Bei der Kompilierung in WebAssembly wird die AV1-Konfiguration für einen generischen CPU-Typ verwendet. 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 dies der Fall ist, wird die Dekodierung erheblich beschleunigt. Dann ist es möglich, 4K-HD-Videos in Echtzeit mit einem WebAssembly-Videodekoder zu decodieren.
In jedem Fall ist der Beispielcode hilfreich, um ein vorhandenes Befehlszeilen-Dienstprogramm für die Ausführung als WebAssembly-Modul zu portieren. Außerdem zeigt er, was im Web bereits heute möglich ist.
Gutschriften
Vielen Dank an Jeff Posnick, Eric Bidelman und Thomas Steiner für ihre wertvollen Rezensionen und ihr Feedback.