vlambda博客
学习文章列表

使用 FFmpeg 与 WebAssembly 实现纯前端视频截帧

| 导语  随着短视频兴起,音视频技术已经越来越火热,或许你之前有了解过如何在前端处理音视频,但随着视频文件的逐渐增大、用户体验要求的不断提高,纯前端处理音视频的技术也推成出新。下面将结合实际案例,讲解如何使用 FFmpeg 和 WebAssembly 实现前端视频截帧。文章较长,也非常硬核,建议先收藏再慢慢看。

背景

腾讯课堂涨知识创作者后台,目前主要通过邀请合作老师来平台上发布视频。上传视频的同时,需要对视频进行截帧生成推荐封面,生成规则比较简单,根据视频总时长,平均截取 8 帧。用户可以从其中选择一张图片作为视频封面。

前期调研

视频截帧,首先想到的是 video + canvas 方案,毕竟接触最多的就是它了,不过后面的深入分析,可以发现他们的局限性还是挺多的。

下面主要对比了不同截帧方案,每种方案都是可以走通的,也有不同的问题。

1. 腾讯云视频上传转换能力

腾讯云“数据万象”,图片上传和存储服务都基于对象存储服务(COS),同时官网上提供了媒体截图接口 GenerateSnapshot,可以获取某个时刻的截图,输出的截图统一为 jpeg 格式,同时在我们的内部库也封装基础的 JS 操作。视频上传和每个时刻的截图处理分成多个异步任务,上传任务返回结果后才能执行下一个截图处理。但是目前这种方案需要服务端配合实现鉴权,比较麻烦,而且只有在上传视频后再进行截图,整个耗时会非常长。

2. video + canvas 视频截图

可以看下网上的demo: 

主体实现代码如下:

async takeSnapshot(time?: VideoTime): Promise {
    // 首先通过createElement,创建video,
    // 在video上设置src后,通过currentTime方法,将视频设置到指定时间戳
    const video = await this.loadVideo(time);
    const canvas = document.createElement('canvas');
    // 获取video标签的尺寸,作为画布的长宽
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    const context = canvas.getContext('2d');

    if (!context) {
        throw new Error('error creating canvas context');
    }
    // 当前时间戳下的video作为图像源,在canvas上绘制图像
    context.drawImage(video, 00, canvas.width, canvas.height);

    const dataURL = canvas.toDataURL();
    return dataURL;
}

首先利用video标签播放视频,再设置 videoObject.currentTime 指定时刻播放,最后放 canvas 中进行截图,也可以同上面的 demo 类似,提供一个操作界面,让用户选择截图时刻。

缺点主要在 video 支持视频封装格式和编码有限,而且只支持下面几种:

  • H.264 编码的 MP4 视频(MPEG-LA公司)

  • VP8 编码的 webm 格式的视频(Google公司)

  • Theora 编码的 ogg 格式的视频(iTouch开发)

用户制作上传的其它封装格式和编码组合的视频没法播放,平台上传支持 4 和 flv 格式。

3. wasm + FFfmpeg 实现截取视频截帧

主要看到这篇文章 wasm + FFmpeg 实现前端截取视频帧功能,直接利用 FFmpeg 提供的 lib  库,用 c 语言写好视频截帧功能,最后通过 Emscripten 编译器打包成 wasm + JS 的形式,在浏览器里面跑截图任务。

FFmpeg 是功能强大的开源软件,能够运行音视频多种格式,几乎包括了现存所有的视音频编码标准。至于 wasm 的浏览器支持情况,对比看了下大概在 90% 左右,有不支持的情况以手动上传兜底,最后跟产品讨论可以接受。

4. FFmpeg 截图任务队列

了解到我们服务端已经有一套 FFmpeg 截图方案,不过是异步任务队列的形式,耗时也在分钟级别,可能在视频上传完成后,也没法得到截图结果,所以没法满足需求。

结论

从这次需求出发,主要想实现的功能点是上传视频过程中能快速截帧,提供给用户选择,不阻塞流程,同时需要支持 MP4,FLV 格式,以及 WMV3,H.264 等常见的编码格式截图。

上面的几种方案里面 FFmpeg 才能满足。另一方面,b站使用这套方案已经在线上运行,具有可行性,所以最后决定用 wasm + FFmpeg 方案。

开发踩坑

开发编译 FFmpeg 到后面实现截帧功能,遇到的问题挺多,网上资料相对比较少,这里尽量还原整个实践过程。


基础概念解释

wasm + FFmpeg 的方案里面涉及到很多之前没有接触过的概念,下面一一介绍。

FFmpeg:优秀的音视频处理库,可以实现视频截图,没有 JS 版本。

webAssembly:体积小且加载快的全新二进制格式,已经得到了主流浏览器厂商的支持。

使用 FFmpeg 与 WebAssembly 实现纯前端视频截帧

Emscripten:用来把 c/c++ 代码编译成 asm.js 和 WebAssembly 的工具链。编译流程先把c/c++ 代码编译成 LLVM 字节码,然后根据不同的目标语言编译成 asm.js 或者 wasm。

下面我们从如何安装 Emscripten 开始讲起,到编译 FFmpeg,构建出 ffmpeg.wasm,从而可以在浏览器执行。

文章整体篇幅比较长,而且整体构建也有比较简单的方式,如果你已经了解到网上有很多现成的构建包,可以直接拿来用,那么你就不用太关注整个编译过程及最后的 C语言方案如何实现,直接跳转到部署上线部分。

但是,如果想追求极致,根据自己的业务需求,来调整包大小,或者用新版本的 FFmpeg 来打包,就需要看完 C 语言部分。

安装 Emscripten

编译之前需要手动安装 Emscripten 编译器,安装提供了两种方式:

1. 根据官网指导安装

官方文档:https://emscripten.org/docs/getting_started/downloads.html#download-and-install

Fetch the latest version of the emsdk (not needed the first time you clone)
git pull

# Download and install the latest SDK tools.
./emsdk install latest

# Make the "latest" SDK "active" for the current user. (writes .emscripten file)
./emsdk activate latest

# Activate PATH and other environment variables in the current terminal
source ./emsdk_env.sh

上面的 latest 可以替换成指定的版本号进行安装,需要安装 Python,make 等依赖环境。而且会通过 googlesource.com 源下载依赖,需要保证访问外网。

最后安装成功,运行 emcc -v查看结果。

2. 安装 Emscripten 的 docker 镜像

不用安装其它的依赖环境,通过运行容器的方式使用别人已经搭建好的 Emscripten 环境。

也可以设置 cache wasm 缓存,加速第二次运行速度。

#!/bin/bash -x

# 指定emscripten版本号
EM_VERSION=1.39.18-upstream

# 运行成功后,开始执行./build.sh里面的脚本编译ffmpeg。

docker pull trzeci/emscripten:$EM_VERSION
docker run \
  -v $PWD:/src \
  -v $PWD/cache-wasm:/emsdk_portable/.data/cache/wasm \
  trzeci/emscripten:$EM_VERSION \
  sh -c 'bash ./build.sh'

编译 FFmpeg

编译过程跟gcc编译类似,后面的编译推荐使用ubuntu系统,其它系统遇到问题比较多。

1. 配置 FFmpeg 参数,生成 MakeFile 等配置文件

运行命令

emconfigure ./configure ...

后面加上配置参数,可以运行 ./configure --help 查看所有可以用的配置。

下面列出了配置示例,我们的需求是要支持 MP4,FLV 视频格式,及常见的 H.264,HEVC,WMV3 编码。

具体每个配置含义:https://cloud.tencent.com/developer/article/1393972

2. 构建依赖

emmake make -j4

后面 -j设置启用多个内核并行去构建,

如果在配置中没有传递参数 --disable-programs, 在这一步就会把安装依赖和构建产物走完,所以如果要构建阶段加上一些额外的参数,或者自己写c方案去引入ffmpeg lib库自定义构建,可以在配置时加上 --disable-programs

3. 构建 ffmpeg.wasm

通过 Emscripten 构建 FFmpeg.wasm,目前主流的方案有两种:

(1) 整体编译 FFmpeg, 加上 pre.js post.js 包裹胶水代码,跟 wasm 通信

具体方案是把上面第二步编译得到的二进制产物 FFmpeg,重命名为 ffmpeg.bc,然后经过 emcc 构建出 ffmpeg.wasm+ffmpeg.js 胶水代码。

在网上搜索一下 ffmpeg.js,也可以发现已经有现成的库:

ffmpeg.js: https://github.com/Kagami/ffmpeg.js 

videoconverter.js: https://github.com/bgrins/videoconverter.js

不过该方案目前尝试只在 Emscripten@1.39.15 之前的版本可以实现,在之后的版本产物只有libavcodec.a libswscale.a libavutil.a etc…, 生成的 FFmpeg 文件也是可执行的 FFmpeg 文件,无法作为 emcc 的输入内容。

具体解释可以看:https://github.com/emscripten-core/emscripten/issues/11977

如果想走通整体编译方案,需要使用 Emscripten@1.39.15 之前的版本,对应 [email protected] 老版本进行编译,或者直接找现成编译好的库。

知道构建出来的产物是什么,那如何跟它进行通信?可以想到应该是胶水代码 ffmpeg.js 内部会导出函数或者全局变量,供外部使用,结果放在回调函数中。其实可以利用Emscripten提供的 --pre-js <file>和 --post-js <file>两个可选参数。

用户传入自定义的 pre.js 和 post.js,包裹住最后生成的胶水代码 ffmpeg.js,在wasm被执行之前,运行 pre.js 中的代码,方便在 pre.js 中导出自定义函数(后面提到的 ffmpeg_run 函数)供外部使用,完成通信。代码示例可以参考 videoconverter 中的文件:

ffmpeg_post.js: https://github.com/bgrins/videoconverter.js/blob/master/build/ffmpeg_post.js

ffmpeg_pre.js: https://github.com/bgrins/videoconverter.js/blob/master/build/ffmpeg_pre.js

外部调用方式是:js 代码通过 postmessage 传递截帧任务参数和 File 实例对象,参数经过处理后,执行 pre.js 中定义的 ffmpeg_run 函数,截帧任务成功后执行回调返回结果。

// 外部js业务代码
workers[i].postMessage({
  fps,
  files,
  // 这里的截帧任务参数,跟ffmpeg命令行用法参数一致
  arguments: [
      '-ss''1',
      '-i''/input/' + files[i].name,
      '-vframes''1',
      '-q:v''2',
      '/output/01.jpg'
  ],})

ommessage 接受到任务,传递给内部函数 ffmpeg_run 执行任务。

// web worker中运行截帧任务,引入ffmpeg.js
onmessage = function (e) {
  const { fps, files, arguments } = e.data;
  let params = [];
   ...// ffmpeg_run在ffmpeg.js里面是全局函数,引入后可以直接用
  ffmpeg_run({
      outputDir'/output',
      inputDir'/input',
      argumentsarguments,
      files,
  }, (res) => {
    // 返回所有图片的arrayBuffe二进制数据数组,
    // 二进制转换为base64格式,展示在页面中展示
      self.postMessage(res);
  })}

最后总结一下整体的命令:

# 配置
emconfigure ./configure \
    --prefix=./lib/ffmpeg-emcc \
  ...

# 构建依赖,生成ffmpeg.bc二进制产物
emmake make -j4

# 构建ffmpeg.wasm
emcc 
  -O2 
  -s ASSERTIONS=1 
  -s VERBOSE=1 
  -s TOTAL_MEMORY=33554432 
  -lworkerfs.js \
  -s ALLOW_MEMORY_GROWTH=1 
  -s WASM=1 
  -v ffmpeg.bc \ # 上一步生成产物,重命名后作为emcc的输入内容
  -o ./ffmpeg.js --pre-js ./ffmpeg_pre.js --post-js ./ffmpeg_post.js

实际上这种方案跟 FFmpeg 没有特别复杂通信,整体的调用方法都封装到了 ffmpeg_run 里面了,不用关注 FFmpeg 内部的实现细节,唯一的缺点是体积太大 12M 以上,里面的功能不可控,偶现截图失败,浏览器崩溃的问题,也没法快速定位。我们线上主要用后面 c方案实现,大小在 3.7M(可以根据实际业务需求变化),相比整体编译更加灵活,所以这里主要介绍 c方案实现

(2) 引入自定义的 c 文件,暴露出接口函数供 JS 调用

FFmpeg 内部分别有不同的库文件,提供不同功能。可以自己写一份 c 代码,通过头文件引入的方式,用 FFmpeg 提供的内部库,实现截帧功能。

这种方式非常考验对 FFmpeg 的理解,而且 FFmpeg 里面很多功能库没有提供完备的文档,不过有一篇教程非常详细的讲述每一步怎么做 An ffmpeg and SDL Tutorial,文章里面用的 api 在 2015 年更新过一遍,但是相比现在的 FFmpeg 版本,还是有很多 api 废弃了。

An ffmpeg and SDL Tutorial:http://dranger.com/ffmpeg/tutorial01.html

最新的文章可以看:https://zhuanlan.zhihu.com/p/40786748

这两篇在原文章的基础上更新了api,其中最后一篇应该算是比较新的版本,用到了[email protected] + [email protected]可以编译成功。

在前面第二步编译 make 基础上,再执行 make install, 将 FFmpeg 构建到 prefix 参数指定的目录下,然后执行 emcc, 引入 c 文件和 FFmpeg 的库文件,生成最终产物。所以整体命令总结一下

# 配置ffmpeg参数
emconfigure ./configure \
    --prefix=/data/web-catch-picture/lib/ffmpeg-emcc \
    ...

# 构建make,安装依赖
make  # 或者emmake make -j4,

# 安装ffmpeg及相关lib到指定目录
make install

# 构建目标产物
# capture.c是我们自定义的c代码
# libavformat.a libavcodec.a libswscale.a... 是前一步编译安装ffmpeg后生成的库文件
emcc ${CLIB_PATH}/capture.c ${FFMPEG_PATH}/lib/libavformat.a ${FFMPEG_PATH}/lib/libavcodec.a ${FFMPEG_PATH}/lib/libswscale.a ${FFMPEG_PATH}/lib/libavutil.a \
    -O3 \
    -I "${FFMPEG_PATH}/include" \
    -s WASM=1 \
    -s TOTAL_MEMORY=${TOTAL_MEMORY} \
    -s EXPORTED_FUNCTIONS='["_main", "_free", "_capture", "_setFile"]' \
    -s ASSERTIONS=0 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MAXIMUM_MEMORY=-1 \
    -lworkerfs.js
    -o /data/web-capture/wasm/capture.js

最后编译用到的参数不多,这里简单解释一下:

WASM=1:指定我们想要的 wasm 输出形式。如果我们不指定这个选项,Emscripten 默认将只会生成asm.js。

TOTAL_MEMORY=33554432:可以通过 TOTAL_MEMORY 参数控制内存容量,值必须为 64KB 的整数倍

EXPORTED_FUNCTIONS Emscripten:为了减少代码体积,会删除无用的函数,类似 treeshaking 的 DCE,我们自定义的函数暴露给外部使用,需要同通过 

EXPORTED_FUNCTIONS:保证不被删除,参数的命名形式为 '_funcName'

ASSERTIONS=1:用于为内存分配错误启用运行时检查,ASSERTIONS 默认是开启的,在存在编译优化参数 (-O1+) 的时候会被关闭

ALLOW_MEMORY_GROWTH=1:设置可变内存,初始化后内存容量固定,在可变内存模式下,空间不足可以实现自动扩容

MAXIMUM_MEMORY=-1:设置成 -1,意味着没有额外的内存限制,浏览器会尽可能的允许内存增加。从这篇文章看 https://v8.dev/blog/4gb-wasm-memory, v8 目前允许 WebAssembly 应用的最大内存也是 4GB,这里也可以设置成 4G。

-I "${FFMPEG_PATH}/include":指定了引用的头文件

及到的 FFmpeg 库

  • libavcodec:音视频各种格式的编解码
  • libavformat:用于各种音视频封装格式的生成和解析,包括获取解码所需信息以生成解码上下文和读取音视频帧等功能
  • libavutil:包含一些公共的工具函数的使用库,包括算数运算,字符操作等。
  • libswscale:提供原始视频的比例缩放、色彩映射转换、图像颜色空间或格式转换的功能。libswscale 常用的函数数量很少,一般情况下就 3 个:
    • sws_getContext():初始化一个SwsContext。
    • sws_scale():处理图像数据。
    • sws_freeContext:释放一个SwsContext。

常用FFmpeg数据结构

  • AVFormatContext:描述了媒体文件的构成及基本信息,是统领全局的基本结构体,贯穿程序始终,很多函数都要用它作为参数;
  • AVCodecContext:描述编解码器上下文的数据结构,包含了众多编解码器需要的参数信息;
  • AVCodec:编解码器对象,每种编解码格式(例如H.264、AAC等)对应一个该结构体,如libavcodec/aacdec.c的ff_aac_decoder。每个AVCodecContext中含有一个AVCodec;
  • AVPacket:存放编码后、解码前的压缩数据,即ES数据;
  • AVFrame:存放编码前、解码后的原始数据,如YUV格式的视频数据或PCM格式的音频数据等;

C 代码逻辑梳理

截帧功能的实现,重点在解封装和解码,先从下面的代码流程图看下整个过程:

使用 FFmpeg 与 WebAssembly 实现纯前端视频截帧

对照上面的流程图,进行具体解释:

1. main 主函数

注册所有可用的文件格式和编解码器,在后面打开相应的格式文件时会自动使用。

#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/imgutils.h>
#include <libswscale/swscale.h>

int main(int argc, charg *argv[]) {
  av_register_all();
}

2. 读取视频文件

具体实现拆解:

JS 部分实现

  • 设置 type="file" 属性的 input 标签,触发 change 事件,获取File对象

  • 检查如果file文件之前没有缓存过,则new FileReader(),利用readAsArrayBuffer方法,转换为ArryaBuffer

  • const filePtr = Module._malloc(fileBuffer.length): 建立视图,方便插入和读取内存中的数据

C 部分实现

  • 到 c 文件里面全局变量定义数据结构 BufferData 存放文件位置指针和长度,保存前面 JS 部分传入的变量
typedef struct {
  uint8_t *ptr;  // 文件中对应位置指针
  size_t size;   // 内存长度
} BufferData;
  • 分配更视频文件同等大小的内存区域,后面在 av_read_frame 读取数据包时,会调用avio_alloc_contex t中的 read_packet 方法读取流数据,readPacket 里面主要根据前面传入的 size,拷贝 BufferData 结构体中的数据,
  uint8_t *avioCtxBuffer = (uint8_t *)av_malloc(avioCtxBufferSize);
  
  // avio_alloc_context 开头会读取部分数据探测流的信息,不会全部读取,除非设置的缓存过大。
  // av_read_frame 会在读帧的时候,调用avio_alloc_context中的read_packet方法读取流数据,
  // 每隔avioCtxBufferSize调用一次,直到读完。
  avioCtx = avio_alloc_context(avioCtxBuffer, avioCtxBufferSize, 0NULL, readPacket, NULLNULL);
  • 指定数据获取的方式,表示把媒体数据当作流来读写
    // ->pb 指向有效实例,pb是用来读写数据的,它把媒体数据当做流来读写
    pFormatCtx->pb = avioCtx;
    // AVFMT_FLAG_CUSTOM_IO,表示调用者已指定了pb(数据获取的方式)
    pFormatCtx->flags = AVFMT_FLAG_CUSTOM_IO;
  • 打开文件,读取文件头,同时存储文件信息到 pFormatCtx 机构中,后面的三个参数,描述了文件格式,缓冲区大小和格式参数,简单指明 NULL 和 0,告诉 libavformat 去自动探测文件格式并使用默认的缓冲区大小。
avformat_open_input(&pFormatCtx, ""NULLNULL)

3. 解封装和解码

大部分音视频格式的原始流的数据中,不同类型的流会按时序先后交错在一起,形成多路复用,这样的数据分布,既有利于播放器打开本地文件,读取某一时段的音视频;也有利于网络在线观看视频,从某一刻开始播放视频。

视频文件中包含数个音频和视频流,并且他们各自被分开存储不同的数据包里面,我们要做的是使用 libavformat 依次读取这些包,只提取出我们需要的视频流,并把它们交给 libavcodec 进行解码处理

解码整体流程,再对比看下这张流程图:

使用 FFmpeg 与 WebAssembly 实现纯前端视频截帧

大体的实现思路基本一致。

获取文件主体的流信息,保存到 pFormatCtx 结构体中,遍历 pFormatCtx -> streams 数组类型的指针,大小为 pFormatCtx -> nb_streams,找到视频流 AVMEDIA_TYPE_VIDEO:

if (avformat_find_stream_info(pFormatCtx, NULL) < 0) {
    fprintf(stderr"avformat_find_stream_info failed\n");
    return NULL;
}

int videoStream = -1;

for (int i = 0; i < pFormatCtx->nb_streams; i++) {
    // 找出视频流
    if (pFormatCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
        videoStream = i;
        break;
    }
}

通过 pFormatCtx -> streams[videoStream] -> codec,获取编解码器上下文,后面读取视频流,解码数据包,获取原始的帧数据需要用到。

这里可以通过上下文拿到解码器 id(pCodecCtx -> codec_id 枚举类型),通过 id 获取解码器:

pCodec = avcodec_find_decoder(pCodecCtx -> codec_id);

打开解码器,开始循环读取视频流:

avcodec_open2
av_read_frame

原始流中读取的每一个 packet 的流可能是不一样的,需要判断 packet 的流索引,按类型处理,找到视频流:

if (packet.stream_index == videoStream)

解码数据包,获取原始的 YUV 格式帧数据, 大多数编码器返回 YUV420 格式的图片,然后使用 sws_scale 将 YUV 格式帧数据转换成 RGB24 格式数据:

avcodec_send_packet
avcodec_receive_frame
sws_scale

4. 错误信息捕获

FFmpeg 错误管理是在 C 运行时库的基础上扩展,根据函数的返回值 int 进行判断,成功返回值大于或等于 0(>=0),错误的返回值为负数,错误值继承 c 运行时库的错误值,扩展自己的错误值定义在 libavcodec/error.h 或者 libavutil/error.h (较新版本位置)头文件中。

需要使用 FFmpeg 提供的函数:

int av_strerror(int errnum, char *errbuf, size_t errbuf_size);

对 int 类型的返回值翻译成字符串,比如:


 ret = avcodec_receive_frame(dec, frame);
 fprintf(stderr, "Error during decoding (%s)\n", av_err2str(ret));

5. 读取视频文件优化

需要修改文件的传递方式,利用 Emscripten 提供的 File System API。默认支持 MEMFS 模式,所有文件存在内存中,显然不满足我们在需求。WORKERFS 模式必须运行在 worker 中,在 worker 中提供对 File 和 Blob 对象的只读访问,不会将整个数据复制到内存中,可以用于大型文件,加上参数 -lworkerfs.js才能包括进来。而且在 FFmpeg 配置需要加上--enable-protocol=file,输入的文件也属于协议,不加入 file 的支持是不能读入文件的。

C 文件修改:

ImageData *capture(int ms, const char* path) {
  // 文件路径作为avformat_open_input函数第二个参数,文件流读取交给ffmpeg完成,
  // 不用再设置pFormatCtx->pb读取方式。
  int ret = avformat_open_input(&pFormatCtx, path, NULLNULL);
  ...

JS 入口文件修改:

const MOUNT_DIR  = '/working';

// createFolder只需要在初始化执行一次
FS.createFolder('/', MOUNT_DIR.slice(1), truetrue);

...

// 这里直接传入视频文件的File对象实例。不需要做其他读buffer内存操作。
FS.mount(WORKERFS, { files: [file] }, MOUNT_DIR)

// JavaScript调用C/C++时只能使用Number作为参数, 这里的虚拟路径字符串传递要用Module.cwrap包裹一层
var c_capture = Module.cwrap('capture''number', ['number''string']);

c_capture(timeStamp, `${MOUNT_DIR}/${file.name}`);
FS.unmount(MOUNT_DIR)

修改后运行任务时,无论视频文件的体积多大,内存占用基本稳定在 200M-400M。

到这里,整个需求中最困难的阶段已经结束了,编译构建过程可能在实际操作时非常曲折,后面讲到的错误捕获及内存优化方案对于实现截帧的帮助会非常大。


接下来会讲一下比较简单的部署及线上情况。读者可以根据一些线上数据,来权衡是否能应用到自己的业务场景中。


部署上线

本地开发可以跑通,接下来进行部署上线,项目使用 webpack 打包,假设项目中相关的目录结构如下:

src
├─ffmpeg 
│  ├─wasm
│  │ ├─ffmpeg.wasm
│  │ ├─ffmpeg.min.js     
│  ├─ffmpeg.worker.js  // 封装截帧功能,同时引入并初始化ffmpeg.min.js,并引入ffmpeg.wasm
│  ├─index.js          // 截图功能入口文件,初始化web worker并引入ffmpeg.worker,
    ...

需要结合两个 loader 使用:

  • file-loader: 通过 webpack 默认加载方式,没法在 worker 中引入 wasm 文件,而且我们得到的 ffmpeg.js 经过了压缩,不需要其它loader再次处理,可以直接利用file-loader得到文件路径,加载 ffmpeg.wasm,ffmpeg.js 文件

  • worker-loader: 专门用来处理 web worker 文件引入和初始化操作的 loader,可以直接引入worker 文件,不用担心路径问题。

最后看下 webpack.config:

  configChain.module
    .rule('worker')
    .test(/\.worker\.js/)
    .use('worker-loader')
    .loader('worker-loader')
  
  configChain.module
    .rule('wasm')
    .test(/\.wasm$|ffmpeg.js$/)
    .type("javascript/auto"/** this disabled webpacks default handling of wasm */
    .use('file-loader')
    .loader('file-loader')
    .options({
      name'assets/wasm/[name].[hash].[ext]'
    })

另外,通常我们线上的 JS,css 等资源都放在 cdn 上面,如果不进行特殊处理,这里配置打包出来的 worker.js 文件引入的路径也是 cdn 域名,但是 web worker 严格限制了 worker 初始化时引入的 worker.js 必须跟当前页面同源,所以需要重写 __webpack_public_path__ 的路径。

index.js

import './rewritePublicPath';
import ffmpegWork from './ffmpeg.worker';
const worker = new ffmpegWork();

rewritePublicPath.js

// 这里需要跟页面的url保持一致
__webpack_public_path__ = "//ke.qq.com/admin/";

worker-loader 的相关配置里面也提供了 publicPath 参数,不过跟我们理解的不一样,
worker-loader.options.publicPath 只会影响在 worker 代码里面,再次 import 其他文件的情况,而我们在初始化 worker.js 时,webpack 默认会使用外部的 __webpack_public_path__ 去替换路径,所以需要重写 path。

现网效果

数据上报到 elk,通过 Grafana 查看整体数据情况,从不同维度收集了线上情况,下面对比过去 7 天的数据:

1. 整体支持 FFmpeg 截图的情况,必须同时支持 Webassembly 和 Web Worker,整体支持情况达到 90.87%,对于不支持截帧的情况,我们会引导用户进行手动上传图片并提供裁剪功能。

使用 FFmpeg 与 WebAssembly 实现纯前端视频截帧

2. 首帧耗时平均在 467ms,整体截取 8 帧耗时在 2.47s 左右,主要在 window 上的 qq 浏览器截帧耗时明显慢很多,偶现最长到了 36.56s。

使用 FFmpeg 与 WebAssembly 实现纯前端视频截帧

3. 截帧成功率达到 99.86%,设置了首帧任务超时 18s,出现超时及失败的情况目前看非常少。

使用 FFmpeg 与 WebAssembly 实现纯前端视频截帧

总结

最开始对音视频相关技术了解几乎为零,所以整个方案从前期调研,到后面落地,上线部署,遇到的问题还是挺多。目前的 c 方案根据视频总时长,平均截取 8 帧实际上是串行执行,这块需要优化,在 c 代码中支持同时截帧多次,返回结果数组。

Webassembly 是由主流浏览器厂商制定的规范,目前来看支持情况还可以(除了IE),很大程度增强了浏览器的功能,把 c/c++ 等功能库搬到浏览器上面跑,减轻了服务器压力。应用场景非常广泛,除了 FFmpeg 解析视频,还有很多算法模型训练,文件 MD5 计算等功能都可以借助 Webassembly 在浏览器里面去做。


使用 FFmpeg 与 WebAssembly 实现纯前端视频截帧
紧追技术前沿,深挖专业领域
扫码关注我们吧!