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 时间戳,我习惯按以下步骤来:

  1. 读 mdhd:拿到 timescale(每秒的 tick 数)。比如 timescale = 90000,表示时间单位是 1/90000 秒。
  2. 读 stts:遍历所有 entry,每个 entry 包含 sample_count 和 sample_delta。累加 delta 就能算出每个样本的 DTS。
  3. 读 ctts(如果存在):每个样本有一个 composition_offset。PTS = DTS + composition_offset。
  4. 换算为秒: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 时间戳,说白了就是两步:

  1. 找到 PES 包:从 TS 包(188 字节)的 payload 里,根据 stream_id 定位到音视频 PES。
  2. 解析 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 换算为秒」这一步。

三种容器时间戳存储与提取路径 容器格式时间戳存储与提取路径 MP4 容器 TS 容器 FLV 容器 moov → trak → stbl PES 包头部 Tag 头部 关键 Box mdhd (timescale) stts (delta) + ctts (offset) PES 头字段 PTS_DTS_flags PTS (33bit) / DTS (33bit) Tag 头字段 Timestamp (24bit) TimestampExtended (8bit) PTS / DTS(tick 值) 单位:MP4(tick) / TS(90kHz) / FLV(ms) 换算为秒:PTS(秒) = PTS(tick) / 时间基准

3.7 提取时间戳的通用原则

不管用哪种容器,有几点是通用的:

  • 先确认时间基准:MP4 看 timescale,TS 看 PCR 的 27MHz,FLV 固定毫秒。基准搞错了,时间戳全歪。
  • 注意偏移量:MP4 的 ctts、TS 的 PTS/DTS 差值,都是音画同步的关键。忽略它们,B 帧场景下画面会乱序。
  • 处理异常:时间戳回绕、缺失、非单调递增,这些我在实际流里都遇到过。健壮的提取逻辑必须包含这些边界情况。

再次提醒:不要假设时间戳一定是单调递增的。有些编码器在 seek 或拼接后,会输出乱序的时间戳。提取时最好做一次排序或重映射,否则音画同步算法会直接崩溃。

嗯,容器层的时间戳提取,说白了就是「找到位置、读对格式、换算成秒」。下一层,我们就要拿着这些时间戳,去真正做音画同步的匹配和校正了。