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:
+76
-72
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user