流媒体是如何播放的

之前计划模仿「在浏览器中输入 google.com 并按下回车之后发生了什么?」来具体描述一下流媒体播放的详细过程,但是因为一些客观因素,对音视频的挖掘需要暂时搁置了,所以写这篇文章对流媒体的播放先做一个笼统的介绍。

1. 视频的预处理

流媒体的最上游数据,就是导演制片人剪辑之后的母盘了,可以理解为拍摄过程中导演眼中的原始影像。虽然母盘保留了最原始的画质音质,但是受限于文件体积,直接交付给消费者是不现实的,想象一下如果每一部电影都是 100GB 的大小,那大概只能采用邮寄蓝光盘的方式了。

因为文件大小的限制,首先需要对原始视频进行压缩。需要明确的一点是:流媒体的压缩都是有损压缩,不论画质多好,比特率多高,都仍然是有损压缩。视频压缩的生成文件越小,越有利于流媒体传输;但是另一方面在相同的视频编码和分辨率下,越小的文件也意味着更差的画质,所以这是一个取舍平衡的问题。为了衡量最终压缩的效果,业内有一些参数,其中比较知名的是 VMAF,是 Netflix 对于压缩后画质的一个评估参数。

视频压缩的画质和比特率并不是线性关系,一开始比特率提升对画质有很大影响,但是当画质足够好之后,更高的比特率对画质的影响人眼已经几乎分辨不出来了。这就是为什么流媒体都是有损压缩,达到一个「足够好」的画质之后,更高比特率对画质几乎没有影响了,一般认为 VMAF 打分 93 就已经是足够好的画质了。

这里所说的视频压缩,其实是 encode 的过程,不同的 codec 有不同的压缩效果。对于视频而言 H.265 / AV1 相比 H.264 压缩效果就会更好。那么是不是我们可以只选用压缩效果更好的 codec 呢?很遗憾不是的,压缩算法是不断迭代的过程,新的压缩算法提出来后,需要若干年才能成为主流,市面上的大多数设备并没有集成最新的 decoder 没有能力硬件解码,我们也当然不会在播放中用 CPU 软件解码,否则设备发热、能耗等问题会影响用户体验。

除了 codec,还需要考虑不同的设备分辨率,当然因为播放设备的屏幕千奇百怪有不同的宽高比,我们只需要支持主流分辨率,比如 16:9 下的 480P、720P、1080P、4K。因为在一个 1080P 的设备上播放 4K 影片并没有意义,选择设备支持的分辨率可以节省带宽。

预处理还有一个环节叫作 Mux,意思是把 video、audio、subtitle 这些数据封装进一个文件里,这个文件一般称之为 container。我们常见的 MP4、MKV 都是 container,可以支持非常多的不同的 audio / video codec,可以想象 container 是一个集装箱,不同的 codec 是里面需要网络传输的货物。

2. 切分 segments

经过上一步的预处理,我们得到了一组 container,是不同的 codec 和分辨率的组合,但是这仍然不能直接传输给播放器。因为即使是压缩后的视频,仍然是非常大的。用一些字幕组压制的高清电影举例,一部 2 小时 1080P 的蓝光电影,H.264 大概是 10G,H.265 大概是 5G,仍然不能直接 HTTP 传输。

业内的解决方案是,我们可以把整部电影进行切分,切出来的每一个 segment 只有 5s 左右的时长。那么播放器在播放的过程中就可以像滑动窗口一样维护一个 buffer,窗口左边不断播放消耗 buffer,右边不断请求 server 拿到后续 segment 填充 buffer。这样播放器起播只需要下载第一个 segment 获得更短的起播时间,从中间某一点开始播放或者 seek 也只需要从对应的 segment 开始下载。

流媒体的播放并不是电影院从 0 到结尾彩蛋的过程,中间会有用户的不同操作。切分视频让网络传输以 segment 为单位而不是整个文件,提高了带宽的利用率。

3. 流媒体协议

上一步的视频切分之后,播放器可以直接获取 segment 播放了,但是正如之前说过的,不同的设备有不同的特性,比如支持的 decoder、设备分辨率等都不一致。在播放器请求 segment 之前,仍然需要 server 提供可选的 codec、可选的分辨率。

不同的流媒体协议给出了不同的答案。

常见的流媒体协议是 HLS 和 DASH。

HLS 中使用以 m3u8 结尾的 manifest 文件声明这些 metadata,而 DASH 使用的是 XML 格式的 MPD。虽然格式不同,但是我们可以认为是逻辑上相同的信息,使用了不同的表现格式。

这些 manifest 需要包含的信息有:

对于某一个具体的 video track,还需要列出所有的 segment 起始时间、长度等。 比如对于这个 HLS 的 primary manifest1,有 3 个不同分辨率的 video track 和 1 个英语 subtitle track:

 1#EXTM3U
 2#EXT-X-VERSION:3
 3#EXT-X-INDEPENDENT-SEGMENTS
 4#EXT-X-STREAM-INF:BANDWIDTH=2665726,AVERAGE-BANDWIDTH=2526299,RESOLUTION=960x540,FRAME-RATE=29.970,CODECS="avc1.640029,mp4a.40.2",SUBTITLES="subtitles"
 5index_1.m3u8
 6#EXT-X-STREAM-INF:BANDWIDTH=3956044,AVERAGE-BANDWIDTH=3736264,RESOLUTION=1280x720,FRAME-RATE=29.970,CODECS="avc1.640029,mp4a.40.2",SUBTITLES="subtitles"
 7index_2.m3u8
 8#EXT-X-STREAM-INF:BANDWIDTH=995315,AVERAGE-BANDWIDTH=951107,RESOLUTION=640x360,FRAME-RATE=29.970,CODECS="avc1.4D401E,mp4a.40.2",SUBTITLES="subtitles"
 9index_3.m3u8
10#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subtitles",NAME="caption_1",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO,LANGUAGE="eng",URI="index_4_0.m3u8"

而对于 video track index_1,又可以得到这样的 media manifest 列出 segment:

 1#EXTM3U
 2#EXT-X-VERSION:3
 3#EXT-X-TARGETDURATION:7
 4#EXT-X-MEDIA-SEQUENCE:8779957
 5#EXTINF:6.006,
 6index_1_8779957.ts?m=1566416212
 7#EXTINF:6.006,
 8index_1_8779958.ts?m=1566416212
 9#EXTINF:5.372,
10index_1_8779959.ts?m=1566416212
11#EXT-OATCLS-SCTE35:/DAlAAAAAsvhAP/wFAXwAAAGf+/+AdLfiP4AG3dAAAEBAQAAXytxmQ==
12#EXT-X-CUE-OUT:20.020
13#EXTINF:0.634,
14index_1_8779960.ts?m=1566416212
15#EXT-X-CUE-OUT-CONT:ElapsedTime=0.634,Duration=21,SCTE35=/DAlAAAAAsvhAP/wFAXwAAAGf+/+AdLfiP4AG3dAAAEBAQAAXytxmQ==
16#EXTINF:6.006,
17index_1_8779961.ts?m=1566416212
18#EXT-X-CUE-OUT-CONT:ElapsedTime=6.640,Duration=21,SCTE35=/DAlAAAAAsvhAP/wFAXwAAAGf+/+AdLfiP4AG3dAAAEBAQAAXytxmQ==
19#EXTINF:6.006,
20index_1_8779962.ts?m=1566416212
21#EXT-X-CUE-OUT-CONT:ElapsedTime=12.646,Duration=21,SCTE35=/DAlAAAAAsvhAP/wFAXwAAAGf+/+AdLfiP4AG3dAAAEBAQAAXytxmQ==
22#EXTINF:6.006,
23index_1_8779963.ts?m=1566416212
24#EXT-X-CUE-OUT-CONT:ElapsedTime=18.652,Duration=21,SCTE35=/DAlAAAAAsvhAP/wFAXwAAAGf+/+AdLfiP4AG3dAAAEBAQAAXytxmQ==
25#EXTINF:1.368,
26index_1_8779964.ts?m=1566416212
27#EXT-X-CUE-IN
28#EXTINF:4.638,
29index_1_8779965.ts?m=1566416212
30#EXTINF:6.006,
31index_1_8779966.ts?m=1566416212
32#EXTINF:6.006,
33index_1_8779967.ts?m=1566416212
34#EXTINF:6.006,
35index_1_8779968.ts?m=1566416212

DASH 的 MPD,可以参考 VOD DASH manifest examples

HLS 的 m3u8 可以认为是纯文本,而 DASH 因为是基于 XML 格式,可以通过嵌套缩进提供多维的信息。所以 DASH 并没有 primary manifest 和 media manifest 的区分,只有一个 MPD 就够了。

考虑一下播放器的起播流程,对于 HLS 需要先请求 primary manifest,成功后请求 media manifest,然后才能下载 segment,这是 3 次请求的串联; 但是对于 DASH,请求一次 MPD,直接下载 segment,相比 HLS 会少一次请求。HLS 是 Apple 开发的,而 DASH 背后是多个公司指定的标准,同时 DASH 推出相对比较晚,也算是站在前人的肩膀上了。

那么是不是应该永远用 DASH 呢?很遗憾不可以,在 iOS 设备上因为 DRM 的原因,只能使用 HLS。

4. ABR 自适应码率

之前提到了播放器会选择设备支持的分辨率、codec 对应的 media manifest,但是设备的网速往往是变化的,尤其对于家庭宽带来说,其他设备的下载也会影响播放设备的带宽。为了解决这个问题,业内引入了自适应码率的概念,即 ABR。

就像播放器可以选择设备支持的最大分辨率一样,播放器同时可以感知设备当前的带宽,那么如果提供不同的码率,播放器也就有能力在当前带宽限制下,选择最清晰的流,相当于尽最大努力播放高画质的流。

因为视频已经被切分成了多个 segment,那么这种码率的变化就以 segment 为粒度,带宽受限时,切换到另一个 media manifest,而当网络慢慢变好时,码率再缓慢爬升。

ABR 做的事情就是尽最大努力播放高画质的流,在 rebuffer 的边缘疯狂试探,但是要避免 rebuffer。

5. DRM

和其他数字载体一样,流媒体需要 DRM 来保护内容,用户有权观看数据,但不能拥有数据。所有的 segment 是被加密过的,播放器需要请求 license server 获取 key 来解密,解密的操作也并不在应用层。

这就是为什么当 Android 设备 root 之后,Widevine 会从 L1 掉到 L3,Netflix 只能观看 720P 的画质,因为此时硬件已经不可信了。

DRM 是另一个很广的话题,流媒体也有非常多的 DRM 实现,比如 Apple FairPlay、Google Widevine、Microsoft PlayReady 等。

6. Demux

Demux 可以认为是 Mux 的一个逆向过程,从一个 container 中拆分出 audio、video 数据。常用的 container MP4 其实是由许多 Box 组成的,不同的 Box 封装不同的数据。比如 moov 存储 metadata,tkhd 存储 Track Header 等。

可以简单示范 MP4 的组成,我们首先用 ffmpeg 生成一个简单的视频文件:

1$ export FONT_PATH='/System/Library/Fonts/Avenir Next.ttc'
2$ ffmpeg -f lavfi -i color=c=black:s=1920x800:d=42 -vf "drawtext=fontfile=$FONT_PATH:fontsize=30:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2:text='Main Content" foo.mp4

这会生成一个黑色背景、1920x800 分辨率、长度 42s,居中显示白色文字 Main Content 的 foo.mp4 文件,甚至还指定了 Avenir Next 字体。

之后使用 mp4dump 2查看内部的数据:

 1$ mp4dump foo.mp4
 2[ftyp] size=8+24
 3  major_brand = isom
 4  minor_version = 200
 5  compatible_brand = isom
 6  compatible_brand = iso2
 7  compatible_brand = avc1
 8  compatible_brand = mp41
 9[free] size=8+0
10[mdat] size=8+69742
11[moov] size=8+13403
12  [mvhd] size=12+96
13    timescale = 1000
14    duration = 42000
15    duration(ms) = 42000
16  [trak] size=8+13190
17    [tkhd] size=12+80, flags=3
18      enabled = 1
19      id = 1
20      duration = 42000
21      width = 1920.000000
22      height = 800.000000
23    [edts] size=8+28
24      [elst] size=12+16
25        entry_count = 1
26        entry/segment duration = 42000
27        entry/media time = 1024
28        entry/media rate = 1
29    [mdia] size=8+13054
30      [mdhd] size=12+20
31        timescale = 12800
32        duration = 537600
33        duration(ms) = 42000
34        language = und
35...

7. Decode

拿到 video 的数据后,就可以 decode 从而得到多个帧,这个操作是硬件来完成的,decode 是 encode 的逆向过程。

视频 encode 压缩的过程,并不是在每帧内部独立压缩,需要参考相邻的帧。比如在一个固定场景中人物说话,那么场景的部分像素是完全不变的,没有必要每帧都保留,可以只记录当前帧相比于前一帧变化的部分,所以压缩算法对帧进行了分类:

所以我们可以看出 I 帧是 decode 必不可少的,还有一种特殊的 I 帧叫作 IDR 帧,表示 IDR 之后的所有帧,都不再依赖于 IDR 之前的 I 帧,这个 IDR 之前的数据,对于 decode 就没用了。

那么为什么会区分 P 帧和 B 帧呢?B 帧似乎功能更强大,没必要使用 P 帧了?

B 帧确实可以带来更好的压缩效率,但是代价是会让编解码更复杂,造成更长的编解码时间。在没有 B 帧的情况下,我们可以认为 decode 就是按照帧的显示顺序,专业点说 DTS 是 PTS,但是引入 B 帧之后就不一定了,decode 的拓扑排序会更复杂。

压缩效率和编解码时间也是一个权衡的过程,如果没有 B 帧压缩效率已经足够好了,没有必要追求极致压缩而导致编解码耗时更长。

8. Render

渲染只是把数据喂给硬件,但是在这一步会有很重要的 AV sync 的问题。上一节解释了 video 的 decode 时间并不等于渲染时间,而音频基于固定的采样率、采样深度、频道数得到的 PCM 会以 ByteArray 的形式衡速消耗。

AV sync 要解决的问题是,如何保证在音频播放到某个位置时,显示器也在渲染对应的帧。

9. MISC

9.1 VoD & Live

之前所描述的,都是 VoD 形式的流媒体,在播放之前所有的 media resource 都可用了,生成的 manifest 也是固定的。

还有一种是 Live 形式的,电视台采集画面,服务器立刻切分 segment 再更新 manifest。这种时候 HLS 会要求每隔一段时间必需重新下载 media manifest 以获取最新的 segment 列表,DASH 也会在 MPD 里声明属性要求多久刷新 MPD。在 Live 中需要注意 Live Head 的问题,需要保证播放器的进度总是慢于 server 生成的进度。

另一种特殊的 Live 称之为 Event,用 RxJava 打比方来说 VoD 相当于 cold stream,每次订阅得到的都是之前相同的数据;而 Live 则是带缓存的 hot stream,播放器可以从 0 开始源源不断地播放新数据。Event 特殊在它是有结束的,到了某个时刻就不会再有新数据生成了,而 Live 真的是永远没有 onComplete 事件,会一直播下去。

所以 Live 和 Event 都需要不断请求以更新 manifest,获取最新的 segments,而 VoD 和 Event 总会结束。

9.2 评估流媒体的指标

有一些指标用来评估 playback:


参考链接:


  1. HLS 样例来自 HLS manifest examples ↩︎

  2. mp4dump 是 Bento4 的工具,需要先安装 bento4。 ↩︎