What “30fps” usually means, and sometimes does not
A video file declares a frame rate. Most software reads that number and assumes every frame is spaced evenly — at 30fps, one frame every 33.33 milliseconds, forever. That assumption holds for most footage. It does not hold for a lot of what phones produce. A variable frame rate file has uneven gaps between frames. It still declares a rate, and it still plays correctly, but the declaration describes the maximum rather than the rhythm. Here is a real clip:r_frame_rate is what the file declares: 60fps. avg_frame_rate is what it achieved: 7200 ÷ 239 = 30.13fps. The file says sixty and delivered thirty.
That gap is the tell. On constant-rate footage the two match exactly:
Why it drifts
Play a variable-rate clip and it looks fine, because a player reads each frame’s timestamp and shows it when the timestamp says to. Players do not assume; they follow instructions. Trouble starts when something counts instead of reading. If a tool assumes 30fps and you hand it 1800 frames, it concludes the clip is 60 seconds long. If those frames were actually spread across 63 seconds, everything positioned by frame number is now three seconds out by the end — and only at the end. The error accumulates. That is the shape of the symptom people notice: fine at the start, worse as it goes on. A fixed offset means something else. Drift that grows means the timebase disagrees with the timeline.Why phones do this
Not a defect, and not a bug you can report. It is a deliberate trade:- Low light. The sensor needs longer per frame to gather enough light, so the camera quietly drops the rate rather than give you a dark video.
- Heat. Sustained recording warms the phone; frame rate is one of the first things throttled to keep it going.
- Battery and load. Recording while something else works the CPU produces the same effect.
Whether this is your problem
The declared-versus-achieved check above is quick and not conclusive — a clip with one stall in a steady stream averages close to its declared rate and is still variable. To see the rhythm itself, look at the gaps between frame timestamps:sorted() above is doing that work.
Sample more than the beginning. A phone that only stutters once it warms up records the first thirty seconds perfectly. Reading a prefix and stopping will miss it entirely, no matter how long a prefix you read — the blind spot is positional, not statistical. Sample from a few places across the clip.
What actually breaks
Not everything, and knowing which is which saves work:
The pattern: anything that plays the video is fine, anything that measures it is exposed. A wrong answer from a measurement is worse than a visible failure, because nothing throws an error — the numbers are simply off, and they look like numbers.
Fixing it
Conform the video to a constant rate. Frames get duplicated or dropped so the spacing becomes even:r_frame_rate — which is the rate the camera was aiming for. Every captured frame then lands on a grid it fits, and the gaps get filled by duplication rather than the real frames being discarded.
One exception worth knowing: some containers declare an absurd r_frame_rate (1000fps is a common placeholder). If the declared rate is not a plausible capture rate, fall back to the nearest standard rate above the average.
Checking that the fix worked
Re-run the gap check on the output. You want one distinct gap:FastDrop checks for this — and the other defects that behave like it — in one API call, and can perform the fix and verify the result. Why check footage before you process it covers what it looks for and why the same clip can be fine for one job and unusable for another.