O WebAssembly permite que o navegador seja ampliado com novos recursos. Este artigo mostra como portar o decodificador de vídeo AV1 e reproduzir vídeos AV1 em qualquer navegador moderno.
Uma das melhores coisas sobre o WebAssembly é a capacidade de testar novos recursos e implementar novas ideias antes que o navegador envie esses recursos de forma nativa (se for o caso). Você pode usar o WebAssembly dessa forma como um mecanismo de polyfill de alto desempenho, em que você programa o recurso em C/C++ ou Rust em vez de JavaScript.
Com uma infinidade de códigos disponíveis para portabilidade, é possível fazer coisas no navegador que não eram viáveis até o surgimento do WebAssembly.
Este artigo vai mostrar um exemplo de como usar o código-fonte do codec de vídeo AV1, criar um wrapper para ele e testá-lo no navegador, além de dicas para ajudar a criar um harness de teste para depurar o wrapper. O código-fonte completo do exemplo está disponível em github.com/GoogleChromeLabs/wasm-av1 (em inglês).
Faça o download de um destes dois arquivos de vídeo de teste de 24 fps e teste-os na nossa demonstração.
Como escolher uma base de código interessante
Há alguns anos, observamos que uma grande porcentagem do tráfego na Web consiste em dados de vídeo. A Cisco estima que esse número chega a 80%. É claro que os fornecedores de navegadores e os sites de vídeo estão cientes do desejo de reduzir os dados consumidos por todo esse conteúdo. A chave para isso, é claro, é uma melhor compactação, e como você pode imaginar, há muitas pesquisas sobre a compressão de vídeo de última geração com o objetivo de reduzir a carga de dados do envio de vídeo pela Internet.
A Alliance for Open Media (link em inglês) tem trabalhado em um esquema de compactação de vídeo de última geração chamado AV1, que promete reduzir consideravelmente o tamanho dos dados de vídeo. No futuro, esperamos que os navegadores ofereçam suporte nativo para AV1, mas, felizmente, o código-fonte do compressor e do descompactador são de código aberto, o que os torna candidatos ideais para tentar fazer a compilação no WebAssembly para que possamos testá-los no navegador.
Como adaptar para uso no navegador
Uma das primeiras coisas que precisamos fazer para colocar esse código no navegador é conhecer o código existente para entender como é a API. Ao analisar esse código, duas coisas se destacam:
- A árvore de origem é criada usando uma ferramenta chamada
cmake
; e - Há vários exemplos que assumem algum tipo de interface baseada em arquivos.
Todos os exemplos criados por padrão podem ser executados na linha de comando, e isso provavelmente é verdadeiro em muitas outras bases de código disponíveis na comunidade. A interface que vamos criar para que ela seja executada no navegador pode ser útil para muitas outras ferramentas de linha de comando.
Como usar cmake
para criar o código-fonte
Felizmente, os autores do AV1 têm feito experimentos com o
Emscripten, o SDK que vamos
usar para criar nossa versão do WebAssembly. Na raiz do
repositório AV1, o arquivo
CMakeLists.txt
contém estas regras de build:
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()
A cadeia de ferramentas Emscripten pode gerar saída em dois formatos, um chamado
asm.js
e o outro é o WebAssembly.
Vamos usar o WebAssembly, porque ele produz uma saída menor e pode ser executado
mais rápido. Essas regras de build atuais têm como objetivo compilar uma
versão asm.js
da biblioteca para uso em um
aplicativo de inspeção que é usado para analisar o conteúdo de um arquivo
de vídeo. Para nosso uso, precisamos da saída do WebAssembly. Portanto, adicionamos essas linhas logo
antes da instrução endif()
de fechamento nas
regras acima.
# Force generation of Wasm instead of asm.js
append_link_flag_to_target("inspect" "-s WASM=1")
append_compiler_flag("-s WASM=1")
Criar com cmake
significa primeiro gerar alguns
Makefiles
executando
cmake
, seguido pela execução do comando
make
, que vai realizar a etapa de compilação.
Como estamos usando o Emscripten, precisamos usar a
cadeia de ferramentas do compilador Emscripten em vez do compilador host padrão.
Isso é feito usando Emscripten.cmake
, que
faz parte do SDK do Emscripten e
transmite o caminho como um parâmetro para o próprio cmake
.
A linha de comando abaixo é usada para gerar os 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
O parâmetro path/to/aom
precisa ser definido como o caminho completo do
local dos arquivos de origem da biblioteca AV1. O parâmetro
path/to/emsdk-portable/…/Emscripten.cmake
precisa ser definido
como o caminho do arquivo de descrição do conjunto de ferramentas Emscripten.cmake.
Para facilitar, usamos um script de shell para localizar esse arquivo:
#!/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
Se você olhar para o Makefile
de nível superior desse projeto, vai notar como esse script é usado para configurar o build.
Agora que toda a configuração foi feita, basta chamar make
,
que vai criar toda a árvore de origem, incluindo amostras, mas, o mais
importante, gerar libaom.a
, que contém o
decodificador de vídeo compilado e pronto para ser incorporado ao nosso projeto.
Como projetar uma API para se conectar à biblioteca
Depois de criar nossa biblioteca, precisamos descobrir como interagir com ela para enviar dados de vídeo compactados e ler frames de vídeo que podem ser exibidos no navegador.
Um bom ponto de partida para a árvore de código AV1 é um exemplo
de decodificador de vídeo, que pode ser encontrado no arquivo
[simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c)
.
Esse decodificador lê um arquivo IVF
e o decodifica em uma série de imagens que representam os frames do vídeo.
Implementamos nossa interface no arquivo de origem
[decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c)
.
Como nosso navegador não pode ler arquivos do sistema de arquivos, precisamos projetar alguma forma de interface que permita abstrair a E/S para que possamos criar algo semelhante ao decodificador de exemplo para receber dados na nossa biblioteca AV1.
Na linha de comando, a E/S de arquivos é conhecida como interface de stream. Portanto, podemos definir nossa própria interface que se parece com a E/S de stream e criar o que quisermos na implementação.
Definimos nossa interface assim:
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);
As funções open/read/empty/close
se parecem muito com operações normais
de E/S de arquivos, o que permite mapeá-las facilmente para E/S de arquivos para um
aplicativo de linha de comando ou implementá-las de outra maneira quando executadas em
um navegador. O tipo DATA_Source
é opaco do
lado do JavaScript e serve apenas para encapsular a interface. Criar uma API que siga de perto a semântica do arquivo facilita a reutilização em muitas outras bases de código que devem ser usadas em uma linha de comando (por exemplo, diff, sed etc.).
Também precisamos definir uma função auxiliar chamada DS_set_blob
que vincula dados binários brutos às nossas funções de E/S de fluxo. Isso permite que o blob seja
'lido' como se fosse um stream (ou seja, parecendo um arquivo lido sequencialmente).
Nossa implementação de exemplo permite ler o blob transmitido como se fosse uma
fonte de dados lida sequencialmente. O código de referência pode ser encontrado no arquivo
blob-api.c
,
e toda a implementação é esta:
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;
}
Como criar um arcabouço de teste fora do navegador
Uma das práticas recomendadas em engenharia de software é criar testes de unidade para códigos em conjunto com testes de integração.
Ao criar com o WebAssembly no navegador, faz sentido criar algum tipo de teste unitário para a interface do código com que estamos trabalhando para podermos depurar fora do navegador e também testar a interface que criamos.
Neste exemplo, estamos emulando uma API baseada em stream como a interface para
a biblioteca AV1. Portanto, faz sentido criar um harness de teste que
possamos usar para criar uma versão da nossa API que seja executada na linha de comando e faça
entrada/saída de arquivos reais em segundo plano, implementando a entrada/saída de arquivos
na nossa API DATA_Source
.
O código de E/S de fluxo do nosso harness de teste é simples e tem esta aparência:
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);
}
Ao abstrair a interface de fluxo, podemos criar nosso módulo WebAssembly para
usar blobs de dados binários no navegador e interagir com arquivos reais ao
criar o código para testar na linha de comando. Nosso código de ferramenta de teste pode ser
encontrado no arquivo de origem de exemplo
test.c
.
Como implementar um mecanismo de buffer para vários frames de vídeo
Ao reproduzir um vídeo, é comum armazenar alguns frames em buffer para ajudar a melhorar a reprodução. Para nossos propósitos, vamos implementar um buffer de 10 frames de vídeo, ou seja, vamos armazenar 10 frames antes de iniciar a reprodução. Então, sempre que um frame for mostrado, vamos tentar decodificar outro para manter o buffer cheio. Essa abordagem garante que os frames estejam disponíveis com antecedência para ajudar a interromper a intermitência do vídeo.
No nosso exemplo simples, todo o vídeo compactado está disponível para leitura, então o buffer não é realmente necessário. No entanto, se quisermos estender a interface de dados de origem para oferecer suporte à entrada de streaming de um servidor, precisamos ter o mecanismo de buffer em funcionamento.
O código em
decode-av1.c
para ler frames de dados de vídeo da biblioteca AV1 e armazenar no buffer
é este:
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);
}
}
Escolhemos fazer com que o buffer contenha 10 frames de vídeo, o que é apenas uma escolha arbitrária. Armazenar mais frames em buffer significa mais tempo de espera para que o vídeo seja reproduzido, enquanto armazenar poucos frames em buffer pode causar interrupções durante a reprodução. Em uma implementação de navegador nativa, o armazenamento em buffer de frames é muito mais complexo do que essa implementação.
Como colocar os frames de vídeo na página com o WebGL
Os frames de vídeo que armazenamos em buffer precisam ser exibidos na página. Como esse é um conteúdo de vídeo dinâmico, queremos fazer isso o mais rápido possível. Para isso, usamos o WebGL.
O WebGL permite que você use uma imagem, como um frame de vídeo, como uma textura que é pintada em alguma geometria. No mundo do WebGL, tudo consiste em triângulos. No nosso caso, podemos usar um recurso integrado conveniente do WebGL, chamado gl.TRIANGLE_FAN.
No entanto, há um pequeno problema. As texturas do WebGL precisam ser imagens RGB, com um byte por canal de cor. A saída do nosso decodificador AV1 é imagens em um formato chamado YUV, em que a saída padrão tem 16 bits por canal e cada valor U ou V corresponde a 4 pixels na imagem de saída real. Isso significa que precisamos converter a imagem em cores antes de transmiti-la para o WebGL para exibição.
Para isso, implementamos uma função AVX_YUV_to_RGB()
, que
pode ser encontrada no arquivo de origem
yuv-to-rgb.c
.
Essa função converte a saída do decodificador AV1 em algo que podemos
transmitir para o WebGL. Quando chamamos essa função do JavaScript, precisamos
nos certificar de que a memória em que estamos gravando a imagem convertida foi
alocada dentro da memória do módulo do WebAssembly. Caso contrário, ela não poderá
acessar essa memória. A função para extrair uma imagem do módulo WebAssembly e
pintá-la na tela é esta:
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);
}
}
A função drawImageToCanvas()
que implementa a pintura do WebGL pode ser
encontrada no arquivo de origem
draw-image.js
para referência.
Trabalhos futuros e aprendizados
Testamos nossa demonstração em dois arquivos de teste (gravados como vídeo de 24 fps) e aprendemos algumas coisas:
- É totalmente viável criar uma base de código complexa para executar com eficiência no navegador usando o WebAssembly.
- Algo tão intenso para a CPU quanto a decodificação de vídeo avançada é viável pelo WebAssembly.
No entanto, há algumas limitações: a implementação é executada na linha de execução principal, e intercalamos a pintura e a decodificação de vídeo nessa única linha. O descarregamento da decodificação em um worker da Web pode proporcionar uma reprodução mais suave, já que o tempo para decodificar frames depende muito do conteúdo do frame e às vezes pode levar mais tempo do que o previsto.
A compilação no WebAssembly usa a configuração AV1 para um tipo de CPU genérico. Se compilarmos de forma nativa na linha de comando para uma CPU genérica, vamos notar uma carga de CPU semelhante para decodificar o vídeo, como na versão do WebAssembly. No entanto, a biblioteca de decodificador AV1 também inclui implementações de SIMD que são executadas até cinco vezes mais rápido. O grupo da comunidade do WebAssembly está trabalhando para ampliar o padrão e incluir primitivas SIMD. Quando isso acontecer, a decodificação será consideravelmente mais rápida. Quando isso acontecer, será totalmente viável decodificar vídeos HD 4K em tempo real usando um decodificador de vídeo do WebAssembly.
De qualquer forma, o código de exemplo é útil como um guia para ajudar a portar qualquer utilitário de linha de comando para ser executado como um módulo do WebAssembly e mostra o que já é possível na Web.
Créditos
Agradecemos a Jeff Posnick, Eric Bidelman e Thomas Steiner por fornecer avaliações e feedback valiosos.