7855718f74
Tasks 3-6. Transition is now a directed clip-pair morph; ScaleRing.morph_for looks up (from,to)->file. advance_ring returns structural steps (no file); resolve_move picks each crossed altitude's clip THEN its morph, chained, and reports the locked target. Fast spin keeps the whole chain (blended) instead of collapsing. /api/ring/advance takes from_clip_id, draws picks, returns the resolved chain. Dropped ring_move_to_dict + _rev_file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
340 lines
13 KiB
Python
340 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,
|
|
ResolvedMove,
|
|
advance_ring,
|
|
pick_clip_id,
|
|
resolve_move,
|
|
scale_at,
|
|
)
|
|
from simulator.clips import (
|
|
load_manifest,
|
|
load_ring,
|
|
resolved_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]
|
|
|
|
|
|
# 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
|
|
|
|
|
|
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
|
|
from_clip_id: str = ""
|
|
|
|
|
|
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)
|
|
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 every
|
|
ring transition morph (both directions are explicit entries). 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)
|
|
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,
|
|
)
|
|
if move.steps:
|
|
# Choose the destination member of each crossed altitude FIRST (one
|
|
# random draw per step — content-pipeline §11.1), then resolve the
|
|
# morph from the prior clip to it, so the footage matches what we land
|
|
# on. The currently-shown clip threads in as `from_clip_id`.
|
|
src = req.from_clip_id or scale_at(app.state.ring, move.from_index).clip_id
|
|
picks = tuple(random.random() for _ in move.steps)
|
|
resolved = resolve_move(app.state.ring, move, src, picks)
|
|
else:
|
|
# A no-op move (delta=0) is the initial / re-roll pick: a fresh random
|
|
# member of the current scale, no morph.
|
|
landed = scale_at(app.state.ring, move.to_index)
|
|
resolved = ResolvedMove(steps=(), target_clip_id=pick_clip_id(landed, random.random()))
|
|
return resolved_move_to_dict(move, resolved)
|
|
|
|
@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()
|