Como ampliar o navegador com o WebAssembly

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.

Alex Danilo

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.

Imagem de um coelhinho no filme.

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:

  1. A árvore de origem é criada usando uma ferramenta chamada cmake.
  2. 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.txtconté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:

  1. é totalmente viável criar uma base de código complexa para ter uma boa performance no navegador usando o WebAssembly; e
  2. 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.