提示

返回博客列表

为什么现在的流媒体都在用 fMP4:分片 MP4、CMAF 与低延迟的那些事

我们做 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:它当年为什么被选中

HLS 是苹果 2009 年推出的。当时选 MPEG-TS 作为切片格式,理由很充分:

  1. 广播行业标准:TS 是数字电视(DVB/ATSC)的标准封装,现成的设备、工具、编解码器都支持;
  2. 抗误码:TS 包固定 188 字节,带同步字节,丢包后能快速重新同步;
  3. 流式友好:可以任意位置开始解码(每个包自带 PID),不需要完整的头部信息;
  4. 当时 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(但代价大)

代价要说清楚:

  1. CDN 必须支持(blocking reload 需要 CDN 配合 hold 请求,很多 CDN 不支持或者会超时);
  2. 请求量暴增:每 200ms 一次播放列表请求 × 每个观众 = CDN 请求量涨几十倍;
  3. 客户端兼容性:需要支持 LL-HLS 的播放器(hls.js 有 partial 支持,Safari 原生支持,很多老播放器不支持);
  4. 卡顿率上升:缓冲少了,网络抖动更容易卡顿。

我的建议:

  • 如果目标延迟是 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(简单保险)

十、坑清单

  1. 播放列表缺 #EXT-X-MAP → 播放器拿不到 init 段,直接失败。
  2. 单独 probe 媒体分片报 moov not found → 不是坏了,要先拼 init 段。
  3. init 段文件名配置错了(和实际的 init 文件名不一致)→ 404。
  4. init 段没被 CDN 正确缓存(或者缓存策略不同)→ 每个请求都回源。
  5. 忘了 -sc_threshold 0 → 场景切换插关键帧,分片时长不均匀。
  6. 切片时长不是 GOP 的整数倍 → 分片被强制切碎。
  7. fMP4 用了但客户端不支持 → iOS 9 及更早直接播不了。老设备给 TS。
  8. 以为 ffmpeg 能完整生成 LL-HLS → ffmpeg 对 partial segment 的支持有限,生产用 shaka-packager 或云服务。
  9. 上 LL-HLS 但 CDN 不支持 blocking reload → 延迟一点没降,请求量还涨了几十倍。先确认 CDN 能力。
  10. LL-HLS 的 part 太小(< 100ms)→ 请求量爆炸,收益递减。200~500ms 比较合适。
  11. 低延迟下客户端卡顿率上升 → 缓冲少了,网络抖动更敏感。要有自适应缓冲策略。
  12. CMAF 迁移时旧客户端还在用老的 TS URL → 要做兼容期(两套并存一段时间)。
  13. 字节范围模式(单文件)配置错了 → 客户端 Range 请求不规范会拿到错误数据。
  14. 多码率的 init 段搞混 → 每档有自己的 init(分辨率不同),不能共用。文件名要带 RepresentationID。
  15. 没做 A/B 验证 → 直接全量切 fMP4,老用户播不了才发现。先灰度。

最后说说我对这类"格式迁移"的看法。

技术上,fMP4 确实各方面都比 TS 好(除了老设备兼容)。但我见过不少团队"为了先进而迁移",迁移完发现收益不明显,反而引入了新的兼容性问题。

正确的判断方式是先算账:

  • 带宽成本 × 3~8% = 每月省多少钱?
  • 存储 × 2(如果同时做 HLS 和 DASH)= 多少钱?
  • CDN 缓存命中率提升 = 多少钱?
  • 迁移 + 兼容期维护 = 多少人力?

如果前三项加起来明显大于第四项,就迁;否则别动。我们当时是因为"要做低延迟"这个硬需求才迁的,顺带拿到了带宽和存储的收益——这样迁移的动机就很清晰,不容易做成"为了技术而技术"。

还有一点:迁移期一定要两套并存。我们跑了三个月的双格式(TS 给老客户端,fMP4 给新客户端),期间持续看两边的播放失败率。等 fMP4 侧的失败率稳定低于 TS 侧之后,才把默认的切到 fMP4,TS 保留做兜底。这个过程看起来慢,但它保证了没有任何一批用户"突然就看不了了"。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部