DASH 深度:SegmentTemplate、ContentProtection 與多視角 MPD 結構
MPD 入門指南 講了什麼是 MPD、為什麼跳出來的是 12KB XML 而不是 MP4。這篇是續集——給那些開啟過 MPD、看到 400 行 <SegmentTemplate>、<SegmentTimeline>、<ContentProtection>、<AdaptationSet> 後想知道哪些才是真有用的人。
你在寫 DASH 解析器,或者想知道為什麼你的下載器有的流能下有的下不了,這篇文章就是結構圖。
MPD 的層級結構
每個 MPD 都是這個巢狀結構:
MPD
├── Period # 一段內容(如正片、廣告)
│ ├── AdaptationSet # 一組可互換的版本(影片、音訊等)
│ │ ├── Representation # 一個具體版本(1080p、720p 等)
│ │ │ ├── SegmentList # 顯式列出每個分片 URL
│ │ │ ├── SegmentTemplate # 模板化分片 URL
│ │ │ └── SegmentBase # 單分片 + 位元組範圍
│ │ └── ContentProtection # DRM 信令(Widevine、PlayReady)
│ └── EventStream # 帶外事件(類似 ID3)
一部典型電影的 MPD 有 1 個 Period、2 個 AdaptationSet(影片+音訊)、每 set 5-10 個 Representation。帶廣告的直播可能有 4+ 個 Period。多機位體育直播每個機位一個 AdaptationSet。
核心思想:DASH 一切設計都為了不必列出每個分片的 URL。 HLS 一個一個列,DASH 用模板。
SegmentTemplate:90% 的場景
<SegmentTemplate
initialization="video/init.mp4"
media="video/$Number$.m4s"
startNumber="1"
timescale="1000"
duration="6000" />
翻譯一下:
- 初始化分片是
video/init.mp4(fMP4 的初始化 box) - 每個媒體分片走
video/$Number$.m4s這個模式,$Number$從 1 開始 - 每個分片 6000 毫秒(6 秒,因為
timescale=1000)
下載這個 Representation,把 $Number$ 替換掉生成 URL:
const startNumber = 1
const duration = 6000 // ms
const timescale = 1000 // 1 單位 = 1 ms
const totalDuration = 7200000 // ms — 來自 Representation 的 @duration 屬性
const segmentCount = Math.ceil(totalDuration * timescale / (duration * timescale))
// = 1200 個分片
const urls = []
for (let n = startNumber; n < startNumber + segmentCount; n++) {
urls.push(`video/${n}.m4s`)
}
比 HLS 緊湊得多——1200 行 #EXTINF + URL 被一個 <SegmentTemplate> 替掉。代價是解析器得自己算數學,邊界情況(最後一段更短、分片時長變化)得小心。
$Number$ vs $Time$
$Number$ 是分片索引(1、2、3……)。$Time$ 是分片起始時間(timescale 單位)。有的清單用前者,有的用後者,有的兩個都用:
<SegmentTemplate
media="video/$Time$.m4s"
timescale="1000"
duration="6000" />
解析 $Time$,用分片索引乘時長:
const segmentTime = (n - startNumber) * duration
const url = `video/${segmentTime}.m4s`
忘了乘 timescale,URL 差 1000 倍,全部 404。這是 DASH 解析最常見的 bug。
SegmentTimeline:時長不固定時
直播和動態內容用 <SegmentTimeline> 而不是固定的 duration:
<SegmentTemplate timescale="1000" initialization="init.mp4" media="$Number$.m4s">
<SegmentTimeline>
<S t="0" d="6000" />
<S t="6000" d="6000" />
<S t="12000" d="4000" />
<S t="16000" d="6000" r="3" /> <!-- r=3 表示重複 3 次:共 4 個分片 -->
<S t="40000" d="6000" />
</SegmentTimeline>
</SegmentTemplate>
t— 起始時間(timescale 單位)d— 時長(timescale 單位)r— 重複次數(這條表示r+1個分片)
直播流的 timeline 會隨時間增長。典型流程:
- 客戶端拉 MPD,看到分片 1-50,
minimumUpdatePeriod是 2 秒 - 客戶端播放 1-50
- 客戶端重拉 MPD,現在顯示 1-52(timeline 長了)
- 重複,直到
MPD@endOfAvailability或流結束
下載器一般兩條路:
- 等流徹底結束(轉 VOD)—— 不總可行
- 把當前 timeline 快照下來,接受會錯過後續追加的內容
FlowPick 走快照路線:抓當前 timeline、下這些分片、合併。流還活著,使用者能再觸發一次抓更新的分片。
ContentProtection:DRM 信令
<ContentProtection
schemeIdUri="urn:mpeg:dash:mp4protection:2011"
value="cenc"
cenc:default_KID="12345678-1234-1234-1234-123456789012" />
<ContentProtection
schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
value="Widevine">
<cenc:pssh>AAAA...</cenc:pssh>
</ContentProtection>
<ContentProtection
schemeIdUri="urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95"
value="PlayReady">
<cenc:pssh>BBBB...</cenc:pssh>
</ContentProtection>
UUID 識別 DRM 系統:
edef8ba9-79d6-4ace-a3c8-27dcd51d21ed— Widevine(Google)9a04f079-9840-4286-ab92-e65be0885f95— PlayReady(Microsoft)f239e769-efa3-4850-9c16-a9036d3fe1c1— Adobe Primetime94ce86fb-07ff-4f43-adb8-93d2fa968ca2— FairPlay(Apple,在 DASH 語境下)
<cenc:pssh> 是 Protection System Specific Header——一個 base64 的 blob,含 key ID 和系統特定資料。瀏覽器把它餵給 EME(Encrypted Media Extensions),EME 再跟系統的 CDM(Content Decryption Module)對話。
這裡要劃線: FlowPick 不做、不會做、也做不到解密 DRM 保護的 DASH 內容。任何合法的瀏覽器擴充功能或開源工具都做不到。CDM 是黑盒——你給它加密 buffer,它還你解密後的後的幀,僅用於顯示,永遠不給匯出。繞過這個在反規避法規下違法(美國 DMCA §1201,其他地區類似),還違反 CDM 的合約。
DASH 規範裡有「未受保護」路徑:某個 AdaptationSet 沒有 ContentProtection,分片就是未加密的,下載器能隨便抓和合併。這就是為什麼 FlowPick 能搞定 Bilibili、Udemy 免費課和大多數企業 LMS,但搞不了 Netflix/Disney+/HBO Max。
「感覺像保護」但不是 DRM 的常見模式
不是所有「下不了」都是 DRM。有些模式讓下載很難,但沒真加密:
- 短 TTL 的 token URL —
?token=abc&expires=1234567890。token 幾秒就過期,解析完清單必須立刻抓分片。FlowPick 這麼幹;那種「提前幾小時解析 URL 再下」的批次下載器會撲空。 - CDN 的 CORS 限制 — 但擴充功能從頁面 origin 發請求不受 CORS 約束,所以基本攔不住嗅探器。
<BaseURL>重寫 — MPD 的BaseURL元素可以在文件中間變。每個BaseURL相對其父級解析,所以得追蹤 URL 鏈。這是 DASH 版的 HLS 相對 URI,但更容易出錯。
多 Period MPD:廣告和直播事件
<Period id="ad1" start="PT0S" duration="PT30S">...</Period>
<Period id="main" start="PT30S">...</Period>
<Period id="ad2" start="PT1800S" duration="PT30S">...</Period>
<Period id="main2" start="PT1830S">...</Period>
每個 Period 有自己的 AdaptationSet。下載整個內容:
- 按
@start排序 Period - 每個 Period 下所有影片+音訊分片
- Period 之間拼接——但要留意 codec 在 Period 之間變化
廣告時段常用不同 codec(廣告可能是 H.264 baseline,正片是 H.264 high)。無腦拼接出來的 MP4 正片正常播,到廣告段就花屏。
修復辦法:
- 直接跳過廣告 Period(FlowPick 的做法——反正使用者也不想要廣告)
- 把廣告 Period 重編碼到跟正片一致(慢,要轉碼)
- 用更聰明的封裝器處理 codec 切換(FFmpeg 能,但複雜)
多視角和 trick-mode AdaptationSet
<AdaptationSet id="1" contentType="video" group="1">
<!-- 主機位 -->
</AdaptationSet>
<AdaptationSet id="2" contentType="video" group="1">
<!-- 備用機位,同 group -->
</AdaptationSet>
<AdaptationSet id="3" contentType="video" group="2">
<!-- Trick mode(進度條縮圖) -->
</AdaptationSet>
group="1" 表示這些 AdaptationSet 是同一內容的不同視角。播放器讓使用者切機位。下載器得選一個——通常是第一個,但理想是讓使用者選。
Trick-mode AdaptationSet(這裡的 group=2)是進度條上拖動預覽用的小體積低幀率版本。下載它沒用——確保你的解析器不會把它誤認為「影片」抓走。
預取與並行
DASH 下載速度上最實在的最佳化:分片並行抓。
async function downloadRepresentation(rep, concurrency = 6) {
const segmentUrls = computeSegmentUrls(rep)
const initBytes = await fetch(rep.initUrl).then(r => r.arrayBuffer())
const results = new Array(segmentUrls.length)
let nextIndex = 0
async function worker() {
while (nextIndex < segmentUrls.length) {
const i = nextIndex++
const resp = await fetch(segmentUrls[i])
results[i] = new Uint8Array(await resp.arrayBuffer())
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()))
// 拼接:init + 按序分片
const total = initBytes.byteLength + results.reduce((s, b) => s + b.byteLength, 0)
const out = new Uint8Array(total)
let offset = 0
out.set(new Uint8Array(initBytes), offset); offset += initBytes.byteLength
for (const buf of results) {
out.set(buf, offset); offset += buf.byteLength
}
return out
}
6 個並行連線是甜點——更多會觸發 CDN 限速,更少浪費頻寬。Promise.all worker 池模式保證分片亂序完成時也能落到正確的陣列位置。
這就是 FlowPick 內部用的模式;瀏覽器內合併那篇 講最終 buffer 怎麼封裝。
踩過的坑
坑 1:忽略 BaseURL。 MPD 在 https://cdn.example.com/v/manifest.mpd,裡面是 <BaseURL>https://other-cdn.example.com/</BaseURL>,那麼這一段裡所有相對 URL 都按 other-cdn.example.com 解析,不是按 MPD 的 origin。忘了這個,是「所有分片都 404」bug 的頭號原因。
坑 2:timescale 解析錯。 duration="6000" 配 timescale="1000" 是 6 秒。配 timescale="90000"(影片常見)是 0.067 秒。一定查 timescale——同一個 MPD 裡不同 AdaptationSet 可能不一樣。
坑 3:忘了 init 分片。 HLS(TS 情況下)每個分片自描述,DASH 的 fMP4 分片沒 init 分片就是廢物。下載「看起來成功了」但出來的檔案不能播,多半是漏了 initialization="init.mp4"。
坑 4:直播 MPD 會更新。 解析一次就按快照下,後續追加的分片會漏。VOD 沒問題。直播要嘛定期重拉,要嘛等流結束。
參考資料
- ISO/IEC 23009-1 — DASH 規範 — 正式規範,付費但權威
- DASH-IF 實施指南 — DASH Industry Forum 出的指南,免費,比 ISO 文件實用
- GPAC MP4Box 文件 — 參考級 MP4/DASH 工具,拿來驗證你的解析器
- Bento4 MP4 文件 — 開源 MP4 工具包,理解容器格式有用
小結
DASH 和 HLS 解決的是同一個問題——基於 HTTP 的自適應流——但思路相反。HLS 把每個分片列出來;DASH 用模板。HLS 預設用 MPEG-TS;DASH 用 fMP4。HLS 的 DRM 是 Apple 私有的(FairPlay);DASH 的 DRM 多廠商(Widevine、PlayReady、FairPlay)走 CENC。
下載器角度,DASH 結構上更好搞:fMP4 分片能直接拼、init 分片把 codec 資訊顯式化、$Number$ 替換意味著不用拉額外清單就能算出 URL。複雜度在 XML 解析——但只要搞定 SegmentTemplate、SegmentTimeline、BaseURL、ContentProtection,就覆蓋了 95% 的真實 MPD。
下一篇進容器格式本身——fMP4 拼接為什麼能行、moof box 裡有什麼、為什麼「重封裝不轉碼」是對的哲學。
推薦閱讀
- 什麼是 DASH 串流媒體?MPD 檔案解析 — 這篇的入門版
- HLS 深度:加密、多音軌、值得記住的 EXT-X 標籤 — HLS 那邊的對應物
- 瀏覽器端影片重封裝:fMP4、ISOBMFF 和不轉碼的理由 — DASH 分片下完之後會發生什麼
- 串流媒體下載到底合不合法?一份技術和法律清單 — 為什麼 ContentProtection 標籤是硬底線
- FlowPick 是怎麼在瀏覽器裡把幾百個影片分片合成 MP4 的 — 這裡講的模式在 FlowPick 裡怎麼落地
- WebCodecs + Web Workers + OPFS:瀏覽器影片處理實戰 — 跟 FFmpeg WASM 互補的更底層瀏覽器 API