360视频云前端团队围绕HEVC前端播放及解密实现了一套基于WebAssembly、WebWorker的通用模块化Web播放器,在LiveVideoStackCon2019深圳的演讲中360奇舞团Web前端技术经理胡尊杰对其架构设计、核心原理,具体痛点问题的解决方式进行了详细剖析。
文 / 胡尊杰
整理 / LiveVideoStack
奇舞团是360集团最大的大前端团队,同样也是TC39和W3C会员,拥有Web前端、服务端、Android、iOS、设计、产品、运营等岗位人员,旗下的开源框架和技术品牌有SpriteJS、ThinkJS、MeshJS、Chimee、QiShare、声享、即视、奇字库、众成翻译、奇舞学院、奇舞周刊、泛前端分享等。
奇舞团支持的业务基本上涵盖了360大部分业务线。我个人最开始的时候也曾带队负责360核心安全平台的Web前端支持,包括大家耳熟能详的安全卫士、杀毒软件等。随着公司的业务发展,后面也负责了IoT业务前端支持,最近两年主要配合360视频云的一些Web前端支持工作。基于HEVC的播放器,实际上就是来源于我们最近做的一个叫QHWWPlayer的播放器。HEVC并不是一个新鲜事物,但对于我们团队来说,Web前端的HEVC播放器一直是个亟待优化的领域。虽然移动终端或PC端HEVC播放器已经遍地开花,但在Web端仍旧有很多地方需要改进。包括现存一系列智能硬件产品,也在固件采集端已经应用了HEVC的编码,不过如果想让其在Web端呈现并达到用户需求仍需加倍的努力。本次分享将从以下几个维度展开,希望能给大家带来一定的参考价值。
1. 需求背景
1.1 浏览器端HEVC的支持情况
上图展示了HEVC在浏览器端的支持情况,其中红色代表不支持的浏览器对应版本,绿色代表对HEVC具有良好的支持,青色代表无法保证浏览器可以很好地支持HEVC。总体上来说HEVC在浏览器端并不是一个得到广泛支持的靠谱方案。
一般情况下,PC端浏览器都给我们提供了相应的API,如果我们的业务场景是支持HEVC的浏览器,可尝试有效利用浏览器的原生能力。
基于浏览器原生video,配置source时指定解码器,告知浏览器当前视频采取的是哪一种编码方案。如果浏览器自身有能力进行解码那么其自然会走入“支持HEVC”的逻辑分支当中。
也可以另外通过JS实现检测功能,JS也提供了相应API——canPlayType来判断当前浏览器环境是否支持HEVC解码。
但如果以上流程无法得到有效支持呢?这也是本次分享我们讨论的重点。
1.2 Web端解码方案
浏览器端视频解码总共有以上三种方案,首先就是前文我们提到的基于浏览器原生能力的播放,例如基于video标签拉流、解码以及渲染播放,整个过程完全由浏览器实现。第二种方案是首先通过JS来下载视频流、对视频流进行解封装与转封装处理,最后再通过浏览器提供的相关API,交由浏览器原生video进行解码与渲染播放。如开源社区当中的HLS.JS或FLV.JS等就是基于该思路。
但是HEVC不能仅靠解封装与转封装来实现,因为其本质上在解码层就不支持。因此第三种方案就是:JS下载的视频流首先经由解封装(解密)处理,并在接下来进行解码,解码完成后渲染播放。如果我们这里转成浏览器普遍支持的解码格式并让video标签进行播放,尽管理论上可行,但成本显然是非常高的,并且中间存在一个无端的浪费。因此这里通常直接采用浏览器端Canvas WebAudio API实现视频与音频的渲染,而不再使用浏览器原生video能力。这里如果使用纯浏览器原生的JS,由于 JS天生单线程执行的弱势,会导致整个处理的效率比较差。
近期,万维网标准化委员会正式推出了WebAssembly规范。一方面我们可以借助WebAssembly高于JS的能力,实现更加出色的大规模数据处理与解码,另一方面基于WebAssembly,我们也能方便地将传统媒体处理中基于C或C 开发的一些媒体处理能力集成在浏览器端执行,并且可通过JS来调用API。对于熟悉传统Web前端开发的我们来说,这也是一个值得我们坚持探索与实践的全新领域。有了WebAssembly之后,我们就可以让部门内擅长视频处理的专家级同事来配合实现更加出色的浏览器端视频播放,相对以往的开发流程来说,无论是能力、成本控制还是效率与灵活程度都有十分显著的提升。
1.3 浏览器端WebAssembly的支持情况
上图展现了浏览器端WebAssembly的支持情况,尽管个别低版本的浏览器有一些支持限制,但随着标准化委员会对该标准的不断推进,情况会变得越来越好。在包括一些混合式场景,例如APP内嵌(比如聊天工具或通讯工具当中打开一个链接)等情况,是否支持也取决于WebView本身提供的能力以及WebAssembly的支持情况,总体上来说趋于向好。
1.4 HEVC播放器需求目标
HEVC播放器的需求目标,就是基于 JavaScript 相关API,配合FFmpeg WASM达成 HEVC 在浏览器端的解码&解密、渲染播放的需求,接下来我们就开始研究如何落地这一目标。
2. 架构设计
总体架构设计思路如上图所示,首先我们需要一个专门负责下载的下载器,该下载器也是基于浏览器的JS Fetch或XHR API,以实现文件获取或直播拉流等操作。成功拉取的视频流会被存储在一个数据队列当中,随后基于WebAssembly(WASM) FFmpeg的解码器会来消费处理队列里这些流数据,解码出音视频数据,并放置在音视频帧数据队列当中,等待随后的渲染器对其进行渲染处理。渲染器基于WebGL Canvas与WebAudio调用硬件渲染出图像与音频。
最后则是控制层用于贯穿整体流程中下载、解码、渲染等独立模块,同时实现底层一些基本功能:如之前我们提到JS为单线程,而浏览器提供的WebWork API可拉起一个子线程。该流程中每一个模块都是独立的,队列中的生产与消费过程也是异步进行的。(我们可基于JS本身一些比较好的特性实现诸多便捷的功能。例如基于Promise可以将异步过程进行较为合理的封装,并呈现一些异步处理逻辑流程的关键环节的控制到UI层。)
除此之外,还有控制层的一些基础配置选项,包括播放器本身的一些事件或消息的管理,都可以基于控制层来实现。
3. 分解实现
3.1 下载器
下载器作为一个基本模块独立存在,具有初始配置、启动、暂停、停止、队列管理与Seek响应(用于进度条拖拽)等基本功能。上图左侧图标是在开发完成后,基于下载器的事件消息呈现的数据可视化结果。(柱状图表示单位时间下载量,这里我们可以看到的是,下载量并不均匀,其中的变化可能取决于推流端、服务端、用户端,也可能取决于整个网络环境。)
下载器方面需要留意五个关键问题点:
线性的数据流的合并与拆分
我们应当进行线性数据流的合并与拆分。理论上浏览器从服务端下载一个视频流的过程是线性的,但浏览器的表现实际上并非如此,二者的差异可能会很大。
例如当一个浏览器启动并基于JSFetch API抓取流,其过程也是通过API监听数据回调来实现,每次回调可能间隔会很短、数据量也只是一个很小的一千字节左右的数据包。但有些浏览器的表现并非如此,它们会等抓取到一个1M或2M的数据包之后才反馈给API回调。
而那些过于零碎的数据直接丢给队列或之后的流程来处理,这样势必导致更频繁的数据处理;数据包体积大的直接队列和后续流程势必增加单次处理成本。
因此对线性数据流的合理合并与拆分十分必要,整个过程也是结合初始配置来实现阈值控制。
通过阈值调节控制,我们希望能够做好用户端浏览器硬件资源消耗,与该业务场景下媒体播放产品服务体验之间的取舍与平衡。
内部维护管理 range 状态
除此之外,下载器实际上也需要内部维护管理range 状态。例如当用户选择点播时,我们需要明确是从哪一个字节位置到另一个字节位置下载传输中间这一片数据。而在直播过程中,则可能出现由网络环境造成卡顿或用户端主动暂停的现象,此时下载器需要明确知道播放或当前下载的位置。
不同媒体类型数据获取的差异
第三点是不同媒体类型数据获取的差异,也就是下载器针对不同的媒体类型开发不同的下载功能。例如一个FLV直播流可以理解为是一个连续的线性的数据获取,而点播则以包为单位获取。对于HLS流需要获取m3u8列表,完成分析之后再从中选取数据包的地址并单独下载,随后进行流的合并或拆分。总地来说,我们需要保证数据的最终产出尽量均匀存储到队列中,以便于后续的一系列处理。
MOOV 前置或后置
在媒体处理中像MOOV等的索引数据有前置与后置两种情况,这里需要注意的是,我们的播放器基于Web端。
若索引文件为后置,如果播放器直接下载了一部分数据就直接丢给FFmpeg解码器进行解码,由于FFmpeg解码器无法获取索引,当然也就无法解码成功。除非解码器等待整体媒体源下载完毕,实际上这样是不现实的。
另外由于我们无法控制MOOV索引数据的体量,前置索引的大小无法确定,尤其对于一些特殊情况,这种逻辑会带来很多问题。(但是这里有一个取巧的办法,就是我们可以尝试首先抓取前面几个数据包,探测MOOV边界,并基于此得到MOOV的长度,从而判断取舍在什么时机启动后续的解码。)
慎重并折中的控制内存消耗
最后,慎重并折中控制内存消耗也至关重要。例如尽管较大的缓存能带来流畅的播放,但在Seek时就会带来很大的浪费,我们则需要根据服务所在的应用场景、帧率码率等来实现合理的折中与取舍。
3.2 解码器
下载器之后,整个流程的核心能力就是解码器。解码器的基本功能与下载器相比大同小异,需要特别关注的是解码器并不是像下载器完全是去调用一个原生的JS Fetch API或XHR,而是在启动WebWorker之后再启动WebAssembly(这里的WebAssembly依赖中是引入了定制化的FFmpeg API,以解决解容器、解码等需求),并实现一些API的交互。上图左侧展现了音频与视频帧解码数据队列的可视化结果。
解码器方面,需要关注的关键问题主要有以下几点:
启动解码前依赖数据量控制
刚才讲到MOOV前置与后置时我们也提及这一点,也就是在启动解码前做好数据量控制,明确其数据量是否已经达到FFmpeg的基本需求。如果索引文件的数据还没有完全给到就直接使用命令行启动FFmpeg,那么就会出现报错的情况。我们应当结合数据量的精准控制来对解码器的启动时机做合理的判断。
主动向下载器获取数据
解码器需要主动获取下载器生成的数据队列,这样系统便可根据数据消费效率获知当前解码器是否处于繁忙的状态。同时,主动向下载器获取数据也能在一定程度上减轻CPU的负担,并可根据CPU的负载来决定当前从下载端应该获取多少数据。例如如果CPU负载较大则数据队列自然会出现累积,我们可以在下载器初始化时设置一个阈值,如果数据队列积累达到该阈值则下载器暂停下载,这样就可合理控制处理的整体流程并确保播放的正常。
动态解码模式控制CPU消耗
整个解码过程实际上还依赖CPU的性能,如果单帧解码的时间较长,例如一个帧率是25的视频,仅单帧解码就需耗费半秒钟甚至更长时间,此时如果我们依然按照这样半秒钟或更久的频度解码,则解码数据生产效率完全跟不上渲染的自然时间进度,效果肯定不符合预期,播放也会断断续续。因此我们需要针对不同的应用场景,使用动态解码模式(主动丢帧)控制好CPU的消耗。例如在直播或安防场景下,我们可以舍弃一些指标以保证解码与传输的时效性。
独立的音频、画面帧数据队列
如上图左侧所示,独立的音频与画面帧数据队列分别管理;比如我们启动丢帧策略的话,会看到画面帧数据量变少,但声音没有变化。
音频重新采样
采集端编码数据的音频采样率需要结合播放端的支持情况来留意兼容问题。
浏览器是一个比较特殊的应用场景,各浏览器对音频渲染中采样率的支持程度也是不同的。
例如安防场景对声音的要求并不是很高,通常16,000的采样率即可,但是如果想在浏览器端播放视频,则部分浏览器要求至少22,050的采样率,否则浏览器端播放无法成功识别并渲染音频数据。FFmpeg本身可以进行音频重新采样,因此我们可以在解码器端加入相应的配置项,如果用户有该需求那么就可以启动音频重新采样,重新把16,000的音频采样率重采样成符合浏览器所要求的22050采样率。有了符合要求的独立的音频与视频数据帧队列,接下来也自然就能基于浏览器实现对音视频的渲染与呈现。
3.3 渲染器
渲染器的基本功能与下载器、解码器相似,不同之处在于以下几个关键点:
依赖解码、UI提供画布
渲染器需要浏览器提供一个独立的画布用于绘制相应的视觉画面内容。在UI模块初始化时呈现出一个画布的容器,渲染器渲染生成的画面才能表现在网页上。
除此之外,渲染器依赖解码器解码生产出的音视频帧数据才能进行音画渲染。
主动向解码器获取帧数据
这一点与解码器向下载器主动拿数据相似。
分缓存队列、渲染队列
渲染器会消费处理等待渲染的帧数据队列,只不过帧数据会被分为缓存队列与渲染队列。
而之前我们介绍的下载器与解码器,本身只有一组数据队列。为什么要这样呢?渲染器调用WebAudio API将音频数据传输给浏览器进行PCM渲染时,无法将已经通过该API传输给浏览器的数据做取回控制,因此就需要记录当前已经给了多少数据到浏览器,这就是“渲染队列”。而“缓存队列”则是从进程中获取一部分数据先存储在一个临时队列当中,从而避免频繁地向处于另一个独立WebWorker中的解码器索取其音画帧队列数据,而带来不必要的时间消耗。
音画同步、倍速播放、Waiting
音画同步、倍速播放以及判定是否处于等待状态至关重要。比如要追求直播的低延时,网络抖动导致数据堆积发生的时候,倍速追帧是个有效的办法。
动态码率变化
一个视频在播放的过程中,可能随网络状态的波动出现码率的动态变化,例如为适应较差的网络状况,播放器可以主动将媒体流获取从一个较为清晰的高分辨率变化到一个比较模糊的低分辨率源。
而再渲染中,基于WebGLCanavas的渲染器,我们首先需要对YUV着色器进行初始化操作,而YUV着色器的初始化,依赖于其所绘制的数据对应的分辨率、比例与尺寸。如果最开始的分辨率、比例和尺寸与之后要渲染的数据不一样,而我们又未对此做相应的响应适配,那么就会出现画面绘制花屏的情况。而动态码率变化就是要随时响应每一画面帧所对应的分辨率变化,对YUV着色器作动态调整,从而保证画面的实时性与稳定性。
从下载、解码到渲染,视频播放器的基本流程就此建立,播放器便有了获取媒体数据、完成解码、呈现音画效果的基本能力。
3.4 UI
基本的UI如上图左侧所示,上半部分是整个播放器在实例化之前我们可以去做的一系列初始化配置。图中所示的仅是一小部分参数,例如媒体源的地址、是否启用了加密Key、对应的解密算法,包括渲染时为满足某些特定场景下的需求,音视频是同时进行渲染还是在主动控制下仅渲染音频或视频——例如在安防监控业务场景,会有一些设备需要音频采集、另一些不需要,或者干脆播放时就不想播放源流音频等等。若在这里播放器不做判定支持,则存在由于音画同步控制依赖音频帧视频帧时间戳比对,但没有音频帧数据的原因导致无法正常播放,而播放器使用者能进行主动控制则可以避免该问题。
UI的基本功能包括实例化、用户操作触发后续流程涉及的各模块接下来要做什么,还有状态信息响应展现,也就是根据用户交互行为和播放器工作状态作出反馈与信息传递。
另外,UI也需要对相应的状态变化作出响应,例如用户控制当前播放器从正在播放切换到暂停,那么UI层面则需要针对用户操作进行相应的变化。还有快进、拖拽进度条等等。
3.5 控制层
最后的控制层至关重要,首先控制层隔离校验对外暴露的参数及方法。播放器可实现或具备的特性有很多,不可能全部暴露给用户。在播放视频时,下载与解码的数据实际上存在一个前后呼应的关系,如果我们不考虑用户行为与需求,在网页上呈现播放器的所有特性。而用户也不对其进行科学性选择与判断,而是随意调用API,势必会带来矛盾、冲突与混乱。因此我们需要隔离配置信息、校验对外暴露的控制参数及方法,以避免可能存在的冲突。
另外根据之前的介绍我们可以看到,不同模块的基本功能大致相同。因此在控制层我们需要统一各模块的生命周期,并完成用于调度各模块工作的基础类的实现。
每个独立的模块什么时刻可以实例装载?什么时刻销毁?该模块是否支持热插拔?各模块生命周期状态的管控与事件消息的监听与调度…… 这些都由控制层进行管理。
有时我们需要做一些取舍,例如编码器并不是基于FFmpeg,而是基于我们自己的解码解决方案,那么就可以尝试在播放器实例化时候,更换对应模块当中相对应的部分依赖为自己的解码方案;如果我们需要调整播放器UI层界面样式,那么就可能需要定制自己的UI模块……
在这个播放器实现中,为了规避单线程一些弊端,我们基于WebWorker API对重点模块开启子线程。
而WebWorker本身的设计存在各种不便:
首先,要求我们必须单独打包一个JS文件,基于 new Worker(“*.js”)引入到项目中。
但我们整个播放器作为SDK项目的构建来说,通常只产生一个JS文件发布出去,才是合理的。如果同时产生多个JS文件,这对我们的调试、开发或后续应用等来说都不方便。
针对这个问题我们结合Promise 实现了PromiseWebWorker,PromiseWebWorker 相对于原生Worker,参数不再必须是传入一个JS引用路径,而是可以传入一个函数。
这样以来我们就可以在项目编译时生成一个独立的JS文件,在播放器的执行过程中将其中worker依赖的那部分函数内容生成一个虚拟的文件依赖地址,作为WebWorker执行的资源。
其次,WebWorker原生能力实现父子线程之间数据传递通讯,只能通过postMessage传送数据、通过onMessage获取传送过来的数据,这对于频繁的数据交互中想保证上下文关联对应关系是比较麻烦的。PromiseWebWorker则借助了Promise的优势,对以上整个数据交换过程做严格的应答封装处理,从而实现播放器功能的健壮可靠。
上图链接 http://lab.pyzy.net/qhww中是播放器DEMO展示地址
若对此感兴趣可以前往试用研究
要点回顾
调度控制层控制下载器、解码器、渲染器与UI&交互四大模块,如果要做某功能模块的业务定制化开发、功能增强补充,对对应独立的模块内部进行优化并做出相应的功能扩展或者调整即可。
4. 难点突破
开发过程所遇到的难点总体可以用以上三点来概括:首先基于WebAssembly 工具链(emscripten.org),借助EMSC编译器我们可以直接将一个C和C 编译成JS可用。这一过程本身存在诸多不便之处,主要是因为其本身对系统一些底层库的依赖或对于开发环境的要求,导致可移植性并没有那么好,当需要跨机器协作时容易出现诸多问题。现在我们内部的解决方案是自己找一台专用机器来配置做为编译发布使用。
第二点是队列管理与状态控制,只有精确实现队列管理与状态控制,我们才能保证整个程序能合理稳定的执行。
第三点就是项目构建打包,我们要解决前端一些构建打包的习惯以及其在逻辑需求上存在的一些冲突。
5. 未来展望
展望未来,我希望未来浏览器能对HEVC有更加出色的支持。本次分享虽然是一个播放器,但我们知道FFmpeg的能力不只是解码播放,还可以做更多实用工具的发掘实现。同时我也希望未来媒体类型百花齐放,甚至私有编解码也能够形成Web端场景更规范灵活的解决方案。WASM成熟、标准化完善、各业务领域对应解决能力的细分,也是很值得期待的一件事情;而回到播放器本身,字幕、AI、互动交互等都是能进一步提升音视频播放服务的可玩性与用户体验方向值得研究的方向。