摘要:在B站Web投稿页中,封面、分区、标签的推荐功能都需要使用到视频截帧能力。历史上我们通过WebAssembly + FFmpeg来实现视频截帧。从去年开始,开始引入WebCodecs进行高性能截帧,截帧性能有显著提升,从而给用户带来更快速的推荐体验。
业务背景
在B站Web投稿页中,封面、分区、标签的推荐功能都需要使用到视频截帧能力。历史上我们通过WebAssembly + FFmpeg来实现视频截帧。从去年开始,开始引入WebCodecs进行高性能截帧,截帧性能有显著提升,从而给用户带来更快速的推荐体验。
但目前WebCodecs只提供了用于解码的能力,并没有提供对应解封装能力,只能自行实现。此前,我们通过MP4box.js以及自行开发的mkv-demuxer,解决了mp4+mkv主流视频格式的解封装问题, 实现了WebCodecs高性能封面截帧方案的落地。但仍存在近 2%的视频格式如flv、avi等,因为无法解封装,而无法体验到WebCodecs的高性能。
针对不同视频格式去做解封装处理,需要进行数据转换,API适配类的工作,存在一定的开发成本。同时,相关的高质量在维护的JS解封装库很少,假如继续针对单个格式去做逐个处理,ROI会很低。
于是,我们期望为WebCodecs低成本定制一种通用的解封装方案,一次性支持尽可能多的视频格式。
方案设想
联想到之前使用WebAssembly + FFmpeg进行截帧的经验,FFmpeg支持的视频格式很广泛,如果能复用FFmpeg的Demux能力,并结合WebCodecs的Decode能力,应该就能实现两者的优势互补。将耗时短的Demux环节交给WebAssembly + FFmpeg去支持更多的视频格式,耗时长的Decode环节交给原生的WebCodecs去提升解码性能。
解决方案
核心思路
基于上述设想,核心目标就是将WebAssembly + FFmpeg中的Demux能力独立出来,实现一个WASM Demuxer,主要步骤如下:
C中新增获取WebCodecs解码所需数据的函数JS胶水代码实现JS与C间的双向通信,传递解封装后的数据截帧SDK中基于原始数据进行转换,适配WebCodecs整体流程如下图所示,下面讲详细介绍下具体实现步骤
C中获取WebCodecs解码所需数据
关键数据结构
FFmpeg包含很多library,这里我们的目标是解封装,所以只需要重点关注用于负责多媒体文件流格式处理的libavformat,以及两个关键的结构体:
AVStream: 用于存储视频/音频流信息的结构体,包含编解码器参数、比特率、帧率等AVPacket: 用于存储解码前的压缩数据包的结构体,包含数据包对应的时间戳、大小等WebCodecs视频截帧中,主要用到VideoDecoder中的 configure 和 decode 方法,configure方法用于配置和初始化视频解码器,decode方法则用于向视频解码器提供编码视频数据,以便解码器能够处理和输出解码后的帧。
与configure与decode方法需要的入参做对比后,可以很容易发现,configure方法所需的参数都可以在AVStream中找到,decode方法所需的参数也都可以在AVPacket中找到。
因此,需要在C中实现两个函数,分别用于获取视频文件中视频流的AVStream与视频流中指定时间点的AVPacket。
不过,FFmpeg中的AVStream和AVPacket都比较复杂,而在截帧场景无需用到所有的参数。于是,我们对AVStream和AVPacket进行裁剪,重定义了两个新的结构体 WebAVStream 与 WebAVPacket。
生成WebAVStream
裁剪转换后的WebAVStream结构体如下,包含了编解码器参数、开始时间、时长等。
新建一个 get_av_stream 函数用于从文件中查找对应视频流的AVStream信息。首先从视频文件中查找到匹配的视频流信息,读取AVStream,对其进行裁剪与数据适配,生成并返回新定义的结构体WebAVStream。
如何生成codec_string
在构建WebAVStream时发现,WebCodecs VideoDecoder的configure 方法中有一个必要的 codec 参数,需要传入一个有效的 codec_string,即编解码字符串,描述用于编码或解码的特定编解码器格式。浏览器通过解析该参数,才能知道去调用哪一种编解码器。
codec_string参数无法直接从AVStream上获取,需要结合AVStream中的信息去生成。社区里,这部分的资料非常少,并没有现成可用的轮子,只能自行实现。调研后发现,生成codec_string主要需要两个步骤:
从视频流中解析出视频编解码器的配置信息将视频编解码器的配置信息按照codec_string的标准进行转换首先,对于如何从视频流中去解析出视频编解码器的配置,可以自行按照对应的标准去实现,不过对于不同的codec都需要单独实现,这样成本就会比较高,不符合我们低成本的预期。背靠FFmpeg这个丰富的宝库,相信应该能找到可复用的方法,于是在libavformat中一番探索后,果不其然找到了相关的解析方法。
以vp9为例,与ISOM文件之间的绑定规范中(ISOM 即 ISO Base Media File Format,是一种用于存储多媒体内容的文件格式标准,常见的MP4就是基于这种文件格式),可以看到VP编解码配置信息如下:
在libavformat/vpcc.c中存在一个 ff_isom_write_vpcc 方法,该方法用于将 VP 编解码器配置写入到ISOM文件中(例如VP9会被写入到MP4文件的stsd/vp09/vpcC盒子中)。在写入配置前会通过 ff_isom_get_vpcc_features 方法来解析生成配置参数。
由于 ff_ 开头的方法都是FFmpeg内部的方法,无法直接调用,只能将这部分逻辑复制出来,提取出关键部分,改写后作为生成编解码器配置的逻辑。改写后的 get_vpcc_features 如下,包含了生成codec_string所需的参数。
成功解析出编解码器的配置信息后,还需要将配置信息转换成codec_string,于是再结合VP9的Codecs Parameter String规范,实现codec_string的拼装。
生成codec_string后,对生成的codec_string是否能正确被浏览器解析还是有所疑惑,于是去Chromium中简单探索下,找到解析codec_string的方法video_codec_string_parsers源码,看下浏览器在获取到codec_string以后具体是怎么解析的。
找到解析vp9的函数ParseNewStyleVp9CodecID,可以发现首位的 sample entry 4CC必然是vp09,同时profile、level、bitDepth三个必要参数的解析规则与 ff_isom_get_vpcc_features 中产出的配置信息格式能够正确对应上,辅助映证了生成逻辑无误。
另外,可以看到文档上有提到DASH格式中也有使用到codec_string,在 libavformat/dashenc.c 中可以发现有类似的set_vp9_codec_str方法,也是使用 ff_isom_get_vpcc_features 来实现的。
最后的生成函数如下所示:
其他h264、hevc等编码的codec_string生成逻辑也是同理,都能在FFmpeg中找到可参考的方法,并且更加简单。因为h264、hevc的视频编解码配置信息都会写入到 AVStream→codecpar→extradata 中,所以可以直接按照比特位去读取配置信息。以h264为例,参考 ff_isom_write_avcc,了解到配置信息的字段写入顺序与每个字段所占比特位数,从extradata中反向读取对应配置字段,最后再拼接成codec_string即可。
生成WebAVPacket
裁剪转换后的WebAVPacket结构体很简单,仅需包含关键帧、时间戳、时长、大小及数据
新建一个 get_av_packet 函数,用于获取指定时间点的AVPacket。首先与 get_av_stream 一样,查找到对应的视频流索引。根据传入的截帧时间点与视频流索引,定位至指定时间点的帧,读取AVPacket数据,然后进行裁剪转换,返回新定义的结构体WebAVPacket。
JS与C双向通信
在完成C中的 get_av_stream 与 get_av_packet 方法后,还需要在JS胶水代码中建立JS与C的双向通信。下面以 get_av_packet 方法为例。
JS调用C
首先使用Emscripten提供的Module.cwrap方法,将C函数包装成JS函数,调用包装后的JS函数,将文件路径和时间作为入参传入,执行后的返回值为WebAVPacket结构体指针。
C调用JS
C函数执行完毕后,通过返回的WebAVPacket结构体指针从WASM的内存中读取数据。使用Emscripten提供的Module.getValue传入指针,返回内存中具体的值。最后,将所有值组合成一个JS Object,通过postMessage传出。
截帧SDK新增WASM Demuxer
最后,因为WASM的处理逻辑都运行在Worker上,需要在截帧SDK中对postMessage进行Promise化包装,同时适配WebCodecs的参数格式(WebAVStream => VideoDecoderConfig、WebAVPacket => EncodedVideoChunk),封装成WASM Demuxer。
数据结果
WASM Demuxer上线后,使用WASM Demuxer + WebCodecs截帧对比之前使用WASM + FFMpeg截帧,封面推荐耗时P90减少了约 40%,因为视频封装格式不支持导致WebCodecs截帧失败的错误量下降了约 72%
web-demuxer
考虑到很多项目之前并没有WebAssmbly+FFmpeg的基础,提炼了一个名为web-demuxer 的npm包,将WebAssmbly+FFmpeg中demuxer的部分单独提取编译,大大缩减了WASM的体积,支持MP4+MKV的最小版本gzip后的体积为115KB,对大多数Web项目的使用应该还是可接受的。
通过简单的十几行代码就可以实现视频截帧
同时也提供以ReadableStream逐帧读取的方式,用来进行播放等更复杂的场景
希望能让 WebCodecs 的使用变得更加便捷,详细的介绍可见web-demuxer
写在最后
WebCodecs仓库的issue中也有关于是否支持媒体容器相关API的讨论,但媒体工作组的想法是将这部分工作交给JS/WASM,通过开源库来实现。长期看,原生解封装的能力的支持还遥遥无期。
不过,借助FFmpeg这个丰富的宝库,我们可以将更多的能力进行WASM层面的模块化封装,与WebCodecs等原生能力去结合使用,去补齐原生的不足,在Web上实现更多音视频编辑的可能性。未来,随着原生能力的逐步发展,再逐步替换提升性能,从而实现渐进式的发展。
附录
web-demuxer:
WebCodecs支持的Video Codecs:https://www.w3.org/TR/webcodecs-codec-registry/#video-codec-registry
VP9 Codecs Parameter String :https://github.com/webmproject/vp9-dash/blob/main/VPCodecISOMediaFileFormatBinding.md#codecs-parameter-string
Chromium video_codec_string_parsers源码:https://chromium.googlesource.com/chromium/src/media/+/refs/heads/main/base/video_codec_string_parsers.cc#33
Chromium video_codec_string_parsers单元测试代码:https://chromium.googlesource.com/chromium/src/media/+/refs/heads/main/base/video_codec_string_parsers_unittest.cc
关于媒体容器API的讨论issue:
-End-
作者丨Francis
来源:肖潇科技每日一讲