前端webassembly+ffmpeg+web worker视频抽帧

6,293

0 背景

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

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

1 方案对比

1.1 canvas截帧

利用<video>标签播放视频,再利用videoObject.currentTime=seconds设置到指定时刻播放,最后在<canvas>中进行绘制图片。有一个相关的开源库,可以体验下它的demo

但是,<video>支持视频封装格式有限,只支持MP4、WebM和Ogg。这与业务现网的逻辑不一致,mov、flv等格式不能上传,不能达到上线标准。

canvas.png

1.2 Webassembly截帧

使用功能强大的C/C++编写的ffmpeg,通过emscripten编译器打包成wasm + js的形式,再使用js实现视频截帧功能。

兼容性方面,Webassembly已得到了来自各大主要浏览器但支持,只有部分浏览器仍不支持,对于不支持的浏览器采用旧方案。

该方案在b站等平台已有相关实践,有相关的实现可以参考。最后决定使用该方案。

ffmpeg-wasm.png

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-wasm.png 那么,能直接用吗?有这些问题还待解决:

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

sharedarraybuffer.png

  • 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的脚本如下:

#!/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等)。

emscripten.jpg

那,LLVM和Clang是什么呢?

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

llvm.jpg

  • 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 配置的详细说明可以点击这里查看。
# 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参数选项

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

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-api.png

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的部分源码:

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事件进行编写通信过程会使代码显得繁琐。且postMessage()只能传递由结构化克隆算法处理的任何值或JavaScript对象。这里推荐使用Comlink (1.1kB),使代码变得更友好,让通信变得无感知。

比如截帧的通信:

main.js

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

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

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,还会出现浏览器崩溃的情况。

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.png

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

workerfs.png

参考