feat(sim): scrub-driven altitude transitions #28
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user