MP4 格式简介及 MOOV 解析

前言

最近的项目里做了一点 MP4 Demux 优化相关的工作,所以打算写一篇 MP4 相关的文章。
关于 Demux,可以参见我之前的一篇 Blog『HLS 及多媒体基础知识介绍』中关于“封装”的内容。
MP4 是一种比较常见的媒体文件格式,由于其对移动设备的支持良好,因此是当下最为流行的媒体文件格式之一。
关于 MP4 的历史、发展等介绍,可以参考维基百科中的 MP4 条目,或是介绍更加详细的英文条目 MPEG-4 Part 14
这里需要注意的是,MP4 并不是 MPEG-4 的缩写,而仅为 MPEG-4 的第 14 部分(文件格式相关的内容)。整个 MPEG-4 是由 Moving Picture Experts Group(缩写 MPEG)制定的音视频数据的一整套编码压缩标准。而关于完整的 MPEG-4 的简介,也可以参考维基百科中的条目 MPEG-4

MP4 文件格式简析

MP4 格式的基础:Box

在 MP4 格式中,保存数据(及元数据)的单元称为“Box”,也有人称其为“Atom”,后者实际上是 QuickTime 中的称呼。这里顺便插一嘴,QuickTime 是一种兼容 MPEG-4 的文件格式,由苹果公司开发,比 MPEG-4 的历史更为悠久。而实际上 MP4 本身也是基于 QuickTime File Format 制订的。

说回正题,MP4 文件由多个 Box 组成。Box 分为不同类型,部分类型的 Box 嵌套在另一些类型的 Box 中。所以 MP4 文件的数据单元可以认为是按树形结构组织的。
下图是一个典型的 MP4 文件中包含的 Box。

可以看到,处于第一层级的 Box 主要包括如下 4 个:

  • ftyp 标示文件类型及兼容性信息
  • moov 保存 Track、Index 等元数据
  • wide QuickTime 定义的 Box,一般的 MP4 中不会出现这个 Box,这个主要是用来扩展 MP4 可支持的文件最大尺寸(图中这个 MP4 文件是我用 iPhone 录制的视频)
  • mdat 存放真正的媒体数据

第一层级还有可能出现的 Box 包括:

  • free 空数据,可以忽略
  • skip 用于存放用户自定义数据,例如版权信息
  • meta 保存媒体本身的一些元数据
  • meco 额外的一些元数据

另外,在用于流播的 Fragment MP4 场景下(例如 Smooth Streaming 应用中),媒体数据不是全部放在同一个 mdat Box 中,而是按 Fragment 分成多个 mdat Box 保存。同时会包含如下的 Box。

  • moof 在每个 Fragment 中都会存在,类似于 moov,但保存的仅为当前分片的元数据
  • mfra Movie Fragment Random Access,可以认为是分片索引

此时 MP4 的结构大概会类似于下图所示。

However,本文主要讨论一般情况,所以这些扩展的 Box 就不展开介绍了。

Box 的结构

不管 Box 的类型是什么,每个 Box 都是由 Header 和 Data 两部分组成的。大概的组成如下图所示。

可以看到,Box 的 Header 中开头的 4 个字节中保存了 Box 的大小,接下来的 4 个字节保存了 Box 的类型。
另外,还有一种在 Box 基础上扩展的 Fullbox,会在 Header 部分加入一些额外的信息(version、flag等),这里也不展开了。

查看 Box 的工具

在 Mac 下没找到好用的 GUI,不过有不少命令行的工具,例如 MP4Box。如果使用 Homebrew 作为包管理工具的话,安装 MP4Box 也非常简单,直接一行命令即可。

1
brew install mp4box

而上面截图中出现的 Mp4 Explorer 则是 Windows 下的一个 GUI 工具,但目前貌似已经没有人维护了,代码归档在 CodePlex,下载之后使用 Visual Studio 编译一下即可运行。

moov Box

上文简析了 MP4 整体的结构,可以看到,与 TS、FLV 等专为流媒体设计的文件格式不同,一个典型的 MP4 文件,媒体数据的元数据都统一保存在 moov Box 中。所以播放 MP4 文件时,首先就需要解析 moov Box。
接下来就着重介绍一下 moov Box 的组成。

moov Box 的组成

对于一个典型的 MP4 文件的 moov Box,通常会包括下表中的子 Box(由于篇幅有限,这里仅列举了比较主要/重要的 Box)。

Brief Name Lv1 Box Lv2 Box Lv3 Box Lv4 Box Lv5 Box
媒体的基本信息 Movie Header mvhd
标示一个 Track/Stream Track trak
这条 Track 的基本信息 Track Header tkhd
Edit list container edts
Edit List elst
这条 Track 中的媒体信息 mdia
媒体的基本信息 Media Header mdhd
标示媒体类型(Video/Sound/..) Handler Reference hdlr
Media Information Container minf
视频基本信息 (Only in Video Track) Video Media Header vmhd
音频基本信息 (Only in Sound Track) Sound Media Header smhd
Data Information Container dinf
Track 中的 Sample 信息 Sample Table stbl
Sample Description stsd
Decoding Time to Sample stts
Sync Sample stss
Independent and Disposable Sample sdtp
Sample To Chunk stsc
Sample Size stsz
Chunk Offset stco

由于一个 MP4 文件中可能存在多种类型的媒体数据(视频、音频、字幕等),因此 moov Box 中可能包含多个 trak Box。这些子 Box 当中,除了记录一些基本信息(视频宽高、时长、默认速率、默认音量等),最重要的应该就是 stbl Box,因为这个 Box 中保存了 Sample 相关的信息。

Sample 和 Chunk

上面提到 moov Box 中最重要的 stbl Box 是用来保存 Sample 相关信息的,那么这里又引出一个概念:什么是 Sample?
在苹果的 QuickTime File Format 中,关于 Sample 的描述是这样的。

QuickTime stores media data in samples. A sample is a single element in a sequence of time-ordered data. Samples are stored in the media, and they may have varying durations.
Samples are stored in a series of chunks in a media. Chunks are a collection of data samples in a media that allow optimized data access. A chunk may contain one or more samples.

由这段话可以看出来,MP4 文件(或者说 mdat Box)中的媒体数据,是以 Sample 为最小单元进行保存,Sample 中会储存一组在时间上连续的数据。Sample 之上,又会有一层名为“Chunk”的容器。

那么,为什么要在媒体数据的基础上再封装出 Sample 和 Chunk 呢?以下是个人的一些理解和推测。

首先,MP4 中一般都至少会有一条视频 Track 和一条音频 Track,有些文件甚至会有多条视频/音频/文字 Track。如果是从数据组织的角度来说,最方便的方法是从 mdat Box 的地址 0 开始连续保存所有的视频数据,从视频数据结束的位置开始连续保存所有的音频数据。但这样会造成播放器读取数据时要频繁的寻址,音视频同步也会比较麻烦。
因此,现在的 MP4 文件一般会采用另一种数据组织方式,即存放几秒钟的视频数据,然后再存放几秒钟音频数据,如此反复,对于播放器来说,只需要顺序读取数据即可。这种方式被称为音视频交织(Interleaving)。
两种组织方式的示例可见下图。

在这样的数据组织方式之上,在保存数据时就需要精确控制好每一小段数据必须是在时间上连续、可播放的(如果是一段全部 B 帧或 P 帧的视频数据则失去了交织的意义)。因此 Sample 这个最小单位就应运而生了。
而有了 Sample 还不够,媒体播放不光是顺序播放,还会有 Seek、Trick Mode 的场景。这种情况下仍然按照细碎的 Sample 寻址的话,效率就比较低了,所以在 Sample 之上,又加了一层“Chunk”。

以上就是我个人对 Sample 和 Chunk 的理解,希望我说清楚了。由于刚接触 MP4 格式时,对于这里很是疑惑,虽然很多文章都提到了这两个概念,但都只是介绍了一下定义。但对于当时没什么多媒体开发经验的我来说,很难理解这两个概念背后真正代表的含义。

有了上面的铺垫再回头看 stbl Box 中保存的数据,可以发现基本上都是 Sample 和 Box 的位置、关系等,通过读取 stbl 就可以在一个相对精确的层面上获取到视频各个位置的索引信息。

FFmpeg 如何读取 moov Box

了解了 moov Box 的组成之后,下面稍微介绍一下 FFmpeg 是如何解析 moov Box 的。

FFmpeg (应该)是目前世界上最著名、使用最为广泛的开源多媒体工具库。其中包含了对各种格式、各种协议的多媒体数据的封装、解封、编码、解码、分割、合并等等功能,可以称为多媒体届的瑞士军刀。以下,就结合 FFmpeg 中的相关源码大概描述一下 moov Box 的解析流程。

FFmpeg 中读取 moov Box 的代码主要是位于 ./libavformat/mov.c 文件中。阅读代码的时候,我会习惯于首先大概浏览一下文件整体,建立一个大概的直观感受。
简单翻一下这个文件,可以看到有 7000 多行,一两百个方法,绝大多数方法的命名都是 mov_read_xxx,例如 mov_read_stsdmov_read_stsz 等等。这样就基本可以了解到,FFmpeg 中对于各个 Box 的解析,都是单独封装成函数进行调用。这样的代码结构清晰,应该很易读。

几个重要的结构体

mov.c中定义/引用了几个重要的结构体/表,在阅读具体代码之前最好先了解一下。

ff_mov_demuxer

这个结构体主要是挂载函数,很明显,是供外部调用的。从 .read_header 这个指针的名字也基本就可以看出,解析 Header 的入口就是这个指针挂载的 mov_read_header 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
AVInputFormat ff_mov_demuxer = {
.name = "mov,mp4,m4a,3gp,3g2,mj2",
.long_name = NULL_IF_CONFIG_SMALL("QuickTime / MOV"),
.priv_class = &mov_class,
.priv_data_size = sizeof(MOVContext),
.extensions = "mov,mp4,m4a,3gp,3g2,mj2",
.read_probe = mov_probe,
.read_header = mov_read_header,
.read_packet = mov_read_packet,
.read_close = mov_read_close,
.read_seek = mov_read_seek,
.flags = AVFMT_NO_BYTE_SEEK,
};

mov_default_parse_table

这是一个 MOVParseTableEntry 类型的数组。MOVParseTableEntry 的定义非常简单,包括一个 uint32_t 类型的成员 type,以及一个函数指针成员。源码如下。

1
2
3
4
5
6
/* those functions parse an atom */
/* links atom IDs to parse functions */
typedef struct MOVParseTableEntry {
uint32_t type;
int (*parse)(MOVContext *ctx, AVIOContext *pb, MOVAtom atom);
} MOVParseTableEntry;

而从 mov_default_parse_table 的定义来看,就是挂载各个 mov_read_xxx 函数的一个索引表。

1
2
3
4
5
{ MKTAG('A','C','L','R'), mov_read_aclr },
{ MKTAG('A','P','R','G'), mov_read_avid },
...
{ MKTAG('c','l','l','i'), mov_read_clli },
{ 0, NULL }

这里出现了一个 MKTAG 的 Macro,它定义在 libavutil/common.h 中。

1
#define MKTAG(a,b,c,d) ((a) | ((b) << 8) | ((c) << 16) | ((unsigned)(d) << 24))

虽然这里出现大量的位运算,看起来很复杂,但是静下心来仔细看下,实际上就是把 4 个 char(1 个字节) 按顺序拼成一个 unsigned int32(4 个字节)。
所以,mov_default_parse_table 这里就是为了可读性,将 Box 的名字转换为 uint32_t 类型的索引名。

MOVContext

这个结构体出现在很多 mov_read_xxx 函数的参数中。它的原始定义在 libavformat/isom.h,是解析 moov Box 过程中的一个总体性的 Context。其中挂载了另一个比较重要的结构体 AVFormatContextAVFormatContext 中包含了各条流的信息。

AVIOContext

这个结构体同样出现在很多函数的参数中。它的原始定义在 libavformat/avio.h。其中保存了数据 Buffer 的句柄。
这里需要注意的是,AVIO 是 FFmpeg 封装的一套数据访问的 API,比如 avio_readavio_skipavio_seek等等。函数的作用基本上可以通过函数声明猜出来,这里只需要留意到 avio_ 开头的函数都是数据访问即可。

解析大致流程

从上文提到的结构体 ff_mov_demuxer 中挂载的函数可以看出,mov_read_header() 应该是整个流程的入口。

mov_read_header()

跳过前面的各种声明、初始化及错误检查,7147 行开始,通过循环调用 mov_read_default() 来查找 moov Box。

1
2
3
4
5
6
7
8
9
10
/* check MOV header */
do {
if (mov->moov_retry)
avio_seek(pb, 0, SEEK_SET);
if ((err = mov_read_default(mov, pb, atom)) < 0) {
av_log(s, AV_LOG_ERROR, "error reading header\n");
mov_read_close(s);
return err;
}
} while ((pb->seekable & AVIO_SEEKABLE_NORMAL) && !mov->found_moov && !mov->moov_retry++);

找到之后,开始调用 mov_read_chapters() 读取章节信息,以及后续处理读取 timecode、计算 rfps 等。这些处理和 moov Box 的解析本身关系不大,暂时就不展开了。接下来集中精力看 mov_read_default()

mov_read_default()

这个函数的主体就在 6538 行的 while 循环中。
这个循环的处理虽然长,但逻辑很简单。就是先读取 4 个字节作为 size,再读取 4 个字节作为 type(参考上文提到的 Box 的结构)。然后通过 type 来匹配不同的解析函数,再调用相应的解析函数进行解析。中间繁花乱人眼的各种处理,基本上都是针对不同 Box 的一些特殊处理。
最终,解析完整个 moov Box 之后,函数退出。

参考

本文所引用的是最近发布的 FFmpeg 4.0 的源码。源码均可以在 FFmpeg 的 Git 仓库 中找到。我本人也比对过 3.3 和 2.8 版本中读取 moov Box 的逻辑,并未发现什么大的改动。

另外,本文写作过程中,参考了如下的链接。