From 5382995931b617d342c8348817984c023e2025a2 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Sat, 27 Jun 2026 07:15:03 -0700 Subject: [PATCH] feat(sim): play chosen morph, lock clip per altitude, drop secondary swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 7. advance() threads the current clip as from_clip_id, plays the server-resolved chained morphs (each already lands on its destination clip), then locks activeClipId until the next altitude change — no fade-to-black secondary swap, no re-roll while parked. Builds a (from->to) morph lookup from the ring; preloads only the morphs reachable from the locked clip, refreshed per landing (154 morphs is too many to preload up front). Drops reverseFile + fadeThroughBlack. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/static/app.js | 148 +++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 72 deletions(-) diff --git a/simulator/static/app.js b/simulator/static/app.js index 40c26ae..dae86eb 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -34,9 +34,10 @@ let clipsById = {}; // id -> clip manifest entry let ring = null; // {scales:[{id,clip_id,title,pool:[...]}], transitions:[...]} or null let serverRing = false; // true when /api/ring served a real ring (vs the synthesized fallback) let ringIndex = 0; // current scale position on the ring -let activeClipId = null; // the pool member chosen for the current scale landing +let activeClipId = null; // the pool member chosen for the current scale landing (LOCKED until the altitude changes) let currentClipId = null; // base media currently loaded (reset to force a reload) let busy = false; // true while a ring transition is playing +let morphByPair = {}; // "fromClip→toClip" -> directed morph file (built from the ring) let lastOverlay = null; // {level, intensity} from the most-recent renderOverlay call; drives per-frame track animation async function loadData() { @@ -54,6 +55,12 @@ async function loadData() { // No ring: single-clip mode — synthesize a 1-scale ring (pool of one) over clip 0. ring = { scales: [{ id: clips[0].id, clip_id: clips[0].id, title: clips[0].title, pool: [{ clip_id: clips[0].id, title: clips[0].title }] }], transitions: [] }; } + // Directed (fromClip -> toClip) -> morph file lookup. The manifest holds both + // directions explicitly (forward zoom-in + .rev zoom-out), so no reverse logic + // is needed at play time. A fallback ring (no transitions) yields an empty map. + morphByPair = {}; + if (ring && ring.transitions) + for (const t of ring.transitions) if (t && t.file) morphByPair[`${t.from}→${t.to}`] = t.file; ringIndex = 0; } @@ -101,21 +108,38 @@ function mediaUrl(file) { return mediaBlobs[file] || mediaNetUrl(file); } -// Every media file the ring can show: each scale-pool clip's base footage plus the -// per-edge transition clips. This is the full preload set (~two dozen files). +// The morphs reachable from the CURRENT locked clip: to/from every member of the +// neighboring altitudes (the only clips one detent can reach), both directions. +// There are 154 morphs in all — far too many to preload up front — so we focus on +// what the next move can actually need and refresh it on every landing. +function reachableMorphFiles() { + const files = new Set(); + if (!ring || !ring.scales || ring.scales.length < 2 || !activeClipId) return files; + const n = ring.scales.length; + for (const sign of [1, -1]) { + const nb = ring.scales[((ringIndex + sign) % n + n) % n]; + const members = (nb.pool || [{ clip_id: nb.clip_id }]).map((m) => m.clip_id); + for (const m of members) { + const into = morphByPair[`${activeClipId}→${m}`]; + const back = morphByPair[`${m}→${activeClipId}`]; + if (into) files.add(into); + if (back) files.add(back); + } + } + return files; +} + +// Every base clip the ring can show (so any scale displays instantly). Morphs are +// NOT bulk-listed here — they're cached per-landing via reachableMorphFiles(). function preloadList() { const files = new Set(); for (const c of Object.values(clipsById)) if (c && c.base_file) files.add(c.base_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]; } // Order the preload so the clips most likely to be shown next come first: the -// current scale's pool, then outward to neighboring scales, then transitions. +// current scale's pool, then outward to neighboring scales; then the morphs +// reachable from the current clip; then a sweep of remaining bases. function preloadOrder() { if (!ring || !ring.scales) return preloadList(); const n = ring.scales.length, seen = new Set(), ordered = []; @@ -127,11 +151,26 @@ function preloadOrder() { else if (s) add((clipsById[s.clip_id] || {}).base_file); } } - if (ring.transitions) for (const t of ring.transitions) { add(t.file); add(reverseFile(t.file)); } + for (const f of reachableMorphFiles()) add(f); for (const f of preloadList()) add(f); // sweep up anything not on the ring return ordered; } +// Fetch one media file into the blob cache (idempotent — cached files are skipped). +async function cacheClip(file) { + if (!file || mediaBlobs[file]) return; + try { + const blob = await (await fetch(mediaNetUrl(file), { cache: "reload" })).blob(); + mediaBlobs[file] = URL.createObjectURL(blob); + } catch (_) { /* leave it to the network path on demand */ } +} + +// After landing on a new altitude, kick a background cache fill of the morphs now +// reachable from the locked clip, so the next zoom is instant. Non-blocking. +function refreshReachablePreload() { + for (const f of reachableMorphFiles()) cacheClip(f); +} + // Fetch every clip into the blob cache in the background — non-blocking, so the // first scale plays immediately while the rest stream in. Bounded concurrency keeps // the ~350 MB fetch from stampeding. A tiny progress chip self-removes when done. @@ -149,18 +188,10 @@ async function preloadAllMedia(concurrency = 4) { let i = 0; async function worker() { while (i < files.length) { - const file = files[i++]; - if (!mediaBlobs[file]) { - try { - // The content-hash `?v=` already makes a re-baked clip's URL unique, but - // `cache: "reload"` is belt-and-suspenders: it bypasses the HTTP cache for - // this fetch (so even an un-versioned or immutable-pinned prior entry can't - // serve stale) and refreshes the entry. The blob cache above still gives - // instant in-session swaps; this only affects the one fetch per (re)load. - const blob = await (await fetch(mediaNetUrl(file), { cache: "reload" })).blob(); - mediaBlobs[file] = URL.createObjectURL(blob); - } catch (_) { /* leave it to the network path on demand */ } - } + // cacheClip is idempotent and content-hash-versioned (?v=) + cache:"reload", + // so a re-baked clip's bytes can't serve stale; the blob cache gives instant + // in-session swaps. + await cacheClip(files[i++]); done++; tick(); } } @@ -560,25 +591,22 @@ function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); } const FAST_BLEND_RATE = 2.5; // playback speed for a collapsed fast-spin pass -// 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. - // Zoom-out moves (`reversed`) play the baked reverse clip. Resolves on 'ended' - // (or a safety timeout that scales with playback rate). +function playTransition(file, blended) { + // Play one resolved morph start-to-finish, with the alteration layers hidden. + // The manifest already encodes direction (forward zoom-in vs `.rev` zoom-out), + // so the file is played as-is. A `blended` (fast-spin) step runs at + // FAST_BLEND_RATE so a quick encoder spin lands fast. If no morph was baked for + // the pair (file null), resolve immediately so advance() plain-cuts. Resolves on + // 'ended' (or a safety timeout that scales with playback rate). return new Promise((resolve) => { + if (!file) { resolve(); return; } overlay.style.opacity = "0"; affectLayer.style.opacity = "0"; tint.style.opacity = "0"; vid.style.filter = "none"; vid.loop = false; vid.style.opacity = "1"; - vid.src = mediaUrl(reversed ? reverseFile(file) : file); + vid.src = mediaUrl(file); vid.playbackRate = blended ? FAST_BLEND_RATE : 1; let done = false; const finish = () => { @@ -594,60 +622,36 @@ function playTransition(file, blended, reversed) { }); } -// Briefly fade through the #black overlay while `swap` changes the displayed clip, -// so a content swap reads as a deliberate cut, not a jarring jump. Restores #black -// to its hidden/opaque resting state (the video-off use) afterward. -function fadeThroughBlack(swap) { - return new Promise((resolve) => { - black.style.opacity = "0"; - black.classList.remove("hidden"); - void black.offsetWidth; // reflow so the 0 -> 1 transition runs - black.style.opacity = "1"; - setTimeout(() => { - try { swap(); } catch (_) {} - setTimeout(() => { - black.style.opacity = "0"; - setTimeout(() => { - black.classList.add("hidden"); - black.style.opacity = ""; // back to CSS default for the video-off use - resolve(); - }, 210); - }, 160); // let the chosen clip decode under black - }, 210); - }); -} - +// Advance the ring `delta` detents: pick the destination clip(s), play the matching +// chained morph(s), then LOCK on the landed clip until the next altitude change. async function advance(delta) { if (busy || !ring || ring.scales.length < 2) return; busy = true; try { + // The server chooses the destination clip(s) FIRST (threading the currently- + // shown clip in as from_clip_id), then returns the matching member->member + // morphs to play — chained through each crossed altitude. const resp = await fetch("/api/ring/advance", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ from_index: ringIndex, delta }), + body: JSON.stringify({ from_index: ringIndex, delta, from_clip_id: activeClipId }), }); if (!resp.ok) return; const move = await resp.json(); - // A fast spin comes back collapsed to a single blended step (move.fast); - // a slow spin chains one full transition per scale crossed. + // Play each chained morph. A fast spin marks every step blended (played quick); + // a slow spin plays each at 1x. Each morph already ends on its destination + // clip's frame, so there is NO secondary swap. for (const step of move.steps) { - await playTransition(step.file, step.blended, step.reversed); + await playTransition(step.file, step.blended); } ringIndex = move.to_index; - activeClipId = move.target_clip_id; // the randomly-picked pool member to load + activeClipId = move.target_clip_id; // LOCK on the landed clip — stays until the next altitude change + currentClipId = null; // force ensureClipMedia to (re)load + loop it renderScaleReadout(); - // The baked transition lands on the scale PRIMARY; if the rotating pool picked a - // DIFFERENT member, mask the swap behind a fade to black so it doesn't hard-cut - // from the primary to the chosen clip (e.g. coast birdrock -> surfgrass). - const landedPrimary = (ring.scales[move.to_index] || {}).clip_id; - if (move.target_clip_id && move.target_clip_id !== landedPrimary) { - await fadeThroughBlack(() => { currentClipId = null; ensureClipMedia(); }); - } else { - currentClipId = null; // same clip the transition ended on — plain (re)load - } } finally { busy = false; - update(); + update(); // ensureClipMedia loads the locked clip; nothing re-rolls while parked applyAudio(); // soundtrack follows the Altitude dial (crossfade to the new scale) + refreshReachablePreload(); // warm the morphs now reachable from the locked clip } }