From 7a50ae41bb37050b62d93475ab1d84817bed08cb Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 23:40:21 -0700 Subject: [PATCH] feat(sim+player): scale-ring navigation (endless encoder) + cosmos/abyss scales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 5 +- docs/ROADMAP.md | 24 ++- docs/USER_GUIDE.md | 19 ++- .../plans/2026-06-07-scale-ring-navigation.md | 97 +++++++++++ player/ring.py | 152 ++++++++++++++++++ simulator/app.py | 29 +++- simulator/clips.py | 57 +++++++ simulator/sample_media/README.md | 17 +- simulator/sample_media/manifest.json | 50 +++++- simulator/setup_scales_media.py | 108 +++++++++++++ simulator/static/app.js | 101 +++++++++++- simulator/static/index.html | 12 +- simulator/static/style.css | 7 + tests/test_clips.py | 78 ++++++++- tests/test_player_ring.py | 138 ++++++++++++++++ tests/test_simulator_api.py | 82 ++++++++++ 16 files changed, 951 insertions(+), 25 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-07-scale-ring-navigation.md create mode 100644 player/ring.py create mode 100644 simulator/setup_scales_media.py create mode 100644 tests/test_player_ring.py diff --git a/.gitignore b/.gitignore index cdd216c..92f1625 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ __pycache__/ media/ .superpowers/ *.egg-info/ -# Simulator sample media (look-tuning only; populate via setup_sample_media.py) -simulator/sample_media/forest/*.mp4 +# Simulator sample media (look-tuning only; populate via setup_sample_media.py +# / setup_scales_media.py). All scale + transition binaries are gitignored. +simulator/sample_media/**/*.mp4 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5667f11..d9485d5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -139,6 +139,23 @@ Design: plan: [`2026-06-07-reconciled-simulator-alteration-slice.md`](./superpowers/plans/2026-06-07-reconciled-simulator-alteration-slice.md). +**Slice 3 — scale-ring navigation (simulator) ✅ Done.** Merged to `main` +(session 0011). Adds the **endless rotary-encoder** control — *relative* (vs the +absolute 0–4 experience knobs) — that 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, scales +design §3). The navigation math is canonical Python (`player/ring.py`: +`ScaleRing` + `advance_ring`, one transition clip per edge, played forward +inward / reversed outward); the sim exposes `GET /api/ring` + `POST +/api/ring/advance` and the browser only plays the returned transition(s) then +settles on the target scale, keeping the live knob alteration on top. Two cheap +**true-PD** scales were added so the ring is demonstrable — `cosmos` (NASA/Hubble) +and `abyss` (NOAA Ocean Exploration), provenance recorded in the manifest, bytes +generated as labelled placeholders by `simulator/setup_scales_media.py`. The +expensive real multi-strength flow-stabilized Right re-bake is **deferred** (new +scales carry a raw base only) until the ring is liked. Plan: +[`2026-06-07-scale-ring-navigation.md`](./superpowers/plans/2026-06-07-scale-ring-navigation.md). + **Remaining slices (not started):** - **Runtime renderer** — drive the single panoramic projector via mpv/ffmpeg; @@ -152,10 +169,9 @@ plan: - **Offline v2v variant pipeline** — author the pre-baked Right restyle variants via the local flow-stabilized SD pipeline (now ~free, not a paid API — see the scales-library/right-axis design §1/§4); a real multi-strength flow-stabilized - re-bake per base clip + the multilingual label/string tables + TTS. -- **Scale-ring navigation** — the endless rotary encoder + short pre-baked AI - zoom/warp transitions between neutral "scales of nature" clips on a closed ring - (scales-library design §3); a new control + offline pipeline element. + re-bake per base clip + the multilingual label/string tables + TTS. The + **scale-ring transitions** (forward/reverse per-edge morphs) are part of this + offline pipeline too — the simulator currently uses placeholders. - **Catalog model changes** — audio *source* + "neutral base" vs "altered variant" flag (sub-project 2 territory, design §13). diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index ad95237..588e204 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -298,12 +298,16 @@ piece moved from *selecting* clips to *altering* them.) **One-time setup — populate the sample footage** (look-tuning only; not shipped content): - python simulator/setup_sample_media.py + python simulator/setup_sample_media.py # the forest scale (real POC base + Right variants) + python simulator/setup_scales_media.py # the cosmos + abyss scales + ring transitions -This copies the session-0008 POC artifacts (`~/hef-poc/out/`) into -`simulator/sample_media/forest/` — the neutral base clip and the real +`setup_sample_media.py` copies the session-0008 POC artifacts (`~/hef-poc/out/`) +into `simulator/sample_media/forest/` — the neutral base clip and the real flow-stabilized Right restyle — and generates placeholder intermediate Right -strengths. The `.mp4` binaries are gitignored. +strengths. `setup_scales_media.py` makes the scale **ring** demonstrable: cheap +synthetic placeholder bases for the two true-PD scales (`cosmos` = NASA/Hubble, +`abyss` = NOAA Ocean Exploration) plus the per-edge zoom/warp transition clips. +The `.mp4` binaries are gitignored. **Run it (Docker):** @@ -320,6 +324,13 @@ then open http://localhost:8000. - **Content dial** — picks audio/video channel; "off" and audio-only positions go to black walls. +- **Scale ring (endless encoder)** — `⊖ out` / `in ⊕` (or scroll the stage) walk a + *closed ring* of neutral "scales of nature" clips — cosmos → forest → abyss and + back around (diving past the smallest **wraps** to the largest). It is *relative* + (an endless encoder), distinct from the absolute 0–4 knobs: each step plays a + short placeholder zoom/warp **transition**, then settles on the next scale, with + the current knob alteration still applied. The current scale is named beside the + buttons (`name (i/N)`). - **Four experience knobs (0–4):** - **Dark / Light** — a live runtime color grade (cool/dark ↔ warm/bright; equal or zero = the raw footage). diff --git a/docs/superpowers/plans/2026-06-07-scale-ring-navigation.md b/docs/superpowers/plans/2026-06-07-scale-ring-navigation.md new file mode 100644 index 0000000..8632345 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-scale-ring-navigation.md @@ -0,0 +1,97 @@ +# Scale-Ring Navigation (Simulator) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans or +> superpowers:subagent-driven-development to implement task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add scale-ring navigation to the simulator — an endless rotary-encoder +control (relative, vs the absolute 0–4 experience knobs) that walks a *closed +ring* of neutral "scales of nature" clips, with placeholder AI zoom/warp +transitions between adjacent scales. Add 1–2 cheap, true-PD neutral base clips +(NASA/Hubble cosmos + NOAA Ocean Exploration deep sea) so the ring is +demonstrable. **Defer** the expensive real multi-strength flow-stabilized Right +re-bake until the ring is liked; keep deferring the Pi renderer + serial/firmware. + +**Spec:** `docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md` §3 (scale navigation & zoom transitions), §2 (scales library), §2.1 (strict-PD sourcing map). + +**Architecture:** Python stays the single source of truth. The ring *navigation +math* — step, wrap, which transition clip to play and in which direction — lives +in a new canonical pure module `player/ring.py` (mirrors `player/content.py` / +`player/alteration.py`: frozen dataclasses, pure functions). The simulator's data +layer (`simulator/clips.py`) parses a `ring` section of the manifest into the +ring types; the API exposes the ring and a stateless `advance` move; the browser +holds the current ring index and only *renders* (plays the returned transition +file, then the target scale's clip, with the existing per-knob alteration on top). + +**Ordering convention (canonical):** ring index 0 = the *largest* scale (cosmos); +increasing index zooms **inward** toward the smallest. `+1` detent = zoom in, +`-1` = zoom out — both wrap (past the smallest wraps to the largest, the +infinite-zoom payoff, §3). Edge `i` connects `scales[i] → scales[(i+1) % N]`; the +last edge is the small→large wrap seam. One transition clip per edge plays +**forward** when zooming inward and **reversed** when zooming outward, so N scales +need only N transition clips (§3: "one clip per ring edge"). + +**Deferral, made explicit:** the new cosmos/abyss scale clips carry a raw base +only (no pre-baked Right variants); the `Clip` model already falls back any +unauthored strength to the raw base, so Right on a new scale is a no-op until the +multi-strength flow-stabilized re-bake happens (deliberately deferred). Forest +keeps its existing POC variants. + +**Tech Stack:** Python 3.13, FastAPI + pydantic, pytest, frozen dataclasses; +vanilla JS + CSS/SVG for the browser; ffmpeg (system or `imageio-ffmpeg`) for +placeholder media. + +--- + +## File Structure + +**Engine (create):** +- `player/ring.py` — `Scale`, `Transition`, `ScaleRing`, `RingMove`, + `TransitionStep`, `scale_at`, `advance_ring`. Pure logic, no I/O/JSON. + +**Simulator (modify):** +- `simulator/clips.py` — add `load_ring(manifest)` building a `ScaleRing` from the + manifest `ring` section + `ring_to_dict` / `ring_move_to_dict` serializers. +- `simulator/app.py` — `GET /api/ring`, `POST /api/ring/advance`. +- `simulator/static/{index.html,app.js,style.css}` — endless-encoder control + (zoom-in/zoom-out buttons + mouse-wheel on the stage), current-scale readout, + multi-clip active-clip selection, transition playback. +- `simulator/sample_media/manifest.json` (committed) — add cosmos + abyss clips + and the `ring` section; media binaries stay gitignored. +- `simulator/setup_sample_media.py` (or sibling) — generate cheap cosmos/abyss + base placeholders + per-edge transition placeholders (ffmpeg); record true-PD + provenance in the manifest. Real PD fetch is a best-effort `--fetch` bonus. + +**Tests (create/extend):** +- `tests/test_player_ring.py` (create) — ring navigation logic (step, chain, wrap, + direction/reverse, degenerate N≤1). +- `tests/test_clips.py` (extend) — `load_ring`, serializers. +- `tests/test_simulator_api.py` (extend) — `/api/ring`, `/api/ring/advance`. + +**Docs:** +- `docs/ROADMAP.md` — scale-ring status. +- `docs/USER_GUIDE.md` — simulator "scale ring" gesture (if present). + +--- + +## Task 1: Engine — `player/ring.py` (pure ring navigation) +- [ ] TDD `Scale`/`Transition`/`ScaleRing` construction + validation (N edges for N scales; ≥1 scale; degenerate N≤1 ring). +- [ ] TDD `scale_at` (mod indexing) and `advance_ring(ring, from_index, delta)` → `RingMove` (to_index by mod; per-unit `TransitionStep` list; `reversed` on outward; `wrapped` flag on crossing the seam; chained steps for |delta|>1). + +## Task 2: Simulator data layer — `load_ring` + serializers +- [ ] TDD `load_ring(manifest)` parsing the `ring` section into a `ScaleRing`. +- [ ] TDD `ring_to_dict` / `ring_move_to_dict`. + +## Task 3: API — `/api/ring`, `/api/ring/advance` +- [ ] TDD `GET /api/ring` (scales + transitions, with each scale's title) and `POST /api/ring/advance` (validates from_index/delta; returns the move + target clip_id). + +## Task 4: Media — cosmos + abyss bases + transitions; manifest +- [ ] Add cosmos + abyss clips and the `ring` section to the committed manifest with true-PD provenance. +- [ ] Extend the media-setup script to generate cheap base + transition placeholders (and optional real PD fetch). + +## Task 5: Frontend — endless encoder + multi-clip + transition playback +- [ ] Active-clip selection by ring index; zoom-in/out buttons + wheel; current-scale readout; play returned transition file(s), then settle on target clip; keep live alteration on top. + +## Task 6: Verify + docs +- [ ] Full `pytest -q` green; boot the sim and exercise the ring by eye/screenshot. +- [ ] Update ROADMAP / USER_GUIDE. diff --git a/player/ring.py b/player/ring.py new file mode 100644 index 0000000..ab82051 --- /dev/null +++ b/player/ring.py @@ -0,0 +1,152 @@ +"""Scale-ring navigation: the endless-encoder closed loop of nature scales (§3). + +Design: docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline- +design.md §3 (scale navigation & zoom transitions). + +The scales-of-nature library is navigated as a CLOSED RING, not a line. A +dedicated endless rotary encoder — relative, vs the absolute 0..4 experience +knobs — walks the ring one step per detent; diving past the smallest scale wraps +around to the largest (the infinite-zoom payoff). Between each adjacent pair of +scales a short pre-baked AI zoom/warp transition plays. + +This is the canonical navigation engine (Python-canonical, shared with the Pi +player/firmware); the simulator browser only renders what `advance_ring` returns. + +Ordering convention: + - index 0 = the LARGEST scale (cosmos); increasing index zooms INWARD toward + the smallest. + - +1 detent zooms in, -1 zooms out; BOTH wrap (past the smallest wraps to the + largest, and vice versa). + - edge i connects scales[i] -> scales[(i+1) % N]; the last edge is the + small->large wrap seam. One transition clip per edge plays FORWARD when + zooming inward and REVERSED when zooming outward, so N scales need only N + transition clips. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +class RingError(ValueError): + """Raised when a ScaleRing is structurally invalid.""" + + +@dataclass(frozen=True) +class Scale: + """A node on the ring: a scale of nature and the base clip it plays.""" + + id: str + clip_id: str + + +@dataclass(frozen=True) +class Transition: + """A pre-baked zoom/warp morph along ONE ring edge. + + `file` plays FORWARD when zooming inward (scales[i] -> scales[i+1] in ring + order); the renderer reverses it for the outward direction, so one clip + covers both directions of an edge. + """ + + file: str + model: str = "" + + +@dataclass(frozen=True) +class ScaleRing: + """A closed ring of scales joined by per-edge transitions. + + Invariant: a ring of N>=2 scales has exactly N transitions (one per edge, + including the small->large wrap seam). A degenerate single-scale ring has no + edges. An empty ring is rejected. + """ + + scales: tuple[Scale, ...] + transitions: tuple[Transition, ...] + + def __post_init__(self) -> None: + n = len(self.scales) + if n == 0: + raise RingError("a ScaleRing needs at least one scale") + expected = 0 if n == 1 else n + if len(self.transitions) != expected: + raise RingError( + f"a {n}-scale ring needs {expected} transitions, " + f"got {len(self.transitions)}" + ) + + def __len__(self) -> int: + return len(self.scales) + + +@dataclass(frozen=True) +class TransitionStep: + """One transition to play during a move: which edge clip, in which direction, + and the scale index it lands on.""" + + edge: int + reversed: bool + file: str + to_index: int + + +@dataclass(frozen=True) +class RingMove: + """The result of advancing the encoder: where you end up and the ordered + transitions to play getting there (chained for a multi-detent spin).""" + + from_index: int + to_index: int + steps: tuple[TransitionStep, ...] + wrapped: bool + + +def scale_at(ring: ScaleRing, index: int) -> Scale: + """The scale at `index`, taken modulo the ring length (wraps both ways).""" + return ring.scales[index % len(ring)] + + +def advance_ring(ring: ScaleRing, from_index: int, delta: int) -> RingMove: + """Walk the endless encoder `delta` detents from `from_index` (signed). + + Returns a `RingMove` with the landing index and the ordered `TransitionStep`s + to play. Inward steps (+) play edge i forward; outward steps (-) play the + crossed edge reversed. `wrapped` is True if any step crossed the small<->large + seam. A degenerate single-scale ring (or delta 0) is a no-op. + """ + n = len(ring) + start = from_index % n + if n == 1 or delta == 0: + return RingMove(from_index=start, to_index=start, steps=(), wrapped=False) + + direction = 1 if delta > 0 else -1 + steps: list[TransitionStep] = [] + wrapped = False + index = start + for _ in range(abs(delta)): + if direction > 0: + # zoom inward: edge `index` forward, land at index+1 (mod n) + edge = index + nxt = (index + 1) % n + reversed_ = False + else: + # zoom outward: cross edge `index-1` reversed, land at index-1 (mod n) + edge = (index - 1) % n + nxt = (index - 1) % n + reversed_ = True + if edge == n - 1: + wrapped = True + steps.append( + TransitionStep( + edge=edge, + reversed=reversed_, + file=ring.transitions[edge].file, + to_index=nxt, + ) + ) + index = nxt + + return RingMove( + from_index=start, to_index=index, steps=tuple(steps), wrapped=wrapped + ) diff --git a/simulator/app.py b/simulator/app.py index b7e9b67..45c5f36 100644 --- a/simulator/app.py +++ b/simulator/app.py @@ -23,7 +23,8 @@ from player.alteration import ( ) from player.content import resolve_content from player.controls import CONTENT_POSITIONS -from simulator.clips import load_manifest +from player.ring import advance_ring +from simulator.clips import load_manifest, load_ring, ring_move_to_dict, ring_to_dict STATIC_DIR = Path(__file__).parent / "static" MEDIA_DIR = Path(__file__).parent / "sample_media" @@ -51,6 +52,11 @@ class AlterationRequest(BaseModel): calibration: Optional[CalibrationModel] = None +class RingAdvanceRequest(BaseModel): + from_index: int = 0 + delta: int + + def _load_clips(manifest_path: Optional[Path]): path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST if path.exists(): @@ -58,9 +64,17 @@ def _load_clips(manifest_path: Optional[Path]): return [] +def _load_ring(manifest_path: Optional[Path]): + path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST + if path.exists(): + return load_ring(path) + return None + + def create_app(manifest_path: Optional[Path] = None) -> FastAPI: app = FastAPI(title="HEF Alteration Simulator") app.state.clips = _load_clips(manifest_path) + app.state.ring = _load_ring(manifest_path) @app.post("/api/alteration") def api_alteration(req: AlterationRequest): @@ -88,6 +102,19 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI: def api_clips(): return {"clips": [c.to_dict() for c in app.state.clips]} + @app.get("/api/ring") + def api_ring(): + if app.state.ring is None: + raise HTTPException(status_code=404, detail="no scale ring in manifest") + return ring_to_dict(app.state.ring, app.state.clips) + + @app.post("/api/ring/advance") + 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) + return ring_move_to_dict(move, app.state.ring) + if MEDIA_DIR.exists(): app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") if STATIC_DIR.exists(): diff --git a/simulator/clips.py b/simulator/clips.py index 7d4de52..03c27c5 100644 --- a/simulator/clips.py +++ b/simulator/clips.py @@ -14,6 +14,8 @@ 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: @@ -66,3 +68,58 @@ def load_manifest(path: str | Path) -> list[Clip]: 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 + ], + } diff --git a/simulator/sample_media/README.md b/simulator/sample_media/README.md index ff9f08d..27fccd4 100644 --- a/simulator/sample_media/README.md +++ b/simulator/sample_media/README.md @@ -3,10 +3,19 @@ `manifest.json` is committed; the `.mp4` binaries are **not** (gitignored). They are look-tuning samples, not shipped installation content. -Populate them from the session-0008 POC artifacts: +Populate them in two steps: - python simulator/setup_sample_media.py + python simulator/setup_sample_media.py # forest (the real POC base + Right variants) + python simulator/setup_scales_media.py # cosmos + abyss scales + ring transitions -This copies `~/hef-poc/out/neutral.mp4` → `forest/base.mp4` and -`~/hef-poc/out/right_flow.mp4` → `forest/right4.mp4` (the real flow-stabilized +`setup_sample_media.py` copies `~/hef-poc/out/neutral.mp4` → `forest/base.mp4` +and `~/hef-poc/out/right_flow.mp4` → `forest/right4.mp4` (the real flow-stabilized restyle), and generates placeholder strengths `forest/right1..3.mp4`. + +`setup_scales_media.py` makes the scale **ring** demonstrable: cheap synthetic +placeholder bases for the two true-PD scales — `cosmos/base.mp4` (NASA/Hubble) +and `abyss/base.mp4` (NOAA Ocean Exploration) — plus the per-edge zoom/warp +**transition** clips under `transitions/`. The manifest records the real true-PD +provenance; these bytes are labelled placeholders for tuning ring navigation. +The new scales carry a raw base only — the expensive multi-strength +flow-stabilized Right re-bake is **deferred** until the ring is liked. diff --git a/simulator/sample_media/manifest.json b/simulator/sample_media/manifest.json index 730e5b9..be9c3e9 100644 --- a/simulator/sample_media/manifest.json +++ b/simulator/sample_media/manifest.json @@ -1,5 +1,23 @@ { "clips": [ + { + "id": "cosmos", + "title": "Cosmos (NASA/Hubble, neutral base)", + "base_file": "cosmos/base.mp4", + "license": "public-domain (US Gov, 17 U.S.C. §105)", + "source": "NASA/Hubble — https://svs.gsfc.nasa.gov/ (true PD; placeholder bytes until ingest)", + "right_variants": {}, + "annotations": [ + {"key": "detected.galaxy", "box": [0.34, 0.30, 0.30, 0.34], "min_level": 1}, + {"key": "measure.redshift", "box": [0.36, 0.66, 0.18, 0.08], "min_level": 4} + ], + "strings": { + "en": { + "detected.galaxy": "spiral galaxy", + "measure.redshift": "z ≈ 0.04" + } + } + }, { "id": "forest", "title": "Yosemite Falls (neutral base, POC sample)", @@ -26,6 +44,36 @@ "measure.flow_rate": "~2.1 m³/s" } } + }, + { + "id": "abyss", + "title": "Deep sea (NOAA Ocean Exploration, neutral base)", + "base_file": "abyss/base.mp4", + "license": "public-domain (US Gov, 17 U.S.C. §105)", + "source": "NOAA Ocean Exploration — https://oceanexplorer.noaa.gov/ (true PD, worldwide; placeholder bytes until ingest)", + "right_variants": {}, + "annotations": [ + {"key": "detected.organism", "box": [0.40, 0.38, 0.22, 0.26], "min_level": 1}, + {"key": "measure.depth", "box": [0.06, 0.06, 0.16, 0.08], "min_level": 2} + ], + "strings": { + "en": { + "detected.organism": "siphonophore", + "measure.depth": "−2,140 m" + } + } } - ] + ], + "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-zoom"}, + {"file": "transitions/forest-abyss.mp4", "model": "placeholder-zoom"}, + {"file": "transitions/abyss-cosmos.mp4", "model": "placeholder-zoom"} + ] + } } diff --git a/simulator/setup_scales_media.py b/simulator/setup_scales_media.py new file mode 100644 index 0000000..54cd32f --- /dev/null +++ b/simulator/setup_scales_media.py @@ -0,0 +1,108 @@ +"""Generate cheap placeholder media for the scale RING (scales design §3). + +The ring needs >=2 neutral scales to be demonstrable. This script produces +cheap, offline, synthetic placeholders for the two true-PD scales the ring adds — +**cosmos** (NASA/Hubble) and **abyss** (NOAA Ocean Exploration) — plus the short +zoom/warp **transition** clips between adjacent scales. Forest reuses the real +POC base from `setup_sample_media.py` (or a synthetic placeholder if absent). + +This is deliberately the CHEAP path: the manifest records the real true-PD +provenance (NASA 17 U.S.C. §105 / NOAA PD), but the bytes here are labelled +synthetic placeholders for look-tuning the ring navigation. The expensive real +multi-strength flow-stabilized Right re-bake is DEFERRED until the ring is liked, +so the new scales carry a raw base only (no Right variants). + +Usage: python simulator/setup_scales_media.py +Requires: ffmpeg on PATH (or `pip install imageio-ffmpeg`). +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +MEDIA = Path(__file__).parent / "sample_media" +DUR = 6 # base clip seconds +XDUR = 1.5 # transition seconds +SIZE = "1280x720" +FPS = 25 + +# Each scale: (dir, base color, on-screen placeholder label). +SCALES = { + "cosmos": ("0x05060f", "COSMOS — NASA/Hubble (PD placeholder)"), + "forest": ("0x12301a", "FOREST — Yosemite (POC/placeholder)"), + "abyss": ("0x021016", "ABYSS — NOAA Ocean Exploration (PD placeholder)"), +} + +# Ring edges (adjacent pairs + the abyss->cosmos wrap), matching the manifest. +EDGES = [("cosmos", "forest"), ("forest", "abyss"), ("abyss", "cosmos")] + + +def _ffmpeg() -> str: + if shutil.which("ffmpeg"): + return "ffmpeg" + import imageio_ffmpeg + return imageio_ffmpeg.get_ffmpeg_exe() + + +def _run(args: list[str]) -> None: + subprocess.run(args, check=True, capture_output=True) + + +def _make_base(ff: str, name: str, color: str, label: str) -> Path: + out = MEDIA / name / "base.mp4" + out.parent.mkdir(parents=True, exist_ok=True) + # A calm solid tint + faint noise grain (reads as stars/particles), with a + # placeholder label so it is never mistaken for shipped footage. + vf = ( + "noise=alls=8:allf=t," + "drawtext=text='" + label + "':fontcolor=white@0.55:fontsize=24:" + "x=(w-text_w)/2:y=h-48" + ) + _run([ + ff, "-y", "-f", "lavfi", "-i", + f"color=c={color}:s={SIZE}:d={DUR}:r={FPS}", + "-vf", vf, "-pix_fmt", "yuv420p", "-an", str(out), + ]) + return out + + +def _make_transition(ff: str, src: str, dst: str) -> Path: + a = MEDIA / src / "base.mp4" + b = MEDIA / dst / "base.mp4" + out = MEDIA / "transitions" / f"{src}-{dst}.mp4" + out.parent.mkdir(parents=True, exist_ok=True) + w, h = SIZE.split("x") + # A placeholder zoom/warp morph: zoom-in crossfade from src into dst. Both + # bases are normalized first (size/fps/sar/format) because the real forest + # POC base differs in resolution + fps from the synthetic scale placeholders, + # and xfade requires identical frame geometry on both inputs. + norm = f"trim=0:3,setpts=PTS-STARTPTS,scale={w}:{h},fps={FPS},setsar=1,format=yuv420p" + _run([ + ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex", + f"[0:v]{norm}[a];[1:v]{norm}[b];" + f"[a][b]xfade=transition=zoomin:duration={XDUR}:offset=0.75," + "format=yuv420p[v]", + "-map", "[v]", "-an", str(out), + ]) + return out + + +def main() -> None: + ff = _ffmpeg() + for name, (color, label) in SCALES.items(): + base = MEDIA / name / "base.mp4" + if name == "forest" and base.exists(): + print(f"forest/base.mp4 present (real POC base) — keeping") + continue + _make_base(ff, name, color, label) + print(f"generated {name}/base.mp4 (synthetic placeholder)") + for src, dst in EDGES: + _make_transition(ff, src, dst) + print(f"generated transitions/{src}-{dst}.mp4 (placeholder zoom)") + print(f"scale-ring media ready in {MEDIA}") + + +if __name__ == "__main__": + main() diff --git a/simulator/static/app.js b/simulator/static/app.js index f2cd708..c201e2e 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -1,19 +1,39 @@ // Thin renderer: post controls+calibration -> RenderPlan; render grade, Right -// variant crossfade, and the live Left overlay. All math stays in Python. +// variant crossfade, and the live Left overlay. All alteration math stays in +// Python. The scale RING (endless encoder) is navigated via /api/ring/advance — +// Python owns the step/wrap/transition-selection math; the browser only plays +// the returned transition clip(s) then settles on the target scale's clip. const $ = (id) => document.getElementById(id); const vid = $("vid"), tint = $("tint"), overlay = $("overlay"), black = $("black"), readout = $("readout"); -let clip = null; // active clip manifest entry -let currentVariant = -1; // last loaded Right strength +let clipsById = {}; // id -> clip manifest entry +let ring = null; // {scales:[{id,clip_id,title}], transitions:[...]} or null +let ringIndex = 0; // current scale position on the ring +let currentVariant = -1; // last loaded Right strength (reset on clip change) +let busy = false; // true while a ring transition is playing -async function loadClips() { - const data = await (await fetch("/api/clips")).json(); - clip = data.clips[0] || null; +async function loadData() { + const clips = (await (await fetch("/api/clips")).json()).clips || []; + clipsById = Object.fromEntries(clips.map((c) => [c.id, c])); + const r = await fetch("/api/ring"); + ring = r.ok ? await r.json() : null; + if (!ring && clips.length) { + // No ring: single-clip mode — synthesize a 1-scale ring over clip 0. + ring = { scales: [{ id: clips[0].id, clip_id: clips[0].id, title: clips[0].title }], transitions: [] }; + } + ringIndex = 0; +} + +function activeClip() { + if (!ring) return null; + return clipsById[ring.scales[ringIndex].clip_id] || null; } function mediaUrl(file) { return "/media/" + file; } function variantFile(strength) { + const clip = activeClip(); + if (!clip) return ""; const v = clip.right_variants[String(strength)]; return v ? v.file : clip.base_file; } @@ -37,12 +57,14 @@ function loadVariant(strength) { vid.style.opacity = "0"; setTimeout(() => { vid.src = mediaUrl(variantFile(strength)); + vid.loop = true; vid.play().catch(() => {}); vid.style.opacity = "1"; }, 150); } function renderOverlay(level, intensity) { + const clip = activeClip(); overlay.innerHTML = ""; if (!clip || level <= 0) { overlay.style.opacity = "0"; return; } overlay.style.opacity = String(intensity); @@ -63,6 +85,12 @@ function renderOverlay(level, intensity) { } } +function renderScaleReadout() { + if (!ring) return; + const s = ring.scales[ringIndex]; + $("scale-name").textContent = `${s.title} (${ringIndex + 1}/${ring.scales.length})`; +} + function controls() { return { content: $("content").value, @@ -79,6 +107,7 @@ function calibration() { let timer = null; async function update() { + if (busy) return; const resp = await fetch("/api/alteration", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ controls: controls(), calibration: calibration() }), @@ -95,11 +124,69 @@ async function update() { 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). + return new Promise((resolve) => { + overlay.style.opacity = "0"; + tint.style.opacity = "0"; + vid.style.filter = "none"; + vid.loop = false; + vid.style.opacity = "1"; + vid.src = mediaUrl(file); + let done = false; + const finish = () => { if (done) return; done = true; vid.removeEventListener("ended", finish); resolve(); }; + vid.addEventListener("ended", finish); + vid.play().catch(finish); + setTimeout(finish, 6000); + }); +} + +async function advance(delta) { + if (busy || !ring || ring.scales.length < 2) return; + busy = true; + try { + const resp = await fetch("/api/ring/advance", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ from_index: ringIndex, delta }), + }); + if (!resp.ok) return; + const move = await resp.json(); + for (const step of move.steps) { + await playTransition(step.file); + } + ringIndex = move.to_index; + currentVariant = -1; // force the target clip's variant to (re)load + renderScaleReadout(); + } finally { + busy = false; + update(); + } +} + +let wheelAccum = 0, wheelTimer = null; +function onWheel(e) { + e.preventDefault(); + wheelAccum += e.deltaY; + clearTimeout(wheelTimer); + wheelTimer = setTimeout(() => { + const detents = Math.trunc(wheelAccum / 60) || (wheelAccum > 0 ? 1 : -1); + wheelAccum = 0; + if (detents) advance(detents); // wheel down = zoom inward (+) + }, 90); +} + async function main() { - await loadClips(); + await loadData(); + renderScaleReadout(); for (const id of ["content", "left", "right", "dark", "light", "mood_gain", "overlay_gain"]) { $(id).addEventListener("input", debounced); } + $("zoom-in").addEventListener("click", () => advance(1)); + $("zoom-out").addEventListener("click", () => advance(-1)); + $("stage").addEventListener("wheel", onWheel, { passive: false }); update(); } main(); diff --git a/simulator/static/index.html b/simulator/static/index.html index df5eaf8..2852acc 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -9,7 +9,7 @@

Human Experience Filter — Alteration Preview

-
+
@@ -32,6 +32,16 @@ +
+ Scale ring (endless encoder) +
+ + + +
+

Relative, endless — wraps past the smallest back to the largest. Scroll the stage too.

+
+
Experience knobs (0–4) diff --git a/simulator/static/style.css b/simulator/static/style.css index 5d6bd46..d5d0fc9 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -23,3 +23,10 @@ label { display: block; margin: 0.4rem 0; } input[type=range], select { width: 100%; } #readout { background: #000; padding: 0.5rem; border-radius: 4px; font-size: 12px; white-space: pre-wrap; max-height: 240px; overflow: auto; } +.ring-control { display: flex; align-items: center; gap: 0.4rem; } +.ring-control button { flex: 0 0 auto; background: #1a2436; color: #9af; + border: 1px solid #345; border-radius: 4px; padding: 0.3rem 0.5rem; + cursor: pointer; font-size: 13px; } +.ring-control button:hover { background: #243352; } +.scale-name { flex: 1 1 auto; text-align: center; font-size: 12px; color: #cde; } +.hint { margin: 0.3rem 0 0; font-size: 11px; color: #789; } diff --git a/tests/test_clips.py b/tests/test_clips.py index a9507c2..f144d18 100644 --- a/tests/test_clips.py +++ b/tests/test_clips.py @@ -2,7 +2,14 @@ import json import pytest -from simulator.clips import Clip, load_manifest +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(): @@ -67,3 +74,72 @@ def test_clip_serializes_to_dict_for_the_api(tmp_path): 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 diff --git a/tests/test_player_ring.py b/tests/test_player_ring.py new file mode 100644 index 0000000..96107c0 --- /dev/null +++ b/tests/test_player_ring.py @@ -0,0 +1,138 @@ +import pytest + +from player.ring import ( + RingError, + RingMove, + Scale, + ScaleRing, + Transition, + TransitionStep, + advance_ring, + scale_at, +) + + +def _ring(): + # cosmos (largest, idx 0) -> forest -> abyss (smallest) -> wraps to cosmos + return ScaleRing( + scales=( + Scale(id="cosmos", clip_id="cosmos"), + Scale(id="forest", clip_id="forest"), + 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) + ), + ) + + +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_ring_rejects_empty(): + with pytest.raises(RingError): + ScaleRing(scales=(), transitions=()) + + +def test_scale_at_wraps_modulo(): + r = _ring() + assert scale_at(r, 0).id == "cosmos" + assert scale_at(r, 3).id == "cosmos" # wraps + assert scale_at(r, -1).id == "abyss" # negative wraps + assert scale_at(r, 4).id == "forest" + + +def test_advance_one_step_inward_plays_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), + ) + + +def test_advance_one_step_outward_plays_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), + ) + + +def test_advance_inward_past_smallest_wraps_to_largest(): + r = _ring() + # at abyss (smallest, idx 2), zoom in -> wraps to cosmos (idx 0) via edge 2 + 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), + ) + + +def test_advance_outward_past_largest_wraps_to_smallest(): + r = _ring() + # at cosmos (idx 0), zoom out -> wraps to abyss (idx 2) via edge 2 reversed + 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), + ) + + +def test_advance_multi_detent_chains_transitions(): + 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), + ) + + +def test_advance_full_loop_returns_to_start_and_marks_wrap(): + r = _ring() + move = advance_ring(r, from_index=0, delta=3) + assert move.to_index == 0 + assert move.wrapped is True + assert len(move.steps) == 3 + + +def test_advance_zero_is_a_noop(): + r = _ring() + move = advance_ring(r, from_index=1, delta=0) + assert move.from_index == 1 + assert move.to_index == 1 + assert move.steps == () + assert move.wrapped is False + + +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 + + +def test_degenerate_single_scale_ring_is_a_noop(): + r = ScaleRing(scales=(Scale("solo", "solo"),), transitions=()) + move = advance_ring(r, from_index=0, delta=5) + assert move.to_index == 0 + assert move.steps == () + assert move.wrapped is False diff --git a/tests/test_simulator_api.py b/tests/test_simulator_api.py index d5e32c2..1eefaf0 100644 --- a/tests/test_simulator_api.py +++ b/tests/test_simulator_api.py @@ -88,3 +88,85 @@ def test_index_is_served(): assert resp.status_code == 200 assert "text/html" in resp.headers["content-type"] assert "Alteration" in resp.text + + +@pytest.fixture +def ring_manifest_path(tmp_path): + p = tmp_path / "manifest.json" + p.write_text(json.dumps({ + "clips": [ + {"id": "cosmos", "title": "NASA cosmos", "base_file": "cosmos/base.mp4", + "license": "PD", "source": "NASA", "right_variants": {}, + "annotations": [], "strings": {}}, + {"id": "forest", "title": "Yosemite", "base_file": "forest/base.mp4", + "license": "poc", "source": "hef-poc", + "right_variants": {"4": {"file": "forest/right4.mp4"}}, + "annotations": [], "strings": {}}, + {"id": "abyss", "title": "NOAA abyss", "base_file": "abyss/base.mp4", + "license": "PD", "source": "NOAA", "right_variants": {}, + "annotations": [], "strings": {}}, + ], + "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"}, + ], + }, + })) + return p + + +@pytest.fixture +def ring_client(ring_manifest_path): + return TestClient(create_app(manifest_path=ring_manifest_path)) + + +def test_ring_returns_scales_and_transitions(ring_client): + resp = ring_client.get("/api/ring") + assert resp.status_code == 200 + 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 + + +def test_ring_advance_inward_one_step(ring_client): + resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 1}) + 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 + + +def test_ring_advance_wraps_smallest_to_largest(ring_client): + resp = ring_client.post("/api/ring/advance", json={"from_index": 2, "delta": 1}) + data = resp.json() + assert data["to_index"] == 0 + assert data["target_clip_id"] == "cosmos" + 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}) + data = resp.json() + assert data["to_index"] == 0 + assert data["steps"][0]["reversed"] is True + + +def test_ring_404_when_no_ring_in_manifest(client): + assert client.get("/api/ring").status_code == 404 + assert client.post("/api/ring/advance", json={"from_index": 0, "delta": 1}).status_code == 404 + + +def test_ring_advance_rejects_bad_delta(ring_client): + resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": "x"}) + assert resp.status_code == 422