"""Integration test for ffmpeg_export.py against the synthetic ground-truth fixture (build-order step 3, and a down payment on step 5). Runs the real marker->offset->export pipeline against test_media/sync_test_OUTOFSYNC_audio+350ms.mp4, then independently re-detects where the flash and beep landed in the *output* file using ffmpeg scene-change / silence detection (not the same code path used to build the export, so this isn't circular) and checks they now line up. """ import re import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from sync_logic import compute_offset from ffmpeg_export import export_synced ROOT = Path(__file__).resolve().parent.parent OUT_DIR = ROOT / "test_media" / "seek_validation" def find_video_event_time(path: Path) -> float: """Scene-change detection: the flash causes two sharp scene changes (onset and offset), so the first hit is the flash's start time.""" proc = subprocess.run( [ "ffmpeg", "-i", str(path), "-vf", "select='gt(scene,0.15)',showinfo", "-f", "null", "-", ], capture_output=True, text=True, ) times = [float(m) for m in re.findall(r"pts_time:([\d.]+)", proc.stderr)] if not times: raise RuntimeError(f"no scene change detected in {path}:\n{proc.stderr}") return times[0] def find_audio_event_time(path: Path) -> float: """silencedetect: the near-silent background audio going loud for the beep produces a silence_end at (approximately) the beep's onset.""" proc = subprocess.run( [ "ffmpeg", "-i", str(path), "-af", "silencedetect=noise=-20dB:d=0.1", "-f", "null", "-", ], capture_output=True, text=True, ) times = [float(m) for m in re.findall(r"silence_end: ([\d.]+)", proc.stderr)] if not times: raise RuntimeError(f"no silence_end detected in {path}:\n{proc.stderr}") return times[0] def run_case(fixture_name: str, expected_target: str, output_name: str): input_path = ROOT / "test_media" / fixture_name output_path = OUT_DIR / output_name print(f"Input fixture: {fixture_name}") video_mark = find_video_event_time(input_path) audio_mark = find_audio_event_time(input_path) print(f" detected flash at t={video_mark:.3f}s, beep at t={audio_mark:.3f}s") offset = compute_offset(video_mark_sec=video_mark, audio_mark_sec=audio_mark) print(f" {offset.describe()}") assert offset.target == expected_target, f"expected {expected_target} branch, got {offset.target}" result = export_synced(str(input_path), offset.delay_ms, str(output_path)) print(f" ran: {' '.join(result.command)}") after_video_t = find_video_event_time(output_path) after_audio_t = find_audio_event_time(output_path) print(f" post-export: flash at t={after_video_t:.3f}s, beep at t={after_audio_t:.3f}s") drift_ms = abs(after_video_t - after_audio_t) * 1000 print(f" drift after export: {drift_ms:.1f}ms") assert drift_ms < 34, f"export did not fix sync: {drift_ms:.1f}ms drift remains (expected < 1 frame ~33ms)" print(f" PASS ({expected_target}-delay branch)\n") def main(): OUT_DIR.mkdir(exist_ok=True) run_case("sync_test_OUTOFSYNC_audio+350ms.mp4", "video", "_export_roundtrip_negative.mp4") run_case("sync_test_OUTOFSYNC_video+350ms.mp4", "audio", "_export_roundtrip_positive.mp4") print("PASS: both ffmpeg command branches (positive and negative delay) round-trip correctly.") if __name__ == "__main__": main()