无需下载即可读取 2GB 文件的 MP4 元数据
MP4 将时长和编解码器信息保存在一个 moov box 中。以下是如何通过 HTTP Range 仅获取该 box,并跳过数 GB 的媒体负载。
我们有一个工作进程,需要获取成千上万个已上传视频的时长、分辨率和编解码器,仅此而已。朴素版本会完整下载每个文件,读取几百字节的头部信息,然后丢弃其余部分。对于一个 2GB 的视频,这意味着为了获取四个数字就要传输 2GB 的数据。本文将展示如何通过理解 MP4 的布局方式并使用 Range 请求来仅获取重要的部分,从而直接通过 HTTP 读取该头部信息,只传输几千字节而不是整个文件。
问题所在:你需要从 2 吉字节中获取 200 字节
像时长、宽度、高度和编解码器这样的元数据位于一个小的结构化头部中。实际的音频和视频采样是文件的其余部分,而对于一个长视频来说,其余部分几乎就是全部内容。一个 2GB 的文件可能包含几千字节的元数据和 1.999GB 的媒体采样。
如果你的服务只是偶尔处理一个视频,那么为了读取头部而下载整个文件虽然浪费但尚可接受。如果一个工作程序集群需要对成千上万的上传文件进行分类,那么完整的下载过程将主导一切。它会消耗你的对象存储的出口带宽,在大型传输的整个过程中保持连接开放,并且为一个本应在远不到一秒内完成的任务增加了实际的延迟。传输时间与工作量不成正比;它与文件大小成正比,而为文件大小付费是错误的。
目标是让成本与元数据成正比,而不是与媒体数据成正比。要做到这一点,你需要两样东西:一份元数据在文件中位置的分布图,以及一种只获取该区域而不读取其周围字节的方法。
MP4 如何以 box 的形式布局
MP4 文件是一个扁平的 box 序列,有时也称为 atom。box 是最简单的容器。它以一个 8 字节的头部开始:一个 4 字节的大端序大小,然后是一个 4 字节的类型,例如 ftyp、moov 或 mdat。这个大小包含了头部本身,所以一个大小为 8 的 box 是一个只包含头部的空 box。头部之后是 box 的内容,内容会一直延续到大小耗尽,然后下一个 box 开始。
在一个典型文件的顶层,你会看到少量的 box:
ftyp:文件类型和兼容品牌。很小,总是在文件靠前的位置。moov:电影 box。这是元数据容器。时长、轨道列表、分辨率、编解码器参数和样本表都存放在它里面。相对于媒体数据,它很小,通常是几 KB 到几 MB。mdat:媒体数据。这是音频和视频的原始样本字节。对于任何实际的视频来说,这部分都占了文件的绝大部分。
也可能存在其他的 box,并且格式并未固定其顺序。有两个事实使得低成本解析成为可能。首先,每个 box 都在其头部声明了自己的大小,所以你可以在不读取当前 box 内容的情况下找到下一个 box 的起始位置。其次,你想要的元数据完全在 moov 内部,而 mdat 不包含任何基础元数据所需的内容。因此,策略不言而喻:读取 box 头部,通过计算跳过 mdat,并且只读取 moov 内部的字节。
使用 Range 请求读取一个 box 标头
每一步都依赖于能够在已知偏移量处读取一小段字节范围。在 HTTP 上,这就是一个 Range 请求。你请求字节 N 到 M,支持范围请求的服务器会以 206 Partial Content 响应,并只返回那些字节。服务器通过 Accept-Ranges: bytes 标头来宣告此功能,而像 S3 兼容存储这样的对象存储默认支持它。
这里是一个获取精确字节范围的辅助函数,以及一个从给定偏移量读取并解码单个 box 标头的函数:
package mp4
import (
"encoding/binary"
"fmt"
"io"
"net/http"
)
// fetchRange returns bytes [start, start+length) from url using an HTTP Range request.
func fetchRange(client *http.Client, url string, start, length int64) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
end := start + length - 1
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
return nil, fmt.Errorf("range not honored, got status %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, length))
}
type boxHeader struct {
size int64 // total box size including the header
boxType string
headerSize int64 // 8 for 32-bit size, 16 for 64-bit size
}
// readBoxHeader reads the 8 byte header at offset, expanding to 16 bytes for a 64-bit size.
func readBoxHeader(client *http.Client, url string, offset int64) (boxHeader, error) {
buf, err := fetchRange(client, url, offset, 8)
if err != nil {
return boxHeader{}, err
}
size := int64(binary.BigEndian.Uint32(buf[0:4]))
boxType := string(buf[4:8])
return boxHeader{size: size, boxType: boxType, headerSize: 8}, nil
}
一次标头获取在网络传输中是 8 字节外加 HTTP 开销。请求和响应标头的开销远大于 8 字节的正文,因此在实践中,每个 box 标头需要付出几百字节的代价。一个包含少数几个顶层 box 的文件会产生少数几个这样的请求。这就是关键所在:传输量现在与 box 的数量成正比,而不是与文件的大小成正比。
通过算术运算遍历 box 并跳过 mdat
有了 header 读取器,顶层遍历就是一个循环。在当前偏移量处读取 header,查看其类型,然后决定做什么。如果是 moov,你就找到了元数据,可以获取整个 box 来解析它。如果是其他任何类型,包括巨大的 mdat,你都完全不需要读取其内容。你将其大小加到偏移量上,然后移动到下一个 box。
// findMoov walks top-level boxes and returns the byte range of the moov box.
// It never reads the contents of mdat or any other box it skips.
func findMoov(client *http.Client, url string, fileSize int64) (start, length int64, err error) {
var offset int64
for offset < fileSize {
h, err := readBoxHeader(client, url, offset)
if err != nil {
return 0, 0, err
}
if h.size < h.headerSize {
return 0, 0, fmt.Errorf("invalid box size %d at offset %d", h.size, offset)
}
if h.boxType == "moov" {
return offset, h.size, nil
}
// Skip this box entirely. This is the key move: for mdat we jump
// past gigabytes of media without transferring any of it.
offset += h.size
}
return 0, 0, fmt.Errorf("moov box not found")
}
offset += h.size 这一行代码正是节省开销的关键所在。当遍历到达一个 1.9GB 的 mdat 时,它不会流式传输、缓冲或接触它。它会读取 8 字节的 box 头,获取其大小,然后将该大小加到偏移量上。下一个请求会落在 mdat 之后的 box 上。媒体数据通过纯粹的偏移量算术运算被跳过。
一旦 findMoov 返回一个范围,再发送一个 Range 请求会将整个 moov box 拉取到内存中,你可以在其中解析嵌套的 box 以提取时长和轨道信息。由于 moov 很小,完整读取它的成本很低,也比在其中进行子范围请求更简单。
传输的字节数差异是显著的:
| 方法 | 传输一个 2GB 文件所需的字节数 | 请求次数 |
|---|---|---|
| 完整下载 | ~2,000,000,000 | 1 |
| Range 遍历,moov 靠近文件头部 | 几 KB 到较低的 MB | 3 到 6 |
| Range 遍历,moov 位于文件末尾 | 同样是几 KB 到较低的 MB | 额外几次 |
对于 Range 遍历,传输的数据量是 moov 的大小加上几个 box 头的大小,无论媒体有多大。检查一个 200MB 的视频和一个 20GB 的视频,成本大致相同。
关键在于:moov 的位置决定了延迟
这种格式并未规定 moov 的位置,而它的位置完全决定了遍历完成的速度。
有些文件以 faststart 方式写入,这意味着 moov 被移到了文件开头,紧跟在 ftyp 之后、mdat 之前。这些文件专为流式传输而设计,因为播放器可以立即读取元数据,而无需寻道到文件末尾。对于 faststart 文件,遍历过程在读取第一个或第二个标头时就能找到 moov,然后就完成了。
其他文件则将 moov 放在末尾,即 mdat 之后。这对于录制文件和任何通过单次顺序写入操作生成的文件来说很常见,因为写入器在写完所有样本之前,并不知道 movie box 的最终大小,所以它最后才追加 moov。对于这些文件,顶层遍历必须跳过 mdat 才能到达 moov。好消息是,跳过 mdat 仍然只是 offset += h.size,所以它只会增加一次额外的标头读取成本,而不会下载媒体数据。
有一个值得了解的优化方法。如果你第一步前向遍历就遇到了 mdat,你可以猜测 moov 位于文件尾部,并通过 Range 请求直接获取文件的最后一块数据,然后向后扫描或解析该尾部区域。一种常见的启发式方法是请求最后约 256KB 的数据,并在其中寻找 moov 类型。这将一个两步的遍历变成了一次单独的尾部获取操作。这是一种优化,而非必要操作;无论如何,普通的向前遍历已经避免了下载媒体数据。
唯一真正会增加成本的情况是,mdat 声明的大小为零或延伸至文件末尾,某些格式用它来表示“直至文件结束”。当你遇到这种情况时,应将文件的剩余部分视为媒体数据,并切换到尾部策略来定位 moov。
处理 64 位大小和嵌套 box
有两个细节将玩具解析器和能处理真实文件的解析器区分开来。
第一个是大尺寸。32 位的大小字段最大值不足 4GB,这对于一个大的 mdat 来说是不够的。该格式通过一个转义值来处理这种情况。如果 32 位大小等于 1,则真实大小是一个 64 位的值,存储在紧跟类型之后的 8 个字节中,这使得头部变为 16 字节,而不是 8 字节。大小为 0 意味着该 box 一直持续到文件末尾。你的头部读取器必须在信任这个 32 位数字之前检查这些情况:
func readBoxHeaderFull(client *http.Client, url string, offset, fileSize int64) (boxHeader, error) {
buf, err := fetchRange(client, url, offset, 16) // read enough for a 64-bit header
if err != nil {
return boxHeader{}, err
}
size := int64(binary.BigEndian.Uint32(buf[0:4]))
boxType := string(buf[4:8])
switch size {
case 1:
// 64-bit largesize follows the type field.
size = int64(binary.BigEndian.Uint64(buf[8:16]))
return boxHeader{size: size, boxType: boxType, headerSize: 16}, nil
case 0:
// Box extends to the end of the file.
size = fileSize - offset
return boxHeader{size: size, boxType: boxType, headerSize: 8}, nil
default:
return boxHeader{size: size, boxType: boxType, headerSize: 8}, nil
}
}
第二个细节是嵌套。moov 不是一个叶子节点。在它内部有 mvhd(用于包含总时长和时间标度的影片标头),以及每个轨道对应的一个 trak。在每个 trak 内部,是一个由 mdia、minf、stbl 组成的链,最后是 stsd,即指定编解码器的样本描述。解析嵌套时会递归地使用相同的标头逻辑,但它作用于内存中的 moov 字节而不是通过 HTTP,因为你已经获取了整个 box。你遍历子节点的方式与遍历顶层的方式相同:读取一个标头,根据类型执行操作,然后按大小前进。唯一的区别是,像 trak 这样的容器 box 意味着你会深入其内容,而不是跳过它们。
你不需要在 moov 内部进行范围请求 (Range-request)。一次性获取 moov 的全部意义就在于,你想要的一切现在都在本地了,而本地解析只是简单的字节切片运算。
何时下载整个文件是更好的选择
当文件是远程的、体积很大,而你又只需要一小部分文件头时,范围解析是正确的工具。但凡其中任何一个条件反过来,考量就变了。
- 文件是本地的,或已在内存中。没有需要节省的传输。直接打开并读取,或将其交给一个库处理。范围解析逻辑只会增加复杂性。
- 服务器不支持范围请求。检查
Accept-Ranges: bytes响应头,或发送一个范围请求并确认收到206响应。如果你收到的是200响应和完整的响应体,那么服务器忽略了你的范围请求,你终究还是在下载整个文件。一些 CDN 和配置错误的源服务器会这样做。 - 无论如何你都将要读取文件的大部分内容。如果下一步是转码、为每个场景生成缩略图,或对整个文件进行哈希计算,那么你很快就会拉取所有字节。单独读取文件头只会在实际工作开始前增加一次额外的网络往返。
- 你需要的元数据不在
moov中。分片 MP4(用于自适应流媒体的那种)将时间信息分布在整个文件中的各个moofbox 里。如果你需要每个分片的详细信息而不是顶层摘要,一次单独的文件头抓取无法提供这些信息,并且访问模式也不同。
对于常见的 worker 场景,即处理远程对象存储中的大型媒体文件,且只需要获取时长和编解码器信息时,范围遍历(Range walk)将一个成本与文件大小成正比的任务,转变为一个成本与元数据大小成正比的任务。这正是一个能够廉价地检查数千个视频的集群,与另一个将其大部分时间和出口带宽预算花费在传输那些立即被丢弃的字节上的集群之间的区别。