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 能搞定 B �ilibili、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 里有什么、为什么"重封装不转码"是对的哲学


推荐阅读