f11b9ee72d
A multi-detent encoder spin previously chained one full transition per scale crossed (e.g. +5 ≈ 5 placeholder morphs ≈ 12-15s), which feels sluggish for a fast spin. Design §3 anticipates this: "transitions chain, or past a speed threshold a faster blended pass is used." `player.ring.advance_ring` gains an opt-in `fast_spin_threshold` (canonical default `DEFAULT_FAST_SPIN_THRESHOLD = 3`). A spin batches its detents into one advance() call, so `abs(delta)` is the input layer's proxy for spin speed; at or above the threshold the move collapses to a single blended pass — one `TransitionStep` (the arrival edge, marked `blended`, landing straight on the destination, `RingMove.fast=True`) instead of the full chain. Landing index, seam-crossing (`wrapped`), and arrival edge/direction are exactly the full chain's; only the in-between transitions are dropped. The policy is opt-in (default off) because the pure function cannot observe wall-clock spin speed — the simulator/firmware, which can, enables it. This keeps all existing complete-chain behavior and tests intact. The simulator passes the default threshold and plays the blended step at 2.5× (FAST_BLEND_RATE); a dedicated fast-blend clip can replace the accelerated arrival-edge placeholder when real transition media exists. - player/ring.py: TransitionStep.blended, RingMove.fast, threshold param + policy - simulator/clips.py: serialize fast/blended in ring_move_to_dict - simulator/app.py: apply DEFAULT_FAST_SPIN_THRESHOLD at /api/ring/advance - simulator/static/app.js: play a blended step at FAST_BLEND_RATE - docs: scales design §3, USER_GUIDE, ROADMAP slice-3 note Tests: +9 (ring policy, serializer, API). Suite 224 passed / 2 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
5.5 KiB
Python
159 lines
5.5 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from player.ring import RingMove, ScaleRing, TransitionStep, advance_ring
|
|
from simulator.clips import (
|
|
Clip,
|
|
load_manifest,
|
|
load_ring,
|
|
ring_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": [
|
|
{"file": "transitions/cosmos-forest.mp4", "model": "placeholder"},
|
|
{"file": "transitions/forest-abyss.mp4", "model": "placeholder"},
|
|
{"file": "transitions/abyss-cosmos.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.transitions[2].file == "transitions/abyss-cosmos.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"][2]["file"] == "transitions/abyss-cosmos.mp4"
|
|
|
|
|
|
def test_ring_move_to_dict_serializes_steps(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)
|
|
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]["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):
|
|
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)
|
|
assert d["fast"] is True
|
|
assert len(d["steps"]) == 1
|
|
assert d["steps"][0]["blended"] is True
|