Extiende el navegador con WebAssembly

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

Alex Danilo

Uno de los mejores aspectos de WebAssembly es la capacidad de experimentar con nuevas capacidades y de implementar 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 función en C/C++ o Rust en lugar de JavaScript.

Con una gran cantidad de código existente disponible para la portabilidad, es posible hacer cosas en el navegador que no eran viables hasta el lanzamiento de WebAssembly.

En este artículo, se analizará un ejemplo de cómo tomar el código fuente del codificador de video AV1 existente, compilar un wrapper para él y probarlo en tu navegador, además de sugerencias para ayudarte a compilar un conjunto de pruebas para depurar el wrapper. El código fuente completo del 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 demo compilada.

Elige una base de código interesante

Desde hace varios años, hemos observado que un gran porcentaje del tráfico en la Web consiste en datos de video. De hecho, Cisco estima que es hasta el 80%. 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 eso, por supuesto, es una mejor compresión, y, como es de esperar, hay mucha investigación sobre la compresión de video de nueva generación con el objetivo de reducir la carga de datos de enviar videos a través de Internet.

En efecto, la Alianza para Medios Abiertos ha estado trabajando en un esquema de compresión de video de nueva generación llamado AV1 que promete reducir considerablemente el tamaño de los datos de video. En el futuro, esperamos que los navegadores admitan de forma nativa AV1, pero, por suerte, el código fuente del compresor y el descompresor son de código abierto, lo que lo convierte en un candidato ideal para intentar compilarlo en WebAssembly para que podamos experimentar con él en el navegador.

Imagen de la película de conejos.

Adaptación para su uso en el navegador

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

  1. El árbol fuente se compila con una herramienta llamada cmake.
  2. Hay varios 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 esto sea cierto 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 han estado 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.txtcontiene 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. Nos enfocaremos en WebAssembly, ya que produce un resultado más pequeño y puede ejecutarse más rápido. Estas reglas de compilación existentes están diseñadas para compilar una versión asm.js de la biblioteca para usarla en una aplicación de inspector que se aprovecha para ver el contenido de un archivo de video. Para nuestro uso, necesitamos un resultado de WebAssembly, por lo que agregamos estas líneas justo antes de la declaración endif() de cierre 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 que primero debes generar algunos Makefiles ejecutando cmake y, luego, ejecutando el comando make, que realizará el paso de compilación. Ten en cuenta que, como usamos Emscripten, debemos usar la cadena de herramientas del compilador de Emscripten en lugar del compilador de host predeterminado. Esto se logra con Emscripten.cmake, que forma parte del SDK de Emscripten y pasa su ruta de acceso como parámetro a cmake. La siguiente línea de comandos es la que usamos 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 de AV1. El parámetro path/to/emsdk-portable/…/Emscripten.cmake debe establecerse en la ruta de acceso del archivo de descripción de la cadena de herramientas Emscripten.cmake.

Para mayor comodidad, usamos una secuencia de comandos de shell para ubicar ese 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 completó toda la configuración, simplemente llamamos a make, que compilará todo el árbol de origen, incluidos los samples, pero lo más importante es que genera libaom.a, que contiene el decodificador de video compilado y listo para que lo incorporemos a nuestro proyecto.

Diseñar una API para interactuar con la biblioteca

Una vez que hayamos compilado nuestra biblioteca, debemos descubrir 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 puede encontrar 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 fuente [decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c).

Como nuestro navegador no puede leer archivos del sistema de archivos, debemos diseñar algún tipo de interfaz que nos permita abstraer nuestra E/S para que podamos compilar algo similar al decodificador de ejemplo para obtener datos en nuestra biblioteca de AV1.

En la línea de comandos, la E/S de archivos es lo que se conoce como una interfaz de transmisión, por lo que podemos definir nuestra propia interfaz que se vea como 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 normales de E/S de archivos, lo que nos permite asignarlas fácilmente a la E/S de archivos para una aplicación de línea de comandos o implementarlas de alguna 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 del archivo facilita su reutilización en muchas otras bases de código destinadas a usarse desde una línea de comandos (p. ej., diff, sed, etcétera).

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

Nuestra implementación de ejemplo permite leer el blob pasado como si fuera una fuente de datos que se lee de forma secuencial. El código de referencia se puede encontrar en el archivo blob-api.c, y toda la implementación es solo esto:

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 entorno de pruebas para realizar pruebas fuera del navegador

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

Cuando se compila con WebAssembly en el navegador, tiene sentido compilar algún tipo de prueba de unidad para la interfaz del código con el que estamos trabajando, de modo que podamos depurar fuera del navegador y también probar la interfaz que compilamos.

En este ejemplo, emulamos una API basada en transmisiones como la interfaz de la biblioteca de AV1. Por lo tanto, lógicamente, tiene sentido crear un entorno de pruebas que podamos usar para compilar una versión de nuestra API que se ejecute en la línea de comandos y realice E/S de archivos reales de forma interna mediante la implementación de la E/S de archivos en nuestra API de DATA_Source.

El código de E/S de flujo para nuestro conjunto de pruebas es sencillo y se ve así:

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 de WebAssembly para usar BLOB de datos binarios cuando estamos en el navegador y también interactuar con archivos reales cuando compilamos el código para probarlo desde la línea de comandos. Nuestro código de conjunto de pruebas se puede encontrar en el archivo fuente de ejemplo test.c.

Implementa un mecanismo de almacenamiento en búfer para varios fotogramas de video

Cuando se reproduce un video, es habitual 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 lleno. Este enfoque garantiza que los fotogramas estén disponibles con anticipación para ayudar a detener el salto del video.

En nuestro ejemplo simple, se puede leer todo el video comprimido, por lo que el almacenamiento en búfer no es realmente necesario. Sin embargo, si queremos extender la interfaz de datos fuente para admitir la entrada de transmisión desde un servidor, debemos tener el mecanismo de almacenamiento en búfer implementado.

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 es el siguiente:

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, lo cual es una elección arbitraria. Almacenar más fotogramas significa más tiempo de espera para que el video comience a reproducirse, mientras que almacenar demasiados fotogramas puede provocar interrupciones durante la reproducción. En una implementación de navegador nativa, el almacenamiento en búfer de fotogramas es mucho más complejo que esta implementación.

Cómo llevar los marcos de video a la página con WebGL

Los fotogramas de video que almacenamos en búfer deben mostrarse en nuestra página. Dado que este es contenido de video dinámico, queremos que podamos hacerlo lo más rápido posible. Para ello, recurrimos a WebGL.

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

Sin embargo, hay un problema menor. Se supone que las texturas de WebGL son imágenes RGB, un byte por canal de color. El resultado de nuestro decodificador AV1 son imágenes en un llamado formato YUV, en el que la salida predeterminada tiene 16 bits por canal y cada valor U o V corresponde a 4 píxeles en la imagen de salida real. Todo esto significa que debemos convertir la imagen a color antes de pasarla a WebGL para su visualización.

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 el resultado del decodificador AV1 en algo que podemos 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 de WebAssembly; de lo contrario, no podrá acceder a ella. La función para obtener una imagen del módulo de 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 WebGL se puede encontrar en el archivo fuente draw-image.js como referencia.

Trabajo futuro y conclusiones

Probar nuestra demo en dos archivos de video de prueba (grabados como video de 24 fps) nos enseña lo siguiente:

  1. Es completamente factible compilar una base de código compleja para que se ejecute de manera eficiente en el navegador con WebAssembly.
  2. Con WebAssembly, se puede realizar algo tan intensivo de CPU como la decodificación de video avanzada.

Sin embargo, hay algunas limitaciones: la implementación se ejecuta en el subproceso principal y intercalamos la pintura y la decodificación de video en ese subproceso único. Transferir la decodificación a un trabajador web podría brindarnos 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 tardar más de lo que tenemos 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 para decodificar el video que con 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, el grupo comunitario de WebAssembly está trabajando para ampliar el estándar y, de esta manera, incluir primitivos SIMD. Cuando eso suceda, se promete acelerar considerablemente la decodificación. Cuando eso sucede, se puede decodificar video HD 4K en tiempo real desde un decodificador de video de WebAssembly.

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

Créditos

Gracias a Jeff Posnick, Eric Bidelman y Thomas Steiner por proporcionar opiniones y comentarios valiosos.