前言
B 站 Web 投稿页面通过结合 WASM + FFmpeg 和 WebCodecs 实现了更高效的视频截帧功能,解决了部分视频格式无法解封装而导致的性能问题。今日前端早读课文章由 @Francis 分享,公号:哔哩哔哩技术授权。
正文从这开始~~
业务背景
在 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,以及两个关键的结构体:
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 都需要单独实现,这样成本就会比较高,不符合我们低成本的预期。背靠 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%
【第3204期】基于WebCodecs的网页端高性能视频截帧
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,通过开源库来实现。长期看,原生解封装的能力的支持还遥遥无期。
【第3251期】Webcodecs音视频编解码与封装技术探索
不过,借助 FFmpeg 这个丰富的宝库,我们可以将更多的能力进行 WASM 层面的模块化封装,与 WebCodecs 等原生能力去结合使用,去补齐原生的不足,在 Web 上实现更多音视频编辑的可能性。未来,随着原生能力的逐步发展,再逐步替换提升性能,从而实现渐进式的发展。
附录
web-demuxer:https://github.com/ForeverSc/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:_https://github.com/w3c/webcodecs/issues/24
关于本文
作者:@Francis
原文:https://mp.weixin.qq.com/s/gHtDDVUDrAN43eWaBtBbAg
这期前端早读课
对你有帮助,帮” 赞 “一下,
期待下一期,帮” 在看” 一下 。