diff --git a/simulator/static/app.js b/simulator/static/app.js index ed3eb9b..dbc492f 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -171,7 +171,12 @@ function preloadOrder() { } } for (const f of reachableMorphFiles()) add(f); - for (const f of preloadList()) add(f); // sweep up anything not on the ring + for (const f of preloadList()) add(f); // sweep up every base not already on the ring + // ALL morphs last: preloading every transition before interaction guarantees a + // smooth knob turn anywhere (any random pool re-roll lands on an already-cached + // morph). They're ordered last so the first scenes + nearest transitions cache + // first and the loading bar fills meaningfully. + for (const f of Object.values(morphByPair)) add(f); return ordered; } @@ -720,7 +725,6 @@ 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. @@ -817,25 +821,9 @@ function rebuildSegment(lo, enteredFrom) { vid.style.filter = "none"; vid.loop = false; if (vid.dataset.morph !== file) { - 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(); - } - }); - } + vid.dataset.morph = file; + vid.src = mediaUrl(file); + vid.pause(); } // Diagnostic seam (matches advance()'s old record): the logical morph path // played, reliable even when served from an in-memory blob. @@ -861,38 +849,19 @@ 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. @@ -1331,16 +1300,12 @@ 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) - // 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) + // Preload BEFORE interaction: a base for every altitude + all related morphs, so + // turning the knob is always smooth (no on-demand stall, no blank next scene). The + // "Loading Universe…" bar tracks this; the universe stays gated until it's ready. + await preloadAllMedia(); 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 @@ -1383,3 +1348,31 @@ function setLanguage(lang) { } main(); + +// Temporary ?debug overlay: an on-screen readout of media/render state, for +// diagnosing real-browser playback that headless can't reproduce. Add ?debug to the +// URL to show it. Harmless/no-op without the flag; safe to remove once resolved. +(function debugOverlay() { + if (!/[?&]debug\b/.test(location.search)) return; + const box = document.createElement("div"); + box.style.cssText = "position:fixed;left:6px;bottom:6px;z-index:99999;max-width:96vw;" + + "background:rgba(0,0,0,.82);color:#0f0;font:11px/1.35 monospace;padding:8px 10px;" + + "white-space:pre-wrap;border:1px solid #0a0;border-radius:4px;"; + document.body.appendChild(box); + let webgl = "?"; + try { const c = document.createElement("canvas"); webgl = c.getContext("webgl2") ? "webgl2" : (c.getContext("webgl") ? "webgl1" : "NONE"); } + catch (e) { webgl = "throw:" + e.message; } + const errs = []; + window.addEventListener("error", (e) => { errs.push((e.message || e.error || "err").toString().slice(0, 80)); }); + const m = (el) => el ? `rs=${el.readyState} ${el.paused ? "PAUSED" : "play"} t=${(el.currentTime || 0).toFixed(1)} err=${el.error ? el.error.code : "-"} ${(el.currentSrc || el.src || "(no src)").slice(0, 46)}` : "absent"; + setInterval(() => { + const cfg = window.HEF_CONFIG || {}; + box.textContent = + `cfg: static=${cfg.static} base=${cfg.mediaBase}\n` + + `webgl=${webgl} visual=${(document.getElementById("visual") || {}).checked}\n` + + `loop : ${m(document.getElementById("vid-loop"))}\n` + + `morph: ${m(document.getElementById("vid"))}\n` + + `aud : ${m(document.getElementById("aud"))}\n` + + `errs : ${errs.slice(-3).join(" | ") || "none"}`; + }, 400); +})();