Como ampliar o navegador com o WebAssembly

O WebAssembly nos permite ampliar o navegador com novos recursos. Este artigo mostra como portar o decodificador de vídeo AV1 e reproduzir vídeos AV1 em qualquer navegador moderno.

Alex Danilo

Uma das melhores coisas do WebAssembly é o experimento de capacidade com novos recursos e a implementação de novas ideias antes que o navegador envie esses recursos de forma nativa, se for o caso. Você pode usar o WebAssembly dessa maneira 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 para referência.

Baixe um desses dois arquivos de teste de 24 fps vídeo e teste em nossa demonstração integrada.

Como escolher uma base de código interessante

Há alguns anos, vimos que uma grande porcentagem do tráfego na Web consiste em dados de vídeo. A Cisco estima isso em até 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 o AV1, mas, felizmente, os códigos-fonte do compressor e do descompressor são de código aberto (link em inglês), o que o torna ideal para tentar compilá-lo no WebAssembly para que possamos fazer testes no navegador.

Imagem do filme Bunny.

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:

  1. A árvore de origem é criada usando uma ferramenta chamada cmake.
  2. 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 será 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 estão fazendo experimentos com o Emscripten, o SDK que vamos usar para criar a 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 focar no WebAssembly, porque ele produz uma saída menor e pode ser executado mais rapidamente. 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 gerar primeiro Makefiles executando o próprio cmake e, em seguida, executando o comando make, que 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ê observar o Makefile de nível superior deste projeto, poderá ver 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.

Projetar uma API para interagir com a biblioteca

Depois de criar nossa biblioteca, precisamos descobrir como interagir com ela para enviar dados de vídeo compactados e ler os frames de vídeo que podem ser exibidos no navegador.

Um bom ponto de partida é 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 desta forma:

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. Observe que criar uma API que segue de perto a semântica do arquivo 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 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 é apenas 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, emulamos 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. 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 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 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 fazer 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:

  1. É totalmente viável criar uma base de código complexa para executar com eficiência no navegador usando o WebAssembly.
  2. 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 fornecerem avaliações e feedback valiosos.