有一天,我的媒体处理流水线突然停止发布一部分输出。原因是一条乍看之下甚至有些荒唐的错误。
Invalid CFR packet duration: 5580 ticks, expected 5625
解码器没有崩溃,FFmpeg也已经生成了H.264文件。真正把文件挡下来的,是编码后的校验器:按设计,这个输出应该是恒定帧率,但其中一个数据包没有落在我规定的时间网格上。
我重新执行了一次,还是5580。再执行一次,仍然是5580。后来另一个源也以同样的值失败。这个重复结果很有价值:它说明问题不是暂时的网络故障,也不像罕见的竞态条件,而是同样的输入和算法会稳定重现的确定性违约。
后来又出现了第二个看起来相似、技术上却不同的问题。24 fps、视频轨道时间尺度为90,000时,普通样本的时长应该精确等于3750 tick。校验器却抓到了3751。
这两个数字不能用一句“FFmpeg做了舍入”草草带过。5580与5625相差45 tick,正好是0.5 ms;3751与3750只差1 tick,大约11.1微秒。
这两次故障迫使我把以前混在“FPS”这个词下面的四件事拆开:帧率、时间基、PTS/DTS和数据包时长。
CFR不是一个写着“16 fps”的标签
现在我说一个生成文件是CFR,并不只是因为ffprobe恰好打印出16/1或24/1。
在这条流水线里,CFR是一项更严格的约束:呈现时间必须落在规则的时间网格上,普通视频样本的时长必须正好等于这个网格的一步。
16 fps时,一帧的持续时间是:
1 / 16 = 0.0625 s = 62.5 ms
如果视频轨道使用90,000 tick/s,同一段时间可以用整数精确表示:
90000 / 16 = 5625 ticks
所以5625不是校验器里随手写下的常量。它直接来自输出约束中的两个选择:16 fps和90,000 ticks/s。
之所以能这样要求精确相等,是因为我刻意只允许能被轨道时间尺度整除的帧率。如果某个目标节奏无法用单一整数时长表示,那么正确的校验器应该验证允许的整数模式,而不是要求一个数学上不可能的固定值。
帧率、时间基和MP4时间尺度不是同一个概念
- 帧率描述呈现节奏。CFR 16意味着每62.5 ms显示一帧。
- FFmpeg时间基表示一个整数时间戳单位有多长,例如
1/90000秒。 - MP4轨道时间尺度表示每秒包含多少时间单位。90,000意味着每个单位为
1/90000秒。 - PTS表示图像何时呈现。
- DTS表示编码数据包何时必须解码。
- 数据包时长表示样本在码流时间基下持续多久。
H.264使用B帧时,PTS与DTS不同完全可能是合法的。因此,为了“修复时间戳”而简单设成PTS = DTS是危险的;官方setts文档也明确指出,存在B帧时不建议这样做。
因此,我的校验器检查的是解码顺序与呈现顺序之间的关系是否合法,而不是强迫PTS和DTS数值相同。
为什么我选择90,000 tick/s
90,000不是通用的神奇数字。它对我的流水线很方便,因为允许的每一种帧率都能对应到精确的整数时长:
| 帧率 | 90,000 tick/s下的单帧时长 |
|---|---|
| 10 fps | 9000 ticks |
| 12 fps | 7500 ticks |
| 15 fps | 6000 ticks |
| 16 fps | 5625 ticks |
| 18 fps | 5000 ticks |
| 20 fps | 4500 ticks |
| 24 fps | 3750 ticks |
| 25 fps | 3600 ticks |
| 30 fps | 3000 ticks |
它与以毫秒表示的源时序还有一个很方便的关系:
1 ms = 90 ticks
FFmpeg的MP4多路复用器提供video_track_timescale,因此我可以显式指定这张轨道时间网格。时间尺度本身不会自动修正时序;它的价值在于让这项约束能够用明确的整数来检查。
5580本身就告诉了我该往哪里查
把第一次故障换算回真实时间后,这个数字立刻有了意义:
5580 / 90000 = 0.062 s = 62 ms
这个62 ms并不陌生。我有一个真实的动画源:3.063秒内显示49帧,帧延迟在62 ms与63 ms之间交替。
49 / 3.063 ≈ 15.997 frames/s
62 ms + 63 ms = 125 ms
2 frames at 16 fps = 2 × 62.5 ms = 125 ms
在源数据层面,62/63 ms完全合理:当时间只能以1 ms精度表示时,这正是对16 fps的一种自然近似。
但一旦选择CFR 16,输出就必须落到目标网格上。每个普通帧应持续62.5 ms,也就是5625 tick。
因此,5580是一个很强的线索:源中的62 ms时长很可能一直保留到了一个本应已经完成CFR量化的阶段。
这里我严格区分证据和推断。日志能够证明的是:90 kHz下5580恰好等于62 ms,而且实际源中确实存在62/63 ms的延迟。仅凭那条归档错误记录,并不能证明究竟是哪个函数最先让这个源时长继续传了下去。
1000 Hz的输入时钟本身没有错
这一部分我用FFmpeg 7.1.5和ffconcat单独复现过。使用:
duration 0.010
option framerate 1000
数据包时间戳保留了预期的毫秒位置:
0 ms
10 ms
20 ms
30 ms
如果在这个早期阶段把输入帧率设为30,同样的时序会被大致量化到0和33.3 ms。
所以1000 Hz输入时钟做的是正确的事:以1 ms精度保留作为基准的源延迟。它绝不意味着最终输出是1000 fps视频。
millisecond-accurate source delays
↓
authoritative source timeline
↓
choose target CFR
↓
explicitly quantize onto CFR grid
↓
preserve that grid through encoding and muxing
输入精度高本身不是问题。问题在于没有把“保留源时序”和“转换到目标CFR”之间的边界明确下来。
CFR本质上是受控的时间量化
真实源中的62/63 ms以两帧为一组时自然对应16 fps:两边都是125 ms。但这不意味着最终文件可以继续保留62、63、62、63 ms的时长,然后只标成“16 fps”。这些时长仍然是可变的。
62 ms, 63 ms, 62 ms, 63 ms
↓
62.5 ms, 62.5 ms, 62.5 ms, 62.5 ms
FFmpeg的fps滤镜是执行这一步转换的合理位置。它根据输入时间戳和选定的舍入策略,通过丢帧或重复帧来构造目标帧率。
一旦这个阶段已经创建目标网格,后续步骤就不应该再独立执行一次帧率转换。流水线最好只有一个明确的量化边界,而不是让多个层各自做自己的舍入决定。
为什么重试三次也没有任何变化
FAIL: duration 5580, expected 5625
retry 1/3
FAIL: duration 5580, expected 5625
retry 2/3
FAIL: duration 5580, expected 5625
这条经验并不限于视频。重试适合那些下一次执行时可能自然消失的故障,例如网络错误、暂时不可用的存储、资源压力或暂时不可用的依赖。
如果相同输入和相同算法每次都会生成同一个确定性的约束违例,重试并不能修复它。
- 暂时性故障 — 重试可能有帮助;
- 无效输入 — 换一条处理路径或直接拒绝;
- 确定性不变量故障 — 停止重试,诊断流水线。
反复出现的5580属于第三类。
然后我又遇到了3751,而不是3750
24 fps时,期望值可以精确表示:
90000 / 24 = 3750 ticks
然而,一个真实的拼接输出中出现了时长为以下数值的数据包:
3751 ticks
两者只差:
1 / 90000 s ≈ 11.111 µs
观看者几乎不可能感知1 tick的差异。因此,把校验器放宽到±1看起来很有诱惑力。
我没有这么做。因为在这张网格上,24 fps可以精确表示为3750。3751并不是表示能力有限而不得不接受的近似值,而是说明我定义的精确网格不变量在某处丢失了。
码流复制不等于时间戳完全不变
为了避免第二次有损编码,我用码流复制把兼容的H.264片段拼接起来:
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy \
final.mp4
-c:v copy的含义是压缩后的H.264数据不会被解码再重新编码。它并不意味着多路复用层不再需要处理时序。
concat解复用器文档明确说明,每个文件的时长都会用于调整下一个文件的时间戳。FFmpeg还会在不同的有理数时间基之间重缩放整数时间戳,libavutil也提供带明确舍入模式的重缩放函数。
这并不能证明我遇到的3751只能由concat造成。但它足以说明一种常见理解是错误的:“比特码流被复制了,所以数据包时间戳不可能变化。”
压缩画面保持不变,与时间戳网格保持不变,是两个不同的属性。
PTS和DTS不能靠一个漂亮公式一起重写
很容易想写出这样的修复:
PTS = N * frame_duration
DTS = N * frame_duration
对于使用B帧的H.264,这样做可能是错的。图像的解码顺序和呈现顺序并不一定相同。
正确的目标更窄:用作为基准的片段时间线恢复已知的呈现网格和数据包时长,同时保留合法的解码顺序关系。
所以我不会给出一个脱离上下文的“万能setts表达式”。正确表达式取决于片段边界、选定帧率,以及构建最终时间线时所用的时序元数据。
为什么我使用setts
FFmpeg的setts比特流滤镜可以在不解码并重新编码视频的情况下,修改数据包的PTS、DTS、时长和输出时间基。
这正适合作为concat后的归一化层:保持H.264压缩数据不变,同时根据已知的片段元数据重新施加数据包时间线。
known segment timeline
+ known CFR
+ 90000-tick grid
↓
known valid packet positions and durations
↓
normalize packet timing
↓
validate again
这和“看到3751就减1”完全不是一回事。修复必须从时序模型推导出来,而不是针对今天恰好出现的错误信息打补丁。
为什么我拒绝±1 tick的容差
在很多系统里,小幅容差是正确的设计。如果某个呈现节奏无法在选定的整数时间基上精确表示,校验器就应该对必需的舍入模式建模。
但我的约束不同。我刻意把允许的帧率限制为使90000 / fps为整数的那些值:
16 fps → 5625
24 fps → 3750
30 fps → 3000
当期望时长本来就能精确表示时,通用的±1容差只会把一个无法解释的不变量违例变成“可接受状态”。
1 tick在视觉上并不重要。真正重要的是,系统为什么失去了原本明确规定的约束。
- 如果网格本身必然需要交替使用不同的整数时长,就验证正确的模式;
- 如果时长必须是一个精确整数,就严格要求那个整数;
- 不要把±1当成让校验器变绿的通用开关。
我如何在数据包层验证CFR
avg_frame_rate等码流元数据是有用的摘要,但不足以验证这项约束。
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json \
final.mp4
我会检查实际的码流时间基,以及每个数据包的pts、dts和duration。
expected = 90000 / 16 // 5625
for each normal video packet:
assert packet.duration == 5625
assert decode ordering is legal
assert PTS/DTS relationship is legal for the codec
assert presentation timeline matches planned frame count and duration
这里的“普通数据包”很重要。编辑列表、裁剪、刻意设计的特殊末尾样本或其他容器行为,都可能需要显式建模。我并不是把这项条件宣称成所有MP4文件的通用定律。
但对于结构由我控制的生成器,严格验证比“看起来差不多是16 fps”有价值得多。
元数据校验和完整解码回答的是不同问题
完美的时间戳不能证明整个H.264码流都能成功解码。反过来,完整解码成功也不能证明文件满足我的时序约束。
ffprobe / packet validation
→ structure, timestamps, durations, stream parameters
full decode
→ whether the complete compressed stream can actually be decoded
检查完数据包后,我仍然会执行:
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
只有两个阶段都通过,文件才会发布。编码器退出代码为0,已经不再等同于“完成”。
两次故障真正证明了什么
对于5580,我能确认的是:同一个确定性值在多次重试中反复出现;90 kHz下5580精确等于62 ms;真实源时序中存在62/63 ms延迟;CFR 16需要62.5 ms或5625 tick;1000 Hz输入实验也正确保留了毫秒级延迟。
这些事实使“源时长泄漏到了本应已经完成CFR量化的阶段”成为一个很强的解释。但归档的错误记录本身并不能证明究竟是哪一个函数造成了它。
对于3751,我能确认的是:真实输出里存在一个3751-tick数据包,而24 fps要求3750;流水线使用了码流复制concat;后来concat后的数据包归一化把setts纳入了解决方案;FFmpeg官方文档同时说明了concat对时间戳的调整,以及数据包级时间戳重写。
这些事实与整数重缩放或多路复用/concat边界上的舍入相符,但不足以支持“concat总会增加1 tick”这种普遍断言。
我现在使用的流程
- 恢复作为基准的源时间线,而不是相信某个猜出来的FPS字段。
- 用足够精细的输入时钟保留原始的毫秒延迟。
- 单独选择目标CFR。
- 明确地把源时间线量化到该CFR网格。
- 尽可能选择能精确表示允许帧率的轨道时间尺度。
- 编码后,不让后续阶段再独立进行一次帧率转换。
- concat前验证时间基、帧数、PTS/DTS和数据包时长。
- 码流复制concat前验证编解码器配置和时序兼容性。
- concat后再次检查数据包网格;不要把
-c:v copy当成时间戳保持不变的证明。 - 如果需要归一化,就从已知时间线推导规则,并在数据包层应用。
- 再次验证每一个数据包。
- 完整解码最终文件。
- 只有全部约束都通过后,才以原子方式发布文件。
最终留下的规则:CFR是一项整数时序约束
以前我觉得16 fps这个值几乎已经能解释一切。现在不会这么看。
time base = 1/90000
normal frame duration = 5625 ticks
presentation cadence = 62.5 ms
packet timeline follows that grid
PTS/DTS remain legal for H.264 reordering
5580之所以重要,是因为它暴露了源端旧的毫秒网格。3751之所以重要,恰恰是因为它几乎不可见:只差1 tick,就足以证明系统不再保持自己明确定义的不变量。
我现在的规则很简单:不要验证“CFR”这个标签;验证那个标签应该由其推导出来的时序本身。
主要官方资料
- FFmpeg:fps滤镜 — 构造目标帧率以及PTS的舍入策略。
- FFmpeg:MOV/MP4格式 —
video_track_timescale以及MP4多路复用器选项。 - FFmpeg:concat解复用器 — 文件时长以及拼接输入之间的时间戳调整。
- FFmpeg:setts比特流滤镜 — 重写数据包的PTS、DTS、时长和时间基。
- ffprobe文档 — 检查码流和数据包。
- FFmpeg libavutil:时间计算与时间戳重缩放 — 有理数时间戳重缩放以及明确的舍入模式。