vlambda博客
学习文章列表

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发

前言

又是比较小众细分的领域了,这个领域最近有两个点分享,一个是斗鱼上市,另一个是B站推出互动视频。今日早读文章由@花椒前端投稿分享。


正文从这开始~~

一、背景介绍

随着近些年直播技术的不断更新迭代,高画质、低带宽、低成本成为直播行业追求的重要目标之一,在这种背景下,H.264 标准已成为行业主流,而新一代的 HEVC(H.265)标准也正在直播领域被越来越广泛地采用。花椒直播一直在对 HEVC(H.265)进行研究、应用以及不断优化。

二、技术调研

HEVC(H.265)

高效率视频编码(High Efficiency Video Coding,简称 HEVC),又称为 H.265 和 MPEG-H 第 2 部分,是一种视频压缩标准,被视为是 ITU-T H.264/MPEG-4 AVC 标准的继任者。HEVC 被认为不仅提升影像质量,同时也能达到 H.264/MPEG-4 AVC 两倍之压缩率(等同于同样画面质量下比特率减少到了 50%)。以下统称为 H.265。

H.265 相对于 H.264 的一些主要改进包括:

1.更灵活的图像区块划分

H.265 将图像划分为更具有灵活性的”树编码单元(Coding Tree Unit, CTU)”,而不是像 H.264 划分为 4×4~16×16 的宏块(Micro Block)。CTU 利用四叉树结构,可以被(递归)分割为 64×64、32×32、16×16、8×8 大小的子区域。随着视频分辨率从 720P、1080P 到 2K、4K 不断提升,H.264 相对较小尺寸的宏块划分会产生大量冗余部分,而 H.265 提供了更灵活的动态区域划分。同时,H.265 使用由编码单元(Coding Unit, CU)、预测单元(Predict Unit, PU)和转换单元(Transform Unit, TU)组成的新的编码架构可以有效地降低码率。

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发

2.更加完善的预测单元

帧内预测:相对于 H.264 提供的 9 种帧内预测模式,H.265 提供了 35 种帧内预测模式(Planar 模式、DC 模式、33 种角度模式),并提供了更好的运动补偿和矢量预测方法。

帧间预测:指当前图像中待编码块从邻近图像中预测得到参考块的过程,用于去除视频信号的时间冗余。H.265 有 8 种帧间预测方式,包括 4 种对称划分方式和 4 种非对称划分方式。

3.更好的画质和更低的码率

H.265 在 Deblock 之后增加了新的”采样点自适应补偿(Sample Adaptive Offset)”滤波器,包括边缘补偿(EO,Edge Offset)、带状补偿(BO,Band Offset)、参数融合模式(Merge),用于减少源图像与重构图像之间的失真,以及降低码率。测试数据表明,虽然采用 SAO 会使得编解码复杂度增加约 2%,但是却可以减少 2%~6% 的码流。

可以看到,H.265 在提供了更高的压缩率、更低的码率、更好的画质的同时,也增加了编解码的复杂度,有统计表明 H.265 解码的运算量已经数倍于 H.264。

由于此次是针对 Web 端播放直播流进行的实践,所以本文主要关注解码部分。

硬解与软解

解码通常分为硬解码与软解码。硬解码是指通过专门的解码硬件而非 CPU 进行解码,如 GPU、DSP、FPGA、ASIC 芯片等;软解码是指通过 CPU 运行解码软件来进行解码。严格意义上来说并不存在纯粹的硬解码,因为硬解码过程仍然需要软件来控制。

硬件解码虽然可以获得更好的性能,但是碍于专利授权费以及支持硬解码的设备还并不普及(当前市场上只有部分 GPU 支持 H.265 硬解码)。同时随着计算机 CPU 性能的不断快速提升,H.265 软解码已经开始得到广泛使用。

Web 端软解码

目前各主流浏览器对 H.265 播放的原生支持情况不够理想,Web 端几大浏览器全部不支持 H.265 原生播放,Web 端的 H.265 播放需要通过软件解码来完成。

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发

在 Web 端进行软解码首先会想到使用 JavaScript。libde265.js 是用 C 开发的开源 H.265 编解码器 libde265 的 JavaScript 版本(确切地说是 libde265 的 asm.js 版本,后面会说明)。经测试,使用 libde265.js 并不是一个音视频播放的完善方案,存在帧率偏低和音视频不同步等问题。此外,JavaScript 作为解释型脚本语言,对于 H.265 解码这种重度 CPU 密集型的计算任务而言,也不是理想的选择,于是继续探寻更优方案。

Chrome 原生的 audio/video 播放器原理

查找 Chromium Projects 文档 ,我们可以看到整体大致过程为:

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发
  • video 标签创建一个 DOM 对象,实例化一个 WebMediaPlayer

  • player 驱使 Buffer 请求多媒体数据

  • FFmpeg 进行解封装和音视频解码

  • 把解码后的数据传给相应的渲染器对象进行渲染绘制

video 标签显示或声卡播放

视频解码的目的就是解压缩,把视频数据还原成原始的像素,声音解码就是把 mp3/aac 等格式还原成原始的 PCM 格式。FFmpeg 是一套老牌的、跨平台音视频处理工具,历史悠久,功能强大,性能卓著,市场上有大量基于 FFmpeg 的编解码器和播放器。可以看到 Chrome 也使用了它做为它的解码器之一。根据原生的 audio/video 播放器原理,我们可以利用 FFmpeg 自己来实现 H.265 的播放。

FFmpeg 从早期的 2.1 版本已经开始支持对 H.265 视频进行解码,但是花椒直播是基于 HTTP-FLV 的 H.265 视频流,而 FFmpeg 官方到目前为止并不支持 “HEVC over FLV (and thus RTMP) “,当然这肯定不是因为 FFmpeg 在技术方面存在什么问题,而是因为 Adobe 官方到目前为止也还没有支持以 FLV 来封装 H.265 数据。

HTTP-FLV 扩展

HTTP-FLV 属于三大直播协议之一(另外两种是 RTMP 和 HLS),顾名思义,就是把音视频数据封装为 FLV 格式,然后通过 HTTP 协议进行传输。HTTP-FLV 延迟低,基于 80 端口可以穿透防火墙的数据流协议,并且支持 HTTP 302 进行调度和负载均衡。

上面我们提到,FFmpeg 官方并不支持以 FLV 格式来封装 H.265 数据的编解码,但是非官方的解决方案已经存在,比如国内厂商金山视频云就对 FFmpeg 做了扩展,为 FFmpeg 添加了支持 FLV 封装的 H.265 数据的编解码功能。由此,经过扩展的 FFmpeg 可以支持解码 HTTP-FLV 直播流的 FLV 格式的 H.265 数据了。

但我们知道,FFmpeg 是用 C 语言开发的,如何把 FFmpeg 运行在 Web 浏览器上,并且给其输入待解码的直播流数据呢?使用 WebAssembly 能够解决我们的问题。

WebAssembly

WebAssembly 是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,并为其他语言提供一个编译目标,以便它们可以在 Web 上运行。它也被设计为可以与 JavaScript 共存,允许两者一起工作。近几年已经被各主流浏览器所广泛支持:

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发

在了解 Wasm 的特点和优势之前,先来看一下 JavaScript 代码在 V8 引擎里是如何被解析和运行的,这大致可以分为以下几个步骤(不同的 JS 引擎或不同版本的引擎之间会存在一些差异):

1.JavaScript 代码由 Parser 转换为抽象语法树 AST
2.Ignition 根据 AST 生成字节码(V8 引擎 v8.5.9 之前没有这一步,而是直接编译成机器码,v8.5.9 之后 Ignition 字节码解释器则会默认启动)
3.TurboFan(JIT) 优化、编译字节码生成本地机器码

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发

其中第 1 步生成 AST,JS 代码越多,耗时就会越长,也是整个过程中相对较慢的一个环节。而 Wasm 本身已经就是字节码,无需这个环节,所以整体运行速度要更快。

在第 3 步中,由于 Wasm 的数据类型已经是确定的,因此 JIT 不需要根据运行时收集的信息对数据类型进行假设,也就不会出现重复优化的周期。此外,由于 Wasm 是字节码,比实现同等功能的 JavaScript 代码(即使是压缩后的)体积也会小很多。

由此可见,实现等效功能的 Wasm 无论是下载速度还是运行速度都会比 JavaScript 更好。前面提到过的 asm.js,在本质上也是 JavaScript,在 JS 引擎中运行时同样要经历上述几个步骤。

到目前为止,已经有很多高级语言先后支持编译生成 Wasm,从最早的 C/C++、Rust 到后来的 TypeScript、Kotlin、Scala、Golang,甚至是 Java、C# 这样的老牌服务器端语言。开发语言层面支持 Wasm 的态势如此百花齐放,也从侧面说明 WebAssembly 技术的发展前景值得期待。

前面我们说到,WebAssembly 技术可以帮我们把 FFmpeg 运行在浏览器里,其实就是通过 Emscripten 工具把我们按需定制、裁剪后的 FFmpeg 编译成 Wasm 文件,加载进网页,与 JavaScript 代码进行交互。

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发

三、实践方案

整体架构/流程示意图

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发
涉及技术栈

WebAssembly、FFmpeg、Web Worker、WebGL、Web Audio API

关键点说明

Wasm 用于从 JavaScript 接收 HTTP-FLV 直播流数据,并对这些数据进行解码,然后通过回调的方式把解码后的 YUV 视频数据和 PCM 音频数据传送回 JavaScript,并最终通过 WebGL 在 Canvas 上绘制视频画面,同时通过 Web Audio API 播放音频。

Web Worker

Web Worker 为 Web 内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。此外,他们可以使用 XMLHttpRequest 执行 I/O 。一旦创建, 一个 worker 可以将消息发送到创建它的 JavaScript 代码, 通过将消息发布到该代码指定的事件处理程序。

在主线程初始化两个 Web Worker,Downloader 和 Decoder,分别用于拉流和解码,其中 Decoder 与 Wasm 进行数据交互,三个线程之间通过 postMessage 通信,在传送流数据时使用 Transferable 对象,只传递引用,而非拷贝数据,提高性能。

Downloader 使用 Streams API 拉取直播流。Fetch 拉取流数据并返回一个 ReadableStreamDefaultReader 对象(默认),它可以用来从一个流当中读取一个个 Chunk。该对象的 read 方法返回一个 Promise 对象,通过这个 Promise 对象可以连续获得一组{done,value} 值,其中 done 表示当前流是否已结束,如果未结束的话,value.buffer 即是此次拉取到的二进制数据段,这段数据会通过 postMessage 发送给 Decoder。

音频解码完成会放到主线程的 AudioQueue 队列里面,视频解码完成会放到主线程 VideoQueue 队列里面,等待主线程的读取。作用是为了保证流畅的播放体验,也进行音视频同步处理。

FFmpeg

FFmpeg 主要是由几个 lib 目录组成:

  • libavcodec:提供编解码功能

  • libavformat:封装(mux)和解封装(demux)

  • libswscale:图像伸缩和像素格式转化


首先使用 libavformat 的 API 把容器进行解封装,得到音视频在这个文件存放的位置等信息,再使用 libavcodec 进行解码得到图像和音频数据。

YUV 视频数据的呈现

YUV 的采样主要有 YUV4:4:4,YUV4:2:2,YUV4:2:0 三种,分别表示每一个 Y 分量对应一组 UV 分量、每两个 Y 分量共用一组 UV 分量、每四个 Y 分量共用一组 UV 分量,YUV4:2:0 所需的码流最低。

YUV 数据的排列包括 Planar 和 Packed 两种格式。Planar 格式的 YUV 依次连续存储像素点的 Y、U、V 数据;Packed 格式的 YUV 交替存储每个像素点的 Y、U、V 数据。

这里我们解码出的视频数据是 YUV420P 格式的,但是 Canvas 不能直接渲染 YUV 格式的数据,而只能接收 RGBA 格式的数据。把 YUV 数据转换为 RGBA 数据,会消耗掉一部分性能。我们通过 WebGL 处理 YUV 数据再渲染到 Canvas 上,这样可以省略掉数据转换的开销,利用了 GPU 的硬件加速功能,提高性能。

内存环/环形缓冲区 (Circular-Buffer)

直播流是一个不断进行传输、未知总长度的数据源,拉取到的数据在被 Decoder Worker 读取之前会进行暂存,被读取之后需要及时清除或覆盖,否则会导致客户端被占用过多的内存和磁盘资源。

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发
FFmpeg 自定义数据 IO

FFmpeg 允许开发者自定数据 IO 来源,比如文件系统或内存等。在我们的方案中使用内存来向 FFmpeg 发送待解码数据,也就是通过 avio_alloc_context 创建一个 AVIOContext,AVIOContext 结构体定义如下:

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发

buffer 是指向一块自定义的内存缓冲区的指针;

buffer_size 是这块缓冲区的长度;

write_flag 是标识向内存中写数据(1,编码时使用)还是其他,比如从内存中读数据(0,解码时使用);

opaque 包含一组指向自定义数据源的操作指针,是可选参数;

read_packet 和 write_packet 是两个回调函数,分别用于从自定义数据源读取和向自定义数据源写入,注意这两个方法在待处理数据不为空时是循环调用的;

seek 用于在自定义数据源中指定的字节位置。FFmpeg 通过自定义 IO 读取数据进行解码的处理过程如下图所示:

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发
Wasm 体积的优化

FFmpeg 提供了对大量媒体格式的封装/解封装、编码/解码支持,以及对各种协议、颜色空间、过滤器、硬件加速等的支持,可以使用 ffmpeg 命令来详细查看当前 FFmpeg 版本的具体信息。

【第1674期】 花椒前端基于WebAssembly 的H.265播放器研发

由于我们此次主要针对 H.265 的解码进行实践,所以可以在编译时通过参数来定制 FFmpeg 只支持必要的解封装和解码器。不同于常规编译 FFmpeg 时使用的./configure,在编译给 Wasm 调用的 FFmpeg 时需要使用 Emscripten 提供的 emconfigure ./configure:

这样定制后编译的 FFmpeg 版本,与解码器 C 文件合并编译生成的 Wasm 大小为 1.2M,比优化之前的 1.4M 缩小了 15%,提升加载速度。

四、实践结果

实现花椒 Web 端 H.265 直播流解码播放。经测试,在 MacBook Pro 2.2GHz Intel Core i7 / 16G 内存笔记本上,使用 Chrome 浏览器长时间观看直播,内存使用量稳定在 270M ~ 320M 之间,CPU 占用率在 40% ~ 50% 之间。

五、主要参考资料或网站

1.FFmpeg 官网(http://ffmpeg.org/)
2.关于 FFmpeg 不支持 HTTP-FLV/RTMP 的讨论 (http://trac.ffmpeg.org/ticket/6389)
3.WebAssembly 官网 (https://webassembly.org/)
4.谷歌 V8 引擎(https://v8.dev/)
5.Emscripten 官网(https://emscripten.org/)

为你推荐