diff --git a/simulator/app.py b/simulator/app.py index 7ebe72d..3f718b5 100644 --- a/simulator/app.py +++ b/simulator/app.py @@ -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=` 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`): `.mp4` -> `.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=`. 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: diff --git a/simulator/static/app.js b/simulator/static/app.js index 52c9e62..739af85 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -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= 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(); diff --git a/simulator/static/style.css b/simulator/static/style.css index f64d528..c028bf2 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -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; } diff --git a/tests/test_simulator_api.py b/tests/test_simulator_api.py index b3c5669..0e39a70 100644 --- a/tests/test_simulator_api.py +++ b/tests/test_simulator_api.py @@ -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.