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 複雜、實操上卻更好下。


推薦閱讀