Merge pull request 'perf(sim): preload all clips into memory for instant altitude swaps' (#19) from feature/preload-media-cache into main
This commit was merged in pull request #19.
This commit is contained in:
+11
-5
@@ -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
@@ -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"]) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user