浏览器端视频重封装:fMP4、ISOBMFF 和不转码的理由
FlowPick 在你浏览器里把 300 个 HLS 分片合成一个 MP4 时,它没在转码。没在重编码。连像素都没看一眼。它做的是重封装(remux)——重写容器,不碰 codec 的码流。
这件事之所以重要:重封装比转码快 50 倍,5 年前的笔记本也能实时跑,零质量损失。代价是:你得真的搞懂 MP4 文件是什么,不能把它当黑盒。
这篇文章就是来拆黑盒的。
MP4 文件到底是什么
MP4 是 ISO Base Media File Format(ISOBMFF,ISO/IEC 14496-12)的品牌名。它是一棵嵌套的"box"(也叫 atom)树,每个 box 有 4 字符的类型和长度前缀:
ftyp (文件类型 — 标识这是 MP4,声明 brand)
moov (电影元数据 — codec 配置、轨道布局、时序)
mvhd (电影头 — timescale、时长)
trak (轨道 — 视频/音频/字幕每路一个)
tkhd (轨道头)
mdia (媒体信息)
mdhd (媒体头 — 轨道 timescale)
hdlr (handler — 声明"这是视频"或"这是音频")
minf (媒体信息)
stbl (sample 表 — 实际帧数据存放处)
stsd (sample 描述 — codec 配置如 SPS/PPS)
stts (时间到 sample — 每帧 PTS)
stsc (sample 到 chunk — 哪个 chunk 含哪些 sample)
stsz (sample 大小)
stco (chunk 偏移 — 每个 chunk 在文件里的位置)
mdat (媒体数据 — 实际 H.264/AAC 码流)
moov 告诉播放器怎么解读 mdat。没有 moov,你手里就是一堆没人能解的压缩字节。
普通 MP4 把整个 moov 放前面(或者放后面,要二次 seek),mdat 是一整块连续数据。想加一帧?得重写 stts、stsc、stsz、stco 还要挪字节。这是为什么"编辑 MP4"历史上一直很痛。
Fragmented MP4:对流媒体友好的变体
Fragmented MP4(fMP4)把文件切成自包含的"片段":
ftyp
moov (还是有 codec 配置 — stsd,但 stts/stsc/stsz 是空的)
moof (电影片段 — 只针对这一段的 sample 表)
tfhd (这段的轨道头)
trun (track run — 这段的 sample 大小、PTS、时长)
mdat (媒体数据 — 只针对这一段)
moof (下一段)
...
mdat
...
每个 moof+mdat 对是一个完整、自描述的单元。两个 fMP4 片段要拼接?直接把字节追加上去——不重写、不算偏移。每个 moof 里的 trun 装的就是这一段 sample 的表。
这就是为什么 HLS 和 DASH 都转向了 fMP4。每个分片就是一个 moof+mdat 对(外加一个含 ftyp+moov 的 init 分片)。分片拼起来就是一个合法的 fMP4 文件。
init 分片里有什么
ftyp
moov
mvhd
trak (视频)
tkhd
mdia
mdhd
hdlr (vide)
minf
stbl
stsd ← avc1 配置 (H.264 的 SPS/PPS)
... (空的 sample 表 — 由 moof 填)
trak (音频)
...
init 分片是"文件头"——声明 codec、轨道布局、timescale,但不含实际帧。没有它,moof/mdat 分片没人能解。
这就是为什么 DASH 的 <SegmentTemplate initialization="init.mp4"> 和 HLS 的 #EXT-X-MAP:URI="init.mp4" 都存在——它们都是指向 fMP4 的 init 分片。
重封装算法
合并 fMP4 分片(DASH 场景——本来就是 fMP4):
- 写 init 分片字节(输出的
ftyp+moov) - 按顺序写每个媒体分片的
moof+mdat字节 - 完
就这样。输出是合法的 fMP4 文件。codec 一个字节没碰,没重编码,没质量损失。CPU 占用基本是"内存带宽极限"——你就是在那做 memcpy。
async function mergeFmp4(initUrl, segmentUrls) {
const init = new Uint8Array(await (await fetch(initUrl)).arrayBuffer())
const chunks = [init]
for (const url of segmentUrls) {
chunks.push(new Uint8Array(await (await fetch(url)).arrayBuffer()))
}
// 算总长度
const total = chunks.reduce((s, c) => s + c.byteLength, 0)
const out = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
out.set(chunk, offset)
offset += chunk.byteLength
}
return new Blob([out], { type: 'video/mp4' })
}
这 12 行代码就是 FlowPick 处理 DASH 流的大部分工作。复杂度不在合并——在于先解析 MPD 拿到 URL(DASH 深度那篇 讲了),还有下面这些边界情况。
HLS 这边:MPEG-TS 转 MP4
HLS 传统上用 MPEG-TS,不是 fMP4。MPEG-TS 是另一种容器——188 字节定长包,音视频复用在一起,自带时序(PCR/PTS)。.ts 文件不能直接拼成 MP4;你得把 TS 解复用、抽出 PES 包、解析 PTS、写成 fMP4。
这就是 FFmpeg WASM 撑场面的地方。FFmpeg 的 mpegts 解封装器 + mp4 封装器要处理:
- 读 188 字节 TS 包,按 PID 过滤(视频 PID、音频 PID)
- 从 TS payload 重组 PES 包
- 从 PES 头抽 PTS/DTS
- 分组访问单元(一帧视频 = 一个 AAC 音频帧)
- 写
moof/mdatbox,trun的 sample 表要正确
命令行等价物:
ffmpeg -i input.ts -c copy -f mp4 output.mp4
-c copy 是关键——告诉 FFmpeg 把 codec 码流原样复制,不解码也不重编码。CPU 占用极小。
浏览器里,FFmpeg WASM 做同样的事:
import { FFmpeg } from '@ffmpeg/ffmpeg'
const ffmpeg = new FFmpeg()
await ffmpeg.load()
// 把分片写进 FFmpeg 的虚拟 FS
for (let i = 0; i < segments.length; i++) {
await ffmpeg.writeFile(`seg${i}.ts`, segments[i])
}
// 拼接列表
const concatList = segments.map((_, i) => `file 'seg${i}.ts'`).join('\n')
await ffmpeg.writeFile('list.txt', new TextEncoder().encode(concatList))
// 重封装
await ffmpeg.exec([
'-f', 'concat', '-safe', '0', '-i', 'list.txt',
'-c', 'copy',
'-f', 'mp4', 'out.mp4'
])
const output = await ffmpeg.readFile('out.mp4')
30 分钟 1080p 视频、300 个分片,现代笔记本跑 15-30 秒。同样操作转码要 10-20 分钟。
为什么重封装不转码
速度。 重封装是内存带宽瓶颈;转码是 CPU 瓶颈。4 核 x86 转 1080p H.264 大概 1-3 倍实时(30 分钟视频要 10-30 分钟)。重封装 50-100 倍实时。
质量。 转码有代际损失——每次重编码都掉画质,"高码率"也掉。重封装原样保留码流,一个字节不差。
电量。 转码烧 CPU,重封装不烧。笔记本上转 4K 流 20 分钟电池见底。重封装几乎察觉不到。
可预测。 转码时长取决于源内容复杂度(暗场比细节场编码快)。重封装时长只看文件大小——线性可预测。
转码的唯一理由:你真要换 codec——比如 H.265 源转 H.264 给老设备播。FlowPick 不做这个,因为(a)现代设备都支持 H.264/AAC,(b)延迟代价不值。
真的能让无脑拼接翻车的边界情况
1. 不连续性。 HLS 广告位用 #EXT-X-DISCONTINUITY 信号 PTS 重置。跨不连续点拼接,MP4 播放器会"时间倒流"。FFmpeg 处理得了;裸字节拼接不行。
2. Period 之间换 codec。 DASH 多 Period MPD(广告 + 正片)每个 Period 可能用不同 H.264 profile。fMP4 拼起来后 init 分片的 stsd 跟后续片段对不上。播放器要么报错要么花屏。
3. init 分片不匹配。 下错 init 分片(比如下了另一个 Representation 的),codec 配置跟你的分片对不上。输出文件不能播。
4. 分片顺序。 并发抓取时分片乱序到达。拼接前必须重排。DASH 并发抓取模式 用索引数组处理这个。
5. moov atom 在文件末尾。 "渐进下载" MP4(流媒体少见,直链下载常见)把 moov 放在末尾。没 moov,文件没下完就没法播。FFmpeg 的 -movflags +faststart 把 moov 挪到前面。给下载用的封装一定加这个 flag。
moof box 里到底有什么
想往深里钻,这是一个典型 moof 的字节布局:
moof (size: 0x00000400)
mfhd (size: 0x00000010) — 电影片段头
sequence_number: 0x00000001
traf (size: 0x000003e0) — 轨道片段
tfhd (size: 0x00000018) — 轨道片段头
track_id: 1
default_sample_duration: 0x000003e8 (1000,timescale 单位)
tfdt (size: 0x00000014) — 轨道片段解码时间
baseMediaDecodeTime: 0x00000000
trun (size: 0x000003c0) — track run
sample_count: 60
sample 0: duration=1000, size=12345, flags=0x02000000
sample 1: duration=1000, size=11876, flags=0x02000000
...
flags 字段编码这个 sample 是不是:
- 关键帧(
0x02000000= sync sample) - 非关键帧(
0x00010000= non-sync) - 不连续(
0x00000100)
tfdt box 给这段第一个 sample 的绝对解码时间。把 trun 里的时长累加起来,就是这段每个 sample 的 PTS。
这就是 FFmpeg 重封装时解析的结构。想自己写封装器(别写,用 FFmpeg 就行),这就是你要写的结构。
我的看法:别自己写封装器
有一票"轻量级"浏览器封装器想跳过 FFmpeg,纯手撸 ISOBMFF。我看过其中大多数的源码。无一例外,覆盖 80% 的场景,挂在:
- HEVC 流(NAL unit 打包方式不同)
- 带 gapless playback 元数据的音频
- 有 B 帧的流(DTS ≠ PTS,顺序很重要)
- 可变帧率内容
- HDR 元数据(Content Light Level、Mastering Display Color Volume)
FFmpeg 是 30 年积累的边界情况处理。JS 里重写哪怕 50% 也要好几年。引入 FFmpeg WASM(25MB gzip)的代价远比"在真实内容上把封装搞错"的代价小。
例外:你做的是"只拼 fMP4 分片、零边界情况"的最小工具,上面那 12 行 merge 函数够用。FlowPick 两条路都有——DASH 走简单拼接,HLS 有不连续性时走完整 FFmpeg 重封装。
参考资料
- ISO/IEC 14496-12 — ISOBMFF 规范 — 正式规范
- MP4RA — MP4 注册机构 — Box 类型注册表
- FFmpeg MP4 封装器文档 — 含
+faststart和分片选项 - Bento4 源码 — 想深搞 ISOBMFF 就读它;AP4_Atom.cpp 是 box 解析的大师课
- WebCodecs VideoEncoder 文档 — FFmpeg WASM 的浏览器原生替代,下一篇讲
小结
"把分片合成 MP4"这一步从外面看像魔法。其实不是——它是一个结构化的重封装,把为拼接而生的 fMP4 片段按顺序写出来,前面挂一个 init 分片当文件头。HLS 还要先用 FFmpeg WASM 把 MPEG-TS 解封装成 fMP4。无论哪种,你都在复制 codec 字节,不在重编码,所以快、所以无损。
难的不在重封装——在解析清单、处理不连续性、init 分片拿对、处理 DRM。这些在 HLS 和 DASH 深度篇里讲。
下一篇讲更底层的浏览器 API——WebCodecs、Web Workers 和 OPFS——FFmpeg WASM 不是对的工具、想在浏览器里真做视频处理时用。
推荐阅读
- FlowPick 是怎么在浏览器里把几百个视频切片合成 MP4 的 — 这篇里讲的模式在 FlowPick 里怎么落地
- HLS 深度:加密、多音轨、值得记住的 EXT-X 标签 —
.ts分片从哪来 - DASH 深度:SegmentTemplate、ContentProtection 与多视角 MPD — fMP4 分片从哪来
- WebCodecs + Web Workers + OPFS:浏览器视频处理实战 — FFmpeg WASM 的更底层替代
- 浏览器视频处理性能基准 — FFmpeg WASM、WebCodecs、原生的实测对比
- 如何下载 M3U8/HLS 流:完整入门指南 — HLS 入门指南