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:
BenStullsBets
2026-06-25 07:47:32 -07:00
parent 9a83c0132d
commit d19fe4e8fa
3 changed files with 111 additions and 6 deletions
+11 -5
View File
@@ -234,12 +234,18 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
return {"ok": True, "clip": merged}
@app.middleware("http")
async def _no_cache(request, call_next):
# Dev preview server: never let a browser serve a stale app.js/style.css.
# The whole point is by-eye iteration, so a plain refresh must always get
# the latest assets (the heuristic cache otherwise hides JS/CSS edits).
async def _cache_policy(request, call_next):
# Dev preview server: never let a browser serve a stale app.js/style.css
# by-eye iteration needs a plain refresh to always get the latest assets.
# Media is the exception: the clips are static and large, so they must be
# browser-cacheable. The renderer also preloads them into in-memory blob
# URLs (static/app.js), but a cacheable header keeps the preload fetch and
# any fallback off the network on repeat loads — instant altitude swaps.
response = await call_next(request)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
if request.url.path.startswith("/media/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
if MEDIA_DIR.exists():
+74 -1
View File
@@ -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"]) {
+26
View File
@@ -91,6 +91,32 @@ def test_index_is_served():
assert "Alteration" in resp.text
def test_app_assets_are_not_cached(client):
# Dev iteration relies on the browser never serving stale renderer code.
resp = client.get("/")
assert "no-store" in resp.headers.get("cache-control", "")
def test_media_is_cacheable(tmp_path, monkeypatch):
# Media is static: it must be browser-cacheable so the preloader's fetch (and
# any fallback) doesn't re-hit the network on every altitude change.
import simulator.app as appmod
media = tmp_path / "media"
(media / "cosmos").mkdir(parents=True)
(media / "cosmos" / "base.mp4").write_bytes(b"\x00\x00\x00\x18ftyp")
monkeypatch.setattr(appmod, "MEDIA_DIR", media)
manifest = tmp_path / "manifest.json"
manifest.write_text(json.dumps({"clips": []}))
client = TestClient(create_app(manifest_path=manifest))
resp = client.get("/media/cosmos/base.mp4")
assert resp.status_code == 200
cc = resp.headers.get("cache-control", "")
assert "no-store" not in cc
assert "max-age" in cc
@pytest.fixture
def ring_manifest_path(tmp_path):
p = tmp_path / "manifest.json"