diff --git a/simulator/build_pool_manifest.py b/simulator/build_pool_manifest.py index 362f93d..4dfb76d 100644 --- a/simulator/build_pool_manifest.py +++ b/simulator/build_pool_manifest.py @@ -399,6 +399,28 @@ def _adjacent_edges() -> list[tuple[str, str]]: return [(RING_ORDER[i], RING_ORDER[(i + 1) % n]) for i in range(n)] +# All-intra H.264: every frame a keyframe, so the scrub interaction can seek to an +# arbitrary frame smoothly (a sparse GOP scrubs "steppy"). Files grow, but each clip +# stays well under the LFS/proxy ceiling. (Scrub-driven-transitions design ยง4.) +ALL_INTRA = ["-c:v", "libx264", "-g", "1", "-keyint_min", "1", "-sc_threshold", "0", "-pix_fmt", "yuv420p"] + + +def transition_cmd(ff: str, a: str, b: str, out: str) -> list[str]: + """The ffmpeg argv for a forward (zoom-in) member-pair morph, baked all-intra.""" + norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p" + return [ + ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex", + f"[0:v]{norm}[a];[1:v]{norm}[b];" + "[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]", + "-map", "[v]", "-an", *ALL_INTRA, str(out), + ] + + +def reverse_cmd(ff: str, forward: str, out: str) -> list[str]: + """The ffmpeg argv for the zoom-OUT companion (forward played backward), all-intra.""" + return [ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", *ALL_INTRA, str(out)] + + def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path: """A zoom/warp morph between two CLIP MEMBERS' base footage (the well-liked orbit-coast recipe), keyed by clip id so every adjacent member pair gets its @@ -407,13 +429,7 @@ def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path: b = MEDIA / dst_clip / "base.mp4" out = MEDIA / "transitions" / f"{src_clip}__{dst_clip}.mp4" out.parent.mkdir(parents=True, exist_ok=True) - norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p" - subprocess.run([ - ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex", - f"[0:v]{norm}[a];[1:v]{norm}[b];" - "[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]", - "-map", "[v]", "-an", str(out), - ], check=True, capture_output=True) + subprocess.run(transition_cmd(ff, str(a), str(b), str(out)), check=True, capture_output=True) return out @@ -423,9 +439,7 @@ def _make_reverse(ff: str, forward: Path) -> Path: crosses its edge `reversed`; the renderer then plays this file, so zooming out recedes from the current scale back to the higher one instead of zooming in.""" out = forward.with_suffix(".rev.mp4") - subprocess.run([ - ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", str(out), - ], check=True, capture_output=True) + subprocess.run(reverse_cmd(ff, str(forward), str(out)), check=True, capture_output=True) return out diff --git a/tests/test_build_pool_manifest.py b/tests/test_build_pool_manifest.py index 247f883..c406490 100644 --- a/tests/test_build_pool_manifest.py +++ b/tests/test_build_pool_manifest.py @@ -54,3 +54,17 @@ def test_generate_media_bakes_every_member_pair(monkeypatch, tmp_path): for h in b.POOLS[H] for l in b.POOLS[L]} assert fwd <= outs and rev <= outs assert len(fwd) + len(rev) == 154 + + +def test_transition_cmd_is_all_intra(): + cmd = b.transition_cmd("ffmpeg", "/a/base.mp4", "/b/base.mp4", "/out/x__y.mp4") + assert "-g" in cmd and cmd[cmd.index("-g") + 1] == "1" + assert "-keyint_min" in cmd and cmd[cmd.index("-keyint_min") + 1] == "1" + assert "-sc_threshold" in cmd and cmd[cmd.index("-sc_threshold") + 1] == "0" + assert "libx264" in cmd + + +def test_reverse_cmd_is_all_intra(): + cmd = b.reverse_cmd("ffmpeg", "/out/x__y.mp4", "/out/x__y.rev.mp4") + assert "-g" in cmd and cmd[cmd.index("-g") + 1] == "1" + assert "reverse" in " ".join(cmd)