| 导语 随着短视频兴起,音视频技术已经越来越火热,或许你之前有了解过如何在前端处理音视频,但随着视频文件的逐渐增大、用户体验要求的不断提高,纯前端处理音视频的技术也推成出新。下面将结合实际案例,讲解如何使用 FFmpeg 和 WebAssembly 实现前端视频截帧。文章较长,也非常硬核,建议先收藏再慢慢看。
背景
腾讯课堂涨知识创作者后台,目前主要通过邀请合作老师来平台上发布视频。上传视频的同时,需要对视频进行截帧生成推荐封面,生成规则比较简单,根据视频总时长,平均截取 8 帧。用户可以从其中选择一张图片作为视频封面。
前期调研
视频截帧,首先想到的是 video canvas 方案,毕竟接触最多的就是它了,不过后面的深入分析,可以发现他们的局限性还是挺多的。
下面主要对比了不同截帧方案,每种方案都是可以走通的,也有不同的问题。
1. 腾讯云视频上传转换能力
腾讯云“数据万象”,图片上传和存储服务都基于对象存储服务(COS),同时官网上提供了媒体截图接口 GenerateSnapshot,可以获取某个时刻的截图,输出的截图统一为 jpeg 格式,同时在我们的内部库也封装基础的 JS 操作。视频上传和每个时刻的截图处理分成多个异步任务,上传任务返回结果后才能执行下一个截图处理。但是目前这种方案需要服务端配合实现鉴权,比较麻烦,而且只有在上传视频后再进行截图,整个耗时会非常长。
2. video canvas 视频截图
可以看下网上的demo:
demo地址:https://zzarcon.github.io/video-snapshot/
主体实现代码如下:
代码语言:javascript复制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, 0, 0, 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:体积小且加载快的全新二进制格式,已经得到了主流浏览器厂商的支持。
Emscripten:用来把 c/c 代码编译成 asm.js 和 WebAssembly 的工具链。编译流程先把c/c 代码编译成 LLVM 字节码,然后根据不同的目标语言编译成 asm.js 或者 wasm。
下面我们从如何安装 Emscripten 开始讲起,到编译 FFmpeg,构建出 ffmpeg.wasm,从而可以在浏览器执行。
文章整体篇幅比较长,而且整体构建也有比较简单的方式,如果你已经了解到网上有很多现成的构建包,可以直接拿来用,那么你就不用太关注整个编译过程及最后的 C语言方案如何实现,直接跳转到部署上线部分。
但是,如果想追求极致,根据自己的业务需求,来调整包大小,或者用新版本的 FFmpeg 来打包,就需要看完 C 语言部分。
安装 Emscripten
编译之前需要手动安装 Emscripten 编译器,安装提供了两种方式:
1. 根据官网指导安装
代码语言:javascript复制官方文档: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 环境。
Emscripten 镜像地址:https://hub.docker.com/r/trzeci/emscripten
也可以设置 cache wasm 缓存,加速第二次运行速度。
代码语言:javascript复制#!/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 等配置文件
运行命令
代码语言:javascript复制emconfigure ./configure ...
后面加上配置参数,可以运行 ./configure --help
查看所有可以用的配置。
下面列出了配置示例,我们的需求是要支持 MP4,FLV 视频格式,及常见的 H.264,HEVC,WMV3 编码。
具体每个配置含义:https://cloud.tencent.com/developer/article/1393972
2. 构建依赖
代码语言:javascript复制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 之前的版本,对应 ffmpeg@3.x 老版本进行编译,或者直接找现成编译好的库。
知道构建出来的产物是什么,那如何跟它进行通信?可以想到应该是胶水代码 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 函数,截帧任务成功后执行回调返回结果。
代码语言:javascript复制// 外部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 执行任务。
代码语言:javascript复制// 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',
arguments: arguments,
files,
}, (res) => {
// 返回所有图片的arrayBuffe二进制数据数组,
// 二进制转换为base64格式,展示在页面中展示
self.postMessage(res);
})}
最后总结一下整体的命令:
代码语言:javascript复制# 配置
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,其中最后一篇应该算是比较新的版本,用到了ffmpeg@3.4.8 emscripten@1.39.18可以编译成功。
在前面第二步编译 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 代码逻辑梳理
截帧功能的实现,重点在解封装和解码,先从下面的代码流程图看下整个过程:
对照上面的流程图,进行具体解释:
1. main 主函数
注册所有可用的文件格式和编解码器,在后面打开相应的格式文件时会自动使用。
代码语言:javascript复制#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. 读取视频文件
文件读取主要通过读取文件到内存,然后传递首地址指针到c文件中,完成内存文件传递。
具体实现拆解:
JS 部分实现
- 设置 type="file" 属性的 input 标签,触发 change 事件,获取File对象
- 检查如果file文件之前没有缓存过,则new FileReader(),利用readAsArrayBuffer方法,转换为ArryaBuffer
- const filePtr = Module._malloc(fileBuffer.length): 建立视图,方便插入和读取内存中的数据
- Module是emscripten编译出的ffmpeg.js中暴露出来的全局变量,接着通过Module._malloc分配同等大小的内存空间,Module.HEAP8.set(fileBuffer, filePtr),将数据填充进去,最后将内存首地址指针,及长度传给c文件暴露出来的方法。
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, 0, NULL, readPacket, NULL, NULL);
- 指定数据获取的方式,表示把媒体数据当作流来读写
// ->pb 指向有效实例,pb是用来读写数据的,它把媒体数据当做流来读写
pFormatCtx->pb = avioCtx;
// AVFMT_FLAG_CUSTOM_IO,表示调用者已指定了pb(数据获取的方式)
pFormatCtx->flags = AVFMT_FLAG_CUSTOM_IO;
- 打开文件,读取文件头,同时存储文件信息到 pFormatCtx 机构中,后面的三个参数,描述了文件格式,缓冲区大小和格式参数,简单指明 NULL 和 0,告诉 libavformat 去自动探测文件格式并使用默认的缓冲区大小。
avformat_open_input(&pFormatCtx, "", NULL, NULL)
3. 解封装和解码
大部分音视频格式的原始流的数据中,不同类型的流会按时序先后交错在一起,形成多路复用,这样的数据分布,既有利于播放器打开本地文件,读取某一时段的音视频;也有利于网络在线观看视频,从某一刻开始播放视频。
视频文件中包含数个音频和视频流,并且他们各自被分开存储不同的数据包里面,我们要做的是使用 libavformat 依次读取这些包,只提取出我们需要的视频流,并把它们交给 libavcodec 进行解码处理
解码整体流程,再对比看下这张流程图:
大体的实现思路基本一致。
获取文件主体的流信息,保存到 pFormatCtx 结构体中,遍历 pFormatCtx -> streams 数组类型的指针,大小为 pFormatCtx -> nb_streams,找到视频流 AVMEDIA_TYPE_VIDEO:
代码语言:javascript复制if (avformat_find_stream_info(pFormatCtx, NULL) < 0) {
fprintf(stderr, "avformat_find_stream_info failedn");
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 获取解码器:
代码语言:javascript复制pCodec = avcodec_find_decoder(pCodecCtx -> codec_id);
打开解码器,开始循环读取视频流:
代码语言:javascript复制avcodec_open2
av_read_frame
原始流中读取的每一个 packet 的流可能是不一样的,需要判断 packet 的流索引,按类型处理,找到视频流:
代码语言:javascript复制if (packet.stream_index == videoStream)
解码数据包,获取原始的 YUV 格式帧数据, 大多数编码器返回 YUV420 格式的图片,然后使用 sws_scale 将 YUV 格式帧数据转换成 RGB24 格式数据:
代码语言:javascript复制avcodec_send_packet
avcodec_receive_frame
sws_scale
4. 错误信息捕获
FFmpeg 错误管理是在 C 运行时库的基础上扩展,根据函数的返回值 int 进行判断,成功返回值大于或等于 0(>=0),错误的返回值为负数,错误值继承 c 运行时库的错误值,扩展自己的错误值定义在 libavcodec/error.h 或者 libavutil/error.h (较新版本位置)头文件中。
需要使用 FFmpeg 提供的函数:
代码语言:javascript复制int av_strerror(int errnum, char *errbuf, size_t errbuf_size);
对 int 类型的返回值翻译成字符串,比如:
代码语言:javascript复制 ret = avcodec_receive_frame(dec, frame);
fprintf(stderr, "Error during decoding (%s)n", av_err2str(ret));
5. 读取视频文件优化
文件传递本来是将原始的视频数据,通过 js 的 readAsArrayBuffer 方法文件转换为 ArrayBuffer,传递内存地址进去,占用了很大空间,同时在读取数据包时,又会额外开辟空间,截帧过程中,内存占用可以达到文件本身大小的 3 倍多。测试上传一个 1.8G 左右的视频文件,运行任务时内存占用达到了 5.4G。
需要修改文件的传递方式,利用 Emscripten 提供的 File System API。默认支持 MEMFS 模式,所有文件存在内存中,显然不满足我们在需求。WORKERFS 模式必须运行在 worker 中,在 worker 中提供对 File 和 Blob 对象的只读访问,不会将整个数据复制到内存中,可以用于大型文件,加上参数 -lworkerfs.js
才能包括进来。而且在 FFmpeg 配置需要加上--enable-protocol=file
,输入的文件也属于协议,不加入 file 的支持是不能读入文件的。
C 文件修改:
代码语言:javascript复制ImageData *capture(int ms, const char* path) {
// 文件路径作为avformat_open_input函数第二个参数,文件流读取交给ffmpeg完成,
// 不用再设置pFormatCtx->pb读取方式。
int ret = avformat_open_input(&pFormatCtx, path, NULL, NULL);
...
JS 入口文件修改:
代码语言:javascript复制const MOUNT_DIR = '/working';
// createFolder只需要在初始化执行一次
FS.createFolder('/', MOUNT_DIR.slice(1), true, true);
...
// 这里直接传入视频文件的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 打包,假设项目中相关的目录结构如下:
代码语言:javascript复制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:
代码语言:javascript复制 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
代码语言:javascript复制import './rewritePublicPath';
import ffmpegWork from './ffmpeg.worker';
const worker = new ffmpegWork();
rewritePublicPath.js
代码语言:javascript复制// 这里需要跟页面的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%,对于不支持截帧的情况,我们会引导用户进行手动上传图片并提供裁剪功能。
2. 首帧耗时平均在 467ms,整体截取 8 帧耗时在 2.47s 左右,主要在 window 上的 qq 浏览器截帧耗时明显慢很多,偶现最长到了 36.56s。
3. 截帧成功率达到 99.86%,设置了首帧任务超时 18s,出现超时及失败的情况目前看非常少。
总结
最开始对音视频相关技术了解几乎为零,所以整个方案从前期调研,到后面落地,上线部署,遇到的问题还是挺多。目前的 c 方案根据视频总时长,平均截取 8 帧实际上是串行执行,这块需要优化,在 c 代码中支持同时截帧多次,返回结果数组。
Webassembly 是由主流浏览器厂商制定的规范,目前来看支持情况还可以(除了IE),很大程度增强了浏览器的功能,把 c/c 等功能库搬到浏览器上面跑,减轻了服务器压力。应用场景非常广泛,除了 FFmpeg 解析视频,还有很多算法模型训练,文件 MD5 计算等功能都可以借助 Webassembly 在浏览器里面去做。
紧追技术前沿,深挖专业领域
扫码关注我们吧!