celsius的个人博客

星星之火可以燎原

0%

WebAssembly+ffmpeg纯前端实现视频处理

最近学习了一下 wasm

WebAssembly 是由主流浏览器厂商组成的 W3C 社区团体 制定的一个新的规范。

这是一个运行于沙箱中的指令集,其二进制的格式能最大效率发挥硬件能力,运行速度是 js 的几倍十几倍

而因为其是二进制格式,你可以将 C/C++/java 等等等语言编译成 wasm 格式,并在各种语言的虚拟机中运行,自然也包括 JavaScript 虚拟机

这也就意味着我们能在前端实现一些以前只能在后端实现的功能

这里我们以 ffmpeg 举例,这是一个 C++ 开发的很有名的开源库,能实现大部分视频的编码格式处理,包括压缩,转码等功能

我们将 ffmpeg 转成 wasm 格式后就能在前端实现视频处理

注:如果只是想直接使用,请直接滑到最下面

Emscripten & asm.js

Emscripten: An LLVM-to-JavaScript Compiler

Asm.js an extraordinarily optimizable, low-level subset of JavaScript

这是 wasm 过程中 2 个比较重要的工具,官网链接在上:

  • Emscripten: wasm 的编译器,根据上面的介绍,这是一个 LLVM 到 js 的编译器。咱对于编译原理这些底层的东西了解也不太多,简单理解的话 LLVM 就是一个编译的中间状态,使用 llvm-gcc 或者 clang 这些编译工具可以将 c/c++编译到 LLVM bitcode 这样一个中间代码的状态, 同样类似像 rust 的语言也可以编译到 LLVM。有了 LLVM bitcode,就可以使用 Emscripten 这个工具来将其编译成更底层的汇编 js 代码。
  • 那么更底层的汇编 js 代码是啥,早期在 wasm 还没有实现之前,汇编 js 的实现就是 Asm.js(字如其名。。)。asm.js 的核心功能就是模拟一个汇编语言的运行环境,通过一个大的 UintArray 数组来模拟机器内存,然后通过 js 实现各种汇编指令来对这个虚拟的内存(UintArray 对象)进行操作。

通过上面这 2 个步骤,我们先是把 c/c++通过编译器编译到 LLVM,再利用 emscripten 将 LLVM 编译成 asm.js,当我们在运行这段 asm.js 代码的时候,就会省去大量的解释器开销,相当于直接使用汇编代码一样来直接操作内存空间运行。

但是 asm.js 实现的汇编环境毕竟还是基于 js 的,在性能上还是不能完全满意,这个时候,WebAssebmly 就应运而生了,由浏览器来实现汇编环境,规定好 webassembly 的汇编格式,使得性能进一步提示

编译过程

扯了这么多,还是具体说说编译 FFmpeg 的过程吧。编译 ffmpeg 参考了 github 上的一个现有项目,videoconverter.js,下面从 0 开始按照顺序说明一下

  • 编译 Emcc 工具链

首先我们需要编译 Emcripten 到你的机器上,我这里用的是一台 Ubuntu14.04.

在执行下面命令之前,请先确保你的机器上有如下依赖

cmake git python2.7.X

1
2
3
4
git clone https://github.com/juj/emsdk && cd emsdk
./emsdk install sdk-incoming-64bit binaryen-master-64bit
./emsdk activate sdk-incoming-64bit binaryen-master-64bit
. ./emsdk_env.sh

编译完成之后,按照输出给出的提示,以后只要直接 source ./emsdk_env.sh 就会自动配置环境变量,将各种编译工具的设置成 emsdk 下的 path

至此,在命令行输入 emcc –help,看到正常的输出说明编译和配置成功。现在你就拥有了一个编译 wasm 的编译器。

开始编译 FFmpeg 到 LLVM

跟着上篇的进度,现在我们已经拥有了 Emscripten 的编译环境,现在让我们正式开始编译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 首先,从github等地方获取ffmpeg的源代码
git clone https://github.com/FFmpeg/FFmpeg
cd FFmpeg

# 开始configure
# 这里的参数参考自videoconverter.js,其中注意需要额外带上下面第一行的CPPFLAGS
# 否则不能在最新的emcripten下编译通过
# 这里通过--cc="emcc"来指定编译器为emcc,emcc会调用clang来将target设置成LLVM
CPPFLAGS="-D_POSIX_C_SOURCE=200112 -D_XOPEN_SOURCE=600" \
emconfigure ./configure --cc="emcc" \
--prefix=$(pwd)/../dist --enable-cross-compile --target-os=none --arch=x86_64 \
--cpu=generic --disable-ffplay --disable-ffprobe --disable-ffserver \
--disable-asm --disable-doc --disable-devices --disable-pthreads \
--disable-w32threads --disable-network --disable-hwaccels \
--disable-parsers --disable-bsfs --disable-debug --disable-protocols \
--disable-indevs --disable-outdevs --enable-protocol=file

# ?等待运行完成,执行make开始编译
make

如果一切正常,你将看到下面的结果

img

你会得到一个叫 ffmpeg 的输出文件(如果最后阶段报 strip error 直接无视掉就好,和我们的栗子没有关系)

img

这个 ffmpeg 文件就是我们第一步要得到的 LLVM bitcode,下一步我们就可以将这个 LLVM bitcode 编译到 js 或者 wasm 里面啦

行百里者半九十

在执行最后一步之前,有个疑问,ffmpeg 正常编译出来的二进制应该是个命令行文件,接受命令行参数执行操作。但当我们把它编译到 js 里时,该怎么向它传递参数呢?传递了参数之后又要怎么样才能拿到它的执行结果呢?

理想的状况下,我们希望最终编译出来的 wasm/asm.js 文件能向外 export 一个函数来作为入口,我们通过这个函数入口传入参数,通过返回值或者回调函数等方法获取结果。

其实这就是 wasm/asm.js 文件和浏览器 js 的信息通信问题,Emscripten 为我们提供了这样一些解决方案,以下摘自 Emscripten 官网文档 API Reference - Emscripten 1.37.10 documentation

  • pre.js 和 post.js

–pre-js Specify a file whose contents are added before the generated code. This is done before optimization, so it will be minified properly if the Closure Compiler is run. –post-js Specify a file whose contents are added after the generated code. This is done before optimization, so it will be minified properly if the Closure Compiler is run.

这 2 个其实是 emcc 编译时的 2 个可选项,通过传入用户自己编写的 pre.js 和 post.js,来将最终生成的代码包裹在 pre.js 和 post.js 之下,就像这样 [pre.js]–[生成的 wasm/asm.js]–[post.js]

也就是可以让我在 wasm 被执行之前,运行 pre.js 里的代码,在 wasm 代码被执行后(但并不是执行结束后)运行 post.js 里的代码。

那这样有什么意义呢?其实最方便的就是我们可以在 pre.js 里 export 一个自定义的函数给外部,或者比如说在 pre.js 里做一些必要的初始化,具体做法看下面的介绍

  • Module 对象

Module is a global JavaScript object with attributes that Emscripten-generated code calls at various points in its execution.

简单来说,这里的 Module 是一个特殊全局对象,当我们执行最终的 wasm/asm.js 文件时,在不同的阶段,程序会自动调用 Module 下不同的方法。比如执行结束时(Module.postrun),输出到 stdout(Module.print)时等等。我们就可以自己实现 Module 下那些阶段的函数来挂钩子,比如将返回值返回给回调函数,将 log 打印到浏览器 console 等等。

结合上面的 pre.js,是不是想到什么了?

没错,我们可以在 pre.js 里编写定义 Module 函数,来控制 wasm/asm.js 程序的输入和输出。

Module.arguments The commandline arguments. The value of arguments contains the values returned if compiled code checks argc and argv.

通过给 Module 的 arguments 传入值,emscripten 就会把这个值带到 wasm/asm.js 程序的命令行输入里,也就是 ffmpeg 的命令行输入。

这样,我们就大概解决了命令行参数输入的问题,但是还有几个点需要考虑。

ffmpeg 输入文件名之后,但是文件要怎么传给他? ffmpeg 输出的文件我们又要怎么得到?

  • File System API

File System API - Emscripten 1.37.10 documentation

没错,解决方案依旧是 EmscriptenAPI

emscripten 为我们提供了一套文件系统 api,通过这套 API,就可以用 ArrayBuffer,Web File 等方法来创建文件,并将这个文件写入 emscripten 为我们提供的一个虚拟文件系统。

之后 wasm/asm.js 程序请求文件都会去使用这个虚拟的文件系统,包括读入和写出。

具体 api 的使用方法这里就不说太多了有兴趣的可以看看文档。

至此,我们通过这 3 个方法,解决了我们的需求:

  • 导出一个入口函数到上层 js
  • 传入函数参数来控制 ffmpeg 命令行参数
  • 通过虚拟文件系统传入输入文件以及获取输出文件

Final Battle 编译 LLVM 到 WebAssmbly

这里使用的命令依旧是 emcc,但是注意此时 emcc 的输入为 LLVM bitcode,它将会调用 emscriptem 来将其编译到 js (和第一步 emcc 的行为不同,因为输入格式不同,target 也会不同)

1
2
3
4
5
6
7
8
9
10
# 这里的ffmpeg是上一步编译输出的LLVM bitcode
cp ffmpeg ffmpeg.bc

# 最终的输出是 -o 指定的,这些 -s 参数的意义可以从emcc的文档中找到
# 这里打开了ALLOW_MEMORY_GROWTH是因为在移动端测试下会遇到内存(wasm/asm.js的虚拟内存)
# 不够的情况,默认内存大小是TOTAL_MEMORY指定的
# 设置WASM=1就会编译到WebAssembly,默认编译到asm.js
emcc -s ASSERTIONS=1 -s VERBOSE=1 -s TOTAL_MEMORY=33554432 \
-s ALLOW_MEMORY_GROWTH=1 -s WASM=1 -O2 -v ffmpeg.bc \
-o ../ffmpeg.js --pre-js ../ffmpeg_pre.js --post-js ../ffmpeg_post.js

如果一切顺利,你就会得到最终的输出,包括这 2 货

img

因为我们之前在 pre.js 里写了入口函数并将其 export,所以我们只需要直接 require(“ffmpeg.js”),或者在 worker 里直接 importScript(“ffmpeg.js”)就可以拿到入口函数。

注意 ffmpeg.js 和 ffmpeg.wasm 需要放在一个路径下,执行 ffmpeg.js 的时候会去请求 wasm 文件

下面贴上使用案例,worker 下的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
self.importScripts("ffmpeg.js");

onmessage = function (e) {
console.log("ffmpeg_run", ffmpeg_run);
var files = e.data;
console.log(files);
ffmpeg_run(
{
arguments: ["-i", "/input/" + files[0].name, "-b:v", "64k", "-bufsize", "64k", "-vf", "showinfo", "-strict", "-2", "out.mp4"],
files: files,
},
function (results) {
console.log("result", results);
self.postMessage(results[0].data, [results[0].data]);
}
);
};

workers 的输入就是一个 WebFile,通过 emscriptenAPI 的 workerfs 可以直接将 html5 的 File 对象传入虚拟文件系统

输出就是输出文件的 ArrayBuffer,最后再贴个效果图

img

使用 ffmpeg.wasm 在前端实现视频压缩

当然如果我们不想自己编译,可以直接用已经编译好的wasm文件

比如这个:github 仓库地址: https://github.com/ffmpegwasm/ffmpeg.wasm

实例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Title</title>
<script src="https://unpkg.com/@ffmpeg/ffmpeg@0.9.5/dist/ffmpeg.min.js"></script>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
body {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
</head>
<body>
<h3>视频前端压缩方案</h3>
<video id="output-video" controls></video><br />
<input type="file" id="uploader" />
<p id="message"></p>
<script>
const { createFFmpeg, fetchFile } = FFmpeg;
const message = document.getElementById("message");
const ffmpeg = createFFmpeg({
log: true,
progress: ({ ratio }) => {
message.innerHTML = `Complete: ${(ratio * 100.0).toFixed(2)}%`;
},
});
const transcode = async ({ target: { files } }) => {
const { name } = files[0];
message.innerHTML = "Loading ffmpeg-core.js";
await ffmpeg.load();
message.innerHTML = "Start transcoding";
ffmpeg.FS("writeFile", name, await fetchFile(files[0]));
await ffmpeg.run("-i", name, "output.mp4");
message.innerHTML = "Complete transcoding";
const data = ffmpeg.FS("readFile", "output.mp4");
const video = document.getElementById("output-video");
video.src = URL.createObjectURL(
new Blob([data.buffer], {
type: "video/mp4",
})
);
};
document.getElementById("uploader").addEventListener("change", transcode);
</script>
</body>
</html>

压缩一个 30M 的视频能达到 2.3M 左右,但是因为 ffmpeg 库本身比较大,我们又是全部编译过来的,有大概 20 多 M,因此不建议在移动端使用

当然如果你是 C++ 大佬,可以自己精简 ffmpeg 库之后再编译