花椒前端基于WebAssembly 的H.265播放器研发

一、背景介绍

随着近些年直播技术的不断更新迭代,高画质、低带宽、低成本成为直播行业追求的重要目标之一,在这种背景下,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)组成的新的编码架构可以有效地降低码率。

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 播放需要通过软件解码来完成。

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

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

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

  1. video 标签创建一个 DOM 对象,实例化一个 WebMediaPlayer
  2. player 驱使 Buffer 请求多媒体数据
  3. FFmpeg 进行解封装和音视频解码
  4. 把解码后的数据传给相应的渲染器对象进行渲染绘制
  5. 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 共存,允许两者一起工作。近几年已经被各主流浏览器所广泛支持:

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

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

其中第 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 代码进行交互。

三、实践方案

整体架构/流程示意图

涉及技术栈

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。

Decoder 负责与由 FFmpeg 编译生成的 Wasm 发送原始待解码数据和接收已解码后的数据。向 Wasm 发送原始数据时,把每个数据段放进一个 Uint8Array 数组中,用 Module._malloc 分配一块同等长度的 buffer 内存空间,再用 Module.HEAPU8.set 把这段数据写入 buffer 指向的内存空间中,最后把 buffer 在内存中的起始地址和这段数据的长度一起传递给 Wasm。在从 Wasm 接收解码后的数据时,通过在 Decoder 中定义的视频数据回调和音频数据回调两个 Callback 方法接收,之后会通过 postMessage 传送给主线程。

音频解码完成会放到主线程的 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 读取之前会进行暂存,被读取之后需要及时清除或覆盖,否则会导致客户端被占用过多的内存和磁盘资源。

一个可行方法是把每次拉取到的数据段写入到一个环形的内存空间中,由一个 Head 指针指向 Decoder 每次解码所需要读取数据的内存起始地址,再用一个 Tail 指针指向后续流数据段写入的内存地址,并随着解码的进行,不断向后移动两个指针指向的位置,这样就可以让流数据在这个内存环中不断写入、被解码、被覆盖,使得总体内存使用量可控,在直播过程中不会耗费客户端过多的资源。

FFmpeg 自定义数据 IO

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

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

buffer_size 是这块缓冲区的长度;

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

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

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

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

Wasm 体积的优化

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

由于我们此次主要针对 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/)

原文发布于微信公众号 - LiveVideoStack(livevideostack)

原文发表时间:2019-07-17

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券