Estendere il browser con WebAssembly

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

Alex Danilo

Uno degli aspetti migliori di WebAssembly è l'esperimento di abilità con nuove funzionalità e l'implementazione di nuove idee prima che il browser le distribuisci in modo nativo (se non del tutto). In questo modo, l'utilizzo di WebAssembly è come un meccanismo di polyfill ad alte prestazioni, in cui la caratteristica viene scritta in C/C++ o in Rust anziché in JavaScript.

Data l'eccessiva quantità di codice esistente disponibile per il trasferimento, è possibile eseguire nel browser cose che non erano attuabili finché non è stato introdotto WebAssembly.

Questo articolo illustra un esempio di come utilizzare il codice sorgente del codec video AV1 esistente, sviluppa un wrapper, provalo all'interno del browser e offre suggerimenti per aiutarti a creare un gruppo di test per eseguire il debug del wrapper. Il codice sorgente completo per l'esempio è disponibile all'indirizzo github.com/GoogleChromeLabs/wasm-av1 come riferimento.

Scarica uno di questi due file video di test a 24 FPS e provali nella nostra demo creata.

Scelta di un codebase interessante

Da diversi anni vediamo che una grande percentuale del traffico sul web è costituita da dati video, e Cisco lo stima addirittura fino all'80%. Naturalmente, i fornitori di browser e i siti di video sono estremamente consapevoli dell'intenzione di ridurre i dati consumati da tutti questi contenuti video. Il segreto di tutto questo, ovviamente, è una migliore compressione e, come aspetteresti, sono state effettuate molte ricerche sulla compressione dei video di nuova generazione con l'obiettivo di ridurre il carico sui dati derivante dalla distribuzione dei video su internet.

Al momento, 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 dei video. In futuro, prevediamo che i browser forniranno il supporto nativo per AV1, ma per fortuna il codice sorgente per il compressore e il decompressore sono open source, il che lo rende il candidato ideale per provare a compilarlo in WebAssembly in modo da poterlo sperimentare nel browser.

Immagine del film coniglietto.

Adattamento per l'utilizzo nel browser

Una delle prime cose che dobbiamo fare per inserire questo codice nel browser è conoscere il codice esistente per capire com'è l'API. Alla prima analisi di questo codice, emergono due aspetti:

  1. La struttura ad albero del codice sorgente viene creata usando uno strumento chiamato cmake.
  2. Esistono numerosi esempi che presuppongono un qualche tipo di interfaccia basata su file.

Tutti gli esempi creati per impostazione predefinita possono essere eseguiti dalla riga di comando, e probabilmente è vero in molti altri codebase 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 creare il codice sorgente

Fortunatamente, gli autori di AV1 stanno sperimentando con Emscripten, l'SDK che useremo per creare la nostra versione WebAssembly. Nella directory radice del repository AV1, il file CMakeLists.txtcontiene queste regole di 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()

La toolchain Emscripten può generare output in due formati: uno è chiamato asm.js e l'altro è WebAssembly. Sceglieremo WebAssembly perché produce un output più piccolo e può essere eseguito più velocemente. Le regole di build esistenti sono destinate a compilare una versione asm.js della libreria da utilizzare in un'applicazione di ispezione che viene utilizzata per esaminare i contenuti di un file video. Per il nostro utilizzo, è necessario l'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")

Per creare con cmake devi prima generare alcune Makefiles eseguendo prima il comando cmake, quindi eseguendo il comando make che eseguirà il passaggio di compilazione. Tieni presente che, poiché stiamo usando Emscripten, dobbiamo usare la toolchain di compilazione Emscripten anziché il compilatore host predefinito. Per farlo, usa Emscripten.cmake, che fa parte dell'SDK Emscripten, e passa il suo percorso come parametro a cmake. La riga di comando seguente è quella che utilizziamo per generare i 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

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 toolchain 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 l'elemento Makefile di primo livello per questo progetto, puoi vedere come lo script viene utilizzato per configurare la build.

Ora che la configurazione è stata completata, chiamiamo semplicemente make che creerà l'intero albero di origine, inclusi gli esempi, ma, soprattutto, genererà libaom.a che contiene il decoder video compilato e pronto per essere incorporato nel progetto.

Progettazione di un'API per l'interfaccia con la libreria

Una volta creata la nostra libreria, dobbiamo capire come interfacciarla per inviarla dati video compressi e poi leggere i frame di video che possiamo mostrare nel browser.

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

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

Poiché il nostro browser non può leggere i file del file system, dobbiamo progettare una forma di interfaccia che ci permetta di astrarre l'I/O in modo da poter creare qualcosa di simile al decoder di esempio per inserire i dati nella nostra libreria AV1.

Sulla riga di comando, l'I/O dei file è noto come interfaccia di flusso, quindi possiamo semplicemente definire la nostra interfaccia, che assomiglia a I/O del flusso, e creare qualsiasi cosa nell'implementazione sottostante.

La nostra interfaccia è definita 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 altri modi quando vengono eseguite all'interno di un browser. Il tipo DATA_Source è opaco dal lato JavaScript e serve solo per incapsulare l'interfaccia. Tieni presente che creare un'API che segue da vicino la semantica del file semplifica il riutilizzo in molte altre codebase destinate all'uso da riga di comando (ad es. diff, sed e così via).

Dobbiamo anche definire una funzione helper denominata DS_set_blob che associa i dati binari non elaborati alle nostre funzioni di I/O dei flussi. In questo modo il BLOB può essere "letto" 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 si trattasse di un'origine dati letta in sequenza. Il codice di riferimento si trova nel file blob-api.c e l'intera implementazione è semplicemente questa:

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 gruppo di test per eseguire test al di fuori del browser

Una delle best practice in materia di software engineering è la creazione di test delle unità per il codice in combinazione con i test di integrazione.

Quando si crea con WebAssembly nel browser, ha senso creare una sorta di test delle unità per l'interfaccia del codice con cui stiamo lavorando in modo da poter eseguire il debug al di fuori del browser e anche essere in grado di testare l'interfaccia che abbiamo creato.

In questo esempio abbiamo emulato un'API basata su stream come interfaccia per la libreria AV1. Quindi, a livello logico, ha senso creare un test complesso che possiamo utilizzare per creare una versione dell'API che venga eseguita sulla riga di comando e che esegua l'I/O effettivo dei file in background implementando l'I/O stesso dei file sotto l'API DATA_Source.

Il codice di I/O dello stream per il nostro test di test è semplice e si presenta come segue:

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

Astraendo l'interfaccia del flusso, possiamo creare il nostro modulo WebAssembly per utilizzare BLOB di dati binari nel browser e interfacciarci con file reali quando creiamo il codice da testare dalla riga di comando. Il nostro codice di set di test è disponibile nel file sorgente di esempio test.c.

Implementazione di un meccanismo di buffering per più frame video

Durante la riproduzione di un video, è prassi comune eseguire il buffer di alcuni fotogrammi per ottenere una riproduzione più fluida. Per i nostri scopi implementeremo un buffer di 10 frame video, quindi eseguiremo il buffering di 10 frame prima di avviare la riproduzione. Quindi, ogni volta che viene visualizzato un frame, proviamo a decodificarne un altro in modo da mantenere il buffer pieno. Questo approccio garantisce che i frame siano disponibili in anticipo per contribuire ad arrestare lo stuttering del video.

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

Il codice in decode-av1.c per leggere frame di dati video dalla libreria AV1 e memorizzarli nel buffer come segue:

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 includere 10 frame di video per il buffer, che è solo una scelta arbitraria. Eseguire il buffering di un numero maggiore di frame significa aumentare il tempo di attesa per l'avvio della riproduzione del video, mentre un buffering troppo basso può causare blocchi durante la riproduzione. Nell'implementazione di un browser nativo, il buffering dei frame è molto più complesso di questa implementazione.

Inserire i frame video sulla pagina con WebGL

I frame del video sottoposti a buffering devono essere visualizzati nella nostra pagina. Poiché si tratta di contenuti video dinamici, vogliamo essere in grado di farlo il più rapidamente possibile. Per questo, ci rivolgiamo a WebGL.

WebGL ci consente di scattare un'immagine, ad esempio un fotogramma di un video, e di utilizzarla come texture dipinta su alcune forme geometriche. Nel mondo WebGL, tutto è costituito da triangoli. Nel nostro caso possiamo utilizzare una pratica funzionalità integrata di WebGL, chiamata gl.TRIANGLE_FAN.

Tuttavia, c'è un problema di minore entità. Le texture WebGL dovrebbero essere immagini RGB, un byte per canale di colore. L'output del nostro decodificatore AV1 sono immagini nel cosiddetto formato YUV, in cui l'output predefinito ha 16 bit per canale e anche ciascun valore U o V corrisponde a 4 pixel nell'immagine di output effettiva. Questo significa che dobbiamo convertire il colore dell'immagine prima di passarla a WebGL per la visualizzazione.

Per farlo, implementiamo una funzione AVX_YUV_to_RGB() che puoi trovare nel file sorgente 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 potrà accedervi. La funzione per estrarre un'immagine dal modulo WebAssembly e mostrarla 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.

Lavoro futuro e concetti chiave

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

  1. È del tutto possibile creare una base di codice complesso da eseguire in modo efficace nel browser utilizzando WebAssembly.
  2. Con WebAssembly è possibile utilizzare una funzionalità che consuma molta CPU, ad esempio la decodifica video avanzata.

Esistono però alcune limitazioni: l'implementazione è tutta in esecuzione sul thread principale e interfoniamo la pittura e la decodifica video su quel singolo thread. La decodificazione dei frame in un web worker potrebbe comportare una riproduzione più fluida, poiché il tempo per la decodifica dei frame dipende molto dai contenuti 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 la riga di comando per una CPU generica, notiamo un carico della CPU simile a quello della versione WebAssembly, tuttavia la libreria di decodifica AV1 include anche implementazioni SIMD che vengono eseguite fino a 5 volte più velocemente. Il WebAssembly Community Group sta attualmente lavorando all'estensione dello standard per includere le primitive SIMD e, quando succede, promette di accelerare notevolmente la decodifica. In tal caso, sarà completamente possibile decodificare i video 4K HD in tempo reale da un decodificatore video WebAssembly.

In ogni caso, il codice di esempio è utile come guida per semplificare il trasferimento di qualsiasi utilità della riga di comando esistente per l'esecuzione come modulo WebAssembly e mostra ciò che è possibile fare sul web già oggi.

Crediti

Ringraziamo Jeff Posnick, Eric Bidelman e Thomas Steiner per aver fornito recensioni e feedback preziosi.