看前端如何通过WebAssembly实现播放器预览能力
最近,团队小组内部体验Web浏览器上课的音视频播放功能,除了对比同行产品,也对比了主流视频内容的网站平台。计划补齐和增强与播放体验相关的能力。
其中有一项能力在主流媒体视频网站都支持的,那就是进度条帧预览:在鼠标进度条停留,不必跳转进度,即可展示所指画面。
在简单分析了B站、腾讯视频后,发现都是采取在上架视频时,由后台生成专门用来帧预览的组合sprite图,然后前端拉取后再计算进度进行展示。
由于目前的我们后台云点播录制没有生成帧预览图功能。另一方面,即便升级可能大量的存量存储视频无法帧预览。于是我们决定尝试前端实现动态帧预览的方案。
浏览器获取视频画面的方法:
目前浏览器视频帧提取的方案主要有:
-
canvas + video方案:主要video在播通过canvas的drawImage提取视频帧。但注意浏览器一般只能解析MP4/WebM的格式, H264/VP8编解码的视频。如果不是指定格式,要先解复用在利用MSE来实现。 -
webassembly + ffmpeg方案:webassembly的出现为前端解码视频数据提供了可能,将ffmpeg编译为wasm库,通过js调用并提取视频帧数据,再给到canvas绘制。
第一种方案对于单个MP4文件还是合适的,但hls资源不是完整加载,并且浏览器不能直接复用ts格式,所以行不通。
HLS动态解密ts分片wasm ffmpeg获取帧画面的技术方案
整体技术方案:
①通过解析HLS masterPlayList 和 levelPlayList,拿到低清晰度的ts文件索引数组。
②支持区分判断HLS加密,获取解密秘钥,AES 解密ts文件数据。
③ts文件arraybuffer数据,申请内存并写入wasm,调用wasm封装截图方法,返回RGB数据。
④将RGB数据转为canvas imagedata,更新展示帧画面,并缓存。监听鼠标事件定位帧缓存画面,或加载新数据。
FFmpeg编译至WebAssembly
前置准备
安装emscripten的emsdk,实际上会遇到不少困难。按照emscripten官网的指示一步一步,遇到阻碍及时谷歌变更解决。安装完成后执行emcc -v 能查看版本,代表安装成功。
# Get the emsdk repo
git clone https://github.com/emscripten-core/emsdk.git
# Enter that directory
cd emsdk
# 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
FFmpeg编译
FFmpeg是个优秀的音视频处理库,包含了采集、格式转化、编解码、截图、滤镜等能力。我们需要禁用掉大部分能力,只编译我们需要的部分,最后编译产物是c依赖库和相关头文件。
emconfigure ./configure \
--prefix=$WEB_CAPTURE_PATH/lib/ffmpeg-emcc \
--cc="emcc" \
--cxx="em++" \
--ar="emar" \
--cpu=generic \
--target-os=none \
--arch=x86_32 \
--enable-gpl \
--enable-version3 \
--enable-cross-compile \
--disable-ffmpeg \
--disable-ffplay \
--disable-ffprobe \
--disable-doc \
--disable-ffserver \
--disable-swresample \
--disable-postproc \
--disable-programs \
--disable-avfilter \
--disable-pthreads \
--disable-w32threads \
--disable-os2threads \
--disable-network \
--disable-logging \
--disable-everything \
--enable-protocol=file \
--enable-demuxer=mpegts \
--enable-decoder=h264 \
--disable-asm \
--disable-debug \
分析ffmpeg提取帧流程
视频文件数据到帧的图像数据,按照流程:解格式封装、视频解码,图像数据转换(YUV=>RGB)。则按照HLS分片提取图像数据流程,需要涉及到以下ffmpeg中的库。
-
libavcodec:提供编解码功能。这里我只是需要H264的视频编解码。 -
libavformat:多路解复用(demux)和多路复用(mux)。这里我3需要解复用ts文件的格式、即mpegts。 -
libswscale:图像伸缩和像素格式转化。 -
libavutil:工具函数。
编译至Wasm
最后需要通过 emcc 来将demuxer和decoder和依赖的相关库编译为 wasm 然后提供浏览器使用javascript进行调用。编译选项如下:
emcc ./getframe.c ./ffmpeg/lib/libavformat.a ./ffmpeg/lib/libavcodec.a ./ffmpeg/lib/libswscale.a ./ffmpeg/lib/libavutil.a \
-O3 \
-I "./ffmpeg/include" \
-s WASM=1 \
-s TOTAL_MEMORY=33554432 \
-s EXPORTED_FUNCTIONS='["_main", "_free", "_getFrame", "_setFile"]' \
-s ASSERTIONS=1 \
-s ALLOW_MEMORY_GROWTH=1 \
-s MAXIMUM_MEMORY=4GB \
-o getframe.js
emcc编译选项,请参考: https://emscripten.org/docs/tools_reference/emcc.html
最后编译wasm成功是一个wasm的二进制文件,和一个胶水代码js文件。
Blockquote EXPORTED_FUNCTIONS: 参数告诉编译器,代码里面需要输出的函数名。函数名前面要加下划线.
ASSERTIONS: ASSERTIONS=1 用于为内存分配错误启用运行时检查(例如,写入比分配更多的内存)。它还定义了Emscripten如何处理程序流中的错误。可以将值设置为ASSERTIONS=2,以便运行额外的测试。
ALLOW_MEMORY_GROWTH: Emscripten堆一经初始化,容量就固定了,无法再扩容。而某些程序在运行时需要的内存容量在不同工况下可能有很大的波动。为了满足某些极端工况的需求而将TOTAL_MEMORY设置得非常高无疑是非常浪费的,为此,Emscripten提供了可在运行时扩大内存容量的模式,欲开启该模式,需要在编译时增加-s ALLOW_MEMORY_GROWTH=1参数。
封装API
这里参考了网上一些现成的做法,虽然可以生成ffmpeg.js和ffpmeg.wasm,并提供Module对象来操控,但是这样JS的数据类型和C的数据类型差异比较多,频繁地调C的API,让数据传来传去比较麻烦。这里参考网上的教程、前置封装ffmpeg的API,具体参考这里的实现和教程:https://github.com/liyincheng/ffmpeg-wasm-video-to-picture http://dranger.com/ffmpeg/tutorial01.html。ffempg可调用函数:http://dranger.com/ffmpeg/functions.html 。
-
注册所有可用的文件格式和编解码器,后续打开具有相应格式/编解码器的文件时就可使用,请注意,我们在main()中只需要调用一次 av_register_all()
即可。
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/imgutils.h>
#include <libswscale/swscale.h>
int main(int argc, char const *argv[]) {
av_register_all();
return 0;
}
-
打开文件、检索流信息、找视频流的解码器、复制上下文并打开编解码器。
AVFormatContext *pFormatCtx = NULL;
// Open video file
if(avformat_open_input(&pFormatCtx, argv[1], NULL, 0, NULL)!=0)
return -1; // Couldn't open file
// Retrieve stream information
if(avformat_find_stream_info(pFormatCtx, NULL)<0)
return -1; // Couldn't find stream information
AVCodec *pCodec = NULL;
// Find the decoder for the video stream
pCodec=avcodec_find_decoder(pCodecCtx->codec_id);
if(pCodec==NULL) {
fprintf(stderr, "Unsupported codec!\n");
return -1; // Codec not found
}
// Copy context
pCodecCtx = avcodec_alloc_context3(pCodec);
if(avcodec_copy_context(pCodecCtx, pCodecCtxOrig) != 0) {
fprintf(stderr, "Couldn't copy codec context");
return -1; // Error copying codec context
}
// Open codec
if(avcodec_open2(pCodecCtx, pCodec)<0)
return -1; // Could not open codec
其中主要步骤在于,读取整个数据流,方法是读取数据包,将其解码为帧,一旦帧完成,我们将对其进行转换RGB(PIX_FMT_RGB24)并保存。
struct SwsContext *sws_ctx = NULL;
int frameFinished;
AVPacket packet;
// initialize SWS context for software scaling
sws_ctx = sws_getContext(pCodecCtx->width,
pCodecCtx->height,
pCodecCtx->pix_fmt,
pCodecCtx->width,
pCodecCtx->height,
PIX_FMT_RGB24,
SWS_BILINEAR,
NULL,
NULL,
NULL
);
i=0;
while(av_read_frame(pFormatCtx, &packet)>=0) {
// Is this a packet from the video stream?
if(packet.stream_index==videoStream) {
// Decode video frame
avcodec_decode_video2(pCodecCtx, pFrame, &frameFinished, &packet);
// Did we get a video frame?
if(frameFinished) {
// Convert the image from its native format to RGB
sws_scale(sws_ctx, (uint8_t const * const *)pFrame->data,
pFrame->linesize, 0, pCodecCtx->height,
pFrameRGB->data, pFrameRGB->linesize);
// Save the frame to disk
if(++i<=5)
SaveFrame(pFrameRGB, pCodecCtx->width,
pCodecCtx->height, i);
}
}
// Free the packet that was allocated by av_read_frame
av_free_packet(&packet);
}
javascript 调用 wasm
https://www.cntofu.com/book/150/zh/ch2-c-js/ch2-01-js-call-c.md
javascript调用wasm,简单概括是就是内存的写入与读取的过程。理论上HLS文件拿到ts分片文件,将文件保存Unit8Array,并写入到wasm中。
-
xhr 请求 tsFile 保存为 Uint8Array
let tsBuffer = new Uint8Array(tsFileArrayBuffer);
-
申请内存空间
let tsBufferPtr = Module._malloc(tsBuffer.length);
-
将buffer 写入 wasm 内存
Module.HEAP8.set(tsBuffer, tsBufferPtr);
-
执行封装 _getFrame 函数,传入内存指针,内存大小,时间点。
let imgData = Module._getFrame(tsBufferPtr, tsBuffer.length, time)
去的RGB数据在js层转化canvas可用数据。
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = width;
canvas.height = height;
let imageData = ctx.createImageData(width, height);
let j = 0;
for (let i = 0; i < imageBuffer.length; i++) {
if (i && i % 3 == 0) {
imageData.data[j] = 255;
j += 1;
}
imageData.data[j] = imageBuffer[i];
j += 1;
}
ctx.putImageData(imageData, 0, 0, 0, 0, width, height);
const finalData = canvas.toDataURL('image/jpeg');
HLS动态加载ts分片及解密
HLS masterPlayList/ levelPalyList解析
HLS点播资源并非单文件,而是一个m3u8协议的索引。要取拿到帧数据,必须要加载ts分片文件数据。必须先HLS解析m3u8文件。由于我们取帧图片拿来做预览,并不需要很大的尺寸和清晰度。当包含多个level(清晰度)的情况下,优先选取最低清晰度的levelPlayList。
MSE HLS解析:一般MSE HLS使用hls.js加载视频播放,通过其创建实例(client),在onManifestParsed
事件后通过client.levels
可以读取到到不同level的参数。
Native HLS解析:对于移动端浏览器,或者safari等浏览器,使用native播放m3u8的模式。我们可以自己解析m3u8的masterPlayList,然后自行解析。比如通过BANDWIDTH和RESOLUTION,取出最低清晰度,或者可以借助m3u8-parser
进行解析。
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:PROGRAM-ID=0,BANDWIDTH=2099325,RESOLUTION=1920x1080
v.f124099.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=0,BANDWIDTH=197642,RESOLUTION=1280x720
v.f22239.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=0,BANDWIDTH=142162,RESOLUTION=960x540
v.f22240.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=0,BANDWIDTH=95767,RESOLUTION=480x270
v.f22241.m3u8
获取playList的segments映射时间段
我们需要知道定位时间距离最近的segment,直接维护最低清晰度playlist数组,根据#EXTINF
得知每个segment的duration时长,计算出总时长,及每个segment的开始时间和结束时间。当我们定位到指定时间,即可匹配到最近的ts文件作为被解析的数据。
......
#EXTINF:10.000000,
v.f22241.ts?start=260400&end=382047&type=mpegts
#EXT-X-KEY:METHOD=AES-128,URI="http://getkeyurl",IV=0x00000000000000000000000000000000
#EXTINF:10.000000,
v.f22241.ts?start=382048&end=502943&type=mpegts
#EXT-X-KEY:METHOD=AES-128,URI="http://getkeyurl",IV=0x00000000000000000000000000000000
#EXTINF:10.000000,
v.f22241.ts?start=502944&end=623455&type=mpegts
#EXT-X-KEY:METHOD=AES-128,URI="http://getkeyurl",IV=0x00000000000000000000000000000000
#EXTINF:10.000000,
v.f22241.ts?start=623456&end=748303&type=mpegts
.....
AES解密ts文件
同样的,在MSE HLS播放的,hls.js实例上能读取到KEY和IV;对于native hls播放的,需要自己二次请求获取。
WebCrypto ASE解密参考hls.js源码,将请求到的ts分片进行解密。https://github.com/videojs/aes-decrypter
let decrypter = new Decrypter();
const { key, iv } = levelKey;
decrypter.decrypt(data, key, iv, (data) => {
const finalSegmentFile = new Blob([data], {
type: 'video/mp2t',
});
// 给到wasm写入finalSegmentFile
});
点播进度帧预览逻辑及缓存策略
动态节流加载,并缓存至对应时间区间:由于用户的鼠标在进度条可能频繁移动,这里设计应该监听mousemove但节流触发。从解析playlist开始,到ts文件加载与解密,wasm解码获取帧数据拿到imagedata,设置500ms触发阈值,获取帧图像数据缓存到对应时间区间。
就近读取缓存帧画面:一般来说,相邻进度的帧画面往往是相似,但加载到解帧的整个过程异步且存在一定耗时,优先展示相邻分片区间的缓存帧图像数据,可以让用户第一时间感知,提升体验效果。
问题与小结
用wasm做前端播放帧预览的能力,已经在业务侧灰度上线。由于我们只需要解复用mpegts和h624decoder,编译wasm大小2.6MB左右。主要受限于加载分片的网络耗时,从hover进度条到预览图展示约在1.1秒左右,wasm解帧耗时60ms以内。在支持wasm的PC浏览器上chrome、新版firefox和safari也都没什么太大问题。
目前一个完整600M左右的高清回放资源,如果加载完整的资源用于帧预览的消耗30-50MB流量,但实际情况下并不会完整的加载,一般都只在10M以内。虽然这部分资源被http缓存,但是如果不是因为网络差而走到低清晰度的情况下,这部分资源的流量多少还是有点被浪费了。
这几年wasm从一些demo尝试,到业务真正落地,越来越多场景中被得到应用。以前一些客户端app才有的功能,现在浏览器也并非不可想象。期待可以落地更多有趣和实用的功能。
1.16,2020 IMWeb Conf 上,本文作者 javen 会有一场专业的前端音视频分享,感兴趣的小伙伴不要错过哦。