feat(ring,api): pick destination clip then resolve matching chained morph

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>
This commit is contained in:
BenStullsBets
2026-06-27 07:12:25 -07:00
parent cc469b5298
commit 7855718f74
6 changed files with 326 additions and 160 deletions
+27 -17
View File
@@ -29,11 +29,18 @@ from player.alteration import (
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, ring_move_to_dict, ring_to_dict
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"
@@ -87,12 +94,6 @@ def _media_version(rel: str) -> Optional[str]:
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
@@ -119,6 +120,7 @@ class AlterationRequest(BaseModel):
class RingAdvanceRequest(BaseModel):
from_index: int = 0
delta: int
from_clip_id: str = ""
class AuthorTrackRequest(BaseModel):
@@ -213,14 +215,14 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
@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."""
`?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)
files.add(_rev_file(t.file))
versions = {}
for f in sorted(files):
v = _media_version(f)
@@ -244,12 +246,20 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
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)
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):