Compare commits

...

15 Commits

Author SHA1 Message Date
Ben Stull 9cf6324bb4 claim human-experience-filter-art session 0013 (placeholder) + sessions.json entry 2026-06-22 08:35:25 -07:00
Ben Stull 76ccfb19b4 add sessions/0012/SESSION-0012.0-TRANSCRIPT-2026-06-08T00-05--2026-06-08T07-00.md + replace placeholder/variant SESSION-0012.0-TRANSCRIPT-2026-06-08T00-05--INPROGRESS.md 2026-06-08 07:01:27 -07:00
benstull eace3e3467 Merge pull request 'feat(v2v): real multi-strength Right variants for the forest scale (vertical slice)' (#11) from feature/v2v-forest-right-variants into main 2026-06-08 13:58:53 +00:00
Ben Stull 46064912ed feat(sim): forest carries real multi-strength Right variants
Ran the baker for the forest scale: real flow-stabilized painterly Right variants
1-4 now back the dreamlike axis on real Yosemite footage (was: 1 real + 3 ffmpeg
placeholders). By-eye finding this surfaced: sd-turbo's painterly transform is
sharply non-linear in img2img strength (barely registers <0.5, ramps hard through
~0.5-0.65), so the initial linear 0.28-0.58 ramp left levels 1-3 ~identical to
raw. Re-baked with a clustered 0.46/0.52/0.58/0.64 curve -> a real perceptual
ramp: gentle dream haze -> clearly impressionist -> strongest, each notch visibly
dreamier, temporally calm (flow-stabilized).

- manifest: forest right_variants 1-4 all model 'sd-turbo+farneback-flow'
- setup_sample_media.py: now only stages the neutral base; the baker owns the
  Right variants, so re-running it never clobbers a real bake
- docs: USER_GUIDE setup steps + ROADMAP v2v vertical-slice-done note

Suite 228 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:58:29 -07:00
Ben Stull 4e3b326d40 feat(pipeline): forest Right-variant baker (POC flow restyle, productionized)
Right strength curve (level 1-4 -> increasing img2img keyframe strength) is pure
and unit-tested; the restyle engine is a verbatim port of the session-0008 POC
flow_restyle.py (SD img2img keyframes + Farneback optical-flow tweens = calm,
no boiling). torch/diffusers imported lazily so the module + curve test stay light.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:33:04 -07:00
Ben Stull 95d08d5c01 docs(plan): v2v vertical slice — real forest Right variants
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:32:04 -07:00
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
27 changed files with 2080 additions and 84 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
+33 -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,18 @@ 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.
- **Vertical slice done (session 0012):** the Right-variant half is
productionized and run for the **forest** scale — `simulator/bake_right_variants.py`
(the POC flow-restyle algorithm, repo-resident) bakes real strengths 14 on a
by-eye keyframe-strength ramp; forest now carries real Right variants in the
sim, so the dreamlike-axis look is judgeable on real footage. **Still
deferred:** real Right variants for cosmos/abyss (need their real strict-PD
bases sourced first) and the **real i2v ring transitions** (need a 2nd real
base + a generative-video model not in the POC). Plan:
[`2026-06-08-v2v-vertical-slice-forest-right-variants.md`](./superpowers/plans/2026-06-08-v2v-vertical-slice-forest-right-variants.md).
- **Catalog model changes** — audio *source* + "neutral base" vs "altered
variant" flag (sub-project 2 territory, design §13).
+25 -5
View File
@@ -298,12 +298,22 @@ 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 # stage the forest neutral base
python -m simulator.bake_right_variants --scale forest # real Right variants (SD on MPS, ~11 min)
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
flow-stabilized Right restyle — and generates placeholder intermediate Right
strengths. The `.mp4` binaries are gitignored.
`setup_sample_media.py` stages the forest neutral base from the session-0008 POC
artifacts (`~/hef-poc/out/neutral.mp4`). `bake_right_variants.py` then produces
the **real** flow-stabilized painterly Right variants (strengths 14) for the
forest scale — SD img2img keyframes + Farneback optical-flow tweens on Apple MPS,
the temporally-calm POC algorithm, productionized (scales design §1/§4). The
keyframe-strength ramp per Right level is by-eye tunable in
`simulator/bake_right_variants.py`. `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 — these scales carry a raw base only (no real
Right variants yet; the baker can extend to them once their real footage is
sourced). The `.mp4` binaries are gitignored.
**Run it (Docker):**
@@ -320,6 +330,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.
@@ -0,0 +1,239 @@
# v2v Vertical Slice — Real Forest Right Variants Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Produce the first *real* (not placeholder) multi-strength Right-axis
restyle media for the simulator — flow-stabilized painterly variants of the
forest scale at Right strengths 14 — so the dreamlike-axis *look* can finally be
judged by eye on real footage, breaking the "can't judge the ring liked without
real content" chicken-and-egg (operator chose a vertical slice, session 0012).
**Architecture:** Productionize the proven session-0008 POC restyle algorithm
(`~/hef-poc/flow_restyle.py`) into a repo-resident offline baker,
`simulator/bake_right_variants.py`, a sibling of the existing
`setup_sample_media.py` / `setup_scales_media.py` media-generation scripts. The
algorithm is unchanged (extract frames → SD img2img on keyframes → Farneback
optical-flow warp + light refine on tweens → reassemble), which sessions 0008
proved is temporally calm (no boiling). The one new idea is a **strength curve**:
Right level 14 maps to an increasing keyframe img2img `strength`, so the four
discrete pre-baked variants form a subtle→strong dreamlike ramp. Only the curve
helper is unit-tested; the rendered media is verified by eye (screenshots), as
the other media-setup scripts are.
**Tech Stack:** Python, diffusers `AutoPipelineForImage2Image` + `stabilityai/sd-turbo`
(cached, 4.8 G) on Apple MPS (torch 2.12, available in the project `.venv`),
OpenCV Farneback optical flow, imageio-ffmpeg. ~2.7 min/clip × 4 ≈ ~11 min local
compute.
**Scope note — what this slice is NOT:** the *real i2v ring transition* half of
the v2v build is deferred: it needs a second real scale base (cosmos/abyss are
still synthetic placeholders) plus a generative-video model not in the POC, and
real strict-PD footage sourcing (NASA/NOAA, sub-project 2). This slice does the
Right-variant half, which is fully achievable now (forest has a real base).
---
### Task 1: The Right strength curve (pure, testable)
**Files:**
- Create: `simulator/bake_right_variants.py`
- Test: `tests/test_bake_right_variants.py`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_bake_right_variants.py
import pytest
from simulator.bake_right_variants import right_strength_params, RIGHT_LEVELS
def test_levels_are_one_through_four():
assert RIGHT_LEVELS == (1, 2, 3, 4)
def test_strength_increases_with_level():
keys = [right_strength_params(l)[0] for l in RIGHT_LEVELS]
assert keys == sorted(keys) # monotonic ramp
assert keys[0] < keys[-1] # subtle -> strong
def test_tween_strength_is_a_fraction_of_key_strength():
for l in RIGHT_LEVELS:
key, tween = right_strength_params(l)
assert 0.0 < tween < key # tween is a light refine only
def test_rejects_out_of_range_level():
with pytest.raises(ValueError):
right_strength_params(0)
with pytest.raises(ValueError):
right_strength_params(5)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `.venv/bin/python -m pytest tests/test_bake_right_variants.py -q`
Expected: FAIL (ModuleNotFoundError / ImportError — module not created yet)
- [ ] **Step 3: Write minimal implementation (curve only, no torch import at module load)**
```python
# simulator/bake_right_variants.py (top of file)
"""Offline baker: real flow-stabilized painterly Right-axis variants (scales
design §1/§4). Productionizes the session-0008 POC (~/hef-poc/flow_restyle.py):
SD img2img keyframes + Farneback optical-flow tweens = temporally calm restyle.
Right level -> keyframe img2img strength ramp (subtle -> strong); tween strength
is a light refine fraction of the keyframe strength. Curve is by-eye tunable.
Usage: .venv/bin/python -m simulator.bake_right_variants [--scale forest]
Requires: torch+MPS, diffusers, sd-turbo (cached), opencv, imageio-ffmpeg.
"""
from __future__ import annotations
RIGHT_LEVELS = (1, 2, 3, 4)
# Keyframe img2img strength per Right level (by-eye ramp). Level 4 ~ the POC's
# proven 0.5-0.58 painterly look; level 1 is a barely-there dream haze.
_KEY_STRENGTH = {1: 0.28, 2: 0.38, 3: 0.48, 4: 0.58}
_TWEEN_RATIO = 0.36 # POC ratio (0.18 / 0.5): tweens are a light refine only
def right_strength_params(level: int) -> tuple[float, float]:
"""(keyframe_strength, tween_strength) for a Right level in RIGHT_LEVELS."""
if level not in _KEY_STRENGTH:
raise ValueError(f"Right level must be one of {RIGHT_LEVELS}, got {level}")
key = _KEY_STRENGTH[level]
return key, round(key * _TWEEN_RATIO, 4)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `.venv/bin/python -m pytest tests/test_bake_right_variants.py -q`
Expected: PASS (4 passed)
- [ ] **Step 5: Commit**
```bash
git add simulator/bake_right_variants.py tests/test_bake_right_variants.py
git commit -m "feat(pipeline): Right strength curve for the v2v variant baker"
```
---
### Task 2: The baker (flow restyle, productionized)
**Files:**
- Modify: `simulator/bake_right_variants.py`
No unit test for the render itself (non-trivial ML output; verified by eye in
Task 4). The pure curve is already covered by Task 1.
- [ ] **Step 1: Add the restyle engine + per-level bake, porting the POC algorithm verbatim**
Port `extract`, `warp`, and the keyframe/tween loop from
`~/hef-poc/flow_restyle.py` unchanged (it is the approved calm algorithm). Wrap
it as `restyle_clip(src, out, key_strength, tween_strength, *, fps=12.0, keyint=24)`
and add a `bake_scale(scale="forest")` that loops `RIGHT_LEVELS`, calling
`restyle_clip` with `right_strength_params(level)` and writing
`simulator/sample_media/<scale>/right<level>.mp4`. Load the SD pipeline ONCE and
reuse it across levels. `PROMPT`/`NEG`/Farneback params/seed = the POC's.
Source base = `simulator/sample_media/<scale>/base.mp4` (forest = the real
Yosemite POC clip). `main()` parses `--scale` (default `forest`), `--fps`,
`--keyint` and calls `bake_scale`.
- [ ] **Step 2: Smoke-check the module imports and the curve still passes**
Run: `.venv/bin/python -m pytest tests/test_bake_right_variants.py -q`
Expected: PASS (torch imported lazily inside the bake funcs, not at module load,
so the test stays fast and import-safe)
- [ ] **Step 3: Commit**
```bash
git add simulator/bake_right_variants.py
git commit -m "feat(pipeline): forest Right-variant baker (POC flow restyle, productionized)"
```
---
### Task 3: Run the bake (real media, ~11 min)
**Files:**
- Writes (gitignored): `simulator/sample_media/forest/right{1,2,3,4}.mp4`
- [ ] **Step 1: Run the baker**
Run: `.venv/bin/python -m simulator.bake_right_variants --scale forest`
Expected: 4 clips written; per-level timing printed; ~11 min total. Replaces the
3 placeholder strengths (13) and the single real strength (4) with a consistent
real ramp.
- [ ] **Step 2: Confirm the outputs exist and are real video**
Run: `ls -la simulator/sample_media/forest/right*.mp4`
Expected: right14.mp4 present, each a multi-MB H.264 clip.
(No commit — media is gitignored; it is reproduced by the baker.)
---
### Task 4: Wire the manifest + verify by eye
**Files:**
- Modify: `simulator/sample_media/manifest.json` (forest `right_variants` models)
- Modify: `docs/USER_GUIDE.md` (note real forest Right variants + the baker)
- Modify: `docs/ROADMAP.md` (v2v slice progress)
- [ ] **Step 1: Update the forest variant models in the manifest**
Set every forest `right_variants` entry (14) `model` to
`"sd-turbo+farneback-flow"` (drop the `"placeholder"` labels) — they are all real
bakes now.
- [ ] **Step 2: Boot the sim and drive the Right axis across 0→4 on forest**
Run: `.venv/bin/python -m uvicorn simulator.app:app --port 8013` (background),
navigate to the forest scale, and capture headless screenshots at Right 0/1/2/3/4.
Expected: a coherent subtle→strong painterly ramp on the real Yosemite footage,
temporally calm (no boiling), grade/overlay still compose on top.
- [ ] **Step 3: Update docs**
USER_GUIDE: note forest now carries real flow-stabilized Right variants generated
by `simulator/bake_right_variants.py`. ROADMAP: mark the v2v Right-variant vertical
slice done; note the i2v ring-transition half still deferred (needs a 2nd real
base + video model).
- [ ] **Step 4: Run the full suite**
Run: `.venv/bin/python -m pytest -q`
Expected: PASS (existing + Task-1 curve tests).
- [ ] **Step 5: Commit**
```bash
git add simulator/sample_media/manifest.json docs/USER_GUIDE.md docs/ROADMAP.md
git commit -m "feat(sim): forest carries real multi-strength Right variants; docs"
```
---
## Self-Review
**Spec coverage:** scales design §1 (local restyle pipeline) + §4 (economics:
local bake) → Tasks 23 productionize + run it. The operator's vertical-slice
choice (real Right variants for one scale to judge the look) → Tasks 34. The
deferred i2v transition half is explicitly scoped out (noted in header + Task 4).
The strength curve (the one new design element) → Task 1.
**Placeholder scan:** none — the baker algorithm is a verbatim port of the
existing `~/hef-poc/flow_restyle.py` (cited), the curve is concrete constants,
commands are exact.
**Type consistency:** `right_strength_params(level) -> (key, tween)` defined in
Task 1, consumed by `bake_scale` in Task 2. `RIGHT_LEVELS` shared. Output paths
`simulator/sample_media/forest/right<level>.mp4` consistent with the manifest's
existing forest `right_variants` map (Task 4).
@@ -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,113 @@
# Session 0012.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-08T00-05 (PST)
> End: 2026-06-08T07-00 (PST)
> Type: coding
> Status: **FINALIZED.**
> Outcome: two PRs merged to `main` — **#10 fast-spin blended pass** + **#11 v2v
> vertical slice (real forest Right variants)**. Suite 228 passed / 2 skipped.
## Launch prompt
`/goal next` → resumed the stored Next /goal from memory: **tune the ring
experience by eye (transition feel/length, scale ordering, wrap moment,
fast-spin behavior) and decide whether it's "liked"; once liked, the next big
build is the offline v2v variant pipeline.**
## Plan
Goal frontier = tune/judge the scale ring (sub-project 3, scales design §3).
**Findings (early in session, after driving the sim + ring API):**
- **Ring mechanics are sound & correct** — single step, wrap (correct reversed
seam edge), multi-detent chaining, full-loop all verified via the canonical
`/api/ring` + `/api/ring/advance`. UI renders the endless-encoder control +
live RenderPlan readout.
- **Aesthetic "feel/liked" is gated on real footage.** The current scale bases
are near-black solid-color placeholders with text labels; transitions are
ffmpeg `zoomin`-xfades. Transition feel / scale ordering / wrap-moment cannot
be meaningfully judged by eye on these — that is exactly what the deferred v2v
build (real strict-PD footage + real first-last-frame i2v transitions)
produces. Placeholder by-eye tuning has a hard ceiling here.
- **Fast-spin is the one genuine, footage-independent gap.** A multi-detent spin
returns N steps and the browser plays ALL N full transitions sequentially
(+5 ≈ 5 transitions ≈ 1215 s of placeholder morphs). Design §3 explicitly
anticipates this: "transitions chain, **or past a speed threshold a faster
blended pass is used**." Not implemented.
**This session's work:**
1. Implement the design §3 fast-spin policy (Python-canonical step-reduction +
thin frontend), TDD. The one tuning dimension that is real, footage-
independent, and named in the goal.
2. Verify (suite + drive the ring + screenshot), update USER_GUIDE + design
note + memory.
3. Surface the genuine **"liked?" → greenlight v2v build** decision to the
operator with this honest assessment + recommendation (the aesthetic sign-off
is the artist's call; v2v also needs real footage sourcing, so it is the
natural next session).
## 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._
- **Fast-spin policy shape (PR #10).** Made fast-spin opt-in (`fast_spin_threshold`,
default off) rather than default-on, so the pure `advance_ring` keeps its
complete-chain contract + all existing tests, and the input layer (sim/firmware)
— which actually knows wall-clock spin speed — enables it. Used `abs(delta)` as
the spin-speed proxy (a spin batches detents into one advance call). Threshold
default = 3 and the sim's blended playback rate = 2.5× are by-eye guesses, easily
tuned. Confidence: high on the architecture, medium on the exact threshold/rate.
- **Right strength curve (v2v slice).** Picked the keyframe-strength ramp per Right
level by eye against the first real bake. First attempt was a linear 0.280.58 —
but sd-turbo's painterly effect is sharply non-linear (barely registers <0.5,
ramps hard through ~0.50.65), so levels 13 looked ~identical to raw. Re-baked
with a clustered curve (0.46/0.52/0.58/0.64) for real perceptual progression.
This is fundamentally an aesthetic + ML-param tuning loop (prompt/steps/strength)
that wants the operator's eye; the curve is one-line tunable in
`simulator/bake_right_variants.py` + re-bake. Confidence: medium — a sound,
usable first real ramp, but the operator will likely want to fine-tune it (and
possibly the prompt) before the look is locked.
## Session arc
1. **Opened** via `/goal next` → routed to a coding session, claimed ID 0012,
synced the clean `main` baseline (transcripts live in this same repo under
`sessions/`).
2. **Oriented** on `player/ring.py`, the simulator, the manifest, and scales
design §3/§4. Booted the sim and drove the ring via the canonical API +
a headless screenshot.
3. **Diagnosed** the goal honestly: ring *mechanics* are sound; the *aesthetic*
judgment the goal asks for is gated on real footage (placeholders can't show
it). The one footage-independent gap was **fast-spin**.
4. **Built fast-spin** (TDD) — `advance_ring` opt-in `fast_spin_threshold`,
serializer + API + thin frontend. Verified live (slow chains, fast collapses,
correct wrap/arrival). PR #10, merged.
5. **Surfaced the "liked?" gate** to the operator with the honest assessment
(`AskUserQuestion`). Operator chose **vertical-slice the v2v build** — get
real content for one scale to break the chicken-and-egg.
6. **Built the v2v vertical slice** — wrote a plan, productionized the POC
flow-restyle into `simulator/bake_right_variants.py` (curve TDD'd), baked real
forest Right variants (~11 min MPS). First ramp was back-loaded (linear
0.280.58 → levels 13 looked raw); re-baked with a clustered 0.460.64 curve;
verified the perceptual ramp frame-by-frame. PR #11, merged.
7. **Finalized** — memory updated, deferred decisions recorded, transcript
published.
## Outcome & next session
The goal's "**decide if liked**" is now the operator's to make *with real footage
in hand*: `make sim` → forest scale → sweep Right 0→4 to judge the dreamlike axis
on real Yosemite footage. Mechanics (incl. fast-spin) are complete.
**Deferred (next):** fine-tune the forest curve/prompt if the look isn't liked;
source real strict-PD bases for cosmos/abyss (sub-project 2 ingest) so their Right
variants can bake; build the real **i2v ring transitions** (needs a generative
video model + two real adjacent bases). Pi renderer + serial/firmware stay
deferred per the simulator-first directive.
```
/goal Judge the now-real forest Right (dreamlike) axis by eye (make sim → forest → sweep Right 0→4) and decide if the look + strength ramp is liked; fine-tune the curve/prompt in simulator/bake_right_variants.py + re-bake if not. Then continue the v2v build: source real strict-PD bases for cosmos/abyss (sub-project 2) and build the real i2v ring transitions. Per docs/ROADMAP.md + scales design §1/§3/§4.
```
@@ -0,0 +1,22 @@
# Session 0013.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-22T08-34 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: f0a3044a-e9cb-467e-991f-f1ce618fc418
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0013 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0013.0-TRANSCRIPT-2026-06-22T08-34--<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._
+9
View File
@@ -28,5 +28,14 @@
},
"0010": {
"title": ""
},
"0011": {
"title": ""
},
"0012": {
"title": ""
},
"0013": {
"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():
+193
View File
@@ -0,0 +1,193 @@
"""Offline baker: real flow-stabilized painterly Right-axis variants (scales
design §1/§4).
Productionizes the session-0008 POC (``~/hef-poc/flow_restyle.py``): SD img2img
on KEYFRAMES + Farneback optical-flow warp on in-between frames = a temporally
calm painterly restyle (no per-frame "boiling" — the look the operator approved
in 0008). The one new idea on top of the POC is a **strength curve**: each Right
level 1-4 maps to an increasing keyframe img2img ``strength`` so the four
discrete pre-baked variants form a subtle -> strong dreamlike ramp. Level 0 is
always the raw base (handled by the runtime, not baked here).
The curve (``right_strength_params``) is pure + unit-tested; the rendered media
is verified by eye (like the other ``simulator/setup_*_media.py`` scripts). torch
/ diffusers are imported lazily inside the bake functions so importing this
module (e.g. for the curve test) is fast and dependency-light.
Usage:
.venv/bin/python -m simulator.bake_right_variants [--scale forest]
[--fps 12] [--keyint 24]
Requires: torch + Apple MPS, diffusers, ``stabilityai/sd-turbo`` (cached),
opencv-python, imageio-ffmpeg. ~2.7 min/clip x 4 levels ~= ~11 min local compute.
"""
from __future__ import annotations
import argparse
import glob
import os
import subprocess
import tempfile
import time
from pathlib import Path
MEDIA = Path(__file__).parent / "sample_media"
RIGHT_LEVELS = (1, 2, 3, 4)
# Keyframe img2img strength per Right level (by-eye ramp). sd-turbo's painterly
# transformation is sharply NON-LINEAR in strength — it barely registers below
# ~0.5 and ramps hard through ~0.5-0.65 (verified by eye, session 0012: a linear
# 0.28-0.58 ramp left levels 1-3 ~indistinguishable from raw). So the curve is
# clustered in that active band for real perceptual progression: a gentle dream
# haze (L1) -> clearly painterly (L3, ~the POC's proven look) -> strongest (L4).
# Still by-eye tunable — re-run the baker after editing.
_KEY_STRENGTH = {1: 0.46, 2: 0.52, 3: 0.58, 4: 0.64}
_TWEEN_RATIO = 0.36 # POC ratio (0.18 / 0.5): tweens are a light refine only.
# The restyle target (matches the POC spike that the operator approved).
PROMPT = (
"impressionist oil painting, dreamlike, soft painterly brushstrokes, "
"ethereal, luminous, fine art"
)
NEG = "text, watermark, frame, border, ugly, deformed, lowres"
SEED = 42
def right_strength_params(level: int) -> tuple[float, float]:
"""(keyframe_strength, tween_strength) for a Right level in RIGHT_LEVELS."""
if level not in _KEY_STRENGTH:
raise ValueError(f"Right level must be one of {RIGHT_LEVELS}, got {level}")
key = _KEY_STRENGTH[level]
return key, round(key * _TWEEN_RATIO, 4)
# --- the restyle engine (verbatim port of ~/hef-poc/flow_restyle.py) ---
def _ffmpeg() -> str:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def _run(cmd: list[str]) -> None:
subprocess.run(cmd, check=True, capture_output=True)
def _extract(ff: str, src: str, d: str, fps: float) -> list[str]:
_run([ff, "-hide_banner", "-v", "error", "-i", src, "-vf", f"fps={fps}",
os.path.join(d, "f_%05d.png"), "-y"])
return sorted(glob.glob(os.path.join(d, "f_*.png")))
def _warp(prev_bgr, src_prev_gray, src_cur_gray):
"""Warp the previous stylized frame to align with the current source frame
via Farneback optical flow — this is what kills the per-frame flicker."""
import cv2
import numpy as np
flow = cv2.calcOpticalFlowFarneback(
src_prev_gray, src_cur_gray, None,
pyr_scale=0.5, levels=3, winsize=21, iterations=3,
poly_n=7, poly_sigma=1.5, flags=0)
h, w = src_cur_gray.shape
gx, gy = np.meshgrid(np.arange(w), np.arange(h))
mx = (gx + flow[..., 0]).astype(np.float32)
my = (gy + flow[..., 1]).astype(np.float32)
return cv2.remap(prev_bgr, mx, my, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
def _load_pipe(model: str):
"""Load the SD img2img pipeline once (reused across all levels)."""
import torch
from diffusers import AutoPipelineForImage2Image
dev = "mps" if torch.backends.mps.is_available() else "cpu"
pipe = AutoPipelineForImage2Image.from_pretrained(
model, torch_dtype=torch.float16 if dev == "mps" else torch.float32,
safety_checker=None).to(dev)
return pipe, dev
def restyle_clip(pipe, dev, src: str, out: str, key_strength: float,
tween_strength: float, *, fps: float = 12.0, keyint: int = 24) -> None:
"""Restyle one clip: SD img2img keyframes + flow-warped light-refine tweens."""
import cv2
import numpy as np
import torch
from PIL import Image
ff = _ffmpeg()
gen = torch.Generator(device=dev).manual_seed(SEED)
def img2img(pil, strength):
steps = max(2, int(np.ceil(1.0 / max(strength, 0.05))) + 1)
return pipe(prompt=PROMPT, negative_prompt=NEG, image=pil,
strength=strength, guidance_scale=0.0,
num_inference_steps=steps, generator=gen).images[0]
with tempfile.TemporaryDirectory() as d:
frames = _extract(ff, src, d, fps)
print(f" [frames] {len(frames)} key_s={key_strength} tween_s={tween_strength}")
prev_styled = None # BGR uint8
prev_src_gray = None
t0 = time.time()
for i, fp in enumerate(frames):
src_bgr = cv2.imread(fp)
h0, w0 = src_bgr.shape[:2]
scale = 768 / max(h0, w0)
wh = (int(w0 * scale) // 8 * 8, int(h0 * scale) // 8 * 8)
src_small = cv2.resize(src_bgr, wh)
src_gray = cv2.cvtColor(src_small, cv2.COLOR_BGR2GRAY)
if i % keyint == 0 or prev_styled is None:
pil = Image.fromarray(cv2.cvtColor(src_small, cv2.COLOR_BGR2RGB))
out_img = img2img(pil, key_strength)
else:
warped = _warp(prev_styled, prev_src_gray, src_gray)
pil = Image.fromarray(cv2.cvtColor(warped, cv2.COLOR_BGR2RGB))
out_img = img2img(pil, tween_strength) # light refine only
styled = cv2.cvtColor(np.array(out_img), cv2.COLOR_RGB2BGR)
prev_styled, prev_src_gray = styled, src_gray
cv2.imwrite(os.path.join(d, f"s_{i+1:05d}.png"),
cv2.resize(styled, (w0, h0)))
if i % 12 == 0:
print(f" frame {i+1}/{len(frames)} {time.time()-t0:.1f}s")
_run([ff, "-hide_banner", "-v", "error", "-framerate", str(fps),
"-i", os.path.join(d, "s_%05d.png"), "-c:v", "libx264", "-crf", "20",
"-preset", "medium", "-pix_fmt", "yuv420p", out, "-y"])
print(f" [done] {time.time()-t0:.1f}s -> {out}")
def bake_scale(scale: str = "forest", *, fps: float = 12.0, keyint: int = 24,
model: str = "stabilityai/sd-turbo") -> None:
"""Bake all RIGHT_LEVELS variants for one scale's base clip."""
base = MEDIA / scale / "base.mp4"
if not base.exists():
raise FileNotFoundError(
f"no base clip at {base} — run the media-setup scripts first")
print(f"[bake] scale={scale} base={base}")
pipe, dev = _load_pipe(model)
print(f"[device] {dev}")
for level in RIGHT_LEVELS:
key_s, tween_s = right_strength_params(level)
out = MEDIA / scale / f"right{level}.mp4"
print(f" [right {level}] -> {out}")
restyle_clip(pipe, dev, str(base), str(out), key_s, tween_s,
fps=fps, keyint=keyint)
print(f"[bake] {scale} done — {len(RIGHT_LEVELS)} real Right variants")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--scale", default="forest")
ap.add_argument("--fps", type=float, default=12.0)
ap.add_argument("--keyint", type=int, default=24)
a = ap.parse_args()
bake_scale(a.scale, fps=a.fps, keyint=a.keyint)
if __name__ == "__main__":
main()
+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.
+52 -4
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)",
@@ -7,9 +25,9 @@
"license": "poc-sample (look-tuning only; not shipped content)",
"source": "hef-poc/out/neutral.mp4",
"right_variants": {
"1": {"file": "forest/right1.mp4", "model": "placeholder"},
"2": {"file": "forest/right2.mp4", "model": "placeholder"},
"3": {"file": "forest/right3.mp4", "model": "placeholder"},
"1": {"file": "forest/right1.mp4", "model": "sd-turbo+farneback-flow"},
"2": {"file": "forest/right2.mp4", "model": "sd-turbo+farneback-flow"},
"3": {"file": "forest/right3.mp4", "model": "sd-turbo+farneback-flow"},
"4": {"file": "forest/right4.mp4", "model": "sd-turbo+farneback-flow"}
},
"annotations": [
@@ -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"}
]
}
}
+13 -32
View File
@@ -1,52 +1,33 @@
"""Populate simulator/sample_media/forest/ from the session-0008 POC artifacts.
"""Stage the forest scale's neutral BASE clip from the session-0008 POC artifacts.
Copies the real neutral base + the real flow-stabilized Right restyle out of
~/hef-poc/out/, and generates placeholder intermediate Right strengths (1..3) by
blending the base toward the real restyle with ffmpeg. The media binaries are
gitignored; only the manifest is committed. Sample footage is for look-tuning
only, not shipped content.
Copies the real neutral Yosemite base out of ~/hef-poc/out/ into
simulator/sample_media/forest/. The real multi-strength Right variants are then
produced by the offline baker (`simulator/bake_right_variants.py`) — this script
no longer generates placeholder Right strengths, so re-running it never clobbers
a real bake. The media binaries are gitignored; only the manifest is committed.
Sample footage is for look-tuning only, not shipped content.
Usage: python simulator/setup_sample_media.py
Requires: ffmpeg on PATH (or `pip install imageio-ffmpeg`), and ~/hef-poc/out/.
Usage:
python simulator/setup_sample_media.py # stage the base
python -m simulator.bake_right_variants --scale forest # then bake variants
Requires: ~/hef-poc/out/neutral.mp4 (the POC neutral base).
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
POC = Path.home() / "hef-poc" / "out"
DEST = Path(__file__).parent / "sample_media" / "forest"
def _ffmpeg() -> str:
if shutil.which("ffmpeg"):
return "ffmpeg"
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def main() -> None:
DEST.mkdir(parents=True, exist_ok=True)
base = DEST / "base.mp4"
right4 = DEST / "right4.mp4"
shutil.copyfile(POC / "neutral.mp4", base)
shutil.copyfile(POC / "right_flow.mp4", right4)
ff = _ffmpeg()
# Placeholder strengths 1..3: opacity-blend base toward the real restyle.
for strength, alpha in ((1, 0.25), (2, 0.5), (3, 0.75)):
out = DEST / f"right{strength}.mp4"
subprocess.run(
[ff, "-y", "-i", str(base), "-i", str(right4),
"-filter_complex",
f"[1:v]format=yuva444p,colorchannelmixer=aa={alpha}[top];"
f"[0:v][top]overlay=shortest=1[v]",
"-map", "[v]", "-an", str(out)],
check=True,
)
print(f"generated {out.name} (alpha {alpha})")
print(f"sample media ready in {DEST}")
print(f"staged {base} (real POC neutral base)")
print("next: python -m simulator.bake_right_variants --scale forest")
if __name__ == "__main__":
+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; }
+26
View File
@@ -0,0 +1,26 @@
import pytest
from simulator.bake_right_variants import RIGHT_LEVELS, right_strength_params
def test_levels_are_one_through_four():
assert RIGHT_LEVELS == (1, 2, 3, 4)
def test_strength_increases_with_level():
keys = [right_strength_params(l)[0] for l in RIGHT_LEVELS]
assert keys == sorted(keys) # monotonic ramp
assert keys[0] < keys[-1] # subtle -> strong
def test_tween_strength_is_a_fraction_of_key_strength():
for l in RIGHT_LEVELS:
key, tween = right_strength_params(l)
assert 0.0 < tween < key # tween is a light refine only
def test_rejects_out_of_range_level():
with pytest.raises(ValueError):
right_strength_params(0)
with pytest.raises(ValueError):
right_strength_params(5)
+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