Extiende el navegador con WebAssembly

WebAssembly nos permite ampliar el navegador con funciones nuevas. En este artículo, se muestra cómo transferir el decodificador de videos AV1 y reproducir videos de AV1 en cualquier navegador moderno.

Alex Danilo

Una de las mejores características de WebAssembly es la capacidad de experimentar con funciones nuevas y la implementación de ideas nuevas antes de que el navegador envíe esas funciones de forma nativa (si corresponde). Puedes pensar en usar WebAssembly de esta manera como un mecanismo de polyfills de alto rendimiento, en el que escribes tu atributo en C/C++ o Rust en lugar de JavaScript.

Con una gran cantidad de códigos existentes disponibles para la portabilidad, es posible realizar acciones en el navegador que no fueron viables hasta que apareció WebAssembly.

En este artículo, se explicará un ejemplo de cómo tomar el código fuente del códec de video AV1 existente, compilar un wrapper para él y probarlo en tu navegador, además de sugerencias para compilar un agente de prueba para depurar el wrapper. El código fuente completo para este ejemplo está disponible en github.com/GoogleChromeLabs/wasm-av1 como referencia.

Descarga uno de estos dos archivos de video de prueba de 24 FPS y pruébalos en nuestra demostración compilada.

Cómo elegir una base de código interesante

Hace varios años que observamos que un gran porcentaje del tráfico en la Web consta de datos de video. Cisco estima eso hasta un 80%, de hecho. Por supuesto, los proveedores de navegadores y los sitios de videos son muy conscientes del deseo de reducir los datos que consume todo este contenido de video. La clave para ello, por supuesto, es una mejor compresión y, como es de esperar, hay muchas investigaciones sobre la compresión de video de nueva generación destinadas a reducir la carga de datos del envío de videos a través de Internet.

A medida que sucede, Alliance for Open Media trabaja en un esquema de compresión de video de nueva generación llamado AV1 que promete reducir el tamaño de los datos de video de manera considerable. En el futuro, se espera que los navegadores envíen compatibilidad nativa con AV1, pero afortunadamente, el código fuente del compresor y el descompresor es de código abierto, lo que lo convierte en un candidato ideal para intentar compilarlo en WebAssembly, de modo que podamos experimentar con él en el navegador.

Imagen de la película del conejo.

Cómo adaptar el contenido para usarlo en el navegador

Una de las primeras cosas que debemos hacer para obtener este código en el navegador es conocer el código existente y comprender cómo es la API. Cuando ves este código por primera vez, se destacan dos aspectos:

  1. El árbol de fuentes se compila con una herramienta llamada cmake.
  2. Hay una serie de ejemplos que suponen algún tipo de interfaz basada en archivos.

Todos los ejemplos que se compilan de forma predeterminada se pueden ejecutar en la línea de comandos y es probable que suceda en muchas otras bases de código disponibles en la comunidad. Por lo tanto, la interfaz que compilaremos para que se ejecute en el navegador podría ser útil para muchas otras herramientas de línea de comandos.

Usa cmake para compilar el código fuente

Afortunadamente, los autores de AV1 estuvieron experimentando con Emscripten, el SDK que usaremos para compilar nuestra versión de WebAssembly. En la raíz del repositorio de AV1, el archivo CMakeLists.txt contiene estas reglas de compilación:

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 cadena de herramientas de Emscripten puede generar resultados en dos formatos: uno se llama asm.js y el otro es WebAssembly. Definiremos la segmentación de WebAssembly, ya que produce resultados más pequeños y se puede ejecutar más rápido. Estas reglas de compilación existentes están diseñadas para compilar una versión asm.js de la biblioteca con el objetivo de usarla en una aplicación de inspector que se aproveche para observar el contenido de un archivo de video. Para nuestro uso, necesitamos la salida de WebAssembly, por lo que agregamos estas líneas justo antes de la declaración de cierre endif() en las reglas anteriores.

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

Compilar con cmake significa, primero, generar algunos Makefiles ejecutando cmake y, luego, el comando make, que realizará el paso de compilación. Ten en cuenta que, como estamos usando Emscripten, debemos usar la cadena de herramientas del compilador Emscripten en lugar del compilador de host predeterminado. Para ello, usa Emscripten.cmake, que forma parte del SDK de Emscripten, y pasa su ruta de acceso como parámetro al elemento cmake. Usamos la siguiente línea de comandos para generar los archivos makefile:

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

El parámetro path/to/aom debe establecerse en la ruta de acceso completa de la ubicación de los archivos de origen de la biblioteca AV1. El parámetro path/to/emsdk-portable/…/Emscripten.cmake debe configurarse en la ruta de acceso del archivo de descripción de la cadena de herramientas de Emscripten.cmake.

Para mayor comodidad, usamos una secuencia de comandos de shell a fin de localizar el archivo:

#!/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 observas el Makefile de nivel superior de este proyecto, puedes ver cómo se usa esa secuencia de comandos para configurar la compilación.

Ahora que se realizó toda la configuración, simplemente llamaremos a make, que compilará todo el árbol de fuentes, incluidas las muestras, pero lo más importante será generar libaom.a que contiene el decodificador de videos compilado y listo para incorporarlo a nuestro proyecto.

Cómo diseñar una API para interactuar con la biblioteca

Una vez que creemos nuestra biblioteca, debemos pensar cómo interactuar con ella para enviarle datos de video comprimidos y, luego, leer los fotogramas de video que podemos mostrar en el navegador.

Si observas el árbol de código de AV1, un buen punto de partida es un ejemplo de decodificador de video que se encuentra en el archivo [simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c). Ese decodificador lee un archivo IVF y lo decodifica en una serie de imágenes que representan los fotogramas del video.

Implementamos nuestra interfaz en el archivo de origen [decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c).

Dado que el navegador no puede leer archivos del sistema de archivos, debemos diseñar alguna forma de interfaz que nos permita abstraer nuestra E/S de modo que podamos compilar algo similar al decodificador de ejemplo para obtener datos en nuestra biblioteca AV1.

En la línea de comandos, la E/S de archivos es lo que se conoce como interfaz de transmisión, por lo que podemos definir nuestra propia interfaz que se parezca a la E/S de transmisión y compilar lo que queramos en la implementación subyacente.

Nuestra interfaz se define de la siguiente manera:

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

Las funciones open/read/empty/close se parecen mucho a las operaciones de E/S de archivos normales, lo que nos permite asignarlas fácilmente a E/S de archivos para una aplicación de línea de comandos o implementarlas de otra manera cuando se ejecutan dentro de un navegador. El tipo DATA_Source es opaco desde el lado de JavaScript y solo sirve para encapsular la interfaz. Ten en cuenta que compilar una API que siga de cerca la semántica de archivos facilita la reutilización en muchas otras bases de código diseñadas para usarse desde una línea de comandos (p.ej., diff, sed, etc.).

También debemos definir una función auxiliar llamada DS_set_blob que vincule datos binarios sin procesar a nuestras funciones de E/S de transmisión. Esto permite que el BLOB se "lee" como si fuera una transmisión (es decir, como un archivo leído de forma secuencial).

Nuestra implementación de ejemplo habilita la lectura del BLOB que se pasó como si fuera una fuente de datos leída de forma secuencial. Puedes encontrar el código de referencia en el archivo blob-api.c y toda la implementación es solo la siguiente:

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

Cómo compilar un agente de prueba para realizar pruebas fuera del navegador

Una de las prácticas recomendadas en ingeniería de software es compilar pruebas de unidades para código junto con pruebas de integración.

Cuando compilas con WebAssembly en el navegador, tiene sentido compilar alguna forma de prueba de unidades para la interfaz del código con el que estamos trabajando, de modo que podamos realizar depuraciones fuera del navegador y, además, probar la interfaz que compilamos.

En este ejemplo, emulamos una API basada en transmisiones como la interfaz para la biblioteca de AV1. Por lo tanto, desde un punto de vista lógico, tiene sentido compilar un agente de prueba que podamos usar para compilar una versión de nuestra API que se ejecute en la línea de comandos y realice la E/S real del archivo de forma interna implementando la E/S del archivo en nuestra API de DATA_Source.

El código de E/S de transmisión para nuestro agente de prueba es sencillo y se ve de la siguiente manera:

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

Mediante la abstracción de la interfaz de transmisión, podemos compilar nuestro módulo WebAssembly para usar BLOB de datos binarios en el navegador y crear interfaces con archivos reales cuando compilamos el código para probarlo desde la línea de comandos. Nuestro código del agente de prueba se puede encontrar en el archivo fuente de ejemplo test.c.

Implementación de un mecanismo de almacenamiento en búfer para varios fotogramas de video

Cuando se reproduce un video, se suele almacenar en búfer algunos fotogramas para que la reproducción sea más fluida. Para nuestros fines, solo implementaremos un búfer de 10 fotogramas de video, por lo que almacenaremos en búfer 10 fotogramas antes de comenzar la reproducción. Luego, cada vez que se muestre un fotograma, intentaremos decodificar otro para mantener el búfer completo. Con este enfoque, se garantiza que los fotogramas estén disponibles de antemano para ayudar a detener las saltos de video.

Con nuestro ejemplo simple, se puede leer todo el video comprimido, por lo que no se necesita el almacenamiento en búfer. Sin embargo, si queremos extender la interfaz de datos de origen para que admita la transmisión de entradas desde un servidor, necesitamos implementar un mecanismo de almacenamiento en búfer.

El código en decode-av1.c para leer fotogramas de datos de video de la biblioteca de AV1 y almacenarlos en el búfer de la siguiente manera:

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


Decidimos que el búfer contenga 10 fotogramas de video, que es una opción arbitraria. Almacenar en búfer más fotogramas significa más tiempo de espera para que el video comience a reproducirse, mientras que almacenar en búfer muy pocos fotogramas puede causar demoras durante la reproducción. En una implementación de navegador nativo, el almacenamiento en búfer de fotogramas es mucho más complejo que esta implementación.

Cómo incorporar los fotogramas de video a la página con WebGL

Los fotogramas de video que almacenamos en el búfer deben mostrarse en nuestra página. Como se trata de contenido de video dinámico, queremos poder hacerlo lo más rápido posible. Para eso, recurriremos a WebGL.

WebGL nos permite tomar una imagen, como un fotograma de video, y usarla como una textura que se pinta en cierta geometría. En el mundo de WebGL, todo consta de triángulos. Por lo tanto, para nuestro caso, podemos usar una práctica función integrada de WebGL llamada gl.TRIANGLE_FAN.

Sin embargo, hay un problema menor. Se supone que las texturas de WebGL son imágenes RGB, de un byte por canal de color. La salida del decodificador AV1 son imágenes en un formato denominado YUV, en el que la salida predeterminada tiene 16 bits por canal y, además, cada valor U o V corresponde a 4 píxeles en la imagen de salida real. Esto significa que debemos convertir la imagen a color antes de poder pasarla a WebGL para mostrarla.

Para ello, implementamos una función AVX_YUV_to_RGB() que puedes encontrar en el archivo fuente yuv-to-rgb.c. Esa función convierte la salida del decodificador de AV1 en algo que podamos pasar a WebGL. Ten en cuenta que, cuando llamamos a esta función desde JavaScript, debemos asegurarnos de que la memoria en la que escribimos la imagen convertida se haya asignado dentro de la memoria del módulo WebAssembly; de lo contrario, no se podrá acceder a ella. La función para obtener una imagen del módulo WebAssembly y pintarla en la pantalla es la siguiente:

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 función drawImageToCanvas() que implementa la pintura de WebGL se puede encontrar en el archivo fuente draw-image.js a modo de referencia.

Trabajo futuro y conclusiones

Probar la demostración en dos archivos de video de prueba (grabados como un video de 24 f.p.s.) nos enseña algunas cosas:

  1. Es completamente posible compilar una base de código compleja para que se ejecute con buen rendimiento en el navegador usando WebAssembly.
  2. Mediante WebAssembly, se puede usar algo tan intensivo de CPU como la decodificación de video avanzada.

Sin embargo, existen algunas limitaciones: la implementación se ejecuta en el subproceso principal y intercalamos la pintura y la decodificación de video en ese único subproceso. Transferir la decodificación a un trabajador web podría proporcionarnos una reproducción más fluida, ya que el tiempo para decodificar fotogramas depende en gran medida del contenido de ese fotograma y, a veces, puede llevar más tiempo del presupuestado.

La compilación en WebAssembly usa la configuración de AV1 para un tipo de CPU genérico. Si compilamos de forma nativa en la línea de comandos para una CPU genérica, veremos una carga de CPU similar a la de la versión de WebAssembly. Sin embargo, la biblioteca de decodificador AV1 también incluye implementaciones de SIMD que se ejecutan hasta 5 veces más rápido. Actualmente, WebAssembly Community Group está trabajando para extender el estándar y poder incluir primitivas SIMD, y cuando eso ocurra, promete acelerar la decodificación considerablemente. Cuando eso suceda, será completamente posible decodificar video HD 4K en tiempo real desde un decodificador de video WebAssembly.

En cualquier caso, el código de ejemplo es útil como guía para ayudar a portar cualquier utilidad de línea de comandos existente de modo que se ejecute como un módulo de WebAssembly y muestra lo que ya es posible en la Web actualmente.

Créditos

Gracias a Jeff Posnick, Eric Bidelman y Thomas Steiner por sus valiosas opiniones y comentarios.