Compare commits

...

9 Commits

Author SHA1 Message Date
benstull 49b1f0a549 Merge pull request 'feat(ring): fast-spin blended pass past a speed threshold (scales design §3)' (#10) from feature/ring-fast-spin-blended-pass into main 2026-06-08 07:18:16 +00:00
Ben Stull f11b9ee72d 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>
2026-06-08 00:17:47 -07:00
Ben Stull e975ab1f5a claim human-experience-filter-art session 0012 (placeholder) + sessions.json entry 2026-06-08 00:05:46 -07:00
Ben Stull ef79da0fde add sessions/0011/SESSION-0011.0-TRANSCRIPT-2026-06-07T23-26--2026-06-07T23-42.md + replace placeholder/variant SESSION-0011.0-TRANSCRIPT-2026-06-07T23-26--INPROGRESS.md 2026-06-07 23:43:50 -07:00
benstull 435f201534 Merge pull request 'feat(sim+player): scale-ring navigation (endless encoder) + cosmos/abyss scales' (#9) from feature/scale-ring-navigation into main 2026-06-08 06:41:20 +00:00
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
Ben Stull 6cf1ae08ff claim human-experience-filter-art session 0011 (placeholder) + sessions.json entry 2026-06-07 23:27:02 -07:00
Ben Stull 7cf550ac60 add sessions/0010/SESSION-0010.0-TRANSCRIPT-2026-06-07T23-09--2026-06-07T23-22.md + replace placeholder/variant SESSION-0010.0-TRANSCRIPT-2026-06-07T23-09--INPROGRESS.md 2026-06-07 23:23:47 -07:00
benstull 638cf5808f Merge pull request 'feat(sim+engine): lock alteration calibration + fix psychedelic dark grade' (#8) from feature/lock-alteration-calibration into main 2026-06-08 06:20:53 +00:00
22 changed files with 1472 additions and 48 deletions
+3 -2
View File
@@ -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
+24 -4
View File
@@ -139,6 +139,27 @@ 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 04 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).
Session 0012 added the design §3 **fast-spin** behavior on top: past an opt-in
speed threshold (`DEFAULT_FAST_SPIN_THRESHOLD = 3`, `abs(delta)` as the spin-speed
proxy) a quick spin collapses to a single **blended pass** instead of chaining
every transition, so fast navigation stays responsive on placeholder media.
**Remaining slices (not started):**
- **Runtime renderer** — drive the single panoramic projector via mpv/ffmpeg;
@@ -152,10 +173,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).
+18 -4
View File
@@ -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,16 @@ 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 04 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)`). A **fast spin** (scroll several detents at once, ≥3)
collapses to a single quick **blended pass** straight to the destination scale
instead of grinding through every transition (scales design §3); slow single
steps still chain one full transition per scale crossed.
- **Four experience knobs (04):**
- **Dark / Light** — a live runtime color grade (cool/dark ↔ warm/bright; equal
or zero = the raw footage).
@@ -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 04 experience knobs) that walks a *closed
ring* of neutral "scales of nature" clips, with placeholder AI zoom/warp
transitions between adjacent scales. Add 12 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.
@@ -162,6 +162,17 @@ the ring continuous.
local; one clip per ring edge (N scales → N transitions, including the micro→cosmos
closer). A fast spin may cross several scales — transitions chain, or past a speed
threshold a faster blended pass is used.
- **Implemented (session 0012):** `player.ring.advance_ring` takes 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/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 chaining every transition. The policy is opt-in because the pure function
cannot observe wall-clock speed — the simulator/firmware enables it. The
simulator plays the blended step at 2.5× (`FAST_BLEND_RATE`); when real
transition clips exist a dedicated fast-blend clip can replace the
accelerated arrival-edge placeholder. Threshold + rate are tunable by eye.
- **Thesis-safe:** dwells on a scale are the neutral, knob-altered interactive cores;
transitions are fixed connective moments (un-altered, or at most carrying the
current mood grade). The awe lives in the *movement between* scales, not the base.
+211
View File
@@ -0,0 +1,211 @@
"""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.
Fast spin (§3, "transitions chain, or past a speed threshold a faster blended
pass is used"): a quick encoder spin batches many detents into one advance, so
`abs(delta)` is the input adapter's proxy for spin speed. Past an opt-in
`fast_spin_threshold` the move collapses to a SINGLE blended pass (the
arrival-edge clip, played fast) rather than chaining N full transitions, so a
fast spin stays responsive instead of grinding through every morph. The policy
is opt-in (default off) because this pure function cannot observe wall-clock
spin speed the input layer (simulator/firmware), which can, enables it.
"""
from __future__ import annotations
from dataclasses import dataclass
# Default spin-speed cutoff for the input layer (simulator/firmware) to enable
# the fast-spin blended pass: 3+ detents batched into one advance() is a fast
# spin crossing several scales; 1-2 are deliberate single steps that chain. Tune
# by eye — it is a UX policy, not canonical ring structure.
DEFAULT_FAST_SPIN_THRESHOLD = 3
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.
`blended` marks the single collapsed step of a fast-spin pass (see
`advance_ring`'s `fast_spin_threshold`): the renderer plays it as one quick
accelerated morph straight to the destination instead of a full transition.
"""
edge: int
reversed: bool
file: str
to_index: int
blended: bool = False
@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).
`fast` is True when the move was collapsed to a single blended pass because
the spin crossed the speed threshold (see `advance_ring`).
"""
from_index: int
to_index: int
steps: tuple[TransitionStep, ...]
wrapped: bool
fast: bool = False
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,
*,
fast_spin_threshold: int = 0,
) -> 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.
`fast_spin_threshold` (opt-in, default off): when >= 2 and `abs(delta)` meets
it, the spin is treated as fast and the move collapses to a single blended
pass one `TransitionStep` (the arrival edge, marked `blended`) landing
straight on the destination, with `RingMove.fast=True` instead of chaining
every transition. See the module docstring (§3).
"""
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
# Fast spin: collapse the whole chain to a single blended arrival pass. The
# landing index, seam-crossing, and arrival edge/direction are exactly those
# of the full chain — only the in-between transitions are dropped.
if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold:
arrival = steps[-1]
blended = TransitionStep(
edge=arrival.edge,
reversed=arrival.reversed,
file=arrival.file,
to_index=arrival.to_index,
blended=True,
)
return RingMove(
from_index=start,
to_index=index,
steps=(blended,),
wrapped=wrapped,
fast=True,
)
return RingMove(
from_index=start, to_index=index, steps=tuple(steps), wrapped=wrapped
)
@@ -0,0 +1,129 @@
# Session 0010.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-07T23-09 (PST)
> End: 2026-06-07T23-22 (PST)
> Type: coding
> Status: **FINALIZED**
> Posture: autonomous (yolo)
> Landed: PR #8 (merge `638cf58`) — calibration lock + dark-grade look fix
## Launch prompt
```
Tune the alteration look by eye in the simulator (python simulator/setup_sample_media.py then make sim-local) and LOCK the knob→strength calibration into DEFAULT_CALIBRATION in player/alteration.py (+ a unit test) — settling the open session-0006 calibration decision. While there, judge whether more neutral "scales of nature" base clips + a real multi-strength flow-stabilized Right re-bake are worth doing next vs. moving to scale-ring navigation (endless encoder + AI zoom transitions). Keep deferring Pi renderer + serial/firmware. Read sub-project-3-player-progress memory + docs/superpowers/specs/2026-06-07-reconciled-simulator-alteration-slice-design.md (§8) first.
```
## Pre-state
- `main` clean at `e753a68` (then ff'd to the 0010 claim commit `7d2a306`).
- Sub-project 3 slice 2 (sim alteration) merged in session 0009 (PR #7). Engine
in `player/alteration.py` carried a `Calibration` + `DEFAULT_CALIBRATION` with
**behavior-preserving placeholder** values; the knob→strength calibration had
been the open decision since session 0006.
- Sample media already populated under `simulator/sample_media/forest/` (base +
Right strengths 14; only strength 4 is the real flow-stabilized restyle, 13
are ffmpeg blend placeholders). POC artifacts present in `~/hef-poc/out/`.
## The arc
### 1. Session open + grounding
- Classified the launch prompt as a **coding** session; ran
`wgl-session-coding-init`, claimed session **0010** (no other sessions in
flight). Verified clean pushed `main` baseline + CLAUDE.md stub.
- Read the resume context: `sub-project-3-player-progress` memory + the reconciled
slice design §8 (open questions: calibration curve shape, grade-vs-overlay
ordering, crossfade timing, placeholder fidelity).
- Inspected the engine (`player/alteration.py`), its test, the sim frontend
(`simulator/static/app.js`), and the content/clip plumbing. Established that the
calibration's three params (`mood_gain`, `overlay_gain`, `right_variant_map`)
are the lock target, and that the *grade look* itself lives in `app.js` CSS
filters (frontend), not in `DEFAULT_CALIBRATION`.
### 2. Tuning the look by eye (the key finding)
- `python`/`ffmpeg` weren't on the non-interactive shell PATH; used the project
`.venv`. Baseline suite: **192 passed / 2 skipped**.
- Viewed the operator's own POC renders in `~/hef-poc/out/` (`all_axes.png`,
`right_three.png`, `dark_frame.png`, `light_frame.png`, `lefthud_frame.png`) —
the by-eye-approved (session 0008) looks: tasteful cool-blue dark, warm golden
light, soft dreamy flow restyle, legible analytical HUD.
- Booted the sim (`uvicorn simulator.app:app`) and drove it with **headless
Chrome (puppeteer-core)** to screenshot the *actual* rendered look across all
four axes at full knob, plus the whole-brain-dark corner.
- **Finding:** the sim's **dark** mood pole was rendering a full-frame
`hue-rotate(-cool*200deg)` → rock turned orange, trees purple — a **psychedelic**
look, exactly the "disorienting, not peaceful" effect the operator rejected in
0008, and nothing like the POC `dark_frame`. Light / Left / Right all looked
good and peaceful. So the dark grade was the one thing blocking a likable look.
### 3. Dark-grade fix
- Replaced the hue-rotate in `applyGrade` (`simulator/static/app.js`) with:
darken + slight desaturate on the video filter for the cool pole, plus a
`multiply`-blended deep-blue wash (`#tint`, new element) that lifts shadows
toward blue while preserving natural greens. Placed `#tint` **below** the SVG
overlay so the Left HUD stays legible regardless of mood (resolves the §8
grade-vs-overlay ordering question → overlay above).
- Re-screenshotted: full dark now reads cool/somber with natural greens (matches
POC `dark_frame`); whole-brain-dark corner peaceful + HUD legible; light/neutral
unchanged (the warm path is algebraically identical for tone ≥ 0).
### 4. Locking the calibration
- With full tilt now tasteful on **every** axis (POC + sim evidence), the data
says full knob = full look, 5 notches map 1:1 to the 5 discrete Right bakes,
equal Dark/Light = identity → **unity gains + linear variant map**.
- Promoted `DEFAULT_CALIBRATION` from placeholder to a **deliberately locked**
`Calibration(mood_gain=1.0, overlay_gain=1.0, right_variant_map=(0,1,2,3,4))`
with a provenance comment; this also confirms the session-0006 convention
(0=off..4=max, equal Dark/Light = identity — no "centered at 2 = no push").
- Added `test_default_calibration_is_locked` pinning the literal constants.
Suite: **193 passed / 2 skipped**.
- Updated design §8 (calibration-curve + grade-vs-overlay resolved; dark-grade
fix recorded).
### 5. Land + build-vs-next judgment
- Committed on `feature/lock-alteration-calibration`, pushed, opened **PR #8**
(Gitea API via `wgl-gitea-admin` helper), merged to `main` (`638cf58`), deleted
the branch.
- Presented the build-vs-next judgment the prompt asked for. Recommendation
(operator-confirmed via AskUserQuestion): **move to scale-ring navigation next**
— pull in 12 *cheap, true-PD* neutral base clips (NASA cosmos, NOAA deep-sea)
so the ring is demonstrable, build the endless-encoder control + placeholder
zoom transitions in the sim, and **defer** the expensive real multi-strength
flow-stabilized Right re-bake until the ring experience is liked (re-baking
before the clip set is final risks wasted renders). Keep deferring Pi renderer +
serial/firmware.
## Cut state
- `main` @ `638cf58`, clean, pushed. Suite **193 passed / 2 skipped**.
- `DEFAULT_CALIBRATION` locked; dark-grade look fixed; design §8 updated; memory
updated with the session outcome + the new `Next /goal`.
- Deferred (unchanged): Pi renderer, serial/firmware; the expensive Right re-bake.
## Deferred decisions
- **Locked `DEFAULT_CALIBRATION` to unity/linear autonomously.** The operator
asked to "tune by eye" and lock; I made the by-eye judgment (via POC renders +
headless-Chrome sim screenshots) that full tilt is peaceful on every axis after
the dark fix, so no softening of gains was warranted, and locked the unity/linear
values. *Alternative:* soften `mood_gain`/`overlay_gain` (<1.0) so full knob is
gentler — set aside because the evidence showed full tilt is already calm. The
calibration stays fully parameterized, so flipping the feel is a one-line edit +
the lock test if the operator disagrees on review.
- **Dark-grade fix landed in the sim frontend (not the final renderer).** The fix
makes the *simulator* look right for tuning; the Pi/mpv renderer (later slice)
will do proper grading. Recorded in §8.
- The build-vs-next direction was **not** a deferred call — it was put to the
operator directly and confirmed.
## Operator plate
- Calibration is settled and guarded; the long-open session-0006 decision is
closed. The sim look is now likable on the one forest clip across all axes.
- Next session's first move is the `/goal` below (scale-ring navigation).
## Next-session prompt
```
/goal Build scale-ring navigation in the simulator — add 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 scales. Add 12 cheap, true-PD neutral base clips so the ring is demonstrable (NASA/Hubble cosmos + NOAA Ocean Exploration deep sea). Defer the expensive real multi-strength flow-stabilized Right re-bake until the ring is liked; keep deferring the Pi renderer + serial/firmware. Read the sub-project-3-player-progress memory + docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md first.
```
@@ -1,23 +0,0 @@
# Session 0010.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-07T23-09 (PST)
> Type: coding
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0010 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0010.0-TRANSCRIPT-2026-06-07T23-09--<end>.md form at session end.
## Launch prompt
```
Tune the alteration look by eye in the simulator (python simulator/setup_sample_media.py then make sim-local) and LOCK the knob→strength calibration into DEFAULT_CALIBRATION in player/alteration.py (+ a unit test) — settling the open session-0006 calibration decision. While there, judge whether more neutral "scales of nature" base clips + a real multi-strength flow-stabilized Right re-bake are worth doing next vs. moving to scale-ring navigation (endless encoder + AI zoom transitions). Keep deferring Pi renderer + serial/firmware. Read sub-project-3-player-progress memory + docs/superpowers/specs/2026-06-07-reconciled-simulator-alteration-slice-design.md (§8) first.
```
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -0,0 +1,167 @@
# Session 0011.0 — Transcript
> App: human-experience-filter-art
> Type: coding
> Start: 2026-06-07T23-26 (PST) · End: 2026-06-07T23-42 (PST)
> Goal: `/goal` — Build **scale-ring navigation** in the simulator: an endless
> rotary-encoder control (relative, vs the absolute 04 experience knobs)
> walking a closed ring of neutral "scales of nature" clips with placeholder
> AI zoom/warp transitions; add 12 cheap true-PD base clips (NASA cosmos +
> NOAA deep sea) so the ring is demonstrable; defer the expensive
> multi-strength flow-stabilized Right re-bake + the Pi renderer + firmware.
> Outcome: **Sub-project 3 slice 3 — scale-ring navigation — built and MERGED to
> `main` via PR #9** (merge commit `435f201`). New canonical
> `player/ring.py`; `/api/ring` + `/api/ring/advance`; endless-encoder UI;
> cosmos (NASA/Hubble) + abyss (NOAA Ocean Exploration) true-PD scales with
> placeholder media. 215 passed / 2 skipped (+22 new). Deferral honored.
## Plan
Implement the scales-library design §3 (scale navigation & zoom transitions) in
the simulator. The "what" was already approved in session 0008's design, so no
fresh brainstorm: keep the navigation math **Python-canonical** in a new pure
`player/ring.py` (mirrors `player/content.py`/`alteration.py`), expose it via the
simulator API, and have the browser only *render* (play returned transition(s),
settle on the target scale, keep the live knob alteration on top). Add two cheap
**true-PD** scales (cosmos, abyss) so the ring has ≥2 demonstrable scales;
**defer** the expensive multi-strength flow-stabilized Right re-bake (new scales
carry a raw base only) and keep deferring the Pi renderer + serial/firmware per
[[simulator-first-before-hardware]]. Process: short plan doc → TDD pure logic →
API → media → UI → verify (boot + headless drive) → branch → PR → merge.
## Launch prompt
```
/goal Build scale-ring navigation in the simulator — add 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 scales. Add 12 cheap, true-PD neutral base clips so the ring is
demonstrable (NASA/Hubble cosmos + NOAA Ocean Exploration deep sea). Defer the
expensive real multi-strength flow-stabilized Right re-bake until the ring is
liked; keep deferring the Pi renderer + serial/firmware.
```
## Pre-session state
- `main` @ `7cf550a` locally, clean; behind `origin/main` by 1 after the claim
(sessions live in this same repo under `sessions/`); fast-forwarded during init.
- Slice 1 (player core, PR #5) + slice 2 (sim alteration, PR #7) + calibration
lock & dark-grade fix (PR #8) all merged. `DEFAULT_CALIBRATION` locked unity/linear.
- Simulator had ONE neutral clip (forest) wired with the real flow-stabilized
Right variant; no ring, no second scale.
- Ran from the **main clone** (not a nested worktree) — the 0010 resolver gotcha
did **not** recur.
## Turn-by-turn arc
1. **Gate / classify + claim.** `/goal …` → unambiguously a coding session. Ran
`wgl-session-coding-init`; dry-run peek showed no in-flight sessions; claimed
session **0011** (type coding) at `6cf1ae0`. Verified clean pushed `main`
baseline + the `@~/.claude/wiggleverse.md` stub.
2. **Read context.** `[[sub-project-3-player-progress]]` memory + the scales
design (`2026-06-07-scales-library-and-right-axis-pipeline-design.md` §3 ring /
§2 scales / §2.1 strict-PD map). Surveyed `simulator/` (`clips.py`, `app.py`,
`setup_sample_media.py`, static), `player/` (content/controls/alteration/state),
and the `test_clips.py` / `test_simulator_api.py` contracts.
3. **Plan.** Wrote `docs/superpowers/plans/2026-06-07-scale-ring-navigation.md`
(6 tasks). Key design choices: ordering index 0 = largest (cosmos), +1 zooms
inward, both wrap; **one transition clip per edge**, forward inward / reversed
outward (N transitions for N scales); ring math is canonical Python, the browser
only renders; new scales carry a raw base only (Right re-bake deferred).
4. **TDD Task 1 — `player/ring.py`.** Red `tests/test_player_ring.py` (12 cases:
construction/validation, `scale_at` mod, single-step in/out, wrap both ways,
multi-detent chaining, full-loop, no-op, degenerate N≤1) → implemented
`Scale`/`Transition`/`ScaleRing`/`TransitionStep`/`RingMove`/`scale_at`/
`advance_ring` → green.
5. **TDD Task 2 — data layer.** Extended `tests/test_clips.py` → added
`load_ring` + `ring_to_dict` (scales carry their clip title) +
`ring_move_to_dict` (`target_clip_id`) to `simulator/clips.py` → green.
6. **TDD Task 3 — API.** Extended `tests/test_simulator_api.py` → added
`GET /api/ring` (404 if no ring) + `POST /api/ring/advance` to `simulator/app.py`
(loads the ring into `app.state.ring`) → green.
7. **Task 4 — media + manifest.** Rewrote the committed
`simulator/sample_media/manifest.json`: added `cosmos` (NASA/Hubble) + `abyss`
(NOAA Ocean Exploration) clips with true-PD provenance (17 U.S.C. §105 / NOAA),
empty `right_variants` (deferral), illustrative annotations, and the `ring`
section. Broadened `.gitignore` to `simulator/sample_media/**/*.mp4`. Wrote
`simulator/setup_scales_media.py` — synthetic labelled placeholder bases +
per-edge `xfade=zoomin` transitions. First run failed (xfade `-22`: forest base
is 1920×1080@29.97 vs synthetic 1280×720@25) → fixed by normalizing both inputs
(scale/fps/sar/format/trim) before xfade. Re-ran clean: 3 bases + 3 transitions.
8. **Task 5 — frontend.** Rewrote `app.js` for multi-clip active-scale selection
by ring index, an endless-encoder control (⊖ out / in ⊕ buttons + stage
mouse-wheel), current-scale readout, and sequential transition playback that
restores the live alteration on settle; added the control to `index.html` + CSS.
9. **Verify.** Full suite **215 passed / 2 skipped** (+22 new). Booted the sim on
:8011; `/api/ring`, `/api/ring/advance` (incl. the abyss→cosmos wrap), and
`/media` all correct; headless-Chrome initial screenshot showed the cosmos
placeholder + the ring control. Then a **CDP drive** (websockets) called
`advance(1)` ×3, walking cosmos → forest → abyss → (wrap) → cosmos with the
active video reloading `cosmos/base.mp4`; post-nav screenshot confirmed.
10. **Docs.** ROADMAP slice-3 = done (+ folded the ring-transitions into the
offline-pipeline bullet); USER_GUIDE gained the two-step media setup + the
scale-ring gesture.
11. **Ship.** Committed; pushed `feature/scale-ring-navigation`; created **PR #9**
and merged it via the Gitea API helper (`gitea-api.sh git.benstull.org`, merge
commit `435f201`, branch auto-deleted); synced `main`; deleted the local
branch. Post-merge suite green. Updated memory + finalized.
## Cut state
- `main` @ `435f201` (PR #9 merged). New `player/ring.py` + `tests/test_player_ring.py`;
`simulator/clips.py`/`app.py`/`static/*` extended; `simulator/setup_scales_media.py`
+ manifest cosmos/abyss/ring; ROADMAP + USER_GUIDE updated. Full suite 215 passed /
2 skipped. Working tree clean; local + remote feature branch deleted.
- Sub-project 3 = **⏳ in progress**. Slices 13 done; remaining slices (runtime
renderer, serial framing, white-noise, **offline v2v pipeline now incl. real ring
transitions**, catalog model) in ROADMAP §3 + [[sub-project-3-player-progress]].
- Scale-ring media is placeholder (gitignored); repopulate with
`python simulator/setup_sample_media.py && python simulator/setup_scales_media.py`.
## Deferred decisions (operator plate)
Low-confidence / judgment calls made autonomously this session:
1. **Ring shape = cosmos → forest → abyss (3 scales).** The goal asked for 12 new
true-PD clips; added two (cosmos, abyss) to the existing forest for a 3-scale
ring — the minimum that exercises both an interior edge and the wrap seam.
Forest sits between them as a stand-in for the terrestrial/mid scales (the
design's PD soft spot, §2.1); real ordering (orbit/forest/reef/abyss/micro/cosmos)
is an artistic call for when real footage is ingested.
2. **One transition clip per edge, reversed for the outward direction.** Halves the
bake count (N not 2N) and matches the design's "one clip per ring edge." The
browser plays the placeholder forward in both directions for now (true reverse
playback is a renderer concern); the `reversed` flag is carried through the API
so the real pipeline / Pi can honor it.
3. **"True-PD" = provenance recorded, bytes are labelled placeholders.** Media is
gitignored, look-tuning-only, and real ingest is sub-project-2 territory (an
open question in the design). The manifest records the real NASA/NOAA PD
sources + license; `setup_scales_media.py` generates cheap synthetic stand-ins
so the ring is always demonstrable offline. A `--fetch` real-download path was
noted as a future bonus but not built (network-fragile; deterministic offline
verification preferred).
4. **Right axis on the new scales is a no-op (deferral).** New scales have empty
`right_variants`; the `Clip` model already falls back any unauthored strength to
the raw base, so this is a clean structural expression of "defer the expensive
re-bake until the ring is liked."
## Next-session prompt
Tune the ring by eye and decide whether the experience is "liked"; once it is, the
big next build is the offline v2v pipeline (real Right re-bake **and** real ring
transitions) + strict-PD ingest. Read [[sub-project-3-player-progress]] and the
scales design §3/§4 first.
```
/goal Tune the scale-ring experience in the simulator by eye — transition feel/length,
scale ordering, the wrap moment, fast-spin behavior — and decide whether it's "liked."
If liked, begin the offline v2v variant pipeline: the real multi-strength
flow-stabilized Right re-bake per base clip PLUS real first-last-frame-i2v /
infinite-zoom ring transitions to replace the placeholders, and source/ingest the
actual strict-PD scale clips (NASA/NOAA/NPS) via sub-project 2. Keep deferring the
Pi renderer + serial/firmware. Per docs/ROADMAP.md §3 and the scales design §3/§4.
```
Note: PRs on this repo go through the **Gitea API** (`wgl-gitea-admin`
`gitea-api.sh`, host `git.benstull.org`) — no `tea`/`gh` installed. Running from
the main clone (not a nested worktree) avoided the 0010 resolver-ambiguity gotcha.
@@ -0,0 +1,20 @@
# Session 0012.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-08T00-05 (PST)
> Type: coding
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0012 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0012.0-TRANSCRIPT-2026-06-08T00-05--<end>.md form at session end.
## Launch prompt
_(launch prompt not captured at claim time)_
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
+6
View File
@@ -28,5 +28,11 @@
},
"0010": {
"title": ""
},
"0011": {
"title": ""
},
"0012": {
"title": ""
}
}
+33 -1
View File
@@ -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 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"
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,24 @@ 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,
fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD,
)
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():
+60
View File
@@ -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,61 @@ 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). `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": [
{
"edge": st.edge,
"reversed": st.reversed,
"file": st.file,
"to_index": st.to_index,
"blended": st.blended,
}
for st in move.steps
],
}
+13 -4
View File
@@ -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.
+49 -1
View File
@@ -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"}
]
}
}
+108
View File
@@ -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()
+107 -7
View File
@@ -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,82 @@ async function update() {
function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }
// --- Scale ring (endless encoder) ---
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";
vid.style.filter = "none";
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);
vid.playbackRate = 1;
resolve();
};
vid.addEventListener("ended", finish);
vid.play().catch(finish);
setTimeout(finish, blended ? 3000 : 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();
// 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, step.blended);
}
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();
+11 -1
View File
@@ -9,7 +9,7 @@
<body>
<header><h1>Human Experience Filter — Alteration Preview</h1></header>
<main>
<section class="stage">
<section class="stage" id="stage">
<div class="screen">
<video id="vid" loop muted playsinline></video>
<div id="tint"></div>
@@ -32,6 +32,16 @@
</select>
</fieldset>
<fieldset>
<legend>Scale ring (endless encoder)</legend>
<div class="ring-control">
<button type="button" id="zoom-out" title="zoom out (toward cosmos)">⊖ out</button>
<span id="scale-name" class="scale-name"></span>
<button type="button" id="zoom-in" title="zoom in (toward microscopic)">in ⊕</button>
</div>
<p class="hint">Relative, endless — wraps past the smallest back to the largest. Scroll the stage too.</p>
</fieldset>
<fieldset>
<legend>Experience knobs (04)</legend>
<label>Left (analytical) <input type="range" id="left" min="0" max="4" value="0" /></label>
+7
View File
@@ -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; }
+90 -1
View File
@@ -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,85 @@ 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
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
+208
View File
@@ -0,0 +1,208 @@
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
# --- fast-spin: a faster blended pass past a speed threshold (scales design §3) ---
#
# A fast encoder spin batches many detents into one advance() call, so |delta| is
# the input adapter's proxy for spin speed. Past a configured threshold the move
# collapses to a SINGLE blended pass (the arrival-edge clip, played fast) instead
# of chaining N full transitions. The policy is opt-in (default off) because the
# pure function cannot know wall-clock spin speed — the input layer enables it.
def test_fast_spin_disabled_by_default_chains_every_detent():
r = _ring()
move = advance_ring(r, from_index=0, delta=5)
assert move.fast is False
assert len(move.steps) == 5
assert all(not s.blended for s in move.steps)
def test_fast_spin_below_threshold_still_chains():
r = _ring()
move = advance_ring(r, from_index=0, delta=2, fast_spin_threshold=3)
assert move.fast is False
assert len(move.steps) == 2
assert move.steps[0].edge == 0 and move.steps[1].edge == 1
def test_fast_spin_at_or_above_threshold_collapses_to_one_blended_step():
r = _ring()
# 4 inward detents from cosmos(0): 0->1->2->0->1, lands on forest(1),
# crosses the abyss->cosmos seam, last edge crossed is edge 0 (forward).
move = advance_ring(r, from_index=0, delta=4, fast_spin_threshold=3)
assert move.fast is True
assert move.to_index == 1
assert move.wrapped is True
assert move.steps == (
TransitionStep(
edge=0, reversed=False, file="t/cosmos-forest.mp4", to_index=1, blended=True
),
)
def test_fast_spin_outward_blended_step_is_reversed():
r = _ring()
# 4 outward detents from cosmos(0): 0->2->1->0->2, lands on abyss(2);
# outward arrival crosses edge 2 (abyss->cosmos) reversed.
move = advance_ring(r, from_index=0, delta=-4, fast_spin_threshold=3)
assert move.fast is True
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, blended=True
),
)
def test_fast_spin_threshold_below_two_is_ignored():
# A threshold of 0 or 1 would blend even single deliberate steps — treat as off.
r = _ring()
move = advance_ring(r, from_index=0, delta=3, fast_spin_threshold=1)
assert move.fast is False
assert len(move.steps) == 3
def test_fast_spin_noop_on_degenerate_ring():
r = ScaleRing(scales=(Scale("solo", "solo"),), transitions=())
move = advance_ring(r, from_index=0, delta=9, fast_spin_threshold=3)
assert move.fast is False
assert move.steps == ()
+100
View File
@@ -88,3 +88,103 @@ 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_advance_small_delta_chains_not_fast(ring_client):
# 2 detents is below the simulator's fast-spin threshold (3) -> full chain.
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 2})
data = resp.json()
assert data["fast"] is False
assert len(data["steps"]) == 2
def test_ring_advance_fast_spin_collapses_to_one_blended_pass(ring_client):
# A fast spin (4 detents batched) crosses the threshold -> one blended pass.
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 4})
data = resp.json()
assert data["fast"] is True
assert len(data["steps"]) == 1
assert data["steps"][0]["blended"] is True
assert data["to_index"] == 1
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