MP4 格式
MP4 格式由 MPEG 组织制定。它的知名度和 JPEG 一样,在目前的生活中已经太过常见了,也是非常成熟的内容。网上关于 MP4 的资料也非常多,因此其基本背景和概念就不赘述了。但是网上关于 MP4 格式的介绍相对来说比较零散,这篇文章也是想把较多的内容总结和梳理一下。
首先需要说明的是,MP4 格式只是视频、音频数据的封装格式,所以对于具体的视频、音频格式这篇文章不会介绍。并且这篇文章只针对视频相关内容进行铺开,不涉及音频。
以下是自己目前关注到的几份 ISO 文档:
1. ISO/IEC 14496-12 : 基于 ISO 的媒体文件格式。
2. ISO/IEC 14496-14 : MPEG-4 文件格式。
3. ISO/IEC 14496-15 : AVC 文件格式。
ISO/IEC 14496-14 是 MP4 格式的整体介绍。
ISO/IEC 14496-12 是 ISO/IEC 14496-14 的基础。此 ISO 文档是“重中之重”,不仅是 MP4 格式,HEIF 等格式同样也基于 ISO/IEC 14496-12。
通过 ISO/IEC 14496-15,我们可以进一步了解如何封装 H264 视频流。
目标驱动
有 ISO 文档,了解的过程只是时间问题。但是仅仅是关注的三篇 ISO 文档,就动辄上百页,读起来会很慢。所以我们基于目标来阅读文档,只先关注与目标关联的内容。
设定的目标是,开发一个 MP4 帧率分析工具:
1. 获取 MP4 文件视频的帧率。
2. 按帧号 seek 定位视频内容。
调试工具
1. mp4box.js 可以核对查看自己解析 MP4 格式所得到的内容是否正确。
2. elecardstreameyetools 工具可以核对查看自己提取的 H264 流是否正确。
最基本的结构 —— Box
MP4 文件最基本的结构是 Box。图 1 展示了 MP4 文件的整体结构,可以看到它是由多个 Box 组合起来的,而且 Box 可以嵌套。总体看上去像是和目录一样的结构。

代码清单 1 是一个 Box 的定义,采用的是自定义的语法,在 ISO/IEC 14496-1 中有介绍。但这种类 C 的语法,基本上不用看文档介绍也能知道其大致含义。
一个 Box 由两部分组成:Box 头部(BoxHeader)和 Box 负载(BoxPayload)。Box 头部由 size 和 boxtype 成员组成。size 成员代表整个 Box 结构的大小,包括其自身这个字段的大小;如果 32 比特大小不够存,则使用到 largesize 字段(此时 size == 1)。boxtype 字段区分 Box 的类型,由 4 个 ascii 码组成,图 1 中显示的名称就是此字段;如果使用到用户扩展类型,则使用到 extended_type 字段(此时 boxtype == 'uuid')。
- aligned(8) class BoxHeader(
- unsigned int(32) boxtype,
- optional unsigned int(8)[16] extended_type) {
- unsigned int(32) size;
- unsigned int(32) type = boxtype;
- if (size == 1) {
- unsigned int(64) largesize;
- }
- else if (size == 0) {
- // box extends to end of file
- }
- if (boxtype == 'uuid') {
- unsigned int(8)[16] usertype = extended_type;
- }
- }
- aligned(8) class Box(
- unsigned int(32) boxtype,
- optional unsigned int(8)[16] extended_type) {
- BoxHeader(boxtype, extended_type);
- // the remaining bytes are the BoxPayload
- }
如代码清单 2 所示,还有一种 FullBox,相比于普通的 Box,只是多了 version 和 flags 字段。其中 extends 关键字可以看出是继承的概念。
- aligned(8) class FullBoxHeader(unsigned int(8) v, bit(24) f)
- {
- unsigned int(8) version = v;
- bit(24) flags = f;
- }
- aligned(8) class FullBox(unsigned int(32) boxtype,
- unsigned int(8) v, bit(24) f,
- optional unsigned int(8)[16] extended_type)
- extends Box(boxtype, extended_type)
- {
- FullBoxHeader(v, f);
- // the remaining bytes are the FullBoxPayload
- }
图 2 截取了标准中定义的一部分,列数代表了其所在的层数,星号代表这个 Box 是必需的。不同的 boxtype 有对应的章节进行详细介绍,后续我们只关注我们用得到的 Box。Box 的定义还有一个好处,就是对“不感兴趣”的 Box 可以直接跳过。

1 《ISO/IEC 14496-12》:4.2.2 Object definitions
2 《ISO/IEC 14496-12》:6.2.3 Box order
目标 1 —— 获取帧率
我们的第一个目标是获取视频的帧率,moov/trak/mdia/minf/stbl 是我们主要需要关注的 Box。其附属的 stts Box 可以用来计算视频帧率。如果需要进一步知道每帧的显示时长可能还会利用到 ctts Box(视频流可能会用到双向预测的帧间压缩)。
有视频和音频等 track,可根据其附属的 hdlr Box 中的 handler 成员进行区分(视频值为 'vide')。
stts
代码清单 3 是 stts 的定义,全称是 decoding time to sample box,即此 Box 包含样本的解码时间。Box 中是按样本顺序记录的解码时间,不过存储的是前后解码时间的增量。记录的方式采用游程编码压缩,即连续相同的增量合并到一起,并记录数量。
entry_count 成员指示项的个数。<sample_count,sample_delta> 为一项:sample_delta 成员为增量,单位为 mdhd Box 中的 time-scale;sample_count 成员指示连续有多少个样本为此增量。
- aligned(8) class TimeToSampleBox
- extends FullBox('stts', version = 0, 0) {
- unsigned int(32) entry_count;
- int i;
- for (i = 0; i < entry_count; i++) {
- unsigned int(32) sample_count;
- unsigned int(32) sample_delta;
- }
- }
如果将记录的项都“解压”开来,则各样本的解码时间为:
\(DT(n+1) = DT(n) + STTS(n)\)
至此,就可以计算帧率了:根据 <sample_count,sample_delta> 将所有的增量全部累加起来,就得到了所有的时长。再根据 entry_count 和 sample_count 得到总样本数。最后总时长除以总样本数,就得到了帧率。
time-scale 在 mdhd Box 中,借此将时间转化成单位秒。
ctts
以 H264 视频流举例,由于视频中可能存在 B 帧,需要前、后帧才可以进行解码,因此显示时间相对于解码时间会延后。且如图 3 所示,在样本中针对 B 帧的顺序也有所不同,因此使用 ctts 记录合成(显示)时间基于解码时间的偏移。

代码清单 4 是 ctts 的定义,全称是 composition time to sample box,即此 Box 包含样本的合成时间。entry_count 成员指示项的个数。<sample_count,sample_offset> 为一项:sample_offset 成员为基于解码时间的偏移;sample_count 成员指示连续有多少个样本为此偏移。有两个版本,区别在于偏移是否能为负数。
- aligned(8) class CompositionOffsetBox
- extends FullBox('ctts', version, 0) {
- unsigned int(32) entry_count;
- int i;
- if (version == 0) {
- for (i = 0; i < entry_count; i++) {
- unsigned int(32) sample_count;
- unsigned int(32) sample_offset;
- }
- }
- else if (version == 1) {
- for (i = 0; i < entry_count; i++) {
- unsigned int(32) sample_count;
- signed int(32) sample_offset;
- }
- }
- }
可以基于偏移得到各样本的合成时间:
\(CT(n) = DT(n) + CTTS(n)\)
如果视频流中不存在 B 帧,则也不存在 ctts,各帧时长就可以直接通过 stts 获得。否则就需要结合 ctts 计算得到各帧时长。
3 《ISO/IEC 14496-12》:8.6.1.2 Decoding time to sample box
4 《ISO/IEC 14496-12》:8.6.1 Time to sample boxes
5 《ISO/IEC 14496-12》:8.6.1.3 Composition time to sample box
目标 2 —— 按帧 seek 视频内容
我们的第二个目标是可以按帧 seek 视频内容,以定位查看我们感兴趣的帧。最终目标是定位并提取某帧对应的视频流,再往后就不属于文件格式的范畴了,我们只需要将视频流传给视频解码器。此处我们关注封装的 H264 视频流。
在此之前,我们需要了解一些 MP4 组织视频流的方式,并说明一些文档中提及的术语。在 MP4 文件中,术语 sample 是视频流的基本单位(前文中将其翻译为样本)。术语 chunk 是 sample 的集合,存储若干个连续的 sample。
一个 chunk 中的各个 sample 的存储布局是连续的,依次往后读取即可。
sample 的序号按照 chunk 的序号依次排列下来。
多个 chunk 的存储布局是否连续,并不需要关心,可以通过 stco/co64 定位到。
这边我们先说定位 sample 的整体流程,涉及到的 Box 后面在一一说明。定位某一 sample 的流程如下:
1. 利用 stsc 定位到想要的 sample 所在的 chunk 序号。
2. 利用 stco 或 co64 定位到 chunk 的首地址偏移。
3. 利用 stsz 定位到想要的 sample 在此 chunk 的具体位置。
H264 中有三种帧:I 帧、P 帧、B 帧。I 帧的压缩方式类似于 JPEG,是基于自身的;P 帧还需要基于前一帧的信息进行压缩;B 帧不仅需要前面一帧,还需要后面一帧进行压缩。因此 I 帧的压缩率最低,P 帧次之,B 帧最高。这就对解码想要的帧带来了一点问题,如果此帧是 P 帧或 B 帧,则不能直接提取这帧数据进行解码。我们的做法是需要定位到此帧前面最近的 I 帧,接着往后依次解码,因此最先的步骤是:
0. 利用 stss 定位到最近的关键帧。
接着依次解码,直到解码到想要的帧,即重复步骤 1 到 3。下面就具体说明上述提及到的 Box。
stss
stss Box 的定义如图 5 所示,就是一个最基本的表项形式。文档中使用术语 sync sample,即表项中存储各个 sync sample 的序号。特化成 H264 的情况,我们简单将其理解为 I 帧所在的 sample,即对应步骤 0。
- aligned(8) class SyncSampleBox
- extends FullBox('stss', version = 0, 0) {
- unsigned int(32) entry_count;
- int i;
- for (i = 0; i < entry_count; i++) {
- unsigned int(32) sample_number;
- }
- }
stsc
自己初学时,stsc Box 的定义感觉最费解。其定义如代码清单 6,<first_chunk,samples_per_chunk,sample_description_index> 是一项。同样采取游程编码的压缩方式,将连续的具有相同数量 sample 的 chunk 放在一起,只不过数量没有直接给出。
- aligned(8) class SampleToChunkBox
- extends FullBox('stsc', version = 0, 0) {
- unsigned int(32) entry_count;
- for (i = 1; i <= entry_count; i++) {
- unsigned int(32) first_chunk;
- unsigned int(32) samples_per_chunk;
- unsigned int(32) sample_description_index;
- }
- }
first_chunk 成员表示此组 chunk 中第一个 chunk 的序号。如要确定此组 chunk 的数量,需要用后一项的 first_chunk 减去当前的得到。
因此 chunk 的总数量单凭 stsc 是无法确定的,还需要借助如 stco 来确定。
samples_per_chunk 成员表示此组 chunk 包含的 samples 数量。
sample_description_index 成员指示 sample 描述信息索引,一般各个 sample 的描述信息是一致的,即一般此字段也是相同的。定义的描述信息在 stsd Box,后续会进行说明。
结合 first_chunk 和 samples_per_chunk 就可以定位到想要 sample 所在的 chunk 序号,即对应步骤 1。
stco/co64
stco 和 co64 Box 的定义如代码清单 7 所示,存储各个 chunk 的偏移地址。其两者的区别,一个是按 32 位存储偏移地址,另一个是按 64 位存储的。
步骤 1 得到 sample 所在的 chunk 序号,再使用 stco/co64 就能获取到此 chunk 的偏移地址,即对应步骤 2。
- aligned(8) class ChunkOffsetBox
- extends FullBox('stco', version = 0, 0) {
- unsigned int(32) entry_count;
- for (i = 1; i <= entry_count; i++) {
- unsigned int(32) chunk_offset;
- }
- }
- aligned(8) class ChunkLargeOffsetBox
- extends FullBox('co64', version = 0, 0) {
- unsigned int(32) entry_count;
- for (i = 1; i <= entry_count; i++) {
- unsigned int(64) chunk_offset;
- }
- }
stsz
stsz Box 的定义如代码清单 8,存储各个 sample 的大小。如果 sample_size 不为 0,则代表所有的 sample 的大小一样,就使用此成员指示;如果为 0,则代表各个 sample 的大小不一样,使用后续的数组指定。
由于想要的 sample 不一定就是 chunk 开始,所以还需要借助 stsz 跳过之前的 sample,并且得到想要 sample 的大小,即对应步骤 3。
- aligned(8) class SampleSizeBox extends FullBox('stsz', version = 0, 0) {
- unsigned int(32) sample_size;
- unsigned int(32) sample_count;
- if (sample_size == 0) {
- for (i = 1; i <= sample_count; i++) {
- unsigned int(32) entry_size;
- }
- }
- }
6 《ISO/IEC 14496-12》:8.6.2 Sync sample box
7 《ISO/IEC 14496-12》:8.7.4 Sample to chunk box
8 《ISO/IEC 14496-12》:8.7.5 Chunk offset box
9 《ISO/IEC 14496-12》:8.7.3 Sample size boxes
传递 H264 裸流给解码器
以上我们已经可以获取到想要的 sample 的数据了,后续内容和 H264 标准更为密切,这里我们暂不做深入了解,只先了解一些基本概念,能达成目标即可。我们还需要知道:
1. sample 是如何组织 H264 二进制视频流的。
2. 如何传递数据给解码器进行解码(此处使用 FFMpeg)。
图 4 是 sample 结构的一个例子,可以看到 sample 中可以有多个 NAL 单元,每个 NAL 单元前附加了此单元的长度域。

对应的语法如代码清单 9,其中的 sample_size 对应的之前说过的 stsz Box 里的内容。LengthSizeMinusOne 在 AVCDecoderConfigurationRecord 里定义,后续会介绍。从这边可以了解到,我们需要依次剥离出各个 NAL 单元的内容。
- aligned(8) class NALUSample
- {
- unsigned int PictureLength = sample_size; //Size of Sample from SampleSizeBox
- for (i = 0; i < PictureLength; ) // to end of the picture
- {
- unsigned int((DecoderConfigurationRecord.LengthSizeMinusOne + 1) * 8)
- NALUnitLength;
- bit(NALUnitLength * 8) NALUnit;
- i += (DecoderConfigurationRecord.LengthSizeMinusOne + 1) + NALUnitLength;
- }
- }
H264 的解码还需要使用到 SPS 和 PPS 这两个参数集,它们存储在 stsd 下面。
首先看 stsd,它在介绍 stsc 的时候有提及到。其定义如代码清单 10,注意到其中的 SampleEntry Box,它是一个抽象基类,不同的视频流继承于它。H264 对应的 format 为 'avc1'。
- aligned(8) abstract class SampleEntry(unsigned int(32) format)
- extends Box(format) {
- const unsigned int(8)[6] reserved = 0;
- unsigned int(16) data_reference_index;
- }
- aligned(8) class SampleDescriptionBox(unsigned int(32) handler_type)
- extends FullBox('stsd', version, 0) {
- int i;
- unsigned int(32) entry_count;
- for (i = 1; i <= entry_count; i++) {
- SampleEntry(); // an instance of a class derived from SampleEntry
- }
- }
avc1 Box 的定义如代码清单 11 所示,其包含了 avcC 和其他可选 Box。而 avcC 里面是一个 AVCDecoderConfigurationRecord “结构体”。
- class AVCConfigurationBox extends Box('avcC') {
- AVCDecoderConfigurationRecord() AVCConfig;
- }
- class AVCSampleEntry() extends VisualSampleEntry(type) {
- // type is 'avc1' or 'avc3'
- AVCConfigurationBox config;
- MPEG4ExtensionDescriptorsBox(); // optional
- }
AVCDecoderConfigurationRecord 的定义如代码清单 12 所示,可以发现我们想要的 SPS 和 PPS 就定义在这里,同时代码清单 9 中使用到的 LengthSizeMinusOne 也定义在这边。我们按定义将它们提取出来即可。
- aligned(8) class AVCDecoderConfigurationRecord {
- unsigned int(8) configurationVersion = 1;
- unsigned int(8) AVCProfileIndication;
- unsigned int(8) profile_compatibility;
- unsigned int(8) AVCLevelIndication;
- bit(6) reserved = '111111'b;
- unsigned int(2) lengthSizeMinusOne;
- bit(3) reserved = '111'b;
- unsigned int(5) numOfSequenceParameterSets;
- for (i = 0; i < numOfSequenceParameterSets; i++) {
- unsigned int(16) sequenceParameterSetLength;
- bit(8 * sequenceParameterSetLength) sequenceParameterSetNALUnit;
- }
- unsigned int(8) numOfPictureParameterSets;
- for (i = 0; i < numOfPictureParameterSets; i++) {
- unsigned int(16) pictureParameterSetLength;
- bit(8 * pictureParameterSetLength) pictureParameterSetNALUnit;
- }
- if (AVCProfileIndication != 66 && AVCProfileIndication != 77 &&
- AVCProfileIndication != 88)
- {
- bit(6) reserved = '111111'b;
- unsigned int(2) chroma_format;
- bit(5) reserved = '11111'b;
- unsigned int(3) bit_depth_luma_minus8;
- bit(5) reserved = '11111'b;
- unsigned int(3) bit_depth_chroma_minus8;
- unsigned int(8) numOfSequenceParameterSetExt;
- for (i = 0; i < numOfSequenceParameterSetExt; i++) {
- unsigned int(16) sequenceParameterSetExtLength;
- bit(8 * sequenceParameterSetExtLength) sequenceParameterSetExtNALUnit;
- }
- }
- }
还有一个不太重要的点是,在代码清单 11 中可以看到 avc1 继承于 VisualSampleEntry。而 VisualSampleEntry 的定义如代码清单 13 所示,也可以根据其 width 和 height 成员得到视频的宽高。
- class VisualSampleEntry(codingname) extends SampleEntry(codingname) {
- unsigned int(16) pre_defined = 0;
- const unsigned int(16) reserved = 0;
- unsigned int(32)[3] pre_defined = 0;
- unsigned int(16) width;
- unsigned int(16) height;
- template unsigned int(32) horizresolution = 0x00480000; // 72 dpi
- template unsigned int(32) vertresolution = 0x00480000; // 72 dpi
- const unsigned int(32) reserved = 0;
- template unsigned int(16) frame_count = 1;
- uint(8)[32] compressorname;
- template unsigned int(16) depth = 0x0018;
- int(16) pre_defined = -1;
- // other boxes from derived specifications
- CleanApertureBox clap; // optional
- PixelAspectRatioBox pasp; // optional
- }
至此,我们已经可以得到 SPS、PPS 以及视频流数据。之后我们只要在各个 NAL 单元前附加 4 字节的 0x00 0x00 0x00 0x01(Annex B 格式),然后传给 FFMpeg 进行解码即可。
10 《ISO/IEC 14496-15》:4.3.3 Sample format
11 《ISO/IEC 14496-12》:8.5.2 Sample description box
12 《ISO/IEC 14496-15》:5.4.2 AVC video stream definition
13 《ISO/IEC 14496-15》:5.3.3 Decoder configuration information
14 《ISO/IEC 14496-12》:12.1.3 Sample entry
总结
本篇文章通过计算视频帧率和按帧 seek 视频内容这两个目标,来介绍 MP4 文件格式。
从计算视频帧率这个目标中,我们了解了解码时间和合成(显示)时间的概念。
从按帧 seek 视频这个目标中,我们了解了 MP4 格式是如何封装 H264 视频流的。这个流程实验下来,让我感觉是在做视频播放器😂 再加上时间戳就是播放器的 seek 功能了。