借助 WebAssembly,我们可以使用新功能扩展浏览器。本文介绍了如何移植 AV1 视频解码器,以及如何在任何现代浏览器中播放 AV1 视频。
WebAssembly 的一大优势是能够在浏览器原生提供新功能(如果有)之前对新功能进行实验并实现新想法。您可以将以这种方式使用 WebAssembly 视为一种高性能的 polyfill 机制,您可以使用 C/C++ 或 Rust(而非 JavaScript)编写功能。
由于有大量现有代码可供移植,因此可以在浏览器中执行在 WebAssembly 出现之前不可行的操作。
本文将通过示例介绍如何获取现有的 AV1 视频编解码器源代码、为其构建封装容器,并在浏览器中试用该封装容器,以及有助于构建用于调试封装容器的测试框架的技巧。如需参考,请访问 github.com/GoogleChromeLabs/wasm-av1 查看此示例的完整源代码。
下载这两个 24fps 测试视频文件中的任一一个,然后在我们构建的演示版中试用它们。
选择一个有趣的代码库
多年来,我们发现网络上的大量流量都包含视频数据,Cisco 估计这一比例甚至高达 80%!当然,浏览器供应商和视频网站非常清楚,用户希望减少所有这些视频内容所消耗的数据量。当然,关键在于更好的压缩。正如您所料,我们对下一代视频压缩进行了大量研究,旨在减少通过互联网传输视频的数据负担。
恰巧的是,开放媒体联盟一直在研究一种名为 AV1 的下一代视频压缩方案,该方案有望大幅缩减视频数据大小。未来,我们希望浏览器能够提供对 AV1 的原生支持,但幸运的是,压缩器和解压缩器的源代码是开源的,因此非常适合尝试将其编译为 WebAssembly,以便我们可以在浏览器中进行实验。
适应在浏览器中使用
为了将此代码添加到浏览器中,我们首先需要了解现有代码,以便了解该 API 的具体情况。初次查看此代码时,有两点特别引人注目:
- 源代码树是使用名为
cmake
的工具构建的; - 有许多示例都假定某种基于文件的接口。
默认构建的所有示例都可以在命令行上运行,社区中提供的许多其他代码库可能也是如此。因此,我们要构建能够在浏览器中运行的界面对于许多其他命令行工具很有用。
使用 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 个视频文件)的演示可以从中了解到以下信息:
- 完全可以构建一个复杂的代码库,以便使用 WebAssembly 在浏览器中高效运行;
- 通过 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 提供宝贵的评价和反馈。