WebAssembly nos permite extender el navegador con funciones nuevas. En este artículo, se muestra cómo portar el decodificador de video AV1 y reproducir videos AV1 en cualquier navegador moderno.
Una de las mejores características de WebAssembly es la capacidad de experimentar con nuevas funciones e implementar ideas nuevas antes de que el navegador envíe esas funciones de forma nativa (si es que lo hace). Puedes usar WebAssembly de esta manera como un mecanismo de polyfill 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 tareas en el navegador que no eran viables hasta que apareció 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 que tiene como objetivo 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.
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:
- El árbol de origen se compila con una herramienta llamada
cmake
. - 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.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.
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 la salida de WebAssembly, por lo que agregamos estas líneas justo antes de la sentencia 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 primero 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 en nuestro proyecto.
Diseñar una API para interactuar con la biblioteca
Una vez que hayamos compilado nuestra biblioteca, debemos determinar cómo interactuar con ella para enviarle datos de video comprimidos y, luego, volver a 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.
Definimos nuestra interfaz 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 de archivos facilita su reutilización en muchos otros bases de código que se diseñaron 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 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);
}
Si abstraemos la interfaz de transmisión, podemos compilar nuestro módulo de WebAssembly para usar fragmentos de datos binarios cuando estemos en el navegador y establecer la interfaz con archivos reales cuando compilemos 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 bloqueo del video.
Con nuestro ejemplo simple, todo el video comprimido está disponible para leer, por lo que no se necesita el almacenamiento en búfer. 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 obtener los fotogramas de video en la página con WebGL
Los fotogramas de video que almacenamos en 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 ello, recurrimos a WebGL.
WebGL nos permite tomar una imagen, como un fotograma de video, y usarla como una textura que se pinta en alguna geometría. En el mundo de WebGL, todo consiste en triángulos. Por lo tanto, en 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. 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 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:
- Es completamente factible compilar una base de código compleja para que se ejecute de manera eficiente en el navegador con WebAssembly.
- Algo tan intensivo en la CPU como la decodificación de video avanzada es factible a través de WebAssembly.
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 demorar 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, vemos una carga de CPU similar para decodificar el video como con la versión de WebAssembly. Sin embargo, la biblioteca del 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 suceda, será completamente factible decodificar videos en 4K HD 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 portar cualquier utilidad de línea de comandos existente para que se ejecute como un módulo de WebAssembly y muestra lo que ya es posible en la Web.
Créditos
Gracias a Jeff Posnick, Eric Bidelman y Thomas Steiner por proporcionarnos opiniones y comentarios valiosos.