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-12ISO/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 MP4 文件整体结构

代码清单 1 是一个 Box 的定义,采用的是自定义的语法,在 ISO/IEC 14496-1 中有介绍。但这种类 C 的语法,基本上不用看文档介绍也能知道其大致含义。

一个 Box 由两部分组成:Box 头部(BoxHeader)和 Box 负载(BoxPayload)。Box 头部由 sizeboxtype 成员组成。size 成员代表整个 Box 结构的大小,包括其自身这个字段的大小;如果 32 比特大小不够存,则使用到 largesize 字段(此时 size == 1)。boxtype 字段区分 Box 的类型,由 4 个 ascii 码组成,图 1 中显示的名称就是此字段;如果使用到用户扩展类型,则使用到 extended_type 字段(此时 boxtype == 'uuid')。

代码清单 1 Box 结构定义 1
  • 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,只是多了 versionflags 字段。其中 extends 关键字可以看出是继承的概念。

代码清单 2 FullBox 结构定义
  • 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 可以直接跳过。

图 2 boxtype 2

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 成员指示连续有多少个样本为此增量。

代码清单 3 stts 3
  • 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_countsample_count 得到总样本数。最后总时长除以总样本数,就得到了帧率。

time-scale 在 mdhd Box 中,借此将时间转化成单位秒。

ctts

以 H264 视频流举例,由于视频中可能存在 B 帧,需要前、后帧才可以进行解码,因此显示时间相对于解码时间会延后。且如图 3 所示,在样本中针对 B 帧的顺序也有所不同,因此使用 ctts 记录合成(显示)时间基于解码时间的偏移。

图 3 Closed GOP 和 Open GOP 4

代码清单 4 是 ctts 的定义,全称是 composition time to sample box,即此 Box 包含样本的合成时间。entry_count 成员指示项的个数。<sample_count,sample_offset> 为一项:sample_offset 成员为基于解码时间的偏移;sample_count 成员指示连续有多少个样本为此偏移。有两个版本,区别在于偏移是否能为负数。

代码清单 4 ctts 5
  • 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 是视频流的基本单位(前文中将其翻译为样本)。术语 chunksample 的集合,存储若干个连续的 sample

一个 chunk 中的各个 sample 的存储布局是连续的,依次往后读取即可。

sample 的序号按照 chunk 的序号依次排列下来。

多个 chunk 的存储布局是否连续,并不需要关心,可以通过 stco/co64 定位到。

这边我们先说定位 sample 的整体流程,涉及到的 Box 后面在一一说明。定位某一 sample 的流程如下:

1. 利用 stsc 定位到想要的 sample 所在的 chunk 序号。

2. 利用 stcoco64 定位到 chunk 的首地址偏移。

3. 利用 stsz 定位到想要的 sample 在此 chunk 的具体位置。

H264 中有三种帧:I 帧、P 帧、B 帧。I 帧的压缩方式类似于 JPEG,是基于自身的;P 帧还需要基于前一帧的信息进行压缩;B 帧不仅需要前面一帧,还需要后面一帧进行压缩。因此 I 帧的压缩率最低,P 帧次之,B 帧最高。这就对解码想要的帧带来了一点问题,如果此帧是 P 帧或 B 帧,则不能直接提取这帧数据进行解码。我们的做法是需要定位到此帧前面最近的 I 帧,接着往后依次解码,因此最先的步骤是:

0. 利用 stss 定位到最近的关键帧。

接着依次解码,直到解码到想要的帧,即重复步骤 13。下面就具体说明上述提及到的 Box。

stss

stss Box 的定义如图 5 所示,就是一个最基本的表项形式。文档中使用术语 sync sample,即表项中存储各个 sync sample 的序号。特化成 H264 的情况,我们简单将其理解为 I 帧所在的 sample,即对应步骤 0

代码清单 5 stss 6
  • 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 放在一起,只不过数量没有直接给出。

代码清单 6 stsc 7
  1. aligned(8) class SampleToChunkBox
  2.     extends FullBox('stsc', version = 0, 0) {
  3.     unsigned int(32) entry_count;
  4.     for (i = 1; i <= entry_count; i++) {
  5.          unsigned int(32) first_chunk;
  6.          unsigned int(32) samples_per_chunk;
  7.          unsigned int(32) sample_description_index;
  8.     }
  9. }

first_chunk 成员表示此组 chunk 中第一个 chunk 的序号。如要确定此组 chunk 的数量,需要用后一项的 first_chunk 减去当前的得到。

因此 chunk 的总数量单凭 stsc 是无法确定的,还需要借助如 stco 来确定。

samples_per_chunk 成员表示此组 chunk 包含的 samples 数量。

sample_description_index 成员指示 sample 描述信息索引,一般各个 sample 的描述信息是一致的,即一般此字段也是相同的。定义的描述信息在 stsd Box,后续会进行说明。

结合 first_chunksamples_per_chunk 就可以定位到想要 sample 所在的 chunk 序号,即对应步骤 1

stco/co64

stcoco64 Box 的定义如代码清单 7 所示,存储各个 chunk 的偏移地址。其两者的区别,一个是按 32 位存储偏移地址,另一个是按 64 位存储的。

步骤 1 得到 sample 所在的 chunk 序号,再使用 stco/co64 就能获取到此 chunk 的偏移地址,即对应步骤 2

代码清单 7 stco/co64 8
  • 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

代码清单 8 stsz 9
  • 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 单元前附加了此单元的长度域。

图 4 sample 结构 10

对应的语法如代码清单 9,其中的 sample_size 对应的之前说过的 stsz Box 里的内容。LengthSizeMinusOne 在 AVCDecoderConfigurationRecord 里定义,后续会介绍。从这边可以了解到,我们需要依次剥离出各个 NAL 单元的内容。

代码清单 9 NALUSample 10
  • 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'

代码清单 10 NALUSample 11
  • 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 “结构体”。

代码清单 11 avc1 和 avcC 12
  • 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 也定义在这边。我们按定义将它们提取出来即可。

代码清单 12 AVCDecoderConfigurationRecord 13
  • 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 所示,也可以根据其 widthheight 成员得到视频的宽高。

代码清单 13 VisualSampleEntry 14
  • 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 功能了。