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):
|
||||
|
||||
+25
-20
@@ -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
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user