From 1a6ffc74a1bb8473761f9825f2f33e42e2c2996e Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 09:55:46 -0700 Subject: [PATCH] feat(load): phase-1 preload gate + eligible-pick pool growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the universe on one clip per altitude + connecting morphs (~126 MB, ~10-20s), then grow the pool in the background. The random pick (pickPoolClip/pickRandomMember) is restricted to fully-loaded clips — base + connecting morphs both ways cached (HEFPreload.eligibleDestinations) — so a transition never stalls on un-downloaded media and new clips become reachable only once fully loaded. Pure planner in preload.js (+ node tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/static/app.js | 68 ++++++++++++++++++++++++++-------- simulator/static/index.html | 1 + simulator/static/preload.js | 52 ++++++++++++++++++++++++++ simulator/unit/preload.test.js | 53 ++++++++++++++++++++++++++ tools/build_static.py | 7 ++-- 5 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 simulator/static/preload.js create mode 100644 simulator/unit/preload.test.js diff --git a/simulator/static/app.js b/simulator/static/app.js index 72203cb..3023f57 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -89,15 +89,30 @@ async function loadData() { // advance (content-pipeline §11.1, Python owns the randomness). The synthesized // fallback ring has a pool of one, so it just returns that member. Returns a // clip_id or null. Shared by the initial landing AND the Dev Mode re-roll button. +// Lookups for the pure preload planner (HEFPreload): map clip/morph → media file and +// test the in-memory blob cache. A clip is "loaded" only when its file is a cached blob. +function _preloadDeps() { + return { + baseOf: (id) => (clipsById[id] || {}).base_file, + morphFile: (a, b) => morphByPair[`${a}→${b}`] || null, + isCached: (f) => !!f && !!mediaBlobs[f], + }; +} + +function poolIds(scale) { + return ((scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }]).map((m) => m.clip_id); +} + async function pickRandomMember() { const scale = ring && ring.scales[ringIndex]; if (!scale) return null; - // Uniform random pool member, client-side (mirrors hef.selection.pick_clip_id). - // The drag/scroll navigation already resolves picks + morphs client-side via - // scrub.js; this removes the last /api/ring/advance dependency so the build is - // fully static. The synthesized fallback ring has a pool of one → returns it. - const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }]; - return pool[Math.floor(Math.random() * pool.length)].clip_id; + // Show only a clip whose base is already loaded (eligible). Initially that's the + // phase-1 first member; the pool grows as the background preload finishes. Fallback + // to the first member (guaranteed loaded by the phase-1 gate). + const ids = poolIds(scale); + let elig = HEFPreload.eligibleMembers(ids, _preloadDeps()); + if (!elig.length) elig = [ids[0]]; + return HEFPreload.pick(elig, Math.random); } // Land on the current scale: pick a random pool member and force its media to load. @@ -180,6 +195,18 @@ function preloadOrder() { return ordered; } +// Cache a fixed list of files (bounded concurrency), driving the "Loading Universe…" +// bar. Used for the phase-1 gate (one clip/altitude + connecting morphs) before unlock. +async function cacheMany(files, concurrency = 6) { + const total = files.length || 1; + let done = 0, i = 0; + setLoadingProgress(0, total); + async function worker() { + while (i < files.length) { await cacheClip(files[i++]); done++; setLoadingProgress(done, total); } + } + await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker)); +} + // Fetch one media file into the blob cache (idempotent — cached files are skipped). async function cacheClip(file) { if (!file || mediaBlobs[file]) return; @@ -928,11 +955,18 @@ function angDelta(a, b) { // destination pick (was the server's job in /api/ring/advance; moved here so the // scrub responds without a round-trip. `pick_clip_id` in player/ring.py stays the // canonical pure helper for the Pi player). -function pickPoolClip(index) { +function pickPoolClip(index, fromId) { const n = ring.scales.length; const s = ring.scales[HEFScrub.wrapIndex(index, n)]; - const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }]; - return pool[Math.floor(Math.random() * pool.length)].clip_id; + const ids = poolIds(s); + // Pick a DESTINATION only among clips fully loaded relative to fromId (base + + // morphs both ways) — so the transition never stalls on un-downloaded media. The + // eligible set grows as the background preload completes. Fallback to the first + // pool member (the phase-1 gate guarantees it + its connecting morphs are loaded). + let elig = fromId ? HEFPreload.eligibleDestinations(ids, fromId, _preloadDeps()) + : HEFPreload.eligibleMembers(ids, _preloadDeps()); + if (!elig.length) elig = [ids[0]]; + return HEFPreload.pick(elig, Math.random); } // Build (or re-roll) the segment [lo, lo+1]. The end equal to the rested altitude @@ -942,8 +976,8 @@ function pickPoolClip(index) { function rebuildSegment(lo, enteredFrom) { const n = ring.scales.length; const atLo = HEFScrub.wrapIndex(lo, n), atHi = HEFScrub.wrapIndex(lo + 1, n); - const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo); - const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1); + const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo, activeClipId); + const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1, activeClipId); const file = morphByPair[`${loId}→${hiId}`] || null; activeSeg = { lo, clipLo: loId, clipHi: hiId, file }; if (file) { @@ -1515,12 +1549,16 @@ async function main() { dial.addEventListener("keydown", onDialKey); // arrows/Home/End + Enter/Space on labels $("stage").addEventListener("wheel", onWheel, { passive: false }); update(); // render the initial state (both toggles off → black, silent) - // 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(); + // Phase 1 — gate the universe on a minimal, fully-loaded set: one clip per altitude + // + the morphs connecting them (~126 MB). Fast to start; the knob is smooth from the + // first turn because the random pick only ever lands on loaded clips (eligibility). + await cacheMany(HEFPreload.phase1Files(ring.scales, _preloadDeps())); hideLoading(); // experience is ready — boots SILENT (video off, audio 0) $("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage + // Phase 2 — grow the pool in the background: more clips per altitude + their morphs. + // Each becomes eligible for the random pick only once fully loaded, so navigation + // keeps using the already-loaded clips until the new ones are ready (no stall). + preloadAllMedia(); // NO await — streams the rest behind the unlocked universe maybeShowMotionWarning(); // first-visit photosensitivity notice, above the button // 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 diff --git a/simulator/static/index.html b/simulator/static/index.html index 1fc144a..4ad8c95 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -130,6 +130,7 @@ + diff --git a/simulator/static/preload.js b/simulator/static/preload.js new file mode 100644 index 0000000..083b4bb --- /dev/null +++ b/simulator/static/preload.js @@ -0,0 +1,52 @@ +// Pure preload planning — no DOM. The boot caches phase1Files() before unlocking the +// universe; the random clip pick is then restricted to eligible clips (fully loaded +// base + connecting morphs), so a transition never stalls on un-downloaded media and +// the pool grows safely as the background preload finishes. UMD so the browser gets +// `HEFPreload` and `node --test` can require() it. +// +// deps shape: { baseOf(clipId)->file, morphFile(fromId,toId)->file|null, isCached(file)->bool } +(function (root, factory) { + const api = factory(); + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.HEFPreload = api; +})(typeof self !== "undefined" ? self : this, function () { + "use strict"; + + // The minimal set to load before the simulator can run: the FIRST pool member of + // every altitude + the morphs connecting adjacent first-members (both directions, + // cyclic ring). One clip per altitude + corresponding morphs. + function phase1Files(scales, deps) { + const n = scales.length; + const chosen = scales.map((s) => (s.pool && s.pool.length ? s.pool[0].clip_id : s.clip_id)); + const files = new Set(); + for (const c of chosen) { + const bf = deps.baseOf(c); + if (bf) files.add(bf); + } + for (let i = 0; i < n; i++) { + const a = chosen[i], b = chosen[(i + 1) % n]; + for (const f of [deps.morphFile(a, b), deps.morphFile(b, a)]) if (f) files.add(f); + } + return [...files]; + } + + // Pool members eligible as a DESTINATION from `fromId`: base cached AND the morph + // cached BOTH ways (so the forward transition and a turn-back are ready). + function eligibleDestinations(poolIds, fromId, deps) { + return poolIds.filter((c) => + deps.isCached(deps.baseOf(c)) && + deps.isCached(deps.morphFile(fromId, c)) && + deps.isCached(deps.morphFile(c, fromId))); + } + + // Pool members eligible to SHOW at their own altitude (no transition): base cached. + function eligibleMembers(poolIds, deps) { + return poolIds.filter((c) => deps.isCached(deps.baseOf(c))); + } + + function pick(list, rnd) { + return list.length ? list[Math.floor(rnd() * list.length)] : null; + } + + return { phase1Files, eligibleDestinations, eligibleMembers, pick }; +}); diff --git a/simulator/unit/preload.test.js b/simulator/unit/preload.test.js new file mode 100644 index 0000000..1021681 --- /dev/null +++ b/simulator/unit/preload.test.js @@ -0,0 +1,53 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const P = require("../static/preload.js"); + +// Tiny fixture: 3-altitude cyclic ring, 2 pool members each. base_file = ".b"; +// morph file = ">-" when it exists. +const scales = [ + { id: "a", pool: [{ clip_id: "a1" }, { clip_id: "a2" }] }, + { id: "b", pool: [{ clip_id: "b1" }, { clip_id: "b2" }] }, + { id: "c", pool: [{ clip_id: "c1" }, { clip_id: "c2" }] }, +]; +const baseOf = (id) => id + ".b"; +const morphFile = (a, b) => `${a}>-${b}`; // pretend every pair has a morph +const cached = new Set(); +const deps = () => ({ baseOf, morphFile, isCached: (f) => cached.has(f) }); + +test("phase1Files = first member of each altitude + connecting morphs (both dirs, cyclic)", () => { + const files = P.phase1Files(scales, deps()); + // bases: a1.b b1.b c1.b + for (const b of ["a1.b", "b1.b", "c1.b"]) assert.ok(files.includes(b), `missing base ${b}`); + assert.ok(!files.includes("a2.b"), "second members must NOT be in phase 1"); + // connecting morphs between adjacent first-members, both directions, cyclic (a-b, b-c, c-a) + for (const m of ["a1>-b1", "b1>-a1", "b1>-c1", "c1>-b1", "c1>-a1", "a1>-c1"]) + assert.ok(files.includes(m), `missing morph ${m}`); + assert.equal(files.length, 3 + 6); +}); + +test("eligibleDestinations requires base + BOTH morphs cached", () => { + cached.clear(); + // from a1, candidate b1: nothing cached → not eligible + assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), []); + cached.add("b1.b"); cached.add("a1>-b1"); // base + forward only + assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), [], "reverse morph still missing"); + cached.add("b1>-a1"); // now reverse too + assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), ["b1"]); + // b2 fully cached too → both eligible + cached.add("b2.b"); cached.add("a1>-b2"); cached.add("b2>-a1"); + assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()).sort(), ["b1", "b2"]); +}); + +test("eligibleMembers requires only the base cached", () => { + cached.clear(); + assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), []); + cached.add("a1.b"); + assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), ["a1"]); +}); + +test("pick returns null on empty, a member otherwise", () => { + assert.equal(P.pick([], Math.random), null); + assert.equal(P.pick(["x"], () => 0), "x"); + assert.equal(P.pick(["x", "y", "z"], () => 0.99), "z"); +}); diff --git a/tools/build_static.py b/tools/build_static.py index bf3cfb8..041be8f 100644 --- a/tools/build_static.py +++ b/tools/build_static.py @@ -36,8 +36,8 @@ from simulator.app import MEDIA_DIR, create_app STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static" # Frontend files that ship; everything else in static/ (author*, review*) is dev-only. -PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css", - "credits.html", "credits.js", "about.html", "flash.js"] +PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js", + "style.css", "credits.html", "credits.js", "about.html", "flash.js"] def _bake_api(app_dir: Path) -> dict: @@ -84,7 +84,8 @@ def _version_assets(app_dir: Path) -> None: # = guaranteed-fresh fetch on a normal reload. The HTML itself is served no-cache # (_headers), but Cloudflare Pages caches .js/.css by type (max-age=14400) and # ignores _headers for them — so without this, returning visitors run stale JS. - assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "style.css", "credits.js", "config.js"] + assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js", "flash.js", + "style.css", "credits.js", "config.js"] tok = {} for a in assets: p = app_dir / a