Files
human-experience-filter-art/simulator/clips.py
T
Ben Stull 7a50ae41bb feat(sim+player): scale-ring navigation (endless encoder) + cosmos/abyss scales
Add scale-ring navigation to the simulator per the scales-library design §3:
an endless rotary-encoder control (relative, vs the absolute 0-4 experience
knobs) walks a closed ring of neutral "scales of nature" clips, with placeholder
AI zoom/warp transitions between adjacent scales and a small->large wrap (the
infinite-zoom payoff).

- player/ring.py (new, canonical pure logic): ScaleRing + advance_ring; one
  transition clip per edge, played forward zooming inward / reversed outward;
  modulo wrap both ways; chained steps for a multi-detent spin. Mirrors
  player/content.py conventions (frozen dataclasses, pure functions).
- simulator: load_ring + ring_to_dict/ring_move_to_dict; GET /api/ring and
  POST /api/ring/advance (Python owns step/wrap/transition math; the browser
  only plays the returned transition(s) then settles on the target scale).
- frontend: endless-encoder control (zoom in/out buttons + stage scroll),
  current-scale readout, multi-clip active-scale selection, transition playback.
- media: two cheap true-PD scales so the ring is demonstrable -- cosmos
  (NASA/Hubble) + abyss (NOAA Ocean Exploration), provenance recorded in the
  manifest, bytes generated as labelled placeholders by setup_scales_media.py.
  The expensive multi-strength flow-stabilized Right re-bake is DEFERRED (new
  scales carry a raw base only) until the ring is liked; Pi renderer +
  serial/firmware remain deferred (simulator-first).
- docs: ROADMAP slice-3 done; USER_GUIDE scale-ring gesture; plan doc.

Verified: pytest 215 passed / 2 skipped (+22 new); sim boots, /api/ring +
/api/ring/advance correct incl. wrap, media served, and a headless-Chrome CDP
drive walked cosmos -> forest -> abyss -> (wrap) -> cosmos with the active clip
reloading correctly.

Spec: docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md (§3)
Plan: docs/superpowers/plans/2026-06-07-scale-ring-navigation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:40:21 -07:00

126 lines
4.1 KiB
Python

"""The base-clip + variant + annotation manifest the simulator renders.
Replaces simulator/fixtures.py (the selection-era synthetic catalog). Each base
clip carries: the raw base file, a map of pre-baked Right-strength variant files
(strength 0 is always the raw base), an authored Left annotation track (box +
label key + the minimum Left level at which it appears), and per-language string
tables. See the reconciled-simulator-alteration-slice design §3.2.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from player.ring import RingMove, Scale, ScaleRing, Transition, scale_at
@dataclass(frozen=True)
class Clip:
id: str
title: str
base_file: str
license: str
source: str
right_variants: dict # {"1": {"file": ...}, "4": {...}} (no "0")
annotations: list # [{"key", "box":[x,y,w,h], "min_level"}, ...]
strings: dict # {"en": {key: text}}
def variant_file(self, strength: int) -> str:
"""The video file for a Right strength; 0 and any unauthored strength
fall back to the raw base file."""
entry = self.right_variants.get(str(strength))
return entry["file"] if entry else self.base_file
def to_dict(self) -> dict:
variants = {"0": {"file": self.base_file, "raw": True}}
for k, v in self.right_variants.items():
variants[k] = v
return {
"id": self.id,
"title": self.title,
"base_file": self.base_file,
"license": self.license,
"source": self.source,
"right_variants": variants,
"annotations": self.annotations,
"strings": self.strings,
}
def _clip_from_dict(d: dict[str, Any]) -> Clip:
return Clip(
id=d["id"],
title=d["title"],
base_file=d["base_file"],
license=d.get("license", ""),
source=d.get("source", ""),
right_variants=d.get("right_variants", {}),
annotations=d.get("annotations", []),
strings=d.get("strings", {}),
)
def load_manifest(path: str | Path) -> list[Clip]:
"""Load the base-clip manifest. Raises FileNotFoundError if missing."""
path = Path(path)
data = json.loads(path.read_text())
return [_clip_from_dict(c) for c in data["clips"]]
def load_ring(path: str | Path) -> ScaleRing | None:
"""Load the optional `ring` section as a `ScaleRing`, or None if absent.
The ring closes the scales-of-nature library into the navigable loop the
endless encoder walks (scales design §3); the navigation math itself lives
in player.ring.
"""
path = Path(path)
data = json.loads(path.read_text())
ring = data.get("ring")
if not ring:
return None
scales = tuple(
Scale(id=s["id"], clip_id=s["clip_id"]) for s in ring.get("scales", [])
)
transitions = tuple(
Transition(file=t["file"], model=t.get("model", ""))
for t in ring.get("transitions", [])
)
return ScaleRing(scales=scales, transitions=transitions)
def ring_to_dict(ring: ScaleRing, clips: list[Clip]) -> dict:
"""JSON form of the ring for the API: ordered scales (with their clip title)
and the per-edge transitions."""
titles = {c.id: c.title for c in clips}
return {
"scales": [
{"id": s.id, "clip_id": s.clip_id, "title": titles.get(s.clip_id, s.id)}
for s in ring.scales
],
"transitions": [{"file": t.file, "model": t.model} for t in ring.transitions],
}
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)."""
return {
"from_index": move.from_index,
"to_index": move.to_index,
"wrapped": move.wrapped,
"target_clip_id": scale_at(ring, move.to_index).clip_id,
"steps": [
{
"edge": st.edge,
"reversed": st.reversed,
"file": st.file,
"to_index": st.to_index,
}
for st in move.steps
],
}