feat(sim): play chosen morph, lock clip per altitude, drop secondary swap

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) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-27 07:15:03 -07:00
parent 7855718f74
commit 5382995931
+76 -72
View File
@@ -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 `<edge>.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
}
}