One day my media pipeline stopped publishing part of its output because of an error that looked almost absurd:
Invalid CFR packet duration: 5580 ticks, expected 5625
The decoder had not crashed. FFmpeg had produced an H.264 file. My validator rejected it after encoding because the file was supposed to be constant-frame-rate, yet one packet did not sit on the timing grid I had designed.
I retried the job. It produced the same 5580. I retried again. Still 5580. A different source later failed with the same value. That was useful evidence: this was not a transient network problem or a rare race. The violation was deterministic.
Later I found a second failure that looked similar but was technically different. At 24 fps with a 90,000-tick video track, I expected every normal sample duration to be exactly 3750 ticks. The validator found 3751.
Those two numbers should not be explained by the same hand-waving sentence. 5580 instead of 5625 is a 45-tick difference: exactly 0.5 ms. 3751 instead of 3750 is one tick: about 11.1 microseconds.
The bugs forced me to separate four concepts I had previously allowed to blur together under the word “FPS”: frame rate, time base, PTS/DTS, and packet duration.
CFR is not a label that says “16 fps”
When I say a generated file is constant-frame-rate, I no longer mean only that ffprobe prints a convenient 16/1 or 24/1.
For this pipeline CFR is a stricter contract: presentation times lie on a regular grid, and the normal duration of each video sample is one step of that grid.
At 16 fps, one frame lasts:
1 / 16 = 0.0625 s = 62.5 ms
With a 90,000-tick video track, that same interval is an exact integer:
90000 / 16 = 5625 ticks
So 5625 was not an arbitrary validator constant. It followed directly from two choices in the output contract: 16 fps and 90,000 ticks/s.
This exact equality is only reasonable because I deliberately chose rates that divide the track timescale cleanly. If a target cadence cannot be represented by one integer duration, a correct validator must model the permitted integer pattern rather than demand one impossible value.
Frame rate, time base, and MP4 timescale are different things
- Frame rate describes presentation cadence. CFR 16 means one displayed frame every 62.5 ms.
- FFmpeg time base is the duration of one integer timestamp unit, for example
1/90000seconds. - MP4 track timescale expresses the reciprocal idea: units per second. A timescale of 90,000 gives one unit a duration of
1/90000seconds. - PTS says when a picture is presented.
- DTS says when the encoded packet must be decoded.
- Packet duration expresses the sample duration in the stream time base.
With B-frames, PTS and DTS can legitimately differ. That is why “fixing timestamps” by simply setting PTS = DTS is dangerous; the official setts documentation even notes that this is not recommended when B-frames are involved.
My validator therefore checks a legal decoding/presentation relationship rather than demanding identical timestamps.
Why I chose a 90,000-tick track
90,000 is not a universal magic number. It was useful because every frame rate allowed by my pipeline mapped to an exact integer duration:
| Rate | Frame duration at 90,000 ticks/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 |
It also has a convenient relationship to millisecond source timing:
1 ms = 90 ticks
FFmpeg exposes video_track_timescale for the MP4 muxer, so I can request that track grid explicitly. The grid does not make timing correct by itself. It makes the contract measurable.
5580 told me where to look
The first failure becomes much more interesting when converted back to time:
5580 / 90000 = 0.062 s = 62 ms
That was not random. I had a real animated source with 49 displayed frames over 3.063 seconds, with frame delays alternating between 62 and 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
At the source level, alternating 62/63 ms is a perfectly sensible millisecond-grid approximation of 16 fps.
After selecting CFR 16, however, the output must live on the target grid: 62.5 ms or 5625 ticks per normal frame.
That made 5580 a strong clue that a 62 ms source duration had survived into a stage that was expected to contain already-quantized CFR timing.
I keep the evidence boundary explicit. The archived log proves that 5580 equals exactly 62 ms at 90 kHz, and my real source timing did contain 62/63 ms delays. The log alone does not prove which exact function first allowed that source duration to survive. It is a strong mechanism-level inference, not permission to invent a line of code after the fact.
The 1000 Hz input clock was not the mistake
I separately reproduced this part of the architecture with FFmpeg 7.1.5 and ffconcat. With:
duration 0.010
option framerate 1000
the packet timestamps preserved the intended millisecond positions:
0 ms
10 ms
20 ms
30 ms
Using an input rate of 30 at that early stage quantized the same timing to roughly 0 and 33.3 ms instead.
So the 1000 Hz input clock was doing the right job: preserving authoritative source delays at 1 ms precision. It did not mean the final output was a 1000 fps video.
The correct architecture has an explicit boundary:
millisecond-accurate source delays
↓
authoritative source timeline
↓
choose target CFR
↓
explicitly quantize onto CFR grid
↓
preserve that grid through encoding and muxing
High precision on input is not the bug. Failing to make the source-to-CFR transition explicit is.
CFR is controlled time quantization
The real 62/63 ms source naturally maps to 16 fps over pairs of frames: both cover 125 ms. But that does not mean the final file can keep durations of 62, 63, 62, 63 ms and merely advertise “16 fps.” Those are still variable durations.
62 ms, 63 ms, 62 ms, 63 ms
↓
62.5 ms, 62.5 ms, 62.5 ms, 62.5 ms
FFmpeg's fps filter is a sensible place to perform that conversion. It constructs the requested frame rate by dropping or repeating input frames according to their timestamps and the selected rounding policy.
Once that step has created the intended grid, I do not want a later stage independently performing another frame-rate conversion. The pipeline should have one deliberate quantization boundary, not several layers each making their own rounding decisions.
Why three retries changed nothing
FAIL: duration 5580, expected 5625
retry 1/3
FAIL: duration 5580, expected 5625
retry 2/3
FAIL: duration 5580, expected 5625
This is a useful reliability lesson outside video. Retries are appropriate for failures that may disappear between attempts: network errors, temporary storage failures, resource pressure, unavailable dependencies.
They do not fix a deterministic contract violation produced by the same input and the same algorithm.
I now separate at least three classes:
- transient failure — retry may help;
- bad input — route or reject it;
- deterministic invariant failure — stop retrying and diagnose the pipeline.
The repeated 5580 belonged to the third class.
Then I found 3751 instead of 3750
At 24 fps the expected value is exact:
90000 / 24 = 3750 ticks
Yet one real concatenated output contained a packet with:
3751 ticks
The difference was only:
1 / 90000 s ≈ 11.111 µs
No viewer was going to notice one tick. That made it tempting to loosen the validator to ±1.
I chose not to. At 24 fps on this grid, 3750 is exactly representable. A 3751 packet therefore was not an unavoidable representation compromise; it was evidence that my exact-grid invariant had been lost somewhere.
Stream copy does not mean timestamps are untouched
The pipeline concatenated compatible H.264 segments without a second lossy encode:
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy \
final.mp4
-c:v copy means the compressed H.264 payload is not decoded and encoded again. It does not mean the muxing layer has no timing work to do.
The concat demuxer documentation explicitly states that each file's duration is used to adjust timestamps of the next file. FFmpeg also rescales integer timestamps between rational time bases, and libavutil exposes rescaling functions with explicit rounding modes.
That does not prove concat was the only possible cause of my particular 3751. It does explain why “the bitstream was copied, therefore packet timestamps cannot move” is the wrong mental model.
Compressed-picture identity and timestamp-grid identity are separate properties.
PTS and DTS cannot be repaired with one pretty equation
A tempting timestamp repair looks like this:
PTS = N * frame_duration
DTS = N * frame_duration
That can be wrong for H.264 with B-frames. Pictures may be decoded in a different order from the order in which they are presented.
The correct goal is narrower: use the authoritative segment timeline to restore the known presentation grid and packet durations while preserving a legal decode-order relationship.
That is also why I do not publish a context-free “magic setts expression.” The correct expression depends on the segment boundaries, selected rates, and timing metadata from which the final timeline was built.
Why I used setts
FFmpeg's setts bitstream filter can change packet PTS, DTS, duration, and output time base without decoding and re-encoding the video.
That made it the right layer for my post-concat normalization: leave the H.264 payload alone, but reassert the packet timeline derived from known segment metadata.
known segment timeline
+ known CFR
+ 90,000-tick grid
↓
known valid packet positions and durations
↓
normalize packet timing
↓
validate again
This is very different from “if duration equals 3751, subtract one.” The repair must follow from the timing model, not from today's error message.
Why I refused a ±1 tick tolerance
Small tolerances are correct in many systems. If a cadence cannot be represented exactly in a chosen integer time base, a validator must model the necessary rounding pattern.
My contract was different. I deliberately constrained the allowed rates so that 90000 / fps is an integer:
16 fps → 5625
24 fps → 3750
30 fps → 3000
When the expected duration is exactly representable, a generic ±1 tolerance converts an unexplained invariant violation into an accepted state.
One tick is visually irrelevant. The unexplained loss of the contract is not.
My rule now is:
- if the chosen grid necessarily alternates integer durations, validate the correct pattern;
- if the duration must be one exact integer, require that integer;
- never use ±1 as a universal way to turn a red validator green.
How I validate CFR at packet level
avg_frame_rate and similar stream metadata are useful summaries, but they are not enough for this contract.
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json \
final.mp4
I inspect the actual stream time base and packet-level pts, dts, and duration.
A simplified CFR-16 validator for this particular output design looks conceptually like:
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
“Normal packet” matters. Edit lists, trimming, an intentionally special terminal sample, or other container behavior may require explicit modeling. I am not proposing this condition as a universal MP4 law.
For a generator whose structure I control, however, strict validation is far more useful than “it looks approximately like 16 fps.”
Metadata validation and full decode answer different questions
Perfect timestamps do not prove the entire H.264 stream decodes. A file can also decode cleanly while violating my timing contract.
ffprobe / packet validation
→ structure, timestamps, durations, stream parameters
full decode
→ whether the complete compressed stream can actually be decoded
After packet inspection I still run:
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
Only after both stages pass does the file become publishable. Encoder exit code zero is no longer my definition of “done.”
What the two failures actually proved
For 5580, I can confirm that the same deterministic value appeared across retries; 5580 at 90 kHz is exactly 62 ms; the real source timing contained 62/63 ms delays; CFR 16 requires 62.5 ms or 5625 ticks; and the 1000 Hz input experiment preserved millisecond delays correctly.
That makes source-duration leakage into a stage that should already have been CFR a strong explanation. The archived error line alone does not prove the exact function that caused it.
For 3751, I can confirm that a real output contained one 3751-tick packet where 24 fps required 3750; the pipeline used stream-copy concatenation; post-concat packet normalization with setts became part of the solution; and FFmpeg documents both concat timestamp adjustment and packet-level timestamp rewriting.
Those facts are consistent with integer rescaling or mux/concat boundary rounding. They do not justify the universal claim “concat always adds one tick.”
The workflow I use now
- Recover an authoritative source timeline instead of trusting one guessed FPS field.
- Preserve original millisecond delays on a sufficiently precise input clock.
- Choose the target CFR separately.
- Explicitly quantize the source timeline onto that CFR grid.
- Use a track timescale that represents the allowed rates exactly where possible.
- Encode without asking later stages to independently redo frame-rate conversion.
- Validate time base, frame count, PTS/DTS, and packet duration before concatenation.
- Validate codec configuration and timing compatibility before stream-copy concat.
- Inspect the packet grid again after concat; never treat
-c:v copyas proof of timestamp identity. - If normalization is needed, derive it from the known timeline and apply it at packet level.
- Validate every packet again.
- Fully decode the final file.
- Publish atomically only after the contract passes.
The rule I kept: CFR is an integer timing contract
I used to treat 16 fps as almost self-explanatory. I no longer do.
In this pipeline it means:
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 mattered because it exposed the old millisecond grid. 3751 mattered precisely because it was almost invisible: one tick was enough to prove that the system had stopped preserving an invariant it was designed to preserve.
My rule now is simple: do not validate the label “CFR.” Validate the timing from which that label is supposed to follow.
Primary documentation
- FFmpeg Filters: fps — constructing a target frame rate and PTS rounding policy.
- FFmpeg Formats: MOV/MP4 —
video_track_timescaleand MP4 muxer options. - FFmpeg Formats: concat demuxer — file durations and timestamp adjustment across concatenated inputs.
- FFmpeg Bitstream Filters: setts — packet PTS, DTS, duration, and time-base rewriting.
- ffprobe Documentation — stream and packet inspection.
- FFmpeg libavutil Mathematics — rational timestamp rescaling and explicit rounding modes.