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):
+25 -20
View File
@@ -14,7 +14,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from player.ring import RingMove, Scale, ScaleRing, Transition, scale_at
from player.ring import ResolvedMove, RingMove, Scale, ScaleRing, Transition
@dataclass(frozen=True)
@@ -99,7 +99,12 @@ def load_ring(path: str | Path) -> ScaleRing | None:
return None
scales = tuple(_scale_from_dict(s) for s in ring.get("scales", []))
transitions = tuple(
Transition(file=t["file"], model=t.get("model", ""))
Transition(
from_clip=t.get("from", ""),
to_clip=t.get("to", ""),
file=t["file"],
model=t.get("model", ""),
)
for t in ring.get("transitions", [])
)
return ScaleRing(scales=scales, transitions=transitions)
@@ -127,35 +132,35 @@ def ring_to_dict(ring: ScaleRing, clips: list[Clip]) -> dict:
}
for s in ring.scales
],
"transitions": [{"file": t.file, "model": t.model} for t in ring.transitions],
"transitions": [
{"from": t.from_clip, "to": t.to_clip, "file": t.file, "model": t.model}
for t in ring.transitions
],
}
def ring_move_to_dict(
move: RingMove, ring: ScaleRing, chosen_clip_id: str | None = None
) -> dict:
"""JSON form of an encoder move: the landing scale's clip and the ordered
transition clips to play (with direction). `fast` flags a collapsed fast-spin
pass; the single step then carries `blended` so the renderer plays it quick.
def resolved_move_to_dict(move: RingMove, resolved: ResolvedMove) -> dict:
"""JSON form of a RESOLVED encoder move: where it lands, the locked clip, and
the ordered chained morphs. Each step carries the clip it morphs FROM (the
clip currently shown), the chosen clip it morphs TO, and the matching morph
file (None if no morph was baked — the renderer plain-cuts). `fast` marks a
fast spin; each step then carries `blended` so the renderer accelerates it.
`target_clip_id` is the pool member the player should load on landing. The
caller passes the random pick (`pick_clip_id(landed_scale, random.random())`,
content-pipeline §11.1); when omitted it falls back to the scale's primary
`clip_id` (deterministic — keeps pre-pool callers/tests working)."""
`target_clip_id` is the final clip the viewer locks onto."""
return {
"from_index": move.from_index,
"to_index": move.to_index,
"wrapped": move.wrapped,
"fast": move.fast,
"target_clip_id": chosen_clip_id or scale_at(ring, move.to_index).clip_id,
"target_clip_id": resolved.target_clip_id,
"steps": [
{
"edge": st.edge,
"reversed": st.reversed,
"file": st.file,
"to_index": st.to_index,
"blended": st.blended,
"from_clip": s.from_clip,
"to_clip": s.to_clip,
"file": s.file,
"to_index": s.to_index,
"blended": s.blended,
}
for st in move.steps
for s in resolved.steps
],
}