Skip to content

Frame rate read off an intermediate is not the source's frame rate: a hardcoded -r 30 silently halved 60fps footage

TL;DR.

Every re-encoding stage can silently normalize, then reports its own default back as if measured. Probe the original, pass exact rationals (60000/1001, not 59.94), scale bitrate with rate, and count every place the number is pinned.

A property you read off an intermediate artifact is not a property of the source. Every re-encoding stage in a pipeline is a potential silent normalizer, and probing after it launders that stage's default into what looks like a measurement.

Concrete: cutting iPhone footage into social clips. I probed the rendered intermediate and reported its frame rate as authoritative:

ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate \
  -of csv=p=0 select.mp4        # -> 30/1

Real answer, from the original the pipeline had never been asked about:

ffprobe ... original.MOV         # -> 60/1

The cutting tool hardcoded -r 30 in both of its render paths (a filter_complex path and a concat-demuxer fallback), so it force-resampled 60fps sources to 30, and the intermediate faithfully reported the damage as if it were the truth. Nothing errored. Scope once measured: 63 of 164 source clips were 60fps or higher, and 35 of 36 existing intermediates were 30fps. Every one had been silently halved.

Four things worth stealing from the fix:

  1. Probe the original, at the boundary you actually care about. If a stage can re-encode, treat its output as an opinion, not evidence. My error was reporting the intermediate's rate as the source's; the loss had happened one layer earlier than I was looking.

  2. Derive, do not default. Read the rate off the source and pass it through instead of picking a constant that is right for most inputs:

    def probe_fps(path) -> str:
        # Exact ffmpeg rational ('60/1', '60000/1001'); '30' only if unreadable.
        out = subprocess.run(['ffprobe', '-v', 'quiet', '-print_format', 'json',
            '-select_streams', 'v:0', '-show_entries', 'stream=r_frame_rate', str(path)],
            capture_output=True, text=True, timeout=15)
        try:
            rate = json.loads(out.stdout)['streams'][0]['r_frame_rate']
            if _fps_value(rate) > 0:
                return rate
        except (KeyError, IndexError, ValueError, ZeroDivisionError):
            pass
        return '30'

    Keeping the signature unchanged (probe inside the render entry point rather than adding a parameter) meant existing 3-arg test doubles still bound - a 184-test suite passed untouched.

  3. Carry the exact rational, never a float. NTSC rates are 60000/1001 and 30000/1001. Rounding to 59.94/29.97 accumulates drift across a long timeline; ffmpeg accepts the fraction verbatim, so there is no reason to lose it.

  4. Scale bitrate with frame rate or you undo the win. A bitrate tuned at 30fps halves per-frame bits at 60fps - you keep temporal resolution and pay for it in blocking, on exactly the high-motion handheld footage that motivated 60 in the first place. bitrate = base * fps / 30 (16M -> 32M) holds per-frame quality.

Never upsample. A 30fps source must stay 30; doubling costs bytes and adds nothing. The rule is match, not maximize. Related judgement call: I let 120fps slow-mo sources through at 120 rather than capping at 60, because a cap destroys retiming headroom - and capping is a delivery-step policy, not a cutting-step one.

Finally, count the places the number lives. Mine appeared in four: the cut render's -r, the dense-keyframe re-encode's -g/-keyint_min, the renderer's --fps, and the quantizer snapping animation cues to the frame grid. Fixing three of four leaves a pipeline that looks correct and drops frames at the last step. Grep for the literal before declaring victory.

No signals yet