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
+88 -39
View File
@@ -74,57 +74,61 @@ class Scale:
@dataclass(frozen=True)
class Transition:
"""A pre-baked zoom/warp morph along ONE ring edge.
"""A pre-baked zoom/warp morph between TWO specific clips in adjacent
altitudes.
`file` plays FORWARD when zooming inward (scales[i] -> scales[i+1] in ring
order); the renderer reverses it for the outward direction, so one clip
covers both directions of an edge.
`file` plays as-is: the manifest holds both directions as separate, directed
entries — a forward zoom-in (`<src>__<dst>.mp4`, descending) and its `.rev`
zoom-out companion (ascending). So a directed `(from_clip, to_clip)` pair maps
to exactly one file and the renderer needs no reverse logic at play time.
"""
from_clip: str
to_clip: str
file: str
model: str = ""
@dataclass(frozen=True)
class ScaleRing:
"""A closed ring of scales joined by per-edge transitions.
"""A closed ring of scales joined by per-clip-pair morphs.
Invariant: a ring of N>=2 scales has exactly N transitions (one per edge,
including the small->large wrap seam). A degenerate single-scale ring has no
edges. An empty ring is rejected.
Each `Transition` is a directed morph between two clips in adjacent altitudes
(both directions stored explicitly). A single-scale (or empty-pool) ring has
no morphs. An empty ring is rejected. The directed `(from_clip, to_clip) ->
file` lookup is built once for `morph_for`.
"""
scales: tuple[Scale, ...]
transitions: tuple[Transition, ...]
def __post_init__(self) -> None:
n = len(self.scales)
if n == 0:
if len(self.scales) == 0:
raise RingError("a ScaleRing needs at least one scale")
expected = 0 if n == 1 else n
if len(self.transitions) != expected:
raise RingError(
f"a {n}-scale ring needs {expected} transitions, "
f"got {len(self.transitions)}"
)
lookup = {(t.from_clip, t.to_clip): t.file for t in self.transitions}
object.__setattr__(self, "_morphs", lookup)
def __len__(self) -> int:
return len(self.scales)
def morph_for(self, from_clip: str, to_clip: str) -> str | None:
"""The morph file for the directed clip pair, or None if none was baked."""
return self._morphs.get((from_clip, to_clip))
@dataclass(frozen=True)
class TransitionStep:
"""One transition to play during a move: which edge clip, in which direction,
and the scale index it lands on.
"""One STRUCTURAL step of a move: which edge is crossed, in which direction,
and the scale index it lands on. The actual morph FILE is resolved later by
`resolve_move` (it depends on the clip picked for the destination), so this
step carries no file.
`blended` marks the single collapsed step of a fast-spin pass (see
`advance_ring`'s `fast_spin_threshold`): the renderer plays it as one quick
accelerated morph straight to the destination instead of a full transition.
`blended` marks a fast-spin step (see `advance_ring`'s `fast_spin_threshold`):
the renderer plays it as one quick accelerated morph rather than at 1x.
"""
edge: int
reversed: bool
file: str
to_index: int
blended: bool = False
@@ -207,31 +211,24 @@ def advance_ring(
if edge == n - 1:
wrapped = True
steps.append(
TransitionStep(
edge=edge,
reversed=reversed_,
file=ring.transitions[edge].file,
to_index=nxt,
)
TransitionStep(edge=edge, reversed=reversed_, to_index=nxt)
)
index = nxt
# Fast spin: collapse the whole chain to a single blended arrival pass. The
# landing index, seam-crossing, and arrival edge/direction are exactly those
# of the full chain — only the in-between transitions are dropped.
# Fast spin: play the WHOLE chain quickly (every crossed altitude still
# morphs, so the chained member->member morphs all show) — each step is just
# marked `blended` so the renderer accelerates it, rather than collapsing the
# chain to a single arrival pass.
if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold:
arrival = steps[-1]
blended = TransitionStep(
edge=arrival.edge,
reversed=arrival.reversed,
file=arrival.file,
to_index=arrival.to_index,
blended=True,
fast_steps = tuple(
TransitionStep(edge=s.edge, reversed=s.reversed,
to_index=s.to_index, blended=True)
for s in steps
)
return RingMove(
from_index=start,
to_index=index,
steps=(blended,),
steps=fast_steps,
wrapped=wrapped,
fast=True,
)
@@ -239,3 +236,55 @@ def advance_ring(
return RingMove(
from_index=start, to_index=index, steps=tuple(steps), wrapped=wrapped
)
@dataclass(frozen=True)
class MorphStep:
"""One resolved morph to play while advancing: from the clip currently shown
to the chosen clip of the next altitude, the morph file, the index it lands
on, and whether it plays fast (a fast-spin pass). `file` is None if no morph
was baked for the pair (the renderer falls back to a plain cut)."""
from_clip: str
to_clip: str
file: str | None
to_index: int
blended: bool
@dataclass(frozen=True)
class ResolvedMove:
"""A move with each step's destination clip chosen and its morph resolved.
`target_clip_id` is the final clip the viewer locks onto."""
steps: tuple[MorphStep, ...]
target_clip_id: str
def resolve_move(
ring: ScaleRing,
move: RingMove,
from_clip_id: str,
picks: tuple[float, ...],
) -> ResolvedMove:
"""Choose the destination clip for each crossed altitude (injected `picks`,
one per step) and resolve the morph from the previous clip to it. The
destination clip is chosen BEFORE the morph, so the footage matches what we
land on; the morph direction (zoom-in vs zoom-out) is encoded in the directed
`(src, dst)` pair, which `morph_for` maps to the right baked file. The final
`target_clip_id` is the clip the viewer locks onto."""
if len(picks) != len(move.steps):
raise ValueError(f"need one pick per step: {len(picks)} != {len(move.steps)}")
steps: list[MorphStep] = []
src = from_clip_id
for st, r in zip(move.steps, picks):
dst = pick_clip_id(scale_at(ring, st.to_index), r)
steps.append(MorphStep(
from_clip=src,
to_clip=dst,
file=ring.morph_for(src, dst),
to_index=st.to_index,
blended=st.blended,
))
src = dst
return ResolvedMove(steps=tuple(steps), target_clip_id=src)