Memperluas browser dengan WebAssembly

WebAssembly memungkinkan kita memperluas browser dengan fitur baru. Artikel ini menunjukkan cara melakukan port decoder video AV1 dan memutar video AV1 di browser modern.

Alex Danilo

Salah satu hal terbaik tentang WebAssembly adalah kemampuan untuk bereksperimen dengan kemampuan baru dan menerapkan ide baru sebelum browser mengirimkan fitur tersebut secara native (jika ada). Anda dapat menggunakan WebAssembly dengan cara ini sebagai mekanisme polyfill berperforma tinggi, dengan menulis fitur dalam C/C++ atau Rust, bukan JavaScript.

Dengan banyaknya kode yang ada dan tersedia untuk porting, Anda dapat melakukan hal-hal di browser yang tidak dapat dilakukan hingga WebAssembly hadir.

Artikel ini akan membahas contoh cara mengambil kode sumber codec video AV1 yang ada, mem-build wrapper untuknya, dan mencobanya di dalam browser Anda serta tips untuk membantu mem-build harness pengujian guna men-debug wrapper. Kode sumber lengkap untuk contoh di sini tersedia di github.com/GoogleChromeLabs/wasm-av1 sebagai referensi.

Download salah satu dari dua file video pengujian 24 fps ini dan coba di demo bawaan kami.

Memilih code-base yang menarik

Selama beberapa tahun terakhir, kami melihat bahwa sebagian besar traffic di web terdiri dari data video. Cisco memperkirakannya hingga 80%. Tentu saja, vendor browser dan situs video sangat menyadari keinginan untuk mengurangi data yang digunakan oleh semua konten video ini. Kuncinya, tentu saja, adalah kompresi yang lebih baik, dan seperti yang Anda harapkan, ada banyak riset tentang kompresi video generasi berikutnya yang bertujuan untuk mengurangi beban data pengiriman video di internet.

Kebetulan, Alliance for Open Media telah mengerjakan skema kompresi video generasi berikutnya yang disebut AV1 yang menjanjikan untuk memperkecil ukuran data video secara signifikan. Di masa mendatang, kami berharap browser akan mengirimkan dukungan native untuk AV1, tetapi untungnya kode sumber untuk kompresor dan dekompresor adalah open source, yang menjadikannya kandidat ideal untuk mencoba mengompilasi ke dalam WebAssembly sehingga kita dapat bereksperimen dengannya di browser.

Gambar film Kelinci.

Beradaptasi untuk digunakan di browser

Salah satu hal pertama yang perlu kita lakukan untuk memasukkan kode ini ke browser adalah mengenali kode yang ada untuk memahami seperti apa API tersebut. Saat pertama kali melihat kode ini, ada dua hal yang menarik:

  1. Hierarki sumber dibuat menggunakan alat yang disebut cmake; dan
  2. Ada sejumlah contoh yang semuanya mengasumsikan semacam antarmuka berbasis file.

Semua contoh yang dibuat secara default dapat dijalankan di command line, dan hal ini mungkin berlaku di banyak basis kode lain yang tersedia di komunitas. Jadi, antarmuka yang akan kita buat agar dapat berjalan di browser dapat berguna untuk banyak alat command line lainnya.

Menggunakan cmake untuk mem-build kode sumber

Untungnya, penulis AV1 telah bereksperimen dengan Emscripten, SDK yang akan kita gunakan untuk mem-build versi WebAssembly. Di root repositori AV1, file CMakeLists.txtberisi aturan build berikut:

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

Toolchain Emscripten dapat menghasilkan output dalam dua format, salah satunya disebut asm.js dan yang lainnya adalah WebAssembly. Kita akan menargetkan WebAssembly karena menghasilkan output yang lebih kecil dan dapat berjalan lebih cepat. Aturan build yang ada ini dimaksudkan untuk mengompilasi library versi asm.js untuk digunakan dalam aplikasi inspector yang dimanfaatkan untuk melihat konten file video. Untuk penggunaan kita, kita memerlukan output WebAssembly sehingga kita menambahkan baris ini tepat sebelum pernyataan endif() penutup dalam aturan di atas.

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

Melakukan build dengan cmake berarti terlebih dahulu membuat beberapa Makefiles dengan menjalankan cmake itu sendiri, diikuti dengan menjalankan perintah make yang akan melakukan langkah kompilasi. Perhatikan bahwa karena kita menggunakan Emscripten, kita perlu menggunakan toolchain compiler Emscripten, bukan compiler host default. Hal ini dicapai dengan menggunakan Emscripten.cmake yang merupakan bagian dari Emscripten SDK dan meneruskan jalurnya sebagai parameter ke cmake itu sendiri. Command line di bawah ini adalah yang kita gunakan untuk membuat 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

Parameter path/to/aom harus ditetapkan ke jalur lengkap lokasi file sumber library AV1. Parameter path/to/emsdk-portable/…/Emscripten.cmake harus ditetapkan ke jalur untuk file deskripsi toolchain Emscripten.cmake.

Untuk memudahkan, kita menggunakan skrip shell untuk menemukan file tersebut:

#!/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

Jika melihat Makefile level atas untuk project ini, Anda dapat melihat cara skrip tersebut digunakan untuk mengonfigurasi build.

Setelah semua penyiapan selesai, kita cukup memanggil make yang akan mem-build seluruh hierarki sumber, termasuk sampel, tetapi yang paling penting membuat libaom.a yang berisi decoder video yang dikompilasi dan siap untuk kita gabungkan ke dalam project.

Mendesain API untuk antarmuka ke library

Setelah mem-build library, kita perlu mencari tahu cara berinteraksi dengan library tersebut untuk mengirim data video yang dikompresi ke library, lalu membaca kembali frame video yang dapat ditampilkan di browser.

Melihat di dalam hierarki kode AV1, titik awal yang baik adalah contoh dekoder video yang dapat ditemukan dalam file [simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c). Decoder tersebut membaca file IVF dan mendekodenya menjadi serangkaian gambar yang mewakili frame dalam video.

Kita menerapkan antarmuka dalam file sumber [decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c).

Karena browser tidak dapat membaca file dari sistem file, kita perlu mendesain beberapa bentuk antarmuka yang memungkinkan kita mengabstraksi I/O sehingga kita dapat mem-build sesuatu yang mirip dengan contoh dekoder untuk memasukkan data ke library AV1.

Di command line, I/O file dikenal sebagai antarmuka streaming, sehingga kita dapat menentukan antarmuka kita sendiri yang terlihat seperti I/O streaming dan mem-build apa pun yang kita suka dalam implementasi yang mendasarinya.

Kita menentukan antarmuka sebagai berikut:

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

Fungsi open/read/empty/close terlihat sangat mirip dengan operasi I/O file normal yang memungkinkan kita memetakan dengan mudah ke I/O file untuk aplikasi command line, atau menerapkannya dengan cara lain saat dijalankan di dalam browser. Jenis DATA_Source bersifat buram dari sisi JavaScript, dan hanya berfungsi untuk mengenkapsulasi antarmuka. Perhatikan bahwa mem-build API yang mengikuti semantik file dengan cermat akan memudahkan penggunaan kembali di banyak code-base lain yang dimaksudkan untuk digunakan dari command line (misalnya, diff, sed, dll.).

Kita juga perlu menentukan fungsi bantuan yang disebut DS_set_blob yang mengikat data biner mentah ke fungsi I/O streaming kita. Hal ini memungkinkan blob 'dibaca' seolah-olah merupakan streaming (yaitu terlihat seperti file yang dibaca secara berurutan).

Contoh implementasi kami memungkinkan pembacaan blob yang diteruskan seolah-olah itu adalah sumber data yang dibaca secara berurutan. Kode referensi dapat ditemukan dalam file blob-api.c, dan seluruh implementasinya adalah ini:

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

Mem-build harness pengujian untuk menguji di luar browser

Salah satu praktik terbaik dalam rekayasa software adalah membuat pengujian unit untuk kode bersama dengan pengujian integrasi.

Saat mem-build dengan WebAssembly di browser, sebaiknya buat beberapa bentuk pengujian unit untuk antarmuka ke kode yang sedang kita kerjakan sehingga kita dapat melakukan debug di luar browser dan juga dapat menguji antarmuka yang telah kita build.

Dalam contoh ini, kita telah mengemulasi API berbasis streaming sebagai antarmuka untuk library AV1. Jadi, secara logis, sebaiknya buat harness pengujian yang dapat kita gunakan untuk mem-build versi API yang berjalan di command line dan melakukan I/O file yang sebenarnya di balik layar dengan menerapkan I/O file itu sendiri di bawah DATA_Source API.

Kode I/O streaming untuk harness pengujian kami sederhana, dan terlihat seperti ini:

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

Dengan memisahkan antarmuka streaming, kita dapat mem-build modul WebAssembly untuk menggunakan blob data biner saat berada di browser, dan antarmuka ke file sebenarnya saat mem-build kode untuk diuji dari command line. Kode harness pengujian kami dapat ditemukan dalam contoh file sumber test.c.

Mengimplementasikan mekanisme buffering untuk beberapa frame video

Saat memutar video, praktik umum adalah melakukan buffering pada beberapa frame untuk membantu pemutaran yang lebih lancar. Untuk tujuan kita, kita hanya akan menerapkan buffering 10 frame video, sehingga kita akan melakukan buffering 10 frame sebelum memulai pemutaran. Kemudian, setiap kali frame ditampilkan, kita akan mencoba mendekode frame lain agar buffer tetap penuh. Pendekatan ini memastikan frame tersedia terlebih dahulu untuk membantu menghentikan video yang tersendat.

Dengan contoh sederhana kami, seluruh video yang dikompresi dapat dibaca, sehingga buffering tidak terlalu diperlukan. Namun, jika kita ingin memperluas antarmuka data sumber untuk mendukung input streaming dari server, kita harus memiliki mekanisme buffering.

Kode di decode-av1.c untuk membaca frame data video dari library AV1 dan menyimpannya dalam buffering seperti ini:

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


Kami telah memilih untuk membuat buffering berisi 10 frame video, yang merupakan pilihan arbitrer. Buffering lebih banyak frame berarti lebih banyak waktu tunggu untuk video mulai diputar, sedangkan buffering terlalu sedikit frame dapat menyebabkan perlambatan selama pemutaran. Dalam implementasi browser native, buffering frame jauh lebih kompleks daripada implementasi ini.

Mendapatkan frame video ke halaman dengan WebGL

Frame video yang telah di-buffer harus ditampilkan di halaman. Karena ini adalah konten video dinamis, kita ingin dapat melakukannya secepat mungkin. Untuk itu, kita beralih ke WebGL.

WebGL memungkinkan kita mengambil gambar, seperti frame video, dan menggunakannya sebagai tekstur yang dicat ke beberapa geometri. Di dunia WebGL, semuanya terdiri dari segitiga. Jadi, untuk kasus ini, kita dapat menggunakan fitur bawaan WebGL yang praktis, yang disebut gl.TRIANGLE_FAN.

Namun, ada masalah kecil. Tekstur WebGL seharusnya berupa gambar RGB, satu byte per saluran warna. Output dari decoder AV1 kami adalah gambar dalam format yang disebut YUV, dengan output default memiliki 16 bit per saluran, dan juga setiap nilai U atau V sesuai dengan 4 piksel dalam gambar output yang sebenarnya. Artinya, kita perlu mengonversi warna gambar sebelum dapat meneruskannya ke WebGL untuk ditampilkan.

Untuk melakukannya, kita mengimplementasikan fungsi AVX_YUV_to_RGB() yang dapat Anda temukan di file sumber yuv-to-rgb.c. Fungsi tersebut mengonversi output dari decoder AV1 menjadi sesuatu yang dapat kita teruskan ke WebGL. Perhatikan bahwa saat memanggil fungsi ini dari JavaScript, kita perlu memastikan bahwa memori tempat kita menulis gambar yang dikonversi telah dialokasikan di dalam memori modul WebAssembly - jika tidak, memori tersebut tidak dapat mendapatkan akses ke sana. Fungsi untuk mendapatkan gambar dari modul WebAssembly dan melukisnya ke layar adalah ini:

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

Fungsi drawImageToCanvas() yang mengimplementasikan gambar WebGL dapat ditemukan di file sumber draw-image.js untuk referensi.

Pekerjaan mendatang dan poin penting

Mencoba demo pada dua file video pengujian (direkam sebagai video 24 fps) memberi kita beberapa pelajaran:

  1. Anda dapat membuat code-base yang kompleks untuk berjalan dengan performa tinggi di browser menggunakan WebAssembly; dan
  2. Hal yang membutuhkan CPU secara intensif seperti decoding video lanjutan dapat dilakukan melalui WebAssembly.

Namun, ada beberapa batasan: penerapan semuanya berjalan di thread utama dan kita menyelang-seling antara proses menggambar dan mendekode video di satu thread tersebut. Dengan memindahkan decoding ke pekerja web, pemutaran akan menjadi lebih lancar, karena waktu untuk mendekode frame sangat bergantung pada konten frame tersebut dan terkadang dapat memerlukan waktu lebih lama dari yang kita anggarkan.

Kompilasi ke dalam WebAssembly menggunakan konfigurasi AV1 untuk jenis CPU generik. Jika kita mengompilasi secara native di command line untuk CPU generik, kita akan melihat beban CPU yang serupa untuk mendekode video seperti pada versi WebAssembly, tetapi library decoder AV1 juga menyertakan implementasi SIMD yang berjalan hingga 5 kali lebih cepat. WebAssembly Community Group saat ini sedang berupaya memperluas standar untuk menyertakan primitive SIMD, dan jika sudah tersedia, hal ini akan sangat mempercepat proses decoding. Jika hal itu terjadi, Anda dapat mendekode video HD 4K secara real-time dari decoder video WebAssembly.

Apa pun kasusnya, kode contoh berguna sebagai panduan untuk membantu mem-port utilitas command line yang ada untuk dijalankan sebagai modul WebAssembly dan menunjukkan hal yang mungkin dilakukan di web saat ini.

Kredit

Terima kasih kepada Jeff Posnick, Eric Bidelman, dan Thomas Steiner atas ulasan dan masukan yang berharga.