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.
Uno dei vantaggi principali di WebAssembly è la possibilità di sperimentare nuove funzionalità e implementare nuove idee prima che il browser le implementi 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.
Questo articolo illustra un esempio di come prendere il codice sorgente del codec video AV1 esistente, creare un wrapper e provarlo nel browser, oltre a fornire suggerimenti per la creazione di un harness di test per 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.
Scegliere una base di codice 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 molto consapevoli del desiderio di ridurre i dati consumati da tutti questi contenuti video. La chiave per ottenere questo risultato, ovviamente, è una migliore compressione e, come è facile immaginare, sono in corso molti studi sulla compressione video di nuova generazione volti a ridurre il carico di dati per l'invio 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.
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 come funziona l'API. Quando guardi per la prima volta questo codice, noti due cose:
- L'albero di origine viene creato utilizzando uno strumento chiamato
cmake
; e - 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.txt
contiene 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 WebAssembly, quindi aggiungiamo queste righe subito prima dell'istruzione endif()
di chiusura nelle regole riportate sopra.
# 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 eseguendo il comando
make
che eseguirà il passaggio di compilazione.
Tieni presente che, poiché utilizziamo Emscripten, dobbiamo utilizzare 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 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 esamini il file 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 su 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. In questo modo il blob può essere "letto" come se fosse uno stream (ovvero come un file letto in sequenza).
La nostra implementazione di esempio consente di leggere il blob passato come se fosse un'origine dati di lettura sequenziale. 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 essere in grado di 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 ed esegua effettivamente l'I/O file sotto il cofano implementando l'I/O file stessa sotto la nostra API DATA_Source
.
Il codice 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 sorgente di esempio test.c
.
Implementazione di un meccanismo di buffering per più frame 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 decodificarne un altro per mantenere pieno il buffer. In questo modo, i fotogrammi sono disponibili in anticipo e contribuiscono a eliminare le interruzioni 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 in streaming da un server, dobbiamo implementare il meccanismo di buffering.
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 fotogrammi di video, ma si tratta solo di una scelta arbitraria. Un buffering di più frame significa più tempo di attesa prima che inizi la riproduzione del video, mentre un buffering di pochi frame può causare interruzioni durante la riproduzione. In un'implementazione del browser nativo, il buffering dei frame è molto più complesso rispetto a questa implementazione.
Inserire i frame video nella pagina con WebGL
I frame del video che abbiamo memorizzato nella cache 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. Pertanto, per il nostro caso possiamo utilizzare una comoda 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 del decodificatore AV1 in qualcosa che possiamo
trasferire 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 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 la pittura WebGL può essere trovata nel file di origine
draw-image.js
come riferimento.
Attività future e punti chiave
Abbiamo provato la nostra demo su due file video di prova (registrati come video a 24 f/s) e abbiamo appreso alcune cose:
- È del tutto fattibile creare una base di codice complessa da eseguire in modo efficiente nel browser utilizzando WebAssembly.
- Un'operazione che richiede un utilizzo intensivo della CPU come la decodifica video avanzata è fattibile tramite WebAssembly.
Esistono però alcune limitazioni: l'implementazione viene eseguita interamente nel thread principale e intercaliamo la pittura e la decodifica video in 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à a riga di comando esistente in modo che venga eseguita 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.