使用 WebAssembly 扩展浏览器

借助 WebAssembly,我们可以使用新功能扩展浏览器。本文介绍了如何移植 AV1 视频解码器,以及如何在任何现代浏览器中播放 AV1 视频。

Alex Danilo

WebAssembly 的一大优势在于,您可以在浏览器原生提供这些功能(如果提供的话)之前,试用新功能并实现新想法。您可以将以这种方式使用 WebAssembly 视为一种高性能的 polyfill 机制,您可以使用 C/C++ 或 Rust(而非 JavaScript)编写功能。

由于有大量现有代码可供移植,因此在 WebAssembly 出现之前无法在浏览器中执行的操作现在也能执行了。

本文将通过示例介绍如何获取现有的 AV1 视频编解码器源代码、为其构建封装容器,并在浏览器中试用该封装容器,以及有助于构建用于调试封装容器的测试框架的技巧。如需参考,请访问 github.com/GoogleChromeLabs/wasm-av1 查看此示例的完整源代码。

下载这两个 24fps 测试视频 文件中的任一一个,然后在我们构建的演示版中试用它们。

选择一个有趣的代码库

多年来,我们发现网络上的大量流量都包含视频数据,事实上,思科估计这一比例高达 80%!当然,浏览器供应商和视频网站非常清楚,用户希望减少所有这些视频内容所消耗的数据量。当然,关键在于更好的压缩。正如您所料,我们对下一代视频压缩进行了大量研究,旨在减少通过互联网传输视频的数据负担。

恰巧的是,开放媒体联盟一直在研究一种名为 AV1 的下一代视频压缩方案,该方案有望大幅缩减视频数据大小。未来,我们希望浏览器能够提供对 AV1 的原生支持,但幸运的是,压缩器和解压缩器的源代码是开源的,因此非常适合尝试编译为 WebAssembly,以便我们在浏览器中对其进行实验。

“Bunny”影片图片。

适应在浏览器中使用

为了将此代码添加到浏览器中,我们首先需要了解现有代码,以便了解该 API 的用途。初次查看此代码时,有两点特别引人注目:

  1. 源代码树是使用名为 cmake 的工具构建的;
  2. 有许多示例都假定某种基于文件的接口。

默认情况下构建的所有示例都可以在命令行上运行,社区中提供的许多其他代码库可能也是如此。因此,我们将构建的用于在浏览器中运行的界面对许多其他命令行工具都很有用。

使用 cmake 构建源代码

幸运的是,AV1 作者一直在实验 Emscripten,我们将使用该 SDK 构建 WebAssembly 版本。在 AV1 代码库的根目录中,CMakeLists.txt 文件包含以下 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
()

Emscripten 工具链可以生成两种格式的输出,一种称为 asm.js,另一种是 WebAssembly。我们将以 WebAssembly 为目标平台,因为它生成的输出更小,并且运行速度更快。这些现有 build 规则旨在编译库的 asm.js 版本,以便在用于查看视频文件内容的检查器应用中使用。在我们的用例中,我们需要 WebAssembly 输出,因此我们将这些代码添加到上述规则中的结束 endif() 语句之前。

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

使用 cmake 进行构建意味着,首先通过运行 cmake 本身生成一些 Makefiles,然后运行命令 make 以执行编译步骤。请注意,由于我们使用的是 Emscripten,因此需要使用 Emscripten 编译器工具链,而不是默认的主机编译器。为此,您可以使用 Emscripten SDK 中的 Emscripten.cmake,并将其路径作为参数传递给 cmake 本身。我们使用以下命令行生成 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

参数 path/to/aom 应设置为 AV1 库源文件所在位置的完整路径。path/to/emsdk-portable/…/Emscripten.cmake 参数需要设置为 Emscripten.cmake 工具链说明文件的路径。

为方便起见,我们使用 shell 脚本来查找该文件:

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

如果您查看此项目的顶级 Makefile,可以了解该脚本如何用于配置 build。

现在,所有设置都已完成,我们只需调用 make 即可,它将构建整个源代码树(包括示例),但最重要的是生成 libaom.a,其中包含已编译的视频解码器,可供我们纳入项目中。

设计 API 以与库交互

构建库后,我们需要确定如何与其交互,以便向其发送压缩的视频数据,然后读取可在浏览器中显示的视频帧。

如需了解 AV1 代码树的内部结构,不妨从文件 [simple_decoder.c](https://aomedia.googlesource.com/aom/+/master/examples/simple_decoder.c) 中找到的示例视频解码器入手。该解码器会读取 IVF 文件,并将其解码为一系列表示视频中帧的图片。

我们在源文件 [decode-av1.c](https://github.com/GoogleChromeLabs/wasm-av1/blob/master/decode-av1.c) 中实现接口。

由于我们的浏览器无法从文件系统读取文件,因此我们需要设计某种形式的接口,以便我们提取 I/O,以便构建类似于示例解码器的功能,将数据提取到 AV1 库中。

在命令行中,文件 I/O 被称为流接口,因此我们只需定义一个类似于流 I/O 的接口,并在底层实现中构建我们喜欢的任何内容。

我们将接口定义为:

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

open/read/empty/close 函数与常规文件 I/O 操作非常相似,这让我们可以轻松将它们映射到命令行应用的文件 I/O,或者在浏览器中运行时以其他方式实现它们。DATA_Source 类型在 JavaScript 端是不可见的,只用于封装接口。请注意,构建紧密遵循文件语义的 API 可让您轻松地在许多其他打算通过命令行使用的代码库(例如 diff、sed 等)中重复使用。

我们还需要定义一个名为 DS_set_blob 的辅助函数,用于将原始二进制数据绑定到我们的流 I/O 函数。这样,就可以像读取数据流一样“读取”blob(即看起来像是顺序读取的文件)。

我们的示例实现支持像顺序读取数据源一样读取传入的 blob。参考代码可以在 blob-api.c 文件中找到,整个实现就是这样:

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

构建自动化测试框架以在浏览器之外进行测试

软件工程领域的一项最佳实践是,结合集成测试为代码构建单元测试。

在浏览器中使用 WebAssembly 进行构建时,为我们所使用的代码的接口构建某种形式的单元测试很有意义,这样我们就可以在浏览器之外进行调试,还能测试我们构建的接口。

在此示例中,我们模拟了基于流的 API 作为 AV1 库的接口。因此,从逻辑上讲,构建一个测试框架是明智之举。我们可以使用该框架构建一个在命令行上运行的 API 版本,并通过在 DATA_Source API 下实现文件 I/O 本身,在后台执行实际文件 I/O。

我们的测试框架的流 I/O 代码非常简单,如下所示:

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

通过抽象化流接口,我们可以构建 WebAssembly 模块,以便在浏览器中使用二进制数据 blob,并在通过命令行构建代码以进行测试时与真实文件交互。我们的自动化测试框架代码可在示例源文件 test.c 中找到。

为多个视频帧实现缓冲机制

在播放视频时,通常会缓冲几个帧,以便顺畅播放。为满足我们的目的,我们只会实现 10 帧视频的缓冲区,因此我们会在开始播放之前缓冲 10 帧。然后,每显示一帧时,我们都会尝试解码另一帧,以便保持缓冲区满载。这种方法可确保提前提供帧,以帮助停止视频卡顿。

在我们的简单示例中,整个压缩视频都可以读取,因此实际上不需要缓冲。不过,如果我们要扩展来源数据接口以支持从服务器流式传输输入,则需要采用缓冲机制。

decode-av1.c 中的代码用于从 AV1 库读取视频数据帧并将其存储在缓冲区中,如下所示:

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


我们选择让缓冲区包含 10 帧视频,这只是一个任意选择。缓冲的帧越多,视频开始播放所需的等待时间就越长,而缓冲的帧过少可能会导致播放期间出现卡顿。在原生浏览器实现中,帧缓冲要比此实现复杂得多。

使用 WebGL 将视频帧呈现到页面上

我们缓冲的视频帧需要显示在我们的页面上。由于这是动态视频内容,因此我们希望能够尽快完成。为此,我们将使用 WebGL

借助 WebGL,我们可以获取图片(例如视频帧),并将其用作纹理绘制到某些几何图形上。在 WebGL 世界中,所有内容都是由三角形组成的。因此,在本例中,我们可以使用 WebGL 的一项便捷的内置功能,称为 gl.TRIANGLE_FAN。

不过,有一个小问题。WebGL 纹理应为 RGB 图片,每个颜色通道占用一个字节。AV1 解码器的输出是所谓的 YUV 格式的图片,其中默认输出每个通道有 16 位,并且每个 U 或 V 值对应于实际输出图片中的 4 个像素。这意味着,我们需要先对图片进行颜色转换,然后才能将其传递给 WebGL 进行显示。

为此,我们实现了一个函数 AVX_YUV_to_RGB(),您可以在源文件 yuv-to-rgb.c 中找到该函数。该函数会将 AV1 解码器的输出转换为我们可以传递给 WebGL 的内容。请注意,从 JavaScript 调用此函数时,我们需要确保要将转换后的图片写入的内存已在 WebAssembly 模块的内存中分配 - 否则,它将无法访问该内存。用于从 WebAssembly 模块中获取图片并将其绘制到屏幕上的函数如下所示:

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

您可以在源文件 draw-image.js 中找到用于实现 WebGL 绘制的 drawImageToCanvas() 函数,以供参考。

后续工作和要点

在两个测试视频 文件(以 24 帧/秒的视频录制)上试用我们的演示版,我们发现了以下几点:

  1. 完全可以构建一个复杂的代码库,以便使用 WebAssembly 在浏览器中高效运行;
  2. 通过 WebAssembly,您可以实现 CPU 密集型任务,例如高级视频解码。

不过,这也存在一些限制:所有实现都在主线程中运行,并且我们会在该单个线程中交错绘制和视频解码。将解码工作分流到 Web Worker 可以实现更流畅的播放,因为解码帧的时间在很大程度上取决于该帧的内容,有时可能比预算的时间还要长。

编译为 WebAssembly 时,会针对通用 CPU 类型使用 AV1 配置。如果我们在命令行上针对通用 CPU 进行原生编译,则会发现解码视频的 CPU 负载与 WebAssembly 版本类似,但 AV1 解码器库还包含 SIMD 实现,其运行速度最高可提高 5 倍。WebAssembly 社区组目前正在努力扩展该标准,以纳入 SIMD 基元,一旦完成,解码速度有望大幅加快。届时,完全可以通过 WebAssembly 视频解码器实时解码 4K 高清视频。

无论如何,示例代码都非常有用,可作为指南来帮助将任何现有命令行实用程序移植为 WebAssembly 模块,并展示 Web 上目前可实现的功能。

赠金

感谢 Jeff Posnick、Eric Bidelman 和 Thomas Steiner 提供宝贵的评价和反馈。