From 7da944af0c20cdb0c972e8af978d230637114e1d Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 08:53:40 -0700 Subject: [PATCH] feat(load): progressive boot + hold-last-frame until media loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot becomes ready on the first clip and streams the rest in the background (was: blocked on the full ~2.3 GB before 'Run simulation' appeared). During nav, the visible morph and landing base only swap to cached blobs — otherwise the current frame holds (settleGen guards a stale settle) — so rapid altitude-clicking never blanks the stage. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/static/app.js | 59 +++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/simulator/static/app.js b/simulator/static/app.js index f843d44..e2aedf4 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -720,6 +720,7 @@ let pos = 0; // continuous knob position; rest value == ringInd let activeSeg = null; // { lo, clipLo, clipHi, file } for the segment being scrubbed let seekPending = false; // throttle: at most one currentTime seek per animation frame let scrubDir = 1; // last travel direction (+1 descend / -1 ascend) — which end we'll land on +let settleGen = 0; // bumped on every scrub frame; invalidates a pending "settle when the base is ready" callback so a stale one can't fire mid-new-scrub // Diagnostic seam (sibling of window.__hefMorphs): the committed altitude state, so // E2E can assert the locked clip mid-scrub when #scale-name (settle-only) is stale. @@ -816,9 +817,25 @@ function rebuildSegment(lo, enteredFrom) { vid.style.filter = "none"; vid.loop = false; if (vid.dataset.morph !== file) { - vid.dataset.morph = file; - vid.src = mediaUrl(file); - vid.pause(); + if (mediaBlobs[file]) { + // Cached → instant swap to the in-memory blob. + vid.dataset.morph = file; + vid.src = mediaBlobs[file]; + vid.pause(); + } else { + // Not loaded yet — HOLD the current frame (never point the visible element at + // a network src mid-scrub, which would blank it). Fetch into the blob cache and + // swap in once ready, IF we're still heading into this same morph. Meanwhile + // `vid.dataset.morph` stays unset, so the seek below is skipped and the last + // frame holds — keeping rapid altitude-clicking smooth. + cacheClip(file).then(() => { + if (activeSeg && activeSeg.file === file && mediaBlobs[file] && vid.dataset.morph !== file) { + vid.dataset.morph = file; + vid.src = mediaBlobs[file]; + vid.pause(); + } + }); + } } // Diagnostic seam (matches advance()'s old record): the logical morph path // played, reliable even when served from an in-memory blob. @@ -844,19 +861,38 @@ function setPos(next) { pos = next; const { lo, frac } = HEFScrub.segmentOf(pos); if (frac === 0) { // settled exactly on an altitude → lock + base loop + single soundtrack - busy = false; delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base) currentClipId = null; // force ensureClipMedia to reload + loop the locked clip - playLoop(); // the loop element was preloaded to this clip @ tail during the scrub → instant, no reload - showActiveSource(); setNeedle(ringIndex * dialStep()); renderScaleReadout(); - update(); // ensureClipMedia: idempotent loadLoop + keep the loop running restAudio(ringIndex); refreshReachablePreload(); // warm the morphs now reachable from the locked clip + // Swap to the base loop only once it can actually play. The loop element was + // preloaded to this clip @ tail during the scrub, so the common case is ready + // immediately and lands instantly (as before). If rapid navigation outran the + // load, KEEP `busy` true — displayVid() stays the morph element, whose last frame + // already depicts this clip — until the loop is decoded. Never blank the stage. + const myGen = ++settleGen; + const landing = activeClip(); + let done = false; + const settleToLoop = () => { + if (done || myGen !== settleGen) return; // a new scrub started → this settle is stale + done = true; + busy = false; // displayVid() → loopVid; the shader now reads the base loop + playLoop(); + showActiveSource(); + update(); // ensureClipMedia: idempotent loadLoop + keep the loop running + }; + if (loopVid.readyState >= 2 && landing && loopVid.dataset.clip === landing.id) { + settleToLoop(); // already decoded (preloaded @ tail) → instant landing + } else { + loopVid.addEventListener("canplay", settleToLoop, { once: true }); + loopVid.addEventListener("loadeddata", settleToLoop, { once: true }); + } return; } busy = true; // mid-morph: block update() from reloading the base clip under us + settleGen++; // moving again → invalidate any pending settle-when-ready from a prior landing if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex); // Preload the clip we're heading toward into the LOOP element (paused @ tail) so the // landing is a swap, not a reload+seek. dir>0 lands on clipHi, dir<0 on clipLo. @@ -1292,9 +1328,16 @@ async function main() { dial.addEventListener("wheel", onWheel, { passive: false }); $("stage").addEventListener("wheel", onWheel, { passive: false }); update(); // render the initial state (both toggles off → black, silent) - await preloadAllMedia(); // download all media, updating the loading bar + // Progressive boot: become ready as soon as the FIRST scene can play, then stream + // the rest in the background — don't block the visitor on the full ~2.3 GB. The + // graduated preload (bases → reachable morphs → sweep) keeps filling the blob cache; + // anything not cached yet falls back to the network path on demand (mediaUrl). + const firstClip = activeClip(); + if (firstClip) await cacheClip(firstClip.base_file); // just the first base (~16 MB) hideLoading(); // experience is ready — boots SILENT (video off, audio 0) $("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage + refreshReachablePreload(); // front-load the morphs the first knob-turn will need + preloadAllMedia(); // NO await — bases + morphs stream in behind the scenes // No un-gestured auto-start: a browser autoplay policy blocks an audio play() made // outside a user gesture (muted video survives, sound does not), so an auto-start // would show video but stay silent. Instead the experience waits for the operator to