From 0201690ce0c7f6fac7201a298d474eb0caa0f67a Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 25 Jun 2026 08:34:50 -0700 Subject: [PATCH] feat(sim): real zoom transitions on every ring edge, both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cosmos→orbit, reef→abyss and abyss→cosmos transition clips were stale leftovers from an earlier 5-scale ring, built against old/placeholder footage — so those edges flashed placeholder frames mid-zoom. Only orbit-coast and coast-reef had been rebuilt from the current real bases. - build_pool_manifest.generate_media(): now rebuilds EVERY ring edge from the current real primary pool bases (the orbit-coast recipe applied uniformly), overwriting the stale clips, and bakes a reversed companion (.rev.mp4) per edge via an ffmpeg `reverse` pass. - app.js: a `reversed` step (an ascending / zoom-OUT ring move) now plays the baked .rev clip instead of the forward zoom-in, so going up recedes from the current scale. Both directions are added to the in-memory preload set. Media is gitignored (generated locally / on deploy); regenerated all 10 clips locally. 269 passed, 2 skipped; node --check clean; clips serve 200. Visual result to be verified by-eye by the operator. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/build_pool_manifest.py | 34 ++++++++++++++++++++++++-------- simulator/static/app.js | 22 +++++++++++++++------ 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/simulator/build_pool_manifest.py b/simulator/build_pool_manifest.py index d234f8c..19bd710 100644 --- a/simulator/build_pool_manifest.py +++ b/simulator/build_pool_manifest.py @@ -355,7 +355,7 @@ def build_manifest() -> dict: return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}} -# --- Optional: generate the NEW transition placeholders (orbit-coast, coast-reef) --- +# --- Generate the per-edge transition clips (zoom morph + reversed companion) --- def _ffmpeg() -> str: if shutil.which("ffmpeg"): @@ -365,8 +365,10 @@ def _ffmpeg() -> str: def _make_transition(ff: str, scale_a: str, scale_b: str) -> Path: - """A placeholder zoom/warp morph between two scales, from their PRIMARY pool - members' bases (mirrors setup_scales_media.py but keyed by scale->primary).""" + """A zoom/warp morph between two scales, from their PRIMARY pool members' + bases (mirrors setup_scales_media.py but keyed by scale->primary). This is the + recipe behind the well-liked orbit-coast edge; generate_media() now applies it + uniformly to every edge so none carry stale footage from an earlier ring.""" a = MEDIA / POOLS[scale_a][0] / "base.mp4" b = MEDIA / POOLS[scale_b][0] / "base.mp4" out = MEDIA / "transitions" / f"{scale_a}-{scale_b}.mp4" @@ -381,13 +383,29 @@ def _make_transition(ff: str, scale_a: str, scale_b: str) -> Path: return out +def _make_reverse(ff: str, forward: Path) -> Path: + """The zoom-OUT companion of a forward (zoom-in) edge: the same clip played + backward, written alongside it as `.rev.mp4`. An ascending ring move + 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) + return out + + def generate_media() -> None: - """Generate the new ring edges introduced by the coast scale; the cosmos-orbit, - reef-abyss and abyss-cosmos edges already exist from the prior 5-scale ring.""" + """(Re)build EVERY ring-edge transition from the current real primary bases — + the orbit-coast recipe applied uniformly, overwriting any stale clips left from + an earlier ring — plus a reversed companion per edge for zoom-out moves.""" ff = _ffmpeg() - for a, b in [("orbit", "coast"), ("coast", "reef")]: - _make_transition(ff, a, b) - print(f"generated transitions/{a}-{b}.mp4 (placeholder zoom)") + n = len(RING_ORDER) + for i in range(n): + a, b = RING_ORDER[i], RING_ORDER[(i + 1) % n] + fwd = _make_transition(ff, a, b) + _make_reverse(ff, fwd) + print(f"generated transitions/{a}-{b}.mp4 (+ .rev) from real bases") def main(argv: list[str]) -> None: diff --git a/simulator/static/app.js b/simulator/static/app.js index 376d3d5..86b0aff 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -93,7 +93,11 @@ function mediaUrl(file) { return mediaBlobs[file] || ("/media/" + file); } function preloadList() { const files = new Set(); for (const c of Object.values(clipsById)) if (c && c.base_file) files.add(c.base_file); - if (ring && ring.transitions) for (const t of ring.transitions) if (t && t.file) files.add(t.file); + // Each ring edge ships both directions: the forward (zoom-in) clip and its baked + // reverse (zoom-out) companion — cache both so up and down moves are instant. + if (ring && ring.transitions) for (const t of ring.transitions) { + if (t && t.file) { files.add(t.file); files.add(reverseFile(t.file)); } + } return [...files]; } @@ -110,7 +114,7 @@ function preloadOrder() { else if (s) add((clipsById[s.clip_id] || {}).base_file); } } - if (ring.transitions) for (const t of ring.transitions) add(t.file); + if (ring.transitions) for (const t of ring.transitions) { add(t.file); add(reverseFile(t.file)); } for (const f of preloadList()) add(f); // sweep up anything not on the ring return ordered; } @@ -515,11 +519,17 @@ function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); } const FAST_BLEND_RATE = 2.5; // playback speed for a collapsed fast-spin pass -function playTransition(file, blended) { +// The zoom-OUT companion of an edge clip: the baked `.rev.mp4` reverse. A +// `reversed` step (an ascending / zoom-out ring move) plays this instead of the +// forward zoom-in clip, so going up recedes from the current scale. +function reverseFile(file) { return file.replace(/\.mp4$/, ".rev.mp4"); } + +function playTransition(file, blended, reversed) { // Play one zoom/warp transition clip start-to-finish, with the alteration // layers hidden. A `blended` (fast-spin) pass runs at FAST_BLEND_RATE so a // quick encoder spin lands fast instead of grinding through every morph. - // Resolves on 'ended' (or a safety timeout that scales with playback rate). + // Zoom-out moves (`reversed`) play the baked reverse clip. Resolves on 'ended' + // (or a safety timeout that scales with playback rate). return new Promise((resolve) => { overlay.style.opacity = "0"; affectLayer.style.opacity = "0"; @@ -527,7 +537,7 @@ function playTransition(file, blended) { vid.style.filter = "none"; vid.loop = false; vid.style.opacity = "1"; - vid.src = mediaUrl(file); + vid.src = mediaUrl(reversed ? reverseFile(file) : file); vid.playbackRate = blended ? FAST_BLEND_RATE : 1; let done = false; const finish = () => { @@ -556,7 +566,7 @@ async function advance(delta) { // A fast spin comes back collapsed to a single blended step (move.fast); // a slow spin chains one full transition per scale crossed. for (const step of move.steps) { - await playTransition(step.file, step.blended); + await playTransition(step.file, step.blended, step.reversed); } ringIndex = move.to_index; activeClipId = move.target_clip_id; // the randomly-picked pool member to load