feat(ring): fast-spin blended pass past a speed threshold (scales design §3)

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>
This commit is contained in:
Ben Stull
2026-06-08 00:17:47 -07:00
parent e975ab1f5a
commit f11b9ee72d
10 changed files with 212 additions and 13 deletions
+7 -2
View File
@@ -23,7 +23,7 @@ from player.alteration import (
)
from player.content import resolve_content
from player.controls import CONTENT_POSITIONS
from player.ring import advance_ring
from player.ring import DEFAULT_FAST_SPIN_THRESHOLD, advance_ring
from simulator.clips import load_manifest, load_ring, ring_move_to_dict, ring_to_dict
STATIC_DIR = Path(__file__).parent / "static"
@@ -112,7 +112,12 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
def api_ring_advance(req: RingAdvanceRequest):
if app.state.ring is None:
raise HTTPException(status_code=404, detail="no scale ring in manifest")
move = advance_ring(app.state.ring, req.from_index, req.delta)
move = advance_ring(
app.state.ring,
req.from_index,
req.delta,
fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD,
)
return ring_move_to_dict(move, app.state.ring)
if MEDIA_DIR.exists():
+4 -1
View File
@@ -107,11 +107,13 @@ def ring_to_dict(ring: ScaleRing, clips: list[Clip]) -> dict:
def ring_move_to_dict(move: RingMove, ring: ScaleRing) -> dict:
"""JSON form of an encoder move: the landing scale's clip and the ordered
transition clips to play (with direction)."""
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."""
return {
"from_index": move.from_index,
"to_index": move.to_index,
"wrapped": move.wrapped,
"fast": move.fast,
"target_clip_id": scale_at(ring, move.to_index).clip_id,
"steps": [
{
@@ -119,6 +121,7 @@ def ring_move_to_dict(move: RingMove, ring: ScaleRing) -> dict:
"reversed": st.reversed,
"file": st.file,
"to_index": st.to_index,
"blended": st.blended,
}
for st in move.steps
],
+19 -6
View File
@@ -126,9 +126,13 @@ function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }
// --- Scale ring (endless encoder) ---
function playTransition(file) {
// Play one placeholder zoom/warp transition clip start-to-finish, with the
// alteration layers hidden. Resolves on 'ended' (or a safety timeout).
const FAST_BLEND_RATE = 2.5; // playback speed for a collapsed fast-spin pass
function playTransition(file, blended) {
// Play one zoom/warp transition clip start-to-finish, with the alteration
// layers hidden. A `blended` (fast-spin) pass runs at FAST_BLEND_RATE so a
// quick encoder spin lands fast instead of grinding through every morph.
// Resolves on 'ended' (or a safety timeout that scales with playback rate).
return new Promise((resolve) => {
overlay.style.opacity = "0";
tint.style.opacity = "0";
@@ -136,11 +140,18 @@ function playTransition(file) {
vid.loop = false;
vid.style.opacity = "1";
vid.src = mediaUrl(file);
vid.playbackRate = blended ? FAST_BLEND_RATE : 1;
let done = false;
const finish = () => { if (done) return; done = true; vid.removeEventListener("ended", finish); resolve(); };
const finish = () => {
if (done) return;
done = true;
vid.removeEventListener("ended", finish);
vid.playbackRate = 1;
resolve();
};
vid.addEventListener("ended", finish);
vid.play().catch(finish);
setTimeout(finish, 6000);
setTimeout(finish, blended ? 3000 : 6000);
});
}
@@ -154,8 +165,10 @@ async function advance(delta) {
});
if (!resp.ok) return;
const move = await resp.json();
// A fast spin comes back collapsed to a single blended step (move.fast);
// a slow spin chains one full transition per scale crossed.
for (const step of move.steps) {
await playTransition(step.file);
await playTransition(step.file, step.blended);
}
ringIndex = move.to_index;
currentVariant = -1; // force the target clip's variant to (re)load