29c6d822bd
- player/audio.py: pure resolve_visual / resolve_audio_url / resolve_audio (§4/§6) - retire player/content.py (7-way table) + its test - player/controls.py: serial contract content -> visual + audio - player/state.py: Playback carries video:bool + audio_source:str - simulator/app.py: /api/alteration validates visual+audio, returns render.video + server-resolved render.audio.url from altitude_index; NOISE_URL constant - tests reframed; full suite 285 passed, 2 skipped Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
333 lines
13 KiB
Python
333 lines
13 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.audio import AUDIO_SOURCES, VISUAL_POSITIONS, resolve_audio, resolve_visual
|
|
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"
|
|
|
|
# The global white-noise bed (audio spec §5.2): synthesized, altitude-independent.
|
|
NOISE_URL = "/media/audio/noise/pink.mp3"
|
|
|
|
# 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]
|
|
|
|
|
|
# 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):
|
|
visual: str
|
|
audio: 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
|
|
altitude_index: int = 0
|
|
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.visual not in VISUAL_POSITIONS:
|
|
raise HTTPException(status_code=422, detail=f"invalid visual {c.visual!r}")
|
|
if c.audio not in AUDIO_SOURCES:
|
|
raise HTTPException(status_code=422, detail=f"invalid audio {c.audio!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)
|
|
# Resolve the soundtrack url against the CURRENT altitude (server-side, audio
|
|
# spec §6.1). White-noise/off ignore the scale; soundtrack follows it.
|
|
scale_audio = ""
|
|
if app.state.ring is not None:
|
|
scale_audio = scale_at(app.state.ring, req.altitude_index).audio
|
|
audio = resolve_audio(c.audio, scale_audio=scale_audio, noise_url=NOISE_URL)
|
|
return {
|
|
"plan": render_plan_to_dict(plan),
|
|
"render": {
|
|
"video": {"shown": resolve_visual(c.visual)},
|
|
"audio": {
|
|
"source": audio.source,
|
|
"url": audio.url,
|
|
"altitude_coupled": audio.altitude_coupled,
|
|
},
|
|
},
|
|
}
|
|
|
|
@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/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:
|
|
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()
|