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:
+88
-39
@@ -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)
|
||||
|
||||
+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
|
||||
],
|
||||
}
|
||||
|
||||
+26
-15
@@ -2,12 +2,12 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from player.ring import RingMove, ScaleRing, TransitionStep, advance_ring
|
||||
from player.ring import RingMove, ScaleRing, advance_ring, resolve_move
|
||||
from simulator.clips import (
|
||||
Clip,
|
||||
load_manifest,
|
||||
load_ring,
|
||||
ring_move_to_dict,
|
||||
resolved_move_to_dict,
|
||||
ring_to_dict,
|
||||
)
|
||||
|
||||
@@ -86,9 +86,12 @@ def _ring_manifest_dict():
|
||||
{"id": "abyss", "clip_id": "abyss"},
|
||||
],
|
||||
"transitions": [
|
||||
{"file": "transitions/cosmos-forest.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/forest-abyss.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/abyss-cosmos.mp4", "model": "placeholder"},
|
||||
{"from": "cosmos", "to": "forest", "file": "transitions/cosmos__forest.mp4", "model": "placeholder"},
|
||||
{"from": "forest", "to": "cosmos", "file": "transitions/cosmos__forest.rev.mp4", "model": "placeholder"},
|
||||
{"from": "forest", "to": "abyss", "file": "transitions/forest__abyss.mp4", "model": "placeholder"},
|
||||
{"from": "abyss", "to": "forest", "file": "transitions/forest__abyss.rev.mp4", "model": "placeholder"},
|
||||
{"from": "abyss", "to": "cosmos", "file": "transitions/abyss__cosmos.mp4", "model": "placeholder"},
|
||||
{"from": "cosmos", "to": "abyss", "file": "transitions/abyss__cosmos.rev.mp4", "model": "placeholder"},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -102,7 +105,8 @@ def test_load_ring_builds_a_scale_ring(tmp_path):
|
||||
assert len(ring) == 3
|
||||
assert ring.scales[0].id == "cosmos"
|
||||
assert ring.scales[0].clip_id == "cosmos"
|
||||
assert ring.transitions[2].file == "transitions/abyss-cosmos.mp4"
|
||||
assert ring.morph_for("abyss", "cosmos") == "transitions/abyss__cosmos.mp4" # zoom in (wrap)
|
||||
assert ring.morph_for("cosmos", "forest") == "transitions/cosmos__forest.mp4"
|
||||
assert ring.transitions[0].model == "placeholder"
|
||||
|
||||
|
||||
@@ -127,32 +131,39 @@ def test_ring_to_dict_carries_scale_titles_from_clips(tmp_path):
|
||||
assert [s["id"] for s in d["scales"]] == ["cosmos", "forest", "abyss"]
|
||||
assert d["scales"][0]["title"] == "NASA cosmos"
|
||||
assert d["scales"][0]["clip_id"] == "cosmos"
|
||||
assert d["transitions"][2]["file"] == "transitions/abyss-cosmos.mp4"
|
||||
assert d["transitions"][0] == {
|
||||
"from": "cosmos", "to": "forest",
|
||||
"file": "transitions/cosmos__forest.mp4", "model": "placeholder",
|
||||
}
|
||||
|
||||
|
||||
def test_ring_move_to_dict_serializes_steps(tmp_path):
|
||||
def test_resolved_move_to_dict_serializes_chained_morphs(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_ring_manifest_dict()))
|
||||
ring = load_ring(p)
|
||||
move = advance_ring(ring, from_index=2, delta=1) # abyss -> wrap -> cosmos
|
||||
d = ring_move_to_dict(move, ring)
|
||||
resolved = resolve_move(ring, move, from_clip_id="abyss", picks=(0.0,))
|
||||
d = resolved_move_to_dict(move, resolved)
|
||||
assert d["from_index"] == 2
|
||||
assert d["to_index"] == 0
|
||||
assert d["wrapped"] is True
|
||||
assert d["target_clip_id"] == "cosmos"
|
||||
assert d["steps"][0]["file"] == "transitions/abyss-cosmos.mp4"
|
||||
assert d["steps"][0]["reversed"] is False
|
||||
assert d["steps"][0]["from_clip"] == "abyss"
|
||||
assert d["steps"][0]["to_clip"] == "cosmos"
|
||||
assert d["steps"][0]["file"] == "transitions/abyss__cosmos.mp4" # zoom in (wrap)
|
||||
assert d["steps"][0]["to_index"] == 0
|
||||
assert d["fast"] is False
|
||||
assert d["steps"][0]["blended"] is False
|
||||
|
||||
|
||||
def test_ring_move_to_dict_marks_fast_blended_pass(tmp_path):
|
||||
def test_resolved_move_to_dict_marks_fast_blended_chain(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_ring_manifest_dict()))
|
||||
ring = load_ring(p)
|
||||
move = advance_ring(ring, from_index=0, delta=4, fast_spin_threshold=3)
|
||||
d = ring_move_to_dict(move, ring)
|
||||
resolved = resolve_move(ring, move, from_clip_id="cosmos",
|
||||
picks=tuple(0.0 for _ in move.steps))
|
||||
d = resolved_move_to_dict(move, resolved)
|
||||
assert d["fast"] is True
|
||||
assert len(d["steps"]) == 1
|
||||
assert d["steps"][0]["blended"] is True
|
||||
assert len(d["steps"]) == 4 # whole chain kept, played fast
|
||||
assert all(s["blended"] for s in d["steps"])
|
||||
|
||||
+125
-55
@@ -9,12 +9,20 @@ from player.ring import (
|
||||
TransitionStep,
|
||||
advance_ring,
|
||||
pick_clip_id,
|
||||
resolve_move,
|
||||
scale_at,
|
||||
)
|
||||
|
||||
|
||||
def _pair(a, b):
|
||||
"""Both directed morphs for an adjacent clip pair (zoom-in + zoom-out)."""
|
||||
return (Transition(a, b, f"t/{a}__{b}.mp4"),
|
||||
Transition(b, a, f"t/{a}__{b}.rev.mp4"))
|
||||
|
||||
|
||||
def _ring():
|
||||
# cosmos (largest, idx 0) -> forest -> abyss (smallest) -> wraps to cosmos
|
||||
# cosmos (largest, idx 0) -> forest -> abyss (smallest) -> wraps to cosmos.
|
||||
# Pool-of-one scales, so each adjacent pair is a single clip pair.
|
||||
return ScaleRing(
|
||||
scales=(
|
||||
Scale(id="cosmos", clip_id="cosmos"),
|
||||
@@ -22,19 +30,20 @@ def _ring():
|
||||
Scale(id="abyss", clip_id="abyss"),
|
||||
),
|
||||
transitions=(
|
||||
Transition(file="t/cosmos-forest.mp4"), # edge 0: cosmos->forest
|
||||
Transition(file="t/forest-abyss.mp4"), # edge 1: forest->abyss
|
||||
Transition(file="t/abyss-cosmos.mp4"), # edge 2: abyss->cosmos (wrap)
|
||||
*_pair("cosmos", "forest"),
|
||||
*_pair("forest", "abyss"),
|
||||
*_pair("abyss", "cosmos"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_ring_needs_one_transition_per_scale():
|
||||
with pytest.raises(RingError):
|
||||
ScaleRing(
|
||||
scales=(Scale("a", "a"), Scale("b", "b")),
|
||||
transitions=(Transition("t/ab.mp4"),), # 1 edge for 2 scales
|
||||
)
|
||||
def test_morph_for_returns_directed_file():
|
||||
r = _ring()
|
||||
assert r.morph_for("cosmos", "forest") == "t/cosmos__forest.mp4" # zoom in
|
||||
assert r.morph_for("forest", "cosmos") == "t/cosmos__forest.rev.mp4" # zoom out
|
||||
# cosmos<->abyss is adjacent via the wrap edge, so it resolves too:
|
||||
assert r.morph_for("cosmos", "abyss") == "t/abyss__cosmos.rev.mp4"
|
||||
assert r.morph_for("cosmos", "nope") is None # no such pair baked
|
||||
|
||||
|
||||
def test_ring_rejects_empty():
|
||||
@@ -42,6 +51,13 @@ def test_ring_rejects_empty():
|
||||
ScaleRing(scales=(), transitions=())
|
||||
|
||||
|
||||
def test_ring_allows_any_transition_count():
|
||||
# The old "N scales need N transitions" rule is gone — the per-clip-pair model
|
||||
# has a variable count, and a ring with no morphs is valid (plain cuts).
|
||||
r = ScaleRing(scales=(Scale("a", "a"), Scale("b", "b")), transitions=())
|
||||
assert r.morph_for("a", "b") is None
|
||||
|
||||
|
||||
def test_scale_at_wraps_modulo():
|
||||
r = _ring()
|
||||
assert scale_at(r, 0).id == "cosmos"
|
||||
@@ -50,27 +66,23 @@ def test_scale_at_wraps_modulo():
|
||||
assert scale_at(r, 4).id == "forest"
|
||||
|
||||
|
||||
def test_advance_one_step_inward_plays_edge_forward():
|
||||
def test_advance_one_step_inward_crosses_edge_forward():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=1)
|
||||
assert isinstance(move, RingMove)
|
||||
assert move.from_index == 0
|
||||
assert move.to_index == 1
|
||||
assert move.wrapped is False
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=0, reversed=False, file="t/cosmos-forest.mp4", to_index=1),
|
||||
)
|
||||
assert move.steps == (TransitionStep(edge=0, reversed=False, to_index=1),)
|
||||
|
||||
|
||||
def test_advance_one_step_outward_plays_edge_reversed():
|
||||
def test_advance_one_step_outward_crosses_edge_reversed():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=1, delta=-1)
|
||||
assert move.to_index == 0
|
||||
assert move.wrapped is False
|
||||
# going forest(1) -> cosmos(0) is edge 0 played in reverse
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=0, reversed=True, file="t/cosmos-forest.mp4", to_index=0),
|
||||
)
|
||||
# going forest(1) -> cosmos(0) is edge 0 crossed in reverse
|
||||
assert move.steps == (TransitionStep(edge=0, reversed=True, to_index=0),)
|
||||
|
||||
|
||||
def test_advance_inward_past_smallest_wraps_to_largest():
|
||||
@@ -79,9 +91,7 @@ def test_advance_inward_past_smallest_wraps_to_largest():
|
||||
move = advance_ring(r, from_index=2, delta=1)
|
||||
assert move.to_index == 0
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=2, reversed=False, file="t/abyss-cosmos.mp4", to_index=0),
|
||||
)
|
||||
assert move.steps == (TransitionStep(edge=2, reversed=False, to_index=0),)
|
||||
|
||||
|
||||
def test_advance_outward_past_largest_wraps_to_smallest():
|
||||
@@ -90,19 +100,17 @@ def test_advance_outward_past_largest_wraps_to_smallest():
|
||||
move = advance_ring(r, from_index=0, delta=-1)
|
||||
assert move.to_index == 2
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=2, reversed=True, file="t/abyss-cosmos.mp4", to_index=2),
|
||||
)
|
||||
assert move.steps == (TransitionStep(edge=2, reversed=True, to_index=2),)
|
||||
|
||||
|
||||
def test_advance_multi_detent_chains_transitions():
|
||||
def test_advance_multi_detent_chains_steps():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=2)
|
||||
assert move.to_index == 2
|
||||
assert move.wrapped is False
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=0, reversed=False, file="t/cosmos-forest.mp4", to_index=1),
|
||||
TransitionStep(edge=1, reversed=False, file="t/forest-abyss.mp4", to_index=2),
|
||||
TransitionStep(edge=0, reversed=False, to_index=1),
|
||||
TransitionStep(edge=1, reversed=False, to_index=2),
|
||||
)
|
||||
|
||||
|
||||
@@ -125,7 +133,6 @@ def test_advance_zero_is_a_noop():
|
||||
|
||||
def test_advance_normalizes_from_index():
|
||||
r = _ring()
|
||||
# from_index out of range is taken modulo N first
|
||||
move = advance_ring(r, from_index=3, delta=1)
|
||||
assert move.from_index == 0
|
||||
assert move.to_index == 1
|
||||
@@ -142,10 +149,10 @@ def test_degenerate_single_scale_ring_is_a_noop():
|
||||
# --- fast-spin: a faster blended pass past a speed threshold (scales design §3) ---
|
||||
#
|
||||
# A fast encoder spin batches many detents into one advance() call, so |delta| is
|
||||
# the input adapter's proxy for spin speed. Past a configured threshold the move
|
||||
# collapses to a SINGLE blended pass (the arrival-edge clip, played fast) instead
|
||||
# of chaining N full transitions. The policy is opt-in (default off) because the
|
||||
# pure function cannot know wall-clock spin speed — the input layer enables it.
|
||||
# the input adapter's proxy for spin speed. Past a configured threshold every step
|
||||
# is marked `blended` so the renderer plays the WHOLE chain fast (each crossed
|
||||
# altitude still morphs). The policy is opt-in (default off) because the pure
|
||||
# function cannot know wall-clock spin speed — the input layer enables it.
|
||||
|
||||
|
||||
def test_fast_spin_disabled_by_default_chains_every_detent():
|
||||
@@ -156,46 +163,40 @@ def test_fast_spin_disabled_by_default_chains_every_detent():
|
||||
assert all(not s.blended for s in move.steps)
|
||||
|
||||
|
||||
def test_fast_spin_below_threshold_still_chains():
|
||||
def test_fast_spin_below_threshold_still_chains_unblended():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=2, fast_spin_threshold=3)
|
||||
assert move.fast is False
|
||||
assert len(move.steps) == 2
|
||||
assert move.steps[0].edge == 0 and move.steps[1].edge == 1
|
||||
assert all(not s.blended for s in move.steps)
|
||||
|
||||
|
||||
def test_fast_spin_at_or_above_threshold_collapses_to_one_blended_step():
|
||||
def test_fast_spin_at_or_above_threshold_keeps_all_steps_blended():
|
||||
r = _ring()
|
||||
# 4 inward detents from cosmos(0): 0->1->2->0->1, lands on forest(1),
|
||||
# crosses the abyss->cosmos seam, last edge crossed is edge 0 (forward).
|
||||
# crosses the abyss->cosmos seam. Every step is kept, marked blended.
|
||||
move = advance_ring(r, from_index=0, delta=4, fast_spin_threshold=3)
|
||||
assert move.fast is True
|
||||
assert move.to_index == 1
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(
|
||||
edge=0, reversed=False, file="t/cosmos-forest.mp4", to_index=1, blended=True
|
||||
),
|
||||
)
|
||||
assert len(move.steps) == 4
|
||||
assert all(s.blended for s in move.steps)
|
||||
# structural path preserved (not collapsed)
|
||||
assert [s.edge for s in move.steps] == [0, 1, 2, 0]
|
||||
|
||||
|
||||
def test_fast_spin_outward_blended_step_is_reversed():
|
||||
def test_fast_spin_outward_keeps_all_steps_blended_and_reversed():
|
||||
r = _ring()
|
||||
# 4 outward detents from cosmos(0): 0->2->1->0->2, lands on abyss(2);
|
||||
# outward arrival crosses edge 2 (abyss->cosmos) reversed.
|
||||
move = advance_ring(r, from_index=0, delta=-4, fast_spin_threshold=3)
|
||||
assert move.fast is True
|
||||
assert move.to_index == 2
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(
|
||||
edge=2, reversed=True, file="t/abyss-cosmos.mp4", to_index=2, blended=True
|
||||
),
|
||||
)
|
||||
assert len(move.steps) == 4
|
||||
assert all(s.blended and s.reversed for s in move.steps)
|
||||
|
||||
|
||||
def test_fast_spin_threshold_below_two_is_ignored():
|
||||
# A threshold of 0 or 1 would blend even single deliberate steps — treat as off.
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=3, fast_spin_threshold=1)
|
||||
assert move.fast is False
|
||||
@@ -215,7 +216,6 @@ def test_fast_spin_noop_on_degenerate_ring():
|
||||
def test_scale_without_pool_is_a_pool_of_one():
|
||||
s = Scale(id="cosmos", clip_id="cosmos")
|
||||
assert s.members == ("cosmos",)
|
||||
# any r maps to the single member
|
||||
assert pick_clip_id(s, 0.0) == "cosmos"
|
||||
assert pick_clip_id(s, 0.99) == "cosmos"
|
||||
|
||||
@@ -227,7 +227,6 @@ def test_scale_with_pool_exposes_all_members():
|
||||
|
||||
def test_pick_clip_id_selects_member_by_injected_r():
|
||||
s = Scale(id="coast", clip_id="a", pool=("a", "b", "c", "d"))
|
||||
# r in [0,1) partitions the pool into len equal buckets
|
||||
assert pick_clip_id(s, 0.0) == "a"
|
||||
assert pick_clip_id(s, 0.24) == "a"
|
||||
assert pick_clip_id(s, 0.25) == "b"
|
||||
@@ -237,13 +236,84 @@ def test_pick_clip_id_selects_member_by_injected_r():
|
||||
|
||||
def test_pick_clip_id_clamps_out_of_range_r():
|
||||
s = Scale(id="coast", clip_id="a", pool=("a", "b", "c"))
|
||||
assert pick_clip_id(s, -5.0) == "a" # below 0 clamps to first
|
||||
assert pick_clip_id(s, 1.0) == "c" # 1.0 must not index past the end
|
||||
assert pick_clip_id(s, -5.0) == "a"
|
||||
assert pick_clip_id(s, 1.0) == "c"
|
||||
assert pick_clip_id(s, 999.0) == "c"
|
||||
|
||||
|
||||
def test_scale_carries_an_optional_audio_path():
|
||||
s = Scale(id="coast", clip_id="coast_birdrock", audio="coast/waves.loop.mp3")
|
||||
assert s.audio == "coast/waves.loop.mp3"
|
||||
# back-compat: audio defaults to empty
|
||||
assert Scale(id="x", clip_id="x").audio == ""
|
||||
|
||||
|
||||
# --- resolve_move: pick the destination clip THEN the matching morph, chained ---
|
||||
|
||||
|
||||
def _chain_ring():
|
||||
# 3 scales, pools of 2/1/2; descending edges cosmos->coast->abyss + wrap.
|
||||
scales = (Scale("cosmos", "c1", ("c1", "c2")),
|
||||
Scale("coast", "o1", ("o1",)),
|
||||
Scale("abyss", "a1", ("a1", "a2")))
|
||||
t = []
|
||||
for h in ("c1", "c2"):
|
||||
t += list(_pair(h, "o1")) # cosmos<->coast
|
||||
for l in ("a1", "a2"):
|
||||
t += list(_pair("o1", l)) # coast<->abyss
|
||||
for h in ("a1", "a2"):
|
||||
t += list(_pair(h, "c1")) # abyss<->cosmos wrap (to c1)
|
||||
t += list(_pair(h, "c2")) # abyss<->cosmos wrap (to c2)
|
||||
return ScaleRing(scales=scales, transitions=tuple(t))
|
||||
|
||||
|
||||
def test_resolve_single_step_zoom_in():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=1) # cosmos -> coast
|
||||
res = resolve_move(r, move, from_clip_id="c2", picks=(0.0,))
|
||||
assert len(res.steps) == 1
|
||||
s = res.steps[0]
|
||||
assert (s.from_clip, s.to_clip) == ("c2", "o1")
|
||||
assert s.file == "t/c2__o1.mp4"
|
||||
assert res.target_clip_id == "o1"
|
||||
|
||||
|
||||
def test_resolve_chains_through_intermediate_altitude():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=2) # cosmos -> coast -> abyss
|
||||
# coast member (only o1) then abyss member: 0.6*2 -> index 1 -> a2
|
||||
res = resolve_move(r, move, from_clip_id="c1", picks=(0.0, 0.6))
|
||||
files = [s.file for s in res.steps]
|
||||
assert files == ["t/c1__o1.mp4", "t/o1__a2.mp4"]
|
||||
assert res.target_clip_id == "a2"
|
||||
|
||||
|
||||
def test_resolve_zoom_out_uses_reverse_file():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=2, delta=-1) # abyss -> coast (ascend)
|
||||
res = resolve_move(r, move, from_clip_id="a1", picks=(0.0,))
|
||||
assert res.steps[0].file == "t/o1__a1.rev.mp4"
|
||||
assert res.target_clip_id == "o1"
|
||||
|
||||
|
||||
def test_resolve_empty_move_keeps_source():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=0)
|
||||
res = resolve_move(r, move, from_clip_id="c2", picks=())
|
||||
assert res.steps == () and res.target_clip_id == "c2"
|
||||
|
||||
|
||||
def test_resolve_requires_one_pick_per_step():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=2)
|
||||
with pytest.raises(ValueError):
|
||||
resolve_move(r, move, from_clip_id="c1", picks=(0.0,))
|
||||
|
||||
|
||||
def test_resolve_missing_morph_yields_none_file():
|
||||
# A pair with no baked morph resolves to file=None (renderer plain-cuts).
|
||||
r = ScaleRing(scales=(Scale("a", "a1", ("a1",)), Scale("b", "b1", ("b1",))),
|
||||
transitions=())
|
||||
move = advance_ring(r, from_index=0, delta=1)
|
||||
res = resolve_move(r, move, from_clip_id="a1", picks=(0.0,))
|
||||
assert res.steps[0].file is None
|
||||
assert res.target_clip_id == "b1"
|
||||
|
||||
+35
-14
@@ -200,9 +200,12 @@ def ring_manifest_path(tmp_path):
|
||||
{"id": "abyss", "clip_id": "abyss", "audio": "abyss/whale.loop.mp3"},
|
||||
],
|
||||
"transitions": [
|
||||
{"file": "transitions/cosmos-forest.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/forest-abyss.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/abyss-cosmos.mp4", "model": "placeholder"},
|
||||
{"from": "cosmos", "to": "forest", "file": "transitions/cosmos__forest.mp4", "model": "placeholder"},
|
||||
{"from": "forest", "to": "cosmos", "file": "transitions/cosmos__forest.rev.mp4", "model": "placeholder"},
|
||||
{"from": "forest", "to": "abyss", "file": "transitions/forest__abyss.mp4", "model": "placeholder"},
|
||||
{"from": "abyss", "to": "forest", "file": "transitions/forest__abyss.rev.mp4", "model": "placeholder"},
|
||||
{"from": "abyss", "to": "cosmos", "file": "transitions/abyss__cosmos.mp4", "model": "placeholder"},
|
||||
{"from": "cosmos", "to": "abyss", "file": "transitions/abyss__cosmos.rev.mp4", "model": "placeholder"},
|
||||
],
|
||||
},
|
||||
}))
|
||||
@@ -220,7 +223,7 @@ def test_ring_returns_scales_and_transitions(ring_client):
|
||||
data = resp.json()
|
||||
assert [s["id"] for s in data["scales"]] == ["cosmos", "forest", "abyss"]
|
||||
assert data["scales"][0]["title"] == "NASA cosmos"
|
||||
assert len(data["transitions"]) == 3
|
||||
assert len(data["transitions"]) == 6 # 3 edges x 2 directions (clip-pair morphs)
|
||||
|
||||
|
||||
def test_ring_exposes_each_scales_audio(ring_client):
|
||||
@@ -233,14 +236,27 @@ def test_ring_exposes_each_scales_audio(ring_client):
|
||||
|
||||
|
||||
def test_ring_advance_inward_one_step(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 1})
|
||||
resp = ring_client.post("/api/ring/advance",
|
||||
json={"from_index": 0, "delta": 1, "from_clip_id": "cosmos"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["to_index"] == 1
|
||||
assert data["target_clip_id"] == "forest"
|
||||
assert data["wrapped"] is False
|
||||
assert data["steps"][0]["file"] == "transitions/cosmos-forest.mp4"
|
||||
assert data["steps"][0]["reversed"] is False
|
||||
step = data["steps"][0]
|
||||
assert step["from_clip"] == "cosmos" # threaded current clip
|
||||
assert step["to_clip"] == "forest" # picked destination
|
||||
assert step["file"] == "transitions/cosmos__forest.mp4" # morph matches landing
|
||||
|
||||
|
||||
def test_ring_advance_picks_then_returns_matching_morph(ring_client):
|
||||
# The morph's `to_clip` always equals the locked target, and its file is the
|
||||
# directed morph for (from_clip -> to_clip).
|
||||
data = ring_client.post("/api/ring/advance",
|
||||
json={"from_index": 0, "delta": 1, "from_clip_id": "cosmos"}).json()
|
||||
step = data["steps"][-1]
|
||||
assert step["to_clip"] == data["target_clip_id"]
|
||||
assert step["file"] == f"transitions/cosmos__{data['target_clip_id']}.mp4"
|
||||
|
||||
|
||||
def test_ring_advance_wraps_smallest_to_largest(ring_client):
|
||||
@@ -251,11 +267,15 @@ def test_ring_advance_wraps_smallest_to_largest(ring_client):
|
||||
assert data["wrapped"] is True
|
||||
|
||||
|
||||
def test_ring_advance_outward_reverses_edge(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 1, "delta": -1})
|
||||
def test_ring_advance_outward_plays_reverse_morph(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance",
|
||||
json={"from_index": 1, "delta": -1, "from_clip_id": "forest"})
|
||||
data = resp.json()
|
||||
assert data["to_index"] == 0
|
||||
assert data["steps"][0]["reversed"] is True
|
||||
# forest(1) -> cosmos(0) is a zoom-out: the directed (forest, cosmos) morph
|
||||
# resolves to the .rev companion.
|
||||
assert data["steps"][0]["file"] == "transitions/cosmos__forest.rev.mp4"
|
||||
assert data["target_clip_id"] == "cosmos"
|
||||
|
||||
|
||||
def test_ring_advance_small_delta_chains_not_fast(ring_client):
|
||||
@@ -266,13 +286,14 @@ def test_ring_advance_small_delta_chains_not_fast(ring_client):
|
||||
assert len(data["steps"]) == 2
|
||||
|
||||
|
||||
def test_ring_advance_fast_spin_collapses_to_one_blended_pass(ring_client):
|
||||
# A fast spin (4 detents batched) crosses the threshold -> one blended pass.
|
||||
def test_ring_advance_fast_spin_plays_whole_chain_blended(ring_client):
|
||||
# A fast spin (4 detents batched) crosses the threshold -> the whole chain is
|
||||
# kept and played fast (each step blended), not collapsed to one pass.
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 4})
|
||||
data = resp.json()
|
||||
assert data["fast"] is True
|
||||
assert len(data["steps"]) == 1
|
||||
assert data["steps"][0]["blended"] is True
|
||||
assert len(data["steps"]) == 4
|
||||
assert all(s["blended"] for s in data["steps"])
|
||||
assert data["to_index"] == 1
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user