Étendre le navigateur avec WebAssembly

WebAssembly nous permet d'étendre le navigateur avec de nouvelles fonctionnalités. Cet article explique comment porter le décodeur vidéo AV1 et lire des vidéos AV1 dans un navigateur récent.

Alex Danilo

L'un des principaux avantages de WebAssembly est l'expérimentation de nouvelles capacités et la mise en œuvre de nouvelles idées avant que le navigateur ne déploie ces fonctionnalités de manière native (le cas échéant). Vous pouvez considérer WebAssembly de cette façon comme un mécanisme polyfill hautes performances, dans lequel vous écrivez votre caractéristique en C/C++ ou Rust plutôt qu'en JavaScript.

Avec un grand nombre de codes existants disponibles pour le portage, il est possible de faire dans le navigateur des tâches qui n'étaient pas viables avant l'arrivée de WebAssembly.

Cet article présente un exemple d'utilisation du code source de codec vidéo AV1 existant, de création d'un wrapper pour celui-ci et de l'essayer dans votre navigateur. Il fournit également des conseils pour créer un exercice de test afin de déboguer le wrapper. Le code source complet de l'exemple présenté ici est disponible à l'adresse github.com/GoogleChromeLabs/wasm-av1.

Téléchargez l'un de ces deux fichiers vidéo test à 24 ips et essayez-le sur notre démonstration.

Choisir un codebase intéressant

Depuis un certain nombre d'années, nous avons constaté qu'une grande part du trafic sur le Web est constituée de données vidéo. Cisco lui estime qu'il s'élève à 80 %. Bien entendu, les fournisseurs de navigateurs et les sites de vidéos sont parfaitement conscients du souhait de réduire la quantité de données consommées par tout ce contenu vidéo. La clé est évidemment d'améliorer la compression. Comme on peut s'y attendre, de nombreuses recherches sont consacrées à la compression vidéo nouvelle génération visant à réduire la charge que représente la livraison de vidéos sur Internet.

En l'occurrence, Alliance for Open Media a travaillé sur un schéma de compression vidéo nouvelle génération appelé AV1, qui promet de réduire considérablement la taille des données vidéo. À l'avenir, nous nous attendons à ce que les navigateurs proposent une compatibilité native avec AV1. Heureusement, le code source du compresseur et du décompresseur est Open Source, ce qui en fait un candidat idéal pour le compiler dans WebAssembly afin de pouvoir le tester dans le navigateur.

Image de film de lapin.

Adaptation pour une utilisation dans le navigateur

L'une des premières choses à faire pour intégrer ce code dans le navigateur est d'apprendre à connaître le code existant pour comprendre à quoi ressemble l'API. Lorsque vous examinez ce code pour la première fois, deux choses ressortent:

  1. L'arborescence source est construite à l'aide d'un outil appelé cmake.
  2. Un certain nombre d'exemples supposent tous une sorte d'interface basée sur des fichiers.

Tous les exemples créés par défaut peuvent être exécutés en ligne de commande, ce qui est probablement le cas dans de nombreuses autres bases de code disponibles dans la communauté. L'interface que nous allons créer pour l'exécuter dans le navigateur pourrait donc être utile pour de nombreux autres outils de ligne de commande.

Utiliser cmake pour compiler le code source

Heureusement, les auteurs AV1 ont testé Emscripten, le SDK que nous allons utiliser pour créer notre version WebAssembly. À la racine du dépôt AV1, le fichier CMakeLists.txt contient les règles de compilation suivantes:

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

La chaîne d'outils Emscripten peut générer une sortie dans deux formats : l'un s'appelle asm.js et l'autre WebAssembly. Nous allons cibler WebAssembly, car il génère un résultat plus petit et peut s'exécuter plus rapidement. Ces règles de compilation existantes sont destinées à compiler une version asm.js de la bibliothèque afin de l'utiliser dans une application d'inspection qui permet d'examiner le contenu d'un fichier vidéo. Pour notre utilisation, nous avons besoin de la sortie WebAssembly. Nous ajoutons donc ces lignes juste avant l'instruction de fermeture endif() dans les règles ci-dessus.

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

La compilation avec cmake signifie d'abord générer des Makefiles en exécutant cmake lui-même, puis en exécutant la commande make qui effectuera l'étape de compilation. Notez que puisque nous utilisons Emscripten, nous devons utiliser la chaîne d'outils du compilateur Emscripten plutôt que le compilateur hôte par défaut. Pour ce faire, utilisez Emscripten.cmake, qui fait partie du SDK Emscripten, et transmettez son chemin d'accès en tant que paramètre à cmake lui-même. La ligne de commande ci-dessous est celle que nous utilisons pour générer les fichiers 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

Le paramètre path/to/aom doit être défini sur le chemin d'accès complet de l'emplacement des fichiers sources de la bibliothèque AV1. Le paramètre path/to/emsdk-portable/…/Emscripten.cmake doit être défini sur le chemin du fichier de description de la chaîne d'outils Emscripten.cmake.

Pour plus de commodité, nous utilisons un script shell pour localiser ce fichier:

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

Si vous examinez l'élément Makefile de premier niveau de ce projet, vous pouvez voir comment ce script est utilisé pour configurer la compilation.

Maintenant que toute la configuration est terminée, nous appelons simplement make, qui va créer l'intégralité de l'arborescence source, y compris les exemples, et surtout générer libaom.a, qui contient le décodeur vidéo compilé et prêt à être intégré dans notre projet.

Concevoir une API pour communiquer avec la bibliothèque

Une fois notre bibliothèque créée, nous devons trouver une interface pour lui envoyer des données vidéo compressées, puis lire les images vidéo que nous pouvons afficher dans le navigateur.

Si vous examinez l'arborescence de code AV1, vous pouvez partir d'un exemple de décodeur vidéo disponible dans le fichier [simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c). Ce décodeur lit un fichier IVF et le décode en une série d'images représentant les images de la vidéo.

Nous implémentons l'interface dans le fichier source [decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c).

Comme notre navigateur ne peut pas lire les fichiers du système de fichiers, nous devons concevoir une forme d'interface qui nous permette d'extraire nos E/S afin de créer un outil semblable à l'exemple de décodeur pour importer des données dans notre bibliothèque AV1.

Sur la ligne de commande, les E/S de fichiers sont ce qu'on appelle une interface de flux. Nous pouvons donc simplement définir notre propre interface qui ressemble à des E/S de flux et compiler ce que nous voulons dans l'implémentation sous-jacente.

Nous définissons notre interface de la manière suivante:

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

Les fonctions open/read/empty/close ressemblent beaucoup aux opérations d'E/S de fichier normales, ce qui nous permet de les mapper facilement sur les E/S de fichiers pour une application de ligne de commande, ou de les implémenter d'une autre manière lorsqu'elles sont exécutées dans un navigateur. Le type DATA_Source est opaque du côté JavaScript et sert uniquement à encapsuler l'interface. Notez que la création d'une API qui respecte la sémantique des fichiers facilite la réutilisation dans de nombreux autres codebases destinés à être utilisés à partir d'une ligne de commande (par exemple, diff, sed, etc.).

Nous devons également définir une fonction d'assistance appelée DS_set_blob qui lie les données binaires brutes à nos fonctions d'E/S de flux. Cela permet de "lire" l'objet blob comme s'il s'agissait d'un flux (c'est-à-dire comme s'il s'agissait d'un fichier lu de manière séquentielle).

Notre exemple d'implémentation permet de lire l'objet blob transmis comme s'il s'agissait d'une source de données à lecture séquentielle. Le code de référence se trouve dans le fichier blob-api.c. L'implémentation complète est la suivante:

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

Créer un atelier de test pour effectuer des tests en dehors du navigateur

L'une des bonnes pratiques en ingénierie logicielle consiste à créer des tests unitaires pour le code conjointement avec des tests d'intégration.

Lorsque vous compilez avec WebAssembly dans le navigateur, il est judicieux de créer une forme de test unitaire pour l'interface avec le code avec lequel nous travaillons afin de pouvoir déboguer en dehors du navigateur et tester l'interface que nous avons créée.

Dans cet exemple, nous avons émulé une API basée sur les flux en tant qu'interface vers la bibliothèque AV1. Il est donc logique de créer un atelier de test que nous pouvons utiliser pour créer une version de notre API qui s'exécute sur la ligne de commande et effectue des E/S de fichiers en arrière-plan en implémentant les E/S de fichiers elles-mêmes sous notre API DATA_Source.

Le code d'E/S de flux pour notre atelier de test est simple et ressemble à ceci:

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

En extrayant l'interface de flux, nous pouvons créer notre module WebAssembly pour utiliser des blobs de données binaires dans le navigateur et nous connecter à des fichiers réels lorsque nous compilons le code à tester à partir de la ligne de commande. Le code de notre atelier de test est disponible dans l'exemple de fichier source test.c.

Implémenter un mécanisme de mise en mémoire tampon pour plusieurs images vidéo

Lors de la lecture d'une vidéo, il est courant de mettre quelques images en mémoire tampon pour une lecture plus fluide. Pour les besoins de cet atelier, nous allons simplement implémenter un tampon de 10 images de la vidéo, de sorte que nous mettions 10 images en mémoire tampon avant de commencer la lecture. Ensuite, chaque fois qu'une image s'affiche, nous essayons de décoder une autre image afin de garder la mémoire tampon pleine. Cette approche garantit que les images sont disponibles à l'avance pour aider à arrêter le stuttering de la vidéo.

Dans notre exemple simple, la vidéo compressée entière peut être lue. La mise en mémoire tampon n'est donc pas vraiment nécessaire. Toutefois, si vous souhaitez étendre l'interface de données sources pour prendre en charge l'entrée en flux continu d'un serveur, vous devez mettre en place le mécanisme de mise en mémoire tampon.

Voici le code dans decode-av1.c permettant de lire les images des données vidéo de la bibliothèque AV1 et de les stocker dans le tampon, comme suit:

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


Nous avons choisi de faire en sorte que le tampon contienne 10 images de vidéo, ce qui n'est qu'un choix arbitraire. La mise en mémoire tampon d'un plus grand nombre d'images augmente le temps d'attente pour le démarrage de la lecture de la vidéo. En revanche, une mise en mémoire tampon trop faible peut entraîner un blocage lors de la lecture. Dans une implémentation de navigateur native, la mise en mémoire tampon des frames est beaucoup plus complexe que cette implémentation.

Afficher les images vidéo sur la page avec WebGL

Les images de la vidéo que nous avons mises en mémoire tampon doivent être affichées sur notre page. Comme il s'agit d'un contenu vidéo dynamique, nous souhaitons être en mesure de le faire aussi rapidement que possible. Pour cela, nous nous tournons vers WebGL.

WebGL nous permet de prendre une image, comme un cadre vidéo, et de l'utiliser comme texture à appliquer à une géométrie. Dans le monde WebGL, tout est constitué de triangles. Nous pouvons donc utiliser une fonctionnalité intégrée de WebGL, appelée gl.TRIANGLE_FAN.

Il y a toutefois un petit problème. Les textures WebGL sont censées être des images RVB, à raison d'un octet par canal de couleur. La sortie de notre décodeur AV1 est constituée d'images au format YUV, où la sortie par défaut comporte 16 bits par canal, et chaque valeur U ou V correspond à 4 pixels dans l'image de sortie réelle. Cela signifie que nous devons convertir l'image en couleur avant de la transmettre à WebGL pour l'affichage.

Pour ce faire, nous implémentons une fonction AVX_YUV_to_RGB() que vous trouverez dans le fichier source yuv-to-rgb.c. Cette fonction convertit la sortie du décodeur AV1 en quelque chose que nous pouvons transmettre à WebGL. Notez que lorsque nous appelons cette fonction à partir de JavaScript, nous devons nous assurer que la mémoire dans laquelle nous écrivons l'image convertie a été allouée à la mémoire du module WebAssembly, sinon il ne pourra pas y accéder. La fonction permettant d'extraire une image du module WebAssembly et de la peindre à l'écran est la suivante:

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

La fonction drawImageToCanvas() qui implémente le tableau WebGL est disponible dans le fichier source draw-image.js pour référence.

Travaux futurs et points à retenir

Essayer notre démonstration sur deux tests vidéo fichiers (enregistrées en vidéo à 24 f.p.) nous apprend quelques choses:

  1. Il est tout à fait possible de créer un codebase complexe à exécuter efficacement dans le navigateur à l'aide de WebAssembly.
  2. Avec WebAssembly, vous pouvez faire appel à une utilisation aussi intensive que le décodage vidéo avancé.

Il existe toutefois quelques limites: l'implémentation s'exécute entièrement sur le thread principal, et nous entrelacons la peinture et le décodage vidéo sur ce thread unique. Le déchargement du décodage par un nœud de calcul Web peut nous offrir une lecture plus fluide, car le temps de décodage des images dépend fortement du contenu de l'image et peut parfois prendre plus de temps que prévu.

La compilation dans WebAssembly utilise la configuration AV1 pour un type de processeur générique. Si nous compilons en mode natif sur la ligne de commande pour un processeur générique, la charge du processeur pour décoder la vidéo est semblable à celle de la version WebAssembly. Cependant, la bibliothèque de décodeur AV1 inclut également des implémentations SIMD qui s'exécutent jusqu'à cinq fois plus vite. Le groupe de la communauté WebAssembly travaille actuellement à l'extension de la norme pour inclure les primitives SIMD, et cette norme promet d'accélérer considérablement le décodage. Dans ce cas, il sera tout à fait possible de décoder des vidéos HD 4K en temps réel à partir d'un décodeur vidéo WebAssembly.

Dans tous les cas, l'exemple de code est utile comme guide pour transférer tout utilitaire de ligne de commande existant afin qu'il s'exécute en tant que module WebAssembly et montre ce qui est déjà possible sur le Web.

Crédits

Merci à Jeff Posnick, Eric Bidelman et Thomas Steiner pour leurs précieux avis et commentaires.