> ## Documentation Index
> Fetch the complete documentation index at: https://developers.fastdrop.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Variable frame rate, and what it breaks

> Why captions drift, why exports go out of sync, and how to tell whether this is your problem

Your captions were fine at the start and a second late by minute three. Or a clip cut cleanly in your editor and landed a beat off after export. Or a pose model returned confident nonsense on footage that looks perfectly normal when you play it.

These usually have one cause, and it is not the tool you are blaming. It is the video.

## 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:

```bash theme={null}
ffprobe -v error -select_streams v:0 \
  -show_entries stream=r_frame_rate,avg_frame_rate \
  -of default=nw=1 your-video.mp4
```

```
r_frame_rate=60/1
avg_frame_rate=7200/239
```

`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:

```
r_frame_rate=30/1
avg_frame_rate=30/1
```

## 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.

The camera is choosing a watchable video over a mathematically tidy one, and for watching, it is right. The file only becomes a problem when it reaches software that measures rather than plays.

## 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:

```bash theme={null}
ffprobe -v error -select_streams v:0 \
  -show_entries packet=pts_time -of csv=p=0 your-video.mp4 \
  | head -400 \
  | python3 -c "
import sys, statistics
ts = sorted(float(l.strip().rstrip(',')) for l in sys.stdin if l.strip())
gaps = [round((b - a) * 1000, 1) for a, b in zip(ts, ts[1:])]
print('distinct gaps (ms):', sorted(set(gaps)))
print('median:', statistics.median(gaps), 'ms')
"
```

Constant-rate footage gives you one number:

```
distinct gaps (ms): [33.3]
```

The variable clip from earlier gives you two, alternating — capture in bursts, then a pause:

```
distinct gaps (ms): [16.7, 50.0]
median: 16.7 ms
```

Two caveats, because naive versions of this check get it wrong:

**Sort by presentation time, not file order.** Compressed video stores frames out of order — a B-frame is encoded after frames it appears before. Comparing timestamps in storage order makes perfectly constant footage look variable. The `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:

| | |
| - | - |
| **Playback in a browser or player** | Fine. Players follow timestamps. |
| **Caption and subtitle sync** | Breaks. Timings computed from frame counts drift. |
| **Cutting on an NLE timeline** | Breaks. A timeline is constant-rate; conforming to it slips against audio. |
| **Motion tracking, optical flow, pose** | Breaks worst. These divide by the frame interval, and an uneven one does not degrade the answer — it silently scales it wrong. |
| **Thumbnails at a timestamp** | Usually fine. One frame, no accumulation. |
| **Re-encoding for delivery** | Fine, and often fixes it as a side effect. |

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:

```bash theme={null}
ffmpeg -i input.mp4 -r 30 -fps_mode cfr -c:v libx264 -c:a copy output.mp4
```

**Which rate you choose matters more than it looks.** The instinct is to use the average — the file achieved 30.13fps, so conform to 30. That is usually wrong.

The average is dragged down by whatever the camera dropped. A clip that shot 60 and stuttered to an average of 30 is not 30fps footage; it is 60fps footage with gaps. Conform it to 30 and you throw away half of what was actually captured, permanently, in the name of fixing it.

Use the **declared** rate — `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:

```
distinct gaps (ms): [33.3]
```

Duration should be unchanged within about one frame, and the frame *count* will have changed — that is the fix working, not a problem. Conforming 400 unevenly-spaced frames to a constant 30fps over 20 seconds produces 600 frames. The extra ones are duplicates filling gaps where the camera captured nothing.

***

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](/guides/checking-footage) covers what it looks for and why the same clip can be fine for one job and unusable for another.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.