最近组长交给我一个任务,让我尝试一下将知名视频转码库 ffmpeg (使用 C 编写)跑在浏览器里面,我当时就懵了,还能这么玩?调研了一番,发现有个叫 WebAssembly 的东西可以干这么件事情,于是就有了这篇文章。
Web 平台可以看做有两个部分:
长期以来,VM 只能加载 JS 运行,JS 可能足够满足我们的需求,但如今我们却遇到了各种性能问题,如 3D 游戏、VR/AR、计算机视觉、图片/视频编辑、以及其他需要原生性能的领域。
同时,下载、解析和编译大体积的 JS 应用是很困难的,在一些资源更加受限的平台上,如移动设备等,则会更加放到这种性能瓶颈。
WebAssembly 是一种与 JavaScript 不同的语言,它不是为了替代 JS 而生的,而是被设计为与 JS 互为补充并能协作,使得 Web 开发者能够重复利用两种语言的优点:
随着 WebAssembly 的出现,上述提到的 VM 现在可以加载两种类型的代码执行:JavaScript 和 WebAssembly。
JavaScript 和 WebAssembly 可以互操作,实际上一份 WebAssembly 代码被称为一个模块,而 WebAssembly 的模块与 ES2015 的模块在具有很多共同的特性。
为了理解 WebAssembly 是如何在 Web 运行的,需要了解几个关键概念:
postMessage
共享,一个 Module 声明了类似 ES2015 模块类似的 import 和 export。WebAssembly 的 JavaScript API 提供给开发者创建 Module、Memory、Table 和 Instance 的能力,给定一个 WebAssembly 的 Instance,JS 代码可以同步的调用它的 exports -- 被作为普通的 JavaScript 函数导出。任意 JavaScript 函数可以被 WebAssembly 代码同步的调用,通过将 JavaScript 函数作为 imports 传给 WebAssembly Instance。
因为 JavaScript 能够完全控制 WebAssembly 代码的下载、编译和运行,所以 JavaScript 开发者可以认为 WebAssembly 只是 JavaScript 的一个新特性 -- 可以高效的生成高性能的函数。
在未来, WebAssembly 模块可以以 ES2015 的模块加载形式加载,如 <script type="module">
,意味着 JS 可以获取、编译、和导入一个 WebAssembly 模块,就像导入 ES2015 模块一样简单。
WebAssembly 给 Web 平台添加了两块内容:一种二进制格式代码,以及一系列可用于加载和执行二进制代码的 API。
WebAssembly 目前处于一个萌芽的节点,之后肯定会涌现出很多工具,而目前有四个主要的入口:
虽然也有一些其他工具如:
WasmFiddle[1]
WasmFiddle++[2]
WasmExplorer[3]
但是这些工具都缺乏 EMScripten 的工具链和优化操作,EMScripten 的具体运行过程如下:
未来 WebAssembly 也可以直接调用 Web API[4]。
上述的 JS 胶水代码并不像想象中那么简单,一开始,EMScripten 实现了一些流行的 C/C++ 库,如 SDL、OpenGL、OpenAL、以及一部分 POSIX 库,这些库都是根据 Web API 来实现的,所以需要 JS 胶水代码来帮助 WebAssembly 和底层的 Web API 进行交互。
所以,有部分胶水代码实现了 C/C++ 代码需要用到的对应的库的功能,胶水代码还同时包含调用上述 WebAssembly JavaScript API 的以获取、加载和运行 .wasm 文件的逻辑。
生成的 HTML 文档加载 JS 胶水代码,然后将输出写入到 <textarea>
中去,如果应用使用到了 OpenGL,HTML 也包含 <canvas>
元素来作为渲染目标,你可以很方便的改写 EMScripten 的输出,将其转换成 Web 应用需要的形式。
如果你想构建自己的编译器、工具链,或者能够在运行时生成 WebAssembly 代码的 JS 库,你可以选择手写 WebAssembly 代码。和物理汇编语言类似,WebAssembly 的二进制格式也有一种文本表示,你可以手动编写或生成这种文本格式,并通过 WebAssembly 的文本到二进制(text-to-binary)的工具将文本转为二进制格式。
多谢 Rust WebAssembly 工作组的不懈努力,我们现在可以将 Rust 代码编译为 WebAssembly 代码。
可以参考这个链接:https://developer.mozilla.org/en-US/docs/WebAssembly/Rust_to_wasm
对于 Web 开发者来说,可是使用类 TypeScript 的形式来尝试 WebAssembly 的编写,而不需要学习 C 或 Rust 的细节,那么 AssemblyScript 将会是最好的选择。AssemblyScript 将 TypeScript 的变体编译为 WebAssembly,使得 Web 开发者可以使用 TypeScript 兼容的工具链,例如 Prettier、VSCode Intellisense,你可以查看它的文档[5]来了解如何使用。
通过 EMScripten 工具,可将新写的 C/C++ 代码编译为 WebAssembly 使用。
为了能够使用 Emscripten 工具,我们需要安装它。首先 Clone 相关代码:
git clone https: // github . com / emscripten-core / emsdk . git
cd emsdk
然后执行如下脚本来配置 emsdk:
# 如果之前 clone 过,那么这里更新最新的代码
git pull
# 下载和安装最新的 SDK 工具
./emsdk install latest
# 为当前的 user 激活最新的 SDK 工具,在 .emscripten 文件中写入当前用户
./emsdk activate latest
# 将 SDK 相关的命令加入到 PATH,以及激活其他环境变量
source ./emsdk_env.sh
通过上面的操作我们就可以在命令行使用 Emscripten 相关的命令了,一般我们使用 Emscripten 时,主要有两种场景:
首先在 emsdk
目录同级创建一个文件夹:WebAssembly
,然后在文件夹下创建一份 C 代码:hello.c
如下:
#include <stdio.h>
int main() {
printf("Hello World\n");
}
然后在命令行中导航到此 hello.c
目录下, 运行如下命令来调用 Emscripten 进行编译:
emcc hello.c -s WASM=1 -o hello.html
上述命令解释如下:
emcc
为 Emscripten 的命令行命令-s WASM=1
则告诉 Emscripten 需要输出 wasm 文件,如果不指定这个参数,那么默认会输出 asm.js
-o hello.html
则告诉编译器生成一个名为 hello.html
的 HTML 文档来运行代码,以及 wasm 模块和对应的用于编译和实例化 wasm 的 JavaScript 胶水代码,以便 wasm 可以在 Web 环境中使用运行如上命令之后,你的 WebAssembly
目录下应该多出了三个文件:
hello.wasm
hello.js
,通过它将原生 C 函数翻译成 JavaScript/wasm 代码hello.html
,用于加载、编译和实例化 wasm 的代码,并将 wasm 代码的输出展示在浏览器上。目前剩下的工作为在支持 WebAssembly 的浏览器中加载 hello.html
运行。
在 Firefox 52+、Chrome 57+ 和最小的 Opera 浏览器中默认支持,也可以通过在 Firefox 47+ 中的
about:config
开启javascript.options.wasm
以及 Chrome 51+、Opera 38+ 中的chrome://flags
来允许实验性的 WebAssembly 特效支持。
因为现代浏览器不支持 file://
形式的 XHR 请求,所以在 HTML 中无法加载 .wasm
等相关的文件,所以为了能够看到效果,需要额外的本地服务器支持,可以通过运行如下命令:
npx serve .
npx 为 npm 在 5.2.0+ 之后推出的一个便捷执行 npm 命令的工具,如上述的 serve,在运行时首先检测本地是否存在,如果不存在则下载原创对应的包,并执行对应的命令,并且为一次性的操作,免除了先安装再允许,且需要暂用本地内存的操作。
在 WebAssembly
文件夹下运行一个本地 Web 服务器,然后打开 http://localhost:5000/hello.html 查看效果:
可以看到 我们在 C 代码里面编写的打印 Hello World
的代码,成功输出到了浏览器里,你也可以打开控制台看到对应的输出:
恭喜你!你成功将一个 C 模块编译成了 WebAssembly,并将其运行在了浏览器中!
上述例子中是使用了 Emscripten 默认的 HTML 模板,但是很多场景下我们都需要用到自定义的 HTML 模板,如将 WebAssembly 整合到现有的项目中使用时,就需要自定义 HTML 模板,接下来我们了解一下如何使用自定义的 HTML 模板。
首先在 WebAssembly
目录下新建 hello2.c
文件,写入如下内容:
#include <stdio.h>
int main() {
printf("Hello World\n");
}
在之前 clone 到本地的 emsdk 仓库代码中找到 shell_minimal.html
文件,将其复制到 WebAssembly
目录下的子文件夹 html_template
下(此文件夹需要新建),现在 WebAssembly
目录下的文件结构如下:
.
├── hello.c
├── hello.html
├── hello.js
├── hello.wasm
├── hello2.c
└── html_template
└── shell_minimal.html
在命令行导航到 WebAssembly
下,运行如下命令:
emcc -o hello2.html hello2.c -O3 -s WASM=1 --shell-file html_template/shell_minimal.html
可以看到,相比之前在参数传递上有几点变化:
-o hello2.html
,编译器将会将输出 hello2.js
的 JS 胶水代码以及 hello2.html
的 HTML 文件--shell-file html_template/shell_minimal.html
,通过这个命令提供了你在生成 HTML 文件时使用的 HTML 模板地址。现在让我们运行这个 HTML,通过如下命令:
npx serve .
在浏览器中导航到:localhosthttp://localhost:5000/hello2.html[6] 来访问运行结果,可以观测到和之前类似的效果:
可以看到只是缺少了之前的 Emscripten 头部,其他都和之前类似,查看 WebAssembly
文件目录,会发现生成了类似的 JS、Wasm 代码:
.
├── hello.c
├── hello.html
├── hello.js
├── hello.wasm
├── hello2.c
├── hello2.html
├── hello2.js
├── hello2.wasm
└── html_template
└── shell_minimal.html
注意:你可以指定只输出 JavaScript 胶水代码,而不是一份完整的 HTML 文档,通过在
-o
标签后面指定为.js
文件,例如emcc -o hello2.js hello2.c -O3 -s WASM=1
,然后你可以自定义 HTML 文件,然后导入这份胶水代码使用,然而这是一种更加高级的方法,常用的形式还是使用提供的 HTML 模板:
Emscripten 需要大量的 JavaScript 胶水代码来处理内存分配,内存泄露以及一系列其他问题。
如果你在 C 代码里定义了一个函数,然后想在 JavaScript 中调用它,你可以使用 Emscripten 的 ccall
函数,以及 EMSCRIPTEN_KEEPALIVE
声明(这个声明将你的 C 函数加入到函数输出列表,具体的工作过程如下:
首先在 WebAssembly
目录下创建 hello3.c
文件,添加如下内容:
#include <stdio.h>
#include <emscripten/emscripten.h>
int main() {
printf("Hello World\n");
}
#ifdef __cplusplus
extern "C" {
#endif
EMSCRIPTEN_KEEPALIVE void myFunction(int argc, char ** argv) {
printf("MyFunction Called\n");
}
#ifdef __cplusplus
}
#endif
Emscripten 生成的代码默认只调用 main
函数,其他函数会作为 “死代码” 删除掉。在函数名之前加入 EMSCRIPTEN_KEEPALIVE
声明会阻止这种 “删除” 发生,你需要导入 emscripten.h
头文件来使用 EMSCRIPTEN_KEEPALIVE
声明。
注意我们在代码中添加了
#ifdef
块,确保在 C++ 代码中导入这个使用时也是可以正确工作的,因为 C 和 C++ 的命名可能存在一些混淆的规则,所以上述添加EMSCRIPTEN_KEEPALIVE
声明的函数可能会失效,所以在 C++ 环境下为函数加上external
,将其当做external
函数,这样在 C++ 环境下也可以正确工作。
然后为了演示方便, HTML 文件照样使用我们之前放到 html_template
目录下的 shell_minimal.html
文件,然后使用如下命令编译 C 代码:
emcc -o hello3.html hello3.c -O3 -s WASM=1 --shell-file html_template/shell_minimal.html -s NO_EXIT_RUNTIME=1 -s "EXTRA_EXPORTED_RUNTIME_METHODS=['ccall']"
注意到在上述编译中,我们加上了 NO_EXIT_RUNTIME
参数,因为当 main
函数运行完之后,程序就会退出,所以加上这个参数确保其他函数还是还能如期运行。
而额外添加的 EXTRA_EXPORTED_RUNTIME_METHODS
则用于为 WebAssembly 的 Module 导出 ccall
方法使用,使得可以在 JavaScript 调用导出的 C 函数。
当你通过 npx serve .
运行时,依然可以看到类似之前的结果:
现在我们可以尝试在 JavaScript使用 myFunction
函数,首先在编辑器中打开 hello3.html
文件,然后添加一个 <button>
元素,并在 <button>
元素点击时能够调用 myFunction
函数:
<!-- 其他内容 --->
<button class="mybutton">Run myFunction</button>
<script type='text/javascript'>
// ... 其他生成的代码
// script 标签底部
document.querySelector('.mybutton')
.addEventListener('click', function() {
alert('check console');
var result = Module.ccall(
'myFunction', // name of C function
null, // return type
null, // argument types
null // arguments
);
});
</script>
<!-- 其他内容 --->
保存上述内容,重新刷新浏览器可以看到如下结果:
当我们点击上图中的按钮时,可以获得如下结果:
首先会收到一个 alert
提示,然后在输出里面打印了 MyFunction Called 内容,表示 myFunction
调用了,打开控制台也可以看到如下打印结果:
上述例子展示了可以在 JavaScript 中通过 ccall
来调用 C 代码中导出的函数。
一个 WebAssembly 的核心使用场景就是将重复利用已经存在的 C 生态系统中的库,并将它们编译到 Web 平台上使用而不用重新实现一套代码。
这些 C 库通常依赖 C 的标准库,操作系统,文件系统或者其他依赖,Emscripten 提供绝大部分上述依赖的特性,尽管还是存在一些限制。
让我们将 C 库的 WebP 编码器编译到 wasm 来了解如何编译已经存在的 C 模块,WebP codec 的源码是用 C 实现的,能够在 Github[7] 上找到它,同时可以了解到它的一些 API 文档[8]。
首先 Clone WebP 编码器的源码到本地,和 emsdk
、WebAssembly
目录同级:
git clone https://github.com/webmproject/libwebp
为了快速上手,我们可以先导出 encode.h
头文件里面的 WebPGetEncoderVersion
函数给到 JavaScript 使用,首先在 WebAssembly
文件夹下创建 webp.c
文件并加入如下:
#include "emscripten.h"
#include "src/webp/encode.h"
EMSCRIPTEN_KEEPALIVE
int version() {
return WebPGetEncoderVersion();
}
上述的例子可以很快速的检验是否正确编译了 libwebp 的源码并能成功使用其函数,因为上述函数无需各种复杂的传参和数据结构即可成功执行。
为了编译上述函数,我们首先得告诉编译器如何找到 libwebp 库的头文件,通过在编译时加上标志 I
,然后指定 libwep 头文件的地址来告诉编译器地址,并将编译器所需要的所有 libwebp 里面的 C 文件都传给它。但有时候一个个列举 C 文件非常的繁琐,所以一种有效的策略就是将所有的 C 文件都传给编译器,然后依赖编译器自身去过滤掉那些不必要的文件,上述描述的操作可以通过在命令行编写如下命令实现:
emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
-I libwebp \
WebAssembly/webp.c \
libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c
注意:上述的传参策略并不对在所有 C 项目都生效,有很多项目在编译前依赖 autoconfig/automake 等库来生成系统特定的代码,而 Emscripten 提供了
emconfigure
和emmake
来封装这些命令,并注入合适的参数来抹平那些有前置依赖的项目。
运行上述命令之后,会产出一份 a.out.js
胶水代码,和 a.out.wasm
文件,然后你需要在 a.out.js
文件输出的目录下创建一份 HTML 文件,并在其中添加如下代码
<script src="./a.out.js"></script>
<script>
Module.onRuntimeInitialized = async _ => {
const api = {
version: Module.cwrap('version', 'number', []),
};
console.log(api.version());
};
</script>
上述代码中,我们首先导入编译器编译输出的 a.out.js
胶水代码,然后在 WebAssembly 的模块初始化好了之后,通过 cwrap
函数导出 C 函数 version
使用,通过运行和之前类似的 npx serve .
命令,然后打开浏览器可以看到如下效果:
libwebp 通过十六进制的
0xabc
的 abc 来表示当前版本a.b.c
,例如 v0.6.1,则会被编码成十六进制0x000601
,对应的十进制为 1537。而这里为十进制 66049,转成 16 进制则为0x010201
,表示当前版本为 v1.2.1。
刚刚通过调用编码器的 WebPGetEncoderVersion
方法来获取版本号来证实了已经成功编译了 libwebp 库到 wasm,然后可以在 JavaScript 使用它,接下来我们将了解更加复杂的操作,如何使用 libwebp 的编码 API 来转换图片格式。
libwebp 的 encoding API 需要接收一个关于 RGB、RGBA、BGR 或 BGRA 的字节数组,所以首先要回答的问题是,如何将图片放入 wasm 运行?幸运的是,Canvas API 有一个 CanvasRenderingContext2D.getImageData
方法,能够返回一个 Uint8ClampedArray
,这个数组包含 RGBA 格式的图片数据。
首先我们需要在 JavaScript 中编写加载图片的函数,将其写到上一步创建的 HTML 文件里:
<script src="./a.out.js"></script>
<script>
Module.onRuntimeInitialized = async _ => {
const api = {
version: Module.cwrap('version', 'number', []),
};
console.log(api.version());
};
async function loadImage(src) {
// 加载图片
const imgBlob = await fetch(src).then(resp => resp.blob());
const img = await createImageBitmap(imgBlob);
// 设置 canvas 画布的大小与图片一致
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
// 将图片绘制到 canvas 上
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, img.width, img.height);
}
</script>
现在剩下的操作则是如何将图片数据从 JavaScript 复制到 wasm,为了达成这个目的,需要在先前的 webp.c
函数里面暴露额外的方法:
修改 webp.c
如下:
#include <stdlib.h> // 此头文件导入用于分配内存的 malloc 方法和释放内存的 free 方法
EMSCRIPTEN_KEEPALIVE
uint8_t* create_buffer(int width, int height) {
return malloc(width * height * 4 * sizeof(uint8_t));
}
EMSCRIPTEN_KEEPALIVE
void destroy_buffer(uint8_t* p) {
free(p);
}
create_buffer
为 RGBA 的图片分配内存,RGBA 图片一个像素包含 4 个字节,所以代码中需要添加 4 * sizeof(uint8_t)
,malloc
函数返回的指针指向所分配内存的第一块内存单元地址,当这个指针返回给 JavaScript 使用时,会被当做一个简单的数字处理。当通过 cwrap
函数获取暴露给 JavaScript 的对应 C 函数时,可以使用这个指针数字找到复制图片数据的内存开始位置。
我们在 HTML 文件中添加额外的代码如下:
<script src="./a.out.js"></script>
<script>
Module.onRuntimeInitialized = async _ => {
const api = {
version: Module.cwrap('version', 'number', []),
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
encode: Module.cwrap("encode", "", ["number","number","number","number",]),
free_result: Module.cwrap("free_result", "", ["number"]),
get_result_pointer: Module.cwrap("get_result_pointer", "number", []),
get_result_size: Module.cwrap("get_result_size", "number", []),
};
const image = await loadImage('./image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);
};
async function loadImage(src) {
// 加载图片
const imgBlob = await fetch(src).then(resp => resp.blob());
const img = await createImageBitmap(imgBlob);
// 设置 canvas 画布的大小与图片一致
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
// 将图片绘制到 canvas 上
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, img.width, img.height);
}
</script>
可以看到上述代码除了导入之前添加的 create_buffer
和 destroy_buffer
外,还有很多用于编码文件等方面的函数,我们将在后续讲解,除此之外,代码首先加载了一份 image.jpg
的图片,然后调用 C 函数为此图片数据分配内存,并相应的拿到返回的指针传给 WebAssembly 的 Module.HEAP8
,在内存开始位置 p,写入图片的数据,最后会释放分配的内存。
现在图片数据已经加载进 wasm 的内存中,可以调用 libwebp 的 encoder 方法来完成编码过程了,通过查阅 WebP 的文档[9],发现可以使用 WebPEncodeRGBA
函数来完成工作。这个函数接收一个指向图片数据的指针以及它的尺寸,以及一个区间在 0-100 的可选的质量参数。在编码的过程中,WebPEncodeRGBA
会分配一块用于输出数据的内存,我们需要在编码完成之后调用 WebPFree
来释放这块内存。
我们打开 webp.c
文件,添加如下处理编码的代码:
int result[2];
EMSCRIPTEN_KEEPALIVE
void encode(uint8_t* img_in, int width, int height, float quality) {
uint8_t* img_out;
size_t size;
size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);
result[0] = (int)img_out;
result[1] = size;
}
EMSCRIPTEN_KEEPALIVE
void free_result(uint8_t* result) {
WebPFree(result);
}
EMSCRIPTEN_KEEPALIVE
int get_result_pointer() {
return result[0];
}
EMSCRIPTEN_KEEPALIVE
int get_result_size() {
return result[1];
}
上述 WebPEncodeRGBA
函数执行的结果为分配一块输出数据的内存以及返回内存的大小。因为 C 函数无法使用数组作为返回值(除非我们需要进行动态内存分配),所以我们使用一个全局静态数组来获取返回的结果,这可能不是很规范的 C 代码写法,同时它要求 wasm 指针为 32 比特长,但是为了简单起见我们可以暂时容忍这种做法。
现在 C 侧的相关逻辑已经编写完毕,可以在 JavaScript 侧调用编码函数,获取图片数据的指针和图片所占用的内存大小,将这份数据保存到 JavaScript 自己的内存中,然后释放 wasm 在处理图片时所分配的内存,让我们打开 HTML 文件完成上述描述的逻辑:
<script src="./a.out.js"></script>
<script>
Module.onRuntimeInitialized = async _ => {
const api = {
version: Module.cwrap('version', 'number', []),
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
encode: Module.cwrap("encode", "", ["number","number","number","number",]),
free_result: Module.cwrap("free_result", "", ["number"]),
get_result_pointer: Module.cwrap("get_result_pointer", "number", []),
get_result_size: Module.cwrap("get_result_size", "number", []),
};
const image = await loadImage('./image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
api.encode(p, image.width, image.height, 100);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
const result = new Uint8Array(resultView);
api.free_result(resultPointer);
api.destroy_buffer(p);
};
async function loadImage(src) {
// 加载图片
const imgBlob = await fetch(src).then(resp => resp.blob());
const img = await createImageBitmap(imgBlob);
// 设置 canvas 画布的大小与图片一致
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
// 将图片绘制到 canvas 上
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, img.width, img.height);
}
</script>
在上述代码中我们通过 loadImage
函数加载了一张本地的 image.jpg
图片,你需要事先准备一张图片放置在 emcc
编译器输出的目录下,也就是我们的 HTML 文件目录下使用。
注意:
new Uint8Array(someBuffer)
将会在同样的内存块上创建一个新视图,而new Uint8Array(someTypedArray)
只会复制someTypedArray
的数据。
当你的图片比较大时,因为 wasm 不能扩充可以容纳 input
和 output
图片数据的内存,你可能会遇到如下报错:
但是我们例子中使用的图片比较小,所以只需要单纯的在编译时加上一个过滤参数 -s ALLOW_MEMORY_GROWTH=1
忽略这个报错信息即可:
emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
-I libwebp \
test-dir/webp.c \
libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c \
-s ALLOW_MEMORY_GROWTH=1
再次运行上述命令,得到添加了编码函数的 wasm 代码和对应的 JavaScript 胶水代码,这样当我们打开 HTML 文件时,它已经能够将一份 JPG 文件编码成 WebP 的格式,为了近一步证实这个观点,我们可以将图片展示到 Web 界面上,通过修改 HTML 文件,添加如下代码:
<script>
// ...
api.encode(p, image.width, image.height, 100);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
const result = new Uint8Array(resultView);
// 添加到这里
const blob = new Blob([result], {type: 'image/webp'});
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img)
api.free_result(resultPointer);
api.destroy_buffer(p);
</script>
然后刷新浏览器,应该可以看到WebP图片展示到 Web 端,通过将这个文件下载到本地,可以看到其格式转成了 WebP:
通过上述的流程我们成功编译了现有的 libwebp C 库到 wasm 使用,并将 JPG 图片转成了 WebP 格式并展示在 Web 界面上,通过 wasm 来处理计算密集型的转码操作可以大大提高网页的性能,这也是 WebAssembly 带来的主要优势之一。
emconfigure
is to replace the compiler from gcc to emcc (or g++ to em++):编译 C 项目.o
文件在第二个例子中我们成功编译了已经存在的 C 模块到 WebAssembly,但是有很多项目在编译前依赖 autoconfig/automake 等库来生成系统特定的代码,而 Emscripten 提供了 emconfigure
和 emmake
来封装这些命令,并注入合适的参数来抹平那些有前置依赖的项目,接下来我们通过实际编译 ffmpeg 来讲解如何处理这种依赖 autoconfig/automake 等库来生成特定的代码。
经过实践发现 ffmpeg 的编译依赖于特定的 ffmpeg 版本、Emscripten 版本、操作系统环境等,所以以下的 ffmpeg 的编译都是限制在特定的条件下进行的,主要是为之后通用的 ffmpeg 的编译提供一种思路和调试方法。
使用 Emscripten 编译大部分复杂的 C/C++ 库时,主要需要三个步骤:
emconfigure
运行项目的 configure
文件将 C/C++ 代码编译器从 gcc/g++
换成 emcc/em++
emmake make
来构建 C/C++ 项目,生成 wasm 对象的 .o
文件emcc
来编译特定的文件为了验证 ffmpeg 的验证,我们需要依赖特定的版本,下面详细讲解依赖的各种文件版本。
首先安装 1.39.18
版本的 Emscripten 编译器,进入之前我们 Clone 到本地的 emsdk 项目运行如下命令:
./emsdk install 1.39.18
./emsdk activate 1.39.18
source ./emsdk_env.sh
通过在命令行中输入如下命令验证是否切换成功:
emcc -v # 输出 1.39.18
在 emsdk 同级下载分支为 n4.3.1
的 ffmpeg 代码:
git clone --depth 1 --branch n4.3.1 https://github.com/FFmpeg/FFmpeg
通过如下脚本来处理 configure
文件:
export CFLAGS="-s USE_PTHREADS -O3"
export LDFLAGS="$CFLAGS -s INITIAL_MEMORY=33554432"
emconfigure ./configure \
--target-os=none \ # 设置为 none 来去除特定操作系统的一些依赖
--arch=x86_32 \ # 选中架构为 x86_32
--enable-cross-compile \ # 处理跨平台操作
--disable-x86asm \ # 关闭 x86asm
--disable-inline-asm \ # 关闭内联的 asm
--disable-stripping \ # 关闭处理 strip 的功能,避免误删一些内容
--disable-programs \ # 加速编译
--disable-doc \ # 添加一些 flag 输出
--extra-cflags="$CFLAGS" \
--extra-cxxflags="$CFLAGS" \
--extra-ldflags="$LDFLAGS" \
--nm="llvm-nm" \ # 使用 llvm 的编译器
--ar=emar \
--ranlib=emranlib \
--cc=emcc \ # 将 gcc 替换为 emcc
--cxx=em++ \ # 将 g++ 替换为 em++
--objcc=emcc \
--dep-cc=emcc
上述脚本主要做了如下几件事:
USE_PTHREADS
开启 pthreads
支持-O3
表示在编译时优化代码体积,一般可以从 30MB 压缩到 15MBINITIAL_MEMORY
设置为 33554432 (32MB),主要是 Emscripten 可能占用 19MB,所以设置更大的内存容量来避免在编译过程中可分配的内存不足的问题emconfigure
来配置 configure
文件,替换 gcc
编译器为 emcc
,以及设置一些必要的操作来处理可能遇到的编译 BUG,最终生成用于编译构建的配置文件通过上述步骤,就处理好了配置文件,接下来需要通过 emmake 来构建实际的依赖,通过在命令行中运行如下命令:
# 构建最终的 ffmpeg.wasm 文件
emmake make -j4
通过上述的编译,会生成如下四个文件:
ffmpeg
ffmpeg_g
ffmpeg_g.wasm
ffmpeg_g.worker.js
前两个都是 JS 文件,第三个为 wasm 模块,第四个是处理 worker 中运行相关逻辑的函数,上述生成的文件的理想形式应该为三个,为了达成这种自定义的编译,有必要自定义使用 emcc
命令来进行处理。
在 FFmpeg
目录下创建 wasm
文件夹,用于放置构建之后的文件,然后自定义编译文件输出如下:
mkdir -p wasm/dist
emcc \
-I. -I./fftools \
-Llibavcodec -Llibavdevice -Llibavfilter -Llibavformat -Llibavresample -Llibavutil -Llibpostproc -Llibswscale -Llibswresample \
-Qunused-arguments \
-o wasm/dist/ffmpeg-core.js fftools/ffmpeg_opt.c fftools/ffmpeg_filter.c fftools/ffmpeg_hw.c fftools/cmdutils.c fftools/ffmpeg.c \
-lavdevice -lavfilter -lavformat -lavcodec -lswresample -lswscale -lavutil -lm \
-O3 \
-s USE_SDL=2 \ # 使用 SDL2
-s USE_PTHREADS=1 \
-s PROXY_TO_PTHREAD=1 \ # 将 main 函数与浏览器/UI主线程分离
-s INVOKE_RUN=0 \ # 执行 C 函数时不首先执行 main 函数
-s EXPORTED_FUNCTIONS="[_main, _proxy_main]" \
-s EXTRA_EXPORTED_RUNTIME_METHODS="[FS, cwrap, setValue, writeAsciiToMemory]" \
-s INITIAL_MEMORY=33554432
上述的脚本主要有如下几点改进:
-s PROXY_TO_PTHREAD=1
在编译时设置了 pthread
时,使得程序具备响应式特效-o wasm/dist/ffmpeg-core.js
则将原 ffmpeg
js 文件的输出重命名为 ffmpeg-core.js
,对应的输出 ffmpeg-core.wasm
和 ffmpeg-core.worker.js
-s EXPORTED_FUNCTIONS="[_main, _proxy_main]"
导出 ffmpeg 对应的 C 文件里的 main
函数,proxy_main
则是通过设置 PROXY_TO_PTHREAD
代理 main
函数用于外部使用-s EXTRA_EXPORTED_RUNTIME_METHODS="[FS, cwrap, setValue, writeAsciiToMemory]"
则是导出一些帮助函数,用于导出 C 函数、处理文件系统、指针的操作通过上述编译命令最终输出下面三个文件:
ffmpeg-core.js
ffmpeg-core.wasm
ffmpeg-core.worker.js
在 wasm
目录下创建 ffmpeg.js
文件,在其中写入如下代码:
const Module = require('./dist/ffmpeg-core.js');
Module.onRuntimeInitialized = () => {
const ffmpeg = Module.cwrap('proxy_main', 'number', ['number', 'number']);
};
然后通过如下命令运行上述代码:
node --experimental-wasm-threads --experimental-wasm-bulk-memory ffmpeg.js
上述代码解释如下:
onRuntimeInitialized
是加载 WebAssembly 模块完成之后执行的逻辑,我们所有相关逻辑需要在这个函数中编写cwrap
则用于导出 C 文件中(fftools/ffmpeg.c
)的 proxy_main
使用,函数的签名为 int main(int argc, char **argv)
,其中 int
对应到 JavaScript 就是 number
,而 char **argv
是 C 中的指针,也可以映射到 number
ffmpeg
的传参兼容逻辑,对于命令行中运行 ffmpeg -hide_banner
,在我们代码里通过函数调用需要 main(2, ["./ffmpeg", "-hide_banner"])
,第一个参数很好解决,那么我们如何传递一个字符串数组呢?这个问题可以分解为两个部分:第一部分很简单,因为 Emscripten 提供了一个辅助函数 writeAsciiToMemory
来完成这一工作:
const str = "FFmpeg.wasm";
const buf = Module._malloc(str.length + 1); // 额外分配一个字节的空间来存放 0 表示字符串的结束
Module.writeAsciiToMemory(str, buf);
第二部分有一点困难,我们需要创建 C 中的 32 位整数的指针数组,可以借助 setValue
来帮助我们创建这个数组:
const ptrs = [123, 3455];
const buf = Module._malloc(ptrs.length * Uint32Array.BYTES_PER_ELEMENT);
ptrs.forEach((p, idx) => {
Module.setValue(buf + (Uint32Array.BYTES_PER_ELEMENT * idx), p, 'i32');
});
将上述的代码合并起来,我们就可以获取一个能与 ffmpeg
交互的程序:
const Module = require('./dist/ffmpeg-core');
Module.onRuntimeInitialized = () => {
const ffmpeg = Module.cwrap('proxy_main', 'number', ['number', 'number']);
const args = ['ffmpeg', '-hide_banner'];
const argsPtr = Module._malloc(args.length * Uint32Array.BYTES_PER_ELEMENT);
args.forEach((s, idx) => {
const buf = Module._malloc(s.length + 1);
Module.writeAsciiToMemory(s, buf);
Module.setValue(argsPtr + (Uint32Array.BYTES_PER_ELEMENT * idx), buf, 'i32');
})
ffmpeg(args.length, argsPtr);
};
然后通过同样的命令运行程序:
node --experimental-wasm-threads --experimental-wasm-bulk-memory ffmpeg.js
上述运行的结果如下:
可以看到我们成功编译并运行了 ffmpeg 。
Emscripten 内建了一个虚拟的文件系统来支持 C 中标准的文件读取和写入,所以我们需要将音频文件传给 ffmpeg.wasm 时先写入到文件系统中。
可以戳此查看更多关于文件系统 API[10] 。
为了完成上述的任务,只需要使用到 FS 模块的两个函数 FS.writeFile()
和 FS.readFile()
,对于从文件系统中读取和写入的所有数据都要求是 JavaScript 中的 Uint8Array 类型,所以在消费数据之前有必要约定数据类型。
我们将通过 fs.readFileSync()
方法读取名为 flame.avi
的视频文件,然后使用 FS.writeFile()
将其写入到 Emscripten 文件系统。
const fs = require('fs');
const Module = require('./dist/ffmpeg-core');
Module.onRuntimeInitialized = () => {
const data = Uint8Array.from(fs.readFileSync('./flame.avi'));
Module.FS.writeFile('flame.avi', data);
const ffmpeg = Module.cwrap('proxy_main', 'number', ['number', 'number']);
const args = ['ffmpeg', '-hide_banner'];
const argsPtr = Module._malloc(args.length * Uint32Array.BYTES_PER_ELEMENT);
args.forEach((s, idx) => {
const buf = Module._malloc(s.length + 1);
Module.writeAsciiToMemory(s, buf);
Module.setValue(argsPtr + (Uint32Array.BYTES_PER_ELEMENT * idx), buf, 'i32');
})
ffmpeg(args.length, argsPtr);
};
现在我们已经可以将视频文件保存到 Emscripten 文件系统了,接下来就是实际使用编译好的 ffmepg 来进行视频的转码了。
我们修改代码如下:
const fs = require('fs');
const Module = require('./dist/ffmpeg-core');
Module.onRuntimeInitialized = () => {
const data = Uint8Array.from(fs.readFileSync('./flame.avi'));
Module.FS.writeFile('flame.avi', data);
const ffmpeg = Module.cwrap('proxy_main', 'number', ['number', 'number']);
const args = ['ffmpeg', '-hide_banner', '-report', '-i', 'flame.avi', 'flame.mp4'];
const argsPtr = Module._malloc(args.length * Uint32Array.BYTES_PER_ELEMENT);
args.forEach((s, idx) => {
const buf = Module._malloc(s.length + 1);
Module.writeAsciiToMemory(s, buf);
Module.setValue(argsPtr + (Uint32Array.BYTES_PER_ELEMENT * idx), buf, 'i32');
});
ffmpeg(args.length, argsPtr);
const timer = setInterval(() => {
const logFileName = Module.FS.readdir('.').find(name => name.endsWith('.log'));
if (typeof logFileName !== 'undefined') {
const log = String.fromCharCode.apply(null, Module.FS.readFile(logFileName));
if (log.includes("frames successfully decoded")) {
clearInterval(timer);
const output = Module.FS.readFile('flame.mp4');
fs.writeFileSync('flame.mp4', output);
}
}
}, 500);
};
在上述代码中,我们添加了一个定时器,因为 ffmpeg 转码视频的过程是异步的,所以我们需要不断的去读取 Emscripten 文件系统中是否有转码好的文件标志,当拿到文件标志且不为 undefined,我们就使用 Module.FS.readFile()
方法从 Emscripten 文件系统中读取转码好的视频文件,然后通过 fs.writeFileSync()
将视频写入到本地文件系统。最终我们会收到如下结果:
在上一步中,我们成功在 Node 端使用了编译好的 ffmpeg 完成从了 avi
格式到 mp4
格式的转码,接下来我们将在浏览器中使用 ffmpeg 转码视频,并在浏览器中播放。
之前我们编译的 ffmpeg 虽然可以将 avi
格式转码到 mp4
,但是 mp4
的文件无法直接在浏览器中播放,因为不支持这种编码,所以我们需要使用 libx264
编码器来将 mp4
文件编码成浏览器可播放的编码格式。
首先在 WebAssembly
目录下下载 x264
的编码器源码:
curl -OL https://download.videolan.org/pub/videolan/x264/snapshots/x264-snapshot-20170226-2245-stable.tar.bz2
ttar xvfj x264-snapshot-20170226-2245-stable.tar.bz2
然后进入 x264 的文件夹,可以创建一个 build.sh
文件,并加入如下内容:
#!/bin/bash -x
ROOT=$PWD
BUILD_DIR=$ROOT/build
cd $ROOT/x264-snapshot-20170226-2245-stable
ARGS=(
--prefix=$BUILD_DIR
--host=i686-gnu # use i686 gnu
--enable-static # enable building static library
--disable-cli # disable cli tools
--disable-asm # disable asm optimization
--extra-cflags="-s USE_PTHREADS=1" # pass this flags for using pthreads
)
emconfigure ./configure "${ARGS[@]}"
emmake make install-lib-static -j4
cd -
注意需要在 WebAssembly 目录下运行如下命令来构建 x264:
bash x264-snapshot-20170226-2245-stable/build-x264.sh
安装了 x264
编码器之后,就可以在 ffmpeg 的编译脚本中加入打开 x264
的开关,这一次我们在 ffmpeg
文件夹下创建 Bash 脚本用于构建,创建 configure.sh
如下:
#!/bin/bash -x
emcc -v
ROOT=$PWD
BUILD_DIR=$ROOT/build
cd $ROOT/ffmpeg-4.3.2-3
CFLAGS="-s USE_PTHREADS -I$BUILD_DIR/include"
LDFLAGS="$CFLAGS -L$BUILD_DIR/lib -s INITIAL_MEMORY=33554432" # 33554432 bytes = 32 MB
CONFIG_ARGS=(
--target-os=none # use none to prevent any os specific configurations
--arch=x86_32 # use x86_32 to achieve minimal architectural optimization
--enable-cross-compile # enable cross compile
--disable-x86asm # disable x86 asm
--disable-inline-asm # disable inline asm
--disable-stripping
--disable-programs # disable programs build (incl. ffplay, ffprobe & ffmpeg)
--disable-doc # disable doc
--enable-gpl ## required by x264
--enable-libx264 ## enable x264
--extra-cflags="$CFLAGS"
--extra-cxxflags="$CFLAGS"
--extra-ldflags="$LDFLAGS"
--nm="llvm-nm"
--ar=emar
--ranlib=emranlib
--cc=emcc
--cxx=em++
--objcc=emcc
--dep-cc=emcc
)
emconfigure ./configure "${CONFIG_ARGS[@]}"
# build ffmpeg.wasm
emmake make -j4
cd -
然后创建用于自定义输出构建文件的脚本文件 build-ffmpeg.sh
:
ROOT=$PWD
BUILD_DIR=$ROOT/build
cd ffmpeg-4.3.2-3
ARGS=(
-I. -I./fftools -I$BUILD_DIR/include
-Llibavcodec -Llibavdevice -Llibavfilter -Llibavformat -Llibavresample -Llibavutil -Llibpostproc -Llibswscale -Llibswresample -L$BUILD_DIR/lib
-Qunused-arguments
# 这一行加入 -lpostproc 和 -lx264,添加加入 x264 的编译
-o wasm/dist/ffmpeg-core.js fftools/ffmpeg_opt.c fftools/ffmpeg_filter.c fftools/ffmpeg_hw.c fftools/cmdutils.c fftools/ffmpeg.c
-lavdevice -lavfilter -lavformat -lavcodec -lswresample -lswscale -lavutil -lpostproc -lm -lx264 -pthread
-O3 # Optimize code with performance first
-s USE_SDL=2 # use SDL2
-s USE_PTHREADS=1 # enable pthreads support
-s PROXY_TO_PTHREAD=1 # detach main() from browser/UI main thread
-s INVOKE_RUN=0 # not to run the main() in the beginning
-s EXPORTED_FUNCTIONS="[_main, _proxy_main]" # export main and proxy_main funcs
-s EXTRA_EXPORTED_RUNTIME_METHODS="[FS, cwrap, setValue, writeAsciiToMemory]" # export preamble funcs
-s INITIAL_MEMORY=268435456 # 268435456 bytes = 268435456 MB
)
emcc "${ARGS[@]}"
cd -
我们将创建一个 Web 网页,然后提供一个上传视频文件的按钮,以及播放上传的视频文件。尽管无法直接在 Web 端播放 avi 格式的视频文件,但是我们可以通过 ffmpeg 转码之后播放。
在 ffmpeg 目录下的 wasm
文件夹下创建 index.html
文件,然后添加如下内容:
<html>
<head>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%
}
body {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
</head>
<body>
<h3>上传视频文件,然后转码到 mp4 (x264) 进行播放!</h3>
<video id="output-video" controls></video><br/>
<input type="file" id="uploader">
<p id="message">ffmpeg 脚本需要等待 5S 左右加载完成</p>
<script type="text/javascript">
const readFromBlobOrFile = (blob) => (
new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => {
resolve(fileReader.result);
};
fileReader.onerror = ({ target: { error: { code } } }) => {
reject(Error(`File could not be read! Code=${code}`));
};
fileReader.readAsArrayBuffer(blob);
})
);
const message = document.getElementById('message');
const transcode = async ({ target: { files } }) => {
const { name } = files[0];
message.innerHTML = '将文件写入到 Emscripten 文件系统';
const data = await readFromBlobOrFile(files[0]);
Module.FS.writeFile(name, new Uint8Array(data));
const ffmpeg = Module.cwrap('proxy_main', 'number', ['number', 'number']);
const args = ['ffmpeg', '-hide_banner', '-nostdin', '-report', '-i', name, 'out.mp4'];
const argsPtr = Module._malloc(args.length * Uint32Array.BYTES_PER_ELEMENT);
args.forEach((s, idx) => {
const buf = Module._malloc(s.length + 1);
Module.writeAsciiToMemory(s, buf);
Module.setValue(argsPtr + (Uint32Array.BYTES_PER_ELEMENT * idx), buf, 'i32');
});
message.innerHTML = '开始转码';
ffmpeg(args.length, argsPtr);
const timer = setInterval(() => {
const logFileName = Module.FS.readdir('.').find(name => name.endsWith('.log'));
if (typeof logFileName !== 'undefined') {
const log = String.fromCharCode.apply(null, Module.FS.readFile(logFileName));
if (log.includes("frames successfully decoded")) {
clearInterval(timer);
message.innerHTML = '完成转码';
const out = Module.FS.readFile('out.mp4');
const video = document.getElementById('output-video');
video.src = URL.createObjectURL(new Blob([out.buffer], { type: 'video/mp4' }));
}
}
}, 500);
};
document.getElementById('uploader').addEventListener('change', transcode);
</script>
<script type="text/javascript" src="./dist/ffmpeg-core.js"></script>
</body>
</html>
打开上述网页运行,我们可以看到如下效果:
恭喜你!成功编译 ffmpeg 并在 Web 端使用。
[1]WasmFiddle: https://wasdk.github.io/WasmFiddle/
[2]WasmFiddle++: https://anonyco.github.io/WasmFiddlePlusPlus/
[3]WasmExplorer: https://mbebenita.github.io/WasmExplorer/
[4]直接调用 Web API: https://github.com/WebAssembly/gc/blob/master/README.md
[5]它的文档: https://www.assemblyscript.org/
[6]localhosthttp://localhost:5000/hello2.html: http://localhost:5000/hello2.html
[7]Github: https://github.com/webmproject/libwebp
[8]API 文档: https://developers.google.com/speed/webp/docs/api
[9]WebP 的文档: https://developers.google.com/speed/webp/docs/api#simple_encoding_api
[10]文件系统 API: https://emscripten.org/docs/api_reference/Filesystem-API.html
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8