Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cf6324bb4 | |||
| 76ccfb19b4 | |||
| eace3e3467 | |||
| 46064912ed | |||
| 4e3b326d40 | |||
| 95d08d5c01 | |||
| 49b1f0a549 | |||
| f11b9ee72d | |||
| e975ab1f5a | |||
| ef79da0fde | |||
| 435f201534 | |||
| 7a50ae41bb | |||
| 6cf1ae08ff | |||
| 7cf550ac60 | |||
| 638cf5808f |
+3
-2
@@ -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
@@ -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 0–4 experience knobs) — that walks a **closed ring** of neutral
|
||||
"scales of nature" clips, with placeholder AI zoom/warp **transitions** between
|
||||
adjacent scales and a small→large **wrap** (the infinite-zoom payoff, scales
|
||||
design §3). The navigation math is canonical Python (`player/ring.py`:
|
||||
`ScaleRing` + `advance_ring`, one transition clip per edge, played forward
|
||||
inward / reversed outward); the sim exposes `GET /api/ring` + `POST
|
||||
/api/ring/advance` and the browser only plays the returned transition(s) then
|
||||
settles on the target scale, keeping the live knob alteration on top. Two cheap
|
||||
**true-PD** scales were added so the ring is demonstrable — `cosmos` (NASA/Hubble)
|
||||
and `abyss` (NOAA Ocean Exploration), provenance recorded in the manifest, bytes
|
||||
generated as labelled placeholders by `simulator/setup_scales_media.py`. The
|
||||
expensive real multi-strength flow-stabilized Right re-bake is **deferred** (new
|
||||
scales carry a raw base only) until the ring is liked. Plan:
|
||||
[`2026-06-07-scale-ring-navigation.md`](./superpowers/plans/2026-06-07-scale-ring-navigation.md).
|
||||
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 1–4 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
@@ -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 1–4) 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 0–4 knobs: each step plays a
|
||||
short placeholder zoom/warp **transition**, then settles on the next scale, with
|
||||
the current knob alteration still applied. The current scale is named beside the
|
||||
buttons (`name (i/N)`). 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 (0–4):**
|
||||
- **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 0–4 experience knobs) that walks a *closed
|
||||
ring* of neutral "scales of nature" clips, with placeholder AI zoom/warp
|
||||
transitions between adjacent scales. Add 1–2 cheap, true-PD neutral base clips
|
||||
(NASA/Hubble cosmos + NOAA Ocean Exploration deep sea) so the ring is
|
||||
demonstrable. **Defer** the expensive real multi-strength flow-stabilized Right
|
||||
re-bake until the ring is liked; keep deferring the Pi renderer + serial/firmware.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md` §3 (scale navigation & zoom transitions), §2 (scales library), §2.1 (strict-PD sourcing map).
|
||||
|
||||
**Architecture:** Python stays the single source of truth. The ring *navigation
|
||||
math* — step, wrap, which transition clip to play and in which direction — lives
|
||||
in a new canonical pure module `player/ring.py` (mirrors `player/content.py` /
|
||||
`player/alteration.py`: frozen dataclasses, pure functions). The simulator's data
|
||||
layer (`simulator/clips.py`) parses a `ring` section of the manifest into the
|
||||
ring types; the API exposes the ring and a stateless `advance` move; the browser
|
||||
holds the current ring index and only *renders* (plays the returned transition
|
||||
file, then the target scale's clip, with the existing per-knob alteration on top).
|
||||
|
||||
**Ordering convention (canonical):** ring index 0 = the *largest* scale (cosmos);
|
||||
increasing index zooms **inward** toward the smallest. `+1` detent = zoom in,
|
||||
`-1` = zoom out — both wrap (past the smallest wraps to the largest, the
|
||||
infinite-zoom payoff, §3). Edge `i` connects `scales[i] → scales[(i+1) % N]`; the
|
||||
last edge is the small→large wrap seam. One transition clip per edge plays
|
||||
**forward** when zooming inward and **reversed** when zooming outward, so N scales
|
||||
need only N transition clips (§3: "one clip per ring edge").
|
||||
|
||||
**Deferral, made explicit:** the new cosmos/abyss scale clips carry a raw base
|
||||
only (no pre-baked Right variants); the `Clip` model already falls back any
|
||||
unauthored strength to the raw base, so Right on a new scale is a no-op until the
|
||||
multi-strength flow-stabilized re-bake happens (deliberately deferred). Forest
|
||||
keeps its existing POC variants.
|
||||
|
||||
**Tech Stack:** Python 3.13, FastAPI + pydantic, pytest, frozen dataclasses;
|
||||
vanilla JS + CSS/SVG for the browser; ffmpeg (system or `imageio-ffmpeg`) for
|
||||
placeholder media.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Engine (create):**
|
||||
- `player/ring.py` — `Scale`, `Transition`, `ScaleRing`, `RingMove`,
|
||||
`TransitionStep`, `scale_at`, `advance_ring`. Pure logic, no I/O/JSON.
|
||||
|
||||
**Simulator (modify):**
|
||||
- `simulator/clips.py` — add `load_ring(manifest)` building a `ScaleRing` from the
|
||||
manifest `ring` section + `ring_to_dict` / `ring_move_to_dict` serializers.
|
||||
- `simulator/app.py` — `GET /api/ring`, `POST /api/ring/advance`.
|
||||
- `simulator/static/{index.html,app.js,style.css}` — endless-encoder control
|
||||
(zoom-in/zoom-out buttons + mouse-wheel on the stage), current-scale readout,
|
||||
multi-clip active-clip selection, transition playback.
|
||||
- `simulator/sample_media/manifest.json` (committed) — add cosmos + abyss clips
|
||||
and the `ring` section; media binaries stay gitignored.
|
||||
- `simulator/setup_sample_media.py` (or sibling) — generate cheap cosmos/abyss
|
||||
base placeholders + per-edge transition placeholders (ffmpeg); record true-PD
|
||||
provenance in the manifest. Real PD fetch is a best-effort `--fetch` bonus.
|
||||
|
||||
**Tests (create/extend):**
|
||||
- `tests/test_player_ring.py` (create) — ring navigation logic (step, chain, wrap,
|
||||
direction/reverse, degenerate N≤1).
|
||||
- `tests/test_clips.py` (extend) — `load_ring`, serializers.
|
||||
- `tests/test_simulator_api.py` (extend) — `/api/ring`, `/api/ring/advance`.
|
||||
|
||||
**Docs:**
|
||||
- `docs/ROADMAP.md` — scale-ring status.
|
||||
- `docs/USER_GUIDE.md` — simulator "scale ring" gesture (if present).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Engine — `player/ring.py` (pure ring navigation)
|
||||
- [ ] TDD `Scale`/`Transition`/`ScaleRing` construction + validation (N edges for N scales; ≥1 scale; degenerate N≤1 ring).
|
||||
- [ ] TDD `scale_at` (mod indexing) and `advance_ring(ring, from_index, delta)` → `RingMove` (to_index by mod; per-unit `TransitionStep` list; `reversed` on outward; `wrapped` flag on crossing the seam; chained steps for |delta|>1).
|
||||
|
||||
## Task 2: Simulator data layer — `load_ring` + serializers
|
||||
- [ ] TDD `load_ring(manifest)` parsing the `ring` section into a `ScaleRing`.
|
||||
- [ ] TDD `ring_to_dict` / `ring_move_to_dict`.
|
||||
|
||||
## Task 3: API — `/api/ring`, `/api/ring/advance`
|
||||
- [ ] TDD `GET /api/ring` (scales + transitions, with each scale's title) and `POST /api/ring/advance` (validates from_index/delta; returns the move + target clip_id).
|
||||
|
||||
## Task 4: Media — cosmos + abyss bases + transitions; manifest
|
||||
- [ ] Add cosmos + abyss clips and the `ring` section to the committed manifest with true-PD provenance.
|
||||
- [ ] Extend the media-setup script to generate cheap base + transition placeholders (and optional real PD fetch).
|
||||
|
||||
## Task 5: Frontend — endless encoder + multi-clip + transition playback
|
||||
- [ ] Active-clip selection by ring index; zoom-in/out buttons + wheel; current-scale readout; play returned transition file(s), then settle on target clip; keep live alteration on top.
|
||||
|
||||
## Task 6: Verify + docs
|
||||
- [ ] Full `pytest -q` green; boot the sim and exercise the ring by eye/screenshot.
|
||||
- [ ] Update ROADMAP / USER_GUIDE.
|
||||
@@ -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 1–4 — 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 1–4 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 (1–3) 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: right1–4.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 (1–4) `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 2–3 productionize + run it. The operator's vertical-slice
|
||||
choice (real Right variants for one scale to judge the look) → Tasks 3–4. 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
@@ -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 1–4; only strength 4 is the real flow-stabilized restyle, 1–3
|
||||
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 1–2 *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 1–2 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 0–4 experience knobs)
|
||||
> walking a closed ring of neutral "scales of nature" clips with placeholder
|
||||
> AI zoom/warp transitions; add 1–2 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 1–2 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 1–3 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 1–2 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 ≈ 12–15 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.28–0.58 —
|
||||
but sd-turbo's painterly effect is sharply non-linear (barely registers <0.5,
|
||||
ramps hard through ~0.5–0.65), so levels 1–3 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.28–0.58 → levels 1–3 looked raw); re-baked with a clustered 0.46–0.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._
|
||||
@@ -28,5 +28,14 @@
|
||||
},
|
||||
"0010": {
|
||||
"title": ""
|
||||
},
|
||||
"0011": {
|
||||
"title": ""
|
||||
},
|
||||
"0012": {
|
||||
"title": ""
|
||||
},
|
||||
"0013": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
+33
-1
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 (0–4)</legend>
|
||||
<label>Left (analytical) <input type="range" id="left" min="0" max="4" value="0" /></label>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 == ()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user