tips

DASH 深度:SegmentTemplate、ContentProtection 與多視角 MPD 結構

MPD 入門之後的進階——SegmentTemplate 對比 SegmentTimeline、$Number$ 對比 $Time$、ContentProtection 信令,以及 DASH 比 HLS 難解析卻好下載的結構性原因。
FlowPick 團隊
15 分鐘閱讀
# dash # mpd # 深度 # segmenttemplate # drm # 串流

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 會隨時間增長。典型流程:

  1. 客戶端拉 MPD,看到分片 1-50,minimumUpdatePeriod 是 2 秒
  2. 客戶端播放 1-50
  3. 客戶端重拉 MPD,現在顯示 1-52(timeline 長了)
  4. 重複,直到 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 Primetime
  • 94ce86fb-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。有些模式讓下載很難,但沒真加密:

  1. 短 TTL 的 token URL?token=abc&expires=1234567890。token 幾秒就過期,解析完清單必須立刻抓分片。FlowPick 這麼幹;那種「提前幾小時解析 URL 再下」的批次下載器會撲空。
  2. CDN 的 CORS 限制 — 但擴充功能從頁面 origin 發請求不受 CORS 約束,所以基本攔不住嗅探器。
  3. <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。下載整個內容:

  1. @start 排序 Period
  2. 每個 Period 下所有影片+音訊分片
  3. Period 之間拼接——但要留意 codec 在 Period 之間變化

廣告時段常用不同 codec(廣告可能是 H.264 baseline,正片是 H.264 high)。無腦拼接出來的 MP4 正片正常播,到廣告段就花屏。

修復辦法:

  1. 直接跳過廣告 Period(FlowPick 的做法——反正使用者也不想要廣告)
  2. 把廣告 Period 重編碼到跟正片一致(慢,要轉碼)
  3. 用更聰明的封裝器處理 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 沒問題。直播要嘛定期重拉,要嘛等流結束。

參考資料

小結

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 解析——但只要搞定 SegmentTemplateSegmentTimelineBaseURLContentProtection,就覆蓋了 95% 的真實 MPD。

下一篇進容器格式本身——fMP4 拼接為什麼能行、moof box 裡有什麼、為什麼「重封裝不轉碼」是對的哲學


推薦閱讀