Files
BenStullsBets 7855718f74 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>
2026-06-27 07:12:25 -07:00

170 lines
6.5 KiB
Python

import json
import pytest
from player.ring import RingMove, ScaleRing, advance_ring, resolve_move
from simulator.clips import (
Clip,
load_manifest,
load_ring,
resolved_move_to_dict,
ring_to_dict,
)
def _manifest_dict():
return {
"clips": [
{
"id": "forest",
"title": "Yosemite Falls (neutral)",
"base_file": "forest/base.mp4",
"license": "poc-sample",
"source": "hef-poc",
"right_variants": {
"4": {"file": "forest/right4.mp4", "model": "sd-turbo+flow"},
"1": {"file": "forest/right1.mp4"},
},
"annotations": [
{"key": "detected.water", "box": [0.1, 0.2, 0.3, 0.4], "min_level": 1},
{"key": "detected.conifer", "box": [0.6, 0.1, 0.2, 0.2], "min_level": 3},
],
"strings": {"en": {"detected.water": "flowing water", "detected.conifer": "conifer"}},
}
]
}
def test_load_manifest_parses_clips(tmp_path):
p = tmp_path / "manifest.json"
p.write_text(json.dumps(_manifest_dict()))
clips = load_manifest(p)
assert len(clips) == 1
c = clips[0]
assert isinstance(c, Clip)
assert c.id == "forest"
assert c.base_file == "forest/base.mp4"
def test_clip_lists_variant_files_by_strength(tmp_path):
p = tmp_path / "manifest.json"
p.write_text(json.dumps(_manifest_dict()))
c = load_manifest(p)[0]
# variant 0 is always the raw base; authored strengths come from the manifest
assert c.variant_file(0) == "forest/base.mp4"
assert c.variant_file(4) == "forest/right4.mp4"
assert c.variant_file(1) == "forest/right1.mp4"
# an unauthored strength falls back to the raw base
assert c.variant_file(2) == "forest/base.mp4"
def test_clip_serializes_to_dict_for_the_api(tmp_path):
p = tmp_path / "manifest.json"
p.write_text(json.dumps(_manifest_dict()))
d = load_manifest(p)[0].to_dict()
assert d["id"] == "forest"
assert d["base_file"] == "forest/base.mp4"
assert d["annotations"][0]["key"] == "detected.water"
assert d["strings"]["en"]["detected.water"] == "flowing water"
# variant map is exposed keyed by strength string, including 0 -> base
assert d["right_variants"]["0"]["file"] == "forest/base.mp4"
assert d["right_variants"]["4"]["file"] == "forest/right4.mp4"
def test_missing_manifest_raises(tmp_path):
with pytest.raises(FileNotFoundError):
load_manifest(tmp_path / "nope.json")
def _ring_manifest_dict():
return {
"clips": [],
"ring": {
"scales": [
{"id": "cosmos", "clip_id": "cosmos"},
{"id": "forest", "clip_id": "forest"},
{"id": "abyss", "clip_id": "abyss"},
],
"transitions": [
{"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"},
],
},
}
def test_load_ring_builds_a_scale_ring(tmp_path):
p = tmp_path / "manifest.json"
p.write_text(json.dumps(_ring_manifest_dict()))
ring = load_ring(p)
assert isinstance(ring, ScaleRing)
assert len(ring) == 3
assert ring.scales[0].id == "cosmos"
assert ring.scales[0].clip_id == "cosmos"
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"
def test_load_ring_is_none_when_absent(tmp_path):
p = tmp_path / "manifest.json"
p.write_text(json.dumps(_manifest_dict())) # no "ring" key
assert load_ring(p) is None
def test_ring_to_dict_carries_scale_titles_from_clips(tmp_path):
p = tmp_path / "manifest.json"
md = _ring_manifest_dict()
md["clips"] = [
{"id": "cosmos", "title": "NASA cosmos", "base_file": "cosmos/base.mp4"},
{"id": "forest", "title": "Yosemite", "base_file": "forest/base.mp4"},
{"id": "abyss", "title": "NOAA abyss", "base_file": "abyss/base.mp4"},
]
p.write_text(json.dumps(md))
ring = load_ring(p)
clips = load_manifest(p)
d = ring_to_dict(ring, clips)
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"][0] == {
"from": "cosmos", "to": "forest",
"file": "transitions/cosmos__forest.mp4", "model": "placeholder",
}
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
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]["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_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)
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"]) == 4 # whole chain kept, played fast
assert all(s["blended"] for s in d["steps"])