O WebAssembly permite ampliar o navegador com novos recursos. Este artigo mostra como transferir o decodificador de vídeo AV1 e reproduzir vídeos AV1 em qualquer navegador mais recente.
Uma das melhores coisas do WebAssembly é a capacidade de testar novos recursos e implementar novas ideias antes que o navegador envie esses recursos de maneira nativa (se aplicável). Pense no WebAssembly dessa maneira como um mecanismo de polyfill de alto desempenho, em que você escreve 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 mostra um exemplo de como usar o código-fonte do codec de vídeo AV1 existente, criar um wrapper para ele e testá-lo no seu navegador, além de dar dicas sobre como criar um arcabouço de testes para depurar o wrapper. O código-fonte completo do exemplo está disponível em github.com/GoogleChromeLabs/Wasm-av1 (em inglês) para referência.
Faça o download de um destes dois arquivos de vídeo de teste de 24 QPS e use na nossa demonstração criada.
Escolher uma base de código interessante
Há vários anos, constatamos que uma grande porcentagem do tráfego da Web consiste em dados de vídeo. A Cisco estima isso em até 80%. É claro que os fornecedores de navegadores e sites de vídeo estão cientes do desejo de reduzir os dados consumidos por todo esse conteúdo em vídeo. O segredo para isso, é claro, é melhor a compactação e, como é de se esperar, há muitas pesquisas sobre a compactação de vídeo de última geração com o objetivo de reduzir a carga de dados do envio de vídeos pela Internet.
Enquanto isso acontece, a Alliance para Open Media está trabalhando 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, é esperado que os navegadores ofereçam suporte nativo para AV1, mas felizmente o código-fonte do compressor e do descompressor tem código aberto, o que é um candidato ideal para tentar compilá-lo no WebAssembly para que possamos testá-lo 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 pela primeira vez, duas coisas se destacam:
- A árvore de origem é criada usando uma ferramenta chamada
cmake
. - Há vários exemplos que presumem algum tipo de interface baseada em arquivo.
Todos os exemplos criados por padrão podem ser executados na linha de comando, e isso provavelmente será verdade em muitas outras bases de código disponíveis na comunidade. Portanto, a interface que vamos criar para executá-la 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 estão fazendo 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()
O conjunto de ferramentas Emscripten pode gerar saída em dois formatos, um é chamado
asm.js
e o outro é WebAssembly.
Vamos direcionar para o WebAssembly porque ele produz uma saída menor e pode ser executado
mais rápido. Essas regras de build atuais precisam compilar uma
versão asm.js
da biblioteca para uso em um
aplicativo inspetor que é usado para analisar o conteúdo de um arquivo
de vídeo. Para nosso uso, precisamos da saída do WebAssembly, então 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
e depois o comando
make
, que realizará a etapa de compilação.
Observe que, como estamos usando o Emscripten, precisamos usar o
conjunto de ferramentas do compilador Emscripten em vez do compilador de host padrão.
Para isso, use Emscripten.cmake
,
que faz parte do SDK Emscripten,
e transmita o caminho dele como um parâmetro para a própria cmake
.
A linha de comando abaixo é a que usamos 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.
Por conveniência, 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ê observar o Makefile
de nível superior desse projeto, poderá
ver como esse script é usado para configurar o build.
Agora que toda a configuração foi concluída, basta chamar make
,
que vai criar toda a árvore de origem, incluindo amostras, mas principalmente
gerar libaom.a
, que contém o
decodificador de vídeo compilado e pronto para ser incorporado ao projeto.
Projetar uma API para interface com a biblioteca
Depois de criar nossa biblioteca, precisamos descobrir como interagir com ela para enviar dados de vídeo compactados e depois ler frames de vídeo que podem ser exibidos no navegador.
Analisando a árvore de código AV1, um bom ponto de partida é um exemplo
de codificador 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 no 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 uma forma de interface que nos permita abstrair nossa E/S para que possamos criar algo semelhante ao decodificador de exemplo para colocar dados na nossa biblioteca AV1.
Na linha de comando, a E/S de arquivo é conhecida como interface de stream. Assim, podemos definir nossa própria interface que se parece com a E/S de stream e criar o que quiser na implementação subjacente.
Definimos nossa interface da seguinte maneira:
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
são muito parecidas com operações
normais de E/S de arquivo, o que permite mapeá-las facilmente na E/S de arquivos para um
aplicativo de linha de comando ou implementá-las de outra forma quando executadas em
um navegador. O tipo DATA_Source
é opaco do
lado do JavaScript e serve apenas para encapsular a interface. Observe que
criar uma API que segue de perto a semântica de arquivos facilita a reutilização em
muitas outras bases de código destinadas ao uso 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 stream. Isso permite que o blob seja
"lido" como se fosse um stream, ou seja, parecendo um arquivo de leitura sequencial.
Nosso exemplo de implementação 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 é apenas o seguinte:
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;
}
Criar um arcabouço de testes para testar fora do navegador
Uma das práticas recomendadas na engenharia de software é criar testes de unidade para código em conjunto com testes de integração.
Ao criar com o WebAssembly no navegador, faz sentido criar alguma forma de teste de unidade para a interface do código com que estamos trabalhando. Assim, podemos depurar fora do navegador e também testar a interface criada.
Neste exemplo, estamos emulando uma API baseada em fluxo como a interface para
a biblioteca AV1. Portanto, logicamente, faz sentido criar um arcabouço de testes que
possamos usar para criar uma versão da nossa API que seja executada na linha de comando e realize
E/S de arquivo em segundo plano implementando a própria E/S de arquivo na
nossa API DATA_Source
.
O código de E/S de stream para nosso arcabouço de testes é simples e tem a seguinte 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 stream, podemos criar nosso módulo WebAssembly para
usar blobs de dados binários no navegador e interagir com arquivos reais quando
criamos o código para teste na linha de comando. O código do arcabouço de testes pode ser
encontrado no arquivo de origem de exemplo
test.c
.
Implementar um mecanismo de armazenamento em buffer para vários frames de vídeo
Ao reproduzir um vídeo, é comum armazenar em buffer alguns frames para ajudar a melhorar a reprodução. Para nossos propósitos, vamos implementar apenas um buffer de 10 frames de vídeo, então vamos armazenar 10 frames em buffer antes de iniciar a reprodução. Em seguida, sempre que um frame for exibido, tentaremos decodificar outro frame para manter o buffer cheio. Essa abordagem garante que os frames estejam disponíveis com antecedência para evitar a renderização lenta do vídeo.
No nosso exemplo simples, o vídeo compactado inteiro fica disponível para leitura, então o armazenamento em buffer não é necessário. No entanto, se quisermos ampliar a interface de dados de origem para oferecer suporte à entrada de streaming de um servidor, será necessário ter o mecanismo de armazenamento em buffer.
O código em
decode-av1.c
para ler frames de dados de vídeo da biblioteca AV1 e armazenar no buffer
da seguinte forma:
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);
}
}
Optamos por fazer com que o buffer contenha 10 quadros de vídeo, o que é uma escolha arbitrária. Armazenar mais frames em buffer significa mais tempo de espera para o vídeo iniciar a reprodução, enquanto armazenar poucos frames em buffer pode causar paralisações durante a reprodução. Em uma implementação de navegador nativo, o armazenamento em buffer de frames é muito mais complexo do que essa implementação.
Colocar os frames do vídeo na página com o WebGL
Os frames de vídeo que armazenamos em buffer precisam ser exibidos na nossa página. Como esse é um conteúdo de vídeo dinâmico, queremos fazer isso o mais rápido possível. Para isso, vamos usar o WebGL.
O WebGL permite usar uma imagem, como um frame de vídeo, como uma textura que é pintada em alguma geometria. No mundo WebGL, tudo consiste em triângulos. Então, nesse 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, um byte por canal de cor. A saída do nosso decodificador AV1 são imagens no chamado formato 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. Tudo isso significa que precisamos converter a imagem em cores antes de transmiti-la ao 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
passar para o WebGL. Quando chamamos essa função do JavaScript, precisamos
garantir que a memória em que estamos gravando a imagem convertida foi
alocada dentro da memória do módulo WebAssembly. Caso contrário, ela não vai conseguir
ter acesso a ela. 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 WebGL pode ser
encontrada no arquivo de origem
draw-image.js
para referência.
Trabalho futuro e lições
Testar nossa demonstração em dois arquivos de vídeo de teste (gravados como um vídeo 24 f.p.s.) ensina algumas coisas:
- é totalmente viável criar uma base de código complexa para ter uma boa performance no navegador usando o WebAssembly; e
- Algo que consome muita CPU e a decodificação avançada de vídeo é viável via WebAssembly.
No entanto, existem algumas limitações: a implementação é executada na linha de execução principal, e intercalamos a pintura e a decodificação de vídeos nessa única linha de execução. Descarregar a decodificação em um web worker pode oferecer uma reprodução mais suave, já que o tempo para decodificar frames depende muito do conteúdo desse frame e, às vezes, pode levar mais tempo do que o orçamento.
A compilação no WebAssembly usa a configuração AV1 para um tipo de CPU genérico. Se compilarmos nativamente na linha de comando para uma CPU genérica, notamos uma carga de CPU semelhante para decodificar o vídeo como na versão do WebAssembly. No entanto, a biblioteca decodificador AV1 também inclui implementações SIMD que são executadas até cinco vezes mais rápido. O WebAssembly Community Group está trabalhando para ampliar o padrão para incluir primitivos SIMD (link em inglês), e quando chegar lá, promete acelerar consideravelmente a decodificação. Quando isso acontecer, será possível decodificar um vídeo em 4K HD em tempo real de um decodificador de vídeo do WebAssembly.
De qualquer forma, o código de exemplo é útil como um guia para ajudar a transferir qualquer utilitário de linha de comando atual para ser executado como um módulo WebAssembly e mostra o que já é possível na Web atualmente.
Créditos
Agradecemos a Jeff Posnick, Eric Bidelman e Thomas Steiner por fornecer comentários valiosos e feedback.