Estendere il browser con WebAssembly

WebAssembly ci consente di estendere il browser con nuove funzionalità. Questo articolo spiega come eseguire il porting del decodificatore video AV1 e riprodurre i video AV1 in qualsiasi browser moderno.

Alex Danilo

Uno dei vantaggi principali di WebAssembly è la possibilità di sperimentare nuove funzionalità e implementare nuove idee prima che il browser le rilasci in modo nativo (se possibile). In questo modo, puoi utilizzare WebAssembly come meccanismo di polyfill ad alte prestazioni, in cui scrivi la funzionalità in C/C++ o Rust anziché in JavaScript.

Con una pletora di codice esistente disponibile per il porting, è possibile fare nel browser cose che non erano possibili fino all'avvento di WebAssembly.

In questo articolo viene illustrato un esempio di come acquisire il codice sorgente del codec video AV1 esistente, creare un wrapper e provarlo all'interno del browser. Troverai anche suggerimenti per la creazione di una struttura di test per eseguire il debug del wrapper. Il codice sorgente completo dell'esempio è disponibile all'indirizzo github.com/GoogleChromeLabs/wasm-av1 come riferimento.

Scarica uno di questi due file video di prova a 24 fps e provali sulla nostra demo.

Scelta di un codebase interessante

Da alcuni anni ormai, abbiamo notato che una grande percentuale di traffico sul web è costituita da dati video. Cisco stima che si tratti addirittura dell'80%. Naturalmente, i fornitori di browser e i siti di video sono profondamente consapevoli del desiderio di ridurre i dati consumati da tutti questi contenuti video. La chiave per raggiungere questo obiettivo, ovviamente, è una migliore compressione e, come ci si aspetterebbe, esistono molte ricerche sulla compressione dei video di nuova generazione allo scopo di ridurre il carico dei dati associato alla spedizione di video su internet.

In effetti, l'Alliance for Open Media sta lavorando a uno schema di compressione video di nuova generazione chiamato AV1 che promette di ridurre notevolmente le dimensioni dei dati video. In futuro, prevediamo che i browser supporteranno il codice nativo per AV1, ma fortunatamente il codice sorgente del compressore e del decompressore è open source, il che lo rende un candidato ideale per provare a compilarlo in WebAssembly in modo da poterlo sperimentare nel browser.

Immagine del film Bunny.

Adattamento per l'utilizzo nel browser

Una delle prime cose che dobbiamo fare per inserire questo codice nel browser è imparare a conoscere il codice esistente per comprendere l'aspetto dell'API. Quando guardi per la prima volta questo codice, noti due cose:

  1. l'albero di origine viene creato con uno strumento chiamato cmake; e
  2. Esistono diversi esempi che presuppongono tutti un qualche tipo di interfaccia basata su file.

Tutti gli esempi creati per impostazione predefinita possono essere eseguiti sulla riga di comando e probabilmente lo stesso vale per molte altre basi di codice disponibili nella community. Pertanto, l'interfaccia che creeremo per eseguirla nel browser potrebbe essere utile per molti altri strumenti a riga di comando.

Utilizzo di cmake per compilare il codice sorgente

Fortunatamente, gli autori di AV1 stanno sperimentando con Emscripten, l'SDK che utilizzeremo per creare la nostra versione WebAssembly. Nella directory principale del repository AV1, il file CMakeLists.txtcontiene queste regole di compilazione:

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 toolchain Emscripten può generare output in due formati, uno chiamato asm.js e l'altro WebAssembly. Il target sarà WebAssembly perché produce output più piccoli e può essere eseguito più rapidamente. Queste regole di compilazione esistenti hanno lo scopo di compilare una versione asm.js della libreria da utilizzare in un'applicazione di ispezione utilizzata per esaminare i contenuti di un file video. Per il nostro utilizzo, abbiamo bisogno dell'output di WebAssembly, quindi aggiungiamo queste righe appena prima dell'istruzione endif()di chiusura nelle regole sopra riportate.

# Force generation of Wasm instead of asm.js
append_link_flag_to_target("inspect" "-s WASM=1")
append_compiler_flag("-s WASM=1")

La compilazione con cmake significa prima generare alcuni Makefiles eseguendo cmake stesso, quindi eseguire il comando make che eseguirà il passaggio di compilazione. Poiché utilizziamo Emscripten, dobbiamo usare la toolchain del compilatore Emscripten anziché il compilatore host predefinito. Questo risultato viene ottenuto utilizzando Emscripten.cmake, che fa parte dell'SDK Emscripten, e passando il relativo percorso come parametro a cmake stesso. La riga di comando riportata di seguito viene utilizzata per generare i file 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

Il parametro path/to/aom deve essere impostato sul percorso completo della posizione dei file di origine della libreria AV1. Il parametro path/to/emsdk-portable/…/Emscripten.cmake deve essere impostato sul percorso del file di descrizione della catena di strumenti Emscripten.cmake.

Per praticità, utilizziamo uno script shell per individuare il file:

#!/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 osservi il Makefile di primo livello per questo progetto, puoi vedere come viene utilizzato lo script per configurare la build.

Ora che la configurazione è stata completata, basta chiamare make che compilerà l'intera struttura ad albero delle sorgenti, inclusi i sample, ma soprattutto genererà libaom.a che contiene il decoder video compilato e pronto per essere incorporato nel nostro progetto.

Progettare un'API per l'interfaccia con la libreria

Una volta creata la libreria, dobbiamo capire come interagire con essa per inviare i dati video compressi e poi leggere i frame dei video che possiamo visualizzare nel browser.

Se dai un'occhiata all'albero del codice AV1, un buon punto di partenza è un decoder video di esempio che puoi trovare nel file[simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c). Il decoder legge un file IVF e lo decodifica in una serie di immagini che rappresentano i fotogrammi del video.

Implementiamo la nostra interfaccia nel file di origine[decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c).

Poiché il nostro browser non è in grado di leggere i file dal file system, dobbiamo progettare un qualche tipo di interfaccia che ci consenta di astrarre le operazioni di I/O in modo da poter creare qualcosa di simile al decodificatore di esempio per inserire i dati nella nostra libreria AV1.

Nella riga di comando, l'I/O file è nota come interfaccia stream, quindi possiamo semplicemente definire la nostra interfaccia simile all'I/O stream e creare ciò che vogliamo nell'implementazione sottostante.

Definiamo la nostra interfaccia come segue:

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);

Le funzioni open/read/empty/close sono molto simili alle normali operazioni di I/O dei file, il che ci consente di mapparle facilmente sull'I/O dei file per un'applicazione a riga di comando o di implementarle in altro modo quando vengono eseguite all'interno di un browser. Il tipo DATA_Source è opaco dal lato di JavaScript e serve solo a incapsulare l'interfaccia. Tieni presente che la creazione di un'API che segue da vicino la semantica dei file consente di riutilizzarla facilmente in molte altre basi di codice destinate a essere utilizzate da una riga di comando (ad es. diff, sed e così via).

Dobbiamo anche definire una funzione di supporto chiamata DS_set_blob che lega i dati binari non elaborati alle nostre funzioni di I/O dello stream. Ciò consente di "leggere" il blob come se fosse un flusso (ovvero come un file letto in sequenza).

La nostra implementazione di esempio consente di leggere il blob passato come se fosse un'origine dati letta in sequenza. Il codice di riferimento è disponibile nel file blob-api.c, e l'intera implementazione è la seguente:

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;
}

Creazione di un harness di test per i test al di fuori del browser

Una delle best practice in ingegneria del software è creare test di unità per il codice insieme ai test di integrazione.

Quando esegui la compilazione con WebAssembly nel browser, ha senso creare un qualche tipo di test di unità per l'interfaccia del codice con cui stai lavorando in modo da poter eseguire il debug al di fuori del browser e anche testare l'interfaccia che hai creato.

In questo esempio abbiamo emulato un'API basata su stream come interfaccia per la libreria AV1. Quindi, logicamente, ha senso creare un framework di test che possiamo utilizzare per creare una versione della nostra API che funzioni sulla riga di comando e che esegua l'I/O file effettiva sotto il cofano implementando l'I/O file stessa sotto la nostra API DATA_Source.

Il codice di I/O dello stream per il nostro test harness è semplice e simile al seguente:

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);
}

Se estraiamo l'interfaccia dello stream, possiamo creare il nostro modulo WebAssembly in modo che utilizzi blob di dati binari quando è nel browser e si interfaccia con file reali quando viene creato il codice da testare dalla riga di comando. Il nostro codice di test può essere trovato nel file di origine di esempio test.c.

Implementazione di un meccanismo di buffering per più fotogrammi video

Durante la riproduzione di un video, è prassi comune mettere in buffer alcuni frame per contribuire a una riproduzione più fluida. Per i nostri scopi, implementeremo un buffer di 10 fotogrammi di video, quindi metteremo in buffer 10 fotogrammi prima di avviare la riproduzione. Ogni volta che viene visualizzato un frame, proveremo a decodificare un altro frame in modo da mantenere il buffer pieno. Questo approccio assicura che i fotogrammi siano disponibili in anticipo per contribuire a interrompere il stuttering del video.

Con il nostro semplice esempio, l'intero video compresso è disponibile per la lettura, quindi il buffering non è realmente necessario. Tuttavia, se vogliamo estendere l'interfaccia dei dati di origine per supportare l'input di flussi di dati da un server, è necessario disporre di un meccanismo di buffer.

Il codice in decode-av1.c per leggere i frame dei dati video dalla libreria AV1 e archiviarli nel buffer è il seguente:

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);
        }
    }


Abbiamo scelto di fare in modo che il buffer contenga 10 frame video. Questa è solo una scelta arbitraria. Il buffering di un numero maggiore di frame significa più tempo di attesa per l'avvio della riproduzione del video, mentre un numero insufficiente di fotogrammi può causare lo stallo durante la riproduzione. In un'implementazione del browser nativo, il buffering dei frame è molto più complesso rispetto a questa implementazione.

Caricamento dei frame video sulla pagina con WebGL

I frame del video che abbiamo inserito nel buffer devono essere visualizzati nella nostra pagina. Poiché si tratta di contenuti video dinamici, vogliamo poterlo fare il più rapidamente possibile. Per farlo, utilizziamo WebGL.

WebGL ci consente di acquisire un'immagine, ad esempio un frame di un video, e di utilizzarla come texture da dipingere su una geometria. Nel mondo di WebGL, tutto è costituito da triangoli. Quindi, per il nostro caso, possiamo usare una pratica funzionalità integrata di WebGL, chiamata gl.TRIANGLE_FAN.

Tuttavia, c'è un piccolo problema. Le texture WebGL dovrebbero essere immagini RGB, con un byte per canale di colore. L'output del nostro decodificatore AV1 è costituito da immagini in un cosiddetto formato YUV, in cui l'output predefinito ha 16 bit per canale, e ogni valore U o V corrisponde a 4 pixel nell'immagine di output effettiva. Ciò significa che dobbiamo convertire l'immagine a colori prima di poterla passare a WebGL per la visualizzazione.

A tal fine, implementiamo una funzione AVX_YUV_to_RGB() che puoi trovare nel file di origine yuv-to-rgb.c. Questa funzione converte l'output dal decoder AV1 in qualcosa che possiamo passare a WebGL. Tieni presente che quando chiamiamo questa funzione da JavaScript, dobbiamo assicurarci che la memoria in cui stiamo scrivendo l'immagine convertita sia stata allocata all'interno della memoria del modulo WebAssembly, altrimenti non sarà possibile accedervi. La funzione per estrarre un'immagine dal modulo WebAssembly e visualizzarla sullo schermo è la seguente:

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 funzione drawImageToCanvas() che implementa il disegno WebGL è disponibile nel file di origine draw-image.js come riferimento.

Attività future e punti chiave

Provando la nostra demo su due file video di prova (registrati come video a 24 f.p.s.), ci insegna quanto segue:

  1. È del tutto possibile creare una base di codice complessa da eseguire in modo efficiente nel browser utilizzando WebAssembly.
  2. Un'operazione che richiede un utilizzo intensivo della CPU come la decodifica video avanzata è fattibile tramite WebAssembly.

Tuttavia, ci sono alcune limitazioni: l'implementazione viene eseguita interamente sul thread principale e intercaliamo la pittura e la decodifica video su questo singolo thread. Il trasferimento della decodifica in un web worker potrebbe consentirci di avere una riproduzione più fluida, poiché il tempo necessario per decodificare i frame dipende molto dal contenuto del frame e a volte può richiedere più tempo del previsto.

La compilazione in WebAssembly utilizza la configurazione AV1 per un tipo di CPU generico. Se compiliamo in modo nativo sulla riga di comando per una CPU generica, notiamo un carico della CPU simile per decodificare il video rispetto alla versione WebAssembly, tuttavia la libreria di decodificatori AV1 include anche implementazioni SIMD che funzionano fino a 5 volte più velocemente. Il gruppo della community WebAssembly sta attualmente lavorando all'estensione dello standard per includere le primitive SIMD, che promette di velocizzare notevolmente la decodifica. In questo caso, sarà del tutto possibile decodificare i video HD 4K in tempo reale da un decodificatore video WebAssembly.

In ogni caso, il codice di esempio è utile come guida per eseguire la porta di qualsiasi utilità da riga di comando esistente come modulo WebAssembly e mostra cosa è già possibile fare sul web.

Crediti

Grazie a Jeff Posnick, Eric Bidelman e Thomas Steiner per aver fornito una recensione e un feedback preziosi.