From e258dfecd35329ea66ae5bdd320c80e58e76f6e4 Mon Sep 17 00:00:00 2001 From: Honney OC Date: Sun, 13 Sep 2026 19:49:23 +0000 Subject: [PATCH] fix: normalize video fps to 24000/1001 with ffprobe check --- extract.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/extract.py b/extract.py index 5d6c7bd..cafc149 100644 --- a/extract.py +++ b/extract.py @@ -37,12 +37,75 @@ OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "extracted # inter-frame only, and FFV1/PCM/ProRes/DNxHR have no GPU implementation. # GPU only makes sense for preview proxies, not for measurement. CPU encode # of 90 s clips is fast enough and bit-exact. -VIDEO_CODEC = ["-c:v", "ffv1", "-level", "3", "-pix_fmt", "yuv444p"] +VIDEO_CODEC_BASE = ["-c:v", "ffv1", "-level", "3", "-pix_fmt", "yuv444p"] AUDIO_CODEC = ["-c:a", "pcm_f32le", "-ar", "48000", "-ac", "1"] +TARGET_FPS = "24000/1001" # Blu-ray film = 23.976023... (shown as 23.98) +TARGET_FPS_FLOAT = 24000 / 1001 +FPS_EPS = 0.02 # tolerance for float comparison + +def _parse_rate(s: str) -> float | None: + s = s.strip() + if not s or s in ("0/0", "0"): + return None + if "/" in s: + try: + n, d = s.split("/") + return float(n) / float(d) if float(d) != 0 else None + except ValueError: + return None + try: + return float(s) + except ValueError: + return None + +def probe_fps(path: str) -> float | None: + """Return probed video fps via ffprobe, or None if probe fails.""" + if shutil.which("ffprobe") is None: + return None + # avg_frame_rate is usually canonical; fall back to r_frame_rate + for entry in ("avg_frame_rate", "r_frame_rate"): + try: + r = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", f"stream={entry}", "-of", "default=nw=1:nk=1", path], + capture_output=True, text=True, timeout=10 + ) + for line in r.stdout.strip().splitlines(): + fps = _parse_rate(line) + if fps and fps > 0 and fps < 1000: + return fps + except Exception: + continue + return None + +def fps_matches(a: float | None, b: float, eps: float = FPS_EPS) -> bool: + return a is not None and abs(a - b) < eps + # ─── Check ffmpeg ─── if shutil.which(f"ffmpeg") is None: sys.exit(f"ERROR: 'ffmpeg' not found on PATH. Install it and try again.") +# ─── Pre-probe all inputs to decide fps handling ─── +probed = {} +for p in INPUT_FILES: + fps = probe_fps(p) if os.path.exists(p) else None + probed[p] = fps + tag = f"{fps:.3f}" if fps else "unknown (ffprobe missing or file not found)" + print(f"[probe] {os.path.basename(p)}: {tag} fps" + (f" {'≈ target ' + TARGET_FPS if fps_matches(fps, TARGET_FPS_FLOAT) else '≠ target ' + TARGET_FPS}" if fps else "")) + +# sanity check: both mismatch target and differ from each other → warn (neither is canonical) +valid_fps = [v for v in probed.values() if v is not None] +if len(valid_fps) >= 2: + all_match = all(fps_matches(v, TARGET_FPS_FLOAT) for v in valid_fps) + any_mismatch = any(not fps_matches(v, TARGET_FPS_FLOAT) for v in valid_fps) + if any_mismatch: + distinct = len({round(v, 2) for v in valid_fps}) > 1 + if not all_match and distinct: + print(f"WARNING: inputs have different non-target fps {valid_fps} — both will be normalized to {TARGET_FPS} ({TARGET_FPS_FLOAT:.6f} fps). " + f"If this is PAL speedup (25) vs NTSC film (24000/1001), consider also compensating audio tempo.") + elif not all_match: + print(f"NOTE: inputs share fps {valid_fps[0]:.3f} but differ from target {TARGET_FPS} — both will be normalized to {TARGET_FPS}.") + # ─── Prepare output folder ─── os.makedirs(OUT_DIR, exist_ok=True) @@ -59,13 +122,25 @@ for INPUT_FILE in INPUT_FILES: print(f"-" * 50) # ─── Extract video-only (first 90s, no audio, CPU FFV1 re-encode) ─── + # Only apply fps filter if probed fps differs from target (avoid needless re-timing) + fps = probed.get(INPUT_FILE) + needs_fps = not fps_matches(fps, TARGET_FPS_FLOAT) + video_codec = VIDEO_CODEC_BASE + (["-vf", f"fps={TARGET_FPS}:round=near", "-vsync", "cfr"] if needs_fps else []) + if fps is not None: + print(f"[fps] {stem}: probed {fps:.3f} → {'convert to ' + TARGET_FPS if needs_fps else 'already ' + TARGET_FPS + ', keeping native timing'}") + else: + print(f"[fps] {stem}: unknown fps → forcing {TARGET_FPS} (safe fallback)") + # ensure fallback forces target if we couldn't probe + if not needs_fps: + video_codec = VIDEO_CODEC_BASE + ["-vf", f"fps={TARGET_FPS}:round=near", "-vsync", "cfr"] + cmd_video = [ "ffmpeg", "-y", # overwrite output "-i", INPUT_FILE, "-t", str(DURATION), "-an", # no audio - *VIDEO_CODEC, # ffv1 lossless intra-only, CPU (no GPU equiv.) + *video_codec, # ffv1 lossless intra-only, CPU (no GPU equiv.) video_out ] print(f"[video: {stem}] running ffmpeg…")