3. 音视频流封装:MP4、TS、FLV 中时间戳的存储与提取
做音画同步,说白了就是跟时间戳打交道。但时间戳这玩意儿,在不同容器格式里藏的地方、存的格式、甚至含义都不一样。我早年刚接触封装层时,以为时间戳就是简单的「第几秒」,结果被 MP4 的 mdhd box 和 TS 的 PCR 狠狠教育了一顿。
今天咱们就把 MP4、TS、FLV 这三种最常见的容器格式扒开看看。时间戳到底存哪儿?怎么读?有什么坑?
3.1 时间戳在容器中的角色
先统一一下概念。无论什么容器,最终要告诉播放器的无非两件事:
- PTS(显示时间戳):这一帧画面/这一段声音,什么时候该显示出来。
- DTS(解码时间戳):解码器什么时候开始解这一帧。
对于音频,PTS 和 DTS 通常一样。视频因为有 B 帧,两者可能不同。容器格式要负责把这两组时间戳,或者能推导出它们的信息,完整地存下来。
核心原则:时间戳的精度和基准确认,决定了音画同步的最终误差。容器格式只是载体,但提取错了,后面全白搭。
3.2 MP4:基于 Box 的时间戳体系
MP4 是我个人最常用的封装格式。它的时间戳不直接写在每个样本上,而是通过 「样本描述 + 时间轴」 的方式间接存储。
3.2.1 关键 Box 结构
MP4 文件由一个个 Box(也叫 Atom)嵌套组成。跟时间戳直接相关的有这几个:
| Box 名称 | 全称 | 作用 |
|---|---|---|
moov |
Movie Box | 元数据根容器,时间戳信息都在它下面 |
trak |
Track Box | 每个音视频轨道一个,包含该轨道的时序信息 |
mdhd |
Media Header Box | 定义该轨道的时间基准(timescale)和时长 |
stbl |
Sample Table Box | 样本表的根,下面有 stts、stss、stsz 等子 Box |
stts |
Time-to-Sample Box | 存储每个样本的时长(delta),用于计算 PTS |
ctts |
Composition Time-to-Sample Box | 存储 PTS 相对于 DTS 的偏移量(有 B 帧时必需) |
3.2.2 时间戳提取方法
提取 MP4 时间戳,我习惯按以下步骤来:
- 读
mdhd:拿到timescale(每秒的 tick 数)。比如timescale = 90000,表示时间单位是 1/90000 秒。 - 读
stts:遍历所有 entry,每个 entry 包含sample_count和sample_delta。累加 delta 就能算出每个样本的 DTS。 - 读
ctts(如果存在):每个样本有一个composition_offset。PTS = DTS + composition_offset。 - 换算为秒:PTS(秒) = PTS(tick) / timescale。
避坑指南:我曾经在解析一个从非编软件导出的 MP4 时,发现视频画面比音频快了大约一帧。排查了半天,发现是 ctts box 里的偏移量符号搞反了。标准规定 composition_offset 是 无符号整数,但有些编码器会按有符号存。遇到这种情况,建议先检查 version 字段——version=1 时偏移量可能是带符号的。
3.2.3 代码示例:提取 MP4 视频轨道的 PTS
// 伪代码:解析 stts 和 ctts 得到 PTS 列表
function extractMP4PTS(moovBox, trackId) {
let trak = findTrack(moovBox, trackId);
let mdhd = trak.findBox('mdhd');
let timescale = mdhd.timescale; // 例如 90000
let stts = trak.findBox('stbl').findBox('stts');
let ctts = trak.findBox('stbl').findBox('ctts');
let dtsList = [];
let currentDTS = 0;
for (let entry of stts.entries) {
for (let i = 0; i < entry.sample_count; i++) {
dtsList.push(currentDTS);
currentDTS += entry.sample_delta;
}
}
let ptsList = [];
let cttsIndex = 0;
for (let i = 0; i < dtsList.length; i++) {
let offset = ctts ? ctts.entries[cttsIndex].composition_offset : 0;
ptsList.push(dtsList[i] + offset);
// 处理 ctts 的 sample_count 累进
if (ctts) {
ctts.entries[cttsIndex].sample_count--;
if (ctts.entries[cttsIndex].sample_count === 0) cttsIndex++;
}
}
// 换算为秒
return ptsList.map(tick => tick / timescale);
}
3.3 TS:基于 PCR 和 PTS/DTS 的实时流
TS(Transport Stream)是广播电视和直播领域的老大哥。它的时间戳体系跟 MP4 完全不同——不是集中式的元数据,而是 分散在包里的时间标签。
3.3.1 关键元素
- PCR(Program Clock Reference):系统时钟参考,27MHz 精度。解码器用它来恢复本地时钟。
- PTS / DTS:每个 PES 包头部可以携带,90kHz 精度(33 位 + 扩展位)。
TS 流里,PCR 通常每 100ms 至少出现一次。PTS 则每个视频帧或每帧音频的 PES 头里都有。
3.3.2 提取方法
提取 TS 时间戳,说白了就是两步:
- 找到 PES 包:从 TS 包(188 字节)的 payload 里,根据 stream_id 定位到音视频 PES。
- 解析 PES 头:检查
PTS_DTS_flags字段。如果为 '10',只有 PTS;如果为 '11',PTS 和 DTS 都有。
注意:TS 的 PTS 是 33 位,但 PES 头里是按 3 个字节 + 2 个字节的奇怪方式存储的。我第一次手动解析时,被那个 bit 排列绕晕了。标准里写的是:先读 4 位标志,再读 3 位 PTS 的高位,再读 1 位标志……建议直接用现成的库,或者严格按照 ISO 13818-1 的位图来写。
3.3.3 PCR 与 PTS 的关系
PCR 是绝对时钟,PTS 是相对时间。播放器的工作流程是:
- 用 PCR 校准本地时钟。
- 当本地时钟到达某个 PTS 值时,显示对应的帧。
所以,如果 PCR 抖动大,或者 PTS 与 PCR 的差值不稳定,音画就会出问题。我在做直播延迟优化时,就遇到过编码器 PCR 间隔不均匀导致播放器缓冲溢出的情况。
3.4 FLV:基于 Tag 的简单时间戳
FLV 是 RTMP 和网页直播的常客。它的时间戳存储方式,我个人觉得是三种里最直观的。
3.4.1 Tag 结构
每个 FLV Tag(音频或视频)都有一个 4 字节的时间戳字段:
- 前 3 字节:时间戳(毫秒),低 24 位。
- 第 4 字节:时间戳扩展(毫秒),高 8 位。
- 组合起来:一个 32 位无符号整数,单位是毫秒。
3.4.2 提取方法
提取 FLV 时间戳,几乎就是直接读:
// 读取 FLV Tag 的时间戳
uint32_t readFLVTimestamp(uint8_t* tagHeader) {
// tagHeader[0..2] 是低 24 位,tagHeader[3] 是高 8 位
uint32_t timestamp = (tagHeader[0] << 16) | (tagHeader[1] << 8) | tagHeader[2];
timestamp |= (tagHeader[3] << 24);
return timestamp; // 单位:毫秒
}
经验之谈:FLV 的时间戳是单调递增的,但要注意 回绕。32 位毫秒最多表示约 49.7 天。对于长直播,时间戳会溢出。我见过一些播放器在回绕后直接崩溃。解决办法是检测回绕,然后加上一个偏移量。
3.5 三种容器时间戳对比
| 特性 | MP4 | TS | FLV |
|---|---|---|---|
| 时间戳存储方式 | 集中式 Box(stts/ctts) | 分散式 PES 头 + PCR | 每个 Tag 头部 |
| 时间单位 | timescale(自定义,如 90000) | 27MHz(PCR)/ 90kHz(PTS) | 毫秒 |
| 精度 | 取决于 timescale,通常很高 | 极高(PCR 27MHz) | 1 毫秒 |
| 是否直接可读 | 需累加计算 | 需解析 PES 头 | 直接读取 |
| 常见问题 | ctts 偏移量符号、timescale 不一致 | PCR 抖动、PTS 缺失 | 时间戳回绕 |
3.6 本章知识体系图
下面这张图,把三种容器的时间戳存储位置和提取路径串起来了。你想想看,无论哪种格式,最终都要落到「PTS/DTS 换算为秒」这一步。
3.7 提取时间戳的通用原则
不管用哪种容器,有几点是通用的:
- 先确认时间基准:MP4 看
timescale,TS 看 PCR 的 27MHz,FLV 固定毫秒。基准搞错了,时间戳全歪。 - 注意偏移量:MP4 的
ctts、TS 的 PTS/DTS 差值,都是音画同步的关键。忽略它们,B 帧场景下画面会乱序。 - 处理异常:时间戳回绕、缺失、非单调递增,这些我在实际流里都遇到过。健壮的提取逻辑必须包含这些边界情况。
再次提醒:不要假设时间戳一定是单调递增的。有些编码器在 seek 或拼接后,会输出乱序的时间戳。提取时最好做一次排序或重映射,否则音画同步算法会直接崩溃。
嗯,容器层的时间戳提取,说白了就是「找到位置、读对格式、换算成秒」。下一层,我们就要拿着这些时间戳,去真正做音画同步的匹配和校正了。