我们做 HLS 分发的时候,切片格式一直是
.ts,用了好几年没出过问题。后来客户提了个需求:"直播延迟能不能压到 5 秒以内?" 我去查 LL-HLS(Low-Latency HLS)的资料,第一条要求就是:必须用 fMP4,不能用 TS。
我当时有点懵——TS 用得好好的,为什么非要换?而且苹果自己当年选 TS 是有原因的(兼容 MPEG-TS 的广播设备)。查了一圈资料和实测之后,我把这事从头到尾搞明白了:TS 的封装开销、音视频分离、以及无法和 DASH 共用文件这三件事,在大规模分发和低延迟场景下都成了硬伤。
这篇写 fMP4 到底改了什么、CMAF 为什么能让 HLS 和 DASH 共用一份文件、低延迟是怎么来的,以及迁移时要踩的坑。
TL;DR:fMP4(fragmented MP4)把 MP4 拆成"初始化段 + 若干媒体段",每段自带索引,可以音视频同段、开销比 TS 小、支持低延迟和复杂多音轨。CMAF 是一套 fMP4 的约束规范,让 HLS 和 DASH 用同一份分片文件(只写不同的播放列表),省一半存储和 CDN 缓存。低延迟(LL-HLS)靠"部分分片"(partial segment /
EXT-X-PART)把最小可播放单元从 6 秒降到几百毫秒。三个注意点:fMP4 需要#EXT-X-MAP指向初始化段;老设备(iOS 9 及更早)只支持 TS;真正的 LL-HLS 打包一般要用专门的打包器(ffmpeg 支持有限)。
目录
- 一、先说 TS:它当年为什么被选中
- 二、TS 的三个硬伤
- 三、fMP4 的结构:init 段 + 媒体段
- 四、fMP4 好在哪
- 五、CMAF:让 HLS 和 DASH 共用一份文件
- 六、低延迟是怎么来的(LL-HLS)
- 七、ffmpeg 怎么生成 fMP4 / CMAF
- 八、验证与调试
- 九、什么时候别用 fMP4
- 十、坑清单
一、先说 TS:它当年为什么被选中
HLS 是苹果 2009 年推出的。当时选 MPEG-TS 作为切片格式,理由很充分:
- 广播行业标准:TS 是数字电视(DVB/ATSC)的标准封装,现成的设备、工具、编解码器都支持;
- 抗误码:TS 包固定 188 字节,带同步字节,丢包后能快速重新同步;
- 流式友好:可以任意位置开始解码(每个包自带 PID),不需要完整的头部信息;
- 当时 MP4 不适合流式:传统 MP4 需要完整的
moov(索引)才能播放,而moov通常在文件末尾——这对"边下边播"是致命的。
所以当年的选择是对的。问题在于,十几年后场景变了。
二、TS 的三个硬伤
1. 封装开销
TS 包是固定 188 字节,其中 4 字节是包头(同步字节 + PID + 各种标志)。加上:
- 每个分片都要重复 PAT(节目关联表)和 PMT(节目映射表);
- 音视频分离成不同的 PID 流,各有各的包头;
- 时间戳(PCR/PTS/DTS)重复写入。
实测(6 秒切片):
| 内容 | TS 封装开销 | fMP4 封装开销 |
|---|---|---|
| 1080p 4.5 Mbps | 约 3.8% | 约 0.6% |
| 360p 500 kbps | 约 8.5% | 约 1.2% |
| 纯音频 128 kbps | 约 11% | 约 2% |
码率越低、切片越短,TS 的开销占比越大。对大规模分发来说,3~8% 的带宽是实打实的成本。
2. 音视频必须分离
TS 里音视频是两个独立的 PID 流,播放器要分别解析。fMP4 里音视频可以在同一个片段(fragment)里,带来两个好处:
- 切换码率时音视频同步更简单;
- 可以做"单文件多音轨/多字幕",管理更简单。
3. 无法和 DASH 共用文件
这是最致命的:DASH 从一开始就用 fMP4(ISO BMFF),HLS 用 TS。要做"一套文件服务两种协议",只能存两份——存储 ×2、CDN 缓存命中率减半(因为同一内容有两个 URL)。
规模大了之后这一条非常贵。
三、fMP4 的结构:init 段 + 媒体段
传统 MP4 的结构:
ftyp (文件类型)
moov (元数据/索引:所有帧的偏移、大小、时间戳)
mdat (媒体数据:所有的帧)
问题:moov 只有一份,且在前面(faststart)或后面,反正要全部拿到才能播。
fMP4 把 mdat 拆成很多小段,每段前面配一个 moof 索引:
ftyp
moov ← 只放"轨道声明"(trak 里没有 sample table),加一个 mvex(表示后面还有分片)
└── mvex ← 关键:告诉播放器"这是分片文件,后面会有 moof"
moof + mdat ← 分片 1(6 秒)
moof + mdat ← 分片 2(6 秒)
moof + mdat ← 分片 3
...
实际部署时,通常拆成两个文件:
| 文件 | 内容 | 说明 |
|---|---|---|
init segment(init.mp4) |
ftyp + moov(含 mvex) |
只下载一次,播放器先拿它 |
media segment(seg-1.m4s) |
moof + mdat |
每个分片一个文件 |
HLS 的播放列表里用 #EXT-X-MAP 指向 init 段:
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MAP:URI="init.mp4" ← 关键这一行
#EXTINF:6.00000,
seg-1.m4s
#EXTINF:6.00000,
seg-2.m4s
为什么拆成两个文件而不是一个? 因为 init 段在所有分片之间是共享的,CDN 缓存一次就行,每个媒体分片只带自己的数据——省掉重复的 moov(对一个两小时的视频,moov 可能有几百 KB)。
box 层级
moof (movie fragment)
├── mfhd (分片序号)
└── traf (track fragment)
├── tfhd (轨道信息:默认 sample 大小/时长)
├── tfdt (分片的解码时间) ← 关键:分片的时间基准
└── trun (每个 sample 的大小和时长)
mdat (该分片的媒体数据)
tfdt(Track Fragment Decode Time)很重要:它是分片的起始时间戳。直播中断流、时间戳跳变这类问题,查 tfdt 就能确认。
四、fMP4 好在哪
| 方面 | TS | fMP4 |
|---|---|---|
| 封装开销 | 3~11% | 0.5~2% |
| 音视频 | 分离 PID | 可同段 |
| 与 DASH 共用 | 不行 | 可以(CMAF) |
| 低延迟支持 | 不行 | 可以(partial segment) |
| 多音轨/字幕 | 麻烦 | 原生支持 |
| 单文件可寻址 | 不行 | 可以(sidx + 字节范围) |
| 老设备兼容 | 更好 | iOS 10+ / 现代浏览器 |
还有一个容易被忽略的好处:可以用字节范围请求。fMP4 支持 sidx(Segment Index)box,客户端可以用 HTTP Range 请求一个大文件里的某一段——不用真的切成一堆小文件。这对存储管理和 CDN 缓存都更友好(少了几百万个小文件)。
五、CMAF:让 HLS 和 DASH 共用一份文件
CMAF(Common Media Application Format) 不是一种新格式,而是一套 fMP4 的约束规范:规定分片该怎么切、命名怎么定、加密怎么做(CENC 通用加密),从而让 HLS 和 DASH 能用完全相同的分片文件。
架构:
┌─── master.m3u8 (HLS)
CMAF 分片文件 ──┤
(init.mp4 + │
seg-N.m4s) └─── manifest.mpd (DASH)
只有播放列表是两份,媒体文件只有一份。
收益(我们的实测,一个 200 部片子的库):
| 指标 | 两套文件(TS + fMP4) | CMAF 一套 |
|---|---|---|
| 存储 | 100% | 52% |
| CDN 回源量 | 100% | 55%(缓存命中率提升) |
| 转码+打包耗时 | 100% | 65% |
CDN 缓存命中率的提升往往比存储节省更值钱——同一个内容只有一套 URL,缓存不会被"两个 URL 各存一份"稀释。
加密也能共用
CMAF 规定了 CENC(Common Encryption):HLS 的 SAMPLE-AES 和 DASH 的 cenc 用同一套加密数据和不同的密钥获取方式。所以加密内容也能一套文件服务两个协议(DRM 系统不同,但密文一样)。
六、低延迟是怎么来的(LL-HLS)
先看传统 HLS 的延迟构成:
采集+编码 0.5 ~ 2 s
切片(6 秒) 6 s ← 最大头:必须攒够一个完整分片才能发布
CDN 分发 0.3 ~ 1 s
客户端缓冲 6 ~ 10 s ← 通常缓冲 2~3 个分片
─────────────────────────
合计 12 ~ 20 s
LL-HLS 的核心思路:把"最小可发布单元"变小。
它引入 partial segment(部分分片):一个 6 秒的分片被切成 30 个 200ms 的 part,每生成一个 part 就立刻发布。
#EXT-X-PART-INF:PART-TARGET=0.200
#EXT-X-PART:DURATION=0.200,URI="seg-1.1.m4s" ← 200ms 就能发
#EXT-X-PART:DURATION=0.200,URI="seg-1.2.m4s"
...
#EXTINF:6.00000,
seg-1.m4s ← 完整分片
其余优化:
| 机制 | 作用 |
|---|---|
| Blocking playlist reload | 客户端请求播放列表时,服务器hold 住不返回,直到有新分片(省掉轮询间隔) |
EXT-X-PRELOAD-HINT |
告诉客户端"下一个 part 的 URL 会是这个",客户端可以提前发请求 |
| 增量播放列表更新 | 只发变化的部分,而不是整个列表 |
| 减少客户端缓冲 | 从缓冲 3 个分片改成缓冲 1~2 个 part |
实测延迟:
| 方案 | 典型延迟 |
|---|---|
| 传统 HLS(6s 切片) | 12~20 s |
| 优化过的 HLS(2s 切片 + 4s 缓冲) | 6~8 s |
| LL-HLS(200ms part) | 2~5 s |
| LL-DASH(CMAF chunk) | 2~5 s |
| WebRTC / SRT | < 1 s(但代价大) |
代价要说清楚:
- CDN 必须支持(blocking reload 需要 CDN 配合 hold 请求,很多 CDN 不支持或者会超时);
- 请求量暴增:每 200ms 一次播放列表请求 × 每个观众 = CDN 请求量涨几十倍;
- 客户端兼容性:需要支持 LL-HLS 的播放器(hls.js 有 partial 支持,Safari 原生支持,很多老播放器不支持);
- 卡顿率上升:缓冲少了,网络抖动更容易卡顿。
我的建议:
- 如果目标延迟是 5~8 秒,用"短切片(2~4 秒)+ 减少缓冲"就够了,不用上 LL-HLS,成本低得多;
- 如果必须 3 秒以内(互动直播、竞猜、拍卖),再上 LL-HLS,并且要评估 CDN 成本。
七、ffmpeg 怎么生成 fMP4 / CMAF
HLS + fMP4
ffmpeg -i input.mp4 \
-c:v libx264 -profile:v high -level 4.1 -preset slow \
-b:v 4500k -maxrate 5400k -bufsize 9000k \
-g 120 -keyint_min 120 -sc_threshold 0 \ # 4 秒 GOP(30fps)
-c:a aac -b:a 128k -ar 48000 \
-f hls \
-hls_time 4 \
-hls_segment_type fmp4 \ # ← 关键:fMP4 而不是 mpegts
-hls_fmp4_init_filename init.mp4 \ # ← init 段文件名
-hls_segment_filename 'seg_%03d.m4s' \
-hls_playlist_type vod \
-master_pl_name master.m3u8 \
out.m3u8
生成的播放列表:
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MAP:URI="init.mp4"
#EXTINF:4.00000,
seg_000.m4s
...
-hls_segment_type fmp4 是关键的一行,不加的话默认是 mpegts。
DASH + fMP4
ffmpeg -i input.mp4 \
-c:v libx264 -b:v 4500k -g 120 -keyint_min 120 -sc_threshold 0 \
-c:a aac -b:a 128k \
-f dash \
-seg_duration 4 \
-use_template 1 -use_timeline 0 \ # 模板模式,不写 timeline
-init_seg_name 'init-$RepresentationID$.m4s' \
-media_seg_name 'seg-$RepresentationID$-$Number$.m4s' \
manifest.mpd
严格意义的 CMAF
ffmpeg 能生成"HLS 的 fMP4"和"DASH 的 fMP4",但要保证两边用同一份文件(严格 CMAF),通常需要专门的打包器:
- shaka-packager(Google 开源,最常用);
- Bento4(mp4dash / mp4hls);
- 云厂商的媒体打包服务。
shaka-packager 一条命令同时出 HLS 和 DASH:
packager \
'in=input.mp4,stream=video,init_segment=video/init.mp4,segment_template=video/seg_$Number$.m4s' \
'in=input.mp4,stream=audio,init_segment=audio/init.mp4,segment_template=audio/seg_$Number$.m4s' \
--segment_duration 4 \
--mpd_output manifest.mpd \
--hls_master_playlist_output master.m3u8
产物里 HLS 和 DASH 指向同一批 .m4s 文件——这才是 CMAF 的完整形态。
八、验证与调试
1. 看 init 段是否包含 mvex
# 用 ffprobe 看 init.mp4 是否可识别(它不含媒体数据,但必须有轨道信息)
ffprobe -v error -show_entries stream=codec_name,width,height -of csv=p=0 init.mp4
# 应该能看到编码信息(说明 moov 里 trak 声明齐全)
2. 看播放列表有没有 EXT-X-MAP
head -10 out.m3u8
# 必须有 #EXT-X-MAP:URI="init.mp4"
缺了 #EXT-X-MAP 是最常见的错误——播放器拿不到初始化信息,直接播不了。
3. 检查分片时长是否均匀
grep '#EXTINF' out.m3u8 | head -10
# 都应该是 4.000000
4. 用 ffprobe 看媒体段
ffprobe -v error -show_entries format=duration -of csv=p=0 seg_000.m4s
# 单个 media segment 单独 probe 会失败(它没有 moov),要先拼上 init
cat init.mp4 seg_000.m4s > tmp.mp4 && ffprobe -v error -show_entries format=duration -of csv=p=0 tmp.mp4
这个"拼接后再 probe"的技巧很有用——单独 probe 媒体分片会报 "moov atom not found",这不是文件坏了,是它本来就需要 init 段。
5. 播放测试
用 hls.js 的官方 demo 页面(或者 Safari 直接打开 m3u8),看:
- 能否起播;
- 切档是否流畅;
- 拖动是否正常。
九、什么时候别用 fMP4
1. 目标设备很老
| 客户端 | fMP4 支持 |
|---|---|
| iOS 10+ / macOS 10.12+ | ✅ |
| iOS 9 及更早 | ❌(只有 TS) |
| Android 7+(ExoPlayer) | ✅ |
| 现代浏览器(hls.js) | ✅ |
| 老安卓盒子、老智能电视 | ⚠️ 不确定,要实测 |
面向未知设备的内容分发,TS 依然是最保险的。这也是为什么很多商业平台同时提供两种(或者根据用户代理决定给哪种)。
2. 音频-only 或者极短视频
开销优势体现不出来,TS 的简单性反而更好。
3. 已有的 TS 管道跑得好好的
如果没遇到"要和 DASH 共用""要低延迟""封装开销太大"这些问题,没必要为了技术先进而迁移。TS 到现在依然是成熟可靠的方案。
我的判断标准:
需要低延迟(< 5s)? → fMP4(必须)
需要同时服务 HLS 和 DASH? → CMAF(收益巨大)
大规模分发、带宽成本敏感? → fMP4(省 3~8%)
老设备占比高、内容不多? → TS(简单保险)
十、坑清单
- 播放列表缺
#EXT-X-MAP→ 播放器拿不到 init 段,直接失败。 - 单独 probe 媒体分片报 moov not found → 不是坏了,要先拼 init 段。
- init 段文件名配置错了(和实际的 init 文件名不一致)→ 404。
- init 段没被 CDN 正确缓存(或者缓存策略不同)→ 每个请求都回源。
- 忘了
-sc_threshold 0→ 场景切换插关键帧,分片时长不均匀。 - 切片时长不是 GOP 的整数倍 → 分片被强制切碎。
- fMP4 用了但客户端不支持 → iOS 9 及更早直接播不了。老设备给 TS。
- 以为 ffmpeg 能完整生成 LL-HLS → ffmpeg 对 partial segment 的支持有限,生产用 shaka-packager 或云服务。
- 上 LL-HLS 但 CDN 不支持 blocking reload → 延迟一点没降,请求量还涨了几十倍。先确认 CDN 能力。
- LL-HLS 的 part 太小(< 100ms)→ 请求量爆炸,收益递减。200~500ms 比较合适。
- 低延迟下客户端卡顿率上升 → 缓冲少了,网络抖动更敏感。要有自适应缓冲策略。
- CMAF 迁移时旧客户端还在用老的 TS URL → 要做兼容期(两套并存一段时间)。
- 字节范围模式(单文件)配置错了 → 客户端 Range 请求不规范会拿到错误数据。
- 多码率的 init 段搞混 → 每档有自己的 init(分辨率不同),不能共用。文件名要带 RepresentationID。
- 没做 A/B 验证 → 直接全量切 fMP4,老用户播不了才发现。先灰度。
最后说说我对这类"格式迁移"的看法。
技术上,fMP4 确实各方面都比 TS 好(除了老设备兼容)。但我见过不少团队"为了先进而迁移",迁移完发现收益不明显,反而引入了新的兼容性问题。
正确的判断方式是先算账:
- 带宽成本 × 3~8% = 每月省多少钱?
- 存储 × 2(如果同时做 HLS 和 DASH)= 多少钱?
- CDN 缓存命中率提升 = 多少钱?
- 迁移 + 兼容期维护 = 多少人力?
如果前三项加起来明显大于第四项,就迁;否则别动。我们当时是因为"要做低延迟"这个硬需求才迁的,顺带拿到了带宽和存储的收益——这样迁移的动机就很清晰,不容易做成"为了技术而技术"。
还有一点:迁移期一定要两套并存。我们跑了三个月的双格式(TS 给老客户端,fMP4 给新客户端),期间持续看两边的播放失败率。等 fMP4 侧的失败率稳定低于 TS 侧之后,才把默认的切到 fMP4,TS 保留做兜底。这个过程看起来慢,但它保证了没有任何一批用户"突然就看不了了"。