Files
human-experience-filter-art/simulator/app.py
T
BenStullsBets a2ef792f0f fix(sim): revalidate /media instead of pinning it immutable
The `immutable, max-age=1y` media header pinned stale footage in the browser for
a year, so a re-sourced or re-cropped clip (e.g. the de-captioned cosmos) never
showed without a manual cache clear. Switch /media to `no-cache` (store, but
revalidate every load): instant altitude swaps already come from the in-memory
blob preload, so the HTTP cache only matters on (re)load — where a conditional
request is a cheap 304 when unchanged but picks up edited clips immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:20:14 -07:00

262 lines
9.8 KiB
Python

"""FastAPI service: controls -> the real alteration engine -> a RenderPlan.
The simulator's alteration surface (reconciled slice). It calls the canonical
player.alteration.plan_alteration; the browser only renders. The selection-era
endpoints (/api/select, /api/catalog/meta) and the X-ray are retired.
"""
from __future__ import annotations
import hashlib
import json
import os
import random
import time
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from hef.selection import Coordinate
from player.alteration import (
DEFAULT_CALIBRATION,
Calibration,
plan_alteration,
render_plan_to_dict,
)
from player.content import resolve_content
from player.controls import CONTENT_POSITIONS
from player.ring import (
DEFAULT_FAST_SPIN_THRESHOLD,
advance_ring,
pick_clip_id,
scale_at,
)
from simulator.clips import load_manifest, load_ring, ring_move_to_dict, ring_to_dict
STATIC_DIR = Path(__file__).parent / "static"
MEDIA_DIR = Path(__file__).parent / "sample_media"
DEFAULT_MANIFEST = MEDIA_DIR / "manifest.json"
# Per-process boot token: changes on every server restart so the dev live-reload
# (below) also fires when Python code changes (which needs a restart), not just
# when a static asset's mtime changes.
_BOOT_ID = f"{os.getpid()}-{int(time.time())}"
def _asset_version() -> str:
"""A short digest of the renderer assets' mtimes + this process's boot token.
Changes whenever app.js/style.css/index.html change on disk OR the server is
restarted — the signal the browser polls to know it's running stale code."""
parts = [_BOOT_ID]
for name in ("app.js", "style.css", "index.html"):
try:
parts.append(f"{name}:{(STATIC_DIR / name).stat().st_mtime_ns}")
except OSError:
parts.append(f"{name}:?")
return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16]
class ControlsModel(BaseModel):
content: str
left: int = Field(ge=0, le=4)
right: int = Field(ge=0, le=4)
dark: int = Field(ge=0, le=4)
light: int = Field(ge=0, le=4)
volume: int = Field(ge=0, le=4)
brightness: int = Field(ge=0, le=4)
class CalibrationModel(BaseModel):
mood_gain: float = 1.0
overlay_gain: float = 1.0
dream_gain: float = 1.0
class AlterationRequest(BaseModel):
controls: ControlsModel
calibration: Optional[CalibrationModel] = None
class RingAdvanceRequest(BaseModel):
from_index: int = 0
delta: int
class AuthorTrackRequest(BaseModel):
"""Author mode: propagate a hand-seeded box on a clip into a keyframed track
(content-pipeline §11.5). `seed_box` + `seed_t` are normalized."""
clip_id: str
key: str
seed_box: list[float] = Field(min_length=4, max_length=4)
seed_t: float = Field(default=0.0, ge=0.0, le=1.0)
salience: int = Field(default=4, ge=1, le=4)
n_keyframes: int = Field(default=5, ge=2, le=20)
max_frames: Optional[int] = None
class AuthorClipRequest(BaseModel):
"""Author mode: persist the authored labels/affect/strings for an existing
pool clip into the manifest (geometry from the tracker, semantics by hand)."""
clip_id: str
annotations: list = []
affect: list = []
strings: dict = {}
def _load_clips(manifest_path: Optional[Path]):
path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
if path.exists():
return load_manifest(path)
return []
def _load_ring(manifest_path: Optional[Path]):
path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
if path.exists():
return load_ring(path)
return None
def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
app = FastAPI(title="HEF Alteration Simulator")
app.state.manifest_path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
app.state.clips = _load_clips(manifest_path)
app.state.ring = _load_ring(manifest_path)
@app.post("/api/alteration")
def api_alteration(req: AlterationRequest):
c = req.controls
if c.content not in CONTENT_POSITIONS:
raise HTTPException(status_code=422, detail=f"invalid content {c.content!r}")
coord = Coordinate(c.left, c.right, c.dark, c.light)
cal = (
Calibration(
mood_gain=req.calibration.mood_gain,
overlay_gain=req.calibration.overlay_gain,
dream_gain=req.calibration.dream_gain,
)
if req.calibration
else DEFAULT_CALIBRATION
)
plan = plan_alteration(coord, cal)
content = resolve_content(c.content)
return {
"plan": render_plan_to_dict(plan),
"content": {"audio_source": content.audio_source, "video": content.video},
}
@app.get("/dev/version")
def dev_version():
# Dev live-reload signal: the browser polls this and reloads when it
# changes, so it never silently runs a stale renderer during iteration.
return {"version": _asset_version()}
@app.get("/api/clips")
def api_clips():
return {"clips": [c.to_dict() for c in app.state.clips]}
@app.get("/api/ring")
def api_ring():
if app.state.ring is None:
raise HTTPException(status_code=404, detail="no scale ring in manifest")
return ring_to_dict(app.state.ring, app.state.clips)
@app.post("/api/ring/advance")
def api_ring_advance(req: RingAdvanceRequest):
if app.state.ring is None:
raise HTTPException(status_code=404, detail="no scale ring in manifest")
move = advance_ring(
app.state.ring,
req.from_index,
req.delta,
fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD,
)
# Rotating pool: pick a random member of the LANDED scale (content-pipeline
# §11.1). A delta=0 advance is the initial / re-roll pick (a no-op move that
# still yields a fresh random clip). The pure pick takes injected randomness.
landed = scale_at(app.state.ring, move.to_index)
chosen = pick_clip_id(landed, random.random())
return ring_move_to_dict(move, app.state.ring, chosen)
@app.post("/api/author/track")
def api_author_track(req: AuthorTrackRequest):
# Author mode (§11.5): run the classical optical-flow tracker on a clip's
# base footage, returning the keyframed track geometry for the author to
# accept/correct. Semantics (key/strings/tiers) stay the author's.
clip = next((c for c in app.state.clips if c.id == req.clip_id), None)
if clip is None:
raise HTTPException(status_code=404, detail=f"unknown clip {req.clip_id!r}")
base = MEDIA_DIR / clip.base_file
if not base.exists():
raise HTTPException(status_code=404, detail=f"media absent: {clip.base_file}")
try:
import cv2
except ImportError:
raise HTTPException(status_code=503, detail="opencv-python not installed")
from tools.pipeline.track import track_seed
cap = cv2.VideoCapture(str(base))
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
cap.release()
seed_frame = max(0, min(total - 1, round(req.seed_t * total))) if total else 0
return track_seed(
base, req.key, tuple(req.seed_box), seed_frame=seed_frame,
salience=req.salience, n_keyframes=req.n_keyframes, max_frames=req.max_frames,
)
@app.post("/api/author/clip")
def api_author_clip(req: AuthorClipRequest):
# Author mode (§11.5 / stage 6): persist authored labels/affect/strings for
# an EXISTING pool clip — idempotent upsert that keeps the clip's media +
# provenance and replaces only the authored content. Reloads in place so
# the preview reflects the edit without a restart.
from tools.pipeline.manifest import upsert_clip
path = app.state.manifest_path
data = json.loads(path.read_text())
existing = next((c for c in data.get("clips", []) if c["id"] == req.clip_id), None)
if existing is None:
raise HTTPException(status_code=404, detail=f"unknown clip {req.clip_id!r}")
merged = dict(existing)
merged["annotations"] = req.annotations
merged["affect"] = req.affect
if req.strings:
merged["strings"] = req.strings
data = upsert_clip(data, merged)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
app.state.clips = load_manifest(path)
app.state.ring = load_ring(path)
return {"ok": True, "clip": merged}
@app.middleware("http")
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 uses `no-cache` (store, but REVALIDATE every load): instant altitude
# swaps already come from the in-memory blob preload (static/app.js), so the
# HTTP cache only matters on (re)load — where a conditional request returns a
# cheap 304 when unchanged, yet picks up a re-sourced/re-cropped clip
# immediately. `immutable` was wrong here: it pinned stale footage for a year
# so edited clips never showed without a manual cache clear.
response = await call_next(request)
if request.url.path.startswith("/media/"):
response.headers["Cache-Control"] = "no-cache"
else:
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
if MEDIA_DIR.exists():
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
if STATIC_DIR.exists():
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
return app
app = create_app()