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 能搞定 B �ilibili、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