1. 课程导入

做流媒体的人,早晚都会跟时间戳打交道。我入行那会儿,第一次接触 HLS 切片,发现视频画面总是卡顿、音画不同步,查了半天才发现是时间戳单位搞错了。从那以后,我就养成了一个习惯——拿到一个流,先看时间基,再看 PTS。

这一章,咱们就把时间戳最核心的几个概念讲清楚。说白了,就是搞明白三个问题:

  • 时间戳到底存的是什么?
  • 为什么要有 PTS、DTS、CTS 三种?
  • 不同封装格式的时间单位怎么换算?

1.1 什么是 PTS、DTS、CTS?

这三个缩写,你肯定见过。但很多人分不清它们的区别。我简单解释一下。

PTS(Presentation Time Stamp)——显示时间戳。它告诉解码器:这一帧画面应该在什么时候显示给用户看。比如一个 25fps 的视频,每帧间隔 40ms,PTS 就是按这个节奏递增的。

DTS(Decoding Time Stamp)——解码时间戳。它告诉解码器:这一帧数据应该在什么时候开始解码。注意,解码和显示不是一回事。尤其是 B 帧存在的时候,解码顺序和显示顺序是错开的。

CTS(Composition Time Stamp)——合成时间戳。这个在 MPEG-2 时代用得比较多,现在很多文档里直接把它等同于 PTS。严格来说,CTS 指的是帧在合成缓冲区里完成组合的时间点。不过在实际工程中,你看到 CTS 的地方,基本可以当作 PTS 来处理。

核心要点:PTS 决定“什么时候给人看”,DTS 决定“什么时候开始解”。两者不一定相等。

为什么会不一样?因为视频编码用了 B 帧(双向预测帧)。B 帧需要依赖前后的 I 帧和 P 帧才能解码,所以解码顺序必须先把参考帧解出来,然后才能解 B 帧。但显示的时候,B 帧要插在参考帧之间。这就导致了 DTS 和 PTS 的偏差。

举个例子:一个 GOP 的帧顺序是 I B B P,显示顺序是 1 2 3 4,但解码顺序是 1 3 4 2。你看,第 2 帧(B 帧)的解码时间比显示时间晚,但显示时间却更早。这就是 DTS 和 PTS 分离的原因。

概念 全称 作用 常见场景
PTS Presentation Time Stamp 控制帧的显示时刻 音画同步、渲染调度
DTS Decoding Time Stamp 控制帧的解码时刻 解码器输入队列排序
CTS Composition Time Stamp 控制帧的合成时刻 MPEG-2 TS 流、部分老旧封装

个人经验:我在做 RTMP 推流时,遇到过编码器输出的 DTS 和 PTS 顺序完全一致的情况。后来发现是编码器配置里把 B 帧关掉了。如果你发现 DTS 和 PTS 始终相等,先检查一下编码参数,大概率是 B 帧数为 0。

1.2 时间基(Timebase)概念

时间基,英文叫 Timebase,也有人叫它时间刻度。它本质上是一个分数,表示“每个时间单位对应多少秒”。

比如 timebase = 1/1000,意思就是每个 tick 代表 1 毫秒。timebase = 1/90000,每个 tick 代表 1/90000 秒,约 11.11 微秒。

为什么会有这么多不同的 timebase?因为不同的封装格式和标准,对时间精度的要求不一样。

  • MPEG-TS:固定使用 90kHz 时钟,timebase = 1/90000。这是 MPEG-2 标准定的,目的是为了兼容 PAL/NTSC 的帧率。
  • FLV/RTMP:使用毫秒级时间戳,timebase = 1/1000。简单粗暴,但精度有限。
  • MP4:timebase 由 mdhd box 中的 timescale 字段决定,常见值有 1000、90000、48000 等。
  • WebM/Matroska:使用纳秒级时间基,timebase = 1/1000000000。精度最高,但数值也最大。
封装格式 常见 Timebase 精度 典型应用
MPEG-TS 1/90000 ~11.11 µs 广播电视、HLS
FLV/RTMP 1/1000 1 ms 直播推流
MP4 可变(常见 1000/90000) 取决于 timescale 点播文件
WebM 1/1000000000 1 ns Web 视频

注意:不同封装之间做时间戳转换时,最容易出问题。我曾经遇到过 MP4 转 TS 时,直接把 MP4 的毫秒时间戳当成 90kHz 时间戳用,结果画面每隔几秒就跳一下。原因就是 timebase 没换算,时间戳数值直接膨胀了 90 倍。

1.3 时间戳单位与换算

时间戳的换算,本质上就是一次比例缩放。公式很简单:

目标时间戳 = 源时间戳 × (源 timebase / 目标 timebase)

或者反过来:

目标时间戳 = 源时间戳 × (目标 timebase / 源 timebase)

我习惯用第一种写法,因为更直观。举个例子:

有一个 MP4 文件,timebase = 1/1000,PTS = 45000。你想把它转成 MPEG-TS(timebase = 1/90000),那么:

TS_PTS = 45000 × (1/1000) / (1/90000)
       = 45000 × (90000 / 1000)
       = 45000 × 90
       = 4050000

你看,数值从 45000 变成了 4050000,大了 90 倍。但实际时间没变:

实际时间 = 45000 × (1/1000) = 45 秒
实际时间 = 4050000 × (1/90000) = 45 秒

所以,时间戳的绝对值不重要,重要的是它和 timebase 的乘积——那个才是真实的时间。

换算口诀:时间戳 × timebase = 真实秒数。只要保证这个等式成立,怎么换算都不会错。

在实际代码中,FFmpeg 提供了 av_rescale_q 和 av_rescale_q_rnd 两个函数来做这件事。我建议你直接用它们,别自己手算。手算容易丢精度,尤其是做 RTC 场景时,微秒级的误差都会导致唇音不同步。

// FFmpeg 时间戳换算示例
int64_t pts_ts = av_rescale_q(
    pts_mp4,
    (AVRational){1, 1000},   // 源 timebase
    (AVRational){1, 90000}   // 目标 timebase
);

这里 AVRational 就是分数,分子是 1,分母是 timebase 的倒数。FFmpeg 内部会做整数运算,避免浮点误差。

1.4 知识体系总览

下面这张图,把这一章的核心逻辑串起来了。你可以把它当作时间戳的“地图”,以后遇到问题,先看这张图定位。

时间戳知识体系总览 时间戳核心概念关系图 PTS 显示时间戳 DTS 解码时间戳 CTS 合成时间戳 B帧导致偏差 等同 Timebase(时间基) 时间戳换算公式 目标时间戳 = 源时间戳 × (源 timebase / 目标 timebase) MPEG-TS (1/90000) FLV/RTMP (1/1000) MP4 (可变) WebM (1/1e9) 核心原则:时间戳 × timebase = 真实秒数,换算时保持该等式不变 不同封装之间做 remux 时,必须换算 timebase,否则音画不同步

这张图从左到右,从上到下,把 PTS/DTS/CTS、Timebase、换算公式、常见封装串起来了。你以后做时间戳相关的工作,可以拿这张图当参考。

1.5 避坑指南

最后,分享几个我踩过的坑:

  • 时间戳回绕:32 位时间戳最大只能表示约 49.7 天(以 90kHz 计)。直播流跑久了,时间戳会溢出。我曾经在马拉松直播中遇到过,画面突然跳到开头。解决方案是用 64 位时间戳,或者做回绕检测。
  • 负时间戳:有些编码器在开头几帧会输出负的 PTS,表示解码延迟。FFmpeg 默认会处理,但如果你自己写 demuxer,记得做偏移校正。
  • 时间戳抖动:网络传输或封装转换时,时间戳可能出现微小波动。我建议在解码器输入端做一个 2-3 帧的抖动缓冲区,能有效过滤掉毛刺。

我的习惯:每次拿到一个新的流,先用 ffprobe -show_packets 看一眼时间戳分布。如果发现 PTS 和 DTS 差值超过 1 秒,或者时间戳不是单调递增的,那多半有问题。早点发现,早点排查。