feat(sim): real zoom transitions on every ring edge, both directions

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 (<edge>.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) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-25 08:34:50 -07:00
parent 0928988abc
commit 0201690ce0
2 changed files with 42 additions and 14 deletions
+16 -6
View File
@@ -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 `<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.
// 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