9、时间戳管理:理解PTS/DTS,如何正确映射时间戳
时间戳这个东西,说实话,刚接触音视频开发那会儿,我也被绕晕过。PTS、DTS,还有那个该死的timeUs,到底谁是谁?
我记得第一次做视频剪辑功能,提取出来的画面总是对不上音频。明明代码逻辑看着没问题,可播放起来就是嘴型对不上。后来才发现,是时间戳映射搞错了。
今天咱们就把这块彻底捋清楚。
9.1 为什么需要两种时间戳?
先问一个问题:视频文件里,每一帧的显示顺序,一定等于它的解码顺序吗?
不一定。你想想看,B帧(双向预测帧)需要参考前后的帧才能解码。所以解码器必须先拿到后面的P帧,才能解码中间的B帧。
这就导致了两个概念:
- DTS(Decoding Time Stamp):解码时间戳。告诉解码器什么时候该解码这一帧。
- PTS(Presentation Time Stamp):显示时间戳。告诉渲染器什么时候该显示这一帧。
说白了,DTS是解码器的工作节奏,PTS是播放器的展示节奏。两者可能不同步,尤其是在包含B帧的视频流里。
核心结论:对于只有I帧和P帧的视频流,DTS和PTS是相等的。一旦出现B帧,DTS就会小于PTS。
9.2 MediaExtractor 中的时间戳体系
在Android的MediaExtractor里,我们主要通过 getSampleTime() 获取当前样本的时间戳。这个时间戳返回的是微秒(microseconds)单位的PTS。
但这里有个坑——MediaExtractor返回的时间戳,是PTS,不是DTS。而且它已经帮你做了封装,你不需要手动去算DTS。
我在项目中遇到过一个问题:用MediaExtractor提取视频帧,然后传给MediaCodec解码,结果画面总是卡顿。排查了半天,发现是我自己手动调整了时间戳,把PTS当成了DTS去处理。
注意:MediaExtractor的getSampleTime()返回的是PTS。如果你需要DTS,得通过MediaCodec的BufferInfo来获取。但大多数场景下,你只需要PTS就够了。
9.3 时间戳映射的实战套路
实际开发中,时间戳映射主要涉及三个环节:
- 提取阶段:从MediaExtractor拿到原始PTS
- 处理阶段:对时间戳进行缩放、偏移等操作
- 输出阶段:把处理后的时间戳写入输出文件
来看一段典型的代码:
// 提取时获取时间戳
long ptsUs = extractor.getSampleTime(); // 单位:微秒
// 如果需要转换成毫秒
long ptsMs = ptsUs / 1000;
// 写入Muxer时,需要设置正确的格式
MediaFormat outputFormat = MediaFormat.createVideoFormat(mime, width, height);
outputFormat.setLong(MediaFormat.KEY_DURATION, totalDurationUs);
// 写入样本时,时间戳必须单调递增
muxer.writeSampleData(trackIndex, byteBuf, bufferInfo);
嗯,这里要注意一点:写入Muxer的时间戳必须严格单调递增。如果你处理后的时间戳出现了回退,Muxer会直接报错。
9.4 常见的时间戳问题与避坑
我曾经踩过一个坑:从视频文件里提取音频轨道,然后重新封装成新的MP4文件。结果播放时音频断断续续,像卡带一样。
排查后发现,原始视频的音频时间戳不是从0开始的。我直接拿原始时间戳写入新文件,导致时间戳不连续。解决方案很简单:
// 记录第一个样本的时间戳作为基准
long baseTimeUs = -1;
while ((extractor.readSampleData(byteBuf, bufferInfo)) >= 0) {
if (baseTimeUs == -1) {
baseTimeUs = extractor.getSampleTime();
}
// 减去基准时间戳,让新文件从0开始
bufferInfo.presentationTimeUs = extractor.getSampleTime() - baseTimeUs;
muxer.writeSampleData(trackIndex, byteBuf, bufferInfo);
extractor.advance();
}
另外,还有一个常见问题:时间戳溢出。有些视频文件的时间戳会非常大,甚至接近Long.MAX_VALUE。如果你做加减运算,很容易溢出变成负数。
我的习惯:在处理时间戳之前,先检查一下数值范围。如果发现异常大的时间戳,可以用 Math.abs() 或者做一次归一化处理。
9.5 时间戳与播放速度的关系
有时候我们需要做倍速播放或者慢动作。这时候时间戳的处理就更有意思了。
比如你想做2倍速播放,本质上就是把时间戳压缩一半:
// 原始时间戳
long originalPts = extractor.getSampleTime();
// 2倍速:时间戳减半
long speedUpPts = originalPts / 2;
// 0.5倍速:时间戳翻倍
long slowDownPts = originalPts * 2;
但这里有个细节:帧率不变,时间戳变了。你想想看,如果时间戳压缩了,但帧率没变,播放器就会以更快的速度显示每一帧,从而实现倍速效果。
我在做一个视频编辑App时,就用了这个思路。用户拖动速度滑块,我实时调整时间戳映射关系,效果还不错。
9.6 时间戳的坐标系
最后,咱们用一张图来总结时间戳的整个流转过程:
从这张图可以看出来,时间戳的流转其实是一条直线:原始文件 → 提取 → 处理 → 输出。每个环节都有各自的注意事项。
我个人习惯在代码里加一个时间戳校验函数,每次写入前检查一下:
private long lastPtsUs = -1;
private boolean validateTimestamp(long ptsUs) {
if (ptsUs < 0) {
Log.w("Timestamp", "时间戳为负数: " + ptsUs);
return false;
}
if (ptsUs < lastPtsUs) {
Log.w("Timestamp", "时间戳回退: " + ptsUs + " < " + lastPtsUs);
return false;
}
lastPtsUs = ptsUs;
return true;
}
这个函数虽然简单,但帮我避免过好几次线上崩溃。你想想看,如果时间戳回退导致Muxer抛异常,用户那边就是直接闪退。
好了,时间戳这块就聊到这儿。记住一句话:PTS是给播放器看的,DTS是给解码器看的。搞清楚了这一点,后面遇到时间戳相关的问题,你心里就有底了。
公众号:蓝海资料掘金营,微信deep3321