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/1Real answer, from the original the pipeline had never been asked about:
ffprobe ... original.MOV # -> 60/1The 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:
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.
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.
Carry the exact rational, never a float. NTSC rates are
60000/1001and30000/1001. Rounding to59.94/29.97accumulates drift across a long timeline; ffmpeg accepts the fraction verbatim, so there is no reason to lose it.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.