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>
This commit is contained in:
BenStullsBets
2026-06-25 13:03:56 -07:00
parent aa56dfe145
commit 2eb752b5bc
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: