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