feat(load): phase-1 preload gate + eligible-pick pool growth
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) <noreply@anthropic.com>
This commit is contained in:
+53
-15
@@ -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
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
<script src="config.js"></script>
|
||||
<script src="scrub.js"></script>
|
||||
<script src="alteration.js"></script>
|
||||
<script src="preload.js"></script>
|
||||
<script src="flash.js"></script>
|
||||
<script src="i18n.js"></script>
|
||||
<script src="app.js"></script>
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 = "<id>.b";
|
||||
// morph file = "<from>>-<to>" 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");
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user