使用 WebAssembly 扩展浏览器

WebAssembly 让我们能够使用新功能扩展浏览器。本文介绍了如何移植 AV1 视频解码器并在任何现代浏览器中播放 AV1 视频。

关于 WebAssembly 最好的事情之一是能够在浏览器原生支持这些功能(如果支持)之前,试验新功能并实施新想法。您可以将这种使用 WebAssembly 的方式视为一种高性能的 polyfill 机制,您可以使用 C/C++ 或 Rust 而不是 JavaScript 编写您的功能。

由于有大量现有代码可供移植,因此可以在浏览器中完成以前在 WebAssembly 出现之前不可行的事情。

本文将逐步介绍如何获取现有的 AV1 视频编解码器源代码,为其构建一个包装器,并在浏览器中试用它,以及帮助构建测试工具来调试包装器的技巧。此处示例的完整源代码可在 github.com/GoogleChromeLabs/wasm-av1 找到,供您参考。

下载以下两个 24fps 测试 视频 文件,并在我们构建的 演示 中试用它们。

选择一个有趣的代码库

多年来,我们看到 web 上的大部分流量都是视频数据,Cisco 估计 实际上高达 80%!当然,浏览器供应商和视频网站都非常清楚减少所有这些视频内容所消耗的数据的愿望。当然,关键在于更好的压缩,正如您所预料的那样,人们正在对下一代视频压缩技术进行大量研究,旨在减轻互联网视频传输的数据负担。

碰巧的是,开放媒体联盟 一直在研究一种名为 AV1 的下一代视频压缩方案,该方案有望大幅缩小视频数据大小。未来,我们希望浏览器能够原生支持 AV1,但幸运的是,压缩器和解压缩器的源代码是 开源的,这使其成为尝试将其编译为 WebAssembly 以便我们在浏览器中进行实验的理想选择。

Bunny movie image.

调整以在浏览器中使用

要将此代码放入浏览器中,我们需要做的第一件事是了解现有代码,以了解 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 为目标,因为它产生的输出更小并且运行速度更快。这些现有的构建规则旨在编译一个 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.cmake 实现的,它是 Emscripten SDK 的一部分,并将它的路径作为参数传递给 cmake 本身。以下命令行是我们用来生成 Makefiles 的命令

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,您可以看到如何使用该脚本来配置构建。

现在所有设置都已完成,我们只需调用 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);
}

通过抽象化流接口,我们可以在浏览器中使用二进制数据 blob 构建我们的 WebAssembly 模块,并在我们构建代码以从命令行进行测试时,与真实文件进行接口。我们的测试工具代码可以在示例源文件 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);
    }
}

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

未来工作和要点

在两个测试 视频 文件(记录为 24 f.p.s. 视频)上试用我们的 演示,我们学到了一些东西

  1. 使用 WebAssembly 构建复杂的代码库以在浏览器中高性能运行是完全可行的;并且
  2. 像高级视频解码这样 CPU 密集型的任务通过 WebAssembly 是可行的。

但是,也存在一些限制:实现都在主线程上运行,并且我们将绘制和视频解码交织在单个线程上。将解码卸载到 web worker 中可以为我们提供更流畅的播放,因为解码帧的时间高度依赖于该帧的内容,有时可能比我们预算的时间更长。

编译为 WebAssembly 使用了通用 CPU 类型的 AV1 配置。如果我们为通用 CPU 在命令行上进行原生编译,我们会看到与 WebAssembly 版本类似的 CPU 负载来解码视频,但是 AV1 解码器库还包括 SIMD 实现,运行速度提高了 5 倍。WebAssembly 社区组目前正在努力扩展标准以包括 SIMD 原语,当它出现时,有望大大加快解码速度。当这种情况发生时,从 WebAssembly 视频解码器实时解码 4k 高清视频将完全可行。

在任何情况下,示例代码都可用作指导,帮助将任何现有的命令行实用程序移植为 WebAssembly 模块,并展示了今天在 web 上已经可以实现的功能。

鸣谢

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