perf(sim): preload all clips into memory for instant altitude swaps
Each altitude change reset the single <video> element's src and re-fetched the scale's footage over the network, compounded by the dev no-store policy applying to /media too — so every scale change paid a full fetch+decode. - simulator/app.py: split the cache middleware — app shell/JS/CSS stay no-store (by-eye dev iteration), but /media is now cacheable (public, max-age, immutable) since the clips are static. - simulator/static/app.js: preload every ring clip (19 base + 5 transition, ~350 MB) into in-memory blob object URLs at startup — non-blocking, the first scale plays immediately while the rest stream in, ordered current scale outward. mediaUrl() serves the cached blob when ready, else falls back to the network. A small progress chip self-removes when done. - tests: assert app assets stay no-store and /media is cacheable. 269 passed, 2 skipped. Frontend verified by-eye by the operator (no headless browser in repo yet). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+74
-1
@@ -75,7 +75,79 @@ function activeClip() {
|
||||
return clipsById[activeClipId] || null;
|
||||
}
|
||||
|
||||
function mediaUrl(file) { return "/media/" + file; }
|
||||
// In-memory preload cache: media file path -> blob object URL. Once a clip's bytes
|
||||
// are cached, mediaUrl() hands the <video> an in-memory blob instead of a network
|
||||
// path, so swapping the altitude scale loads near-instantly (no fetch round-trip).
|
||||
// Falls back to the network path for anything not yet (or never) cached.
|
||||
const mediaBlobs = {};
|
||||
function mediaUrl(file) { return mediaBlobs[file] || ("/media/" + file); }
|
||||
|
||||
// Every media file the ring can show: each scale-pool clip's base footage plus the
|
||||
// per-edge transition clips. This is the full preload set (~two dozen files).
|
||||
function preloadList() {
|
||||
const files = new Set();
|
||||
for (const c of Object.values(clipsById)) if (c && c.base_file) files.add(c.base_file);
|
||||
if (ring && ring.transitions) for (const t of ring.transitions) if (t && t.file) files.add(t.file);
|
||||
return [...files];
|
||||
}
|
||||
|
||||
// Order the preload so the clips most likely to be shown next come first: the
|
||||
// current scale's pool, then outward to neighboring scales, then transitions.
|
||||
function preloadOrder() {
|
||||
if (!ring || !ring.scales) return preloadList();
|
||||
const n = ring.scales.length, seen = new Set(), ordered = [];
|
||||
const add = (f) => { if (f && !seen.has(f)) { seen.add(f); ordered.push(f); } };
|
||||
for (let d = 0; d < n; d++) {
|
||||
for (const sign of d === 0 ? [0] : [1, -1]) {
|
||||
const s = ring.scales[((ringIndex + sign * d) % n + n) % n];
|
||||
if (s && s.pool) for (const m of s.pool) add((clipsById[m.clip_id] || {}).base_file);
|
||||
else if (s) add((clipsById[s.clip_id] || {}).base_file);
|
||||
}
|
||||
}
|
||||
if (ring.transitions) for (const t of ring.transitions) add(t.file);
|
||||
for (const f of preloadList()) add(f); // sweep up anything not on the ring
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// Fetch every clip into the blob cache in the background — non-blocking, so the
|
||||
// first scale plays immediately while the rest stream in. Bounded concurrency keeps
|
||||
// the ~350 MB fetch from stampeding. A tiny progress chip self-removes when done.
|
||||
async function preloadAllMedia(concurrency = 4) {
|
||||
const files = preloadOrder();
|
||||
if (!files.length) return;
|
||||
let done = 0;
|
||||
const total = files.length;
|
||||
const chip = preloadChip();
|
||||
const tick = () => { if (chip) chip.textContent = `⬇ caching clips ${done}/${total}`; };
|
||||
tick();
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < files.length) {
|
||||
const file = files[i++];
|
||||
if (!mediaBlobs[file]) {
|
||||
try {
|
||||
const blob = await (await fetch(mediaUrl(file))).blob();
|
||||
mediaBlobs[file] = URL.createObjectURL(blob);
|
||||
} catch (_) { /* leave it to the network path on demand */ }
|
||||
}
|
||||
done++; tick();
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
|
||||
if (chip) chip.remove();
|
||||
}
|
||||
|
||||
function preloadChip() {
|
||||
let c = document.getElementById("preload-chip");
|
||||
if (!c) {
|
||||
c = document.createElement("div");
|
||||
c.id = "preload-chip";
|
||||
c.style.cssText = "position:fixed;bottom:8px;right:10px;z-index:9998;background:rgba(0,0,0,.55);" +
|
||||
"color:#9fe;font:11px/1.4 monospace;padding:4px 8px;border-radius:4px;pointer-events:none;";
|
||||
document.body.appendChild(c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// Load the active scale's BASE footage into the video (the painterly canvas reads
|
||||
// its frames live). The Right knob no longer swaps the source — it only drives the
|
||||
@@ -633,6 +705,7 @@ async function main() {
|
||||
try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); }
|
||||
await loadData();
|
||||
await landScale(); // pick the initial scale's pool member before first render
|
||||
preloadAllMedia(); // background: cache every clip in memory for instant altitude swaps
|
||||
buildDial(); // draw the altitude knob from the ring's scales
|
||||
renderScaleReadout();
|
||||
for (const id of ["content", "left", "right", "mood"]) {
|
||||
|
||||
Reference in New Issue
Block a user