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
+26 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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