使用 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 文件包含以下构建规则:

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