借助 WebAssembly,我们得以使用新功能扩展浏览器。本文介绍了如何移植 AV1 视频解码器并在任何新型浏览器中播放 AV1 视频。
WebAssembly 最棒的一点是,能够在浏览器以原生方式(若有可能)以原生方式发布这些功能之前,试验新功能并实现新想法。您可以将 WebAssembly 视为一种高性能 polyfill 机制来使用 C/C++ 或 Rust(而非 JavaScript)编写功能。
由于有大量现有代码可供移植,您可以在浏览器中执行在 WebAssembly 推出之前无法实现的操作。
本文将举例说明如何获取现有的 AV1 视频编解码器源代码、为其构建封装容器并在浏览器中试用该代码,并提供有助于构建自动化测试框架来调试封装容器的提示。如需查看此处示例的完整源代码,请访问 github.com/GoogleChromeLabs/wasm-av1,供参考。
请下载这两个 24fps 测试视频 文件中的一个,然后在我们的演示中试用。
选择一个感兴趣的代码库
多年来,我们看到 Web 流量中有很大一部分是由视频数据组成的,Cisco 据 Cisco 的估计,有 80% 之多!当然,浏览器供应商和视频网站非常清楚,他们希望减少所有这些视频内容消耗的数据。当然,这样做的关键是更好的压缩,而且正如您所料,有很多关于新一代视频压缩的研究,旨在减轻在互联网上传送视频所产生的数据负担。
事实上,开放媒体联盟 (Alliance for Open Media) 一直在研究名为 AV1 的新一代视频压缩方案,该方案可以显著缩减视频数据大小。未来,我们希望浏览器提供对 AV1 的原生支持,但所幸的是,压缩器和解压缩器的源代码是开源的,因此非常适合尝试将其编译为 WebAssembly,以便我们可以在浏览器中试验它。
![兔子电影图片。](https://web.developers.google.cn/static/articles/wasm-av1/image/bunny-movie-image-0b3bfd3d941b3.png?authuser=5&hl=sv)
调整以便在浏览器中使用
为了将此代码放入浏览器中,我们首先要做的就是了解现有代码,以了解 API 是什么样的。首先看这段代码时,有两点值得注意:
- 源代码树是使用名为
cmake
的工具构建的;并且 - 有很多示例都假定采用某种基于文件的接口。
默认情况下构建的所有示例都可以在命令行上运行,社区中提供的许多其他代码库中可能也是如此。因此,我们要构建的用于在浏览器中运行的界面对于许多其他命令行工具来说可能很有用。
使用 cmake
构建源代码
幸运的是,AV1 作者一直在尝试 Emscripten,我们将用于构建 WebAssembly 版本。在 AV1 代码库的根目录中,文件 CMakeLists.txt
包含以下构建规则:
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 为目标,因为它生成的输出更少,运行速度更快。这些现有构建规则旨在编译 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 帧/秒的视频)试用我们的演示,可以学到一些东西:
- 使用 WebAssembly 构建可在浏览器中高效运行的复杂代码库是完全可行的;
- 可以通过 WebAssembly 实现与高级视频解码一样的 CPU 密集型操作。
不过,也有一些限制:实现全部在主线程上运行,并且我们会在该单线程上交错绘制和视频解码。将解码分流到 Web 工作器可以带来更顺畅的播放体验,因为解码帧的时间在很大程度上取决于该帧的内容,有时可能需要比预算更多的时间。
编译到 WebAssembly 中,会将 AV1 配置用于通用 CPU 类型。如果我们在命令行上针对通用 CPU 进行原生编译,则会看到与 WebAssembly 版本一样对视频进行解码的 CPU 负载类似,但是 AV1 解码器库还包含运行速度提高多达 5 倍的 SIMD 实现。WebAssembly 社区小组目前正在努力扩展该标准,使其包含 SIMD 基元,并承诺大幅度加快解码速度。发生这种情况时,通过 WebAssembly 视频解码器实时解码 4K 高清视频是完全可行的。
在任何情况下,示例代码都可以作为指导,帮助移植任何现有的命令行实用程序以作为 WebAssembly 模块运行,并展示当今网络上已经可以实现的功能。
赠金
感谢 Jeff Posnick、Eric Bidelman 和 Thomas Steiner 提供了宝贵的评价和反馈。