Compare commits

...

2 Commits

Author SHA1 Message Date
BenStullsBets 2eb752b5bc fix(sim): content-hash media URLs + fade pool-landing clip swap
Two fixes to the simulator's media handling, both surfaced by the cosmos clip swap.

1. Permanent cache-bust via content hash. New `GET /api/media-versions` returns a
   short sha1 token per served file (clip bases + ring transitions + reverses),
   cached by (mtime, size) so a re-bake is picked up without a restart. The client
   fetches it at boot and appends `?v=<hash>` to every /media URL, so a clip
   re-baked under the same path (e.g. a re-sourced cosmos base) gets a fresh URL
   the browser can't serve stale — fixing the recurring stale-clip problem at the
   root (supersedes the `cache: "reload"` belt-and-suspenders, which stays).

2. Fade the pool-landing swap. The baked ring transition zooms into the scale
   PRIMARY, but the rotating pool picks a random member on landing — so a
   non-primary pick hard-cut from the primary to the chosen clip (e.g. coast
   birdrock -> surfgrass, the artifact the operator saw). On landing, when the pick
   differs from the primary, mask the swap behind a short fade through the existing
   #black overlay (CSS opacity transition); same-clip landings still do a plain
   (re)load. General across all pooled scales.

271 passed, 2 skipped (+2: media-version content-hash + absent-file omission).
app.js syntax-checked; endpoint verified live (29 files; token matches a manual
sha1 of the bytes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:03:56 -07:00
benstull aa56dfe145 Merge pull request 'fix(sim): bust HTTP cache on media preload so re-baked clips show' (#22) from fix/preload-cache-bust into main 2026-06-25 19:40:50 +00:00
4 changed files with 127 additions and 10 deletions
+53
View File
@@ -59,6 +59,41 @@ def _asset_version() -> str:
return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16]
# Content-hash tokens for served media, so the client can append `?v=<hash>` to a
# /media URL. A clip re-baked under the SAME path (e.g. a re-sourced cosmos base)
# changes its hash → its URL → a fresh fetch, busting any cached prior bytes
# permanently (even an immutable-pinned entry a plain reload can't revalidate).
# Cached by (mtime_ns, size) so the full-file hash is recomputed only when the
# file actually changes — a re-bake is picked up without a server restart.
_media_hash_cache: dict[str, tuple[int, int, str]] = {}
def _media_version(rel: str) -> Optional[str]:
"""Short content hash of the media file at `rel` under MEDIA_DIR, or None if
it's absent. Cheap on repeat calls: re-hashes only when (mtime, size) change."""
path = MEDIA_DIR / rel
try:
st = path.stat()
except OSError:
return None
cached = _media_hash_cache.get(rel)
if cached and cached[0] == st.st_mtime_ns and cached[1] == st.st_size:
return cached[2]
h = hashlib.sha1()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
token = h.hexdigest()[:12]
_media_hash_cache[rel] = (st.st_mtime_ns, st.st_size, token)
return token
def _rev_file(file: str) -> str:
"""The baked zoom-out companion path for a transition file (mirrors the
client's `reverseFile`): `<edge>.mp4` -> `<edge>.rev.mp4`."""
return file[:-4] + ".rev.mp4" if file.endswith(".mp4") else file
class ControlsModel(BaseModel):
content: str
left: int = Field(ge=0, le=4)
@@ -160,6 +195,24 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
def api_clips():
return {"clips": [c.to_dict() for c in app.state.clips]}
@app.get("/api/media-versions")
def api_media_versions():
"""Per-file content-hash tokens the client appends to /media URLs as
`?v=<hash>`. Covers every served file: each clip's base footage plus each
ring transition and its baked reverse. A re-baked clip's hash changes, so
its URL changes and the browser refetches — a permanent cache-bust."""
files = {c.base_file for c in app.state.clips}
if app.state.ring is not None:
for t in app.state.ring.transitions:
files.add(t.file)
files.add(_rev_file(t.file))
versions = {}
for f in sorted(files):
v = _media_version(f)
if v:
versions[f] = v
return {"versions": versions}
@app.get("/api/ring")
def api_ring():
if app.state.ring is None:
+49 -9
View File
@@ -42,6 +42,11 @@ let lastOverlay = null; // {level, intensity} from the most-recent renderOver
async function loadData() {
const clips = (await (await fetch("/api/clips")).json()).clips || [];
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
// Per-file content-hash tokens → appended to /media URLs as ?v=<hash> so a
// re-baked clip (new bytes, same path) gets a fresh URL the browser can't serve
// stale. Best-effort: an empty map just yields un-versioned URLs.
try { mediaVersions = (await (await fetch("/api/media-versions")).json()).versions || {}; }
catch (_) { mediaVersions = {}; }
const r = await fetch("/api/ring");
serverRing = r.ok;
ring = r.ok ? await r.json() : null;
@@ -86,7 +91,12 @@ function activeClip() {
// 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); }
let mediaVersions = {}; // file -> content-hash token (from /api/media-versions)
// Network path for a media file, content-hash-versioned so a re-baked clip's URL
// changes with its bytes (permanent cache-bust). The blob cache, when present,
// wins — those bytes are already the current ones for this session.
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
function mediaUrl(file) { return mediaBlobs[file] || mediaNetUrl(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).
@@ -136,13 +146,12 @@ async function preloadAllMedia(concurrency = 4) {
const file = files[i++];
if (!mediaBlobs[file]) {
try {
// `cache: "reload"` bypasses the browser HTTP cache and refetches from the
// network, then updates the cache entry. This busts a clip whose bytes were
// re-baked under the SAME path (e.g. a re-sourced cosmos base) — including
// one a pre-`no-cache` build had pinned `immutable, max-age=1y`, which a
// plain reload would never revalidate. The blob cache above still gives
// The content-hash `?v=` already makes a re-baked clip's URL unique, but
// `cache: "reload"` is belt-and-suspenders: it bypasses the HTTP cache for
// this fetch (so even an un-versioned or immutable-pinned prior entry can't
// serve stale) and refreshes the entry. The blob cache above still gives
// instant in-session swaps; this only affects the one fetch per (re)load.
const blob = await (await fetch("/media/" + file, { cache: "reload" })).blob();
const blob = await (await fetch(mediaNetUrl(file), { cache: "reload" })).blob();
mediaBlobs[file] = URL.createObjectURL(blob);
} catch (_) { /* leave it to the network path on demand */ }
}
@@ -504,7 +513,7 @@ async function update() {
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
const data = await resp.json();
readout.textContent = JSON.stringify(data, null, 2);
if (!data.content.video) { black.classList.remove("hidden"); return; }
if (!data.content.video) { black.style.opacity = "1"; black.classList.remove("hidden"); return; }
black.classList.add("hidden");
try {
ensureClipMedia();
@@ -559,6 +568,29 @@ function playTransition(file, blended, reversed) {
});
}
// Briefly fade through the #black overlay while `swap` changes the displayed clip,
// so a content swap reads as a deliberate cut, not a jarring jump. Restores #black
// to its hidden/opaque resting state (the video-off use) afterward.
function fadeThroughBlack(swap) {
return new Promise((resolve) => {
black.style.opacity = "0";
black.classList.remove("hidden");
void black.offsetWidth; // reflow so the 0 -> 1 transition runs
black.style.opacity = "1";
setTimeout(() => {
try { swap(); } catch (_) {}
setTimeout(() => {
black.style.opacity = "0";
setTimeout(() => {
black.classList.add("hidden");
black.style.opacity = ""; // back to CSS default for the video-off use
resolve();
}, 210);
}, 160); // let the chosen clip decode under black
}, 210);
});
}
async function advance(delta) {
if (busy || !ring || ring.scales.length < 2) return;
busy = true;
@@ -576,8 +608,16 @@ async function advance(delta) {
}
ringIndex = move.to_index;
activeClipId = move.target_clip_id; // the randomly-picked pool member to load
currentClipId = null; // force the target scale's base media to (re)load
renderScaleReadout();
// The baked transition lands on the scale PRIMARY; if the rotating pool picked a
// DIFFERENT member, mask the swap behind a fade to black so it doesn't hard-cut
// from the primary to the chosen clip (e.g. coast birdrock -> surfgrass).
const landedPrimary = (ring.scales[move.to_index] || {}).clip_id;
if (move.target_clip_id && move.target_clip_id !== landedPrimary) {
await fadeThroughBlack(() => { currentClipId = null; ensureClipMedia(); });
} else {
currentClipId = null; // same clip the transition ended on — plain (re)load
}
} finally {
busy = false;
update();
+1 -1
View File
@@ -39,7 +39,7 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; }
.hud-chip-text.measure { fill: #ffd79a; }
.hud-conf { fill: #6cf; opacity: 0.8; }
.hud-status { fill: #8fdcff; font: 2.4px monospace; opacity: 0.9; letter-spacing: 0.15px; }
.black { position: absolute; inset: 0; background: #000; }
.black { position: absolute; inset: 0; background: #000; opacity: 1; transition: opacity 200ms ease; }
.hidden { display: none; }
.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem; }
fieldset { border: 1px solid #333; border-radius: 6px; }
+24
View File
@@ -76,6 +76,30 @@ def test_clips_returns_the_manifest(client):
assert data["clips"][0]["annotations"][0]["key"] == "detected.water"
def test_media_versions_endpoint_shape(client):
resp = client.get("/api/media-versions")
assert resp.status_code == 200
assert isinstance(resp.json()["versions"], dict)
def test_media_version_is_a_content_hash(tmp_path, monkeypatch):
import hashlib
import simulator.app as appmod
# Point the media root at a temp dir (the served dir is otherwise fixed).
monkeypatch.setattr(appmod, "MEDIA_DIR", tmp_path)
(tmp_path / "clip").mkdir()
f = tmp_path / "clip" / "base.mp4"
f.write_bytes(b"hello-bytes")
assert appmod._media_version("clip/base.mp4") == hashlib.sha1(b"hello-bytes").hexdigest()[:12]
# Absent file -> None (it's simply omitted from the versions map).
assert appmod._media_version("clip/missing.mp4") is None
# Re-baked under the same path (new bytes) -> a new token, not the cached one.
f.write_bytes(b"different-bytes-entirely")
assert appmod._media_version("clip/base.mp4") == hashlib.sha1(b"different-bytes-entirely").hexdigest()[:12]
def test_retired_selection_endpoints_are_gone(client):
# The route no longer exists; the static catch-all yields 404 on GET and
# 405 on the (now-unrouted) POST. Either proves the endpoint is gone.