2. 时间戳原理:PTS(显示时间戳)和DTS(解码时间戳)的深度解析

上一章我们聊了音画同步为什么是个麻烦事。这一章,咱们直接切入核心——时间戳。

说白了,时间戳就是给每一帧画面、每一段音频贴上的「时间标签」。没有这个标签,播放器就是个瞎子,根本不知道什么时候该干什么。

我个人习惯把时间戳理解成「快递单号」。视频文件就像一箱快递,解码器是分拣员,显示器是收件人。PTS 和 DTS 就是贴在每个包裹上的两张单子,告诉分拣员和收件人:这包裹该什么时候处理、什么时候签收。

2.1 为什么需要两个时间戳?

你可能会问:一个时间戳不就够了吗?为什么非要搞出 PTS 和 DTS 两个?

嗯,这里有个关键点——视频编码不是按顺序来的。

想想看,我们看的视频是连续的。但编码器为了压缩效率,搞出了三种帧:

  • I 帧(关键帧):完整的画面,可以独立解码。相当于一本书的目录页。
  • P 帧(前向预测帧):只记录与前一帧的差异。解码它之前,必须先有前一帧。
  • B 帧(双向预测帧):记录与前后两帧的差异。解码它之前,前后两帧都得准备好。

问题就出在 B 帧上。举个例子:

假设一个视频的帧顺序是:I1 - B2 - P3 - B4 - P5

播放顺序(PTS)是:1, 2, 3, 4, 5

但解码顺序(DTS)呢?B2 需要 P3 才能解码,所以解码器必须先拿到 P3,再回头解 B2。实际解码顺序是:I1, P3, B2, P5, B4

看到了吗?解码顺序和显示顺序完全不一样。这就是为什么我们需要两个时间戳。

核心结论:

  • DTS(Decoding Time Stamp):告诉解码器「什么时候开始解这一帧」
  • PTS(Presentation Time Stamp):告诉显示器「什么时候显示这一帧」

对于没有 B 帧的编码(比如大部分直播场景),PTS 和 DTS 是相等的。但只要有 B 帧,两者就必然不同。

2.2 时间戳的「时间基」

聊时间戳,绕不开一个概念——时间基(Time Base)。

我记得刚入行时,看到 FFmpeg 里一堆 AVRational 结构体,完全懵了。后来才明白,时间基其实就是「刻度」的精度。

比如:

  • 时间基是 1/1000,意味着每个刻度代表 1 毫秒
  • 时间基是 1/90000,每个刻度代表约 11 微秒(MPEG-TS 标准)

时间戳的值乘以时间基,才是真正的秒数。

// FFmpeg 中时间基的定义
typedef struct AVRational {
    int num;   // 分子
    int den;   // 分母
} AVRational;

// 示例:时间基为 1/1000
AVRational time_base = {1, 1000};

// 时间戳值 5000,对应实际时间
double seconds = 5000 * (1.0 / 1000);  // = 5.0 秒

实战经验:我在做播放器时,遇到过时间戳溢出问题。有些视频的时间基是 1/90000,时间戳值很大,如果用 32 位整数存,超过 2^31 就会溢出。后来统一改用 64 位整数,再也没出过问题。

2.3 PTS 和 DTS 在容器中的存储

不同的封装格式,存储时间戳的方式也不同。我整理了一个表格:

封装格式 时间基 存储方式 常见问题
MP4 1/90000 或自定义 每个 sample 有 pts/dts,dts 可推导 B 帧场景下 pts/dts 顺序可能颠倒
TS 1/90000(固定) PES 包头携带 pts/dts 标志位 33 位时间戳,约 26.5 小时回绕
FLV 1/1000(毫秒) 每个 tag 头部携带 24/32 位时间戳 超过 4.66 小时会溢出
MKV 1/(纳秒精度) 每个 block 有相对时间戳 精度高但计算复杂

这里有个坑——我曾经在解析 FLV 时,发现播放到 5 小时左右画面突然卡住。查了半天,原来是 FLV 的时间戳只有 32 位,单位是毫秒,最大值约 4.66 小时。超过这个值,时间戳回绕到 0,播放器以为回到了起点。

避坑指南:处理 FLV 或 TS 流时,一定要做时间戳回绕检测。我一般用「连续差值法」:如果当前时间戳比上一帧小很多(比如差值超过 2^31),就认为是回绕,加上一个偏移量。

2.4 时间戳的同步机制

有了 PTS 和 DTS,播放器怎么用它们做同步?

流程大概是这样的:

  1. 解复用:从容器中读出每个 packet 的 pts 和 dts
  2. 解码:按 dts 顺序送入解码器,解码后得到原始帧
  3. 排序:解码后的帧按 pts 重新排序(因为 B 帧打乱了顺序)
  4. 渲染:根据系统时钟和 pts 的差值,决定立即显示还是等待

我习惯把这一步叫做「时钟对齐」。播放器内部维护一个参考时钟(通常用音频时钟),视频渲染线程不断比较当前帧的 pts 和参考时钟:

  • 如果 pts 比时钟大:说明帧来早了,等待
  • 如果 pts 比时钟小:说明帧来晚了,丢帧或加速播放

这里有个细节——音频的 pts 和 dts 通常是相等的,因为音频没有 B 帧的概念。所以很多播放器直接用音频时钟做主时钟,视频去对齐音频。

2.5 一个完整的 PTS/DTS 示例

咱们用 FFmpeg 命令行看看实际的时间戳:

# 查看视频文件的时间戳信息
ffprobe -show_frames input.mp4 | grep -E "pkt_pts|pkt_dts|pict_type" | head -20

# 输出示例(简化):
pkt_pts=0        pkt_dts=0        pict_type=I
pkt_pts=3003     pkt_dts=1001     pict_type=B
pkt_pts=1001     pkt_dts=2002     pict_type=P
pkt_pts=4004     pkt_dts=3003     pict_type=B
pkt_pts=2002     pkt_dts=4004     pict_type=P

看到没?pts 是 0, 3003, 1001, 4004, 2002(显示顺序),而 dts 是 0, 1001, 2002, 3003, 4004(解码顺序)。

如果时间基是 1/1000,那么:

  • 第 0 秒显示 I 帧
  • 第 1.001 秒显示 P 帧(但实际解码顺序中它是第 2 个被解码的)
  • 第 3.003 秒显示 B 帧(它其实在第 1.001 秒就被解码了,但一直等到现在才显示)

这就是 B 帧的「延迟显示」特性。

2.6 时间戳的常见问题与排查

我在项目中遇到过不少时间戳相关的问题,列几个典型的:

现象 可能原因 排查方法
画面卡顿、音画不同步 pts 不连续或跳变 用 ffprobe 检查 pts 差值是否稳定
播放器崩溃 dts 大于 pts(理论上不可能) 检查编码器是否输出异常
快进后声音正常画面不动 关键帧(I 帧)的 pts 未对齐 检查 seek 后是否从 I 帧开始解码
直播流延迟越来越大 时间戳未从 0 开始或存在漂移 用 wireshark 抓包分析 rtp 时间戳

我的排查习惯:遇到音画不同步,第一步不是看代码,而是用 ffprobe 把时间戳 dump 出来,画个折线图。pts 应该是严格递增的,如果出现平台期或跳变,问题就找到了。

2.7 一张图看懂 PTS 与 DTS

下面这张 SVG 图,把整个流程串起来了:

PTS 与 DTS 工作流程 PTS 与 DTS 工作流程 视频文件/流 解复用器 提取 PTS / DTS 视频解码器 按 DTS 顺序解码 音频解码器 PTS = DTS 排序缓冲区 按 PTS 重排 音频缓冲区 直接按 PTS 输出 时钟同步模块 比较 PTS 与参考时钟 音画同步输出 关键:视频按 DTS 解码,按 PTS 显示 音频 PTS = DTS,通常做主时钟

这张图从左到右展示了完整流程。注意看视频路径上的「排序缓冲区」——这是 B 帧场景下必不可少的环节。没有它,画面顺序就是乱的。

2.8 小结

这一章我们聊了:

  • PTS 和 DTS 为什么是两个不同的东西
  • 时间基的概念和常见取值
  • 不同封装格式下时间戳的存储差异
  • 播放器如何用时间戳做同步
  • 实际排查问题的方法

时间戳是音画同步的基石。搞懂了 PTS 和 DTS,后面讲同步策略、丢帧补偿、时钟漂移处理,你就能很快上手。