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:
+27
-17
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user