前端webassembly+web worker视频截帧

0 背景

目前在业务场景中,用户上传视频,等待视频上传成功后,后台会跑截帧服务,最后返回图片作为推荐封面,展示给用户。这个方案需要等待视频上传后,后台读取视频,再跑截帧任务,用户等待时间比较长。

因此,考虑前端来做截帧,在开始上传视频的同时生成推荐封面,提升用户体验。

1 方案对比

1.1 canvas截帧

利用<video>标签播放视频,再利用videoObject.currentTime=seconds设置到指定时刻播放,最后在<canvas>中进行绘制图片。有一个相关的开源库,可以体验下它的demo
但是,<video>支持视频封装格式有限,只支持MP4、WebM和Ogg。这与业务现网的逻辑不一致,mov、flv等格式不能上传,不能达到上线标准。
canvas-video

1.2 Webassembly截帧

使用功能强大的C/C++编写的ffmpeg,通过emscripten编译器打包成wasm + js的形式,再使用js实现视频截帧功能。
兼容性方面,Webassembly已得到了来自各大主要浏览器但支持,只有部分浏览器仍不支持,对于不支持的浏览器采用旧方案。
该方案在b站等平台已有相关实践,有相关的实现可以参考。最后决定使用该方案。

1.3 Webassembly截帧的实现对比

1.3.1 ffmpeg.wasm

目前,已有开源库ffmpeg.wasm。该库包括:

  • @ffmpeg/core:编译ffmpeg生成ffmpeg-core.wasm + js胶水代码。
  • @ffmpeg/ffmpeg:实现了调用上一步生成的胶水代码的部分,提供了load, run等API。同时,如果开发者对@ffmpeg/core不满意,也可以构建自定义的ffmpeg-core.wasm。

@ffmpeg/ffmpeg
那么,能直接用吗?有这些问题还待解决:

  • 浏览器兼容性:我们知道,浏览器的js线程是单线程的,并且与渲染线程互斥。为了不阻塞页面的渲染和js主线程,@ffmpeg/core在编译ffmpeg时,配置了多线程,导致产生的js胶水代码中使用了sharedarraybuffersharedarraybuffer能满足主线程和worker之间的数据共享,也可以满足多个worker之间的数据共享,用于此场景中是很理想的。但是,因为安全问题,所有主流浏览器均默认禁用,需要另外配置一些返回头部字段,而且支持度不太理想,不能达到上线标准。
    SharedArrayBuffer
  • wasm冗余:@ffmpeg/core编译出来的ffmpeg-core.wasm几乎包括了ffmpeg的所有功能,文件大小是24MB(gzip后是8.5MB),其中很多是截帧不需要的。

1.3.2 其他平台的实现

根据业务的要求(支持的格式比较少),通过自定义编译ffmpeg,最后生成的wasm文件大小可以减少到4.7MB(gzip后可以更小)。
但是,自己维护一份c语言的入口文件,用FFmpeg提供的内部库,实现截帧功能,然后再编译ffmpeg。
这种方式比较考验对FFmpeg的理解,而且与ffmpeg的特定版本绑定,而随着FFmpeg的版本升级,ffmpeg的API、目录可能会有变更。再加上,随着业务发展,我们可能会用到ffmpeg更多的功能时,还需要修改这份c代码,可维护性比较低。

1.4 小结

因此,最终采用的方案是,使用Webassembly截帧,具体实现:

  1. 自定义编译ffmpeg,优化wasm文件大小。
  2. 使用ffmpeg(v4.3.1)提供的fftools/ffmpeg.c入口文件,无需自己写c代码。
  3. 编译出不带sharedarraybuffer的ffmpeg-core.wasm+js,最后使用web worker运行截帧相关的业务代码,以防阻塞主线程。
  4. 调用编译生成的ffmpeg的js胶水代码,实现截帧功能,这部分可以使用@ffmpeg/ffmpeg

2 自定义编译ffmpeg

2.1 运行docker使用官方的emscripten环境

emscripten是一个WebAssembly编译器工具链。

下载Docker Desktop,通过运行docker的方式使用已经搭建好的Emscripten环境,避免本地开发环境的坑。
mac的Docker Desktop老是连接不上,实测windows的ubuntu跑docker命令更稳定。

在ffmpeg源码目录中,编写运行docker的脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
set -euo pipefail

EM_VERSION=2.0.8

docker pull emscripten/emsdk:$EM_VERSION
docker run \
--rm \
-v $PWD:/src \ # 绑定挂载
-v $PWD/wasm/cache:/emsdk_portable/.data/cache/wasm \
emscripten/emsdk:$EM_VERSION \
sh -c 'bash ./build.sh'

2.1.1 了解下emscripten原理

具体来说,就是C/C++等语言,经过 clang 前端变成 LLVM 中间代码(IR),再从LLVM IR到wasm。然后浏览器把 WebAssembly 下载下来,然后先经过 WebAssembly 模块,再到目标机器的汇编代码,再到机器码(x86/ARM等)。

img

那,LLVM和Clang是什么呢?

  • LLVM,就是不同的前端后端使用统一的中间代码LLVM Intermediate Representation (LLVM IR)。
  • Clang是LLVM的一个子项目,基于LLVM架构的C/C++/Objective-C编译器前端。

img

  • Frontend前端:词法分析、语法分析、语义分析、生成中间代码
  • Optimizer优化器:中间代码优化(循环优化、删除无用代码等等)
  • Backend后端:生成目标代码。如目标代码是绝对指令代码(机器码),则这种目标代码可立即执行。如果目标代码是汇编指令代码,则需汇编器汇编之后(生成机器码)才能运行。

接下来是编写编译的脚本build.sh

2.2 配置ffmpeg编译参数,去掉冗余

ffmpeg是优秀的C/C++音视频处理库,可以实现视频截图。

首先,我们要知道实现截图会涉及的库和组件。

涉及到的库:

  • libavcodec:音视频的编码和解码。
  • libavformat:音视频的封装和解封装。
  • libavutil:包含一些公共的工具函数的使用库,包括算数运算,字符操作等。
  • libswscale:图像伸缩和像素格式转化。
    涉及的组件:
  • demuxer:对视频解封装
  • decoder:对视频解码
  • encoder:得到解码后的帧之后,输出图片编码
  • muxer:图片封装

使用emconfigure设置合适的环境参数,和配置FFmpeg编译参数。
关于配置的说明文档:

  • 运行emconfigure ./configure --help 查看所有可以用的配置。
  • 关于FFMPEG 配置的详细说明可以点击这里查看。
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
# configure FFMpeg with Emscripten
emconfigure ./configure
--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 stripping
--disable-programs # disable programs build (incl. ffplay, ffprobe & ffmpeg)
--disable-doc # disable doc
--nm="llvm-nm"
--ar=emar
--ranlib=emranlib
--cc=emcc
--cxx=em++
--objcc=emcc
--dep-cc=emcc
# 去掉不需要的库
--disable-avdevice
--disable-swresample
--disable-postproc
--disable-network
--disable-pthreads
--disable-w32threads
--disable-os2threads
# 配置需要的解封装,编解码器等
--disable-everything # 减少wasm体积的关键,除了以下的组件外的个别组件都disable
--enable-filters
--enable-muxer=image2
--enable-demuxer=mov # mov,mp4,m4a,3gp,3g2,mj2
--enable-demuxer=flv
--enable-demuxer=h264
--enable-demuxer=asf
--enable-encoder=mjpeg
--enable-decoder=hevc
--enable-decoder=h264
--enable-decoder=mpeg4
--enable-protocol=file

# build dependencies
emmake make -j4

2.4 生成js+wasm

使用emcc将上一步 make 生成的链接代码编译为 JavaScript + WebAssembly。这里使用fftools/ffmpeg.c作为入口文件,不需要自己维护一份c语言入口文件。
可通过emcc --help查看emcc参数选项,以及通过clang --help查看clang参数选项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
emcc
-I. -I./fftools # Add directory to include search path
-Llibavcodec -Llibavdevice -Llibavfilter -Llibavformat -Llibavresample -Llibavutil -Llibpostproc -Llibswscale -Llibswresample # Add directory to library search path
-Qunused-arguments # Don't emit warning for unused driver arguments.
-o wasm/dist/ffmpeg-core.js # output
fftools/ffmpeg_opt.c fftools/ffmpeg_filter.c fftools/ffmpeg_hw.c fftools/cmdutils.c fftools/ffmpeg.c # input
-lavdevice -lavfilter -lavformat -lavcodec -lswresample -lswscale -lavutil -lm # library
-s USE_SDL=2 # use SDL2
-s MODULARIZE=1 # use modularized version to be more flexible
-s EXPORT_NAME="createFFmpegCore" # assign export name for browser
-s EXPORTED_FUNCTIONS="[_main]" # export main and proxy_main funcs
-s EXTRA_EXPORTED_RUNTIME_METHODS="[FS, cwrap, ccall, setValue, writeAsciiToMemory]" # export extra runtime methods
-s INITIAL_MEMORY=33554432 # 33554432 bytes = 32MB
-s ALLOW_MEMORY_GROWTH=1 # allows the total amount of memory used to change depending on the demands of the application
-s ASSERTIONS=1 # for debug
--post-js wasm/post-js.js # emits a file after the emitted code. use to expose exit function
-O3 # optimize code and reduce code size

最后构建的ffmpeg-core.wasm大小为5MB,gzip后会更小。
源码:build.sh

到这里,编译ffmpeg完成了!接下来回到我们熟悉的前端领域。

3 实现截帧功能

3.1 调用js胶水代码

关于调用js胶水代码的这部分,在开源库@ffmpeg/ffmpeg已经实现了,我们可以简单地使用它的API

1
2
3
4
5
6
7
8
9
10
11
12
const { createFFmpeg } = require('@ffmpeg/ffmpeg');
const ffmpeg = createFFmpeg({ log: true });

(async () => {
await ffmpeg.load();
// ... 省略获取时长duration部分
const frameNum = 8;
const per = duration / (frameNum - 1);
for (let i = 0; i < frameNum; i++) {
await ffmpeg.run('-ss', `${Math.floor(per * i)}`, '-i', 'example.mp4', '-s', '960x540', '-f', 'image2', '-frames', '1', `frame-${i + 1}.jpeg`);
}
})();

期间,还发现了-ss放在-i前,可以截取指定时间的帧,而不用等待逐帧读取,可以提升截图速度。可查看相关API文档
ffmpeg文档

P.S.@ffmpeg/ffmpeg目前还不支持加载去掉pthreads的ffmpeg-core.wasm+js,也给该库提了pr

3.1.1 JavaScript与C交换数据

loadrun方法具体是怎么实现的呢?
这里要先了解的是,JavaScript与C交换数据时,只能使用Number作为参数。因为从语言角度来说,JavaScript与C/C++有完全不同的数据体系,Number是二者唯一的交集,因此本质上二者相互调用时,都是在交换Number。

因此如果参数是字符串、数组等非Number类型,则需要拆分为以下步骤:

  • 使用Module._malloc()Module堆中分配内存,获取地址ptr;
  • 将字符串/数组等数据拷入内存的ptr处;
  • 将ptr作为参数,调用C/C++函数进行处理;
  • 使用Module._free()释放ptr。

以下是@ffmpeg/ffmpeg的部分源码:

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
const createFFmpegCore = require('path/to/ffmpeg-core.js');
let ffmpeg;

// 加载
const load = async () => {
Core = await createFFmpegCore({
print: (message) => {},
});
ffmpeg = Core.cwrap('_main', 'number', ['number', 'number']); // cwrap调用导出的主函数
};

const parseArgs = (Core, args) => {
const argsPtr = Core._malloc(args.length * Uint32Array.BYTES_PER_ELEMENT);
args.forEach((s, idx) => {
const buf = Core._malloc(s.length + 1);
Core.writeAsciiToMemory(s, buf);
Core.setValue(argsPtr + (Uint32Array.BYTES_PER_ELEMENT * idx), buf, 'i32');
});
return [args.length, argsPtr]; // [数组的长度, 数组的ptr]
};

// 执行ffmpeg命令
const run = (..._args) => {
return new Promise((resolve) => {
ffmpeg(...parseArgs(Core, _args)); // 传入命令参数
});
};

module.exports = {
load,
run,
};

4 web worker

因为在构建中没有配置-s USE_PTHREADS=1,上面调用ffmpeg的方法会阻塞js主线程和页面的渲染。比如,在生成推荐封面的同时,无法更新上传视频的进度状态,用户点击页面上的其他按钮也无法响应等。因此,需要增加一个web worker来运行。
Web Worker是在与浏览器页面线程分开的线程上运行的脚本,可以用于从页面线程分流几乎所有繁重的处理。主线程和worker可以通过postMessage()方法和onmessage事件进行通信。

但使用postMessage()方法和onmessage事件进行编写通信过程会使代码显得繁琐。这里推荐使用Comlink (1.1kB),使代码变得更友好,让通信变得无感知。

比如截帧的通信:
main.js

1
2
3
4
5
import * as Comlink from 'Comlink';
async function onFileUpload(file) {
const ffmpegWorker = Comlink.wrap(new Worker('./worker.js'));
const frameU8Arrs = await ffmpegWorker.getFrames(file);
}

worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import * as Comlink from 'Comlink';
async function getFrames(file) {
// ...
// 先获取时长duration等
const frameNum = 8;
const per = duration / (frameNum - 1);
let frameU8Arrs = [];
for (let i = 0; i < frameNum; i++) {
await ffmpeg.run('-ss', `${Math.floor(per * i)}`, '-i', 'example.mp4', '-s', '960x540', '-f', 'image2', '-frames', '1', `frame-${i + 1}.jpeg`);
}
// 从MEMFS获取图片二进制数据Uint8Array
for (let i = 0; i < frameNum; i++) {
const u8arr = await ffmpeg.FS('readFile', `frame-${i + 1}.jpeg`);
frameU8Arrs.push(u8arr);
ffmpeg.FS('unlink', fileName);
}
return frameU8Arrs;
}

Comlink.expose({
getFrames,
});

Comlink是基于Es6 ProxypostMessage()的RPC实现。例子中,ffmpegWorker是位于worker.js中的对象,main.js里拿到的只是ffmpegWorker的本体的句柄,实际上ffmpegWorker.getFrames等方法的执行也是在worker.js上运行的。
唯一一个坑点是这个库的产出是es6代码,还需要通过构建配置转为es5代码。

4.1 webpack配置

另外,如果你使用webpack,可能还会遇到无法加载正确的worker.js路径的问题。可以这样配置worker-plugin

1
2
3
4
5
6
7
8
9
10
11
const WorkerPlugin = require('worker-plugin');
const isPub = true; // 是否生产环境
{
// ...
plugins: [
new WorkerPlugin({
globalObject: 'this',
filename: isPub ? '[name].[chunkhash:9].worker.js' : '[name].worker.js',
}),
],
}

5 上线效果

上线后,对于支持该方案的浏览器,用户无需等待视频上传完成,即可选择、编辑视频封面。

而且,比起在后台读取视频后截帧,前端截帧的耗时也大大缩小了。这在视频大小越大的视频越明显。

6 后续优化点

6.1 提高浏览器支持率

在部分浏览器报错,之后持续优化,提高浏览器支持率。(如Safari某版本fetch wasm报错)。

6.2 减少wasm文件大小

wasm体积还有减少的空间。(如在编译配置中配置了enable-filters使用了所有的filters)。

6.3 读取视频文件优化

因为默认使用MEMFS,会将视频文件整个存入内存中,然后处理。大的视频文件如800MB+的视频文件,在Firefox 90版本,运行任务时内存占用会接近3G,还会出现浏览器崩溃的情况。

1
2
3
4
5
6
const getVideoInfo = async (file) => {
// ...先实现fileToUint8Array方法
const bufferArr = await fileToUint8Array(file);
ffmpeg.FS('writeFile', 'example.mp4', bufferArr); // 先保存到MEMFS
await ffmpeg.run('-i', 'example.mp4', '-loglevel', 'info');
}

firefox

目前想到的方案是,使用WORKERFS。WORKERFS运行在 Web Worker 中,提供对 woker 内部的 File 和 Blob 对象的只读访问,而无需将整个数据复制到内存中,符合我们的需求。
workerfs

参考