Depth.Love Blog

GitHub:https://github.com/depthlove

0%

视频编解码的 PTS/DTS 时间戳机制与工程实践

在音视频开发中,时间戳(Timestamp)是保障画面流畅和音视频同步(A/V Sync)的核心。如果搞不懂 PTS/DTS 的底层机制,在面对 B 帧、起播黑屏、音画不同步等复杂问题时,基本只能抓瞎。

基于 H.264 底层规范,结合 FFmpeg、VideoToolbox、MediaCodec 等主流框架,我将 PTS/DTS 的核心机理和踩坑经验总结如下。

1. 核心定义与时间基 (Time Base)

1.1 三个核心时间概念

在引入 B 帧的视频流中,单一的时间轴无法满足解码和显示的双重要求,因此定义了三个变量:

  • PTS (Presentation Time Stamp)显示时间戳。告诉播放器这一帧应该在什么绝对时间点渲染到屏幕上。
  • DTS (Decoding Time Stamp)解码时间戳。告诉解码器这一帧压缩数据(NALU)应该在什么绝对时间点送入解码器。
  • CTS (Composition Time Shift)组合时间偏移。即显示时间与解码时间的差值。由于画面必须先解码才能显示,所以通常 PTS ≥ DTS。

音频的特殊性:音频是线性顺序解码的,不存在类似视频 B 帧的前后时域依赖。因此音频的 PTS 永远等于 DTS,处理音频时只需关注 PTS。

1.2 时间基 (Time Base) 与刻度转换

在 FFmpeg 或 WebRTC 等工程实现中,时间戳很少用浮点数(秒)表示,因为浮点数连续累加会带来精度误差。业界普遍采用有理数分数(Rational Number)定义的时间基准来表示离散的 Tick 刻度。

  • MPEG-TS 封装:默认采用 1/90000 秒作为基准。若视频为 25fps,则每帧的增量为:90000 / 25 = 3600 ticks。
  • FLV/RTMP 封装:默认采用 1/1000 秒(即毫秒)作为基准。
  • 换算公式

2. B 帧引入的时域错位

2.1 为什么必须分离 PTS 与 DTS?

视频帧间预测分为三种:

  • I 帧(Intra-coded):关键帧,全量空间编码,不依赖其他帧。
  • P 帧(Predictive):前向预测帧,参考历史显示的 I/P 帧。
  • B 帧(Bi-directional):双向预测帧,同时参考历史帧未来帧,压缩率极高。

因为 B 帧需要“向未来借信息”,编码器/解码器就必须先处理未来的参考帧(P帧),再处理中间的 B 帧。这导致了解码顺序(DTS)与显示顺序(PTS)的错位。

2.2 典型 IBBP 结构下的时域推演

假设视频帧率为 25 fps(帧间隔 $\Delta t = 40\text{ ms}$),GOP 结构为 IBBP(2 个连续 B 帧)。

显示顺序 (PTS): I0(0) ──> B1(40) ──> B2(80) ──> P3(120) ──> B4(160)
│ │ │ │ │
▼ └──────┬─────┘ ▼ │
解码顺序 (DTS): I0(0) ──> P3(40) ──┴─> B1(80) ──> B2(120) ──> P6(160)

显示序号 帧类型 显示时间 (PTS) 解码时间 (DTS) 偏移量 (CTS) 处理序号 依赖关系与说明
0 I 帧 0 ms 0 ms 0 ms 1 独立解码,直接显示
1 B 帧 40 ms 120 ms -80 ms 3 依赖 I0 和 P3,需等待 P3 解码完毕
2 B 帧 80 ms 160 ms -80 ms 4 依赖 I0 和 P3,需等待 P3 解码完毕
3 P 帧 120 ms 40 ms 80 ms 2 仅依赖 I0。必须提前解码以供 B1/B2 参考
4 B 帧 160 ms 240 ms -80 ms 6 依赖 P3 和下一帧 P6

核心铁律:无论是写入 MP4 文件还是网络推流,底层的数据包必须严格按照 DTS 单调递增的顺序排列和传输。播放器接收到数据后,按 DTS 喂给解码器,解码后缓存在内存中,再按照 PTS 顺序挑出画面送给显卡渲染。

3. 裸流与容器层的时间戳解耦

3.1 H.264 裸流中的 POC 机制

误区:很多人以为 H.264 码流内部直接存储了 PTS 和 DTS。事实上,H.264 Annex B 裸流内部没有绝对时间戳的概念。

  • 它通过 Slice Header 中的 POC (Picture Order Count,图像顺序计数) 来标定当前帧的相对显示顺序。
  • 解码器解析 POC 能够知道画面的相对前后关系,但不知道具体的播放毫秒数。

3.2 容器层的绝对时间映射

绝对时间戳是由外部封装格式(Container)维护的,不同容器的存储结构不同:

  • MP4 / MOV:通过 stts box 存储每一帧 DTS 的差值(Delta),通过 ctts box 存储每一帧的 CTS。播放器通过 $PTS = DTS + CTS$ 计算出显示时间。
  • FLV / RTMP:Video Tag Header 中有 32 位的 Timestamp 字段(存储绝对 DTS),以及一个 24 位的 CompositionTime 字段(存储 CTS 偏移)。
  • MPEG-TS:在 PES (Packetized Elementary Stream) 头中,直接以 33 位无符号整数分别写入完整的 PTSDTS

4. 主流编解码框架的工程踩坑点

4.1 FFmpeg

  • 数据结构AVPacket(压缩流)包含 ptsdtsAVFrame(未压缩图像)只包含 pts
  • 内部 DPB 托管:通过 avcodec_send_packet 顺序喂入乱序 PTS 的包,解码器内部维护 DPB(Decoded Picture Buffer),并在 avcodec_receive_frame 时自动吐出按 PTS 排好序的原始帧。
  • 时间基转换:跨容器封装时,必须进行 Time Base 转换,否则时间戳错乱:
    1
    av_packet_rescale_ts(pkt, in_stream->time_base, out_stream->time_base);

4.2 x264 的 dts_shift 策略

由于 B 帧的存在,第一帧 I 帧的 DTS 理论上需要提前(计算结果为负数,如 -80ms)。

  • 痛点:FLV / RTMP 规范中的时间戳是无符号整数,不支持负数。负数会被强转为巨大的正数导致播放器崩溃。
  • x264 解法:默认进行时间轴平移(dts_shift),将整条流的时间轴向右平移(例如都加上 80ms),保证输出的码流 DTS 严格从 0 开始单调递增。

4.3 Apple VideoToolbox

  • 输入约束:调用 VTCompressionSessionEncodeFrame 时,只能传入当前画面的显示时间(PTS)。严禁自行计算并传入 DTS(必须传入 kCMTimeInvalid),否则会破坏底层硬件的重排状态机。
  • 输出提取:在回调中,通过 CMSampleBufferGetPresentationTimeStampCMSampleBufferGetDecodeTimeStamp 提取硬件计算好的 PTS 与 DTS。

4.4 Android MediaCodec

MediaCodec 是处理时间戳最棘手的“黑盒”。

  • API 缺陷:其输入输出核心结构 MediaCodec.BufferInfo 只有一个 presentationTimeUs 字段,完全没有暴露 DTS
  • 录制 MP4 场景:将 BufferInfo 委托给底层的 MediaMuxer,系统底层会自行解析码流并推导 sttsctts
  • RTMP 推流场景:推流必须显式提供 DTS。此时必须自建推导算法:提取 NALU 中的 Slice Header 解析出 POC,在内存中维护一个容量约等于 GOP 大小的滑动窗口,模拟 DPB 延迟出栈逻辑,手动计算并分配严格单调递增的 DTS。

5. 经典案例排查

案例一:短视频剪辑中 MP4 Edit List (elst) 导致的起播黑屏

  • 现象:对带有 B 帧的 MP4 文件进行剪辑拼接后,播放器在起播阶段经常出现 80ms 左右的黑屏或微弱的音画不同步。
  • 原因:由于 dts_shift,第一帧 I 帧的 DTS 为 0,而 PTS 变成了 80ms。如果直接拼装,播放器从 80ms 开始渲染,导致前 80ms 画面为空。
  • 解法:在 MP4 封装层利用 elst (Edit List Box) 机制。封装器需要写入一条 elst 记录,声明:“将媒体轨道的物理起播时间(80ms)映射到逻辑时间轴的 0ms 处”。播放器解析到该 Box 后会自动吃掉这 80ms 的空持续时间。

案例二:RTC 与秀场直播中的音画同步 (Master Clock) 策略

  • 现象:Android 手机长时间推流后,主播的口型与声音逐渐产生偏差,音画分离达到 500ms 以上。
  • 原因:错误地采用了“根据帧率累加”的方式计算 PTS(如 pts += 40ms)。由于视频采集硬件存在丢帧,且音视频晶振存在物理漂移,静态累加的时间轴会与真实物理时间越差越远。
  • 解法:引入系统单调递增时钟(Monotonic Clock),实施 Audio Master 策略
    1. 物理打戳:音视频帧在采集回调的第一时间,统一获取 System.nanoTime() 作为基础时间戳。
    2. 时钟对齐:以音频的物理时间为 Master。视频帧的实际下发 PTS 需动态追赶音频时间轴:PTS_Video = Current_Video_Time - Start_Audio_Time
    3. 拥塞控制:当编码器或网络拥塞时,主动丢弃视频帧,但绝对不能丢弃音频帧。

6. 常见故障速查表

异常现象 根源分析 修复方向
画面前后乱跳(鬼畜) 未区分 PTS 与 DTS,交错发送。推流时误把乱序的 PTS 赋值给了网络包的 DTS 字段。解码器按错误顺序收包,B 帧在找不到参考帧的情况下被强行解码。 检查封装/推流层的 Timestamp 是否严格按单调递增的 DTS 顺序写入。
周期性绿屏 / 大面积马赛克 参考帧丢失或重排缓冲区 (DPB) 损坏。码流中丢失了关键的 P 帧或 I 帧,导致后续依赖它的 P/B 帧宏块预测失败。 检查网络丢包率;检查解码器输入端是否抛弃了无法识别的 NALU;验证 DTS 是否存在负数。
起播画面卡顿,声音先出 首帧 PTS 偏移量未处理。存在 dts_shift 偏移,但容器未写入 elst 或者播放器未正确实现 Edit List 解析。 检查 MP4 文件是否包含有效的 elst box;对于 FLV 检查首帧的 CompositionTime 是否被正确处理。
持续的恒定音画不同步 PTS 丢失或被渲染管线覆盖。如在 Android 端经过 OpenGL 处理后,原始采集的 PTS 被覆盖成了渲染管线的系统时间。 在采集源头将 PTS 绑定在纹理的 Metadata 中透传,在编码前重新赋回给图像流。

7. 架构选型复盘

  • 超低延迟场景(RTC、连麦、云游戏)

    • 强烈建议全局禁用 B 帧(强制设置 Baseline Profile 或 zero-latency 模式)。
    • 没有 B 帧后,PTS ≡ DTS,彻底消除 DPB 重排带来的数十毫秒物理延迟,同时也极大简化了网络传输层的重组逻辑。
  • 高画质流媒体场景(点播、标准直播、录制)

    • 必须开启 B 帧以获取更高的压缩率。
    • 在自建流媒体管道时,务必将“按 DTS 递增传输、按 PTS 排序渲染”作为底层公理,并在模块交界处严格做好 Time Base 的精度转换。