我们的业务是十分养眼的NOW直播,每一场直播结束后,我们都会保存一段时间的直播回放,每一场直播回放都充满了不少的精彩片段,然而要从2、3小时的直播回放中准确找出这些精彩片段却不是那么容易的事情。于是,故事要从一次需求宣讲说起,我们的产品希望能在回放中剪辑出主播的高光时刻,作为前端的我们本来是听听就好,毕竟长期以来视频裁剪工作都是在后台完成,然而这一次,作为IVWEB的前端,我们决定拿起wasm去试一试。
1. 多年前的方案
在2013年(今年是2019年)的Node Knockout比赛上,有人提出了一个叫 Video Funhouse(年代太久远,我没能找到更多的资料)的设想,后来就有了github上的videoconverter方案。videoconverter将音视频领域中的瑞士军刀ffmpeg通过emscripten(一个可以将C/C 代码生成asm/wasm的编译工具)转化为javascript,实现了在浏览器上对视频的简单操作,包括视频的裁剪/转换。它的demo目前还能运行,地址如下:http://bgrins.github.io/videoconverter.js/demo
在demo中,通过输入ffmpeg命令行ffmpeg -i input.webm -vf showinfo -strict 2 output.mp4
就可以的到输入视频input.webm的mp4格式输入,如果把时间参数带入比如增加-ss 10 -t 60
同样可以将视频从第10s开始裁剪,得到一段60s的输出。它利用web worker执行ffmpeg的js版,将本地的input.webm读入后实现转码/裁剪的体验还是比较流畅的。
然而毕竟是一个6年前的纯js视频方案,并且最终停留在一个demo的状态,对于产品的需求还是有很多不能满足的地方,比如:
- 我们业务的直播回放都是hls,videoconverter不能直接支持hls
- 转换后的js非常大,gzip前的ffmpeg-all-codec.js大小为26m,gzip后也有6.8m的大小
在6年后的今天,emscripten的版本已经从1.2.1升级到1.38.45,我们也有了新的方案来实现视频操作,不过videoconverter为我们提供了实现的思路。
2. wasm重生
这篇文章不是webassembly和emscripen的(以下简称wasm)的介绍文,关于wasm这里只提及它的几个核心关键词,二进制字节码,体积更小,运行更快,更多的信息可以参考WebAssembly 不完全指北。如今的emscripten已经可以轻松的将c/c 代码转换成asm/wasm,通过emscripten的Module对象可以控制wasm代码的执行,实现数据的交互,函数调用。之后会有专门介绍emscripten Module对象的文章。
整个方案实现流程如下图所示:
参考videoconverter的方案思路,核心步骤是编译出一个浏览器可用的ffmpeg版本,所以第一步就是去官网下载一个ffmpeg。不能使用brew安装ffmpeg,你需要自己去编译安装。
- 编译ffmpeg 同本地安装ffmpeg一样,也需要先安装第三方依赖,特别是libx264(ffmpeg的encoder中没有h264),然后设置编译参数。在ffmepg目录下
./configure --help
可以查看完整的编译配置。通过--cc="emcc"
将编译器指定为emcc,将一些不需要的ffmpeg和不支持wasm的模块和特性禁用掉,比如--disable-hwaccels
禁用硬解码。完整的配置在最下面的代码仓库中可以查看。
配置好你需要的demuxers/decoders muxers/encoders以及配置链接第三方库,再编译和安装就可能得到你编译的ffmpeg版本。下一步就是通过emcc编译出wasm和胶水js代码。
代码语言:javascript复制 emcc
-O3
-s WASM=1
-s ASSERTIONS=2
-s VERBOSE=1
-s ALLOW_MEMORY_GROWTH=1
-s TOTAL_MEMORY=33554432
-v ffmpeg.bc libx264.bc libvpx.bc libz.bc
-o ../ffmpeg.js --pre-js ../ffmpeg_pre.js --post-js ../ffmpeg_post.js
-O3是编译的优化等级,参数TOTAL_MEMORY和ALLOW_MEMORY_GROWTH设定了wasm需要开辟的内存和执行时内存超过TOTAL_MEMORY时允许自动扩容。
--pre-js和--post-js设置了自定义的js文件,作为最终生成的胶水代码的前缀和后缀,wasm执行前执行在pre.js中的逻辑,来设置一些必要的参数,执行返回等等。这两个文件参考videoconverter的代码,在pre.js中设定了ffmpeg的入口函数ffmpeg_run和数据回调函数。
最终文件的输出会是ffmpeg.wasm和ffmpeg.js, 胶水代码的大小为250k,ffmpeg.wasm的大小为5m,videoconverter的输出js大小为26m,相比之下小了很多,并且ffmpeg.wasm仍然后通过编译配置继续减小的空间。
- 使用命令行 在本地的ffmpeg上使用简单的
ffmpeg -i input.m3u8 -c copy output.mp4
命令就能把hls视频导出一个mp4文件,如果需要第5到第8分钟的视频,用ffmpeg -i input.m3u8 -ss 300 -t 180 -c copy output.mp4
就可以实现。
利用emscripten Module对象的arguments就可以设置ffmpeg wasm版本的命令行参数,Module.arguments是一个参数数组,在执行之前需要设置好。
3. 细节实现
- hls文件分析 对于回放hls文件来说,首先是加载m3u8文件,m3u8文件是一个指定了一个个视频文件片段文本,通过解析m3u8可以知道每一个片段的播放开始时间,比如一个m3u8文件,去掉一些版本、序号指定后:
#EXTM3U
...
#EXT-X-PROGRAM-DATE-TIME:2019-09-21T20:24:50 08:00
#EXTINF:5,
122070284_485656995_1.ts?start=0&end=781327&type=mpegts
#EXTINF:5,
122070284_485656995_1.ts?start=781328&end=1351343&type=mpegts
#EXTINF:5
...
第一个片段是122070284_485656995_1.ts?start=0&end=781327&type=mpegts,它的时长为6.002,第二个片段122070284_485656995_1.ts?start=781328&end=1351343&type=mpegts,它的时长为4.005。通过每一片段的时长,我们在解析m3u8后可以通过指定的时间段计算出真正需要的裁剪时间片段,以及从这个时间片段算起的时间偏移量,这样不需要加载所有的ts文件就可以裁剪出需要的视频。比如我们需要8-15s的视频,只需要第二和第三个片段,并且起始时间将变成3s。
除此之外,还需要重构原先的m3u8文件,保存先前的文件头后,文件的ts片段由裁剪所需的ts构成,可以重新指定文件名字。
- 生成输入文件 重构了m3u8文件后,整个入口函数的调用为:
ffmpeg_run({
print: console.log,
printError: console.error,
files: [
{
name: 'playlist.m3u8'
data: new Uint8Array(buffer)
},
{
name: 'list0.ts'
data: new Uint8Array(buffer0)
},
{
name: 'list1.ts'
data: new Uint8Array(buffer1)
}
...
],
arguments: ['-i', 'playlist.m3u8', '-ss', 重新计算出得起始时间, '-t', '180', input.m3u8', '-c', 'copy', 'output.mp4']
});
回放视频已经拆分成一个个视频片段,那么ffmpeg.wasm应该怎么读取到呢?
emscripen提供了一套文件系统FS来实现虚拟文件,上面提到的输入文件m3u8,ts以及输出文件output.mp4可以用它来实现。利用FS的createDataFile和createFolder就可以创建我们需要的虚拟文件系统。
代码语言:javascript复制 Module['files'].forEach(function(file) {
FS.createDataFile('/', file.name, file.data, true, true);
}
遍历传入的files,createDataFile传入指定的文件名和文件ArrayBufer数据,就可以创建文件,在ffmpeg.wasm解析m3u8时,就可以读取到,m3u8文件和ts文件。
emscripen也提供了Fetch Api,通过XHR可以实现文件的传输,也可以将文件请求步骤交给c/c 去处理,这个方案我没有尝试,有兴趣的同学可以试一下。
4. 一点点优化
mp4格式是由一个一个的box数据块组成,其中moov box包含了视频文件的所有宏观描述信息,如视频尺寸,帧率等信息。当播放视频的时候,需要先读取moov box的信息,来查找视频和音频数据的位置,如果moov box的位置处于视频的尾部,那就需要加载完整个视频才能开始播放。
对于使用视频流的我们来说,这是无法接受的(也有支持seek的方式,让服务器直接seek到视频尾部,不过需要额外的处理)。好在ffmpeg提供了将moov前置的方法,只需要在命令行参数中添加-movflags faststart
。用mp4 info查看我们生成的mp4文件,可以看到moov已经放置到视频数据mdat之前。
5. 总结
作为一个长期享受修改即可见的web开发来说,对ffmpeg的编译以及emcc编译这种一等就是半小时的场面还真的没有见过,wasm ffmpeg的开发调试整体需要更有耐心,不过付出就会有收获,wasm将ffmpeg引入到了web开发领域,相信以后也会看到更多的纯web音视频应用。