深入理解 MP4 视频格式:从 Box 结构、moov 索引到 C++ 录像实现
一、前言
在视频监控、NVR 录像、RTSP 拉流和视频播放器开发中,MP4 是一种非常常见的视频封装格式。
日常开发中,我们可能会遇到这些问题:
-
为什么有些 MP4 文件必须下载完成才能播放,而有些可以边下载边播放?
-
为什么 MP4 文件中有一个叫
moov的结构,它和视频索引有什么关系? -
为什么录像文件的
mdat持续增长,而moov中的索引信息也会越来越多? -
为什么某些 MP4 文件异常断电后无法播放,但文件体积看起来是正常的?
-
为什么同样的视频流,使用 MP4、FLV 或 fMP4 封装后,文件结构和播放方式会有所不同?
-
为什么一个视频文件可以通过时间戳定位到某一帧,而不需要从头解码?
要回答这些问题,就不能只停留在 MP4 是一种视频格式的层面,而应该深入理解它的内部组织方式。
首先需要明确一个重要概念:
MP4 主要是一种媒体封装格式,而不是一种视频压缩算法。
H.264、H.265 负责压缩视频画面,AAC 等编码格式负责压缩音频。MP4 则负责组织这些编码后的数据、时间戳、轨道信息以及索引信息。
可以把整个过程理解为:
摄像头
│
│ H.264 / H.265 视频码流
│ AAC 等音频码流
▼
编码后的音视频数据
│
▼
MP4 封装器
├── 组织音视频 Sample
├── 记录时间戳
├── 记录数据位置
├── 生成索引信息
└── 写入 MP4 文件
│
▼
output.mp4
│
▼
播放器
├── 读取索引
├── 定位媒体数据
├── 解封装
├── 解码
└── 显示画面
本文将从 MP4 的物理文件结构开始,逐步分析其索引机制和实际应用。
二、MP4 的本质:ISO Base Media File Format
MP4 通常基于 ISO Base Media File Format,简称 ISOBMFF。
它是一种通用的媒体文件组织规范。MP4 是该体系中最常见的一种应用形式。
从文件结构上看,MP4 并不是把所有视频数据简单地连续写入文件,而是将文件划分成一个个具有明确边界和类型的结构单元。
这些结构单元通常叫作 Box,也常被称为 Atom。
例如:
output.mp4
│
├── ftyp 文件类型与兼容性信息
├── moov 影片元数据、轨道信息与索引
└── mdat 实际的媒体数据
这只是最基础的示意结构。实际 MP4 文件中,Box 的数量、顺序以及嵌套方式可能有所不同。
某些文件还可能包含 free、udta、meta、moof 等 Box。
理解 Box,是理解 MP4 文件内部结构的第一步。
三、什么是 Box?
3.1 Box 就是带有类型和长度的结构块
假设有一个文件:
example.mp4
在文件最开始的某个位置,可能存在这样的字节:
00 00 00 18 66 74 79 70 ...
将其拆分:
00 00 00 18 66 74 79 70
│ │
▼ ▼
Box 大小 Box 类型
24 字节 ftyp
这里的 66 74 79 70 是 ASCII 字符串 ftyp 对应的十六进制编码。
普通 Box 的基本结构如下:
┌──────────────────────────────────┐
│ Box Header │
├──────────────────────────────────┤
│ size 4 字节 │
│ type 4 字节 │
├──────────────────────────────────┤
│ Box Payload │
│ │
│ 具体结构的数据内容 │
│ │
└──────────────────────────────────┘
普通 Box 的头部占用 8 字节。
-
size:Box 的总长度,包括 Header 本身。 -
type:Box 的类型,通常使用 4 个字节表示。
例如:
00 00 00 18 66 74 79 70 ...
其中:
00 00 00 18 = 24
表示这个 Box 总长度为 24 字节,而不是 Payload 长度为 24 字节。
因此,Payload 长度为:
[
24-8=16\text{ 字节}
]
3.2 为什么需要 Box 大小?
因为 MP4 文件中有很多不同类型的数据块,读取文件时必须知道每个数据块从哪里开始、到哪里结束。
假设一个文件由三个连续的 Box 组成:
文件偏移
0
│
▼
┌──────────┐
│ ftyp │ 24 字节
└──────────┘
│
▼
┌──────────┐
│ free │ 100 字节
└──────────┘
│
▼
┌──────────┐
│ moov │ 2000 字节
└──────────┘
第一个 Box 从偏移 0 开始,长度为 24 字节。
因此,第二个 Box 的起始位置是:
[
0+24=24
]
第三个 Box 的起始位置则是:
[
24+100=124
]
这就是 MP4 解析器能够逐个遍历 Box 的基础。
需要注意,moov、trak 等容器 Box 的 Payload 内部还可以继续包含其他 Box,因此解析时必须区分普通数据和容器结构。
3.3 Box 还有特殊的长度表示方式
不能认为每个 Box 都只有 8 字节 Header。
常见的特殊情况包括:
-
当
size字段等于1时,Header 后面还会有一个 8 字节的扩展长度字段,Box Header 总长度变成 16 字节。 -
当
size等于0时,表示该 Box 延伸到其所属容器或文件的末尾,具体边界取决于所在层级。
所以,实际的 MP4 解析器不能简单地认为:
boxSize = 4 字节 size 字段;
payloadOffset = boxStart + 8;
而必须先识别长度字段的特殊值,再确定真实的 Box 长度和 Payload 起点。
四、MP4 中最重要的几个 Box
在日常视频开发中,最值得优先掌握的是 ftyp、moov、mdat、moof。
4.1 ftyp:文件类型声明
ftyp 通常位于 MP4 文件开头。
它主要描述文件的品牌和兼容性信息,例如:
-
Major Brand:主要品牌。
-
Minor Version:品牌相关版本信息。
-
Compatible Brands:兼容品牌列表。
示意:
ftyp
├── major_brand
├── minor_version
└── compatible_brands
它相当于告诉解析器,这个文件声明自己符合哪些媒体文件格式约定。
需要注意,ftyp 不是视频数据本身,也不负责存储完整的解码参数。
4.2 mdat:实际媒体数据
mdat 是 Media Data Box,主要用于存放编码后的媒体数据。
例如:
mdat
│
├── H.264 编码数据
├── H.264 编码数据
├── H.264 编码数据
├── AAC 编码数据
├── H.264 编码数据
└── ...
上图只是概念示意,并不意味着实际文件一定按照视频帧、音频帧这样的固定顺序排列。
在常见的非分片 MP4 中,mdat 里可以包含许多 Sample 的媒体负载,但 Sample 的具体边界和位置往往需要结合 moov 中的样本表来确定。
mdat 保存媒体数据,moov 负责描述如何理解和定位这些数据。
这一区别非常重要。
一个文件即使拥有完整的 mdat,也可能因为索引或必要的解码元数据缺失而无法正常播放。
4.3 moov:MP4 的元数据和索引中心
moov 是 MP4 中最重要的结构之一。
它通常包含:
-
影片整体信息。
-
视频、音频等轨道信息。
-
轨道时间尺度和时长。
-
编码配置。
-
Sample 的大小和位置。
-
Sample 的解码时间及显示时间。
-
关键帧相关索引。
典型结构如下:
moov
│
├── mvhd
│ └── 影片整体信息
│
├── trak
│ ├── tkhd
│ │ └── 轨道信息
│ │
│ └── mdia
│ ├── mdhd
│ │ └── 媒体时间信息
│ │
│ ├── hdlr
│ │ └── 轨道类型
│ │
│ └── minf
│ └── stbl
│ ├── stsd
│ ├── stts
│ ├── ctts
│ ├── stsc
│ ├── stsz
│ ├── stco / co64
│ └── stss
│
└── 其他可能存在的 Box
其中,trak 是轨道容器。
一个 MP4 可以有多个轨道,例如:
moov
├── trak(视频轨道)
└── trak(音频轨道)
如果文件只有视频,那么可能只有视频轨道;如果同时包含视频和音频,通常就有相应的两个轨道。
moov 不是视频画面,而是帮助播放器理解视频画面及其时间关系的元数据。
五、深入理解 moov 中的 Sample 索引表
如果希望真正理解 MP4 的索引机制,就必须了解 stbl。
stbl 是 Sample Table Box,也就是样本表容器。
这里的 Sample 可以理解为封装层中的一个媒体样本。对于常见的 H.264/H.265 视频轨道,一个 Sample 通常对应一个编码图像访问单元;对于音频轨道,Sample 的定义和组织方式则取决于对应的音频格式。
Sample 并不等于解码后的原始图像。
5.1 stsz:每个 Sample 有多大?
stsz 记录 Sample 的大小。
假设一个视频轨道包含 5 个 Sample:
| Sample 编号 | 大小 |
|---|---|
| 1 | 1200 字节 |
| 2 | 800 字节 |
| 3 | 950 字节 |
| 4 | 1500 字节 |
| 5 | 700 字节 |
那么,解析器可以通过这些信息知道每个 Sample 应该读取多少字节。
如果所有 Sample 的大小相同,stsz 也可以使用统一的 Sample Size 表示,而不必逐项存储所有大小。
5.2 stco / co64:Sample 数据从哪里读取?
仅知道 Sample 大小是不够的。
播放器还必须知道媒体数据在文件中的位置。
这就需要 stco 或 co64:
-
stco:记录 32 位 Chunk Offset。 -
co64:记录 64 位 Chunk Offset。
这里有一个容易混淆的地方:
它们记录的是 Chunk 的起始偏移,不是每个 Sample 的独立文件偏移。
一个 Chunk 可以包含多个 Sample。
例如:
mdat
│
├── Chunk 1
│ ├── Sample 1
│ ├── Sample 2
│ └── Sample 3
│
└── Chunk 2
├── Sample 4
└── Sample 5
假设 Chunk 1 的起始偏移为 1000,Chunk 2 的起始偏移为 4000。
stco 就可以记录:
Chunk Offset
├── 1000
└── 4000
播放器再结合 stsc 和 stsz,推导每个 Sample 的实际位置。
如果文件中 Chunk 的偏移超过 32 位无符号整数的表示范围,就需要使用 co64。
5.3 stsc:每个 Chunk 包含多少个 Sample?
stsc 是 Sample To Chunk Box。
它负责描述 Sample 如何分配到 Chunk 中。
例如:
Chunk 1:包含 3 个 Sample
Chunk 2:包含 2 个 Sample
解析器需要通过 stsc 知道每个 Chunk 包含多少个 Sample,以及相应的 Sample Description 信息。
实际的 stsc 采用分段规则记录,不一定为每一个 Chunk 单独存储一条记录。
因此,不能简单地把 stsc 当成一个固定长度的 Chunk 数组。
5.4 stts:Sample 的解码时间如何排列?
stts 是 Decoding Time to Sample Box。
它描述 Sample 的解码时间增量,也就是相邻 Sample 的解码时间间隔。
例如,一个视频轨道的时间尺度为 90000,视频每秒 30 帧:
[
\Delta DTS=\frac{90000}{30}=3000
]
如果连续 5 个 Sample 的解码时间间隔都是 3000,那么可以通过压缩记录的方式表示:
stts
├── sample_count = 5
└── sample_delta = 3000
这里的 sample_delta 以该轨道的时间尺度为单位。
对于更复杂的视频流,时间间隔不一定完全相同,因此 stts 可以包含多组记录。
5.5 ctts:为什么解码时间和显示时间可能不同?
这是理解视频时间戳的重要知识点。
视频编码中,解码顺序不一定等于显示顺序。特别是在使用 B 帧时,编码器可能改变帧的排列方式,以提高压缩效率。
因此,需要区分:
-
DTS:Decoding Time Stamp,解码时间戳。
-
PTS:Presentation Time Stamp,显示时间戳。
例如:
解码顺序:
I0 → P3 → B1 → B2
显示顺序:
I0 → B1 → B2 → P3
这只是一个用于解释重排序的示意,具体顺序取决于编码结构。
stts 主要描述解码时间的增量,而 ctts 用于描述 Sample 的合成时间偏移,使解析器能够确定显示时间与解码时间之间的关系。
在常见的时间戳模型中,可以将其理解为:
[
PTS=DTS+CTO
]
其中,CTO 是 Composition Time Offset。
需要注意,具体偏移的存储和解释还与 ctts 的版本有关。
5.6 stss:哪些 Sample 是同步样本?
stss 是 Sync Sample Box,通常用于描述同步样本的位置。
在常见的视频轨道中,它可以帮助播放器找到适合随机访问的关键帧。
例如:
Sample 1:同步样本
Sample 2:非同步样本
Sample 3:非同步样本
Sample 4:同步样本
Sample 5:非同步样本
那么播放器在进行时间跳转时,可以先寻找目标时间之前合适的同步样本,再从该位置开始解码。
需要注意,MP4 的同步样本标记不等同于所有编码格式下绝对完整的解码依赖描述;实际随机访问还需要结合编码格式本身的特性。
5.7 这些索引表如何协同工作?
假设播放器要读取第 1000 个 Sample:
用户请求播放某个时间点
│
▼
根据时间信息定位目标 Sample
│
▼
根据 stts / ctts 计算时间关系
│
▼
根据 stss 寻找合适的同步样本
│
▼
根据 stsc 确定 Sample 所在的 Chunk
│
▼
根据 stco / co64 获取 Chunk 偏移
│
▼
结合 stsz 计算 Sample 的大小与具体位置
│
▼
从 mdat 中读取编码数据
│
▼
解码并显示画面
这就是 MP4 索引机制的核心思想。
播放器并不是简单地扫描整个文件,直到找到目标画面,而是利用元数据建立时间、样本和文件位置之间的映射关系。
六、为什么 moov 会随着录像时间增长?
这是 NVR 录像开发中值得重点关注的问题。
假设一台摄像头以 25 FPS 连续录像。
如果视频轨道采用常见的逐 Sample 索引结构,那么 30 分钟内的视频 Sample 数量大致为:
[
25\times30\times60=45000
]
也就是说,30 分钟可能产生约 4.5 万个视频 Sample。
如果同时录制音频,音频轨道还会有自己的一套 Sample 和索引信息。
随着录像时间增加,封装器需要记录更多的样本信息,例如:
-
Sample 大小。
-
Sample 的时间信息。
-
Sample 与 Chunk 的对应关系。
-
Chunk 的文件偏移。
-
同步样本的位置。
这些信息会构成 moov 中的索引数据。
6.1 索引数据和媒体数据有什么区别?
MP4 文件
│
├── mdat
│ └── 实际编码数据
│ ├── 视频画面数据
│ ├── 视频画面数据
│ └── ...
│
└── moov
└── 组织与定位这些数据的信息
├── 时间索引
├── 大小索引
├── 位置索引
└── 同步样本索引
mdat 的增长主要来自持续写入的编码媒体数据。
moov 中部分表的增长则来自持续积累的样本元数据。
因此,不能把 moov 的增长简单理解为视频画面本身越来越大。
6.2 为什么索引会影响内存?
如果封装器采用缓存索引、延迟生成索引或在内存中维护 Sample 表,那么随着录像持续进行,内存占用可能逐步增加。
例如,内部可能维护类似这样的数据:
struct SampleInfo
{
uint64_t offset;
uint32_t size;
uint64_t dts;
uint32_t duration;
bool isSync;
};
这只是用于解释原理的简化示例,并不代表所有 MP4 封装器都采用这样的内部结构。
如果每个 Sample 都保存一份元数据,那么总内存开销会随着 Sample 数量增加而增长。
但需要注意:
MP4 标准并不要求封装器必须把全部索引一直保存在内存中。
不同封装器的实现可能完全不同:
-
有的将索引长期保存在内存中。
-
有的会分批处理或使用磁盘临时结构。
-
有的使用分片 MP4,降低单个文件索引的积累方式。
-
有的会在录像结束时统一生成索引。
-
有的支持在录制过程中周期性更新文件结构。
因此,如果观察到 NVR 录制时 RSS 持续上升,不能仅凭 MP4 格式就认定一定是 moov 导致的,需要结合具体封装器实现和内存分析确认。
6.3 为什么 CPU 也可能随录像时间增加?
一种值得排查的实现是:每次收到新 Sample 后,都重新遍历或重建大量已有索引数据。
假设第 (n) 次更新需要处理前面全部 (n) 条记录,那么累计处理量为:
[
1+2+3+\cdots+N
=\frac{N(N+1)}{2}
]
因此,总处理量的增长近似为:
[
O(N^2)
]
这意味着,随着录像时间增加,索引更新可能越来越耗时。
但这只是一个用于分析的模型,并不是 MP4 的必然行为。
如果封装器采用增量更新或合理的批量处理机制,实际复杂度可能远低于这个模型。
在 NVR 项目中,如果观察到录制时间越长、CPU 波动越明显,可以重点检查:
-
是否每次写入都重新生成全量索引。
-
是否频繁对不断增长的索引数组进行复制或扩容。
-
是否反复重写
moov。 -
是否存在频繁的文件随机写入和同步操作。
-
是否还有其他随录像时长增长的数据结构。
这些方向应当通过代码审查、性能采样和内存分析来验证,而不是仅凭现象直接下结论。
七、moov 为什么会影响 MP4 的播放速度?
常见的普通 MP4 文件可能采用下面两种布局。
7.1 moov 在文件末尾
文件开始
│
▼
┌─────────┐
│ ftyp │
└─────────┘
┌─────────┐
│ mdat │
│ │
│ 视频数据│
│ │
└─────────┘
┌─────────┐
│ moov │
│ 索引信息│
└─────────┘
▲
│
文件结束
这种布局可以让封装器先写媒体数据,再在录制结束时写入完整索引。
但是,如果播放器只获得了文件开头和部分媒体数据,暂时还没有拿到文件末尾的 moov,就可能无法立即获取完整的样本索引。
在 HTTP 渐进式下载、在线播放等场景中,这会影响开始播放的时机。
不过,这不代表所有播放器都必须等整个文件下载完成,也不代表文件末尾存在 moov 就一定不能在线播放。实际行为取决于播放器、服务器和下载方式。
7.2 moov 在文件前面
文件开始
│
▼
┌─────────┐
│ ftyp │
└─────────┘
┌─────────┐
│ moov │
│ 索引信息│
└─────────┘
┌─────────┐
│ mdat │
│ │
│ 视频数据│
│ │
└─────────┘
│
▼
文件结束
这种布局允许播放器在读取文件开头后,就获取影片元数据和索引信息。
在常见的非分片 MP4 中,可以使用 FFmpeg 的 -movflags +faststart 将已经完成的 MP4 中的 moov 移到文件前部。
例如:
ffmpeg -i input.mp4 -c copy -movflags +faststart output.mp4
这里的 -c copy 表示尽量直接复制音视频编码数据,不重新编码。
需要注意,faststart 通常涉及文件结构调整和数据搬移,因此会产生额外的 I/O 开销,也不适合直接作为所有正在录制的文件的通用处理方式。
它的核心价值是改善普通 MP4 的渐进式播放体验,而不是提高视频编码质量。
八、MP4 与 fMP4 有什么区别?
fMP4 是 Fragmented MP4,也就是分片 MP4。
它仍然属于基于 ISOBMFF 的媒体封装方式,但组织媒体数据的方式与常见的非分片 MP4 有明显差异。
8.1 普通 MP4 的组织方式
普通 MP4
│
├── ftyp
├── moov
│ └── 较完整的 Sample 索引
└── mdat
└── 媒体数据
在常见的普通 MP4 中,样本表通常会随着 Sample 数量增长而变大,完整索引往往在文件结束时生成或最终确定。
8.2 fMP4 的组织方式
fMP4
│
├── ftyp
├── moov
│ └── 初始化信息
│ └── mvex
│
├── moof 第 1 个媒体分片的元数据
├── mdat 第 1 个媒体分片的数据
│
├── moof 第 2 个媒体分片的元数据
├── mdat 第 2 个媒体分片的数据
│
├── moof 第 3 个媒体分片的元数据
├── mdat 第 3 个媒体分片的数据
│
└── ...
这里的 moof 是 Movie Fragment Box,mdat 则承载对应的媒体数据。
每个媒体分片通常有自己的时间和样本描述信息,常见结构包括 traf、tfhd、tfdt 和 trun。
它们分别用于描述轨道分片信息、基础轨道参数、分片解码时间以及 Sample 的相关信息。
8.3 fMP4 为什么适合流式传输?
因为 fMP4 可以把媒体数据划分为多个相对独立的片段。
当一个片段的元数据和媒体数据已经准备好后,就可以按相应的流式协议发送或交付,而不必等到整个长视频文件录制结束后再生成一份完整的传统样本表。
这也是 fMP4 经常用于现代流媒体分发方案的重要原因之一。
例如,在 DASH 或基于 fMP4 的 HLS 中,初始化段和媒体分片可以分别传输。
但需要明确:
fMP4 并不意味着内存一定更低、CPU 一定更少,也不意味着任何情况下都能直接随机访问。
实际效果还取决于分片大小、索引管理、写入策略、播放协议和封装器的实现。
对于 NVR 而言,如果主要需求是将录像保存为独立文件、方便后续检索与回放,那么是否采用 fMP4,应当结合录像文件的生命周期、断电恢复需求和播放器兼容性进行评估。
九、MP4 与 FLV 有什么区别?
FLV 是另一种常见的媒体封装格式。
从开发角度来看,两者最明显的区别在于数据组织和索引机制。
| 对比项目 | 常见非分片 MP4 | FLV |
|---|---|---|
| 基本组织结构 | Box | Header、Tag 等结构 |
| 视频数据组织 | Sample 与样本表 | Video Tag 等 |
| 音频数据组织 | 音频轨道与 Sample | Audio Tag 等 |
| 时间信息 | 轨道时间尺度及样本时间表 | Tag 时间戳等 |
| 传统索引方式 | moov 中的样本表 |
可选的 PreviousTagSize、onMetaData、keyframes 等 |
| 流式组织 | 可通过 faststart 或 fMP4 等方式支持 | 适合按 Tag 连续传输 |
| 常见应用 | 本地视频文件、编辑、存储和回放 | 传统 RTMP 直播及相关流媒体场景 |
FLV 的 PreviousTagSize 并不是视频索引表,它主要记录前一个 Tag 的大小。
FLV 也可以包含元数据和关键帧位置等信息,但其组织方式与 MP4 的 stts、stsz、stco 等样本表不同。
因此,不能简单地说 FLV 没有索引,而 MP4 才有索引。更准确的说法是,两者采用不同的数据组织方式和索引机制。
十、使用 FFmpeg 查看 MP4 内部信息
理论知识掌握后,可以通过 FFmpeg 实际观察文件。
10.1 查看媒体文件的基本信息
ffprobe -v error \
-show_format \
-show_streams \
input.mp4
可以查看:
-
容器格式。
-
文件时长。
-
文件大小。
-
视频编码格式。
-
视频分辨率。
-
帧率相关信息。
-
音频编码格式。
-
轨道时间尺度。
-
流级别的时长和其他元数据。
需要注意,ffprobe 输出中的字段会随媒体文件、编码器和 FFmpeg 版本而有所不同。
10.2 查看每个 Packet 的时间信息
ffprobe -v error \
-select_streams v:0 \
-show_packets \
-show_entries packet=pts,dts,duration,size,flags,pos \
-of csv=p=0 \
input.mp4
输出中可能包含类似这样的信息:
0,0,3600,12000,48,K_
3600,3600,3600,1800,12048,__
7200,7200,3600,2100,13848,__
这里的数值仅用于说明输出形式,并非真实测试结果。
字段通常依次对应:
PTS, DTS, duration, size, pos, flags
通过这些信息,可以观察:
-
Packet 的时间戳。
-
Packet 的持续时间。
-
编码数据大小。
-
数据在文件中的位置。
-
Packet 的相关标志。
需要注意,Packet 与 MP4 Sample 并不总是严格一一对应,具体取决于编码格式和封装器的处理方式。
10.3 查看关键帧
ffprobe -v error \
-select_streams v:0 \
-skip_frame nokey \
-show_frames \
-show_entries frame=key_frame,pict_type,pts_time \
-of csv=p=0 \
input.mp4
该命令可以辅助观察关键帧及其时间信息。
10.4 检查 MP4 的 Box 结构
如果想直接观察 ftyp、moov、mdat 的布局,可以使用 Bento4 等 MP4 分析工具。
例如:
mp4dump input.mp4
该命令适用于安装了 Bento4 工具集的环境。
通过输出,可以看到各级 Box 的类型、长度和嵌套关系。
对于分析文件结构、索引大小和 Box 的位置,这类工具比单纯查看 ffprobe 的流级信息更加直观。
十一、使用 C++ 解析 MP4 的 Box Header
如果正在开发 C++ 媒体模块,可以先实现一个简单的 Box Header 解析器。
下面的代码只负责识别 Box 的长度和类型,并不尝试解析 moov 中的所有子结构。
#include <cstdint>
#include <iostream>
#include <string>
#include <vector>
uint32_t readU32BE(const uint8_t* p)
{
return (static_cast<uint32_t>(p[0]) << 24) |
(static_cast<uint32_t>(p[1]) << 16) |
(static_cast<uint32_t>(p[2]) << 8) |
static_cast<uint32_t>(p[3]);
}
uint64_t readU64BE(const uint8_t* p)
{
return (static_cast<uint64_t>(readU32BE(p)) << 32) |
readU32BE(p + 4);
}
struct BoxHeader
{
uint64_t size = 0;
uint64_t headerSize = 0;
std::string type;
};
bool parseBoxHeader(
const uint8_t* data,
uint64_t available,
BoxHeader& box)
{
if (data == nullptr || available < 8)
{
return false;
}
const uint32_t size32 = readU32BE(data);
box.type.assign(
reinterpret_cast<const char*>(data + 4),
4);
box.headerSize = 8;
if (size32 == 1)
{
if (available < 16)
{
return false;
}
box.size = readU64BE(data + 8);
box.headerSize = 16;
}
else if (size32 == 0)
{
// 当前 Box 延伸至所属容器的末尾。
// 调用方必须提供正确的容器边界。
box.size = available;
}
else
{
box.size = size32;
}
if (box.size < box.headerSize ||
box.size > available)
{
return false;
}
return true;
}
int main()
{
const std::vector<uint8_t> data = {
0x00, 0x00, 0x00, 0x18,
0x66, 0x74, 0x79, 0x70
};
BoxHeader box;
if (!parseBoxHeader(data.data(), data.size(), box))
{
std::cerr << "Invalid Box Header\n";
return 1;
}
std::cout << "Type: " << box.type << '\n';
std::cout << "Size: " << box.size << '\n';
return 0;
}
这段代码的目的,是展示 Box Header 的基本解析方式。
不过,它只是一个教学示例。实际工程中还需要注意以下问题:
-
数据长度不足:读取 64 位扩展长度前,必须确认缓冲区足够。
-
整数溢出:计算文件偏移和 Box 结束位置时,应进行溢出检查。
-
容器边界:不能让子 Box 越过父 Box 的末尾。
-
size 为 0:解析时必须正确确定当前 Box 所属的边界。
-
文件读取:真实文件解析通常需要先读取固定长度 Header,再按需读取后续内容。
-
异常文件:不能因为文件扩展名是
.mp4,就假定内部结构一定合法。
还有一个重要细节:上述代码中的 size == 0 分支适用于已经确定当前 Box 可用边界的场景。对于普通文件,需要由调用方提供准确的父容器或文件剩余长度,而不能无条件把整个文件长度作为任意子 Box 的边界。
十二、NVR 录像开发中的几个关键问题
对于 NVR 项目,理解 MP4 结构不仅是为了阅读文件,还可以帮助排查实际运行中的问题。
12.1 为什么突然断电后 MP4 可能无法播放?
假设录像过程如下:
开始录像
│
▼
写入 ftyp
│
▼
持续写入 mdat
│
▼
持续维护索引
│
▼
录像正常结束
│
▼
完成必要的索引和文件收尾
│
▼
文件可正常播放
如果设备突然断电,最后几步可能没有执行。
例如:
-
最后的媒体数据没有完整写入。
-
索引没有完成。
-
文件头或元数据尚未更新。
-
文件系统缓存尚未持久化。
-
文件中的 Box 长度或引用关系不再符合预期。
因此,文件大小正常并不代表文件一定完整。
不过,异常断电后的可播放性取决于封装器采用的写入策略。某些封装器可能会在文件开头预留空间、周期性更新元数据,或者使用分片结构,因此故障表现并不完全相同。
12.2 为什么录制期间的 MP4 可能暂时不能播放?
在某些普通 MP4 封装实现中,完整索引会在录像结束时生成。
如果此时文件尚未正常关闭,播放器就可能找不到完整的索引结构。
这也是为什么某些正在录制的 MP4 文件虽然已经很大,但仍不能正常打开。
而采用分片 MP4 或支持周期性更新索引的实现,可以改变这一行为。
因此,在设计 NVR 录像功能时,需要明确:
-
文件是否必须在录像过程中可播放?
-
是否需要异常退出后尽量恢复?
-
是否要求直接跳转到任意时间点?
-
是否需要每 30 分钟生成一个独立文件?
-
是否允许录像结束时进行额外的文件整理?
这些要求会直接影响封装格式和写入策略的选择。
12.3 如何理解 30 分钟录像文件的索引开销?
假设一台摄像头以 25 FPS 录像 30 分钟。
视频 Sample 数量约为 45000。
如果每个 Sample 的索引记录平均占用 (b) 字节,那么仅按逐 Sample 记录计算,相关索引数据的粗略量级可以表示为:
[
M\approx45000b
]
这里的 (b) 只是用于估算的平均值,不是 MP4 标准规定的固定大小。不同索引表的字段、压缩记录方式、Chunk 组织方式和容器实现都会影响实际开销。
如果同时录制多路摄像头,还需要考虑多个录像任务同时维护索引的情况。
例如,10 路摄像头各自独立录像时,每路文件的索引通常属于独立的封装上下文,但整个 NVR 进程仍可能同时承担多个任务的内存和 CPU 开销。
因此,分析 NVR 的内存增长时,应当同时观察:
-
每路录像任务的 Sample 数量。
-
每个任务的内存占用。
-
索引容器的扩容与复制情况。
-
文件写入频率。
-
录像结束后的资源释放情况。
不要仅凭视频文件的体积,推断索引内存一定是多少。
十三、MP4 相关开发中的常见误区
误区一:MP4 就是视频编码格式
错误。
MP4 主要是封装格式。视频可以使用 H.264、H.265 等编码,音频也可以使用不同的编码格式。
误区二:mdat 就是一个完整的视频码流
不一定。
mdat 存储媒体数据,但实际 Sample 的边界、时间关系和文件位置需要结合轨道元数据及样本表进行理解。
误区三:moov 只是视频文件的简单头部
错误。
moov 通常包含完整的轨道描述、时间信息和索引信息。它可能包含大量嵌套 Box,而不只是几项简单参数。
误区四:每个 Sample 都有一个独立的文件偏移记录
不一定。
MP4 可以按 Chunk 记录偏移,再结合每个 Chunk 中的 Sample 数量和 Sample 大小推导各个 Sample 的位置。
误区五:moov 越大,说明视频质量越高
错误。
视频画面质量主要与编码方式、码率、分辨率、帧率、编码参数和画面内容等因素有关。
moov 的大小更多反映元数据和索引的规模及组织方式,不能直接用来判断画面质量。
误区六:使用 fMP4 一定能解决内存增长问题
错误。
fMP4 改变的是媒体数据和元数据的组织方式。是否降低内存开销,取决于封装器如何维护索引、分片和缓冲区。
误区七:faststart 会让 MP4 的视频数据压缩得更好
错误。
faststart 主要调整普通 MP4 中元数据的位置,通常不会重新编码视频,也不会直接提高压缩率。
十四、总结
MP4 的核心可以归纳为三个层次。
第一层:Box 组织结构。
ftyp 声明文件类型,mdat 承载媒体数据,moov 保存影片和轨道相关元数据。Box 的长度和类型使解析器能够遍历文件结构。
第二层:Sample 索引机制。
stsz 描述 Sample 大小,stco 和 co64 描述 Chunk 的位置,stsc 描述 Sample 与 Chunk 的映射,stts 和 ctts 描述时间关系,stss 帮助定位同步样本。这些信息共同支持播放器读取、解码和随机访问媒体数据。
第三层:录像与流式处理。
普通 MP4、faststart MP4 和 fMP4 的差别,不仅在于 Box 的排列顺序,还涉及索引生成时机、文件可播放性和媒体数据的组织方式。对 NVR 来说,这些因素会影响录像期间的内存占用、CPU 开销、异常断电恢复和回放体验。
学习 MP4,最终要建立一个完整的认识:
视频编码解决如何表示画面,MP4 封装解决如何组织数据,而索引机制解决如何根据时间和位置找到需要的媒体数据。
当你能够从 Box 结构一路理解到 Sample 的时间戳、文件偏移和异常恢复时,就已经具备进一步分析 MP4 封装器源码以及 NVR 录像性能问题的基础。
- 点赞
- 收藏
- 关注作者
评论(0)