tips

HLS 深度:加密、多音轨、值得记住的 EXT-X 标签

M3U8 入门之后的进阶——EXT-X-KEY、EXT-X-MEDIA、不连续性、以及那些能让朴素 HLS 下载器翻车的边界情况。含真实清单片段和 FlowPick 的处理逻辑。
FlowPick 团队
16 分钟阅读
# hls # m3u8 # 深度 # 加密 # 多音轨 # 串流

HLS 入门指南 讲了"什么是 M3U8、怎么下载"。这篇是另一篇——给那些试着下过一个流、拿到一个 200KB 的 .m3u8、里面引用了 8 条音轨、4 种字幕、还有一个让 VLC 吐垃圾的 #EXT-X-KEY 标签的人。

你打开过 Netflix、Disney+ 或付费课程平台的真实 HLS 清单,纳闷为什么你的下载器下出来的是无声视频或损坏文件,原因就在这里。

真实 HLS 清单长什么样

多数教程用这个当例子:

#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:3
#EXTINF:6.0,
segment0.ts
#EXTINF:6.0,
segment1.ts
#EXTINF:4.2,
segment2.ts
#EXT-X-ENDLIST

可爱。真实清单不长这样。这是一个主流平台的真实 master playlist,精简过但能代表全貌:

#EXTM3U
#EXT-X-VERSION:6
#EXT-X-INDEPENDENT-SEGMENTS:yes

# 音频组
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac-64",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,CHANNELS="2",URI="audio/eng_64.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac-128",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,CHANNELS="2",URI="audio/eng_128.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac-64",NAME="Español",LANGUAGE="es",DEFAULT=NO,AUTOSELECT=YES,CHANNELS="2",URI="audio/spa_64.m3u8"
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",LANGUAGE="en",DEFAULT=NO,AUTOSELECT=YES,URI="subs/en.m3u8"
#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc",NAME="CC",LANGUAGE="en",DEFAULT=NO,AUTOSELECT=YES,INSTREAM-ID="CC1"

# 视频变体
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=854x480,CODECS="avc1.64001f,mp4a.40.2",AUDIO="aac-64",SUBTITLES="subs",CLOSED-CAPTIONS="cc"
v_480.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720,CODECS="avc1.640020,mp4a.40.2",AUDIO="aac-128",SUBTITLES="subs",CLOSED-CAPTIONS="cc"
v_720.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2",AUDIO="aac-128",SUBTITLES="subs",CLOSED-CAPTIONS="cc"
v_1080.m3u8

实际媒体分片根本不在这里。master playlist 指向变体 playlist,每个变体再指向分片。音频还单独在自己的 playlist 里。你的下载器要是只抓 v_1080.m3u8 然后拼接分片,得到的就是无声视频。这是"HLS 下载没声音"反馈的头号原因。

CODECS 字段不是装饰

CODECS="avc1.640028,mp4a.40.2" 不是写着好看:

  • avc1.640028 — H.264 High profile、level 4.0。avc1. 后面前两个十六进制位编码 profile(64 = High)、constraint flag、level(28 = 4.0)。
  • mp4a.40.2 — AAC-LC。

为什么重要:某个变体声明 avc1.640028,你却把分片封装进一个 codec box 写错的 MP4(hvc1 而不是 avc1),文件不能播。.ts 分片裸拼接能避开这个问题,因为 MPEG-TS 自带 codec 信息;封装进 MP4 就得解析这个字段、写对 stsd box。

EXT-X-KEY:加密,以及"直接解密"不是一个句子

清单里有这行:

#EXT-X-KEY:METHOD=AES-128,URI="https://cdn.example.com/key.bin",IV=0x1a2b3c4d5e6f7890abcdef1234567890

意味着之后每个分片都用 URI 处的密钥和指定的 IV 做 AES-128-CBC 加密。解密:

const keyResp = await fetch(keyUri)
const keyBytes = await keyResp.arrayBuffer()  // 16 字节
const iv = new Uint8Array(16)
// IV 可能是字面值(如上),也可能从序号推导
const decrypted = await crypto.subtle.decrypt(
  { name: 'AES-CBC', iv },
  await crypto.subtle.importKey('raw', keyBytes, 'AES-CBC', false, ['decrypt']),
  encryptedSegment
)

crypto.subtle.decrypt 是浏览器里的正确工具——不需要 WASM 写 AES,原生 WebCrypto 又快又是 constant-time。

教程不会告诉你的是:METHOD=AES-128 是简单情况。 大多数主流平台用 METHOD=SAMPLE-AES,苹果的 FairPlay DRM 邻近方案。SAMPLE-AES:

  • 每个分片只有部分被加密(视频 NAL unit,音频和 PES 头不加密)
  • key URI 通常返回的是一个被 FairPlay license 交换包过的 EXT-X-KEY 响应——不是裸 16 字节密钥
  • 浏览器标签页里无法解密。这就是 DRM,"FlowPick 下不了这个 Netflix 视频"是特性不是 bug。我们在 流媒体下载合法性指南 里讲了这条法律和技术底线。

我的看法: 哪个下载器声称能处理 FairPlay、Widevine 或 PlayReady,要么在撒谎,要么在大多数司法管辖区违法。FlowPick 明确拒绝碰 SAMPLE-AES 内容。正确行为是大声报错,不是静默产出损坏文件。

EXT-X-MEDIA:音频和字幕轨道

EXT-X-MEDIA 标签是 HLS 多音轨的机制。每个 GROUP-ID 是一组可互换的音频版本;变体 playlist 的 AUDIO 属性指向它用的那组。

对下载器意味着:一次"完整下载"不只是视频分片。是:

  1. 选定变体 playlist 的视频分片
  2. 一条 EXT-X-MEDIA 条目的音频分片(通常是 DEFAULT=YES 那条,但应该让用户选)
  3. 可选的,TYPE=SUBTITLES 的字幕分片
  4. 把它们封装进一个 MP4,写正确的轨道元数据

封装这一步最容易翻车。来自不同 playlist 的 MPEG-TS 分片各有自己的 PTS 时间线;把只有视频的 .ts 和只有音频的 .ts 拼进一个 MP4,你得:

  • 从每个 TS 包解析 PES 头抽 PTS
  • 按 PTS 排序包
  • 把它们写进 MP4,每个轨道的 mdia/minf/stbl 结构要正确

这是 FlowPick 的 WebAssembly FFmpeg 干的活——见 浏览器内合并深度篇。重点:"拼文件"在出现独立音轨那一刻就不灵了。

不连续性标签

#EXT-X-DISCONTINUITY

意思是"时间戳在这之后重置"。常见于:

  • 带广告位的直播(广告有自己的时间线)
  • 不同编码器拼接的清单

无脑拼接分片的下载器,在不连续点会让播放器时间倒流。要么:

  1. 用会处理不连续性的重封装器(FFmpeg 行)
  2. 把不连续性 strip 掉,接受时间戳漂移
  3. 直接跳过广告分片(FlowPick 默认这样做,反正广告多是 2 秒的 .ts 污染流——见 v1.0.0 发布说明 里的尺寸过滤器)

EXT-X-VERSION:值得记住的版本标签

版本加了什么为什么你在意
2I-frame playlistTrick mode(快进/倒退)支持
3浮点 EXTINF低延迟流的亚秒级时长
4独立音频/字幕的 EXT-X-MEDIA多音轨——见上
5EXT-X-KEY 的 IV 可选密钥能从序号推导 IV
6EXT-X-MAPfMP4 的 init 分片——见下一节

看到 #EXT-X-VERSION:6 而变体 playlist 里没有 #EXT-X-MAP,哪里怪怪的。fMP4 HLS 必须有 init 分片;没有它,分片不能播。

EXT-X-MAP 与 fragmented MP4

#EXT-X-MAP:URI="init.mp4"

意思是分片不是 MPEG-TS——是 fragmented MP4(fMP4),init.mp4 含解读它们所需的 ftypmoov box。没有 init 分片,你手里就是裸 moof/mdat box,没有 codec 信息。

合并 fMP4 HLS:

  1. init.mp4——这是输出文件的文件头
  2. 按序号顺序拼接分片的 moof/mdat
  3. 想要 seek 索引的话可选重写 sidx box

这比 TS 重封装简单得多,因为 fMP4 就是为拼接设计的。苹果的 LL-HLS 现在几乎全用 fMP4。

Low-Latency HLS(LL-HLS)

苹果 2019 年的规范扩展加了:

  • EXT-X-PART — 部分分片(通常 200ms 而不是 6s)
  • EXT-X-PRELOAD-HINT — 告诉客户端开始抓还不存在的分片
  • EXT-X-RENDITION-REPORT — 让变体 playlist 直接互相引用

对下载器来说,LL-HLS 主要意味着"更多请求、更小文件"。合并逻辑跟普通 HLS 一样——只是除了 EXTINF 还要处理 EXT-X-PART。FlowPick 处理得了;你自己撸的下载器不处理,就会漏掉部分分片。

我真见过的坑

坑 1:相对 URI。 M3U8 大量用相对 URI。master playlist 在 https://cdn.example.com/v/master.m3u8,里面写 v_720.m3u8,变体就是 https://cdn.example.com/v/v_720.m3u8。但 master 在 https://cdn.example.com/v/master.m3u8?token=abc,token 不会自动传过去。query string 继承要手动处理。

坑 2:字节范围寻址。

#EXT-X-BYTERANGE:522752@1024

意思是"分片是大文件里的一个字节范围"。不带 Range 头去 fetch 这个 URI,你会拿到整个文件。很多 CDN 托管的 HLS playlist 对归档内容这么做——不是几千个小文件,而是一个大文件加字节范围。

坑 3:按 CODECS 选变体。 master playlist 同时有 avc1.*(H.264)和 hvc1.*(HEVC)变体,你的播放器可能选 HEVC。你的下载器只抓第一个变体,可能拿到 HEVC 而你想要的是 H.264。一定让用户选,并解析 CODECS 给变体打标签。

坑 4:token 过期。 URI 里的直播 token 通常几分钟过期。一小时前抓的清单 URL 现在去下,全是 403。FlowPick 在下载时重拉清单,这就是"先抓 URL 后下"在直播内容上经常失败的原因。

参考资料

小结

M3U8 入门叙事——"它是个 playlist,抓分片,拼起来"——覆盖大概 60% 的真实流。剩下 40% 涉及多音轨、加密、fMP4 init 分片、不连续性、字节范围、过期 token。一个稳健的 HLS 下载器得正确解析 EXT-X-MEDIAEXT-X-KEYEXT-X-MAPEXT-X-BYTERANGEEXT-X-DISCONTINUITY。"正确"长什么样,看 FlowPick 源码

下一篇是 DASH/MPD 深度篇,讲 MPEG-DASH 对应的机制——SegmentTemplate$Number$ 替换、ContentProtection,以及为什么 DASH 结构上比 HLS 复杂、实操上却更好下。


推荐阅读