容器格式和 Box
我们知道,如今常见的 MP4 文件通常是指以 H264 为视频编码格式,AAC 为音频编码格式,以MP4(MPEG-4 Part 14)作为容器格式的文件,容器格式也常常被称为封装格式。
当然,MP4 文件也可以使用其他编码格式,比如 H265、Opus 等,也可以使用其他容器格式,比如 MKV、WebM 等。因此,MP4 文件并不一定是 H264+AAC+MP4 的组合,它可以是任意编码格式+任意容器格式的组合。
所谓编码格式,通常是由一些算法和规则组成的,它们可以将原始的音视频数据进行压缩,以达到减小文件大小的目的。
而容器格式,通常是由一些规则组成的,它们可以将编码后的音视频数据进行封装,以达到方便存储和传输的目的。
大部分的容器格式,都可以理解为是由一系列的 Box 组成。Box 是容器格式的基本组成单位,每个 Box 分为两部分,一部分是 Box Header,另一部分是 Box Body。而 Box Header 一般是 8 字节长度,前 4 字节是 Box 的长度,后 4 字节是 Box 的类型。
Box 是可以嵌套的,一个 Box 可以包含多个子 Box,这些子 Box 可以是同类型的,也可以是不同类型的。
下面我们只简单介绍 MP4 文件中常见的第一层 Box 类型,包括 ftyp,free,moov,mdat等。
MP4(MPEG-4 Part 14)容器格式的第一个 Box,类型一定是 ftyp,它的作用是标识文件的类型,表明是 MP4 文件,还是 MKV 文件等。
MP4 文件示例
我们使用 mp4info.exe 打开一个 MP4 文件查看,可以看到其中的 Box 组成,以及 Box 中的二进制数据。左边我们可以看到第一层有四个 Box,分别是 ftyp,free,mdat,moov。
注意,Box 的顺序不是固定的。
第一个 Box 类型一定是 ftyp,有且仅有一个,其中的数据通常是固定的,即不同的 MP4 文件,其 ftyp Box 内容都是一样的。
moov Box 也是有且仅有一个,顺序是不固定的,但一般推进放在 mdat Box 之前。而 free Box 和 mdat Box 的数量和顺序都是不固定的,mdat Box 通常是最后一个 Box,但也不是一定的。
下面是用 mp4box.js 解析一个 MP4 文件得到的 Box 列表,可以看到其中的 Box 类型和 Box 大小,以及 Box 数据在二进制文件 Buffer 中的起始位置。
下面我们基于 MP4 示例文件的数据,来简单解释一些常见的 Box 类型。
ftyp Box
首先是 ftype Box 中字节数据的解释,mp4info 中看到的数据,都是 16 进制展示,所以前 8 字节中,00 00 00 20 代表长度 32,66 74 79 70 代表类型 ftyp,66 74 79 70 的 ASCII 编码就对应 ftyp。
下面是 ASCII 编码可视字符编码表,包括 16 进制和 10 进制对应的数值。
free Box
free Box,是一种特殊的 Box 类型,设计目的是为了在文件中提供一些未使用的空间,允许文件在未来进行扩展或修改时,可以使用这些未使用的空间,而无需重新分配整个文件。这在编辑或修改MP4文件时非常有用,可以避免对整个文件进行重新编码或重新写入。
另外,Free Box 还可以用于填充文件中的空闲空间,以保持文件的结构或对齐要求。这有助于提高文件的读取效率和性能。
大部分 MP4 文件中,Free Box 都没有用到,其 Box Body 通常是 0 字节,也就是说 Box 长度只有 Box Header 的 8 字节。
下面是 MP4 示例文件中 free Box 的二进制数据,下面的截图是来自 Chrome 控制台,是用 10 进制表示字节数据。
前 4 字节代表长度 8,后 4 字节 102 114 101 101,根据 ASCII 编码,对应 free。
mdat Box
mdat Box 是 MP4 文件中最主要的数据 Box,一般占据 MP4 文件中 99% 以上的大小。mdat Box 中的数据即是经过编码的音视频数据,比如 H264 编码的视频数据,AAC 编码的音频数据等。
下面是 MP4 示例文件中 mdat Box 前 8 字节的数据。
其中前4字节 3 79 73 63 代表长度 55527743,后 4 字节 109 100 97 116 代表类型 mdat。
这里代表长度的 4 字节 3 79 73 63 有点复杂,下面给出它的计算方式。
js
// 通过位运算,将 byte 转为 number
function bytesToNumber(bytes) {
var number = 0;
for (var i = 0; i < bytes.length; i++) {
number = (number << 8) | bytes[i];
}
return number;
}
bytesToNumber([3,79,73,63]) // 55527743
// 也可以通过 DataView 获取
new DataView(new Uint8Array([3,79,73,63]).buffer).getUint32(0) // 55527743
moov Box
moov Box 用于存储媒体的元数据,比如视频的分辨率、帧率、码率,音频的采样率、声道数等。
moov Box 相当于 mdat Box 中数据的目录。moov Box 中包含了 mdat 中每个 sample 的大小、位置和 dts、cts 等信息,这些信息可以用于解析 mdat Box 中的数据。
moov Box 的存在,使得解析和处理媒体文件变得更加简单和高效。
在播放媒体文件时,通常需要先解析moov Box来获取媒体的基本信息,然后再根据这些信息进行媒体的解码和播放操作。
moov Box 包含大量的子 Box,但 moov Box 占 MP4 文件的大小通常只有 1% 左右。
moov 前置与后置
这里的前置与后置,是指 moov box 相对于 mdat box 的位置。
moov 前置是指moov Box位于文件的开头,紧随 ftyp Box。中间多了一个 free Box 无关紧要。
因为读取视频文件 Buffer 一般是从头开始按顺序读取,所以 moov 前置这种布局方式使得解析器可以快速读取并获取媒体的基本信息。
如媒体的时长、编解码器信息、媒体轨道数据描述信息等。这样可以更快地初始化解码器和播放器。
这样在播放媒体时可以更快地开始播放,更快地定位到特定的时间点。
moov 后置是指 moov Box 位于文件的末尾,放在 mdat 后面。前文的 MP4 示例视频就是 moov 后置的布局方式。
moov 后置是为了更方便生成视频,因为 moov 作为 mdat 的目录,必须等 mdat 数据都已经全部编码之后,才能生成 moov。
但是 moov 后置的情况,可能会导致网页播放视频时,需要先下载完整个视频文件,才能开始播放。
当然现在的浏览器都支持 moov 后置的视频文件,能够智能地从尾部读取 moov 数据。
moov 后置如何快速读取
那么我们若自己使用 mp4box.js 之类的库来分析 MP4 视频,可以通过什么方式,来快速读取位于视频尾部的 moov 数据呢?
通过前文 Box 的介绍,我们知道 MP4 视频中的 Box 都是有规律的,每个 Box 的前 8 个字节代表 Box 的长度和类型,所以我们可以通过这个规律,来快速读取 moov Box 的位置。
如果是本地文件读取,无论是 Node 端读取文件,还是浏览器页面读取 File,其实都可以不用太考虑 moov 后置的情况,一般处理速度都是可以接受的。
我们需要重点考虑的场景是,通过网络下载视频文件时,如何快速读取 moov Box 的位置。
可以先发送 HTTP HEAD 请求,获取视频文件的大小,然后再通过 HTTP Byte Range 请求,请求视频文件头部的一部分数据,尝试分析其中包含的 Box 信息。
如果 moov 前置,那么这次请求的数据中,大概率已经完整包含了 moov 数据。
如果 moov 后置,那么这种请求的数据中,也已经包含了 mdat box 的起始位置和大小。
结合文件总大小,以 mdat box 的结束位置为起点(这个位置大概率接近文件总大小),再次发送 HTTP Byte Range 请求,获得视频文件的尾部数据,这次获得的数据,大概率就是 moov box 的数据。
将这些 moov box 数据,和前面获得的视频首部数据,一起添加到 mp4box 解析器中,就可以快速解析出视频的基本信息了。
下面是相关的基础代码。
js
const getInfoFromArrayBuffer = async (arrayBufferList, size) => {
return new Promise((resolve, reject) => {
var mp4box = MP4Box.createFile();
mp4box.onReady = function (info) {
const result = {
size,
info,
moov: mp4box.moov,
boxes: mp4box.boxes,
}
resolve(result);
};
mp4box.onError = function (e) {
reject(e);
};
let fileStart = 0;
arrayBufferList.forEach((arrayBuffer) => {
arrayBuffer.fileStart = fileStart;
mp4box.appendBuffer(arrayBuffer);
fileStart += arrayBuffer.byteLength;
});
});
};
const fetchFileSize = async (url) => {
return new Promise((resolve, reject) => {
fetch(url, {
method: "HEAD",
})
.then((res) => {
resolve(res.headers.get("Content-Length"));
})
.catch((err) => {
reject(err);
});
});
};
/** 按 byte range 方式读取视频数据 */
const fetchByteRange = async (url, byteStart, byteEnd) => {
return new Promise((resolve, reject) => {
fetch(url, {
headers: {
Range: `bytes=${byteStart}-${byteEnd - 1}`,
},
})
.then((response) => {
if (!response.ok) {
reject(response.statusText);
}
resolve(response.arrayBuffer());
})
.catch((err) => {
reject(err);
});
});
};