É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 n'importe quel navigateur moderne.

Alex Danilo

L'un des avantages de WebAssembly est la possibilité de tester de nouvelles fonctionnalités et d'implémenter de nouvelles idées avant que le navigateur ne les propose en mode natif (le cas échéant). Vous pouvez considérer l'utilisation de WebAssembly de cette manière comme un mécanisme de polyfill hautes performances, dans lequel vous écrivez votre fonctionnalité en C/C++ ou en Rust plutôt qu'en JavaScript.

Avec une multitude de code existant disponible pour le portage, il est possible d'effectuer des tâches dans le navigateur qui n'étaient pas viables avant l'arrivée de WebAssembly.

Cet article présente un exemple de code source du codec vidéo AV1 existant, de création d'un wrapper pour celui-ci et d'essai dans votre navigateur. Il fournit également des conseils pour créer un banc d'essai afin de déboguer le wrapper. Pour référence, le code source complet de l'exemple est disponible sur github.com/GoogleChromeLabs/wasm-av1.

Téléchargez l'un de ces deux fichiers de vidéo de test à 24 images par seconde et testez-les sur notre démo.

Choisir un codebase intéressant

Depuis plusieurs années, nous constatons qu'un pourcentage important du trafic sur le Web est constitué de données vidéo. Cisco l'estime à 80 %. Bien entendu, les fournisseurs de navigateurs et les sites vidéo sont parfaitement conscients du désir de réduire la quantité de données consommées par tout ce contenu vidéo. La clé de cette approche est bien sûr une meilleure compression. Comme vous pouvez vous y attendre, de nombreuses recherches sont menées sur la compression vidéo de nouvelle génération afin de réduire la charge de données liée à l'envoi de vidéos sur Internet.

L'Alliance for Open Media travaille sur un schéma de compression vidéo de nouvelle génération appelé AV1, qui promet de réduire considérablement la taille des données vidéo. À l'avenir, les navigateurs devraient proposer 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 tenter de le compiler dans WebAssembly afin que nous puissions l'essayer dans le navigateur.

Image du film Lapin.

Adapter l'application pour une utilisation dans le navigateur

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

  1. L'arborescence source est créée à l'aide d'un outil appelé cmake.
  2. Il existe de nombreux exemples qui supposent tous une sorte d'interface basée sur des fichiers.

Tous les exemples créés par défaut peuvent être exécutés sur la ligne de commande, et il est probable que ce soit également le cas pour 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 d'AV1 ont expérimenté Emscripten, le SDK que nous allons utiliser pour créer notre version WebAssembly. Dans 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 appelé asm.js et l'autre WebAssembly. Nous allons cibler WebAssembly, car il produit une sortie plus petite 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 à utiliser dans une application d'inspection permettant 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 consiste d'abord à générer des Makefiles en exécutant cmake, puis à exécuter la commande make qui effectuera l'étape de compilation. Notez que, comme nous utilisons Emscripten, nous devons utiliser la chaîne d'outils de compilation 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. 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 d'accès au 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 le Makefile de niveau supérieur de ce projet, vous pouvez voir comment ce script est utilisé pour configurer le build.

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

Concevoir une API pour l'interface avec la bibliothèque

Une fois notre bibliothèque créée, nous devons déterminer comment l'interfacer pour lui envoyer des données vidéo compressées, puis lire les images vidéo que nous pouvons afficher dans le navigateur.

En examinant l'arborescence de code AV1, un bon point de départ est 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 notre interface dans le fichier source [decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c).

Étant donné que notre navigateur ne peut pas lire les fichiers du système de fichiers, nous devons concevoir une interface qui nous permet de faire abstraction de nos E/S afin de pouvoir créer quelque chose de semblable à l'exemple de décodeur pour intégrer des données à notre bibliothèque AV1.

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

Nous définissons l'interface comme suit:

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 aux 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 suit de près la sémantique des fichiers facilite la réutilisation dans de nombreux autres bases de code destinées à être utilisées à 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 au blob d'être "lu" comme s'il s'agissait d'un flux (c'est-à-dire, ressemblant à un fichier lu de manière séquentielle).

Notre exemple d'implémentation permet de lire le 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 se présente comme suit:

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 créez avec WebAssembly dans le navigateur, il est logique de créer une forme de test unitaire pour l'interface du code avec lequel vous travaillez afin de pouvoir déboguer en dehors du navigateur et tester l'interface que vous avez créée.

Dans cet exemple, nous avons émulé une API basée sur un flux en tant qu'interface de la bibliothèque AV1. Il est donc logique de créer un banc d'essai 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 réelles en implémentant les E/S de fichiers sous notre API DATA_Source.

Le code d'E/S de flux de notre outil 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 faisant abstraction de l'interface de flux, nous pouvons créer notre module WebAssembly pour utiliser des blobs de données binaires dans le navigateur et interagir avec de vrais fichiers lorsque nous compilons le code à tester à partir de la ligne de commande. Le code de notre outil de test se trouve 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 en mémoire tampon quelques images pour améliorer la fluidité de la lecture. Pour nos besoins, nous allons simplement implémenter un tampon de 10 images vidéo. Nous allons donc tamponner 10 images avant de commencer la lecture. Ensuite, chaque fois qu'un frame est affiché, nous essayons de décoder un autre frame afin de maintenir le tampon plein. Cette approche garantit que les images sont disponibles à l'avance pour aider à arrêter le stuttering de la vidéo.

Dans notre exemple simple, toute la vidéo compressée est disponible en lecture. La mise en mémoire tampon n'est donc pas vraiment nécessaire. Toutefois, si nous voulons étendre l'interface de données source pour prendre en charge l'entrée en streaming à partir d'un serveur, nous devons mettre en place le mécanisme de mise en mémoire tampon.

Le code dans decode-av1.c pour lire les images de données vidéo à partir de la bibliothèque AV1 et les stocker dans le tampon se présente 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 cadres vidéo, ce qui n'est qu'un choix arbitraire. Mettre en mémoire tampon un plus grand nombre de frames entraîne un temps d'attente plus long avant le début de la lecture de la vidéo, tandis que mettre en mémoire tampon trop peu de frames peut entraîner un blocage pendant la lecture. Dans une implémentation de navigateur native, le tamponnage des frames est beaucoup plus complexe que cette implémentation.

Placer les images vidéo sur la page avec WebGL

Les images de la vidéo que nous avons mises en mémoire tampon doivent s'afficher sur notre page. Comme il s'agit d'un contenu vidéo dynamique, nous souhaitons pouvoir le faire aussi rapidement que possible. Pour cela, nous utilisons WebGL.

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

Cependant, il y a un problème mineur. Les textures WebGL sont censées être des images RVB, 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 est de 16 bits par canal, et où 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 pouvoir la transmettre à WebGL pour l'afficher.

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 dans la mémoire du module WebAssembly. Sinon, il ne peut 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 la peinture WebGL est disponible dans le fichier source draw-image.js pour référence.

Travaux futurs et enseignements à tirer

Tester notre démonstration sur deux fichiers vidéo (enregistrés en 24 images par seconde) nous apprend plusieurs choses :

  1. Il est tout à fait possible de créer un codebase complexe à exécuter efficacement dans le navigateur à l'aide de WebAssembly.
  2. Une tâche aussi gourmande en ressources processeur que le décodage vidéo avancé est possible via WebAssembly.

Il existe cependant quelques limites : l'implémentation s'exécute entièrement sur le thread principal, et nous intercalons le dessin et le décodage vidéo sur ce seul thread. Décharger le décodage dans un worker Web pourrait nous offrir une lecture plus fluide, car le temps de décodage des images dépend fortement du contenu de ces images et peut parfois prendre plus de temps que prévu.

La compilation en 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, nous observons une charge de processeur similaire pour décoder la vidéo que pour la version WebAssembly. Toutefois, la bibliothèque de décodeur AV1 inclut également des implémentations SIMD qui s'exécutent jusqu'à cinq fois plus rapidement. Le groupe de la communauté WebAssembly travaille actuellement sur l'extension de la norme afin d'inclure les primitives SIMD. Lorsque cela se produira, il promet d'accélérer considérablement le décodage. Dans ce cas, il sera tout à fait possible de décoder la vidéo 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 vous aider à porter n'importe quel utilitaire de ligne de commande existant pour l'exécuter en tant que module WebAssembly. Il montre également ce qui est déjà possible sur le Web.

Crédits

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