Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed5ce09a3c | |||
| 108e6207ba | |||
| b710b259f2 | |||
| 0e401c9a42 | |||
| 7f99c3cdc4 | |||
| 4b1d3afe7b | |||
| 153681a402 | |||
| e862fb498b | |||
| ea530b3df6 | |||
| 80952d7b8e | |||
| 0752746f7b | |||
| 614411b5e4 | |||
| a5e44ea9c7 | |||
| 70fc9a46ab | |||
| f2f242a411 | |||
| ad0a59ac04 | |||
| 31a3fd733f | |||
| 8458ab59eb | |||
| 20f49e936a | |||
| 0548e9a82b | |||
| 3dc3e3491f | |||
| 937461e3ba | |||
| cf74d2c70a | |||
| 6781e40c94 | |||
| 66c65e78b8 | |||
| 528fb5c438 | |||
| c2cc503a9c | |||
| 972ad8832b | |||
| 9b4884b466 | |||
| d7e9fea1d3 | |||
| e8cd783258 | |||
| 5382995931 | |||
| 7855718f74 | |||
| e2b54dd0cc | |||
| cc469b5298 | |||
| 3b3d30e8e0 | |||
| 441645ae7a | |||
| a581fa41ed | |||
| afec816bb0 | |||
| df869d6978 | |||
| 8aa5ee4019 | |||
| 6427ab4a49 | |||
| 56d08d5c60 | |||
| 97d2ddd573 | |||
| 56e352fe65 | |||
| 2564168ac3 | |||
| deb7c34df9 | |||
| 625d32c0f3 | |||
| 58a6ee4644 | |||
| fd1da791bf | |||
| 4ac39c9034 | |||
| 30ec0c9c26 | |||
| 3d46b96ecb | |||
| 3b19d3eb92 | |||
| fddb6d65d4 | |||
| 2722949805 | |||
| d446238b79 | |||
| c51fdc422a | |||
| aa76cd1c9f | |||
| c07067492b | |||
| fd0aad6610 | |||
| 68f9d7342a | |||
| 29c6d822bd | |||
| 1f9b2a0bec | |||
| 39fb9108db | |||
| 062a283de4 | |||
| adc39fe648 | |||
| 99a751cb46 | |||
| 6f2d1c4f7b | |||
| a3ec09af7e | |||
| e0b29c032c | |||
| 1b96e7065b | |||
| f28a06cc7f | |||
| 229af60f1f | |||
| c389e2a834 | |||
| 32e74ca446 | |||
| e3a9b7cff6 | |||
| 7a2f37af7e | |||
| d0ad9a9ac9 | |||
| c3aa419c53 | |||
| 5bb762ae53 | |||
| 2eb752b5bc | |||
| aa56dfe145 | |||
| ed267c554c | |||
| 7da7446740 | |||
| d24f829276 | |||
| de33cbe86d | |||
| 129bb23cb9 | |||
| a2ef792f0f | |||
| 53cb89b4c6 | |||
| 0201690ce0 | |||
| 0928988abc | |||
| a1c9c5c760 | |||
| ab2edf77bb | |||
| d19fe4e8fa | |||
| 9a83c0132d | |||
| 7fd9aabd0e | |||
| 7a32ff1acc | |||
| 5fae4f9005 | |||
| 7628a5321f | |||
| bcd4d32b19 | |||
| c4452396a1 | |||
| 70fd367c70 | |||
| 12408e505e | |||
| edca8a9615 | |||
| 4270c265e3 | |||
| b28f930e11 | |||
| 7be4f18e77 | |||
| 6256bc10e4 | |||
| 317a6be6b0 | |||
| bd4b65684c | |||
| 6369d0c5ab | |||
| 4b8ebf11ea | |||
| 73e5c4a3c7 | |||
| bb5ecf09b4 | |||
| e812f41ef1 | |||
| 8789d6a6d5 | |||
| 2933498c51 | |||
| 02c762fd45 | |||
| f1c83c1a9a | |||
| 38a1046433 | |||
| 60d27aa662 | |||
| dc122fa0bf | |||
| 0fd8b1203d | |||
| d3b6cd7bbd | |||
| 4b5fd20374 | |||
| 7217daeb44 | |||
| 642451925f | |||
| 750e87eb0b | |||
| e210de6f84 | |||
| daa6bddca6 | |||
| cfd8317965 | |||
| 23306fa140 | |||
| f5d8a4f96b | |||
| cbb7c9f53d | |||
| 6adb93407b | |||
| 9cf6324bb4 | |||
| 76ccfb19b4 | |||
| eace3e3467 | |||
| 46064912ed | |||
| 4e3b326d40 | |||
| 95d08d5c01 | |||
| 49b1f0a549 | |||
| f11b9ee72d | |||
| e975ab1f5a | |||
| ef79da0fde | |||
| 435f201534 | |||
| 7a50ae41bb | |||
| 6cf1ae08ff | |||
| 7cf550ac60 | |||
| 638cf5808f |
@@ -0,0 +1,7 @@
|
||||
# Graduated, used experience media is versioned via git-LFS (large binaries).
|
||||
# Only the media the manifest actually references is tracked here — the curated
|
||||
# clip bases, the per-scale audio, and the baked altitude morphs. Stale/unused
|
||||
# media (e.g. the parked right_variants) is NOT tracked and stays gitignored.
|
||||
simulator/sample_media/**/base.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
simulator/sample_media/transitions/*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
simulator/sample_media/**/*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||
+21
-2
@@ -1,9 +1,28 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
media/
|
||||
.superpowers/
|
||||
*.egg-info/
|
||||
# Simulator sample media (look-tuning only; populate via setup_sample_media.py)
|
||||
simulator/sample_media/forest/*.mp4
|
||||
# Simulator media. Throwaway / look-tuning binaries stay out of git; the media
|
||||
# that has GRADUATED into the actual experience — the curated clip bases, the
|
||||
# per-scale audio, and the baked altitude morphs the manifest references — is
|
||||
# committed via git-LFS (see .gitattributes), negated back in below. Stale/unused
|
||||
# media (e.g. the parked right_variants) stays ignored.
|
||||
simulator/sample_media/**/*.mp4
|
||||
simulator/sample_media/**/*.wav
|
||||
simulator/sample_media/**/*.mp3
|
||||
simulator/sample_media/**/*.ogg
|
||||
simulator/sample_media/**/*.m4a
|
||||
# ...EXCEPT the graduated, used experience media (versioned via git-LFS):
|
||||
!simulator/sample_media/**/base.mp4
|
||||
!simulator/sample_media/transitions/*.mp4
|
||||
!simulator/sample_media/**/*.mp3
|
||||
# Local-only candidate review galleries (re-buildable; not shipped content).
|
||||
simulator/static/review.html
|
||||
simulator/static/review_audio.html
|
||||
|
||||
# Editor annotation/comment-thread metadata (not project content)
|
||||
.threads/
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
.PHONY: sim sim-local
|
||||
|
||||
# Prefer the project venv's interpreter (this box has no bare `python` on PATH),
|
||||
# fall back to python3, then python.
|
||||
PYTHON ?= $(firstword $(wildcard .venv/bin/python) python3 python)
|
||||
|
||||
sim:
|
||||
docker compose -f simulator/docker-compose.yml up --build
|
||||
|
||||
sim-local:
|
||||
python -m uvicorn simulator.app:app --reload --port 8000
|
||||
$(PYTHON) -m uvicorn simulator.app:app --reload --port 8000
|
||||
|
||||
+64
-7
@@ -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;
|
||||
@@ -149,13 +170,49 @@ plan:
|
||||
contract** with the firmware (the keyboard/serial stand-in already works via a
|
||||
`Controls` stream).
|
||||
- **White-noise generation** — runtime white/pink noise for that content position.
|
||||
- **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.
|
||||
- **Content pipeline + label track** — source strict-PD neutral bases, run them
|
||||
through the offline pipeline, and author the annotation tracks that drive the
|
||||
Left-HUD overlay. The Right dream is now **real-time** (Kuwahara WebGL shader,
|
||||
session 0013 — no per-clip ML bake), so the pipeline is
|
||||
**source → crossfade-loop → master+proxy transcode → hand-authored /
|
||||
motion-tracked labels → manifest**, not ML baking. Historical note: the
|
||||
flow-stabilized SD-turbo v2v bake (session 0012, `simulator/bake_right_variants.py`)
|
||||
was superseded when the operator found the baked-SD Right "too fluid" and the
|
||||
design pivoted to the deterministic real-time dream; `bake_right_variants.py`
|
||||
is now parked. Spec:
|
||||
[`2026-06-24-content-pipeline-design.md`](./superpowers/specs/2026-06-24-content-pipeline-design.md).
|
||||
|
||||
- **Increment 1 — deterministic tooling + 5-scale ring (session 0014) ✅ Done.**
|
||||
`tools/pipeline/` (frame/loop/transcode/manifest, pure + unit-tested + an
|
||||
opt-in integration test on real footage); the backward-compatible keyframed
|
||||
annotation-track schema with client interpolation; and the **full 5-scale ring**
|
||||
(cosmos → orbit → forest → reef → abyss) with orbit + reef as placeholder
|
||||
scales alongside the existing cosmos/abyss/forest. Plan:
|
||||
[`2026-06-24-content-pipeline-increment-1.md`](./superpowers/plans/2026-06-24-content-pipeline-increment-1.md).
|
||||
|
||||
- **Increment 2 — hybrid motion-track pass + author mode (pending).** The
|
||||
`tools/pipeline/track.py` classical OpenCV tracker + optional ML detect+track,
|
||||
plus a simulator **author mode** for reviewing/editing annotation tracks in the
|
||||
browser. Real strict-PD bases for all five scales sourced per
|
||||
[`docs/content-sourcing.md`](./content-sourcing.md).
|
||||
|
||||
- **Audio + separated Visual/Audio controls (session 0020) ✅ Done.** The
|
||||
simulator now has **sound**: per-altitude soundtracks that follow the Altitude
|
||||
dial + a synthesized pink **white-noise** bed, behind two orthogonal controls —
|
||||
**Visual** (on/off) × **Audio** (off / soundtrack / white_noise) — replacing
|
||||
the bundled 7-way Content dial. `tools/pipeline/audio_ops` + `audio_run` +
|
||||
`simulator/build_audio_media.py` (ffmpeg loop/loudnorm/noise, gitignored
|
||||
assets), `Scale.audio` in the manifest, a pure `player/audio.py` resolver
|
||||
(retiring `player/content.py`), server-resolved `render.audio.url`, and a client
|
||||
A/B `<audio>` crossfade layer. This is the "Content step" of the future control
|
||||
surface (it reframes the proposed networked-control-surface spec §3/§5). Spec:
|
||||
[`2026-06-26-audio-video-separated-controls-design.md`](./superpowers/specs/2026-06-26-audio-video-separated-controls-design.md);
|
||||
plan:
|
||||
[`2026-06-26-audio-video-separated-controls.md`](./superpowers/plans/2026-06-26-audio-video-separated-controls.md).
|
||||
|
||||
- **Still deferred:** real **i2v ring transitions** (need a generative-video
|
||||
model + adjacent real bases); Pi renderer + serial/firmware; the **music**
|
||||
audio layer (reserved dial position, no assets yet).
|
||||
- **Catalog model changes** — audio *source* + "neutral base" vs "altered
|
||||
variant" flag (sub-project 2 territory, design §13).
|
||||
|
||||
|
||||
+49
-18
@@ -296,14 +296,17 @@ exists. (The earlier selection-era "curator's X-ray" view was retired when the
|
||||
piece moved from *selecting* clips to *altering* them.)
|
||||
|
||||
**One-time setup — populate the sample footage** (look-tuning only; not shipped
|
||||
content):
|
||||
content). The ring uses real strict-PD footage in a **rotating pool** per scale
|
||||
(`docs/content-candidate-pool.md`); regenerate the manifest + new transition
|
||||
placeholders with:
|
||||
|
||||
python simulator/setup_sample_media.py
|
||||
python simulator/build_pool_manifest.py --media # write the pool manifest + coast-edge 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.
|
||||
`build_pool_manifest.py` holds the hand-authored label/affect content and emits
|
||||
`simulator/sample_media/manifest.json` (the 19-clip pool baseline). The pool clips
|
||||
themselves are sourced via the content pipeline (`tools/pipeline/run.py
|
||||
process_clip`); the `.mp4` binaries are gitignored. (`setup_scales_media.py` is the
|
||||
older one-base-per-scale generator, now superseded.)
|
||||
|
||||
**Run it (Docker):**
|
||||
|
||||
@@ -320,18 +323,46 @@ then open http://localhost:8000.
|
||||
|
||||
- **Content dial** — picks audio/video channel; "off" and audio-only positions go
|
||||
to black walls.
|
||||
- **Four experience knobs (0–4):**
|
||||
- **Dark / Light** — a live runtime color grade (cool/dark ↔ warm/bright; equal
|
||||
or zero = the raw footage).
|
||||
- **Right (dreamlike)** — selects a discrete pre-baked, flow-stabilized restyle
|
||||
variant and crossfades to it (strength 0 = raw base).
|
||||
- **Left (analytical)** — a live overlay: labelled boxes from the clip's authored
|
||||
annotation track, with more annotations appearing at higher levels. Text is
|
||||
shaped live (the simulator analogue of the Pi's Pango/HarfBuzz path).
|
||||
- **Calibration sliders** — adjust the grade/overlay gain curves live; once a look
|
||||
is liked, bake the values into `DEFAULT_CALIBRATION` in `player/alteration.py`.
|
||||
- **Scale ring (endless encoder)** — `⊖ out` / `in ⊕` (or scroll the stage) walk a
|
||||
*closed ring* of neutral "scales of nature" — **cosmos → orbit → coast → reef →
|
||||
abyss** and back around (diving past the smallest **wraps** to the largest). Each
|
||||
scale is a **rotating pool** of vetted clips: landing on a scale plays a random
|
||||
pool member, so the ring feels fresh each pass. It is *relative* (an endless
|
||||
encoder), distinct from the absolute 0–4 knobs: each step plays a short
|
||||
placeholder zoom/warp **transition**, then settles, with the current alteration
|
||||
still applied. The current scale + chosen member is named beside the buttons
|
||||
(`scale · member (i/N · pool N)`). A **fast spin** (≥3 detents at once) collapses
|
||||
to one quick **blended pass** instead of grinding through every transition.
|
||||
- **Three experience knobs:**
|
||||
- **Mood (−4 dark .. 0 .. +4 light)** — a live runtime color grade (cool/dark ↔
|
||||
warm/bright; 0 = the raw footage).
|
||||
- **Right (dreamlike, 0–4)** — a **deterministic real-time painterly dream** (a
|
||||
WebGL Kuwahara restyle of the live frames; holds still across the loop, goes
|
||||
trippy at max). It also drives **progressive emotion tiers**: when both knobs
|
||||
are up, the affect words escalate from basic to compound as Right rises.
|
||||
- **Left (analytical, 0–4)** — a live HUD of labelled boxes from the clip's
|
||||
authored annotation track. Labels use **progressive detail tiers**: low Left
|
||||
shows fewer, more general labels (high-salience objects only); raising Left
|
||||
brings in more objects *and* escalates each label general → specific →
|
||||
scientific → +fact. Time-windowed tracked labels appear only while their
|
||||
subject is on screen and follow it.
|
||||
- **RenderPlan readout** — always shows the exact numbers the engine produced (the
|
||||
project's honesty "X-ray," now over the alteration model).
|
||||
project's honesty "X-ray," over the alteration model).
|
||||
|
||||
The base clips, Right variants, Left annotation track, and string tables come from
|
||||
The base clips, the Left annotation tracks (with tiers + appear/disappear
|
||||
windows), affect anchors, and per-tier string tables all come from
|
||||
`simulator/sample_media/manifest.json`.
|
||||
|
||||
### Authoring labels — the author mode
|
||||
|
||||
Open **http://localhost:8000/author.html** to author a clip's labels by eye
|
||||
(content-pipeline §11.5). Pick a pool clip, **scrub** to where a subject is on
|
||||
screen, **drag a box** over it, set the label key + salience + the four detail
|
||||
tiers, then **Run tracker** to propagate the box into a keyframed track (classical
|
||||
OpenCV optical flow) with an appear/disappear window — or **Add as static box**
|
||||
for a fixed label. Shift-click the stage to place an **affect anchor** and type its
|
||||
emotion tiers. **Save to manifest** writes the entry back via the pipeline's
|
||||
idempotent upsert (keeping the clip's media + provenance, replacing only the
|
||||
authored content). The tracker only propagates **box geometry** — every label
|
||||
string, scientific name, and fact is **hand-authored**, because a generic detector
|
||||
can't produce "*Gymnothorax*" or a lifespan.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Audio candidate pool — rotating pools per altitude (2026-06-26)
|
||||
|
||||
Candidate soundtrack/ambience for the **"audio + video" setting**. Following operator
|
||||
feedback, each altitude now has a **rotating pool** (mirrors the video `Scale.pool`
|
||||
model — random pick per landing, or pairable per-clip).
|
||||
|
||||
## Two audio layers per altitude (operator, 2026-06-26)
|
||||
|
||||
The "audio + video" setting layers **two** kinds of audio per altitude:
|
||||
1. **Ambience / audio track** — the soundscape (waves, reef crackle, chorus, etc.).
|
||||
**This is what we're sourcing now.**
|
||||
2. **Music track** — a separate composed musical layer. **Deferred** until the
|
||||
ambience tracks are settled across all altitudes.
|
||||
|
||||
Pools below are the **ambience** layer unless marked `music`. **Distinction (operator):**
|
||||
the ambience/soundtrack layer is an *environmental, real-world soundscape* (waves, reef,
|
||||
whale, room-tone hum) — **not composed music**. A musical pad/track, however nice, belongs
|
||||
in the music layer, even for "atmospheric" altitudes like cosmos/orbit.
|
||||
|
||||
**Media is gitignored** (`simulator/sample_media/**/*.{wav,mp3,…}`); this file is the
|
||||
re-sourceable record. Files: `simulator/sample_media/audio/<altitude>/<name>.<ext>`,
|
||||
served at `/media/audio/<altitude>/<name>.<ext>`. Review gallery (gitignored):
|
||||
`simulator/static/review_audio.html` → `/review_audio.html` with the sim running.
|
||||
|
||||
## Round-2 feedback applied (operator, session 2026-06-26)
|
||||
|
||||
- **abyss** ✓ kept (blue-whale moan) · **coast** ✓ kept (waves + terns)
|
||||
- **cosmos** ✗ plasma static "hurt my ears" → replaced with 3 NASA **sonifications**
|
||||
- **orbit** ✗ whistlers "sound like lasers" → replaced with gentle Earth **chorus** (+ wind)
|
||||
- **reef** ✗ "shouldn't have whales (too deep for a reef)" → whale removed; now crackle + diver
|
||||
- Operator: *"try all three… rotation of audio like the video, or per-video audio track."*
|
||||
|
||||
## Design arc
|
||||
|
||||
Descending the dial is one journey: **cosmos & abyss** = vast/dark/sparse siblings;
|
||||
**coast** = warm human middle; **orbit & reef** = transitional membranes. The science
|
||||
recordings (NASA/NOAA) keep the "machines reshaping perception" thesis literal.
|
||||
|
||||
## Pools
|
||||
|
||||
| altitude | id | clip | source | dur | license |
|
||||
|---|---|---|---|---|---|
|
||||
| 🌌 cosmos | `cosmos/pillars` | **✓ KEEP** — Pillars of Creation (M16), twinkly sweep | NASA/CXC/SAO — https://chandra.si.edu/sound/m16.html (`sounds/m16_all.mp4`) | 30s | PD (NASA, credit) |
|
||||
| 🌌 cosmos | `cosmos/music` | 🎵 **music track — TBD** (deferred). Black-hole drone DROPPED (too creepy, not awe-inspiring); Galactic Center dropped earlier | — | — | — |
|
||||
| 🛰 orbit | `orbit/spaceamb` | **✓ KEEP** — station room-tone hum ("inside the orbiting craft") | Sonicfreak — https://freesound.org/s/174450/ (`cdn.freesound.org/previews/174/174450_746632-hq.mp3`) | 2:31 | CC0 |
|
||||
| 🛰 (music) | _deferred_ | 🎵 "Frozen Star" warm pad — moved to MUSIC layer (it's music, not ambience) | Kevin MacLeod / incompetech — https://incompetech.com/music/royalty-free/mp3-royaltyfree/Frozen%20Star.mp3 | 3:41 | CC-BY |
|
||||
| 🐠 reef | `reef/soundscape` | **✓** richer reef (round 4) — 6-layer bake: submerged underwater bed (bubbles/sloosh) + shrimp crackle + fish chorus + toadfish + grouper + damselfish | NOAA SanctSound (FK+GR `*_snappingshrimp_*`, `FK02_01_fishchorus`, `GR01_01_toadfish`, `FK03_01_redgrouper`, `PM05_01_damselfish`) + DCSFX "Underwater [Loop] AMB" https://freesound.org/s/366159/ (`cdn.freesound.org/previews/366/366159_6725579-hq.mp3`); loop-to-60 + amix + dynaudnorm | 60s | PD (NOAA) + CC0 (underwater) |
|
||||
| 🏖 coast | `coast/waves` | Sea waves + tern calls | BigSoundBank #0267 — https://bigsoundbank.com/sea-waves-and-seagulls-s0267.html (`/UPLOAD/mp3/0267.mp3`) | 57s | CC0 |
|
||||
| 🕳 abyss | `abyss/whale` | NE Pacific blue-whale "AB call" | NOAA PMEL — https://www.pmel.noaa.gov/acoustics/multimedia/NETS_bluWhale.wav | 13s | PD (NOAA) |
|
||||
|
||||
## Sourcing rule (learned 2026-06-26)
|
||||
|
||||
**Source only from a browsable page the operator can open and whose license is stated.**
|
||||
The Mixkit clips were pulled by raw CDN asset id (`assets.mixkit.co/.../<id>-preview.mp3`)
|
||||
with no findable catalog page — operator couldn't see them, so they were removed. Good
|
||||
hubs: NASA SVS / Chandra, NOAA PMEL/SanctSound (PD), BigSoundBank (CC0), Pixabay
|
||||
(visible license). Avoid asset-id grabs and ND/NC licenses.
|
||||
|
||||
## Open items / next pass
|
||||
|
||||
- **orbit** — ✅ SETTLED: `orbit/spaceamb` (Sonicfreak station room-tone hum, CC0). Rejected
|
||||
en route: laser whistlers, Earth chorus, plain wind, "Frozen Star" pad (music). A real NASA
|
||||
ISS recording (Hadfield "Space Station Noise", SoundCloud) remains the authentic upgrade if
|
||||
ever wanted — needs a harder fetch (no yt-dlp; brew install denied).
|
||||
- **ambience layer COMPLETE** — all 5 picked: cosmos/pillars · orbit/spaceamb · coast/waves ·
|
||||
reef/soundscape · abyss/whale. Next is the production pass + wiring (below) and the music layer.
|
||||
- **reef** — ✅ round-4 6-layer bake (`reef/soundscape`): NOAA PD biological layers + a CC0
|
||||
submerged underwater "you're under" bed (DCSFX freesound #366159, hq preview). License clean.
|
||||
SanctSound clips: `files/SanctSound_*.mp4` at `https://sanctsound.ioos.us/files/` (extract audio).
|
||||
- **music layer** — separate composed music track per altitude, deferred (cosmos slot reserved).
|
||||
- **Production (after picks):** loop seams, length normalization, cross-altitude crossfade
|
||||
(mirror video); cosmos/abyss clips are short → loop.
|
||||
|
||||
## Re-download / re-bake
|
||||
|
||||
```
|
||||
A=simulator/sample_media/audio; FF=$(python -c "import imageio_ffmpeg;print(imageio_ffmpeg.get_ffmpeg_exe())")
|
||||
mkdir -p $A/cosmos $A/orbit $A/reef $A/coast $A/abyss
|
||||
# NASA sonifications (mp4 -> mp3)
|
||||
for u in perseus_sonification:blackhole m16_all:pillars galactic_all:galaxy; do
|
||||
f=${u%%:*}; o=${u##*:}; curl -fsSL "https://chandra.si.edu/sound/sounds/$f.mp4" -o /tmp/$o.mp4
|
||||
"$FF" -y -i /tmp/$o.mp4 -vn -q:a 4 $A/cosmos/$o.mp3; done
|
||||
curl -fsSL "https://svs.gsfc.nasa.gov/vis/a010000/a011000/a011073/Earthsong-540-MASTER_high.mp4" -o /tmp/es.mp4
|
||||
"$FF" -y -i /tmp/es.mp4 -vn -q:a 4 $A/orbit/earthsong.mp3
|
||||
curl -fsSL "https://assets.mixkit.co/active_storage/sfx/1162/1162-preview.mp3" -o $A/orbit/wind.mp3
|
||||
curl -fsSL "https://www.nhm.ac.uk/content/dam/nhm-www/discover/audio/sound-of-coral-reef.mp3" -o $A/reef/crackle.mp3
|
||||
curl -fsSL "https://assets.mixkit.co/active_storage/sfx/1242/1242-preview.mp3" -o $A/reef/scuba.mp3
|
||||
curl -fsSL "https://bigsoundbank.com/UPLOAD/mp3/0267.mp3" -o $A/coast/waves.mp3
|
||||
curl -fsSL "https://www.pmel.noaa.gov/acoustics/multimedia/NETS_bluWhale.wav" -o $A/abyss/whale.wav
|
||||
# layered reef
|
||||
"$FF" -y -i $A/reef/crackle.mp3 -i $A/reef/scuba.mp3 \
|
||||
-filter_complex "[1:a]volume=0.55[s];[0:a][s]amix=inputs=2:duration=first,dynaudnorm" -q:a 4 $A/reef/layered.mp3
|
||||
```
|
||||
@@ -0,0 +1,135 @@
|
||||
# Content candidate pool — operator-selected, vetted (2026-06-24)
|
||||
|
||||
> **WIRED (session 0016).** The rotating pool below is now the live manifest:
|
||||
> `simulator/build_pool_manifest.py` regenerates `simulator/sample_media/manifest.json`
|
||||
> from this pool; the ring scale `forest`→`coast` rename + the cleanup list (bottom)
|
||||
> are done; the player picks a random pool member on landing
|
||||
> (`player.ring.pick_clip_id`). See content-pipeline design §11.
|
||||
|
||||
The 5 scales each get a **rotating pool** of clips. When the viewer lands on a
|
||||
scale by zooming the ring in/out, the player **randomly picks one clip from that
|
||||
scale's pool**. (This supersedes the one-clip-per-scale model — see
|
||||
[`docs/superpowers/specs/2026-06-24-content-pipeline-design.md`](./superpowers/specs/2026-06-24-content-pipeline-design.md).)
|
||||
|
||||
Every clip below was operator-selected from the candidate gallery, is **strict
|
||||
public-domain or CC‑BY‑class with attribution**, and was vetted **word‑free /
|
||||
human‑free** (windows trimmed past title cards, captions, telemetry burn‑in,
|
||||
divers, construction, logos). All processed via `tools/pipeline/run.py
|
||||
process_clip` (trim → crossfade‑loop → 1080p proxy). **Media is gitignored**;
|
||||
this file is the re‑sourceable record. Sim ids are the `simulator/sample_media/<id>/` dirs.
|
||||
|
||||
License rule confirmed by operator: **CC0 / CC‑BY / CC‑BY‑SA are acceptable with
|
||||
a credit line** (our overlays + dream are derivatives, which these allow);
|
||||
**CC‑BY‑ND is NOT usable** (no derivatives); **CC‑BY‑NC only if the installation
|
||||
stays non‑commercial**.
|
||||
|
||||
## 🌌 cosmos (pool of 5)
|
||||
|
||||
| id | clip | source | window (s) | license / credit |
|
||||
|----|------|--------|-----------|------------------|
|
||||
| `cosmos` | Cosmic Cliffs — Carina Nebula flythrough | NASA SVS 31348 — https://svs.gsfc.nasa.gov/31348/ (`Clifs-3d-STScI.mp4`, *Exploring the Cosmic Cliffs in 3D*, Webb NIRCam, **4K**) | 10–34 | CC‑BY‑class — credit **NASA, ESA, CSA, STScI** |
|
||||
| `cosmos_miri` | Cosmic Cliffs — Carina Nebula, **mid-infrared** (same towers, muted gray/teal/gold dust palette) | ESA/Webb weic2205c — https://esawebb.org/videos/weic2205c/ (*Pan of the Carina Nebula, combined NIRCam + MIRI*, **4K** `weic2205c.mp4`) | 4–26 | CC‑BY‑class — credit **NASA, ESA, CSA, STScI** |
|
||||
|
||||
> ℹ️ **Cosmos primary swapped to the Webb "Cosmic Cliffs" flythrough (2026-06-25, session 0018).**
|
||||
> History: the original primary was NASA/JPL's *"Orion: Dust and Death"* (captions
|
||||
> burned in → cropping lost framing); it was then swapped to STScI's text-free
|
||||
> *Flight Through the Orion Nebula* (SVS 30957, visible light). The operator found
|
||||
> the Orion clip merely good and asked for a more dramatic nebula, so the primary is
|
||||
> now STScI's **Exploring the Cosmic Cliffs in 3D** (Carina Nebula, Webb NIRCam,
|
||||
> **SVS 31348**) — a full-frame, text-free 3D flythrough sourced from its **4K**
|
||||
> master (`Clifs-3d-STScI.mp4`, 3840×2160) → supersampled 1080p base + 4K master.
|
||||
> Trim 10–34s clears the opening title card (gone by ~8s) and the end-credit card
|
||||
> (~102s). The earlier `decaption_cosmos` crop helper remains removed.
|
||||
| `cosmos_galaxies` | Flying Through Galaxies | NASA SVS — https://svs.gsfc.nasa.gov/vis/a010000/a014900/a014950/14950_Galaxies_FlyThrough_4k.mp4 | 8–32 | PD |
|
||||
| `cosmos_hudf` | Hubble Ultra Deep Field zoom | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030687/hudf-b-1920x1080p30.mov | 20–44 | CC‑BY‑class — credit **NASA, ESA, STScI** |
|
||||
| `cosmos_xdf` | eXtreme Deep Field flythrough | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030681/hxdf_fly-b-1920x1080p30.mov | 2–26 | CC‑BY‑class — credit **NASA, ESA, STScI** |
|
||||
|
||||
> ➕ **`cosmos_miri` added (2026-06-26).** Operator wanted more *Cosmic Cliffs*
|
||||
> footage and a different-color rendering. The keep-the-flythrough-primary +
|
||||
> add-the-mid-infrared-pan pair: ESA/Webb's **weic2205c** is the *same* Carina
|
||||
> towers shot with NIRCam **+ MIRI**, so it renders in a muted gray/teal/gold dust
|
||||
> palette instead of the iconic orange/blue — a distinct look from the same scene.
|
||||
> Sourced text-free/human-free from the **4K** master, trim 4–26s (clears the
|
||||
> opening fade-from-black, ends before any tail fade) → 1080p base + 4K master,
|
||||
> 1 s crossfade-loop (~21 s). ESA/Webb's narration-free *"Diving Into the Cosmic
|
||||
> Cliffs"* pan (`carina_revisit_pan`, NIRCam palette) was the runner-up — left
|
||||
> out for now since it repeats the existing palette; easy to add if wanted.
|
||||
|
||||
> ⚠️ **DISCREPANCY to fix next session.** Operator selected cosmos **#1, #3
|
||||
> (Orion), #4, #5**. During processing, **#6 Galaxy Traverse was run by mistake
|
||||
> instead of #3 Orion**. The cosmos primary now lives at
|
||||
> `simulator/sample_media/cosmos/base.mp4` as the Webb Cosmic Cliffs flythrough
|
||||
> (SVS 31348 — see the cosmos-primary note above). **`cosmos_traverse` is an UNSELECTED extra on disk — drop it** (or
|
||||
> confirm with operator). The current ring scale id is `cosmos`; when the pool is
|
||||
> wired, `cosmos` becomes one pool member.
|
||||
|
||||
## 🛰️ orbit (pool of 3)
|
||||
|
||||
| id | clip | source | window (s) | license |
|
||||
|----|------|--------|-----------|---------|
|
||||
| `orbit_planetearth` | ISS — View of Planet Earth | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030700/a030771/ISS_View_of_Planet_Earth_2160p.mp4 | 10–34 | PD |
|
||||
| `orbit_crewobs` | ISS — Crew Earth Observations | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030700/a030771/ISS_Crew_Earth_Observations_2160p.mp4 | 9–33 | PD |
|
||||
| `orbit_bluemarble` | NPP "Blue Marble" rotating globe (CG) | NASA SVS — https://svs.gsfc.nasa.gov/vis/a000000/a004500/a004550/BlueMarble_starfield_4k_2160p30_v2.mp4 | 10–34 | PD |
|
||||
|
||||
> Operator also selected aurora→Perth (#4) and west‑coast lights (#5); both
|
||||
> **rejected as UNUSABLE** — #4 has a date/time burned into every frame, #5 has the
|
||||
> ISS module + solar panel in frame throughout. No clean window exists in either.
|
||||
|
||||
## 🏖️ coast (pool of 4) — NEW scale, replaces the granite/waterfall `forest`
|
||||
|
||||
| id | clip | source | window (s) | license |
|
||||
|----|------|--------|-----------|---------|
|
||||
| `coast_birdrock` | Bird rock + ocean (aerial) | NPS Channel Islands — https://www.nps.gov/nps-audiovideo/audiovideo/663a2f13-6183-4b3e-a4a9-6698a84619d01080p.mp4 | 3–27 | PD |
|
||||
| `coast_surfgrass` | Surfgrass / coralline tidepool | NPS Cabrillo — https://www.nps.gov/nps-audiovideo/audiovideo/de7d1cf2-e422-45b5-aa66-4c69e29a4d811080p.mp4 | 2–26 | PD |
|
||||
| `coast_elkbeach` | Elk resting in coastal grass/fog | NPS Redwood — https://www.nps.gov/nps-audiovideo/legacy/redw/4634070E-F68D-4F74-E43174448168AC8A/redw-elkbeach_1280x720.mp4 | 20–38 | PD (720p source) |
|
||||
| `coast_drakesbeach` | Elephant seals on dark sand | NPS Point Reyes — https://www.nps.gov/nps-audiovideo/audiovideo/1cfd8165-1061-4d75-af53-b46c2e3ed2701080p.mp4 | 20–35 | PD |
|
||||
|
||||
> Operator also selected the Lifeboat Station "elephant seal timelapse" (more‑options
|
||||
> #3) — **FAILED**: the NPS page's video resolved to the wrong footage (an aerial of
|
||||
> the bay with road/parking/boats, no seals). An alternate source UUID
|
||||
> (`50a7a20a-9e52-4643-b186-c61ce1cef0f7`) appeared in the page HTML and could be
|
||||
> retried next session if the seal timelapse is still wanted.
|
||||
>
|
||||
> **Scale rename:** the live ring scale is still `forest` (Yosemite). Next session
|
||||
> renames it `coast` and points it at this pool. The old real Yosemite base
|
||||
> (`simulator/sample_media/forest/`) becomes unused.
|
||||
|
||||
## 🐠 reef (pool of 5)
|
||||
|
||||
All NOAA Fisheries b‑roll (PD, credit "NOAA Fisheries"); Brightcove download
|
||||
endpoint `https://download.gallery.brightcove.com/api/gallery/account/659677166001/site/site-600408/video/<VIDEO_ID>/download`.
|
||||
|
||||
| id | clip | VIDEO_ID | window (s) | notes |
|
||||
|----|------|----------|-----------|-------|
|
||||
| `reef_spawning` | Snapper school + yellow fish over sunlit reef | 1553921798001 | 50–78 | clean window from a 12‑min reel |
|
||||
| `reef_hawkfish` | Humphead parrotfish over reef (Hawaiian atoll) | 6039896460001 | 250–274 | |
|
||||
| `reef_lionfish` | Lionfish hovering + blue snapper | 4088881464001 | 12–36 | underwater clean; humans on land after ~190s |
|
||||
| `reef_snapper` | Red snapper schooling (Gulf, greenish water) | 5305427942001 | 7–31 | |
|
||||
| `reef_coralspacific` | Pacific coral macro, spotted fish | 5231464663001 | 30–54 | diver‑free window (divers at 6–25s) |
|
||||
|
||||
## 🦑 abyss (pool of 3)
|
||||
|
||||
All NOAA Ocean Exploration (PD, 17 U.S.C. §105). These have creatures drifting
|
||||
**into and out of frame** — the prime material for time‑windowed tracked labels.
|
||||
|
||||
| id | clip | source | window (s) | creatures |
|
||||
|----|------|--------|-----------|-----------|
|
||||
| `abyss_wow` | "World of Water" | https://oceanexplorer.noaa.gov/wp-content/uploads/2018/05/wow-1280x720-1.mp4 | 10–34 | jelly + ctenophore + siphonophore enter/exit |
|
||||
| `abyss_midwaterexp` | "Midwater Exploration" | https://oceanexplorer.noaa.gov/wp-content/uploads/2019/09/midwater-exploration-1920x1080-1.mp4 | 16–40 | white medusa + red worm enter/exit |
|
||||
| `abyss_hiding` | "Hiding in the Dark" | https://oceanexplorer.noaa.gov/wp-content/uploads/2018/05/dark-1280x720-1.mp4 | 20–44 | crimson jelly + ctenophore enter/exit |
|
||||
|
||||
> Operator also selected Crossota (#4) and comb jelly (#5); both **rejected** —
|
||||
> Crossota has only ~17s clean (too short for a 24s window), comb jelly has a
|
||||
> species caption covering the whole clip.
|
||||
|
||||
## On disk but NOT in any pool — ✅ CLEANED UP (session 0016)
|
||||
|
||||
Removed from local media (all were gitignored, so repo-invisible):
|
||||
|
||||
- `cosmos_traverse` — unselected (the #6/#3 mix‑up above). **dropped.**
|
||||
- `orbit_westcoast` — rejected (ISS hardware in frame). **dropped.**
|
||||
- `forest` — the old Yosemite base, superseded by the `coast` pool. **dropped.**
|
||||
- `reef` — the original placeholder/early reef base, superseded by `reef_*`. **dropped.**
|
||||
- `orbit` / `abyss` — the Increment‑1 single real bases (NASA ISS Exp65 / ctenophore),
|
||||
not in the new pools. **dropped** (the cosmos base — now the Webb Cosmic Cliffs flythrough, SVS 31348 — stays as the cosmos pool primary).
|
||||
- `review.html` gallery — now `.gitignore`d (local‑only, re‑buildable).
|
||||
@@ -0,0 +1,29 @@
|
||||
# Content sourcing — strict-PD neutral bases
|
||||
|
||||
For each scale, acquire a calm, neutral source clip from a **strict public-domain**
|
||||
US-government source, verify the license, then run it through the pipeline.
|
||||
|
||||
| Scale | Source | Where |
|
||||
|--------|--------------------------------|------------------------------------|
|
||||
| cosmos | NASA / Hubble / JWST | https://images.nasa.gov/ |
|
||||
| orbit | NASA / ISS | https://images.nasa.gov/ |
|
||||
| forest | NPS / USGS | https://www.nps.gov/ , https://www.usgs.gov/ |
|
||||
| reef | NOAA Ocean Exploration | https://oceanexplorer.noaa.gov/ |
|
||||
| abyss | NOAA Ocean Exploration | https://oceanexplorer.noaa.gov/ |
|
||||
|
||||
**License check (mandatory):** confirm the asset is US-gov PD (17 U.S.C. §105) or
|
||||
explicitly CC0. "Free stock" (Pexels/Pixabay/etc.) is royalty-free but NOT public
|
||||
domain — do not use. Record a `Provenance` (tools/pipeline/provenance.py) per clip.
|
||||
|
||||
**Run the pipeline** (replaces the placeholder bytes for a scale):
|
||||
|
||||
```bash
|
||||
python -c "from tools.pipeline.run import process_clip; \
|
||||
process_clip('downloads/<scale>.mp4', 'simulator/sample_media/<scale>', \
|
||||
start=<in_s>, duration=<len_s>, overlap=<0.5..1.0>)"
|
||||
```
|
||||
|
||||
This writes `simulator/sample_media/<scale>/base.mp4` (proxy) + `master.mp4`. Then
|
||||
author the labels (Increment 2: the hybrid track pass + simulator author mode) or
|
||||
hand-edit the annotation track for now (spec §4). The ring + manifest entry already
|
||||
exist; `process_clip` only replaces the media bytes.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,929 @@
|
||||
# Content Pipeline — Increment 1 (real footage, no ML) 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:** Build the deterministic content-pipeline tooling (frame → crossfade-loop → master+proxy transcode → manifest assembly), add the backward-compatible keyframed annotation-track schema with client interpolation, and extend the scale ring to 5 scales — proving the pipeline end-to-end on real footage without any ML.
|
||||
|
||||
**Architecture:** A new pure-Python `tools/pipeline/` package whose stage functions return ffmpeg argument lists (unit-tested without running ffmpeg); a thin `run.py` executes them and is covered by an opt-in integration test gated on `ffmpeg` + the real forest POC base. Manifest assembly is pure dict transforms. The annotation `track` is optional data that rides through the existing pass-through `annotations` list (no Python model change); interpolation is client-side JS, consistent with the existing "alteration math in Python, label placement in the client" split. This implements §3 (stages 1–4, 6), §4 (track schema), §6 (format), §7 (tooling), and Increment 1 of §8 in [`docs/superpowers/specs/2026-06-24-content-pipeline-design.md`](../specs/2026-06-24-content-pipeline-design.md).
|
||||
|
||||
**Tech Stack:** Python 3.13, pytest, ffmpeg (libx264), vanilla JS (SVG overlay). Tests run with `pytest -q` from the repo root in the project `.venv`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Scaffold `tools/pipeline/` + the frame stage
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/pipeline/__init__.py`
|
||||
- Create: `tools/pipeline/ffmpeg_ops.py`
|
||||
- Test: `tests/test_pipeline_ffmpeg_ops.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_ffmpeg_ops.py
|
||||
from tools.pipeline.ffmpeg_ops import frame_args
|
||||
|
||||
|
||||
def test_frame_args_trims_scales_pads_and_strips_audio():
|
||||
args = frame_args(
|
||||
"src.mp4", "out.mp4",
|
||||
start=2.0, duration=8.0, width=1920, height=1080, ff="ffmpeg",
|
||||
)
|
||||
assert args[0] == "ffmpeg"
|
||||
assert "-ss" in args and args[args.index("-ss") + 1] == "2.0"
|
||||
assert "-t" in args and args[args.index("-t") + 1] == "8.0"
|
||||
assert "-an" in args # audio stripped (bases are video-only)
|
||||
vf = args[args.index("-vf") + 1]
|
||||
assert "scale=1920:1080:force_original_aspect_ratio=decrease" in vf
|
||||
assert "pad=1920:1080" in vf
|
||||
assert "format=yuv420p" in vf
|
||||
assert args[-1] == "out.mp4"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'tools.pipeline'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# tools/pipeline/__init__.py
|
||||
"""Content pipeline: source -> frame -> loop -> transcode -> manifest.
|
||||
|
||||
Pure ffmpeg-argument builders (ffmpeg_ops), a thin runner (run), provenance
|
||||
records, and manifest assembly. See docs/superpowers/specs/2026-06-24-content-pipeline-design.md.
|
||||
"""
|
||||
```
|
||||
|
||||
```python
|
||||
# tools/pipeline/ffmpeg_ops.py
|
||||
"""Pure ffmpeg argument builders (no I/O) for the content pipeline.
|
||||
|
||||
Each function returns a list[str] ready for ffmpeg, so command construction is
|
||||
unit-testable without running ffmpeg. tools/pipeline/run.py executes them. The
|
||||
ffmpeg binary name is injected (default "ffmpeg").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _fit(width: int, height: int) -> str:
|
||||
"""A scale+pad filter chain fitting any input into width×height (letterbox),
|
||||
fixing SAR and pixel format so downstream concat/xfade get identical geometry."""
|
||||
return (
|
||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,"
|
||||
"setsar=1,format=yuv420p"
|
||||
)
|
||||
|
||||
|
||||
def frame_args(src, dst, *, start: float, duration: float,
|
||||
width: int, height: int, ff: str = "ffmpeg") -> list[str]:
|
||||
"""Stage 2 — trim [start, start+duration], fit to width×height, strip audio.
|
||||
Produces a high-quality mezzanine for the loop stage."""
|
||||
return [
|
||||
ff, "-y", "-ss", str(start), "-t", str(duration),
|
||||
"-i", str(src), "-vf", _fit(width, height), "-an",
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "16",
|
||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(dst),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/__init__.py tools/pipeline/ffmpeg_ops.py tests/test_pipeline_ffmpeg_ops.py
|
||||
git commit -m "feat(pipeline): frame stage ffmpeg arg builder (content pipeline §3.2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Crossfade seamless-loop stage
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/pipeline/ffmpeg_ops.py`
|
||||
- Test: `tests/test_pipeline_ffmpeg_ops.py`
|
||||
|
||||
The seamless loop (spec §3.3) crossfades the clip's tail over its head. For a clip
|
||||
of duration `D` with overlap `O`, the output (length `D − O`) is
|
||||
`concat( xfade(tail, head, O), mid )` where `head`=`[0,O]`, `mid`=`[O, D−O]`,
|
||||
`tail`=`[D−O, D]`. Both internal joins and the loop seam are then continuous, and
|
||||
the tail→head jump is blended away over `O`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# add to tests/test_pipeline_ffmpeg_ops.py
|
||||
from tools.pipeline.ffmpeg_ops import crossfade_loop_args
|
||||
|
||||
|
||||
def test_crossfade_loop_builds_head_mid_tail_xfade_concat():
|
||||
args = crossfade_loop_args("in.mp4", "loop.mp4", duration=8.0, overlap=1.0)
|
||||
fc = args[args.index("-filter_complex") + 1]
|
||||
assert "trim=0:1.0" in fc # head = first O secs
|
||||
assert "trim=1.0:7.0" in fc # mid = [O, D-O]
|
||||
assert "trim=7.0:8.0" in fc # tail = last O secs
|
||||
assert "xfade=transition=fade:duration=1.0:offset=0" in fc
|
||||
assert "concat=n=2:v=1" in fc
|
||||
assert "-an" in args
|
||||
assert args[-1] == "loop.mp4"
|
||||
|
||||
|
||||
def test_crossfade_loop_rejects_overlap_too_large():
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
crossfade_loop_args("in.mp4", "loop.mp4", duration=4.0, overlap=2.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: FAIL with `ImportError: cannot import name 'crossfade_loop_args'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# add to tools/pipeline/ffmpeg_ops.py
|
||||
def crossfade_loop_args(src, dst, *, duration: float, overlap: float,
|
||||
ff: str = "ffmpeg") -> list[str]:
|
||||
"""Stage 3 — make `src` loop seamlessly by crossfading its tail over its head.
|
||||
Output length = duration − overlap. Requires overlap < duration/2 so a non-empty
|
||||
middle remains."""
|
||||
if overlap <= 0 or overlap >= duration / 2:
|
||||
raise ValueError(f"overlap {overlap} must be in (0, duration/2={duration/2})")
|
||||
d, o = duration, overlap
|
||||
fc = (
|
||||
f"[0:v]trim=0:{o},setpts=PTS-STARTPTS[head];"
|
||||
f"[0:v]trim={o}:{d - o},setpts=PTS-STARTPTS[mid];"
|
||||
f"[0:v]trim={d - o}:{d},setpts=PTS-STARTPTS[tail];"
|
||||
f"[tail][head]xfade=transition=fade:duration={o}:offset=0[xf];"
|
||||
f"[xf][mid]concat=n=2:v=1,format=yuv420p[out]"
|
||||
)
|
||||
return [
|
||||
ff, "-y", "-i", str(src), "-filter_complex", fc,
|
||||
"-map", "[out]", "-an",
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "16",
|
||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(dst),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/ffmpeg_ops.py tests/test_pipeline_ffmpeg_ops.py
|
||||
git commit -m "feat(pipeline): crossfade seamless-loop arg builder (content pipeline §3.3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Master + proxy transcode stage
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/pipeline/ffmpeg_ops.py`
|
||||
- Test: `tests/test_pipeline_ffmpeg_ops.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# add to tests/test_pipeline_ffmpeg_ops.py
|
||||
from tools.pipeline.ffmpeg_ops import transcode_args
|
||||
|
||||
|
||||
def test_transcode_args_high_profile_even_dims_faststart():
|
||||
args = transcode_args("loop.mp4", "proxy.mp4", width=1920, height=1080,
|
||||
crf=20, fps=30)
|
||||
assert "-profile:v" in args and args[args.index("-profile:v") + 1] == "high"
|
||||
assert "-crf" in args and args[args.index("-crf") + 1] == "20"
|
||||
vf = args[args.index("-vf") + 1]
|
||||
assert "scale=1920:1080:force_original_aspect_ratio=decrease" in vf
|
||||
assert "fps=30" in vf
|
||||
assert "+faststart" in args
|
||||
assert "-an" in args
|
||||
assert args[-1] == "proxy.mp4"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: FAIL with `ImportError: cannot import name 'transcode_args'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# add to tools/pipeline/ffmpeg_ops.py
|
||||
def transcode_args(src, dst, *, width: int, height: int, crf: int,
|
||||
fps: int = 30, ff: str = "ffmpeg") -> list[str]:
|
||||
"""Stage 4 — encode a final deliverable (master or proxy). Master: source res
|
||||
capped ~3840 wide, crf 18. Proxy: 1920×1080, crf 20. Both H.264 High, yuv420p,
|
||||
even dims, +faststart, video-only (spec §6)."""
|
||||
vf = (
|
||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,"
|
||||
f"setsar=1,fps={fps},format=yuv420p"
|
||||
)
|
||||
return [
|
||||
ff, "-y", "-i", str(src), "-vf", vf, "-an",
|
||||
"-c:v", "libx264", "-profile:v", "high", "-preset", "slow",
|
||||
"-crf", str(crf), "-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart", str(dst),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_ffmpeg_ops.py -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/ffmpeg_ops.py tests/test_pipeline_ffmpeg_ops.py
|
||||
git commit -m "feat(pipeline): master/proxy transcode arg builder (content pipeline §3.4/§6)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Provenance record (stage 1 output)
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/pipeline/provenance.py`
|
||||
- Test: `tests/test_pipeline_provenance.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_provenance.py
|
||||
from tools.pipeline.provenance import Provenance
|
||||
|
||||
|
||||
def test_provenance_to_dict_roundtrip():
|
||||
p = Provenance(
|
||||
scale="abyss",
|
||||
license="public-domain (US Gov, 17 U.S.C. §105)",
|
||||
source="NOAA Ocean Exploration",
|
||||
url="https://oceanexplorer.noaa.gov/",
|
||||
fetched_at="2026-06-24",
|
||||
)
|
||||
d = p.to_dict()
|
||||
assert d == {
|
||||
"scale": "abyss",
|
||||
"license": "public-domain (US Gov, 17 U.S.C. §105)",
|
||||
"source": "NOAA Ocean Exploration",
|
||||
"url": "https://oceanexplorer.noaa.gov/",
|
||||
"fetched_at": "2026-06-24",
|
||||
}
|
||||
assert Provenance.from_dict(d) == p
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_provenance.py -q`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'tools.pipeline.provenance'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# tools/pipeline/provenance.py
|
||||
"""Stage 1 — the per-clip strict-PD provenance record (spec §3.1).
|
||||
|
||||
Captured at source time so license + source + URL travel with the media. The
|
||||
'no explicit license -> assume PD, verify' trap (scales design §2.1) applies:
|
||||
"free stock" (Pexels/Pixabay) is NOT public domain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Provenance:
|
||||
scale: str
|
||||
license: str
|
||||
source: str
|
||||
url: str
|
||||
fetched_at: str # ISO date; passed in (no clock here -> deterministic)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Provenance":
|
||||
return cls(
|
||||
scale=d["scale"], license=d["license"], source=d["source"],
|
||||
url=d["url"], fetched_at=d["fetched_at"],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_provenance.py -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/provenance.py tests/test_pipeline_provenance.py
|
||||
git commit -m "feat(pipeline): strict-PD provenance record (content pipeline §3.1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Manifest assembly (stage 6)
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/pipeline/manifest.py`
|
||||
- Test: `tests/test_pipeline_manifest.py`
|
||||
|
||||
Pure dict transforms over a loaded manifest: upsert a clip entry, place a scale in
|
||||
the ring order, and rebuild the per-edge transition list so it always has exactly
|
||||
N entries for N scales (one transition per ring edge, including the wrap).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_manifest.py
|
||||
from tools.pipeline.manifest import upsert_clip, add_ring_scale, rebuild_ring_edges
|
||||
|
||||
|
||||
def _base():
|
||||
return {"clips": [{"id": "cosmos", "title": "Cosmos"}],
|
||||
"ring": {"scales": [{"id": "cosmos", "clip_id": "cosmos"}],
|
||||
"transitions": []}}
|
||||
|
||||
|
||||
def test_upsert_clip_replaces_by_id_else_appends():
|
||||
m = _base()
|
||||
m = upsert_clip(m, {"id": "reef", "title": "Reef"})
|
||||
assert [c["id"] for c in m["clips"]] == ["cosmos", "reef"]
|
||||
m = upsert_clip(m, {"id": "reef", "title": "Coral Reef"}) # replace, not dup
|
||||
assert [c["id"] for c in m["clips"]] == ["cosmos", "reef"]
|
||||
assert m["clips"][1]["title"] == "Coral Reef"
|
||||
|
||||
|
||||
def test_add_ring_scale_inserts_after_named_scale():
|
||||
m = _base()
|
||||
m = add_ring_scale(m, "orbit", "orbit", after="cosmos")
|
||||
assert [s["id"] for s in m["ring"]["scales"]] == ["cosmos", "orbit"]
|
||||
|
||||
|
||||
def test_rebuild_ring_edges_one_transition_per_edge_with_wrap():
|
||||
m = _base()
|
||||
m = add_ring_scale(m, "orbit", "orbit", after="cosmos")
|
||||
m = add_ring_scale(m, "abyss", "abyss", after="orbit")
|
||||
m = rebuild_ring_edges(m)
|
||||
files = [t["file"] for t in m["ring"]["transitions"]]
|
||||
assert files == [
|
||||
"transitions/cosmos-orbit.mp4",
|
||||
"transitions/orbit-abyss.mp4",
|
||||
"transitions/abyss-cosmos.mp4", # wrap edge
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py -q`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'tools.pipeline.manifest'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# tools/pipeline/manifest.py
|
||||
"""Stage 6 — assemble manifest entries (spec §3.6). Pure dict transforms over a
|
||||
loaded manifest dict; the caller does the json read/write. Idempotent by id so
|
||||
re-running earlier stages never clobbers authored labels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
|
||||
def upsert_clip(manifest: dict, clip: dict) -> dict:
|
||||
"""Replace the clip with the same id, or append it. Returns a new manifest."""
|
||||
m = copy.deepcopy(manifest)
|
||||
clips = m.setdefault("clips", [])
|
||||
for i, c in enumerate(clips):
|
||||
if c["id"] == clip["id"]:
|
||||
clips[i] = clip
|
||||
return m
|
||||
clips.append(clip)
|
||||
return m
|
||||
|
||||
|
||||
def add_ring_scale(manifest: dict, scale_id: str, clip_id: str,
|
||||
after: str | None = None) -> dict:
|
||||
"""Insert a scale into ring.scales after the named scale (or append if `after`
|
||||
is None / not found). No-op if scale_id already present. Returns a new manifest."""
|
||||
m = copy.deepcopy(manifest)
|
||||
ring = m.setdefault("ring", {"scales": [], "transitions": []})
|
||||
scales = ring.setdefault("scales", [])
|
||||
if any(s["id"] == scale_id for s in scales):
|
||||
return m
|
||||
entry = {"id": scale_id, "clip_id": clip_id}
|
||||
idx = next((i for i, s in enumerate(scales) if s["id"] == after), None)
|
||||
if idx is None:
|
||||
scales.append(entry)
|
||||
else:
|
||||
scales.insert(idx + 1, entry)
|
||||
return m
|
||||
|
||||
|
||||
def rebuild_ring_edges(manifest: dict) -> dict:
|
||||
"""Rebuild ring.transitions to one placeholder entry per ring edge (N scales ->
|
||||
N edges incl. the wrap), preserving any existing model string for an edge.
|
||||
Returns a new manifest."""
|
||||
m = copy.deepcopy(manifest)
|
||||
ring = m.setdefault("ring", {"scales": [], "transitions": []})
|
||||
scales = ring.get("scales", [])
|
||||
existing = {t["file"]: t.get("model", "") for t in ring.get("transitions", [])}
|
||||
edges = []
|
||||
n = len(scales)
|
||||
for i in range(n):
|
||||
a, b = scales[i]["id"], scales[(i + 1) % n]["id"]
|
||||
file = f"transitions/{a}-{b}.mp4"
|
||||
edges.append({"file": file, "model": existing.get(file, "placeholder-zoom")})
|
||||
ring["transitions"] = edges
|
||||
return m
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/manifest.py tests/test_pipeline_manifest.py
|
||||
git commit -m "feat(pipeline): manifest assembly — upsert clip + ring scale/edges (content pipeline §3.6)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Runner + opt-in integration test on the real forest POC base
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/pipeline/run.py`
|
||||
- Test: `tests/test_pipeline_integration.py`
|
||||
|
||||
The runner shells out ffmpeg with the builders from Tasks 1–3, ffprobing the source
|
||||
duration so the loop stage gets real `D`. The integration test runs the **real**
|
||||
forest POC base (`simulator/sample_media/forest/base.mp4`, populated by
|
||||
`simulator/setup_sample_media.py`) through frame → loop → transcode and ffprobes the
|
||||
proxy — gated on `ffmpeg`/`ffprobe` and on the base existing (it's gitignored media),
|
||||
matching `tests/test_tools_integration.py`'s gating.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_integration.py
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.pipeline.run import process_clip
|
||||
|
||||
_HAS_FF = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
|
||||
_FOREST = Path("simulator/sample_media/forest/base.mp4")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_FF, reason="ffmpeg/ffprobe not installed")
|
||||
@pytest.mark.skipif(not _FOREST.exists(),
|
||||
reason="forest POC base absent (run simulator/setup_sample_media.py)")
|
||||
def test_process_clip_emits_proxy_and_master(tmp_path):
|
||||
out = process_clip(_FOREST, tmp_path, overlap=0.5, start=0.0, duration=4.0)
|
||||
assert out["proxy"].exists() and out["master"].exists()
|
||||
# Proxy is exactly 1920x1080.
|
||||
probe = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height", "-of", "csv=p=0", str(out["proxy"])],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
assert probe == "1920,1080"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_integration.py -q`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'tools.pipeline.run'`
|
||||
(or SKIP if ffmpeg/the base are absent — then verify by importing in `python -c`.)
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
# tools/pipeline/run.py
|
||||
"""Thin runner: ffprobe the source, then execute the frame -> loop -> transcode
|
||||
stages with tools.pipeline.ffmpeg_ops. The pure arg builders are unit-tested;
|
||||
this glue is covered by the opt-in integration test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .ffmpeg_ops import crossfade_loop_args, frame_args, transcode_args
|
||||
|
||||
MASTER_MAX_W = 3840
|
||||
|
||||
|
||||
def probe_duration(src, *, ff: str = "ffprobe") -> float:
|
||||
out = subprocess.run(
|
||||
[ff, "-v", "quiet", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", str(src)],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
return float(out)
|
||||
|
||||
|
||||
def _run(args: list[str]) -> None:
|
||||
subprocess.run(args, check=True, capture_output=True)
|
||||
|
||||
|
||||
def process_clip(src, out_dir, *, start: float = 0.0, duration: float | None = None,
|
||||
overlap: float = 1.0, master_w: int = 3840, master_h: int = 2160,
|
||||
ff: str = "ffmpeg") -> dict:
|
||||
"""Run stages 2–4 for one clip; return {'master','proxy','loop','mezzanine'} paths.
|
||||
`duration` defaults to the full source length (minus the trim start)."""
|
||||
src = Path(src)
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
if duration is None:
|
||||
duration = probe_duration(src) - start
|
||||
|
||||
mezz = out_dir / "mezzanine.mp4"
|
||||
loop = out_dir / "loop.mp4"
|
||||
master = out_dir / "master.mp4"
|
||||
proxy = out_dir / "base.mp4" # the proxy is what the manifest's base_file points at
|
||||
|
||||
_run(frame_args(src, mezz, start=start, duration=duration,
|
||||
width=master_w, height=master_h, ff=ff))
|
||||
_run(crossfade_loop_args(mezz, loop, duration=duration, overlap=overlap, ff=ff))
|
||||
_run(transcode_args(loop, master, width=master_w, height=master_h, crf=18, ff=ff))
|
||||
_run(transcode_args(loop, proxy, width=1920, height=1080, crf=20, ff=ff))
|
||||
return {"mezzanine": mezz, "loop": loop, "master": master, "proxy": proxy}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes (or skips cleanly)**
|
||||
|
||||
Run: `pytest tests/test_pipeline_integration.py -q`
|
||||
Expected: PASS if ffmpeg + the forest base are present; otherwise SKIP with the gate
|
||||
reason. If skipped, also run `python -c "from tools.pipeline.run import process_clip"`
|
||||
and expect no error.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/pipeline/run.py tests/test_pipeline_integration.py
|
||||
git commit -m "feat(pipeline): runner + opt-in integration test on real forest base (content pipeline §3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Client annotation-track interpolation + a hand-authored track
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` (`renderOverlay` at lines ~235–269; `paintLoop` at ~173–186)
|
||||
- Modify: `simulator/sample_media/manifest.json` (add a `track` to the forest `detected.water` annotation)
|
||||
|
||||
The track is *data* on an existing annotation; the Python `Clip` already passes
|
||||
`annotations` through opaquely (`simulator/clips.py` `_clip_from_dict` does
|
||||
`d.get("annotations", [])`), so **no Python change is needed**. Interpolation is
|
||||
client-side (spec §4). Because the overlay currently re-renders only on knob change,
|
||||
a tracked box must update as the video plays — drive a re-render from the existing
|
||||
`paintLoop` rAF when the active clip has any track and the overlay is visible.
|
||||
|
||||
- [ ] **Step 1: Add the data — a hand-authored track on forest's water detection**
|
||||
|
||||
In `simulator/sample_media/manifest.json`, replace the forest `detected.water`
|
||||
annotation (currently `{"key": "detected.water", "box": [0.30, 0.10, 0.18, 0.70], "min_level": 1}`)
|
||||
with a keyframed track (a small horizontal drift, looping at t=0 and t=1 so it is
|
||||
seamless):
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "detected.water",
|
||||
"min_level": 1,
|
||||
"track": [
|
||||
{"t": 0.0, "box": [0.30, 0.10, 0.18, 0.70]},
|
||||
{"t": 0.5, "box": [0.33, 0.10, 0.18, 0.70]},
|
||||
{"t": 1.0, "box": [0.30, 0.10, 0.18, 0.70]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the interpolation helper + use it in `renderOverlay`**
|
||||
|
||||
In `simulator/static/app.js`, add a helper above `renderOverlay` and use it instead
|
||||
of `a.box`:
|
||||
|
||||
```javascript
|
||||
// Interpolate an annotation's box at loop-normalized time t (0..1). Static labels
|
||||
// (no track) return their fixed box. Linear between sorted keyframes; clamps to ends.
|
||||
function boxAt(a, t) {
|
||||
if (!a.track || !a.track.length) return a.box;
|
||||
const ks = a.track;
|
||||
if (t <= ks[0].t) return ks[0].box;
|
||||
if (t >= ks[ks.length - 1].t) return ks[ks.length - 1].box;
|
||||
for (let i = 1; i < ks.length; i++) {
|
||||
if (t <= ks[i].t) {
|
||||
const a0 = ks[i - 1], a1 = ks[i];
|
||||
const f = (t - a0.t) / (a1.t - a0.t);
|
||||
return a0.box.map((v, j) => v + (a1.box[j] - v) * f);
|
||||
}
|
||||
}
|
||||
return ks[ks.length - 1].box;
|
||||
}
|
||||
|
||||
// Loop-normalized playback time of the base video (0..1), for track interpolation.
|
||||
function loopT() {
|
||||
return vid && vid.duration ? (vid.currentTime % vid.duration) / vid.duration : 0;
|
||||
}
|
||||
```
|
||||
|
||||
Then in `renderOverlay`, change the box line:
|
||||
|
||||
```javascript
|
||||
const [x, y, w, h] = boxAt(a, loopT()).map((n) => n * 100);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Re-render tracked overlays each frame from `paintLoop`**
|
||||
|
||||
Store the last overlay args and, in `paintLoop`, re-run `renderOverlay` when the
|
||||
active clip has any tracked annotation and the overlay is visible. Add near the top
|
||||
of `app.js` (module scope): `let lastOverlay = null;`. At the end of `renderOverlay`,
|
||||
record the call: set `lastOverlay = { level, intensity };` (add this line just before
|
||||
the closing brace). In `paintLoop` (after the existing shader work, before
|
||||
`requestAnimationFrame(paintLoop)`), add:
|
||||
|
||||
```javascript
|
||||
// Animate keyframed annotation tracks: re-place boxes against playback time.
|
||||
const c = activeClip();
|
||||
if (lastOverlay && lastOverlay.level > 0 && c &&
|
||||
c.annotations.some((a) => a.track && a.track.length)) {
|
||||
renderOverlay(lastOverlay.level, lastOverlay.intensity);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify by eye (no JS unit harness in this repo)**
|
||||
|
||||
Run the sim and watch the forest water reticle drift with playback, looping
|
||||
seamlessly; static labels (rock_face, conifer, the measurement) stay put.
|
||||
|
||||
```bash
|
||||
python -m simulator.app # then open the printed localhost URL
|
||||
```
|
||||
|
||||
Expected: with Left up on forest, the `flowing water` box drifts right then back
|
||||
over the loop with no jump at the loop seam; affect/measure labels unchanged.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/app.js simulator/sample_media/manifest.json
|
||||
git commit -m "feat(sim): client annotation-track interpolation + hand-authored forest track (content pipeline §4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Extend the ring to 5 scales (orbit + reef placeholders)
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/setup_scales_media.py` (`SCALES` lines ~32–36; `EDGES` line ~39)
|
||||
- Modify: `simulator/sample_media/manifest.json` (add `orbit` + `reef` clips; reorder `ring.scales`; rebuild `ring.transitions`)
|
||||
|
||||
Real strict-PD bases are sourced separately (Task 9); to keep the 5-scale ring
|
||||
demonstrable now, generate labelled placeholders for the two new scales with the
|
||||
existing generator, exactly as cosmos/abyss are today.
|
||||
|
||||
- [ ] **Step 1: Add orbit + reef to the placeholder generator**
|
||||
|
||||
In `simulator/setup_scales_media.py`, extend `SCALES` and `EDGES` to the 5-scale
|
||||
ring (order: cosmos → orbit → forest → reef → abyss → wrap):
|
||||
|
||||
```python
|
||||
SCALES = {
|
||||
"cosmos": ("0x05060f", "COSMOS — NASA/Hubble (PD placeholder)"),
|
||||
"orbit": ("0x081427", "ORBIT — NASA/ISS (PD placeholder)"),
|
||||
"forest": ("0x12301a", "FOREST — NPS/USGS (POC/placeholder)"),
|
||||
"reef": ("0x06303a", "REEF — NOAA Ocean Exploration (PD placeholder)"),
|
||||
"abyss": ("0x021016", "ABYSS — NOAA Ocean Exploration (PD placeholder)"),
|
||||
}
|
||||
|
||||
EDGES = [("cosmos", "orbit"), ("orbit", "forest"), ("forest", "reef"),
|
||||
("reef", "abyss"), ("abyss", "cosmos")]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the orbit + reef clip entries to the manifest**
|
||||
|
||||
In `simulator/sample_media/manifest.json`, add two clip objects (after `cosmos` and
|
||||
after `forest` respectively) mirroring the existing placeholder clips' shape, e.g.
|
||||
orbit:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "orbit",
|
||||
"title": "Earth from orbit (NASA/ISS, neutral base)",
|
||||
"base_file": "orbit/base.mp4",
|
||||
"license": "public-domain (US Gov, 17 U.S.C. §105)",
|
||||
"source": "NASA/ISS — https://images.nasa.gov/ (true PD; placeholder bytes until ingest)",
|
||||
"right_variants": {},
|
||||
"annotations": [
|
||||
{"key": "detected.cloud_band", "box": [0.30, 0.30, 0.30, 0.20], "min_level": 1},
|
||||
{"key": "measure.altitude", "box": [0.06, 0.06, 0.18, 0.08], "min_level": 2}
|
||||
],
|
||||
"affect": [
|
||||
{"key": "feel.serenity", "at": [0.50, 0.46], "min_level": 1},
|
||||
{"key": "feel.fragility", "at": [0.22, 0.68], "min_level": 2},
|
||||
{"key": "feel.unity", "at": [0.66, 0.58], "min_level": 3},
|
||||
{"key": "feel.distance", "at": [0.42, 0.82], "min_level": 4}
|
||||
],
|
||||
"strings": {
|
||||
"en": {
|
||||
"detected.cloud_band": "cloud band",
|
||||
"measure.altitude": "~408 km",
|
||||
"feel.serenity": "serenity", "feel.fragility": "fragility",
|
||||
"feel.unity": "unity", "feel.distance": "distance"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And reef:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "reef",
|
||||
"title": "Coral reef (NOAA Ocean Exploration, neutral base)",
|
||||
"base_file": "reef/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.coral", "box": [0.20, 0.45, 0.30, 0.30], "min_level": 1},
|
||||
{"key": "detected.fish", "min_level": 2,
|
||||
"track": [
|
||||
{"t": 0.0, "box": [0.20, 0.30, 0.10, 0.08]},
|
||||
{"t": 0.5, "box": [0.62, 0.34, 0.10, 0.08]},
|
||||
{"t": 1.0, "box": [0.20, 0.30, 0.10, 0.08]}
|
||||
]},
|
||||
{"key": "measure.depth", "box": [0.06, 0.06, 0.16, 0.08], "min_level": 3}
|
||||
],
|
||||
"affect": [
|
||||
{"key": "feel.delight", "at": [0.50, 0.46], "min_level": 1},
|
||||
{"key": "feel.abundance", "at": [0.22, 0.68], "min_level": 2},
|
||||
{"key": "feel.curiosity", "at": [0.66, 0.58], "min_level": 3},
|
||||
{"key": "feel.immersion", "at": [0.42, 0.82], "min_level": 4}
|
||||
],
|
||||
"strings": {
|
||||
"en": {
|
||||
"detected.coral": "coral colony", "detected.fish": "reef fish",
|
||||
"measure.depth": "−18 m",
|
||||
"feel.delight": "delight", "feel.abundance": "abundance",
|
||||
"feel.curiosity": "curiosity", "feel.immersion": "immersion"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Reorder the ring + rebuild edges in the manifest**
|
||||
|
||||
Set `ring.scales` to the 5-scale order and `ring.transitions` to the 5 edges:
|
||||
|
||||
```json
|
||||
"ring": {
|
||||
"scales": [
|
||||
{"id": "cosmos", "clip_id": "cosmos"},
|
||||
{"id": "orbit", "clip_id": "orbit"},
|
||||
{"id": "forest", "clip_id": "forest"},
|
||||
{"id": "reef", "clip_id": "reef"},
|
||||
{"id": "abyss", "clip_id": "abyss"}
|
||||
],
|
||||
"transitions": [
|
||||
{"file": "transitions/cosmos-orbit.mp4", "model": "placeholder-zoom"},
|
||||
{"file": "transitions/orbit-forest.mp4", "model": "placeholder-zoom"},
|
||||
{"file": "transitions/forest-reef.mp4", "model": "placeholder-zoom"},
|
||||
{"file": "transitions/reef-abyss.mp4", "model": "placeholder-zoom"},
|
||||
{"file": "transitions/abyss-cosmos.mp4", "model": "placeholder-zoom"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Regenerate placeholder media + verify the 5-scale ring**
|
||||
|
||||
```bash
|
||||
python simulator/setup_scales_media.py
|
||||
python -m simulator.app # open the URL; spin the encoder full circle
|
||||
```
|
||||
|
||||
Expected: the encoder walks cosmos → orbit → forest → reef → abyss → (wrap) cosmos
|
||||
with a transition on every edge; `GET /api/ring` lists 5 scales + 5 transitions; the
|
||||
reef `reef fish` box drifts with playback (Task 7 interpolation) when Left ≥ 2.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/setup_scales_media.py simulator/sample_media/manifest.json
|
||||
git commit -m "feat(sim): extend scale ring to 5 (orbit + reef) (content pipeline §5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Sourcing procedure doc + ROADMAP update
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/content-sourcing.md`
|
||||
- Modify: `docs/ROADMAP.md` (sub-project 3 "Offline v2v variant pipeline" bullet, lines ~174–187)
|
||||
|
||||
Sourcing the real strict-PD bytes is a curatorial task, not a code step. Document the
|
||||
procedure so anyone can run the pipeline per clip, and record the new content-pipeline
|
||||
work on the roadmap.
|
||||
|
||||
- [ ] **Step 1: Write the sourcing procedure**
|
||||
|
||||
```markdown
|
||||
# Content sourcing — strict-PD neutral bases
|
||||
|
||||
For each scale, acquire a calm, neutral source clip from a **strict public-domain**
|
||||
US-government source, verify the license, then run it through the pipeline.
|
||||
|
||||
| Scale | Source | Where |
|
||||
|--------|--------------------------------|------------------------------------|
|
||||
| cosmos | NASA / Hubble / JWST | https://images.nasa.gov/ |
|
||||
| orbit | NASA / ISS | https://images.nasa.gov/ |
|
||||
| forest | NPS / USGS | https://www.nps.gov/ , https://www.usgs.gov/ |
|
||||
| reef | NOAA Ocean Exploration | https://oceanexplorer.noaa.gov/ |
|
||||
| abyss | NOAA Ocean Exploration | https://oceanexplorer.noaa.gov/ |
|
||||
|
||||
**License check (mandatory):** confirm the asset is US-gov PD (17 U.S.C. §105) or
|
||||
explicitly CC0. "Free stock" (Pexels/Pixabay/etc.) is royalty-free but NOT public
|
||||
domain — do not use. Record a `Provenance` (tools/pipeline/provenance.py) per clip.
|
||||
|
||||
**Run the pipeline** (replaces the placeholder bytes for a scale):
|
||||
|
||||
```bash
|
||||
python -c "from tools.pipeline.run import process_clip; \
|
||||
process_clip('downloads/<scale>.mp4', 'simulator/sample_media/<scale>', \
|
||||
start=<in_s>, duration=<len_s>, overlap=<0.5..1.0>)"
|
||||
```
|
||||
|
||||
This writes `simulator/sample_media/<scale>/base.mp4` (proxy) + `master.mp4`. Then
|
||||
author the labels (Increment 2: the hybrid track pass + simulator author mode) or
|
||||
hand-edit the annotation track for now (spec §4). The ring + manifest entry already
|
||||
exist (Task 8); `process_clip` only replaces the media bytes.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the roadmap**
|
||||
|
||||
In `docs/ROADMAP.md`, under sub-project 3, replace the "Offline v2v variant pipeline"
|
||||
framing with the content-pipeline reality: the Right dream is real-time (no ML bake),
|
||||
so the content pipeline is **source → crossfade-loop → master+proxy transcode →
|
||||
hand-authored/motion-tracked labels → manifest**. Note Increment 1 (this plan: tooling
|
||||
+ track schema + 5-scale ring, no ML) done on merge; Increment 2 (hybrid track pass +
|
||||
simulator author mode) and real i2v ring transitions still pending. Cite the spec
|
||||
`docs/superpowers/specs/2026-06-24-content-pipeline-design.md`.
|
||||
|
||||
- [ ] **Step 3: Run the full suite**
|
||||
|
||||
Run: `pytest -q`
|
||||
Expected: all prior tests still pass + the new pipeline tests (ffmpeg_ops,
|
||||
provenance, manifest) pass; the integration test passes or skips cleanly.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/content-sourcing.md docs/ROADMAP.md
|
||||
git commit -m "docs: content-sourcing procedure + roadmap update (content pipeline Increment 1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **Run from the main clone, not a nested worktree** — `resolve-app.py` errors
|
||||
"ambiguous" when a `.worktrees/` checkout shadows the main clone (session-0013
|
||||
gotcha). If you must use a worktree, expect the `WGL_APP_SCAN_DEPTH` / explicit
|
||||
`publish-transcript.sh` flag workarounds.
|
||||
- **PRs go through the Gitea API** (`wgl-gitea-admin` `gitea-api.sh`, host
|
||||
`git.benstull.org`) — no `tea`/`gh` here: create via `POST /repos/.../pulls`,
|
||||
merge via `.../merge`.
|
||||
- The project `.venv` has the deps (pytest, cv2/torch for later ML work). Activate it
|
||||
before running tests.
|
||||
- **Increment 2 (separate plan):** `tools/pipeline/track.py` (classical OpenCV
|
||||
tracker + optional lazy-imported ML detect+track) and the simulator **author mode**
|
||||
(draw/seed/correct boxes, assign keys/min_level/strings, place affect, write the
|
||||
manifest). Plus real i2v ring transitions once a generative-video model + adjacent
|
||||
real bases exist.
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,939 @@
|
||||
# Altitude Clip-Pair Morph + Lock 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:** When the viewer changes altitude, choose the destination clip *first*, play the morph footage that goes from the currently-shown clip to that chosen clip, then lock the clip in place so it never changes again until the altitude changes.
|
||||
|
||||
**Architecture:** Today the ring carries **5** per-edge morphs (each `scale_a → scale_b`, primary→primary), the server picks a random pool member only for the *landed* scale, and `advance()` plays the edge morph (which ends on the scale's *primary*) then does a *second*, jarring `fadeThroughBlack` swap to the randomly-chosen member. We replace the per-edge model with a **per-clip-pair** model: every pair of clips in adjacent altitudes gets its own morph, in both directions (**154** clips total). The server picks the destination member(s) *before* selecting the morph, threads the currently-shown clip in as the source, and chains morphs through each crossed altitude on a multi-detent jump. The renderer plays those exact morphs and locks on the final clip — no secondary swap, no re-roll while parked.
|
||||
|
||||
**Tech Stack:** Python 3.13 / FastAPI (`simulator/app.py`), pure ring logic (`player/ring.py`), pure manifest/schema (`simulator/clips.py`), media baking via ffmpeg `xfade=zoomin` (`simulator/build_pool_manifest.py`), vanilla JS renderer (`simulator/static/app.js`), pytest, and a new Playwright E2E tier.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Morph file naming:** `transitions/<src_clip_id>__<dst_clip_id>.mp4` (double-underscore separator — clip ids themselves use single `_`, e.g. `cosmos_miri`). The zoom-out companion is `<src>__<dst>.rev.mp4` and represents the **reverse** direction (`dst → src`).
|
||||
- **Direction convention:** a morph is *baked* descending (higher altitude → lower altitude = zoom IN). The `.rev.mp4` companion is the ascending (zoom OUT) playback. The manifest lists **both** directions as explicit, separately-keyed entries so the renderer needs no reverse logic at play time.
|
||||
- **Ring order (descending, wraps):** `["cosmos", "orbit", "coast", "reef", "abyss"]`; `abyss → cosmos` is the wrap edge.
|
||||
- **Pool sizes (current):** cosmos 5, orbit 3, coast 4, reef 5, abyss 3 → **77** adjacent ordered-pair morphs descending + **77** reverse = **154** total transition clips.
|
||||
- **Lock invariant:** the displayed base clip changes **only** inside `advance()` (a deliberate altitude change) and the one initial `landScale()`. Nothing else may reload base media. The Dev-Mode re-roll button stays as an explicit dev affordance.
|
||||
- **Randomness stays injected:** `player/ring.py` is a pure module; the impure `random.random()` draw happens once at the API boundary (`simulator/app.py`). Pure functions take injected `float` picks in `[0,1)`.
|
||||
- **Back-compat:** a manifest with no `pool` (pool of one) and the synthesized single-clip fallback ring must still work. A pool-of-one scale needs no morphs to itself.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Manifest emits clip-pair transition entries
|
||||
|
||||
Re-key the manifest builder from 5 per-edge entries to 154 per-clip-pair entries (both directions), each carrying `from`/`to` clip ids. Pure dict assembly — no ffmpeg.
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/build_pool_manifest.py:374-379` (`build_manifest()` transitions block)
|
||||
- Test: `tests/test_pipeline_manifest.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: module constants `RING_ORDER: list[str]`, `POOLS: dict[str, list[str]]`.
|
||||
- Produces: each transition dict shaped `{"from": <src_id>, "to": <dst_id>, "file": "transitions/<src>__<dst>.mp4", "model": "placeholder-zoom"}`. Forward entries are descending (`H→L`, `transitions/H__L.mp4`); reverse entries are ascending (`L→H`, `transitions/H__L.rev.mp4`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_manifest.py (add)
|
||||
def test_transitions_are_per_clip_pair_both_directions():
|
||||
import simulator.build_pool_manifest as b
|
||||
m = b.build_manifest()
|
||||
trans = m["ring"]["transitions"]
|
||||
order = b.RING_ORDER
|
||||
n = len(order)
|
||||
# Every adjacent (higher H, lower L) edge, every member pair, BOTH directions.
|
||||
expected = set()
|
||||
for i in range(n):
|
||||
H, L = order[i], order[(i + 1) % n]
|
||||
for h in b.POOLS[H]:
|
||||
for l in b.POOLS[L]:
|
||||
expected.add((h, l, f"transitions/{h}__{l}.mp4")) # zoom in
|
||||
expected.add((l, h, f"transitions/{h}__{l}.rev.mp4")) # zoom out
|
||||
got = {(t["from"], t["to"], t["file"]) for t in trans}
|
||||
assert got == expected
|
||||
assert len(trans) == len(expected) == 154
|
||||
assert all(t["model"] == "placeholder-zoom" for t in trans)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py::test_transitions_are_per_clip_pair_both_directions -v`
|
||||
Expected: FAIL — current builder emits 5 `scale-scale` entries with no `from`/`to`.
|
||||
|
||||
- [ ] **Step 3: Implement the per-clip-pair transitions block**
|
||||
|
||||
```python
|
||||
# simulator/build_pool_manifest.py — replace the transitions loop in build_manifest()
|
||||
transitions = []
|
||||
n = len(RING_ORDER)
|
||||
for i in range(n):
|
||||
H, L = RING_ORDER[i], RING_ORDER[(i + 1) % n] # H higher altitude, L lower
|
||||
for h in POOLS[H]:
|
||||
for l in POOLS[L]:
|
||||
fwd = f"transitions/{h}__{l}.mp4" # zoom IN (descend H->L)
|
||||
transitions.append({"from": h, "to": l, "file": fwd,
|
||||
"model": "placeholder-zoom"})
|
||||
transitions.append({"from": l, "to": h, "file": f"transitions/{h}__{l}.rev.mp4",
|
||||
"model": "placeholder-zoom"}) # zoom OUT (ascend L->H)
|
||||
return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py::test_transitions_are_per_clip_pair_both_directions -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/build_pool_manifest.py tests/test_pipeline_manifest.py
|
||||
git commit -m "feat(pipeline): manifest emits per-clip-pair morph transitions (both directions)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Bake the member-pair morph clips
|
||||
|
||||
Generalize the ffmpeg morph recipe from scale-primary→scale-primary to any member→member, and have `generate_media()` bake every adjacent member pair forward + reverse. Tested with a stubbed ffmpeg runner so the test is fast and deterministic (the real bake runs as a media step at execution time).
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/build_pool_manifest.py:391-431` (`_make_transition`, `generate_media`)
|
||||
- Test: `tests/test_pipeline_manifest.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RING_ORDER`, `POOLS`, `MEDIA` (Path), member base footage at `MEDIA/<clip_id>/base.mp4`.
|
||||
- Produces: `_make_transition(ff, src_clip, dst_clip) -> Path` writing `transitions/<src>__<dst>.mp4` from the two members' `base.mp4`; `_make_reverse(ff, forward)` unchanged (writes `.rev.mp4`). `generate_media()` loops every adjacent ordered member pair.
|
||||
|
||||
- [ ] **Step 1: Write the failing test (stubbed ffmpeg)**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_manifest.py (add)
|
||||
def test_generate_media_bakes_every_member_pair(monkeypatch, tmp_path):
|
||||
import simulator.build_pool_manifest as b
|
||||
calls = []
|
||||
def fake_run(args, **kw):
|
||||
# record the output path (last positional arg of each ffmpeg call)
|
||||
calls.append(args[-1])
|
||||
from pathlib import Path
|
||||
Path(args[-1]).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args[-1]).write_bytes(b"x")
|
||||
class R: returncode = 0
|
||||
return R()
|
||||
monkeypatch.setattr(b, "MEDIA", tmp_path)
|
||||
monkeypatch.setattr(b.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(b, "_ffmpeg", lambda: "ffmpeg")
|
||||
b.generate_media()
|
||||
outs = {str(p).split("transitions/")[-1] for p in calls}
|
||||
# one forward + one reverse per adjacent member pair
|
||||
fwd = {f"{h}__{l}.mp4" for H, L in b._adjacent_edges()
|
||||
for h in b.POOLS[H] for l in b.POOLS[L]}
|
||||
rev = {f"{h}__{l}.rev.mp4" for H, L in b._adjacent_edges()
|
||||
for h in b.POOLS[H] for l in b.POOLS[L]}
|
||||
assert fwd <= outs and rev <= outs
|
||||
assert len(fwd) + len(rev) == 154
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py::test_generate_media_bakes_every_member_pair -v`
|
||||
Expected: FAIL — `_adjacent_edges` undefined and `generate_media` loops scales, not members.
|
||||
|
||||
- [ ] **Step 3: Implement member-pair baking**
|
||||
|
||||
```python
|
||||
# simulator/build_pool_manifest.py
|
||||
|
||||
def _adjacent_edges() -> list[tuple[str, str]]:
|
||||
"""Ordered (higher, lower) altitude edges around the ring, including the wrap."""
|
||||
n = len(RING_ORDER)
|
||||
return [(RING_ORDER[i], RING_ORDER[(i + 1) % n]) for i in range(n)]
|
||||
|
||||
|
||||
def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path:
|
||||
"""A zoom/warp morph between two CLIP MEMBERS' base footage (the well-liked
|
||||
orbit-coast recipe), keyed by clip id so every adjacent member pair gets its
|
||||
own morph. Forward = zoom IN (descend src->dst)."""
|
||||
a = MEDIA / src_clip / "base.mp4"
|
||||
b = MEDIA / dst_clip / "base.mp4"
|
||||
out = MEDIA / "transitions" / f"{src_clip}__{dst_clip}.mp4"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
|
||||
subprocess.run([
|
||||
ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
|
||||
f"[0:v]{norm}[a];[1:v]{norm}[b];"
|
||||
"[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]",
|
||||
"-map", "[v]", "-an", str(out),
|
||||
], check=True, capture_output=True)
|
||||
return out
|
||||
|
||||
|
||||
def generate_media() -> None:
|
||||
"""Bake EVERY adjacent member-pair morph from real bases — forward (zoom in,
|
||||
descend) plus its reversed companion (zoom out, ascend)."""
|
||||
ff = _ffmpeg()
|
||||
for H, L in _adjacent_edges():
|
||||
for h in POOLS[H]:
|
||||
for l in POOLS[L]:
|
||||
fwd = _make_transition(ff, h, l)
|
||||
_make_reverse(ff, fwd)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py::test_generate_media_bakes_every_member_pair -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/build_pool_manifest.py tests/test_pipeline_manifest.py
|
||||
git commit -m "feat(pipeline): bake member-pair zoom morphs for every adjacent video combination"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Clip-pair transition schema + morph lookup
|
||||
|
||||
Change the `Transition` dataclass and ring loader to carry `from_clip`/`to_clip`, build a `(from, to) → file` lookup on the ring, and update the structural invariant. Touches both `player/ring.py` (dataclass + ring) and `simulator/clips.py` (parse + serialize).
|
||||
|
||||
**Files:**
|
||||
- Modify: `player/ring.py:75-113` (`Transition`, `ScaleRing.__post_init__`, add `morph_for`)
|
||||
- Modify: `simulator/clips.py:88-131` (`load_ring` transition parse, `ring_to_dict` transitions block)
|
||||
- Test: `tests/test_player_ring.py`, `tests/test_clips.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Transition(from_clip: str, to_clip: str, file: str, model: str = "")`.
|
||||
- Produces: `ScaleRing.morph_for(from_clip: str, to_clip: str) -> str | None` — the morph file for that directed pair, or `None` if absent.
|
||||
- `ScaleRing.__post_init__`: drop the "N edges == N transitions" rule (no longer per-edge); keep "≥1 scale, single-scale ring has no transitions". Build the lookup dict in `__post_init__` (store on a private attr via `object.__setattr__`, since the dataclass is frozen).
|
||||
- `ring_to_dict` transitions become `[{"from","to","file","model"}, ...]`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
# tests/test_player_ring.py (add)
|
||||
from player.ring import Scale, Transition, ScaleRing
|
||||
|
||||
def _two_member_ring():
|
||||
scales = (Scale(id="cosmos", clip_id="c1", pool=("c1", "c2")),
|
||||
Scale(id="abyss", clip_id="a1", pool=("a1",)))
|
||||
# adjacency wraps: cosmos<->abyss; members c1,c2 x a1 -> 2 pairs x2 dirs = 4
|
||||
trans = (Transition("c1", "a1", "transitions/c1__a1.mp4"),
|
||||
Transition("a1", "c1", "transitions/c1__a1.rev.mp4"),
|
||||
Transition("c2", "a1", "transitions/c2__a1.mp4"),
|
||||
Transition("a1", "c2", "transitions/c2__a1.rev.mp4"))
|
||||
return ScaleRing(scales=scales, transitions=trans)
|
||||
|
||||
def test_morph_for_returns_directed_file():
|
||||
r = _two_member_ring()
|
||||
assert r.morph_for("c1", "a1") == "transitions/c1__a1.mp4"
|
||||
assert r.morph_for("a1", "c2") == "transitions/c2__a1.rev.mp4"
|
||||
assert r.morph_for("c1", "c2") is None # not an adjacent-altitude pair
|
||||
```
|
||||
|
||||
```python
|
||||
# tests/test_clips.py (add)
|
||||
def test_load_ring_parses_clip_pair_transitions(tmp_path):
|
||||
import json
|
||||
from simulator.clips import load_ring
|
||||
m = {"ring": {"scales": [{"id": "x", "clip_id": "x1", "pool": ["x1"]},
|
||||
{"id": "y", "clip_id": "y1", "pool": ["y1"]}],
|
||||
"transitions": [{"from": "x1", "to": "y1", "file": "transitions/x1__y1.mp4"},
|
||||
{"from": "y1", "to": "x1", "file": "transitions/x1__y1.rev.mp4"}]}}
|
||||
p = tmp_path / "manifest.json"; p.write_text(json.dumps(m))
|
||||
ring = load_ring(p)
|
||||
assert ring.morph_for("x1", "y1") == "transitions/x1__y1.mp4"
|
||||
assert ring.morph_for("y1", "x1") == "transitions/x1__y1.rev.mp4"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py::test_morph_for_returns_directed_file tests/test_clips.py::test_load_ring_parses_clip_pair_transitions -v`
|
||||
Expected: FAIL — `Transition` has no `from_clip`/`to_clip`; no `morph_for`.
|
||||
|
||||
- [ ] **Step 3: Implement the schema + lookup**
|
||||
|
||||
```python
|
||||
# player/ring.py — replace the Transition dataclass
|
||||
@dataclass(frozen=True)
|
||||
class Transition:
|
||||
"""A pre-baked zoom/warp morph between TWO specific clips in adjacent
|
||||
altitudes. `file` plays as-is (the manifest holds both directions as
|
||||
separate, directed entries — forward zoom-in and `.rev` zoom-out)."""
|
||||
from_clip: str
|
||||
to_clip: str
|
||||
file: str
|
||||
model: str = ""
|
||||
```
|
||||
|
||||
```python
|
||||
# player/ring.py — ScaleRing: relax the invariant, build the lookup
|
||||
@dataclass(frozen=True)
|
||||
class ScaleRing:
|
||||
scales: tuple[Scale, ...]
|
||||
transitions: tuple[Transition, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if len(self.scales) == 0:
|
||||
raise RingError("a ScaleRing needs at least one scale")
|
||||
lookup = {(t.from_clip, t.to_clip): t.file for t in self.transitions}
|
||||
object.__setattr__(self, "_morphs", lookup)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.scales)
|
||||
|
||||
def morph_for(self, from_clip: str, to_clip: str) -> str | None:
|
||||
"""The morph file for the directed clip pair, or None if none baked."""
|
||||
return self._morphs.get((from_clip, to_clip))
|
||||
```
|
||||
|
||||
```python
|
||||
# simulator/clips.py — load_ring transition parse (replace lines 101-104)
|
||||
transitions = tuple(
|
||||
Transition(
|
||||
from_clip=t.get("from", ""),
|
||||
to_clip=t.get("to", ""),
|
||||
file=t["file"],
|
||||
model=t.get("model", ""),
|
||||
)
|
||||
for t in ring.get("transitions", [])
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# simulator/clips.py — ring_to_dict transitions block (replace line 130)
|
||||
"transitions": [
|
||||
{"from": t.from_clip, "to": t.to_clip, "file": t.file, "model": t.model}
|
||||
for t in ring.transitions
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests + the existing ring/clips suites**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py tests/test_clips.py -v`
|
||||
Expected: PASS — fix any existing test that constructed `Transition(file=..., model=...)` positionally to the new 3-arg form (`Transition(from_clip, to_clip, file)`), and any test asserting the old `N == N` invariant.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add player/ring.py simulator/clips.py tests/test_player_ring.py tests/test_clips.py
|
||||
git commit -m "feat(ring): clip-pair Transition schema + morph_for lookup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Resolve chained member→member morphs (pick before transition)
|
||||
|
||||
Add a pure function that, given a structural `RingMove`, the currently-shown clip, and one injected pick per step, walks the steps — choosing a destination member for each crossed altitude and resolving the morph from the *previous* clip to it — and reports the final locked clip. This is the heart of "choose the clip first, then play the matching morph," chained through each altitude.
|
||||
|
||||
**Files:**
|
||||
- Modify: `player/ring.py` (add `MorphStep`, `ResolvedMove`, `resolve_move`)
|
||||
- Test: `tests/test_player_ring.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RingMove` (from `advance_ring`), `pick_clip_id`, `scale_at`, `ScaleRing.morph_for`.
|
||||
- Produces:
|
||||
- `MorphStep(from_clip: str, to_clip: str, file: str | None, to_index: int, blended: bool)`
|
||||
- `ResolvedMove(steps: tuple[MorphStep, ...], target_clip_id: str)`
|
||||
- `resolve_move(ring: ScaleRing, move: RingMove, from_clip_id: str, picks: tuple[float, ...]) -> ResolvedMove` — `len(picks)` must equal `len(move.steps)`; each `picks[k]` chooses the member of the scale that step `k` lands on. `from_clip` of step 0 is `from_clip_id`; each subsequent step's `from_clip` is the prior step's `to_clip`. `target_clip_id` is the last step's `to_clip` (or `from_clip_id` for an empty move).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
# tests/test_player_ring.py (add)
|
||||
from player.ring import advance_ring, resolve_move
|
||||
|
||||
def _chain_ring():
|
||||
# 3 scales, pools of 2/1/2; descending edges cosmos->coast->abyss + wrap abyss->cosmos
|
||||
scales = (Scale("cosmos", "c1", ("c1", "c2")),
|
||||
Scale("coast", "o1", ("o1",)),
|
||||
Scale("abyss", "a1", ("a1", "a2")))
|
||||
t = []
|
||||
def pair(h, l):
|
||||
t.append(Transition(h, l, f"transitions/{h}__{l}.mp4"))
|
||||
t.append(Transition(l, h, f"transitions/{h}__{l}.rev.mp4"))
|
||||
for h in ("c1", "c2"):
|
||||
pair(h, "o1") # cosmos<->coast
|
||||
for h in ("o1",):
|
||||
pair(h, "a1"); pair(h, "a2") # coast<->abyss
|
||||
for h in ("a1", "a2"):
|
||||
pair(h, "c1"); pair(h, "c2") # abyss<->cosmos (wrap)
|
||||
return ScaleRing(scales=scales, transitions=tuple(t))
|
||||
|
||||
def test_resolve_single_step_zoom_in():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=1) # cosmos -> coast
|
||||
res = resolve_move(r, move, from_clip_id="c2", picks=(0.0,))
|
||||
assert len(res.steps) == 1
|
||||
s = res.steps[0]
|
||||
assert (s.from_clip, s.to_clip) == ("c2", "o1")
|
||||
assert s.file == "transitions/c2__o1.mp4"
|
||||
assert res.target_clip_id == "o1"
|
||||
|
||||
def test_resolve_chains_through_intermediate_altitude():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=2) # cosmos -> coast -> abyss
|
||||
# pick coast member (only o1) then abyss member: 0.6*2 -> index 1 -> a2
|
||||
res = resolve_move(r, move, from_clip_id="c1", picks=(0.0, 0.6))
|
||||
files = [s.file for s in res.steps]
|
||||
assert files == ["transitions/c1__o1.mp4", "transitions/o1__a2.mp4"]
|
||||
assert res.target_clip_id == "a2"
|
||||
|
||||
def test_resolve_zoom_out_uses_reverse_file():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=2, delta=-1) # abyss -> coast (ascend)
|
||||
res = resolve_move(r, move, from_clip_id="a1", picks=(0.0,))
|
||||
assert res.steps[0].file == "transitions/o1__a1.rev.mp4"
|
||||
assert res.target_clip_id == "o1"
|
||||
|
||||
def test_resolve_empty_move_keeps_source():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=0)
|
||||
res = resolve_move(r, move, from_clip_id="c2", picks=())
|
||||
assert res.steps == () and res.target_clip_id == "c2"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py -k resolve -v`
|
||||
Expected: FAIL — `resolve_move` undefined.
|
||||
|
||||
- [ ] **Step 3: Implement `resolve_move`**
|
||||
|
||||
```python
|
||||
# player/ring.py (add near the bottom)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MorphStep:
|
||||
"""One morph to play while advancing: from the clip currently shown to the
|
||||
chosen clip of the next altitude, the morph file, the index it lands on, and
|
||||
whether it plays fast (a fast-spin pass)."""
|
||||
from_clip: str
|
||||
to_clip: str
|
||||
file: str | None
|
||||
to_index: int
|
||||
blended: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedMove:
|
||||
steps: tuple[MorphStep, ...]
|
||||
target_clip_id: str
|
||||
|
||||
|
||||
def resolve_move(
|
||||
ring: ScaleRing,
|
||||
move: RingMove,
|
||||
from_clip_id: str,
|
||||
picks: tuple[float, ...],
|
||||
) -> ResolvedMove:
|
||||
"""Choose the destination clip for each crossed altitude (injected `picks`,
|
||||
one per step) and resolve the morph from the previous clip to it. The morph
|
||||
is chosen AFTER the destination clip, so the footage matches what we land on.
|
||||
`file` is None if no morph was baked for a pair (renderer falls back to a
|
||||
plain cut); `target_clip_id` is the final locked clip."""
|
||||
if len(picks) != len(move.steps):
|
||||
raise ValueError(f"need one pick per step: {len(picks)} != {len(move.steps)}")
|
||||
steps: list[MorphStep] = []
|
||||
src = from_clip_id
|
||||
for st, r in zip(move.steps, picks):
|
||||
dst_scale = scale_at(ring, st.to_index)
|
||||
dst = pick_clip_id(dst_scale, r)
|
||||
steps.append(MorphStep(
|
||||
from_clip=src,
|
||||
to_clip=dst,
|
||||
file=ring.morph_for(src, dst),
|
||||
to_index=st.to_index,
|
||||
blended=st.blended,
|
||||
))
|
||||
src = dst
|
||||
return ResolvedMove(steps=tuple(steps), target_clip_id=src)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py -k resolve -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add player/ring.py tests/test_player_ring.py
|
||||
git commit -m "feat(ring): resolve_move picks destination clip then matching morph, chained"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Fast spin plays the whole chain quickly (no collapse)
|
||||
|
||||
The operator chose "chain through each altitude," with fast spins playing that chain *quickly* rather than collapsing to a single arrival pass. Change `advance_ring`'s fast-spin branch to keep every step but mark each `blended=True` (so the renderer plays each morph at `FAST_BLEND_RATE`) and still set `RingMove.fast=True`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `player/ring.py:219-241` (fast-spin branch in `advance_ring`)
|
||||
- Test: `tests/test_player_ring.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Unchanged signature. Behavior change: a fast spin returns **all** steps (each `blended=True`), not one collapsed step.
|
||||
|
||||
- [ ] **Step 1: Write the failing test (and update any existing fast-spin assertions)**
|
||||
|
||||
```python
|
||||
# tests/test_player_ring.py (add)
|
||||
def test_fast_spin_keeps_all_steps_blended():
|
||||
r = _chain_ring() # 3-scale ring from Task 4
|
||||
move = advance_ring(r, from_index=0, delta=3, fast_spin_threshold=3)
|
||||
assert move.fast is True
|
||||
assert len(move.steps) == 3 # NOT collapsed to 1
|
||||
assert all(s.blended for s in move.steps)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py::test_fast_spin_keeps_all_steps_blended -v`
|
||||
Expected: FAIL — current branch collapses to a single `(blended,)` step.
|
||||
|
||||
- [ ] **Step 3: Implement the chain-fast branch**
|
||||
|
||||
```python
|
||||
# player/ring.py — replace the fast-spin collapse block (lines ~219-237)
|
||||
if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold:
|
||||
fast_steps = tuple(
|
||||
TransitionStep(edge=s.edge, reversed=s.reversed, file=s.file,
|
||||
to_index=s.to_index, blended=True)
|
||||
for s in steps
|
||||
)
|
||||
return RingMove(from_index=start, to_index=index, steps=fast_steps,
|
||||
wrapped=wrapped, fast=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full ring suite (catch the old collapse test)**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py -v`
|
||||
Expected: PASS — update/replace any prior test that asserted fast spin yields exactly one step.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add player/ring.py tests/test_player_ring.py
|
||||
git commit -m "feat(ring): fast spin plays the full morph chain quickly instead of collapsing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: API picks before transitioning and serializes the resolved chain
|
||||
|
||||
`/api/ring/advance` accepts the currently-shown clip (`from_clip_id`), draws one random pick per step, resolves the chained morphs, and returns the resolved steps + locked `target_clip_id`. Also extend `media-versions` to cover every transition file.
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/app.py:119-121` (`RingAdvanceRequest`), `:237-252` (`api_ring_advance`), `:213-229` (`api_media_versions`)
|
||||
- Modify: `simulator/clips.py:134-160` (`ring_move_to_dict` → resolved-move serializer)
|
||||
- Test: `tests/test_simulator_api.py`
|
||||
|
||||
**Interfaces:**
|
||||
- `RingAdvanceRequest`: add `from_clip_id: str = ""`.
|
||||
- New serializer `resolved_move_to_dict(move: RingMove, resolved: ResolvedMove) -> dict`:
|
||||
```json
|
||||
{"from_index": int, "to_index": int, "wrapped": bool, "fast": bool,
|
||||
"target_clip_id": str,
|
||||
"steps": [{"from_clip": str, "to_clip": str, "file": str|null,
|
||||
"to_index": int, "blended": bool}, ...]}
|
||||
```
|
||||
- `media-versions`: iterate `t.file` for every transition (no `.rev` derivation — both directions are explicit entries now).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_simulator_api.py (add)
|
||||
def test_advance_picks_then_returns_matching_morph(client):
|
||||
# client fixture serves the sample_media manifest
|
||||
r = client.post("/api/ring/advance",
|
||||
json={"from_index": 0, "delta": 1, "from_clip_id": "cosmos"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["to_index"] == 1
|
||||
target = body["target_clip_id"]
|
||||
step = body["steps"][-1]
|
||||
assert step["from_clip"] == "cosmos" # threaded current clip
|
||||
assert step["to_clip"] == target # morph lands on the locked clip
|
||||
# file matches the directed pair (zoom-in descend, forward file)
|
||||
assert step["file"] == f"transitions/cosmos__{target}.mp4"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_simulator_api.py::test_advance_picks_then_returns_matching_morph -v`
|
||||
Expected: FAIL — response has no `from_clip`/`to_clip`, file is the old per-edge name.
|
||||
|
||||
- [ ] **Step 3: Implement request field, endpoint, serializer, media-versions**
|
||||
|
||||
```python
|
||||
# simulator/app.py
|
||||
class RingAdvanceRequest(BaseModel):
|
||||
from_index: int = 0
|
||||
delta: int
|
||||
from_clip_id: str = ""
|
||||
```
|
||||
|
||||
```python
|
||||
# simulator/app.py — api_ring_advance
|
||||
@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)
|
||||
# Choose the destination member of each crossed altitude FIRST (one random
|
||||
# draw per step), then resolve the morph from the prior clip to it.
|
||||
src = req.from_clip_id or scale_at(app.state.ring, move.from_index).clip_id
|
||||
picks = tuple(random.random() for _ in move.steps)
|
||||
resolved = resolve_move(app.state.ring, move, src, picks)
|
||||
return resolved_move_to_dict(move, resolved)
|
||||
```
|
||||
|
||||
```python
|
||||
# simulator/clips.py — replace ring_move_to_dict with resolved_move_to_dict
|
||||
def resolved_move_to_dict(move, resolved) -> dict:
|
||||
"""JSON form of a resolved encoder move: where it lands, the locked clip, and
|
||||
the ordered chained morphs (each from the prior clip to its chosen
|
||||
destination, with the matching morph file)."""
|
||||
return {
|
||||
"from_index": move.from_index,
|
||||
"to_index": move.to_index,
|
||||
"wrapped": move.wrapped,
|
||||
"fast": move.fast,
|
||||
"target_clip_id": resolved.target_clip_id,
|
||||
"steps": [
|
||||
{"from_clip": s.from_clip, "to_clip": s.to_clip, "file": s.file,
|
||||
"to_index": s.to_index, "blended": s.blended}
|
||||
for s in resolved.steps
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# simulator/app.py — api_media_versions: cover every transition file explicitly
|
||||
files = {c.base_file for c in app.state.clips}
|
||||
if app.state.ring is not None:
|
||||
for t in app.state.ring.transitions:
|
||||
files.add(t.file)
|
||||
```
|
||||
|
||||
Update imports in `simulator/app.py` (`resolve_move`, `scale_at` already imported; add `resolve_move`) and `simulator/clips.py` (drop `ring_move_to_dict`, add `resolved_move_to_dict`; remove the now-unused `_rev_file` if nothing else uses it — grep first).
|
||||
|
||||
- [ ] **Step 4: Run the API suite**
|
||||
|
||||
Run: `pytest tests/test_simulator_api.py -v`
|
||||
Expected: PASS — fix any test referencing the removed `ring_move_to_dict` / old response shape.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/app.py simulator/clips.py tests/test_simulator_api.py
|
||||
git commit -m "feat(api): /api/ring/advance picks clip then returns matching chained morphs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Renderer plays the chosen morph and locks the clip (no secondary swap)
|
||||
|
||||
Rewire the frontend: send the currently-shown clip, play each resolved morph straight (the manifest already encodes direction, so drop the `reversed`/`reverseFile` logic), land + lock on `target_clip_id` with a plain (re)load, and **delete the secondary `fadeThroughBlack` swap**. Build a `(from,to)→file` lookup from the ring and preload only the morphs reachable from the current locked clip, refreshed on each landing (154 clips is too many to preload eagerly).
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` — `advance` (620-652), `playTransition` (568-595), `onload`/ring load (33-57), `preloadOrder`/`preloadList`/`reverseFile` (104-131, 566), remove `fadeThroughBlack` (600-618) call.
|
||||
- Verified by: Task 8 (E2E) + manual run.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes the Task 6 response: `move.steps[k] = {from_clip, to_clip, file, to_index, blended}`, `move.target_clip_id`.
|
||||
- New module state: `morphByPair = {}` — `"from→to"` → file string, built from `ring.transitions`.
|
||||
|
||||
- [ ] **Step 1: Build the morph lookup when the ring loads**
|
||||
|
||||
```javascript
|
||||
// simulator/static/app.js — in loadRing(), after `ring = ...` is set:
|
||||
morphByPair = {};
|
||||
if (ring && ring.transitions)
|
||||
for (const t of ring.transitions) morphByPair[`${t.from}→${t.to}`] = t.file;
|
||||
```
|
||||
|
||||
Add `let morphByPair = {};` near the other module state (around line 38). The synthesized single-clip fallback ring has no transitions → empty lookup → advance is a no-op (pool of one), which is correct.
|
||||
|
||||
- [ ] **Step 2: Simplify `playTransition` (no reverse logic)**
|
||||
|
||||
```javascript
|
||||
// simulator/static/app.js — replace playTransition
|
||||
function playTransition(file, blended) {
|
||||
// Play one resolved morph start-to-finish with alteration layers hidden. The
|
||||
// manifest already encodes direction (forward zoom-in vs `.rev` zoom-out), so
|
||||
// the file is played as-is. A fast-spin step runs at FAST_BLEND_RATE. If no
|
||||
// morph exists for the pair (file null), resolve immediately (plain cut).
|
||||
return new Promise((resolve) => {
|
||||
if (!file) return resolve();
|
||||
overlay.style.opacity = "0"; affectLayer.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);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rewrite `advance` — pick first (via server), play morphs, lock, no swap**
|
||||
|
||||
```javascript
|
||||
// simulator/static/app.js — replace advance
|
||||
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, from_clip_id: activeClipId }),
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const move = await resp.json();
|
||||
for (const step of move.steps) {
|
||||
await playTransition(step.file, step.blended);
|
||||
}
|
||||
ringIndex = move.to_index;
|
||||
activeClipId = move.target_clip_id; // the locked clip — the morph ended on it
|
||||
currentClipId = null; // force ensureClipMedia to (re)load + loop it
|
||||
renderScaleReadout();
|
||||
} finally {
|
||||
busy = false;
|
||||
update(); // ensureClipMedia loads the locked clip; nothing re-rolls
|
||||
applyAudio();
|
||||
refreshReachablePreload();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then **delete** `fadeThroughBlack` (now unused) and `reverseFile` (the `.rev` companion is an explicit manifest entry, no longer derived). Grep to confirm no other callers before deleting.
|
||||
|
||||
- [ ] **Step 4: Preload only the reachable morphs, refreshed per landing**
|
||||
|
||||
```javascript
|
||||
// simulator/static/app.js — replace the eager transition preload in preloadOrder()/preloadList()
|
||||
// Reachable morphs from the CURRENT locked clip: to/from every member of the
|
||||
// neighboring altitudes (the only clips one detent can reach). 154 total is too
|
||||
// many to preload up front; we focus on what the next move can need.
|
||||
function reachableMorphFiles() {
|
||||
const files = new Set();
|
||||
if (!ring || ring.scales.length < 2 || !activeClipId) return files;
|
||||
const n = ring.scales.length;
|
||||
for (const sign of [1, -1]) {
|
||||
const nb = ring.scales[((ringIndex + sign) % n + n) % n];
|
||||
const members = (nb.pool || [{ clip_id: nb.clip_id }]).map((m) => m.clip_id);
|
||||
for (const m of members) {
|
||||
const a = morphByPair[`${activeClipId}→${m}`];
|
||||
const b = morphByPair[`${m}→${activeClipId}`];
|
||||
if (a) files.add(a);
|
||||
if (b) files.add(b);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
function refreshReachablePreload() {
|
||||
// Kick a background cache fill of the reachable morphs (bounded concurrency,
|
||||
// same cacheClip helper used for bases). Idempotent — cached files are skipped.
|
||||
for (const f of reachableMorphFiles()) cacheClip(f);
|
||||
}
|
||||
```
|
||||
|
||||
In `preloadOrder()` replace the `if (ring.transitions) for (const t ...) { add(t.file); add(reverseFile(t.file)); }` block with `for (const f of reachableMorphFiles()) add(f);`. Keep base-clip preloading (current + neighbor pools) as-is. (`cacheClip` = factor out the per-file fetch→blob step already inside the preload loop so `refreshReachablePreload` can reuse it.)
|
||||
|
||||
- [ ] **Step 5: Manual smoke + commit**
|
||||
|
||||
Run: `cd simulator && python -m uvicorn app:app --port 8011` then open `http://localhost:8011/`, turn the Altitude dial one detent each way and several detents; confirm: a zoom morph plays, it lands on a clip, and that clip **does not change** while parked (watch the Dev-Mode clip readout). No black-flash second swap.
|
||||
|
||||
```bash
|
||||
git add simulator/static/app.js
|
||||
git commit -m "feat(sim): play chosen morph then lock clip per altitude; drop secondary swap"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Playwright E2E — lock + matching morph (new tier)
|
||||
|
||||
Stand up a minimal Playwright tier (none exists) and add one spec that proves the two behaviors the operator asked for: after zooming, the displayed clip is stable (locked) until the next altitude change, and the morph that plays matches `from→to`. §9 requires an E2E browser tier for this UI change.
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/e2e/package.json`, `simulator/e2e/playwright.config.ts`, `simulator/e2e/tests/altitude-lock.spec.ts`
|
||||
- Create: `simulator/e2e/README.md` (how to run; how it boots uvicorn)
|
||||
|
||||
**Interfaces:**
|
||||
- The spec drives the real app (uvicorn on a fixed port via Playwright `webServer`), reads the Dev-Mode clip readout (enable Dev Mode via localStorage before load), and intercepts `/media/transitions/*` requests to assert the morph file played.
|
||||
|
||||
- [ ] **Step 1: Scaffold the tier (failing because no test yet)**
|
||||
|
||||
```jsonc
|
||||
// simulator/e2e/package.json
|
||||
{ "name": "hef-e2e", "private": true,
|
||||
"scripts": { "test": "playwright test" },
|
||||
"devDependencies": { "@playwright/test": "^1.45.0" } }
|
||||
```
|
||||
|
||||
```ts
|
||||
// simulator/e2e/playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
webServer: {
|
||||
command: "cd .. && python -m uvicorn app:app --port 8099",
|
||||
url: "http://localhost:8099/api/clips",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
use: { baseURL: "http://localhost:8099" },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the spec**
|
||||
|
||||
```ts
|
||||
// simulator/e2e/tests/altitude-lock.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("zoom plays a morph, lands locked, and does not change while parked", async ({ page }) => {
|
||||
const morphs: string[] = [];
|
||||
page.on("request", (r) => {
|
||||
const m = r.url().match(/\/media\/(transitions\/[^?]+)/);
|
||||
if (m) morphs.push(m[1]);
|
||||
});
|
||||
await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
|
||||
await page.goto("/");
|
||||
const readout = page.locator("#scale-name");
|
||||
await expect(readout).toContainText("cosmos");
|
||||
|
||||
// Zoom inward one altitude (wheel down = +). A morph must play.
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 120 });
|
||||
await page.waitForTimeout(2500);
|
||||
expect(morphs.some((f) => f.startsWith("transitions/cosmos__"))).toBeTruthy();
|
||||
|
||||
// The landed clip is now locked: capture it, wait through several alteration
|
||||
// ticks, and assert it never changes.
|
||||
const landed = (await readout.textContent())!;
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await readout.textContent()).toBe(landed);
|
||||
});
|
||||
|
||||
test("zooming back OUT plays a reverse morph and lands locked", async ({ page }) => {
|
||||
const morphs: string[] = [];
|
||||
page.on("request", (r) => {
|
||||
const m = r.url().match(/\/media\/(transitions\/[^?]+)/);
|
||||
if (m) morphs.push(m[1]);
|
||||
});
|
||||
await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
|
||||
await page.goto("/");
|
||||
const readout = page.locator("#scale-name");
|
||||
await expect(readout).toContainText("cosmos");
|
||||
|
||||
// Zoom inward one altitude, then back OUT (wheel up = -). The outward move must
|
||||
// play a `.rev` morph and land back on cosmos, locked.
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 120 });
|
||||
await page.waitForTimeout(2500);
|
||||
morphs.length = 0; // only watch the zoom-out move
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY: -120 });
|
||||
await page.waitForTimeout(2500);
|
||||
expect(morphs.some((f) => /transitions\/cosmos__.*\.rev\.mp4/.test(f))).toBeTruthy();
|
||||
await expect(readout).toContainText("cosmos");
|
||||
|
||||
// Locked after the outward move too.
|
||||
const landed = (await readout.textContent())!;
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await readout.textContent()).toBe(landed);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Install + run**
|
||||
|
||||
Run: `cd simulator/e2e && npm install && npx playwright install chromium && npm test`
|
||||
Expected: PASS (1 test). If the wheel handler needs a different target, adjust the selector to the element bound in `onWheel` setup.
|
||||
|
||||
- [ ] **Step 4: Document the tier**
|
||||
|
||||
`simulator/e2e/README.md`: prerequisites (`npm install`, `npx playwright install chromium`), how `webServer` boots uvicorn, and `npm test`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/e2e
|
||||
git commit -m "test(e2e): Playwright tier asserts altitude lock + matching morph"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Regenerate sample media + manifest, full suite green
|
||||
|
||||
Bake the 154 morphs into `simulator/sample_media`, rebuild the manifest, and run the entire pytest suite + the E2E spec so the shipped sample media matches the new schema.
|
||||
|
||||
**Files:**
|
||||
- Modify (generated): `simulator/sample_media/manifest.json`, `simulator/sample_media/transitions/*.mp4`
|
||||
|
||||
- [ ] **Step 1: Rebuild media + manifest**
|
||||
|
||||
Run: `cd simulator && python build_pool_manifest.py --media` (or the script's generate+manifest entrypoint — confirm its CLI flags) to bake morphs and rewrite `manifest.json`.
|
||||
Expected: `sample_media/transitions/` contains 154 files; `manifest.json` ring has 154 transition entries.
|
||||
|
||||
- [ ] **Step 2: Verify every manifest transition file exists**
|
||||
|
||||
```python
|
||||
# one-off check (or add as tests/test_catalog_integrity.py case)
|
||||
import json, pathlib
|
||||
m = json.load(open("simulator/sample_media/manifest.json"))
|
||||
base = pathlib.Path("simulator/sample_media")
|
||||
missing = [t["file"] for t in m["ring"]["transitions"] if not (base / t["file"]).exists()]
|
||||
assert not missing, missing
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the full suite**
|
||||
|
||||
Run: `pytest -q`
|
||||
Expected: PASS (all existing + new tests; ~270+).
|
||||
|
||||
- [ ] **Step 4: Run the E2E spec against the regenerated media**
|
||||
|
||||
Run: `cd simulator/e2e && npm test`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/sample_media
|
||||
git commit -m "chore(media): regenerate 154 clip-pair morphs + manifest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage (operator directives):**
|
||||
- "Choose which clip plays in the next altitude *before* transitioning" → Task 4 (`resolve_move` picks dst then morph) + Task 6 (server draws picks before resolving).
|
||||
- "Play the appropriate morph footage" → Task 7 (renderer plays the resolved `step.file`).
|
||||
- "Morph footage for all transitions between all videos in adjacent altitudes" → Tasks 1–2 (manifest + bake 154, both directions) + Task 9 (regenerate).
|
||||
- "Video must not change after we've zoomed to a new altitude" / "lock per altitude until the altitude is changed" → Task 7 (delete secondary swap; lock `activeClipId`; reload only on altitude change) + Task 8 (E2E asserts stability while parked).
|
||||
- "Good transition for every video combination, in and out" → both directions baked (Task 1/2), reverse via explicit `.rev` entries (Task 3/4), E2E covers in + out (extend Task 8 spec with a zoom-out case if desired).
|
||||
- Multi-altitude jump chains through each altitude → Task 4 (`resolve_move` threads src→dst per step) + Task 5 (fast spin plays the chain quickly).
|
||||
|
||||
**Placeholder scan:** No TBD/TODO/"handle edge cases" — every code step shows real code; tests show real assertions.
|
||||
|
||||
**Type consistency:** `Transition(from_clip, to_clip, file, model)` (T3) used by `morph_for` (T3), `load_ring` (T3), `resolve_move` (T4), `resolved_move_to_dict` (T6). `MorphStep`/`ResolvedMove` fields (T4) match the serializer (T6) and the renderer's `step.{from_clip,to_clip,file,to_index,blended}` + `move.target_clip_id` (T7). `morphByPair` key `"from→to"` consistent in build (T7 S1) and lookup (T7 S4).
|
||||
|
||||
**Open items (resolved at the review gate + during execution):**
|
||||
1. **Storage/repo weight:** Corrected during execution — `sample_media` was entirely
|
||||
gitignored (regenerated from the builder), NOT already committed as the plan first
|
||||
assumed. Operator decision: the media that has **graduated** into the actual
|
||||
experience (curated clip bases, per-scale audio, the 154 morphs — ~526 MB) is now
|
||||
committed via **git-LFS**, pointing at the self-hosted Gitea LFS store
|
||||
(`git.benstull.org`). Stale/unused media (parked `right_variants`, ~1.6 GB) stays
|
||||
gitignored (not deleted from disk — that's a separate explicit cleanup).
|
||||
2. **`build_pool_manifest.py` CLI:** ✅ `python simulator/build_pool_manifest.py --media`
|
||||
rebuilds the manifest + bakes all morphs (confirmed at execution).
|
||||
3. **Zoom-out E2E:** ✅ Task 8 covers zoom-in + lock **and** zoom-out (`.rev` morph) + lock.
|
||||
Note: one wheel notch = 60 deltaY = one detent (`Math.trunc(deltaY/60)`).
|
||||
@@ -0,0 +1,803 @@
|
||||
# Scrub-driven Altitude Transitions 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:** Make the Altitude knob position *continuously* drive the scale transition — dragging the dial scrubs the morph video's `currentTime` and crossfades the two adjacent scale soundtracks by knob angle, holding wherever the knob stops, fully reversible.
|
||||
|
||||
**Architecture:** Introduce a continuous float **position** `pos` (integers = altitudes/detents, fractions = mid-morph blend). A small pure module (`scrub.js`) owns the math (position→segment, frac→currentTime, audio gains, integer-crossing detection); `app.js` becomes the DOM/event glue that drives `pos` from the dial, seeks the morph `<video>`, crossfades two `<audio>` elements, and commits/locks/re-rolls on integer crossings. Phase 1 builds the interaction against the *current* (sparse-GOP) morphs to validate feel; Phase 2 re-bakes all 154 morphs all-intra for smooth seeking.
|
||||
|
||||
**Tech Stack:** Vanilla JS (no framework, plain `<script>`), Node built-in `node --test` for pure-logic unit tests, Playwright for E2E, Python + ffmpeg/x264 for the morph re-bake (`simulator/build_pool_manifest.py`), git-LFS for media.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Spec contract:** `docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md` — implement its locked decisions verbatim.
|
||||
- **In-between state:** hold the blend wherever the knob stops — **no auto-complete** (continuous-encoder model).
|
||||
- **Turn-back:** scrubs the **same** segment morph in reverse; landing back on the start integer re-locks **exactly** the clip you left (fully reversible).
|
||||
- **Destination randomness:** the destination altitude's clip is a random pick from its pool, **fixed for a single continuous gesture**, **re-rolled on each fresh approach** (each fresh entry into a segment from an integer).
|
||||
- **Scroll wheel:** auto-scrub one altitude over ~0.6 s, then lock. **Tap a dial label:** auto-scrub to that altitude the shortest way around.
|
||||
- **Lock-per-altitude** still holds: a resting altitude's clip stays locked until you commit into a different one.
|
||||
- **Canonical segment file (locked interpretation):** for the segment between altitude indices `lo` and `lo+1`, always use the **descend/forward** morph `morphByPair["<clip@lo>→<clip@lo+1>"]`, with `currentTime = frac × duration`. Reverse travel seeks the **same** file backward. `.rev` files are not used by the scrub interaction (they remain baked for back-compat).
|
||||
- **Git transport:** SSH only (`ssh://git@git.benstull.org/...`). No inline trailing comments on shell/CLI command lines.
|
||||
- **Pipeline:** ship via §9 — localhost + E2E green → PPE + E2E green → prod (prod human-gated). Every UI change carries its E2E browser tests as first-class tasks.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create** `simulator/static/scrub.js` — pure scrub math, UMD (browser global `HEFScrub` + CommonJS `module.exports`). No DOM.
|
||||
- **Create** `simulator/unit/scrub.test.js` — `node --test` unit suite for `scrub.js`.
|
||||
- **Create** `simulator/unit/README.md` — one-liner on running the unit suite.
|
||||
- **Modify** `simulator/static/index.html` — load `scrub.js` before `app.js`; add a second `<audio id="aud-b" loop preload="auto">`.
|
||||
- **Modify** `simulator/static/app.js` — replace the discrete drag/wheel/tap `advance()` flow with the scrub engine (`setPos`, `rebuildSegment`, `commitCrossings`, auto-scrub animator); two-element audio crossfade; client-side clip pick from `scale.pool`.
|
||||
- **Modify** `simulator/e2e/tests/altitude-lock.spec.ts` (or add `scrub.spec.ts`) — assert `currentTime` + the two audio gains track the dial angle and reverse on turn-back; full turn commits+locks; wheel auto-scrubs one altitude and locks.
|
||||
- **Modify** `simulator/build_pool_manifest.py` — `_make_transition` / `_make_reverse` emit **all-intra** H.264 (`-g 1 -keyint_min 1 -sc_threshold 0`) for seekable frames.
|
||||
- **Modify** `tests/test_build_pool_manifest.py` — assert the ffmpeg arg list carries the all-intra flags.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Scrub interaction against current morphs
|
||||
|
||||
### Task 1: Pure scrub math module (`scrub.js`) + Node unit tests
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/static/scrub.js`
|
||||
- Create: `simulator/unit/scrub.test.js`
|
||||
- Create: `simulator/unit/README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (all pure, no DOM), exported on `HEFScrub` and `module.exports`:
|
||||
- `clamp01(x: number): number`
|
||||
- `wrapIndex(i: number, n: number): number` — `((i % n) + n) % n`
|
||||
- `segmentOf(pos: number): { lo: number, frac: number }` — `lo = floor(pos)`, `frac = pos - lo`
|
||||
- `accumToPos(restPos: number, accumDeg: number, stepDeg: number): number` — `restPos + accumDeg/stepDeg`
|
||||
- `fracToTime(frac: number, duration: number): number` — `clamp01(frac) * (duration || 0)`
|
||||
- `crossfadeGains(frac: number): { from: number, to: number }` — `{ from: 1-clamp01(frac), to: clamp01(frac) }`
|
||||
- `integerCrossings(prevPos: number, pos: number): Array<{ index: number, dir: number }>` — integers strictly crossed going `prevPos → pos`, in travel order, `dir` = `+1` descending (pos increasing) / `-1` ascending. Landing exactly on an integer counts as crossing it.
|
||||
|
||||
- [ ] **Step 1: Write the failing unit suite**
|
||||
|
||||
Create `simulator/unit/scrub.test.js`:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const S = require("../static/scrub.js");
|
||||
|
||||
test("clamp01 bounds to [0,1]", () => {
|
||||
assert.equal(S.clamp01(-0.2), 0);
|
||||
assert.equal(S.clamp01(0.4), 0.4);
|
||||
assert.equal(S.clamp01(1.5), 1);
|
||||
});
|
||||
|
||||
test("wrapIndex wraps both directions", () => {
|
||||
assert.equal(S.wrapIndex(0, 5), 0);
|
||||
assert.equal(S.wrapIndex(5, 5), 0);
|
||||
assert.equal(S.wrapIndex(-1, 5), 4);
|
||||
assert.equal(S.wrapIndex(7, 5), 2);
|
||||
});
|
||||
|
||||
test("segmentOf splits floor and fraction", () => {
|
||||
assert.deepEqual(S.segmentOf(2), { lo: 2, frac: 0 });
|
||||
const s = S.segmentOf(2.4);
|
||||
assert.equal(s.lo, 2);
|
||||
assert.ok(Math.abs(s.frac - 0.4) < 1e-9);
|
||||
});
|
||||
|
||||
test("accumToPos maps knob degrees to position", () => {
|
||||
assert.equal(S.accumToPos(2, 0, 72), 2);
|
||||
assert.equal(S.accumToPos(2, 72, 72), 3);
|
||||
assert.equal(S.accumToPos(2, -36, 72), 1.5);
|
||||
});
|
||||
|
||||
test("fracToTime clamps and scales by duration", () => {
|
||||
assert.equal(S.fracToTime(0, 1.5), 0);
|
||||
assert.equal(S.fracToTime(0.5, 1.5), 0.75);
|
||||
assert.equal(S.fracToTime(1.2, 1.5), 1.5);
|
||||
assert.equal(S.fracToTime(0.5, 0), 0);
|
||||
});
|
||||
|
||||
test("crossfadeGains splits energy by fraction", () => {
|
||||
assert.deepEqual(S.crossfadeGains(0), { from: 1, to: 0 });
|
||||
assert.deepEqual(S.crossfadeGains(1), { from: 0, to: 1 });
|
||||
assert.deepEqual(S.crossfadeGains(0.25), { from: 0.75, to: 0.25 });
|
||||
});
|
||||
|
||||
test("integerCrossings: none while inside a segment", () => {
|
||||
assert.deepEqual(S.integerCrossings(2.1, 2.9), []);
|
||||
assert.deepEqual(S.integerCrossings(2.0, 2.5), []);
|
||||
assert.deepEqual(S.integerCrossings(3.0, 2.5), []);
|
||||
});
|
||||
|
||||
test("integerCrossings: ascending and descending single crossings", () => {
|
||||
assert.deepEqual(S.integerCrossings(2.4, 3.0), [{ index: 3, dir: 1 }]);
|
||||
assert.deepEqual(S.integerCrossings(2.0, 3.0), [{ index: 3, dir: 1 }]);
|
||||
assert.deepEqual(S.integerCrossings(3.4, 3.0), [{ index: 3, dir: -1 }]);
|
||||
});
|
||||
|
||||
test("integerCrossings: multiple crossings in travel order", () => {
|
||||
assert.deepEqual(S.integerCrossings(2.4, 4.1), [{ index: 3, dir: 1 }, { index: 4, dir: 1 }]);
|
||||
assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]);
|
||||
});
|
||||
```
|
||||
|
||||
Create `simulator/unit/README.md`:
|
||||
|
||||
```markdown
|
||||
# Simulator unit tests
|
||||
|
||||
Pure-logic JS unit tests (no browser), run with Node's built-in test runner:
|
||||
|
||||
node --test simulator/unit/
|
||||
|
||||
`scrub.test.js` covers `simulator/static/scrub.js` — the pure altitude-scrub math
|
||||
(position→segment, frac→currentTime, audio gains, integer-crossing detection).
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the suite to verify it fails**
|
||||
|
||||
Run: `node --test simulator/unit/scrub.test.js`
|
||||
Expected: FAIL — `Cannot find module '../static/scrub.js'`.
|
||||
|
||||
- [ ] **Step 3: Write `scrub.js` to pass**
|
||||
|
||||
Create `simulator/static/scrub.js`:
|
||||
|
||||
```js
|
||||
// Pure altitude-scrub math — no DOM. A continuous `pos` (float) is the knob:
|
||||
// integers are altitudes/detents, fractions are mid-morph blend. UMD so the
|
||||
// browser gets a `HEFScrub` global and `node --test` can require() it.
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFScrub = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
const wrapIndex = (i, n) => ((i % n) + n) % n;
|
||||
|
||||
function segmentOf(pos) {
|
||||
const lo = Math.floor(pos);
|
||||
return { lo, frac: pos - lo };
|
||||
}
|
||||
|
||||
function accumToPos(restPos, accumDeg, stepDeg) {
|
||||
return restPos + accumDeg / stepDeg;
|
||||
}
|
||||
|
||||
function fracToTime(frac, duration) {
|
||||
return clamp01(frac) * (duration || 0);
|
||||
}
|
||||
|
||||
function crossfadeGains(frac) {
|
||||
const f = clamp01(frac);
|
||||
return { from: 1 - f, to: f };
|
||||
}
|
||||
|
||||
// Integers strictly crossed moving prevPos -> pos, in travel order. Landing
|
||||
// exactly on an integer counts as crossing it (it commits that altitude).
|
||||
function integerCrossings(prevPos, pos) {
|
||||
const out = [];
|
||||
if (pos > prevPos) {
|
||||
for (let k = Math.floor(prevPos) + 1; k <= pos + 1e-9; k++) out.push({ index: k, dir: 1 });
|
||||
} else if (pos < prevPos) {
|
||||
for (let k = Math.ceil(prevPos) - 1; k >= pos - 1e-9; k--) out.push({ index: k, dir: -1 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, integerCrossings };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the suite to verify it passes**
|
||||
|
||||
Run: `node --test simulator/unit/scrub.test.js`
|
||||
Expected: PASS — all tests, 0 failures.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/scrub.js simulator/unit/scrub.test.js simulator/unit/README.md
|
||||
git commit -m "feat(sim): pure altitude-scrub math module + node unit suite"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Two-element audio crossfade
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html` (add `<audio id="aud-b">`; load `scrub.js`)
|
||||
- Modify: `simulator/static/app.js` (audio layer ~lines 950–1045)
|
||||
- Test: `simulator/unit/scrub.test.js` (gains already covered in Task 1 — no new pure logic)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HEFScrub.crossfadeGains`, `soundtrackUrl()`-style per-scale URL resolution.
|
||||
- Produces:
|
||||
- `scaleAudioUrl(index: number): string|null` — the soundtrack URL for ring scale `index` (wrapped), using the ring `audio` field then `SCALE_AUDIO_FALLBACK`.
|
||||
- `blendAudio(loIndex: number, frac: number): void` — element A plays scale `loIndex`, element B plays scale `loIndex+1` (wrapped), gains `crossfadeGains(frac)`; loads each element's src on first use per gesture. No-op when Audio toggle is off.
|
||||
- `restAudio(index: number): void` — settle to a single scale at rest (full gain on the element holding `index`, fade the other to 0). Replaces the tail of `applyAudio()` for the at-rest case.
|
||||
|
||||
- [ ] **Step 1: Add the second audio element + scrub.js script tag**
|
||||
|
||||
In `simulator/static/index.html`, beside the existing `<audio id="aud" loop preload="auto"></audio>` (line ~21), add:
|
||||
|
||||
```html
|
||||
<audio id="aud" loop preload="auto"></audio>
|
||||
<audio id="aud-b" loop preload="auto"></audio>
|
||||
```
|
||||
|
||||
And load the pure module before `app.js` (line ~112):
|
||||
|
||||
```html
|
||||
<script src="/scrub.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing E2E expectation (gains exist on two elements)**
|
||||
|
||||
Add to `simulator/e2e/tests/altitude-lock.spec.ts` a focused check (full assertions land in Task 5; this verifies the element + hook exist):
|
||||
|
||||
```ts
|
||||
test("two audio elements exist for crossfade", async ({ page }) => {
|
||||
await boot(page);
|
||||
const n = await page.evaluate(() => document.querySelectorAll("audio#aud, audio#aud-b").length);
|
||||
expect(n).toBe(2);
|
||||
});
|
||||
```
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test -g "two audio elements"`
|
||||
Expected: FAIL until the markup change is loaded (and PASS once the running server serves the new `index.html`).
|
||||
|
||||
- [ ] **Step 3: Implement the crossfade audio layer in `app.js`**
|
||||
|
||||
Replace the single-element logic around `playUrl`/`applyAudio` (app.js ~1019–1045). Keep `aud` as element A and add `audB` as element B; generalize URL resolution by index:
|
||||
|
||||
```js
|
||||
const aud = $("aud");
|
||||
const audB = $("aud-b");
|
||||
let audLastErr = "";
|
||||
|
||||
// Soundtrack URL for ring scale `index` (wrapped), ring `audio` field then fallback.
|
||||
function scaleAudioUrl(index) {
|
||||
if (!ring || !ring.scales.length) return null;
|
||||
const s = ring.scales[HEFScrub.wrapIndex(index, ring.scales.length)];
|
||||
const a = s.audio || SCALE_AUDIO_FALLBACK[s.id];
|
||||
return a ? "/media/audio/" + a : null;
|
||||
}
|
||||
|
||||
// Start (once) and set the volume of one element to a given url. Safari needs the
|
||||
// first play() inside a user gesture; later programmatic plays reuse the element.
|
||||
function ensurePlaying(el, url, vol) {
|
||||
if (!url) { el.volume = 0; if (!el.paused) el.pause(); return; }
|
||||
if (el.dataset.url !== url) {
|
||||
el.dataset.url = url;
|
||||
el.src = mediaUrl(url);
|
||||
}
|
||||
if (el.paused) {
|
||||
const pr = el.play();
|
||||
if (pr) pr.then(() => { audLastErr = ""; updateAudioStatus(); })
|
||||
.catch((e) => { audLastErr = "play BLOCKED: " + ((e && e.name) || e); updateAudioStatus(); });
|
||||
}
|
||||
el.volume = HEFScrub.clamp01(vol);
|
||||
}
|
||||
|
||||
// Mid-segment: A = scale `loIndex`, B = scale loIndex+1, gains by frac.
|
||||
function blendAudio(loIndex, frac) {
|
||||
if (!$("audio").checked) return;
|
||||
const g = HEFScrub.crossfadeGains(frac);
|
||||
ensurePlaying(aud, scaleAudioUrl(loIndex), g.from);
|
||||
ensurePlaying(audB, scaleAudioUrl(loIndex + 1), g.to);
|
||||
updateAudioStatus();
|
||||
}
|
||||
|
||||
// At rest on a single altitude: the element already holding it stays at full gain,
|
||||
// the other fades to 0. Picks whichever element currently carries `index`'s url.
|
||||
function restAudio(index) {
|
||||
if (!$("audio").checked) { fadeVolume(aud, 0, FADE_MS, () => aud.pause()); fadeVolume(audB, 0, FADE_MS, () => audB.pause()); return; }
|
||||
const url = scaleAudioUrl(index);
|
||||
const onB = audB.dataset.url === url && url;
|
||||
const active = onB ? audB : aud;
|
||||
const idle = onB ? aud : audB;
|
||||
ensurePlaying(active, url, 1);
|
||||
fadeVolume(idle, 0, FADE_MS);
|
||||
}
|
||||
```
|
||||
|
||||
Then make the Audio toggle and altitude-rest paths call `restAudio(ringIndex)` instead of the old `applyAudio()` swap. Keep `applyAudio()` as a thin shim that calls `restAudio(ringIndex)` so existing call sites (e.g. the toggle handler at ~1073, `update()`) keep working:
|
||||
|
||||
```js
|
||||
function applyAudio() { restAudio(ringIndex); }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run unit suite + the focused E2E**
|
||||
|
||||
Run: `node --test simulator/unit/scrub.test.js`
|
||||
Expected: PASS.
|
||||
Run: `cd simulator/e2e && npx playwright test -g "two audio elements"` (with the dev server running)
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/altitude-lock.spec.ts
|
||||
git commit -m "feat(sim): two-element audio crossfade keyed by scrub fraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Scrub engine — drag drives `pos`, seeks the morph, commits/locks/re-rolls
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` (`onDialDown/Move/Up` ~766–795; `advance` ~640–679; `playTransition` ~595–636; needle/render ~739–751)
|
||||
- Test: `simulator/unit/scrub.test.js` (pure parts covered; engine glue is DOM, covered by E2E in Task 5)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HEFScrub.*`, `morphByPair`, `scale.pool` (client ring), `mediaUrl`, `setNeedle`, `dialStep`, `ensureClipMedia`/`update` (base-clip loop), `blendAudio`/`restAudio`.
|
||||
- Produces:
|
||||
- `pos` (module-level float; rest value = `ringIndex`).
|
||||
- `pickPoolClip(index: number): string` — random `clip_id` from ring scale `index`'s pool (wrapped).
|
||||
- `rebuildSegment(lo: number, enteredFrom: number): void` — sets `activeSeg = { lo, clipLo, clipHi, file }`; the side equal to the just-rested altitude takes `activeClipId`, the other is a fresh `pickPoolClip` (re-roll); `file = morphByPair["<clipLo>→<clipHi>"]` (descend canonical).
|
||||
- `setPos(next: number, opts?: { commit?: boolean }): void` — clamps thrash via one seek per rAF; seeks `vid.currentTime = fracToTime(frac, dur)`; `setNeedle(next * dialStep())`; `blendAudio(lo, frac)`; processes `integerCrossings(pos, next)` to commit/lock/re-roll; updates `pos`.
|
||||
- `commitTo(index: number): void` — `ringIndex = wrapIndex(index,n)`, `activeClipId = clip at that index for the active segment`, settle base loop + `restAudio` when frac becomes 0.
|
||||
|
||||
- [ ] **Step 1: Write the failing E2E (drag partway tracks currentTime + gains)**
|
||||
|
||||
Add to the E2E spec (full version in Task 5; this drives the engine):
|
||||
|
||||
```ts
|
||||
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
|
||||
await boot(page);
|
||||
// Drag the dial ~40% of one detent (clockwise = descend).
|
||||
const box = await page.locator("#dial").boundingBox();
|
||||
const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); // partial turn
|
||||
const state = await page.evaluate(() => ({
|
||||
t: (document.querySelector("video") as HTMLVideoElement).currentTime,
|
||||
ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
|
||||
gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
|
||||
}));
|
||||
expect(state.t).toBeGreaterThan(0); // morph scrubbed off frame 0
|
||||
expect(state.gb).toBeGreaterThan(0); // next scale audio fading in
|
||||
expect(state.ga).toBeLessThan(1); // current scale fading out
|
||||
await page.mouse.up();
|
||||
});
|
||||
```
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test -g "scrubs morph currentTime"`
|
||||
Expected: FAIL (engine not built yet).
|
||||
|
||||
- [ ] **Step 2: Implement the scrub engine in `app.js`**
|
||||
|
||||
Add the position state + segment + setPos near the dial logic, and rewrite `onDialMove`/`onDialUp` to drive it live:
|
||||
|
||||
```js
|
||||
let pos = 0; // continuous knob position; rest value == ringIndex
|
||||
let activeSeg = null; // { lo, clipLo, clipHi, file }
|
||||
let seekPending = false; // throttle: one currentTime seek per rAF
|
||||
|
||||
function pickPoolClip(index) {
|
||||
const n = ring.scales.length;
|
||||
const s = ring.scales[HEFScrub.wrapIndex(index, n)];
|
||||
const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
|
||||
return pool[Math.floor(Math.random() * pool.length)].clip_id;
|
||||
}
|
||||
|
||||
// Build (or re-roll) the segment [lo, lo+1]. The side equal to the rested altitude
|
||||
// keeps the locked clip; the other end is a fresh random pick (re-roll).
|
||||
function rebuildSegment(lo, enteredFrom) {
|
||||
const n = ring.scales.length;
|
||||
const loId = (enteredFrom === HEFScrub.wrapIndex(lo, n)) ? activeClipId : pickPoolClip(lo);
|
||||
const hiId = (enteredFrom === HEFScrub.wrapIndex(lo + 1, n)) ? activeClipId : pickPoolClip(lo + 1);
|
||||
const file = morphByPair[`${loId}→${hiId}`] || null;
|
||||
activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
|
||||
if (file) {
|
||||
overlay.style.opacity = "0"; affectLayer.style.opacity = "0"; tint.style.opacity = "0";
|
||||
vid.style.filter = "none"; vid.loop = false; vid.style.opacity = "1";
|
||||
if (vid.dataset.morph !== file) { vid.dataset.morph = file; vid.src = mediaUrl(file); vid.pause(); }
|
||||
(window.__hefMorphs || (window.__hefMorphs = [])).push(file);
|
||||
}
|
||||
}
|
||||
|
||||
function setPos(next) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
const n = ring.scales.length;
|
||||
// Commit every integer crossed between the old and new position.
|
||||
for (const c of HEFScrub.integerCrossings(pos, next)) {
|
||||
ringIndex = HEFScrub.wrapIndex(c.index, n);
|
||||
// The clip at the crossed integer is whichever segment end matches it.
|
||||
if (activeSeg) activeClipId = (HEFScrub.wrapIndex(activeSeg.lo, n) === ringIndex) ? activeSeg.clipLo : activeSeg.clipHi;
|
||||
activeSeg = null; // force a fresh-approach re-roll for the next segment
|
||||
}
|
||||
pos = next;
|
||||
const { lo, frac } = HEFScrub.segmentOf(pos);
|
||||
if (frac === 0) { // settled on an altitude
|
||||
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
|
||||
update();
|
||||
restAudio(ringIndex);
|
||||
setNeedle(ringIndex * dialStep());
|
||||
return;
|
||||
}
|
||||
if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex);
|
||||
setNeedle(pos * dialStep());
|
||||
blendAudio(lo, frac);
|
||||
if (activeSeg.file && !seekPending) { // throttle seeks to one per frame
|
||||
seekPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
seekPending = false;
|
||||
if (vid.dataset.morph === activeSeg.file) vid.currentTime = HEFScrub.fracToTime(frac, vid.duration);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rewrite the drag handlers to drive `pos` live (replacing the no-live-follow `onDialMove` and the commit-on-release `onDialUp`):
|
||||
|
||||
```js
|
||||
function onDialDown(e) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
e.preventDefault();
|
||||
dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: ringIndex };
|
||||
}
|
||||
function onDialMove(e) {
|
||||
if (!dialDrag) return;
|
||||
const a = dialAngle(e);
|
||||
dialDrag.accum += angDelta(a, dialDrag.lastAng);
|
||||
dialDrag.lastAng = a;
|
||||
dialDrag.moved += Math.abs(angDelta(a, dialDrag.lastAng));
|
||||
setPos(HEFScrub.accumToPos(dialDrag.restPos, dialDrag.accum, dialStep())); // LIVE scrub
|
||||
}
|
||||
function onDialUp(e) {
|
||||
if (!dialDrag) return;
|
||||
const { moved, target } = dialDrag;
|
||||
dialDrag = null;
|
||||
if (moved < 6) { // a tap, not a turn
|
||||
if (target && target.classList && target.classList.contains("dial-label")) jumpToScale(+target.getAttribute("data-index"));
|
||||
return;
|
||||
}
|
||||
// No auto-complete: hold wherever the knob stopped (continuous-encoder model).
|
||||
}
|
||||
```
|
||||
|
||||
Keep `renderDial()` reading `pos` for the needle when mid-segment, else `ringIndex`. Retire `advance()`'s body (or leave it unused) — wheel/tap use the auto-scrub in Task 4.
|
||||
|
||||
- [ ] **Step 3: Run the focused E2E**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test -g "scrubs morph currentTime"` (dev server running)
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run the unit suite (no regressions)**
|
||||
|
||||
Run: `node --test simulator/unit/scrub.test.js`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/app.js simulator/e2e/tests/altitude-lock.spec.ts
|
||||
git commit -m "feat(sim): scrub engine — drag drives pos, seeks morph, commits+locks+re-rolls"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wheel + tap become auto-scrubs, then lock
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` (`onWheel` ~681–691; `jumpToScale` ~798–805)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `setPos`, `ringIndex`, `pos`, `HEFScrub.wrapIndex`.
|
||||
- Produces:
|
||||
- `autoScrub(targetPos: number, ms = 600): void` — rAF-animates `pos` from its current value to `targetPos` (calling `setPos` each frame), landing exactly on the integer target, then locks (frac 0 settles in `setPos`).
|
||||
|
||||
- [ ] **Step 1: Write the failing E2E (wheel auto-scrubs one altitude then locks)**
|
||||
|
||||
Add to the E2E spec:
|
||||
|
||||
```ts
|
||||
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
|
||||
await boot(page);
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 60 });
|
||||
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
|
||||
const landed = (await page.locator("#scale-name").textContent())!;
|
||||
expect(landed).toContain("orbit");
|
||||
// Mid-flight currentTime moved; after landing the morph is settled (frac 0).
|
||||
await page.waitForTimeout(1500);
|
||||
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
|
||||
});
|
||||
```
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test -g "wheel auto-scrubs"`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 2: Implement `autoScrub` and rewire wheel + tap**
|
||||
|
||||
```js
|
||||
let autoRaf = 0;
|
||||
function autoScrub(targetPos, ms = 600) {
|
||||
if (autoRaf) cancelAnimationFrame(autoRaf);
|
||||
const fromPos = pos, dist = targetPos - fromPos;
|
||||
if (!dist) { setPos(targetPos); return; }
|
||||
let startTs = null;
|
||||
const tick = (ts) => {
|
||||
if (startTs === null) startTs = ts;
|
||||
const k = Math.min(1, (ts - startTs) / ms);
|
||||
setPos(fromPos + dist * k);
|
||||
if (k < 1) autoRaf = requestAnimationFrame(tick);
|
||||
else { autoRaf = 0; setPos(targetPos); } // land exactly + lock (frac 0)
|
||||
};
|
||||
autoRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
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) autoScrub(ringIndex + detents);
|
||||
}, 90);
|
||||
}
|
||||
|
||||
function jumpToScale(idx) {
|
||||
if (!ring) return;
|
||||
const n = ring.scales.length;
|
||||
let d = (idx - ringIndex) % n;
|
||||
if (d > n / 2) d -= n;
|
||||
if (d < -n / 2) d += n;
|
||||
if (d) autoScrub(ringIndex + d);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the focused E2E**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test -g "wheel auto-scrubs"` (dev server running)
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/app.js
|
||||
git commit -m "feat(sim): wheel + label-tap auto-scrub one/path of altitudes, then lock"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Full E2E tier for scrub-driven transitions
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/e2e/tests/altitude-lock.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the running simulator, `window.__hefMorphs`, `#scale-name`, `#dial`, `video`, `#aud`/`#aud-b`.
|
||||
|
||||
- [ ] **Step 1: Replace the `.rev`-asserting reverse test with a scrub-reverse test**
|
||||
|
||||
The old "zoom back out plays a reverse morph" test asserted a `.rev.mp4` file. Under the scrub model, turn-back seeks the **same** segment morph backward. Replace it:
|
||||
|
||||
```ts
|
||||
test("turn-back scrubs the same morph in reverse and re-locks the start clip", async ({ page }) => {
|
||||
await boot(page);
|
||||
const box = await page.locator("#dial").boundingBox();
|
||||
const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
|
||||
const read = () => page.evaluate(() => ({
|
||||
t: (document.querySelector("video") as HTMLVideoElement).currentTime,
|
||||
ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
|
||||
gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
|
||||
name: document.querySelector("#scale-name")?.textContent,
|
||||
}));
|
||||
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 22, cy - 20, { steps: 10 }); // descend partway
|
||||
const fwd = await read();
|
||||
await page.mouse.move(cx + 4, cy - 30, { steps: 10 }); // turn back toward start
|
||||
const back = await read();
|
||||
await page.mouse.up();
|
||||
|
||||
expect(back.t).toBeLessThan(fwd.t); // morph seeking backward
|
||||
expect(back.gb).toBeLessThan(fwd.gb); // next-scale audio receding
|
||||
expect(back.name).toContain("cosmos"); // re-locked the altitude we left
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the full-turn commit+lock test**
|
||||
|
||||
```ts
|
||||
test("a full turn commits and locks the destination clip", async ({ page }) => {
|
||||
await boot(page);
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
const box = await page.locator("#dial").boundingBox();
|
||||
const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 30, cy, { steps: 6 });
|
||||
await page.mouse.move(cx, cy + 30, { steps: 6 }); // ~full detent clockwise
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
|
||||
const landed = (await page.locator("#scale-name").textContent())!;
|
||||
expect(landed).toContain("orbit");
|
||||
const played = await page.evaluate(() => (window as any).__hefMorphs || []);
|
||||
expect(played[0]).toMatch(/^transitions\/cosmos.*\.mp4$/);
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Keep the wheel + drag tests from Tasks 2–4; run the whole tier**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test`
|
||||
Expected: PASS — all scrub tests green (drag-scrub, turn-back, full-turn, wheel, two-audio-elements).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/e2e/tests/altitude-lock.spec.ts
|
||||
git commit -m "test(e2e): scrub tier asserts currentTime + audio gains track the dial, reverse, commit-lock, wheel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Phase-1 localhost verification
|
||||
|
||||
**Files:** none (verification task).
|
||||
|
||||
- [ ] **Step 1: Run the unit suite**
|
||||
|
||||
Run: `node --test simulator/unit/`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run the full Python test suite (no regressions)**
|
||||
|
||||
Run: `python -m pytest -q`
|
||||
Expected: PASS (existing tally; no new failures).
|
||||
|
||||
- [ ] **Step 3: Boot the simulator and run E2E against it**
|
||||
|
||||
Run (server): start the simulator dev server per `simulator/` README on its configured port with the venv python.
|
||||
Run (tests): `cd simulator/e2e && npx playwright test`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Eyeball the feel**
|
||||
|
||||
Confirm by hand: slow drag eases the morph slowly; fast drag races it; stopping mid-drag holds a blended frame with mixed audio; turning back reverses; the wheel auto-scrubs one altitude and locks; a label tap travels the shortest way. Note any steppiness (expected on sparse-GOP morphs — Phase 2 fixes it).
|
||||
|
||||
- [ ] **Step 5: Checkpoint the transcript**
|
||||
|
||||
Re-publish the in-progress transcript (no commit needed beyond what tasks already pushed).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — All-intra morph re-bake for smooth seeking
|
||||
|
||||
### Task 7: Re-bake the 154 morphs all-intra (dense keyframes)
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/build_pool_manifest.py` (`_make_transition` ~402–417, `_make_reverse` ~420–429)
|
||||
- Test: `tests/test_build_pool_manifest.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: every baked morph encoded all-intra (each frame a keyframe) so arbitrary-frame seeking is smooth.
|
||||
|
||||
- [ ] **Step 1: Write the failing arg-builder test**
|
||||
|
||||
`_make_transition`/`_make_reverse` currently call `subprocess.run` directly, so factor the ffmpeg command into a pure builder to test it. Add to `simulator/build_pool_manifest.py`:
|
||||
|
||||
```python
|
||||
ALL_INTRA = ["-c:v", "libx264", "-g", "1", "-keyint_min", "1", "-sc_threshold", "0", "-pix_fmt", "yuv420p"]
|
||||
```
|
||||
|
||||
Add a pure helper and a test. In `tests/test_build_pool_manifest.py`:
|
||||
|
||||
```python
|
||||
from simulator import build_pool_manifest as B
|
||||
|
||||
def test_transition_cmd_is_all_intra():
|
||||
cmd = B.transition_cmd("ffmpeg", "/a/base.mp4", "/b/base.mp4", "/out/x__y.mp4")
|
||||
assert "-g" in cmd and cmd[cmd.index("-g") + 1] == "1"
|
||||
assert "-keyint_min" in cmd and cmd[cmd.index("-keyint_min") + 1] == "1"
|
||||
assert "-sc_threshold" in cmd and cmd[cmd.index("-sc_threshold") + 1] == "0"
|
||||
|
||||
def test_reverse_cmd_is_all_intra():
|
||||
cmd = B.reverse_cmd("ffmpeg", "/out/x__y.mp4", "/out/x__y.rev.mp4")
|
||||
assert "-g" in cmd and cmd[cmd.index("-g") + 1] == "1"
|
||||
assert "reverse" in " ".join(cmd)
|
||||
```
|
||||
|
||||
Run: `python -m pytest tests/test_build_pool_manifest.py -q`
|
||||
Expected: FAIL — `transition_cmd`/`reverse_cmd` not defined.
|
||||
|
||||
- [ ] **Step 2: Extract `transition_cmd`/`reverse_cmd` and apply all-intra flags**
|
||||
|
||||
```python
|
||||
def transition_cmd(ff, a, b, out):
|
||||
norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
|
||||
return [
|
||||
ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
|
||||
f"[0:v]{norm}[a];[1:v]{norm}[b];"
|
||||
"[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]",
|
||||
"-map", "[v]", "-an", *ALL_INTRA, str(out),
|
||||
]
|
||||
|
||||
def reverse_cmd(ff, forward, out):
|
||||
return [ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", *ALL_INTRA, str(out)]
|
||||
```
|
||||
|
||||
Then make `_make_transition`/`_make_reverse` call these builders inside `subprocess.run(..., check=True, capture_output=True)`.
|
||||
|
||||
- [ ] **Step 3: Run the arg-builder test**
|
||||
|
||||
Run: `python -m pytest tests/test_build_pool_manifest.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Regenerate the morph media**
|
||||
|
||||
Run: `python simulator/build_pool_manifest.py` (or its documented `generate_media` entrypoint) to re-bake all 154 morphs all-intra. Confirm files regrew (sparse → dense keyframes; expect ~0.5–0.9 GB total, each clip well under the 25 MB LFS/proxy ceiling noted in memory).
|
||||
|
||||
- [ ] **Step 5: Commit the re-baked media via git-LFS**
|
||||
|
||||
```bash
|
||||
git add simulator/sample_media/transitions
|
||||
git commit -m "feat(pipeline): re-bake 154 morphs all-intra for smooth scrub seeking (LFS)"
|
||||
```
|
||||
|
||||
Confirm they are LFS pointers: `git lfs ls-files | head`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Phase-2 verification — smooth scrub on dense keyframes
|
||||
|
||||
**Files:** none (verification task).
|
||||
|
||||
- [ ] **Step 1: Re-run the unit + E2E suites against re-baked media**
|
||||
|
||||
Run: `node --test simulator/unit/` → PASS.
|
||||
Run (server up): `cd simulator/e2e && npx playwright test` → PASS.
|
||||
|
||||
- [ ] **Step 2: Eyeball smoothness**
|
||||
|
||||
Confirm the scrub now seeks smoothly frame-to-frame (no stepping) on a slow drag across a full segment.
|
||||
|
||||
- [ ] **Step 3: Push the branch**
|
||||
|
||||
```bash
|
||||
git push -u origin session-0026
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ship via §9 (execution, not a code task)
|
||||
|
||||
After Phase 1 + Phase 2 are green on localhost (unit + E2E), ship through the mandatory pipeline:
|
||||
1. **localhost + E2E green** (Tasks 6 + 8).
|
||||
2. **PPE deploy + E2E green** (flotilla; provision PPE if it doesn't exist yet). Stamp the `release/<YYYY-MM-DDTHH-MM>` tag.
|
||||
3. **prod is human-gated** — the operator promotes the validated tag; the session stops at PPE-green.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Core model (continuous `pos`, frac drives currentTime + audio) → Tasks 1, 3, 2.
|
||||
- In-between hold / no auto-complete → Task 3 (`onDialUp` holds).
|
||||
- Turn-back reversible / re-lock start clip → Tasks 3, 5.
|
||||
- Destination randomness fixed-per-gesture, re-roll on fresh approach → Task 3 (`rebuildSegment`, re-roll on `activeSeg` reset at crossing).
|
||||
- Scroll wheel auto-scrub then lock; tap shortest way → Task 4.
|
||||
- Lock-per-altitude preserved → Task 3 (`commit` sets `activeClipId`; frac 0 settles base loop).
|
||||
- Audio crossfade two adjacent soundtracks by frac → Task 2.
|
||||
- All-intra re-bake (154 morphs, LFS) → Task 7.
|
||||
- Unit tests (position→segment, frac→time, commit on crossing, re-roll, reverse) → Task 1 (pure) + E2E for glue.
|
||||
- Playwright E2E (currentTime + gains track angle, reverse, full-turn commit-lock, wheel) → Task 5.
|
||||
- Phasing (interaction first, re-bake second) → Phase 1 then Phase 2.
|
||||
|
||||
**Placeholder scan:** none — every code step carries real code and exact commands.
|
||||
|
||||
**Type consistency:** `setPos`, `rebuildSegment`, `activeSeg {lo,clipLo,clipHi,file}`, `pickPoolClip`, `blendAudio(loIndex,frac)`, `restAudio(index)`, `scaleAudioUrl(index)`, `autoScrub(targetPos,ms)`, `HEFScrub.*` names are used consistently across Tasks 1–5. `transition_cmd`/`reverse_cmd`/`ALL_INTRA` consistent in Tasks 7.
|
||||
|
||||
**Open interpretation (logged as deferred decision):** the canonical-segment-file choice (always the descend/forward morph, scrubbed bidirectionally; `.rev` unused by scrub) reinterprets the spec's literal `morphByPair["<from>→<to>"]` directed lookup in favor of its "scrub the same morph in reverse" + full-reversibility requirements. Surfaced for operator awareness at finalize.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Affect channel — emotions in the Left-brain HUD
|
||||
|
||||
**Status:** approved (session 0013, 2026-06-22); **revised session 0018, 2026-06-26**
|
||||
**Surface:** simulator preview (`simulator/`, `player/alteration.py`)
|
||||
**Builds on:** the machine-altered-perception design SPEC; the Left HUD look-and-feel
|
||||
pass (same session).
|
||||
|
||||
> **Revision (session 0018) — affect is a RIGHT-brain output.** The original design
|
||||
> gated emotions on *both* knobs (`strength = min(left, right)`). Per operator
|
||||
> direction, **emotions are now controlled by the Right (dreamlike) knob alone** —
|
||||
> the analytical Left knob no longer gates them. This sharpens the left/right split:
|
||||
> Left = measurement (detections + measurements), Right = the dream **and** the
|
||||
> feeling it evokes. Every `min(left, right)` below now reads `right`. The data
|
||||
> shape, rendering, and per-word `min_level` mechanic are unchanged — only the knob
|
||||
> the strength is keyed off.
|
||||
|
||||
## Idea
|
||||
|
||||
A third HUD channel beside the cyan *detections* and amber *measurements*: soft,
|
||||
glowing **emotion-words** that surface the feelings the experience may invoke.
|
||||
They are a **right-brain output: driven by the dreamlike Right knob alone** (the
|
||||
analytical Left knob does not gate them) — the dream surfacing as the machine's
|
||||
felt reading. This is the uncanny edge of the thesis: the machine trying to
|
||||
quantify not just what is there, but how you are meant to feel.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- **Trigger / intensity.** Affect strength = `right` (0–4). Right at 0 → no
|
||||
emotions at all, regardless of Left. Opacity scales with that strength ×
|
||||
`overlay_gain`.
|
||||
- **Escalation.** A **stable emotional palette per scene** — higher Right reveals
|
||||
*more* words at higher opacity. Each word carries a `min_level` (the same
|
||||
mechanic the detection channel uses), compared against the `right` strength.
|
||||
- **Rendering.** Floating words at authored scene positions — **no boxes or
|
||||
reticles**, lowercase, semi-transparent with a soft glow, in a distinct **violet**
|
||||
register so they read as affective and clearly *softer* than the hard clinical
|
||||
reticles.
|
||||
|
||||
## Where the math lives
|
||||
|
||||
Consistent with the existing split (alteration math in Python, label-selection in
|
||||
the client):
|
||||
|
||||
- `player/alteration.py` gains an `AffectOverlay(strength, intensity)` on the
|
||||
`RenderPlan`. `strength = right`; `intensity = clamp(overlay_gain * right /
|
||||
KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under `"affect"`. Pure,
|
||||
unit-tested.
|
||||
- The simulator client picks *which* words to show by comparing each affect
|
||||
entry's `min_level` to `plan.affect.strength`, and renders them at
|
||||
`plan.affect.intensity` opacity.
|
||||
|
||||
`is_identity` is unaffected: `affect.strength == right`, and `right > 0` already
|
||||
makes `dream.strength > 0`.
|
||||
|
||||
## Data
|
||||
|
||||
New optional per-clip `affect` list in the manifest, parallel to `annotations`:
|
||||
|
||||
```json
|
||||
"affect": [
|
||||
{"key": "feel.awe", "at": [0.5, 0.4], "min_level": 1},
|
||||
{"key": "feel.serenity", "at": [0.2, 0.62], "min_level": 2}
|
||||
],
|
||||
"strings": {"en": {"feel.awe": "awe", "feel.serenity": "serenity"}}
|
||||
```
|
||||
|
||||
`at` is a normalized `[x, y]` anchor point (not a box — feelings are scene-level).
|
||||
The words live in the existing `strings.en` map. `Clip` gains an `affect` field
|
||||
(defaulting to `[]`) threaded through `_clip_from_dict` and `to_dict`.
|
||||
|
||||
Starter palettes (words are art-direction, easily tuned):
|
||||
|
||||
| scale | palette (min_level 1 → 4) |
|
||||
|--------|-----------------------------------------------|
|
||||
| forest | awe · serenity · reverence · stillness |
|
||||
| cosmos | wonder · vastness · insignificance · longing |
|
||||
| abyss | unease · fascination · isolation · dread |
|
||||
|
||||
All three scales carry the data; forest is fully realized (it has the real Right
|
||||
variants).
|
||||
|
||||
## Testing
|
||||
|
||||
- Python unit tests: affect `strength = min(left, right)` across knob combos;
|
||||
intensity scaling and `overlay_gain`; `0` when either knob is 0; presence in the
|
||||
serialized plan.
|
||||
- By-eye in the live simulator (forest, Left & Right both up) — the established
|
||||
judge-by-eye loop for this surface.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No drift-toward-unsettling arc (rejected in favour of a stable palette).
|
||||
- No per-object emotion tags; no animated drift (words are statically placed for
|
||||
v1; gentle motion can come later if wanted).
|
||||
@@ -0,0 +1,109 @@
|
||||
# Right axis as a real-time deterministic dream
|
||||
|
||||
**Status:** built (session 0013, 2026-06-22) — Phase 1 superseded by Phase 2 in the
|
||||
same session after by-eye review (the haze "looked like blurred video, not stylized").
|
||||
The shipped Right dream is the **Phase 2 painterly** path.
|
||||
**Surface:** simulator preview (`simulator/`, `player/alteration.py`)
|
||||
**Supersedes:** the "discrete pre-baked, flow-stabilized restyle variant" definition
|
||||
of the Right axis in the machine-altered-perception design SPEC §4.1 — a deliberate
|
||||
change to a core decision (see Why).
|
||||
|
||||
## Why
|
||||
|
||||
The pre-baked SD img2img variants (`bake_right_variants.py`) are **too fluid**: the
|
||||
painting re-rolls every keyframe (~2 s) and the optical-flow tween warps frames into
|
||||
each other, so across the loop the dream churns and melts. The right brain should be
|
||||
a **still altered state** — a calm, holistic counterpart to the busy, accreting
|
||||
analytical Left — not a restless morph.
|
||||
|
||||
## Concept
|
||||
|
||||
The Right knob no longer selects a pre-baked variant. It drives a **deterministic,
|
||||
real-time filter** applied live to the base footage, scaled 0→4. The same filter runs
|
||||
every frame, so the look is **stable by construction** — locked to scene content,
|
||||
moving only where the footage already moves. Bonus: no baked variant videos to ship,
|
||||
which simplifies the eventual hardware (Pi) story.
|
||||
|
||||
Staged delivery (both built this session; Phase 2 is what shipped):
|
||||
- **Phase 1: luminous haze** — soft-focus, blooming highlights, pastel desaturation.
|
||||
CSS + a bloom layer. Built, then **retired** by eye: blur reads as *out-of-focus
|
||||
video*, not stylized, and softens the base's crisp motion.
|
||||
- **Phase 2: painterly flatten (shipped)** — a WebGL2 **Kuwahara** shader on a
|
||||
`<canvas>` restyling the live video frames. Edge-preserving, so motion/structure
|
||||
stay as crisp as the source — the dream is the same footage, just stylized. No blur.
|
||||
|
||||
## Phase 2 — the shipped painterly dream
|
||||
|
||||
- **Renderer:** `<canvas id="paint">` over `<video>`; a `requestAnimationFrame` loop
|
||||
uploads each video frame to a texture and runs the Kuwahara fragment shader (radius
|
||||
`R=6`). `intensity = 0` ⇒ passthrough (raw base); ring transitions render passthrough
|
||||
(`busy`). WebGL-less fallback: canvas hidden, mood grade applied to `#vid`.
|
||||
- **Painterly:** `mix(base, kuwahara, intensity)` — flatten into painted regions, edges
|
||||
preserved.
|
||||
- **"Trippy" at max** (operator-tuned by eye): the psychedelic terms ramp with
|
||||
`t = intensity²` so low/mid Right stays gently dreamy and only **max** goes
|
||||
psychedelic — vivid saturation (`×(1 + 1.1·t)`), a surreal **hue drift** (~45° at max),
|
||||
and **posterize** banding (`mix(255, 6, t)` levels), plus a slight luminous lift.
|
||||
- **Mood grade** (Dark/Light) rides as a CSS filter on the canvas; `#tint` unchanged.
|
||||
- The Python plan is unchanged — Phase 2 is a client-render swap; `dream.intensity`
|
||||
drives the shader.
|
||||
|
||||
## Phase 1 — components
|
||||
|
||||
All scale with the Right strength `s = intensity` (0→1):
|
||||
|
||||
- **soft-focus** — gentle blur, ~`1.5·s` px.
|
||||
- **pastel** — `saturate(1 − 0.35·s)`, `contrast(1 − 0.15·s)` (lift shadows / flatten).
|
||||
- **luminous** — `brightness(1 + 0.08·s)`.
|
||||
- **bloom** — a second, synced copy of the video, blurred + brightened and
|
||||
`screen`-blended over the main, opacity `~0.6·s` — highlights glow and bleed.
|
||||
|
||||
Starting coefficients are by-eye seeds; the look is tuned live in the sim.
|
||||
|
||||
## Engine — `player/alteration.py`
|
||||
|
||||
- Replace `Restyle(variant)` with `Dream(strength, intensity)`:
|
||||
`strength = right` (0–4, discrete), `intensity = clamp(dream_gain · right / KNOB_MAX,
|
||||
0, 1)`.
|
||||
- `Calibration` gains `dream_gain: float = 1.0`; `DEFAULT_CALIBRATION` sets it 1.0.
|
||||
- `render_plan_to_dict`: `"restyle": {"variant"}` → `"dream": {"strength", "intensity"}`.
|
||||
- `is_identity`: Right contributes when `strength > 0` (replaces the
|
||||
`restyle.variant == 0` clause).
|
||||
- Pure + unit-tested. The client maps `dream.intensity` → CSS params, mirroring how
|
||||
`grade.tone` → video filter is mapped client-side today.
|
||||
|
||||
## Rendering — simulator
|
||||
|
||||
- **Remove** the variant-crossfade machinery (`loadVariant`, `variantFile`, the
|
||||
`right_variants` / `currentVariant` handling). The main video always plays
|
||||
`base_file`.
|
||||
- **Compose one video filter** from *both* the mood grade (`grade.tone`) and the dream
|
||||
(`dream.intensity`) — today `applyGrade` owns `vid.style.filter`; it becomes a single
|
||||
`applyVideoLook(tone, dreamIntensity)` so the two don't clobber each other.
|
||||
- **Add a bloom layer**: a second `<video id="bloom">` (same `base_file`, muted,
|
||||
looped, started in sync), `screen`-blended, blurred/brightened, opacity from
|
||||
`dream.intensity`. Both videos' `src` are set together on scale settle / ring move.
|
||||
- `#tint` (the Dark cool wash) is unchanged.
|
||||
|
||||
## Migration
|
||||
|
||||
- The baked SD variants (`*/right*.mp4`) and `bake_right_variants.py` become **legacy:
|
||||
parked in-repo, not deleted, unused at runtime**. `right_variants` stays in the
|
||||
manifest/`clips.py` (ignored by the client) to avoid churn; it can be removed later.
|
||||
- The scale ring and its transitions are untouched.
|
||||
- The affect channel is unaffected — Right is still a 0–4 knob, so `min(left, right)`
|
||||
emotions keep working.
|
||||
|
||||
## Testing
|
||||
|
||||
- Python unit tests: `Dream.strength = right`, `intensity` scaling + `dream_gain`,
|
||||
clamping, `0` at `right=0`, presence/shape in the serialized plan, `is_identity`.
|
||||
- By-eye in the live sim (forest, Right up, then Left+Right for the whole picture) —
|
||||
the established judge-by-eye loop. Success = the dream holds still across the loop;
|
||||
only real motion (the waterfall) moves.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Removing the legacy baker / baked media (left parked, unused at runtime).
|
||||
- Any change to the scale ring, transitions, Left HUD, or affect channel.
|
||||
- A Pi-GPU performance pass on the Kuwahara shader (desktop sim is the current target).
|
||||
@@ -0,0 +1,355 @@
|
||||
# HEF — Content Pipeline (source → process → label → manifest)
|
||||
|
||||
**Date:** 2026-06-24
|
||||
**Status:** Graduated — operator-approved (session 0014); **Increment 1 built & merged** (PR #13); real PD sourcing + rotating pool curated (PR #14/#15). **Increment 2 BUILT** (session 0016 — pool model + tiers + windows in PR #16; `track.py` + author mode in PR B; see §11).
|
||||
**Repo:** `human-experience-filter-art`
|
||||
**Anchor:** `docs/ROADMAP.md` sub-project 3 (Player Runtime) content needs; the
|
||||
spiritual successor to sub-project 2 (Ingest & Tagging tools), retargeted from the
|
||||
retired coordinate-catalog model to the manifest content model.
|
||||
**Refines:** [`2026-06-07-scales-library-and-right-axis-pipeline-design.md`](./2026-06-07-scales-library-and-right-axis-pipeline-design.md)
|
||||
§2 (scales library), §2.1 (strict-PD sourcing), §6 (open sourcing questions).
|
||||
**Builds on:** [`2026-06-22-right-axis-deterministic-dream-design.md`](./2026-06-22-right-axis-deterministic-dream-design.md)
|
||||
(the real-time dream — **no per-clip ML bake**) and
|
||||
[`2026-06-22-affect-channel-hud-design.md`](./2026-06-22-affect-channel-hud-design.md)
|
||||
(scene-anchored affect words).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
The installation has an alteration engine, a scale ring, a Left analytical HUD, an
|
||||
affect channel, and a real-time Right "dream" — but it has **no real content**. The
|
||||
three ring scales today are: `cosmos` and `abyss` carrying *placeholder bytes* (PD
|
||||
provenance recorded, but the media is synthetic), and `forest` carrying a *POC
|
||||
sample* (a trimmed Yosemite clip licensed "look-tuning only; not shipped content").
|
||||
There is no repeatable way to turn a public-domain source clip into a shippable,
|
||||
seamless-looping, labelled neutral base.
|
||||
|
||||
This design defines that pipeline: **source → frame → seamless-loop → transcode →
|
||||
label/author → manifest entry.** One big anchor fact reshapes it versus the old
|
||||
plan — the Right dream is now a **real-time deterministic shader** (session 0013),
|
||||
so there is **no per-clip ML restyle bake** anymore. The pipeline's offline cost is
|
||||
therefore **transcode + human authoring**, not generative-video baking. The one
|
||||
deliberate exception is the motion-tracking label pass chosen this session (§3),
|
||||
which re-introduces a bounded offline compute step — by choice, for realism.
|
||||
|
||||
## 2. Decisions settled this session
|
||||
|
||||
| # | Decision | Choice | Consequence |
|
||||
|---|----------|--------|-------------|
|
||||
| 1 | Scale set | **Lean 5** — cosmos · orbit · forest/land · reef · abyss (microscopic dropped) | Ring wraps **abyss → cosmos**; replaces the POC forest + both placeholder bases with real strict-PD footage |
|
||||
| 2 | Seamless loop | **Crossfade loop** (tail→head overlap) | Any source clip becomes an indefinite seamless dwell loop; a transcode-time step |
|
||||
| 3 | Label placement | **Motion-tracked, hybrid** | ML proposes tracks where it can; hand-seed + classical tracker elsewhere. Re-introduces an offline pass + an authoring step. Semantics always hand-authored |
|
||||
| 4 | Format | **Master + 1080p proxy** | High-quality master retained per clip; a 1080p H.264 proxy drives the sim; Pi chooses later |
|
||||
|
||||
Microscopic was dropped because PD microscopy *video* (vs stills) is the thinnest
|
||||
pool and hardest to source well; it can be added later by re-running the pipeline.
|
||||
|
||||
## 3. The pipeline — six stages
|
||||
|
||||
Each stage is a deterministic unit with one job (handbook §4.2: deterministic tools,
|
||||
not prose an agent re-derives). Stages 1–4 and 6 are mechanical; stage 5 is the one
|
||||
human-in-the-loop step.
|
||||
|
||||
```
|
||||
(1) source ──▶ (2) frame ──▶ (3) loop ──▶ (4) transcode ──▶ (6) manifest
|
||||
provenance trim/crop crossfade master + proxy entry
|
||||
& scale seam │ ▲
|
||||
└──▶ (5) label/author (hybrid track)
|
||||
```
|
||||
|
||||
### 3.1 Stage 1 — source & provenance
|
||||
Acquire the strict-PD source clip and record license + source URL + capture notes.
|
||||
Reuse sub-project-2 fetchers where they map (a NASA fetcher already exists); add
|
||||
NOAA Ocean Exploration / NPS / USGS fetchers (or accept a manual download + a
|
||||
provenance record). The "no explicit license → assume PD, verify" trap from the
|
||||
scales design §2.1 applies: **"free stock" (Pexels/Pixabay) is NOT public domain.**
|
||||
Output: the raw source file + a provenance record (license, source, url, fetched_at).
|
||||
|
||||
### 3.2 Stage 2 — frame (trim / crop / scale)
|
||||
Pick the in/out points; crop to the target aspect; scale toward the format target
|
||||
(§6). The panoramic projector aspect is still unmeasured (open question), so v1
|
||||
frames to a standard wide 16:9 for the sim; the master retains maximum available
|
||||
width so a later pano crop/extension has pixels to work with. Output: a framed,
|
||||
silent (audio stripped — bases are video-only; audio is the separate content-dial
|
||||
channel) intermediate.
|
||||
|
||||
### 3.3 Stage 3 — seamless crossfade loop
|
||||
Make the clip loop indefinitely with no visible seam by overlapping the tail into
|
||||
the head with a short crossfade (~0.5–1.0 s). The final looped clip is shorter than
|
||||
the source by the overlap; during the overlap region motion is a blend (acceptable
|
||||
for calm ambient nature footage). This is the chosen loop strategy (clean-cut and
|
||||
palindrome were rejected — seam risk and reversed motion respectively). Output: a
|
||||
loop-clean clip whose first and last frames join seamlessly.
|
||||
|
||||
### 3.4 Stage 4 — transcode (master + proxy)
|
||||
Emit two encodes per clip (§6 for exact params): a **master** (high quality, up to
|
||||
4K, retained as the archival/Pi-candidate source) and a **1080p H.264 proxy** (what
|
||||
the sim plays today, matching current `base.mp4` behaviour). Both video-only,
|
||||
`yuv420p`, even dimensions, `+faststart`.
|
||||
|
||||
### 3.5 Stage 5 — label & author (the hybrid motion-track pass)
|
||||
The only human-in-the-loop stage, and the only one that adds offline ML compute.
|
||||
|
||||
- **Semantics are always hand-authored.** A generic detector cannot produce the
|
||||
installation's semantic keys — "spiral galaxy", "siphonophore", `z ≈ 0.04`,
|
||||
`~2.1 m³/s` are out-of-distribution. The author owns the label **keys**,
|
||||
**strings**, and `min_level`; tracking only propagates **box geometry**.
|
||||
- **Hybrid geometry (decision 3).** Where a model can lock onto a subject (a fish
|
||||
on the reef, an animal in the forest), an ML detect+track pass (YOLO+ByteTrack /
|
||||
SAM2-class, lazy-imported like the parked baker imported torch) proposes a track
|
||||
the author maps to a key. Where it can't (cosmos, abyss, microscopic-type
|
||||
content), the author **hand-seeds** a box on a keyframe and a **classical tracker**
|
||||
(OpenCV CSRT / optical-flow) propagates it; the author fixes drift. Both paths
|
||||
emit the same artifact.
|
||||
- **Channel rules.** `detected.*` object labels **track**; `measure.*` readouts and
|
||||
`feel.*` affect words stay **static / scene-anchored** (a depth readout or "awe"
|
||||
is not pinned to a moving pixel). This matches the affect-channel design (affect
|
||||
is scene-level) and keeps measurement chips legible.
|
||||
- Output: an **annotation track** per object label (§4) plus the static measurement
|
||||
and affect placements, all keyed and stringed.
|
||||
|
||||
### 3.6 Stage 6 — manifest assembly
|
||||
Write the clip entry (base/proxy/master paths, license, source, annotations with
|
||||
tracks, affect, strings) into `simulator/sample_media/manifest.json`, and register
|
||||
the scale in the `ring` section with its transition edges (§5). Idempotent: stage 6
|
||||
owns the manifest entry so re-running earlier stages never clobbers authored labels
|
||||
(mirrors how `setup_sample_media.py` was made to never clobber a real bake).
|
||||
|
||||
## 4. Data model — annotation tracks
|
||||
|
||||
Today an annotation is a single static box: `{"key", "box":[x,y,w,h], "min_level"}`.
|
||||
Add an **optional keyframed track**; static labels are unchanged (backward
|
||||
compatible):
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "detected.fish",
|
||||
"min_level": 1,
|
||||
"track": [
|
||||
{"t": 0.0, "box": [0.30, 0.40, 0.10, 0.08]},
|
||||
{"t": 0.5, "box": [0.52, 0.38, 0.10, 0.08]},
|
||||
{"t": 1.0, "box": [0.30, 0.40, 0.10, 0.08]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `t` is **normalized 0→1 over the looped clip** — resolution- and fps-independent,
|
||||
so the same track is correct for both master and proxy and survives a re-transcode.
|
||||
(A track that loops cleanly has matching boxes at `t=0` and `t=1`.)
|
||||
- **Runtime:** if `track` is present, the client interpolates the box at the current
|
||||
loop-normalized playback time (linear between keyframes); otherwise it uses the
|
||||
static `box`. This lives **client-side**, consistent with the existing split
|
||||
(alteration math in Python; label selection/placement in the client). `Clip` gains
|
||||
the track only as data inside `annotations`; `player/alteration.py` is **unchanged**
|
||||
(labels are not part of `RenderPlan`).
|
||||
- `measure.*` keep a single static `box`; `feel.*` keep a single static `at:[x,y]`.
|
||||
|
||||
## 5. The 5 scales, sources, and ring
|
||||
|
||||
| Scale | Strict-PD source | Notes |
|
||||
|-------|------------------|-------|
|
||||
| `cosmos` | NASA / Hubble / JWST | 🟢 abundant true PD (17 U.S.C. §105); replaces placeholder |
|
||||
| `orbit` | NASA / ISS (Earth from orbit) | 🟢 true PD; **new** scale |
|
||||
| `forest` | NPS / USGS (US land/wildlife) | 🟢 true PD (US locations); replaces the non-shippable POC sample |
|
||||
| `reef` | NOAA Ocean Exploration (shallow) | 🟢 true PD worldwide; **new** scale |
|
||||
| `abyss` | NOAA Ocean Exploration (deep sea) | 🟢 true PD worldwide; replaces placeholder |
|
||||
|
||||
**Ring order (large → small, zooms inward; wraps small → large):**
|
||||
`cosmos → orbit → forest → reef → abyss → (wrap) cosmos`. Five scales → five ring
|
||||
edges. The wrap seam is now **abyss → cosmos** (deep sea back out to the cosmos) —
|
||||
the infinite-zoom payoff, minus the microscopic step.
|
||||
|
||||
**Transitions** between the new adjacent edges remain the **deferred i2v generative
|
||||
work** (scales design §3 / §6) — they need a generative-video model and two real
|
||||
adjacent bases, heavier than anything in this pipeline. Until then new edges carry
|
||||
**placeholder transitions** (the `setup_scales_media.py` ffmpeg `xfade=zoomin`
|
||||
generator already produces these), exactly as cosmos/forest/abyss do today. This
|
||||
pipeline's job is the **bases + labels**; transitions are tracked but out of scope
|
||||
here (§8).
|
||||
|
||||
## 6. Format target
|
||||
|
||||
| | Master | Proxy (sim) |
|
||||
|---|--------|-------------|
|
||||
| Resolution | source, capped ~3840 wide | 1920×1080 (fit wide) |
|
||||
| Codec | H.264 High | H.264 High |
|
||||
| Quality | CRF ~18 | CRF ~20 |
|
||||
| FPS | preserve source (cap 30) | 30 |
|
||||
| Pixel fmt | `yuv420p`, even dims | `yuv420p`, even dims |
|
||||
| Audio | none (stripped) | none (stripped) |
|
||||
| Flags | `+faststart` | `+faststart` |
|
||||
|
||||
The sim plays the **proxy** as today's `base_file`. The **master** is retained per
|
||||
clip for the eventual Pi/pano encode once the panoramic resolution is measured.
|
||||
Browser HEVC support is too spotty to make HEVC the sim path, which is why the proxy
|
||||
stays H.264 (the master can be re-encoded to HEVC for the Pi later without
|
||||
re-sourcing).
|
||||
|
||||
## 7. Tooling & where it lives
|
||||
|
||||
- **New `tools/pipeline/` package** — deterministic ffmpeg-backed verbs for stages
|
||||
1–4 and 6 (`fetch`/provenance, `frame`, `loop`, `transcode`, `manifest`), reusing
|
||||
sub-project-2 fetchers where valid. This **supersedes** the coordinate-era
|
||||
ingest *drafting* (the `left/right/dark/light` Record model retired with the
|
||||
machine-altered-perception pivot); the pipeline targets the **manifest**, not
|
||||
catalog `Record`s.
|
||||
- **Stage 5 tracking** — `tools/pipeline/track.py`: classical OpenCV tracker
|
||||
(always available) + an optional lazy-imported ML detect+track path. Emits
|
||||
candidate keyframed tracks for the author to accept/correct.
|
||||
- **Authoring surface** — extend the **simulator** with an **author mode** (it
|
||||
already renders the exact HUD overlay and is the by-eye judge): scrub the clip,
|
||||
draw/seed/adjust boxes, run the tracker, assign keys/`min_level`/strings, place
|
||||
affect anchors, write the manifest entry. Reusing the preview as the editor keeps
|
||||
"what you author is what plays." **This is the largest new piece** — see the
|
||||
delivery order (§8), which lets v1 prove the schema before the full editor exists.
|
||||
|
||||
## 8. Delivery / rollout order
|
||||
|
||||
Build order within this Feature (Solution-Design rollout strategy — not a task
|
||||
list):
|
||||
|
||||
1. **Increment 1 — real footage, no ML.** Stages 1–4 + 6 + the §4 track schema +
|
||||
client interpolation. Source the 5 strict-PD bases, crossfade-loop them, emit
|
||||
master+proxy, wire them into the ring. Author a *couple* of tracks **by hand as
|
||||
JSON** to prove the track schema and the runtime interpolation. Outcome: the
|
||||
whole ring is **real, seamless-looping footage** judgeable by eye — the
|
||||
highest-value, lowest-risk step, and it stands alone.
|
||||
2. **Increment 2 — the hybrid track pass + author mode.** `track.py` (classical +
|
||||
optional ML) and the simulator author mode; label all five scales properly.
|
||||
|
||||
**Deferred (tracked, not built here):** the real **i2v ring transitions** for the
|
||||
new edges (need a generative-video model + adjacent real bases); the Pi renderer +
|
||||
serial/firmware (per the simulator-first directive); the panoramic-resolution
|
||||
master encode (waits on a measured pano spec).
|
||||
|
||||
> Scope note: if Increment 2's author mode grows large enough to be its own
|
||||
> increment in practice, split it at execution time. The design is one coherent
|
||||
> pipeline; the increments above are its sensible build order, not separate
|
||||
> Features.
|
||||
|
||||
## 9. Open questions (for the plan, not blockers)
|
||||
|
||||
- **Panoramic resolution/aspect** — unmeasured (scales design §6). Drives the master
|
||||
crop/extension strategy; v1 frames 16:9 for the sim and retains max width.
|
||||
- **ML model choice** for the hybrid track path (YOLO+ByteTrack vs SAM2-class) and
|
||||
whether it earns its place given how few scales it can usefully fire on.
|
||||
- **Classical tracker drift** on long/fast clips — keyframe interval, re-seed cadence.
|
||||
- **Source fetchers** — which of NOAA / NPS / USGS get real fetchers vs manual
|
||||
download + provenance record for v1.
|
||||
- **Crossfade overlap length** per scale (slow cosmos vs busier reef) — by eye.
|
||||
|
||||
## 11. Increment 2 — schema decisions (session 0016)
|
||||
|
||||
Increment 2 is the second build order from §8. It is mostly **schema work**, so
|
||||
this section pins the concrete data shapes before the build (the operator asked for
|
||||
a short design pass first). Each subsection is a new, **backward-compatible**
|
||||
extension — Increment 1 manifests keep rendering unchanged.
|
||||
|
||||
### 11.1 Rotating clip pool (supersedes one-clip-per-scale)
|
||||
|
||||
Each ring scale references a **pool** of vetted clips
|
||||
(`docs/content-candidate-pool.md`); the player picks **one member at random when
|
||||
the viewer lands on that scale** (zooming the ring in/out). This supersedes
|
||||
decision §5's one-base-per-scale.
|
||||
|
||||
- **Manifest:** `ring.scales[i]` gains `"pool": ["clip_id", …]` (≥1). The legacy
|
||||
`"clip_id"` stays as the **primary** member for back-compat (= `pool[0]` when a
|
||||
pool is written); a scale with only `clip_id` and no `pool` behaves exactly as
|
||||
Increment 1 (pool of one).
|
||||
- **Canonical logic (`player/ring.py`):** `Scale` gains `pool: tuple[str, …]`;
|
||||
`members` = `pool or (clip_id,)`. A **pure** `pick_clip_id(scale, r)` takes an
|
||||
injected `r ∈ [0,1)` and returns `members[int(r·len)]` — randomness stays
|
||||
**injected** (DI), so the function is pure/testable and the Pi reuses it.
|
||||
- **Where the pick happens:** at the **API boundary** (`simulator/app.py`), which
|
||||
may be impure: the advance endpoint calls `pick_clip_id(landed_scale,
|
||||
random.random())` and returns it as `target_clip_id`. A `delta=0` advance is the
|
||||
**initial / re-roll** pick (no-op move, fresh random member). The client loads
|
||||
`move.target_clip_id` directly instead of deriving the clip from the index. One
|
||||
selection implementation, Python-canonical (browser still "only renders").
|
||||
- **Rename + cleanup:** ring scale `forest` → **`coast`** (new land↔sea scale
|
||||
between `orbit` and `reef`). Drop the leftovers `cosmos_traverse`,
|
||||
`orbit_westcoast`, old `forest`, old `reef` (per the pool doc's cleanup list);
|
||||
the cosmos pool's primary stays `cosmos` (Orion). New ring order:
|
||||
`cosmos → orbit → coast → reef → abyss → (wrap) cosmos`; edges regenerated
|
||||
(`orbit-coast`, `coast-reef` placeholders via `setup_scales_media.py`).
|
||||
|
||||
### 11.2 Time-windowed tracked labels
|
||||
|
||||
Objects drift **into and out of frame**, so a label appears only **while its
|
||||
object is on screen** and its box **tracks** the object (the §4 keyframed `track`).
|
||||
|
||||
- **Manifest:** an annotation gains optional `"appear"` and `"disappear"`
|
||||
(loop-normalized `t ∈ [0,1]`, same clock as `track`). The label renders only
|
||||
while `appear ≤ loopT ≤ disappear`. To let an object straddle the loop seam, a
|
||||
**`disappear < appear` window wraps** (visible when `loopT ≥ appear` **or**
|
||||
`loopT ≤ disappear`). Absent both fields → always-present (Increment-1 behavior).
|
||||
- **Runtime:** client-side in `renderOverlay` — a window test gates the annotation
|
||||
before the existing `boxAt(loopT)` placement. Engine unchanged (labels are not in
|
||||
`RenderPlan`). The **abyss + reef** pool clips (creatures entering/leaving) are
|
||||
the authored test material.
|
||||
|
||||
### 11.3 Progressive LEFT-brain detail tiers (replaces flat `min_level` gating)
|
||||
|
||||
The Left knob now drives **how many** labels show **and how analytical** each is —
|
||||
no fading. Low Left = **fewer, more general** labels; high Left = **more** labels +
|
||||
**more analytical** detail.
|
||||
|
||||
- **Per-object `salience` (1–4):** how prominent the object is. It sets the Left
|
||||
level at which the label first appears: `first_level = 5 − salience` (salience 4 →
|
||||
appears at Left 1; salience 1 → only at Left 4). So raising Left brings in
|
||||
lower-salience objects.
|
||||
- **Detail tiers (escalate with Left):** the displayed string escalates
|
||||
`tier 1 general ("eel") → 2 specific ("moray eel") → 3 scientific
|
||||
(*Gymnothorax*) → 4 scientific + a fact (lifespan)`. The tier shown =
|
||||
`clamp(left − first_level + 1, 1, n_tiers)` — an object shows tier 1 the moment
|
||||
it appears and climbs one tier per further Left notch until its tiers run out.
|
||||
- **Strings carry the tiers:** `strings.en[key]` may be a **list** (indexed by
|
||||
`tier−1`) instead of a string; a plain string stays static (back-compat, tier
|
||||
ignored). Keeps one key per object and the i18n table as the single source.
|
||||
- **Back-compat:** an annotation with `min_level` and no `salience` keeps flat
|
||||
gating (`first_level = min_level`, single-tier string). `measure.*` readouts stay
|
||||
single-tier, gated by their level (a measurement is not a tiered object).
|
||||
- **No engine change:** tier selection is client-side against the existing
|
||||
`overlay.level` (the Left knob) already in the `RenderPlan`.
|
||||
|
||||
### 11.4 Progressive RIGHT-brain emotion tiers
|
||||
|
||||
The affect **vocabulary** gets more sophisticated as Right rises — basic words at
|
||||
Right 1, nuanced/compound feelings at high Right — mirroring §11.3 for affect.
|
||||
|
||||
- **Appearance rule unchanged:** affect surfaces only when **both** knobs are up;
|
||||
an anchor's `min_level` is tested against `strength = min(left, right)` as today.
|
||||
- **Emotion tiers:** `strings.en[feel.key]` may be a **list**; the word shown =
|
||||
`clamp(right, 1, n_tiers)` (the Right knob, read from `dream.strength` already in
|
||||
the `RenderPlan`). A plain string stays static (back-compat).
|
||||
- **No engine change:** `AffectOverlay` already carries `strength`; the Right level
|
||||
comes from `dream.strength`. Tier selection is client-side.
|
||||
|
||||
### 11.5 `tools/pipeline/track.py` (hybrid) + simulator author mode
|
||||
|
||||
The stage-5 tooling from §3.5 / §7, delivered as a **second PR** after the
|
||||
experience PR (§8's split note).
|
||||
|
||||
- **`track.py`:** a **classical** path (OpenCV CSRT / optical-flow) seeded by a
|
||||
hand-drawn box on a keyframe, propagated and **sampled to a sparse keyframed
|
||||
`track`** (normalized `{t, box}` over the loop, §4); plus an **optional,
|
||||
lazy-imported ML** detect+track path (YOLO+ByteTrack-class) that proposes tracks
|
||||
the author maps to keys. Both emit the same `track` artifact + an `appear`/
|
||||
`disappear` window. Pure helpers (normalization, keyframe sampling,
|
||||
track→manifest) are unit-tested; an **opt-in** integration test runs the real
|
||||
tracker on a pool clip (mirrors the Increment-1 real-footage test).
|
||||
- **Author mode (simulator):** reuse the preview UI as the editor — scrub a pool
|
||||
clip, draw/seed/correct boxes, run the tracker, assign `key` / `salience` /
|
||||
tiered strings / `appear`-`disappear` / affect anchors, and **write the manifest
|
||||
entry** (an endpoint upserts via `tools/pipeline/manifest.upsert_clip` and
|
||||
persists). Semantics (keys, strings, scientific names, facts) are **always
|
||||
hand-authored** — the tracker only propagates **box geometry** (§3.5).
|
||||
|
||||
## 12. Out of scope (YAGNI)
|
||||
|
||||
- Generative i2v **ring transitions** for the new edges (deferred; placeholders hold).
|
||||
- **Audio** sourcing/alteration (separate content-dial channel; already deferred).
|
||||
- The **Pi/pano** encode + renderer and serial/firmware (simulator-first).
|
||||
- Reviving the retired coordinate-catalog `Record` drafting.
|
||||
- Per-frame **dense** tracks (sparse keyframed tracks interpolated at runtime instead).
|
||||
@@ -0,0 +1,64 @@
|
||||
# Simulator Dev Mode — design
|
||||
|
||||
> 2026-06-25 · session 0017 · planning-and-executing
|
||||
> Anchor: leaf task (simulator dev-tooling — R2b, no upstream design needed)
|
||||
|
||||
## Problem
|
||||
|
||||
While previewing the experience, the operator can't choose *which* clip in an
|
||||
altitude's rotating pool is shown — the server picks a random pool member on each
|
||||
landing (content-pipeline §11.1). For by-eye review they want to step through every
|
||||
clip in a pool on demand, and to see the engine/clip data behind what's on screen.
|
||||
The RenderPlan JSON readout is also always visible, cluttering what should read as
|
||||
an audience-facing preview.
|
||||
|
||||
## Goal
|
||||
|
||||
A single **Dev Mode** toggle at the bottom of the control panel. Off by default
|
||||
(persisted in `localStorage`). When on, a panel appears directly below it with all
|
||||
dev controls and data; when off, the normal view is just the experience + the three
|
||||
control fieldsets.
|
||||
|
||||
## Scope
|
||||
|
||||
Frontend only — `simulator/static/{index.html,app.js,style.css}`. No API changes:
|
||||
every datum Dev Mode shows is already available client-side (`/api/clips`,
|
||||
`/api/ring`, the `/api/alteration` response, and the in-memory preload cache). The
|
||||
separate `/author.html` remains the place to *edit* labels; Dev Mode is
|
||||
inspect-and-select only.
|
||||
|
||||
## Dev panel contents (top → bottom)
|
||||
|
||||
1. **Pool picker** — a `<select>` of the current altitude's pool members
|
||||
(`clip_id · title`), its value reflecting the clip currently playing. Choosing a
|
||||
member sets `activeClipId` to it and reloads the base media, overriding the random
|
||||
landing pick. A `🎲 Re-roll random` button re-runs the server-canonical random
|
||||
pick for the current scale. The dropdown is rebuilt on every scale landing, so it
|
||||
always lists the current pool; single-clip scales (cosmos/orbit) show one entry.
|
||||
2. **Active clip** — `id`, `title`, `source`, `license`, `base_file`.
|
||||
3. **Annotations & affect** — each annotation key with salience (or legacy
|
||||
`min_level`), time window (`appear`–`disappear`), and tier count; each affect key
|
||||
with `min_level` and tier count. Read-only.
|
||||
4. **RenderPlan** — the relocated `#readout` (the `/api/alteration` JSON).
|
||||
5. **Cache & playback** — preload status (`N/total cached`), whether the active
|
||||
clip is served **from memory** (a preloaded blob) vs the network, and live video
|
||||
resolution / duration / loop position.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Pool override is per-landing.** Changing altitude re-rolls randomly as today;
|
||||
the operator then picks again from the new scale's pool. The pick is not persisted
|
||||
across landings (YAGNI — "select any video while on an altitude").
|
||||
- **Refresh points.** Pool picker + clip metadata + annotations rebuild whenever the
|
||||
scale lands (hooked into `renderScaleReadout()`) and after a manual pick/re-roll.
|
||||
The RenderPlan readout updates on each `update()` (knob change) as today. The
|
||||
cache/playback stats refresh on a light interval (~500 ms) while Dev Mode is on,
|
||||
and the cache count also ticks up as the preloader fills.
|
||||
- **`pickRandomMember()`** is factored out of `landScale()` so the initial landing,
|
||||
ring advances, and the Re-roll button all share the one server-canonical pick.
|
||||
|
||||
## Testing
|
||||
|
||||
No JS unit harness in the repo and no API change, so verification is: `node --check`
|
||||
on `app.js`, the Python suite stays green (unaffected), and by-eye review by the
|
||||
operator (no headless browser yet — §9 E2E policy noted, machinery pending).
|
||||
@@ -0,0 +1,274 @@
|
||||
# Audio + Video — separated Visual & Audio controls — design
|
||||
|
||||
**Status:** proposed → built (sessions 0020 build + 0021 revision)
|
||||
**Date:** 2026-06-26
|
||||
**Session:** 0020 (audio) · 0021 (live-UI revision)
|
||||
|
||||
> **v1 build revision (session 0021, operator-driven):** the live **Audio** control
|
||||
> ships as a simple **off / soundtrack** toggle (styled like the Dev Mode switch),
|
||||
> and **Video** is the matching on/off toggle. **White-noise is deferred** with music
|
||||
> (the orthogonal Visual × Audio model and the synthesized-pink-noise machinery
|
||||
> remain in the codebase for when it's wanted). Two real-browser bugs were fixed in
|
||||
> this revision: video-off now blanks GPU-composited layers (z-index + explicit layer
|
||||
> hide), and the soundtrack now plays on Safari/iOS (audio elements unlocked
|
||||
> synchronously inside the start gesture). Sections below describe the fuller
|
||||
> off/soundtrack/white-noise design; v1 live = off/soundtrack.
|
||||
>
|
||||
> **Startup revision (session 0022):** the tap-to-start wall is **removed** — the app
|
||||
> opens behind a **"Loading Universe…"** splash (shown until all media is preloaded
|
||||
> and the experience can run smoothly), then both toggles start **off** (black +
|
||||
> silent). Autoplay is satisfied without a wall: a **single** `<audio>` element is
|
||||
> played **synchronously inside the toggle's own click** (the reliable Safari unlock),
|
||||
> replacing the A/B-crossfade-after-gesture approach that failed on real Safari. The
|
||||
> first time **Video** is turned on, **Audio** turns on with it (one flip = the full
|
||||
> experience); turning Audio on first stays audio-only. Altitude changes swap the
|
||||
> single element's source with a brief fade.
|
||||
**Extends / reframes:** [`2026-06-26-networked-control-surface-design.md`](./2026-06-26-networked-control-surface-design.md)
|
||||
§3 (control inventory) and §5 (server contract) — its bundled **Content** selector
|
||||
is split into two orthogonal controls. Everything else in that spec (server-of-truth
|
||||
+ SSE, the two pages, the Arduino seam, transition lifecycle) stands unchanged.
|
||||
**Asset record:** [`docs/audio-candidate-pool.md`](../../audio-candidate-pool.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Give the experience **sound**, and make audio and video **independently
|
||||
controllable**. Today the control surface bundles audio+video into a single 7-way
|
||||
**Content** selector (*video / audio+video / music+video / off / white noise /
|
||||
music / audio track*). That bundle is both awkward to operate and **incomplete** —
|
||||
it has no "white noise **with** video," for instance.
|
||||
|
||||
Replace it with **two orthogonal dials** — **Visual** and **Audio** — so every
|
||||
combination is reachable, including the ones the bundle omitted. Operator framing
|
||||
(2026-06-26): *"separate these dials… it's ok to let people have e.g. white noise
|
||||
with video."*
|
||||
|
||||
## 2. Core idea: orthogonalize Content into Visual × Audio
|
||||
|
||||
```
|
||||
BEFORE (bundled) AFTER (orthogonal)
|
||||
Content: 7-way selector Visual: on / off
|
||||
video Audio: off / soundtrack / white noise
|
||||
audio+video (· music — deferred)
|
||||
music+video ──▶ ───────────────────────────────────────
|
||||
off Any Visual × Any Audio is now reachable:
|
||||
white noise video + soundtrack (was "audio+video")
|
||||
music video + white noise (NEW — was missing)
|
||||
audio track black + white noise (was "white noise")
|
||||
black + soundtrack (was "audio track")
|
||||
```
|
||||
|
||||
The 7 bundled modes were really points in a **2 × N grid** (visual on/off ×
|
||||
audio source). Making the grid explicit removes the awkwardness and fills the gap.
|
||||
The **Altitude** dial is unchanged and remains the single scale selector.
|
||||
|
||||
## 3. The controls (reframes control-surface §3)
|
||||
|
||||
| Control | Positions (v1) | Drives | Class (§4 of control-surface spec) |
|
||||
|---|---|---|---|
|
||||
| **Altitude** | endless dial, cosmos→abyss (wraps) | the visual **scale** **and**, when Audio=soundtrack, the matching ambience | transitional |
|
||||
| **Visual** | on / off | video shown vs. black | transitional (video fade) |
|
||||
| **Audio** | off / soundtrack / white noise (· *music* deferred) | the audio **source** | live + short crossfade (§7) |
|
||||
| **Left** / **Right** / **Mood** | unchanged | the alteration engine | live |
|
||||
|
||||
So the panel grows from 5 controls to **6** (Content → Visual + Audio). The
|
||||
physical form (later): Visual = a toggle/2-pos switch; Audio = a small rotary
|
||||
selector (3-pos in v1, 4 with music).
|
||||
|
||||
## 4. Audio model (the coupling decision)
|
||||
|
||||
**Decision (operator):** the audio dial is a **source** selector; the per-altitude
|
||||
soundtrack **follows the single Altitude dial**. There is **no** separate audio
|
||||
altitude.
|
||||
|
||||
- **`soundtrack`** → plays the ambience of the **current Altitude scale**. Turning
|
||||
Altitude to coast shows coast video *and* plays coast waves; the soundtrack
|
||||
**crossfades** as the altitude transition settles (mirrors the video ring
|
||||
crossfade). One dial, two coupled outputs.
|
||||
- **`white noise`** → a single global bed, **altitude-independent**. Changing
|
||||
Altitude does not change it. This is what makes "white noise + any video" work.
|
||||
- **`off`** → silence. Video (if Visual=on) plays mute.
|
||||
- **`music`** *(deferred)* → the composed per-altitude music layer. Reserved as a
|
||||
future Audio position; **not in v1** (no assets yet — see
|
||||
[`docs/audio-candidate-pool.md`](../../audio-candidate-pool.md) "two audio layers").
|
||||
|
||||
Independence summary: **Visual ⟂ Audio** (any pairing allowed); **Audio source ⟂
|
||||
Altitude** for white noise/music, **coupled to Altitude** for soundtrack.
|
||||
|
||||
## 5. Assets & manifest
|
||||
|
||||
### 5.1 Per-scale soundtrack
|
||||
|
||||
The five ambiences are sourced and reviewed (PD/CC0; record in
|
||||
[`docs/audio-candidate-pool.md`](../../audio-candidate-pool.md), local-only review
|
||||
gallery `simulator/static/review_audio.html`):
|
||||
|
||||
| scale | soundtrack | license |
|
||||
|---|---|---|
|
||||
| cosmos | Pillars of Creation sonification | PD (NASA) |
|
||||
| orbit | station room-tone hum | CC0 |
|
||||
| coast | sea waves + terns | CC0 |
|
||||
| reef | 6-layer reef soundscape | PD (NOAA) + CC0 |
|
||||
| abyss | blue-whale moan | PD (NOAA) |
|
||||
|
||||
The manifest's `Scale` gains an **`audio`** field (path to the scale's soundtrack),
|
||||
parallel to how clips are referenced; media stays gitignored and is served at
|
||||
`/media/audio/<scale>/<file>`. Soundtracks are a **per-scale single** (not a
|
||||
rotating pool) in v1 — one bed per altitude. (A rotating audio pool, like the clip
|
||||
pool, is a possible later extension; out of v1.)
|
||||
|
||||
### 5.2 White noise
|
||||
|
||||
Altitude-independent global asset. **Synthesized, not sourced** — a calm
|
||||
**pink/brown** noise (gentler than pure white) generated deterministically with
|
||||
`ffmpeg -f lavfi -i anoisesrc=color=pink` → a clean ~60 s loop. Zero licensing
|
||||
concerns. (Colloquially "white noise"; spec a calm colored noise.)
|
||||
|
||||
### 5.3 Production pass (assets)
|
||||
|
||||
Before ship the source clips get a light, deterministic pass (the existing
|
||||
`tools/pipeline` ffmpeg approach): **seamless loop** (crossfade the loop point),
|
||||
**normalize loudness** across the five so altitudes match, and a consistent format
|
||||
(mp3/ogg). Several are short (abyss 13 s, cosmos 30 s) and must loop cleanly. The
|
||||
6-layer reef bake is already loop-length (60 s).
|
||||
|
||||
## 6. Server contract (reframes control-surface §5)
|
||||
|
||||
The control-surface spec's authoritative `SessionState` + four endpoints are
|
||||
unchanged in shape; only the **controls** payload changes: `content` is replaced
|
||||
by `visual` + `audio`, and the derived `render.content` is split.
|
||||
|
||||
### 6.1 `GET /api/state` — updated `controls` + `render`
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": 42,
|
||||
"controls": { "visual": "on", "audio": "soundtrack", "left": 3, "right": 1, "mood": -2 },
|
||||
"altitude": { "index": 2, "scale": "coast", "clip_id": "coast_07" },
|
||||
"transitions": { "altitude": "idle", "visual": "idle" },
|
||||
"render": {
|
||||
"plan": { },
|
||||
"video": { "shown": true },
|
||||
"audio": { "source": "soundtrack", "url": "/media/audio/coast/waves.mp3", "altitude_coupled": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `audio.url` is resolved **server-side** from `(audio source, altitude)`:
|
||||
`soundtrack` → the current scale's `audio`; `white_noise` → the global noise
|
||||
asset; `off` → `null`. The renderer just plays `audio.url` (or nothing).
|
||||
- `transitions` tracks `visual` instead of `content` (Audio is not transitional —
|
||||
§7).
|
||||
|
||||
### 6.2 `POST /api/control`
|
||||
|
||||
```json
|
||||
{ "set": { "audio": "white_noise" } } // audio source change
|
||||
{ "set": { "visual": "off" } } // visual fade to black
|
||||
{ "set": {}, "altitude_delta": -1 } // altitude tick (also re-resolves audio.url when source=soundtrack)
|
||||
```
|
||||
|
||||
Server applies, bumps `seq`, recomputes `render.audio.url`, and broadcasts.
|
||||
Absolute `set` values keep retries idempotent (control-surface §5.6).
|
||||
|
||||
### 6.3 SSE events
|
||||
|
||||
- `state` — carries the new controls/render; emitted on Audio change and on any
|
||||
live change (Audio source change rides the `state` event — §7).
|
||||
- `ring` — altitude move; now **also** carries the new `audio.url` so the renderer
|
||||
can crossfade the soundtrack as it plays the visual transition (when
|
||||
source=soundtrack).
|
||||
- `mode` — repurposed to the **Visual** fade (on/off). (Same renderer fade machinery
|
||||
the bundled Content used.)
|
||||
|
||||
## 7. Live vs. transitional classification
|
||||
|
||||
- **Visual on/off → transitional.** It's a visible video fade (to/from black); it
|
||||
uses the renderer's existing fade + `settled` ack, exactly as the bundled
|
||||
Content's fade did. `transitions.visual` flips `transitioning`→`idle`.
|
||||
- **Audio source → live, with a short renderer-side crossfade.** Swapping
|
||||
soundtrack/white-noise/off is a ~0.5–1 s **gain crossfade** between two `<audio>`
|
||||
elements — perceptible but **non-blocking** and not a renderer animation with
|
||||
unknown timing. So it's classified **live** (no busy spinner, no `settled` ack),
|
||||
like Left/Right/Mood. *Flagged decision:* if the operator wants the Audio knob to
|
||||
feel "busy" during its fade, it can be promoted to transitional later; v1 keeps it
|
||||
live for simplicity.
|
||||
- **Altitude → transitional** (unchanged). When source=soundtrack, the renderer
|
||||
crossfades the audio bed in step with the ring transition; the audio crossfade is
|
||||
slaved to the visual transition and needs no separate ack.
|
||||
|
||||
## 8. Renderer behavior (extends control-surface §6.1)
|
||||
|
||||
The display page adds an **audio layer**:
|
||||
|
||||
- Two `<audio>` elements (A/B) for gapless gain crossfades; a tiny crossfade helper.
|
||||
- On `state`: reconcile `audio.url` — if it changed, crossfade A↔B to the new url
|
||||
(or fade to silence for `off`). White-noise and soundtrack are the same mechanism;
|
||||
only the url differs.
|
||||
- On `ring` (altitude move) with source=soundtrack: start the soundtrack crossfade
|
||||
to the event's new `audio.url` so it lands with the visual scale.
|
||||
- On `mode` (Visual): fade the **video** to/from black; audio is untouched (you can
|
||||
hear soundtrack/white-noise over black).
|
||||
- Each soundtrack loops (the production-pass clean loop); white-noise loops.
|
||||
- Autoplay: the renderer is launched full-screen by the operator; a one-time user
|
||||
gesture (or `--autoplay`/muted-then-unmute) satisfies browser autoplay policy.
|
||||
*Flagged:* document the launch gesture so the projection starts audio reliably.
|
||||
|
||||
## 9. Testing (mandatory pipeline tiers)
|
||||
|
||||
- **Pure unit** — `render.audio.url` resolution from `(source, altitude)`:
|
||||
soundtrack→scale asset, white_noise→global, off→null; altitude tick re-resolves
|
||||
only when source=soundtrack; `seq` bump. No I/O.
|
||||
- **API/contract** — `POST /api/control {visual}` flips `transitions.visual`;
|
||||
`{audio}` rides a `state` event with the new url and **no** transition flag; bad
|
||||
enum → 422; `GET /api/state` shape.
|
||||
- **E2E (Playwright)** — renderer + `/remote`: set Audio=white_noise → assert the
|
||||
renderer's audio element src is the noise asset; tick Altitude with
|
||||
Audio=soundtrack → assert the soundtrack url crossfades to the new scale; set
|
||||
Visual=off → assert the video fades and audio keeps playing. (Audio *playback*
|
||||
asserted via element `src`/`paused`, not waveform.)
|
||||
|
||||
## 10. Scope
|
||||
|
||||
**In v1:**
|
||||
|
||||
- Split **Content** → **Visual** (on/off) + **Audio** (off / soundtrack / white
|
||||
noise) across `SessionState`, `/api/state`, `/api/control`, SSE, and both pages.
|
||||
- Per-scale `audio` in the manifest + the 5 sourced soundtracks (production-passed
|
||||
loops); synthesized white-noise asset.
|
||||
- Server-side `audio.url` resolution; renderer audio layer with crossfades;
|
||||
altitude-coupled soundtrack crossfade.
|
||||
|
||||
**Explicitly NOT in v1 (deferred):**
|
||||
|
||||
- **Music layer** (`audio=music`) — reserved dial position; no assets yet.
|
||||
- **Separate audio altitude** (hear reef while seeing coast) — rejected coupling
|
||||
option; soundtrack follows the one Altitude dial.
|
||||
- **Rotating audio pools** per scale (one bed per altitude in v1).
|
||||
- Per-altitude white-noise variants (one global noise).
|
||||
- Physical Visual/Audio switches (Arduino) — contract only, per control-surface §7.
|
||||
|
||||
## 11. Spec & roadmap impact
|
||||
|
||||
- **Reframes** control-surface §3/§5: `content` → `visual` + `audio`;
|
||||
`transitions.content` → `transitions.visual`; `render.content` → `render.video`
|
||||
+ `render.audio`. That spec is still *proposed* (unbuilt), so this is a clean
|
||||
pre-implementation edit, not a migration. Both can be built together, or this
|
||||
one folds into the control-surface build as its Content step.
|
||||
- The **music layer** becomes a tracked follow-on (compose + source per-altitude
|
||||
music; flip on the reserved Audio position).
|
||||
- `ROADMAP.md` to note the audio capability at implementation time.
|
||||
|
||||
## 12. Open questions / flagged — RESOLVED (session 0020 build)
|
||||
|
||||
- **Audio knob feel** (§7) — **RESOLVED: live**, with a ~0.6 s renderer-side gain
|
||||
crossfade (`XFADE_MS=600`), no `settled` ack. Promote to transitional later only
|
||||
if the iPad makes it feel like it needs a "busy" state.
|
||||
- **White-noise color** — **RESOLVED: pink** (`anoisesrc=color=pink`), a balanced
|
||||
calm bed. Brown is a one-parameter alternative (`color=brown`) if a deeper rumble
|
||||
is wanted; the build is parameterized for it.
|
||||
- **Autoplay gesture** (§8) — **RESOLVED: a one-time tap-to-start overlay**
|
||||
("▶ tap to start sound", `#start-gesture`). The first click unlocks both `<audio>`
|
||||
elements within the user gesture; thereafter audio follows state. This is the
|
||||
documented launch step for the full-screen projection.
|
||||
@@ -0,0 +1,331 @@
|
||||
# Networked Control Surface — design
|
||||
|
||||
**Status:** proposed
|
||||
**Date:** 2026-06-26
|
||||
**Session:** 0019
|
||||
**Supersedes/reframes:** ROADMAP sub-project 4 (Arduino Firmware / control panel) and
|
||||
the "serial input" slice of sub-project 3 (Player Runtime).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Split the existing simulator into a **renderer** (the laptop full-screen
|
||||
projection — "the experience") and a separate **controller** (a touch remote),
|
||||
talking over the local network, so the experience can be driven from a handheld
|
||||
device the way the real installation's panel drives its screen.
|
||||
|
||||
This is **v1 hardware**, deliberately simulator-friendly:
|
||||
|
||||
- The **laptop/computer renders** (no Raspberry Pi yet).
|
||||
- The controller is a **separate device** over **wifi** (LAN), not a USB-serial
|
||||
link to a Pi.
|
||||
- The controller is **prototyped as a web page** (an iPad/tablet/phone twin),
|
||||
and a **physical Arduino remote** drops in later as just another control source
|
||||
speaking the same contract.
|
||||
|
||||
Two goals, equally weighted (operator, session 0019): **decouple control from
|
||||
display cleanly**, *and* get the **handheld "stand back and drive it" feel**.
|
||||
|
||||
## 2. Core idea: one server of truth, interchangeable control sources
|
||||
|
||||
```
|
||||
┌──────────────────┐ POST /api/control ┌─────────────────────┐
|
||||
│ CONTROL SOURCE │ ───── (knob change) ────▶ │ FastAPI server │
|
||||
│ │ │ (laptop) │
|
||||
│ • iPad web remote│ ◀──── GET /api/state ──── │ ┌───────────────┐ │
|
||||
│ (v1) │ (sync on load) │ │ SessionState │ │
|
||||
│ • Arduino (later)│ │ │ controls + │ │
|
||||
└──────────────────┘ │ │ ring index + │ │
|
||||
│ │ transitions + │ │
|
||||
┌──────────────────┐ GET /api/events (SSE) │ │ seq (version) │ │
|
||||
│ RENDERER │ ◀═══ state/ring/mode ════ │ └───────┬───────┘ │
|
||||
│ laptop full- │ (live push) └──────────┼──────────┘
|
||||
│ screen │ │
|
||||
│ (the experience) │ ──── POST /api/render/event ────────▶│ (settled ack)
|
||||
└──────────────────┘ recompute RenderPlan + ring move
|
||||
```
|
||||
|
||||
- **Server** owns the single authoritative `SessionState` (current controls,
|
||||
altitude/ring index, per-field transition state, a monotonic `seq`). Every
|
||||
change recomputes the derived view and broadcasts it.
|
||||
- **Control source** = anything that `POST`s a control change. v1 has exactly
|
||||
one: the iPad web remote. A future Arduino is *another instance of the same
|
||||
role* — no new server concept.
|
||||
- **Renderer** = the laptop projection. It holds **no** input UI; it subscribes
|
||||
to the SSE stream and renders whatever state arrives. It is today's
|
||||
`simulator/static/index.html`, stripped of its sliders, given a `state`
|
||||
listener plus one small upstream ack channel.
|
||||
|
||||
The property that matters: controller and renderer are now **separate web pages
|
||||
off the same server**, decoupled exactly as a real installation's panel and
|
||||
screen are. (Chosen architecture: server-authoritative session + **Server-Sent
|
||||
Events** push. SSE fits because the directions are asymmetric — the renderer only
|
||||
*receives*, the control source only *sends* — so no bidirectional WebSocket is
|
||||
needed. Alternatives considered and rejected: renderer polling — laggy/wasteful;
|
||||
peer-to-peer WebRTC — most plumbing and it breaks the clean single-contract seam
|
||||
that makes the Arduino a drop-in.)
|
||||
|
||||
## 3. Control surface: the six controls (the faithful twin)
|
||||
|
||||
The iPad remote is a **faithful on-screen twin** of the intended physical panel —
|
||||
what you like on the iPad is the spec for the Arduino panel. The control
|
||||
inventory is exactly what the sim exposes today:
|
||||
|
||||
| Control | Sim today | Physical form (later) |
|
||||
|---|---|---|
|
||||
| **Visual** | toggle (show video on/off) | 2-position switch |
|
||||
| **Audio** | on/off toggle (on = per-altitude soundtrack; ·white-noise & music deferred) | 2-position switch (rotary selector when more sources land) |
|
||||
| **Altitude** | endless circular dial — walks the 5 scales (cosmos→abyss), wraps | rotary encoder (endless, detented) |
|
||||
| **Left** (analytical) | slider 0–4 | knob/pot, 5 detents |
|
||||
| **Right** (dreamlike) | slider 0–4 | knob/pot, 5 detents |
|
||||
| **Mood** | slider −4…+4 (dark↔light) | center-detented knob |
|
||||
|
||||
(This is the original sub-project-4 inventory, *updated*: Dark+Light collapsed
|
||||
into one **Mood** knob in session 0013, the **Altitude** encoder added in session
|
||||
0018, and the bundled 7-way **Content** dial split into orthogonal **Visual** ×
|
||||
**Audio** controls in session 0020 — see
|
||||
[`2026-06-26-audio-video-separated-controls-design.md`](./2026-06-26-audio-video-separated-controls-design.md).)
|
||||
|
||||
## 4. Live vs. transitional controls (per-knob transition state)
|
||||
|
||||
The five controls split into two classes by how a **human** perceives the change:
|
||||
|
||||
- **Live controls — Left, Right, Mood, Audio.** The alteration engine treats
|
||||
Left/Right/Mood as `LIVE_UPDATE` (the WebGL Kuwahara dream + HUD overlay + color
|
||||
grade re-tune within a frame). **Audio** source changes are also live: a short
|
||||
(~0.6 s) renderer-side gain crossfade between two `<audio>` elements —
|
||||
perceptible but non-blocking, with no `settled` ack (audio spec §7). These are
|
||||
*always idle*; the remote just shows the value.
|
||||
- **Transitional controls — Altitude, Visual.** These trigger a renderer
|
||||
animation that takes real, perceptible time: Altitude plays ring-transition
|
||||
clips (zoom/warp between scales); **Visual** on/off does fade-to-black /
|
||||
fade-from-black. During that window the control is **busy**, and the control
|
||||
source must model that so its feedback is honest.
|
||||
|
||||
Per-transitional-control lifecycle:
|
||||
|
||||
```
|
||||
idle ──(source sends)──▶ pending ──(renderer starts)──▶ transitioning ──(renderer settles)──▶ idle
|
||||
```
|
||||
|
||||
- **pending** — set locally by the remote the instant the knob is turned
|
||||
(immediate tactile feedback before the server replies).
|
||||
- **transitioning** — the server flags the field once the renderer actually
|
||||
begins the animation; broadcast to *all* control sources.
|
||||
- **idle** — cleared when the **renderer acks completion** (the only party that
|
||||
knows real playback timing), with a server-side max-duration timeout as a
|
||||
safety net so a dropped renderer can't wedge a knob.
|
||||
|
||||
Hardware payoff: a physical Altitude encoder can light an LED while
|
||||
`transitioning`; the iPad twin shows the matching spinner/disabled-detent.
|
||||
|
||||
`SessionState` therefore carries
|
||||
`transitions: { altitude: idle|transitioning, visual: idle|transitioning }`
|
||||
alongside the controls and `seq`. (Live controls — Left/Right/Mood/Audio — need
|
||||
no entry.)
|
||||
|
||||
## 5. Server contract
|
||||
|
||||
Four endpoints carry the whole thing. The existing `/api/alteration`,
|
||||
`/api/ring`, `/api/ring/advance` remain as **internal helpers the server calls**;
|
||||
control sources never hit them directly.
|
||||
|
||||
### 5.1 `GET /api/state` — snapshot (sync-on-load / polling fallback)
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": 42,
|
||||
"controls": { "visual": "on", "audio": "soundtrack", "left": 3, "right": 1, "mood": -2 },
|
||||
"altitude": { "index": 2, "scale": "coast", "clip_id": "coast_07" },
|
||||
"transitions": { "altitude": "idle", "visual": "idle" },
|
||||
"render": {
|
||||
"plan": { },
|
||||
"video": { "shown": true },
|
||||
"audio": { "source": "soundtrack", "url": "/media/audio/coast/waves.loop.mp3", "altitude_coupled": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(The `render.video` + `render.audio` split and server-side `audio.url` resolution
|
||||
are specified in the audio spec §6.1.)
|
||||
|
||||
A control source GETs this on load to draw its knobs in the right positions; the
|
||||
renderer can use it to cold-start or resync after a reconnect.
|
||||
|
||||
### 5.2 `POST /api/control` — the one control-source contract
|
||||
|
||||
A partial patch — only the fields that changed:
|
||||
|
||||
```json
|
||||
{ "set": { "left": 4 }, "altitude_delta": 0 } // live knob
|
||||
{ "set": { "audio": "white_noise" }, "altitude_delta": 0 } // live (audio source)
|
||||
{ "set": { "visual": "off" }, "altitude_delta": 0 } // transitional (video fade)
|
||||
{ "set": {}, "altitude_delta": -1 } // one encoder tick down
|
||||
```
|
||||
|
||||
Server applies it, bumps `seq`, recomputes, and:
|
||||
|
||||
- **live field** (Left/Right/Mood, **or an `audio` source change**) → broadcasts
|
||||
a `state` event immediately (the renderer crossfades audio on its own, §7);
|
||||
- **transitional field** (a `visual` change, or a non-zero `altitude_delta`) →
|
||||
computes the ring move / video fade, marks that field `transitioning`,
|
||||
broadcasts a `ring` (or `mode`) event for the renderer to animate.
|
||||
|
||||
Returns the new snapshot (an immediate authoritative echo for the poster).
|
||||
Absolute `set` values make retries idempotent; `altitude_delta` is relative
|
||||
(encoder ticks) and the server coalesces rapid ticks via the existing
|
||||
`advance_ring` fast-spin logic.
|
||||
|
||||
### 5.3 `GET /api/events` — SSE stream (renderer subscribes)
|
||||
|
||||
Event types:
|
||||
|
||||
- `state` — full snapshot; sent on connect and on every live change (including an
|
||||
**audio** source change, which the renderer crossfades — §7).
|
||||
- `ring` — an altitude move: the transition clip(s) to play + the landed
|
||||
scale/clip, **plus the new `audio.url`** so the renderer can crossfade the
|
||||
soundtrack in step (when `audio=soundtrack`). Renderer plays them, then settles.
|
||||
- `mode` — a **Visual** on/off change: the video fade the renderer should run.
|
||||
- `ping` — keepalive.
|
||||
|
||||
The SSE event payloads reuse the existing serializers
|
||||
(`ring_move_to_dict`, `render_plan_to_dict`, etc.).
|
||||
|
||||
### 5.4 `POST /api/render/event` — the renderer's one upstream ack
|
||||
|
||||
```json
|
||||
{ "type": "settled", "seq": 42, "field": "altitude" }
|
||||
```
|
||||
|
||||
Sent when the renderer finishes a transition. Server flips that field `idle` and
|
||||
broadcasts a `state` so every remote clears its spinner. A server-side timeout
|
||||
(max transition duration + margin) clears it anyway if the renderer never acks.
|
||||
|
||||
### 5.5 End-to-end flow for one altitude tick
|
||||
|
||||
```
|
||||
iPad: turn dial → field=pending locally → POST /api/control {altitude_delta:-1}
|
||||
server: apply, seq++, transitions.altitude="transitioning"
|
||||
→ SSE "ring" to renderer → SSE "state" to all remotes (altitude busy)
|
||||
renderer: play transition clip(s), settle on forest_07
|
||||
→ POST /api/render/event {type:"settled", field:"altitude"}
|
||||
server: transitions.altitude="idle" → SSE "state" to all → iPad clears spinner
|
||||
```
|
||||
|
||||
### 5.6 Decisions flagged (not assumed silently)
|
||||
|
||||
- **Concurrency = last-write-wins.** Multiple control sources are allowed;
|
||||
per-field last write wins, `seq` orders everything. No locking in v1.
|
||||
- **No auth.** v1 trusts the LAN — any device that can reach the laptop can drive
|
||||
it. A room-token is deferred until wanted.
|
||||
|
||||
## 6. The two pages
|
||||
|
||||
### 6.1 Renderer — `/` (display), refactored from today's `index.html`
|
||||
|
||||
- **Removes** the input controls (the Visual toggle, the Audio `<select>`, the
|
||||
Altitude SVG, the three sliders) and their handlers.
|
||||
- **Keeps** everything that draws the experience: the video element, the **A/B
|
||||
`<audio>` crossfade layer**, the WebGL Kuwahara dream shader, the
|
||||
HUD/annotation + affect overlay, the ring-transition playback, the media
|
||||
preload pool, the crossfade-loop logic.
|
||||
- **Adds** a thin `state` client: open `EventSource('/api/events')`; on `state`
|
||||
apply controls→render live (incl. the audio crossfade); on `ring` play the
|
||||
transition clip(s) — crossfading the soundtrack to the event's `audio.url` —
|
||||
then settle and `POST /api/render/event {settled}`; on `mode` run the video
|
||||
fade. On (re)connect,
|
||||
pull `GET /api/state` to resync. SSE auto-reconnects, so a flaky link
|
||||
self-heals.
|
||||
- Runs full-screen on the laptop driving the projector; no keyboard/mouse needed
|
||||
once launched.
|
||||
|
||||
### 6.2 Controller — `/remote`, a new touch-first page (the faithful twin)
|
||||
|
||||
- The six controls laid out like the intended physical panel: **Visual** toggle +
|
||||
**Audio** selector, **Altitude** encoder dial (reuse the existing SVG dial
|
||||
component), **Left** / **Right** / **Mood** knobs.
|
||||
- On any input → `POST /api/control` with just the changed field. Live knobs post
|
||||
on drag; Altitude posts encoder ticks; Visual/Audio post on change.
|
||||
- Per-knob transition state (§4): a transitional control goes **pending** on
|
||||
touch, reflects **transitioning** from the SSE feed, clears on **settled**.
|
||||
Altitude shows a settling indicator and its detents soft-lock (further ticks
|
||||
queue as the next target rather than stacking); **Visual** shows the toggle as
|
||||
pending until the video fade completes; **Audio** is live (no pending state).
|
||||
- It **also** subscribes to `/api/events` so it stays in sync with other
|
||||
sources (turn the future Arduino's knob and the iPad reflects it, and
|
||||
vice-versa). On load it GETs `/api/state` to draw current positions.
|
||||
- Touch-sized hit targets, no hover dependence, works in mobile Safari with no
|
||||
install.
|
||||
|
||||
## 7. The future-Arduino seam (contract only in v1)
|
||||
|
||||
No firmware in this feature, but the design **proves the seam** so sub-project 4
|
||||
is a true drop-in:
|
||||
|
||||
- **ESP32 (wifi Arduino)** → posts straight to `POST /api/control` over the LAN.
|
||||
Zero server change. Recommended hardware target when the time comes.
|
||||
- **Classic USB Arduino** → emits serial frames to a tiny `bridge.py` on the
|
||||
laptop that translates each frame into a `POST /api/control`. The old roadmap
|
||||
"3⇄4 serial framing contract" shrinks to *just* the Arduino↔bridge link; the
|
||||
renderer/server never know the difference.
|
||||
- Either way the Arduino sends **absolute pot values** (`set`) + **encoder
|
||||
ticks** (`altitude_delta`) — exactly the iPad's message shape.
|
||||
|
||||
This is what makes "faithful twin" real: the iPad and the Arduino are the same
|
||||
control source speaking the same contract.
|
||||
|
||||
## 8. Error handling / resilience
|
||||
|
||||
- **Renderer drops:** SSE auto-reconnects; on reconnect it GETs `/api/state` and
|
||||
resyncs. The per-field transition timeout clears any knob left `transitioning`
|
||||
by a renderer that vanished mid-animation.
|
||||
- **Controller drops / flaky wifi:** POSTs are fire-and-retry; absolute `set`
|
||||
values are idempotent, so a retried knob value is harmless. `altitude_delta` is
|
||||
the one relative input — on a failed POST the remote keeps the tick in its
|
||||
local pending target and resends rather than double-counting.
|
||||
- **No renderer connected:** the server still accepts control changes and holds
|
||||
state; a renderer that connects later catches up from the snapshot.
|
||||
- **Stale control source:** every event carries `seq`; a source ignores events
|
||||
older than what it has applied.
|
||||
|
||||
## 9. Testing (satisfies the mandatory pipeline tiers)
|
||||
|
||||
- **Pure unit** — a `SessionState` store in Python: apply patch → live vs.
|
||||
transitional classification; `altitude_delta` → ring move via the existing
|
||||
`advance_ring`; `seq` bump; transition flag set/clear; timeout. No I/O, tested
|
||||
like the rest of `player/`.
|
||||
- **API/contract** — `GET /api/state`; `POST /api/control` (live + transitional +
|
||||
bad input → 422); `POST /api/render/event` clears the flag; the SSE
|
||||
event-builder tested as a pure function.
|
||||
- **E2E (Playwright, required for the UI surface)** — open the renderer and
|
||||
`/remote` as two pages: move a live knob → assert the renderer's render
|
||||
updates; tick Altitude → assert a transition plays, the remote shows
|
||||
`transitioning`, then clears on settle.
|
||||
|
||||
## 10. Scope
|
||||
|
||||
**In v1:**
|
||||
|
||||
- Server `SessionState` + the four endpoints (`/api/state`, `/api/control`,
|
||||
`/api/events`, `/api/render/event`).
|
||||
- Renderer refactor to display-only + SSE client + settled ack.
|
||||
- The `/remote` iPad twin: the five controls + per-knob transition state.
|
||||
- Wifi/LAN only; laptop renders.
|
||||
|
||||
**Explicitly NOT in v1 (deferred):**
|
||||
|
||||
- Any Arduino/firmware (contract defined here; hardware later).
|
||||
- Bluetooth / USB transport.
|
||||
- Auth / room-token.
|
||||
- The Pi renderer.
|
||||
- Multi-room / multi-renderer fan-out (the SSE design allows it; not a v1 goal).
|
||||
|
||||
## 11. Roadmap impact
|
||||
|
||||
- **Sub-project 4** is reframed: the panel is now "any control source over the
|
||||
`/api/control` contract," not USB-serial-to-Pi first. The iPad twin is the v1
|
||||
deliverable; the Arduino is a later drop-in.
|
||||
- **Sub-project 3's "serial input" slice** is absorbed into a transport detail
|
||||
(the Arduino↔bridge link), no longer a Pi-coupled concern.
|
||||
- `ROADMAP.md` to be updated at implementation time.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Scrub-driven Altitude Transitions — Design
|
||||
|
||||
**Status:** Approved (brainstormed 2026-06-27, session 0024). Ready for writing-plans.
|
||||
|
||||
## Goal
|
||||
|
||||
Make the altitude transition **driven by the knob position** rather than auto-played.
|
||||
Turning the physical knob (or, in the simulator, dragging the Altitude dial) at any
|
||||
speed scrubs the morph video and crossfades the audio in lockstep with the knob. The
|
||||
knob position *is* the scene: turn slow → the morph eases slowly; turn fast → it
|
||||
races; stop halfway → it holds in a mid-morph blend; turn back → it reverses exactly.
|
||||
|
||||
This supersedes the timed dial-needle sweep (the dial-vs-video sync problem dissolves
|
||||
because the knob and the transition become the same thing).
|
||||
|
||||
## Core model
|
||||
|
||||
A continuous **altitude position** `pos` (float): integers are altitudes/detents,
|
||||
fractions are mid-morph blend. `pos = 2.4` ⇒ 40 % from coast (index 2) toward reef
|
||||
(index 3). The knob/needle maps directly to `pos`; the morph video's `currentTime`
|
||||
and the audio crossfade are scrubbed by `frac(pos)`.
|
||||
|
||||
**Decisions (locked):**
|
||||
- **In-between state:** hold the blend wherever the knob stops — no auto-complete
|
||||
(continuous encoder model). *(Q1 → a)*
|
||||
- **Turn-back:** scrubs the same morph in reverse; lands on exactly the previous
|
||||
altitude's clip you started from (fully reversible).
|
||||
- **Destination randomness:** the next altitude's clip is a random pick from its pool,
|
||||
**fixed during a single continuous gesture**, **re-rolled on each fresh approach**.
|
||||
*(Q → a)*
|
||||
- **Scroll wheel:** auto-scrub one altitude over ~0.6 s, then lock. *(Q2 → a)*
|
||||
- **Lock-per-altitude** still holds: a resting altitude's clip stays locked until you
|
||||
commit into a different one.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Scrub controller (frontend — `simulator/static/app.js`)
|
||||
Replaces the discrete `advance(delta)` "commit then play" for drag/knob input.
|
||||
- Drives `pos` from the dial pointer angle: `pos = restPos + accum / dialStep`. The
|
||||
needle follows continuously (the needle *is* the knob — live follow is correct here).
|
||||
- For the active segment `[floor, floor+1]` in the travel direction, sets
|
||||
`vid.currentTime = frac(pos) × vid.duration`, **throttled to one seek per
|
||||
`requestAnimationFrame`** (avoid seek thrash).
|
||||
- Crossing an integer **commits**: the crossed altitude's chosen clip becomes the
|
||||
locked `activeClipId`, `ringIndex` updates, and the next segment begins (re-roll).
|
||||
- Reversible: `pos` decreasing scrubs the morph backward.
|
||||
- Keep the pure, testable bits separable (position→segment, `frac`→currentTime,
|
||||
integer-crossing commit, re-roll-on-fresh-approach) from the DOM/event glue.
|
||||
|
||||
### 2. Clip selection / lock
|
||||
Entering a segment toward neighbor `k±1`, pick a random clip from that scale's pool
|
||||
(client-side) and resolve the directed morph via the existing
|
||||
`morphByPair["<fromClip>→<toClip>"]` lookup (built from `ring.transitions`). Fixed for
|
||||
the gesture; re-rolled on a fresh approach. *(This moves the random pick client-side
|
||||
for responsiveness vs the current server pick in `/api/ring/advance`; acceptable for
|
||||
the simulator — the Pi player can mirror the same client-side pick. `pick_clip_id` in
|
||||
`player/ring.py` stays the canonical pure helper.)*
|
||||
|
||||
### 3. Audio crossfade (frontend)
|
||||
During a segment, crossfade the two adjacent scale soundtracks by `frac(pos)` (gains
|
||||
`1-frac` / `frac`); at rest only the current scale plays. Hook into the existing audio
|
||||
layer (`applyAudio` / the per-scale `audio` field in the ring).
|
||||
|
||||
### 4. Morph media re-bake (pipeline — `simulator/build_pool_manifest.py`)
|
||||
Smooth scrubbing = seeking to arbitrary frames, which needs **dense keyframes**. The
|
||||
current morphs use a sparse GOP, so they scrub "steppy." Re-bake the 154 morphs
|
||||
**all-intra** (e.g. x264 `-g 1` / `keyint=1`, or `-intra`) so every frame is seekable.
|
||||
Files grow (~176 MB → ~0.5–0.9 GB) but each clip stays small (front-end upload limit is
|
||||
a non-issue; that was the 68 MB *base*, unrelated). Media is committed via **git-LFS**
|
||||
(see `.gitattributes`); re-push after re-baking.
|
||||
|
||||
**Phasing:** build the interaction first against the *current* morphs to validate the
|
||||
feel (will scrub somewhat steppy), then re-bake all-intra for smoothness. Two plan
|
||||
phases.
|
||||
|
||||
### 5. Wheel + tap
|
||||
- **Wheel:** auto-scrub `pos` by ±1 over ~0.6 s (drives the same scrub path), then lock.
|
||||
- **Tap a dial label:** auto-scrub to that altitude the shortest way around.
|
||||
|
||||
## Testing
|
||||
- **Unit (pytest or JS):** the pure controller logic — position→segment mapping,
|
||||
`frac`→`currentTime`, commit+lock on integer crossing, re-roll on fresh approach,
|
||||
reverse direction.
|
||||
- **E2E (Playwright, `simulator/e2e`):** drag the dial partway → assert the morph
|
||||
`currentTime` and the two audio gains track the angle; drag back → values reverse;
|
||||
a full turn → commits and locks the destination clip; the wheel → auto-scrubs one
|
||||
altitude and locks. (Extends the existing `altitude-lock.spec.ts` tier.)
|
||||
|
||||
## Out of scope
|
||||
- Physical hardware / encoder firmware (the simulator drag is the stand-in; the model
|
||||
is designed to map 1:1 to an encoder position later — see the networked control-surface
|
||||
spec).
|
||||
- Changing the clip-pair morph *set* or naming (`<src>__<dst>.mp4` + `.rev`) — reused as-is.
|
||||
|
||||
## Key existing context (for the implementer)
|
||||
- Dial + transition logic: `simulator/static/app.js` (`advance`, `playTransition`,
|
||||
`onDialDown/Move/Up`, `renderDial/setNeedle`, `morphByPair`, the preload helpers).
|
||||
- Ring/morph contract: `player/ring.py` (`Scale.pool`, `pick_clip_id`, `morph_for`,
|
||||
`resolve_move`), `simulator/clips.py` (`ring_to_dict`, `resolved_move_to_dict`),
|
||||
`/api/ring/advance` in `simulator/app.py`.
|
||||
- Morph manifest + baking: `simulator/build_pool_manifest.py` (`_make_transition`,
|
||||
`_make_reverse`, `generate_media`, `build_manifest`); morphs at
|
||||
`simulator/sample_media/transitions/<src>__<dst>.mp4` (+ `.rev`).
|
||||
- The clip-pair morph + lock feature this builds on shipped in session 0024 (plan:
|
||||
`docs/superpowers/plans/2026-06-27-altitude-clip-pair-morph-lock.md`).
|
||||
+70
-30
@@ -1,18 +1,19 @@
|
||||
"""The alteration engine: a knob vector -> a layered RenderPlan (design §4, §5).
|
||||
|
||||
Reconciled slice (2026-06-07): the Right axis is a DISCRETE selection of a
|
||||
pre-baked, flow-stabilized restyle variant (not a continuous blend), the Left
|
||||
axis carries its knob LEVEL so a runtime annotation track can pick which labels
|
||||
show, and a frozen `Calibration` parameterizes the knob->strength curves so they
|
||||
can be tuned by eye in the simulator and baked into DEFAULT_CALIBRATION.
|
||||
Right axis (reframed session 0013): a DETERMINISTIC, real-time dream applied live
|
||||
to the base footage — a STILL altered state, not the old pre-baked SD variant that
|
||||
churned across the loop (Right-axis dream design,
|
||||
docs/superpowers/specs/2026-06-22-right-axis-deterministic-dream-design.md). The
|
||||
Left axis carries its knob LEVEL so a runtime annotation track can pick which
|
||||
labels show, and a frozen `Calibration` parameterizes the knob->strength gains so
|
||||
they can be tuned by eye in the simulator and baked into DEFAULT_CALIBRATION.
|
||||
|
||||
Layers compose per §4.2:
|
||||
- Substrate: ColorGrade (Dark/Light mood, center = identity §5) + a pre-baked
|
||||
Right restyle variant.
|
||||
- Overlay: AnalyticalOverlay (Left), composited on top at runtime.
|
||||
- Substrate: ColorGrade (Dark/Light mood, center = identity §5) + the Right Dream
|
||||
(deterministic luminous haze, applied client-side from `dream.intensity`).
|
||||
- Overlay: AnalyticalOverlay (Left) + the affect channel, composited on top.
|
||||
Left and Right stack (different layers); Dark/Light are the two poles of one
|
||||
mood grade. See docs/superpowers/specs/2026-06-07-reconciled-simulator-
|
||||
alteration-slice-design.md.
|
||||
mood grade.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,13 +34,13 @@ class Calibration:
|
||||
"""Tunable knob->strength curves (settled by eye in the sim, then baked).
|
||||
|
||||
- mood_gain: scales the signed Dark/Light tone (result clamped to [-1, 1]).
|
||||
- overlay_gain: scales the Left overlay intensity (clamped to [0, 1]).
|
||||
- right_variant_map: knob value (0..4) -> pre-baked Right variant index.
|
||||
- overlay_gain: scales the Left overlay (and affect) intensity (clamped [0, 1]).
|
||||
- dream_gain: scales the Right deterministic-dream intensity (clamped [0, 1]).
|
||||
"""
|
||||
|
||||
mood_gain: float = 1.0
|
||||
overlay_gain: float = 1.0
|
||||
right_variant_map: tuple = (0, 1, 2, 3, 4)
|
||||
dream_gain: float = 1.0
|
||||
|
||||
|
||||
# The LOCKED calibration (session 0010, 2026-06-07) — settled by eye in the
|
||||
@@ -51,15 +52,15 @@ class Calibration:
|
||||
# full tilt is peaceful on every axis, so no softening is warranted.
|
||||
# - overlay_gain = 1.0: full Left = opacity 1.0; the HUD is legible, not
|
||||
# overwhelming, sitting above the grade.
|
||||
# - right_variant_map linear: the 5 knob notches map 1:1 onto the 5 discrete
|
||||
# pre-baked Right strengths (0 = raw base).
|
||||
# These are unity/linear by deliberate choice, not as placeholders. The curve
|
||||
# stays parameterized so a future re-bake or a different feel is one edit away;
|
||||
# test_default_calibration_is_locked guards the values from drifting silently.
|
||||
# - dream_gain = 1.0: full Right = dream intensity 1.0; the deterministic
|
||||
# luminous haze (Right-axis dream design, session 0013) reaches full strength.
|
||||
# These are unity by deliberate choice, not as placeholders. The gains stay
|
||||
# parameterized so a different feel is one edit away; test_default_calibration_is_locked
|
||||
# guards the values from drifting silently.
|
||||
DEFAULT_CALIBRATION = Calibration(
|
||||
mood_gain=1.0,
|
||||
overlay_gain=1.0,
|
||||
right_variant_map=(0, 1, 2, 3, 4),
|
||||
dream_gain=1.0,
|
||||
)
|
||||
|
||||
|
||||
@@ -86,11 +87,28 @@ class AnalyticalOverlay:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Restyle:
|
||||
"""Right axis (§4.1): selects a pre-baked, flow-stabilized restyle variant.
|
||||
`variant` is a discrete index (0 = raw base, no restyle)."""
|
||||
class AffectOverlay:
|
||||
"""Affect channel (RIGHT brain): emotion-words surfaced by the Right (dreamlike)
|
||||
knob alone — the analytical Left knob does NOT gate them. `strength` is the Right
|
||||
knob (0..4) — a runtime affect track picks which feeling words appear by it;
|
||||
`intensity` 0..1 is their opacity. Feeling is the right brain's output, surfaced
|
||||
as the machine's felt reading (affect-channel design, session 0013; Right-only
|
||||
since session 0018)."""
|
||||
|
||||
variant: int
|
||||
strength: int
|
||||
intensity: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Dream:
|
||||
"""Right axis: a deterministic, real-time dream applied live to the base
|
||||
footage — a STILL altered state (Right-axis dream design, session 0013),
|
||||
NOT the old pre-baked SD variant that churned across the loop. `strength` is
|
||||
the Right knob (0..4); `intensity` 0..1 is the dream's strength (the client
|
||||
maps it to the luminous-haze look, the way it maps `grade.tone` to a filter)."""
|
||||
|
||||
strength: int
|
||||
intensity: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -99,15 +117,19 @@ class RenderPlan:
|
||||
|
||||
grade: ColorGrade
|
||||
overlay: AnalyticalOverlay
|
||||
restyle: Restyle
|
||||
affect: AffectOverlay
|
||||
dream: Dream
|
||||
|
||||
@property
|
||||
def is_identity(self) -> bool:
|
||||
"""True when the plan leaves the neutral base un-altered."""
|
||||
"""True when the plan leaves the neutral base un-altered. Affect needs no
|
||||
clause: `affect.strength == right`, and `right > 0` already makes
|
||||
`dream.strength > 0`, so an active-affect plan is non-identity via the
|
||||
dream check below."""
|
||||
return (
|
||||
self.grade.is_identity
|
||||
and self.overlay.level == 0
|
||||
and self.restyle.variant == 0
|
||||
and self.dream.strength == 0
|
||||
)
|
||||
|
||||
|
||||
@@ -115,8 +137,18 @@ def _overlay_intensity(left: int, cal: Calibration) -> float:
|
||||
return _clamp(cal.overlay_gain * left / KNOB_MAX, 0.0, 1.0)
|
||||
|
||||
|
||||
def _right_variant(right: int, cal: Calibration) -> int:
|
||||
return cal.right_variant_map[right]
|
||||
def _affect_strength(right: int) -> int:
|
||||
"""Affect is a RIGHT-brain output: the emotion channel is driven by the Right
|
||||
(dreamlike) knob alone — the analytical Left knob no longer gates it."""
|
||||
return right
|
||||
|
||||
|
||||
def _affect_intensity(right: int, cal: Calibration) -> float:
|
||||
return _clamp(cal.overlay_gain * _affect_strength(right) / KNOB_MAX, 0.0, 1.0)
|
||||
|
||||
|
||||
def _dream_intensity(right: int, cal: Calibration) -> float:
|
||||
return _clamp(cal.dream_gain * right / KNOB_MAX, 0.0, 1.0)
|
||||
|
||||
|
||||
def _mood_tone(dark: int, light: int, cal: Calibration) -> float:
|
||||
@@ -133,7 +165,14 @@ def plan_alteration(
|
||||
level=coord.left,
|
||||
intensity=_overlay_intensity(coord.left, calibration),
|
||||
),
|
||||
restyle=Restyle(variant=_right_variant(coord.right, calibration)),
|
||||
affect=AffectOverlay(
|
||||
strength=_affect_strength(coord.right),
|
||||
intensity=_affect_intensity(coord.right, calibration),
|
||||
),
|
||||
dream=Dream(
|
||||
strength=coord.right,
|
||||
intensity=_dream_intensity(coord.right, calibration),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -142,6 +181,7 @@ def render_plan_to_dict(plan: RenderPlan) -> dict:
|
||||
return {
|
||||
"grade": {"tone": plan.grade.tone},
|
||||
"overlay": {"level": plan.overlay.level, "intensity": plan.overlay.intensity},
|
||||
"restyle": {"variant": plan.restyle.variant},
|
||||
"affect": {"strength": plan.affect.strength, "intensity": plan.affect.intensity},
|
||||
"dream": {"strength": plan.dream.strength, "intensity": plan.dream.intensity},
|
||||
"is_identity": plan.is_identity,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Resolve the orthogonal Visual × Audio controls into render outputs (audio spec
|
||||
§4/§6). The single source of truth for: whether the projector shows video, and
|
||||
which audio url plays for a given (audio source, current altitude).
|
||||
|
||||
Replaces player/content.py's 7-way bundled table. Soundtrack follows the single
|
||||
Altitude dial; off is silence. `white_noise` and `music` are deferred sources —
|
||||
not in v1 (the live control is a simple Audio on/off toggle where on=soundtrack)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
VISUAL_POSITIONS = frozenset({"on", "off"})
|
||||
# v1 audio sources. The live UI is an Audio on/off toggle (on=soundtrack).
|
||||
# "white_noise" and "music" are deferred (no live control) and intentionally absent.
|
||||
AUDIO_SOURCES = frozenset({"off", "soundtrack"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioResolution:
|
||||
source: str
|
||||
url: str | None
|
||||
altitude_coupled: bool
|
||||
|
||||
|
||||
def resolve_visual(position: str) -> bool:
|
||||
"""Visual on/off → whether the renderer shows video (vs fade-to-black)."""
|
||||
if position not in VISUAL_POSITIONS:
|
||||
raise ValueError(
|
||||
f"unknown visual position {position!r}; expected one of {sorted(VISUAL_POSITIONS)}"
|
||||
)
|
||||
return position == "on"
|
||||
|
||||
|
||||
def resolve_audio_url(source: str, *, scale_audio: str) -> str | None:
|
||||
"""The audio url for (source, current altitude): soundtrack → the current
|
||||
scale's asset (or None if none authored); off → None. `scale_audio` is the ring
|
||||
scale's `audio` path (relative to /media/audio/)."""
|
||||
if source not in AUDIO_SOURCES:
|
||||
raise ValueError(
|
||||
f"unknown audio source {source!r}; expected one of {sorted(AUDIO_SOURCES)}"
|
||||
)
|
||||
if source == "off":
|
||||
return None
|
||||
# soundtrack — couple to the current altitude's asset
|
||||
return f"/media/audio/{scale_audio}" if scale_audio else None
|
||||
|
||||
|
||||
def resolve_audio(source: str, *, scale_audio: str) -> AudioResolution:
|
||||
"""The full render.audio view: source, resolved url, and whether it follows the
|
||||
Altitude dial (true only for soundtrack)."""
|
||||
url = resolve_audio_url(source, scale_audio=scale_audio)
|
||||
return AudioResolution(source=source, url=url, altitude_coupled=(source == "soundtrack"))
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Resolve the 7-way content dial into an audio source + video on/off (§6).
|
||||
|
||||
The single source of truth for design §6's table: which audio source plays and
|
||||
whether the projector shows video, for each dial position.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# The four distinct audio sources. "white_noise" is generated at runtime;
|
||||
# "music" is the public-domain classical pool; "audio_track" is the clip's own
|
||||
# field audio; "none" is silence.
|
||||
AUDIO_SOURCES = frozenset({"none", "white_noise", "music", "audio_track"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContentResolution:
|
||||
audio_source: str
|
||||
video: bool
|
||||
|
||||
|
||||
# Design §6, one row per dial position.
|
||||
_TABLE = {
|
||||
"off": ContentResolution("none", False),
|
||||
"white_noise": ContentResolution("white_noise", False),
|
||||
"music": ContentResolution("music", False),
|
||||
"audio_track": ContentResolution("audio_track", False),
|
||||
"video": ContentResolution("none", True),
|
||||
"music_video": ContentResolution("music", True),
|
||||
"audio_video": ContentResolution("audio_track", True),
|
||||
}
|
||||
|
||||
|
||||
def resolve_content(position: str) -> ContentResolution:
|
||||
"""Map a content-dial position to its audio source and video flag (§6)."""
|
||||
try:
|
||||
return _TABLE[position]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"unknown content position {position!r}; expected one of {sorted(_TABLE)}"
|
||||
) from None
|
||||
+15
-12
@@ -1,20 +1,17 @@
|
||||
"""Control-panel state: the data shape read from the Arduino over serial.
|
||||
|
||||
This is the serial-contract payload shared with sub-project 4 (firmware). It
|
||||
models the full panel from design §6/§7: the 7-way content dial, the four
|
||||
experience knobs (0..4), and the two intensity levels (volume, brightness).
|
||||
The wire framing itself is the separate 3<->4 serial contract; this module is
|
||||
the *decoded* form.
|
||||
models the full panel from the audio spec §3: a Visual toggle (on/off) + an
|
||||
Audio source selector (off/soundtrack; white-noise/music deferred), the four experience knobs
|
||||
(0..4), and the two intensity levels (volume, brightness). The wire framing
|
||||
itself is the separate 3<->4 serial contract; this module is the *decoded* form.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, fields
|
||||
|
||||
# The seven positions of the content dial (design §6).
|
||||
CONTENT_POSITIONS = frozenset(
|
||||
{"off", "white_noise", "music", "audio_track", "video", "music_video", "audio_video"}
|
||||
)
|
||||
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS
|
||||
|
||||
KNOB_FIELDS = ("left", "right", "dark", "light", "volume", "brightness")
|
||||
KNOB_MIN = 0
|
||||
@@ -27,7 +24,8 @@ class ControlsError(ValueError):
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Controls:
|
||||
content: str
|
||||
visual: str
|
||||
audio: str
|
||||
left: int
|
||||
right: int
|
||||
dark: int
|
||||
@@ -38,10 +36,15 @@ class Controls:
|
||||
|
||||
def validate_controls(c: Controls) -> None:
|
||||
"""Raise ControlsError if the payload is structurally invalid."""
|
||||
if c.content not in CONTENT_POSITIONS:
|
||||
if c.visual not in VISUAL_POSITIONS:
|
||||
raise ControlsError(
|
||||
f"invalid content position {c.content!r}; "
|
||||
f"expected one of {sorted(CONTENT_POSITIONS)}"
|
||||
f"invalid visual position {c.visual!r}; "
|
||||
f"expected one of {sorted(VISUAL_POSITIONS)}"
|
||||
)
|
||||
if c.audio not in AUDIO_SOURCES:
|
||||
raise ControlsError(
|
||||
f"invalid audio source {c.audio!r}; "
|
||||
f"expected one of {sorted(AUDIO_SOURCES)}"
|
||||
)
|
||||
for name in KNOB_FIELDS:
|
||||
value = getattr(c, name)
|
||||
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
"""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 clip(s) it can play.
|
||||
|
||||
A scale carries a rotating POOL of vetted clips (content-pipeline design
|
||||
§11.1): when the viewer lands on the scale, the player picks one member at
|
||||
random (`pick_clip_id`). `clip_id` is the PRIMARY member (the deterministic
|
||||
default / pool[0]); `pool` is the full set. A scale written with only
|
||||
`clip_id` and no `pool` is a pool of one — exactly the Increment-1 behavior,
|
||||
so old manifests are unchanged.
|
||||
"""
|
||||
|
||||
id: str
|
||||
clip_id: str
|
||||
pool: tuple[str, ...] = ()
|
||||
audio: str = "" # per-scale soundtrack, relative to /media/audio/ (audio spec §5.1)
|
||||
|
||||
@property
|
||||
def members(self) -> tuple[str, ...]:
|
||||
"""The clip ids this scale can play — the pool, or just `clip_id` when
|
||||
no pool was authored (a pool of one)."""
|
||||
return self.pool if self.pool else (self.clip_id,)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transition:
|
||||
"""A pre-baked zoom/warp morph between TWO specific clips in adjacent
|
||||
altitudes.
|
||||
|
||||
`file` plays as-is: the manifest holds both directions as separate, directed
|
||||
entries — a forward zoom-in (`<src>__<dst>.mp4`, descending) and its `.rev`
|
||||
zoom-out companion (ascending). So a directed `(from_clip, to_clip)` pair maps
|
||||
to exactly one file and the renderer needs no reverse logic at play time.
|
||||
"""
|
||||
|
||||
from_clip: str
|
||||
to_clip: str
|
||||
file: str
|
||||
model: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScaleRing:
|
||||
"""A closed ring of scales joined by per-clip-pair morphs.
|
||||
|
||||
Each `Transition` is a directed morph between two clips in adjacent altitudes
|
||||
(both directions stored explicitly). A single-scale (or empty-pool) ring has
|
||||
no morphs. An empty ring is rejected. The directed `(from_clip, to_clip) ->
|
||||
file` lookup is built once for `morph_for`.
|
||||
"""
|
||||
|
||||
scales: tuple[Scale, ...]
|
||||
transitions: tuple[Transition, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if len(self.scales) == 0:
|
||||
raise RingError("a ScaleRing needs at least one scale")
|
||||
lookup = {(t.from_clip, t.to_clip): t.file for t in self.transitions}
|
||||
object.__setattr__(self, "_morphs", lookup)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.scales)
|
||||
|
||||
def morph_for(self, from_clip: str, to_clip: str) -> str | None:
|
||||
"""The morph file for the directed clip pair, or None if none was baked."""
|
||||
return self._morphs.get((from_clip, to_clip))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransitionStep:
|
||||
"""One STRUCTURAL step of a move: which edge is crossed, in which direction,
|
||||
and the scale index it lands on. The actual morph FILE is resolved later by
|
||||
`resolve_move` (it depends on the clip picked for the destination), so this
|
||||
step carries no file.
|
||||
|
||||
`blended` marks a fast-spin step (see `advance_ring`'s `fast_spin_threshold`):
|
||||
the renderer plays it as one quick accelerated morph rather than at 1x.
|
||||
"""
|
||||
|
||||
edge: int
|
||||
reversed: bool
|
||||
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 pick_clip_id(scale: Scale, r: float) -> str:
|
||||
"""Pick one clip id from a scale's rotating pool, given an injected uniform
|
||||
`r` in [0, 1) (content-pipeline design §11.1).
|
||||
|
||||
Randomness is INJECTED so this stays a pure function — testable and shared
|
||||
with the Pi player; the impure draw (`random.random()`) happens once at the
|
||||
API/runtime boundary. `r` is clamped into [0, 1) so an off-by-epsilon caller
|
||||
never indexes out of range; a pool of one always returns its single member.
|
||||
"""
|
||||
members = scale.members
|
||||
r = min(max(r, 0.0), 0.999999)
|
||||
return members[int(r * len(members))]
|
||||
|
||||
|
||||
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_, to_index=nxt)
|
||||
)
|
||||
index = nxt
|
||||
|
||||
# Fast spin: play the WHOLE chain quickly (every crossed altitude still
|
||||
# morphs, so the chained member->member morphs all show) — each step is just
|
||||
# marked `blended` so the renderer accelerates it, rather than collapsing the
|
||||
# chain to a single arrival pass.
|
||||
if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold:
|
||||
fast_steps = tuple(
|
||||
TransitionStep(edge=s.edge, reversed=s.reversed,
|
||||
to_index=s.to_index, blended=True)
|
||||
for s in steps
|
||||
)
|
||||
return RingMove(
|
||||
from_index=start,
|
||||
to_index=index,
|
||||
steps=fast_steps,
|
||||
wrapped=wrapped,
|
||||
fast=True,
|
||||
)
|
||||
|
||||
return RingMove(
|
||||
from_index=start, to_index=index, steps=tuple(steps), wrapped=wrapped
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MorphStep:
|
||||
"""One resolved morph to play while advancing: from the clip currently shown
|
||||
to the chosen clip of the next altitude, the morph file, the index it lands
|
||||
on, and whether it plays fast (a fast-spin pass). `file` is None if no morph
|
||||
was baked for the pair (the renderer falls back to a plain cut)."""
|
||||
|
||||
from_clip: str
|
||||
to_clip: str
|
||||
file: str | None
|
||||
to_index: int
|
||||
blended: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedMove:
|
||||
"""A move with each step's destination clip chosen and its morph resolved.
|
||||
`target_clip_id` is the final clip the viewer locks onto."""
|
||||
|
||||
steps: tuple[MorphStep, ...]
|
||||
target_clip_id: str
|
||||
|
||||
|
||||
def resolve_move(
|
||||
ring: ScaleRing,
|
||||
move: RingMove,
|
||||
from_clip_id: str,
|
||||
picks: tuple[float, ...],
|
||||
) -> ResolvedMove:
|
||||
"""Choose the destination clip for each crossed altitude (injected `picks`,
|
||||
one per step) and resolve the morph from the previous clip to it. The
|
||||
destination clip is chosen BEFORE the morph, so the footage matches what we
|
||||
land on; the morph direction (zoom-in vs zoom-out) is encoded in the directed
|
||||
`(src, dst)` pair, which `morph_for` maps to the right baked file. The final
|
||||
`target_clip_id` is the clip the viewer locks onto."""
|
||||
if len(picks) != len(move.steps):
|
||||
raise ValueError(f"need one pick per step: {len(picks)} != {len(move.steps)}")
|
||||
steps: list[MorphStep] = []
|
||||
src = from_clip_id
|
||||
for st, r in zip(move.steps, picks):
|
||||
dst = pick_clip_id(scale_at(ring, st.to_index), r)
|
||||
steps.append(MorphStep(
|
||||
from_clip=src,
|
||||
to_clip=dst,
|
||||
file=ring.morph_for(src, dst),
|
||||
to_index=st.to_index,
|
||||
blended=st.blended,
|
||||
))
|
||||
src = dst
|
||||
return ResolvedMove(steps=tuple(steps), target_clip_id=src)
|
||||
+21
-13
@@ -3,10 +3,10 @@
|
||||
Pure decision logic — no mpv, no audio, no serial. Each update() resolves the
|
||||
desired Playback (which neutral base clip, how it is altered, what audio plays,
|
||||
at what levels) and returns the Transition from the previous Playback. The
|
||||
transition KIND encodes design §4.3: the Dark/Light grade and the Left overlay
|
||||
are continuous runtime ops (LIVE_UPDATE), whereas swapping the clip or the
|
||||
pre-baked Right restyle variant (a discrete index) needs a CROSSFADE, and
|
||||
toggling video on/off fades to/from black.
|
||||
transition KIND encodes design §4.3: the Dark/Light grade, the Left overlay AND
|
||||
the Right dream (a deterministic, live luminous haze since session 0013) are all
|
||||
continuous runtime ops (LIVE_UPDATE); only swapping the clip needs a CROSSFADE,
|
||||
and toggling video on/off fades to/from black.
|
||||
|
||||
Knobs no longer *select* a clip (the base library is neutral by construction);
|
||||
they *transform* it. Which neutral base to show is an injected policy
|
||||
@@ -20,7 +20,7 @@ from typing import Callable, Optional
|
||||
|
||||
from hef.selection import Coordinate
|
||||
from player.alteration import RenderPlan, plan_alteration
|
||||
from player.content import ContentResolution, resolve_content
|
||||
from player.audio import resolve_visual
|
||||
from player.controls import Controls
|
||||
|
||||
|
||||
@@ -34,11 +34,14 @@ class TransitionKind:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Playback:
|
||||
"""What should currently be playing."""
|
||||
"""What should currently be playing. Visual and audio are orthogonal (audio
|
||||
spec §2): `video` is whether the projector shows footage, `audio_source` is
|
||||
the independent audio dial (off / soundtrack)."""
|
||||
|
||||
clip_id: Optional[str] # None = black walls
|
||||
plan: Optional[RenderPlan] # None when black
|
||||
content: ContentResolution
|
||||
video: bool # whether footage is shown (Visual on/off)
|
||||
audio_source: str # the Audio dial source (off/soundtrack)
|
||||
volume: int
|
||||
brightness: int
|
||||
|
||||
@@ -58,7 +61,8 @@ def _first(library):
|
||||
_BLACK = Playback(
|
||||
clip_id=None,
|
||||
plan=None,
|
||||
content=ContentResolution("none", False),
|
||||
video=False,
|
||||
audio_source="off",
|
||||
volume=0,
|
||||
brightness=0,
|
||||
)
|
||||
@@ -78,12 +82,13 @@ class Player:
|
||||
self._current = _BLACK
|
||||
|
||||
def _resolve(self, controls: Controls) -> Playback:
|
||||
content = resolve_content(controls.content)
|
||||
if not content.video:
|
||||
video = resolve_visual(controls.visual)
|
||||
if not video:
|
||||
return Playback(
|
||||
clip_id=None,
|
||||
plan=None,
|
||||
content=content,
|
||||
video=False,
|
||||
audio_source=controls.audio,
|
||||
volume=controls.volume,
|
||||
brightness=controls.brightness,
|
||||
)
|
||||
@@ -92,7 +97,8 @@ class Player:
|
||||
return Playback(
|
||||
clip_id=clip.id,
|
||||
plan=plan_alteration(coord),
|
||||
content=content,
|
||||
video=True,
|
||||
audio_source=controls.audio,
|
||||
volume=controls.volume,
|
||||
brightness=controls.brightness,
|
||||
)
|
||||
@@ -107,7 +113,9 @@ class Player:
|
||||
if next_video and not prev_video:
|
||||
return TransitionKind.FADE_FROM_BLACK
|
||||
if next_video and prev_video:
|
||||
if nxt.clip_id != prev.clip_id or nxt.plan.restyle != prev.plan.restyle:
|
||||
# The Right dream is a live filter now (not a substrate swap), so it
|
||||
# no longer forces a crossfade — only a clip (scale) change does.
|
||||
if nxt.clip_id != prev.clip_id:
|
||||
return TransitionKind.CROSSFADE
|
||||
return TransitionKind.LIVE_UPDATE
|
||||
# both black: only audio/levels could have changed
|
||||
|
||||
@@ -24,3 +24,9 @@ sim = [
|
||||
"uvicorn[standard]>=0.29",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
# Playwright E2E browser tests (audio spec §9). Install with the browser binary:
|
||||
# pip install -e '.[e2e]' && python -m playwright install chromium
|
||||
# The E2E suite skips cleanly when Playwright/Chromium is absent (headless box).
|
||||
e2e = [
|
||||
"pytest-playwright>=0.4",
|
||||
]
|
||||
|
||||
@@ -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,115 @@
|
||||
# Session 0013.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-22T08-34 (PST)
|
||||
> End: 2026-06-23T11-29 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo (autonomous)
|
||||
> Claude-Session: f0a3044a-e9cb-467e-991f-f1ce618fc418
|
||||
> Status: **FINALIZED**
|
||||
> Lands: PR #12 → `main` (merge `23306fa`). Suite 233 passed / 2 skipped.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
"Let's keep working on the simulation. What's next? Figuring out the labeling for left
|
||||
brain HUD?" — opened against the stored 0012 next-goal (judge the real forest Right look,
|
||||
tune the v2v curve). Reconciled live: operator said "I liked the forest Right," which
|
||||
closed the judgment half and mooted the curve tuning, freeing the session for HUD + a
|
||||
Right-axis rethink.
|
||||
|
||||
## Pre-state
|
||||
|
||||
Through session 0012: engine (`player/`) + simulator built; scale ring (cosmos/forest/abyss);
|
||||
forest carried real flow-stabilized **pre-baked SD Right variants** (1–4); Left HUD was
|
||||
functional but never art-directed (plain cyan boxes); Dark/Light were two separate knobs with
|
||||
`mood_gain`/`overlay_gain` calibration sliders. 228 tests.
|
||||
|
||||
## Arc (turn by turn, wrong turns included)
|
||||
|
||||
1. **Session gate + claim.** Classified planning-and-executing; claimed ID 0013 from the main
|
||||
clone (no nested-worktree resolver issue this time). Clean `main` baseline.
|
||||
|
||||
2. **Left HUD art-direction pass.** Operator chose "look & feel first". Redesigned the analytical
|
||||
overlay: corner-bracket targeting reticles (capped arm length), translucent legible label
|
||||
chips, two sensor channels — cyan `detected.*` with deterministic per-key confidence scores
|
||||
vs amber `measure.*` with a center crosshair — and a bbox-sized `◉ ANALYSIS · L{n} · {k} OBJ`
|
||||
status tag that escalates with the Left knob. Judged by eye via headless-Chrome screenshots.
|
||||
Fixed two self-spotted flaws (status-tag clipping → measure from real `getBBox`; over-long
|
||||
reticle arms → cap).
|
||||
|
||||
3. **Affect / emotion channel (brainstormed → built).** Operator wanted emotions surfaced when
|
||||
Left AND Right are both up. Brainstormed: trigger = `min(left,right)`; soft ambient violet
|
||||
words (not boxed); stable per-scene palette, more words as combined strength rises. Built:
|
||||
`AffectOverlay(strength,intensity)` in the engine (math in Python), own SVG layer in the
|
||||
client, palettes in the manifest. 5 new unit tests. Design doc committed.
|
||||
|
||||
4. **mood_gain "does nothing" (not a bug).** Operator reported it. Traced: it's a gain on the
|
||||
Dark/Light grade — invisible with both at 0. Added a clarifying hint, then (later) removed the
|
||||
whole calibration UI anyway.
|
||||
|
||||
5. **Stale-cache saga (several rounds — the time sink).** "I don't see the emotions" / "Nothing
|
||||
is changing" — repeatedly diagnosed as the open tab running stale `app.js` (the live RenderPlan
|
||||
readout masked it). Added `no-cache` to the dev server; then a real `/dev/version` + 1 s client
|
||||
poll **live-reload**; then an on-screen error banner + crash-proof `update()`. Confirmed by the
|
||||
operator: "Works in incognito mode" — code was always correct; environment state was stale.
|
||||
|
||||
6. **Right axis reframe — the centerpiece.** Operator: the dreamlike knob is "too fluid... changes
|
||||
across the loop more than it should." Brainstormed the concept: Right should be a STILL altered
|
||||
state. Chose a **deterministic real-time filter** over the baked SD churn. Phase 1 (CSS luminous
|
||||
haze) built — then the operator clarified "same movement as the main video, just stylized": the
|
||||
haze read as blurred video, not stylized. Pivoted to **Phase 2: a WebGL2 Kuwahara painterly
|
||||
shader** on the live frames (edge-preserving → crisp motion, holds still across the loop). Caught
|
||||
and fixed a UV-mapping bug (lower-half streaking) mid-build. Operator: "That looks great. Let's
|
||||
get even a little more stylized. Max should be pretty trippy" → added saturation + ~45° hue-drift
|
||||
+ posterize, ramped by `intensity²` so only max goes psychedelic. "Perfect."
|
||||
|
||||
7. **Control surface 4→3 knobs.** Operator: make Dark/Light one dial; fold in mood_gain; drop
|
||||
overlay_gain. Collapsed Dark+Light into one bipolar Mood dial; removed the Calibration section
|
||||
(gains pinned to the locked `1.0` constants). Sim panel is now Left · Right · Mood.
|
||||
|
||||
8. **Processing-time question.** Operator asked compute/min for the coming content pipeline.
|
||||
Measured bundled ffmpeg at ~8× realtime (~10 s/min). Headline: the reframe killed the dominant
|
||||
per-clip cost (the SD bake) — per-minute compute is now trivial; the real cost is human authoring.
|
||||
|
||||
9. **Wrap.** Committed in three feature commits; PR #12 → `main`, merged; branch deleted; memory
|
||||
updated; transcript published.
|
||||
|
||||
## Cut state
|
||||
|
||||
`main` @ `23306fa`. 233 passed / 2 skipped. Sim runs locally (uvicorn :8000); Right dream is
|
||||
real-time WebGL (no bake); 3-knob control. Legacy `bake_right_variants.py` + baked `right*.mp4`
|
||||
parked, unused. Three design docs in `docs/superpowers/specs/` (affect channel; right-axis
|
||||
deterministic dream; + the Phase-1/2 staging recorded there).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **No formal implementation-plan artifact** — built by-eye in the fast iteration loop; the two new
|
||||
design docs are the record. No content repo configured (`CONTENT_REMOTE` empty), so nothing to
|
||||
`submit-plan.sh`; docs live in-repo and merged.
|
||||
- **Pipeline (§9):** no PPE/prod stage exists for the simulator (simulator-first phase) — merge to
|
||||
`main` is the completion; no deploy ran. Not a skipped gate; there is no stage yet.
|
||||
- **4→3 knob change** is a real experience-design change not yet reflected in the canonical design
|
||||
SPEC (still describes 4 knobs). Engine keeps dark/light internally. To record in the SPEC later.
|
||||
- **Legacy SD baker parked, not deleted** — kept for reference; can be removed once the real-time
|
||||
dream is fully settled.
|
||||
|
||||
## Operator plate (what the operator did / decided)
|
||||
|
||||
Drove every aesthetic call by eye: liked forest Right; chose HUD look-and-feel; specified emotions
|
||||
on Left+Right; rejected the "too fluid" SD dream and the blurred haze; approved the painterly
|
||||
direction and asked for a trippy max; asked to merge Dark/Light into one Mood dial and remove the
|
||||
calibration knobs; asked the processing-time question; directed commit + push + finalize and to
|
||||
start a fresh session for the content pipeline.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
`/goal` Start the **content pipeline** for the installation: source full neutral video clips +
|
||||
processing + labeling. **Brainstorm/scope into a content-pipeline spec first**, deciding:
|
||||
(1) sourcing — which strict-PD clips, which scales (beyond cosmos/forest/abyss); reuse the 0008
|
||||
strict-PD map (NASA/Hubble, NOAA Ocean Exploration, NPS/USGS = true PD; "free stock" is NOT PD);
|
||||
(2) clip length + seamless-loop strategy; (3) labeling approach — STATIC boxes (current, 0 compute)
|
||||
vs MOTION-TRACKED (a detection+tracking pass), hand-authored vs detection-assisted, same for affect
|
||||
placement; (4) format target (res/fps/codec for the sim now + the Pi later). Anchor fact: the Right
|
||||
dream is **real-time, no per-clip bake**, so the pipeline is transcode + authoring, not ML baking.
|
||||
Read `memory/sub-project-3-player-progress.md` + the scales design
|
||||
(`docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md` §1/§3/§4) first.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Session 0014.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-24T06-58 (PST)
|
||||
> End: 2026-06-24T08-29 (PST)
|
||||
> Type: brainstorming
|
||||
> Posture: careful
|
||||
> Claude-Session: 50301aba-c534-4a7c-9f33-8b0881920ab2
|
||||
> Anchor: docs/ROADMAP.md sub-project 3 (Player Runtime) — content pipeline
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Launch: /goal next → resolved from memory to the recorded Next /goal:
|
||||
START A NEW SESSION for the content pipeline — sourcing full neutral video clips + processing + labeling. Brainstorm/scope it into a content-pipeline spec FIRST, deciding: (1) sourcing (strict-PD clips, which scales); (2) clip length + seamless-loop strategy; (3) labeling approach (static vs motion-tracked; hand-authored vs detection-assisted; same for affect); (4) format target (res/fps/codec for sim now + Pi later). Anchor fact: Right dream is real-time, no per-clip bake → pipeline is transcode + authoring, NOT ML baking.
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: docs/ROADMAP.md sub-project 3 content pipeline. Roadmap-anchored repo
|
||||
> (no Gitea issue tracker used 0001–0013); content pipeline is one cohesive
|
||||
> Feature → design-eligible. See Deferred decisions for the R1 deviation.
|
||||
|
||||
**Goal:** Explore the content pipeline (source full neutral PD clips → process →
|
||||
label/author) and write its Solution-Design spec. Anchor fact: the Right dream is
|
||||
real-time (session 0013), so the pipeline is **transcode + human authoring**, not
|
||||
ML baking.
|
||||
|
||||
## What happened (arc)
|
||||
|
||||
This opened as `/goal next`; the session gate resolved the stored `Next /goal:`
|
||||
from memory (content pipeline) and routed to **brainstorming** (the goal said
|
||||
"brainstorm/scope it into a spec first"). Init claimed session **0014** (careful
|
||||
posture, the brainstorming default), no concurrent sessions, clean `main`.
|
||||
|
||||
**Orientation.** Read the roadmap (sub-project 3), the scales-library/right-axis
|
||||
design (§1/§3/§4), the two session-0013 specs (real-time dream supersedes the SD
|
||||
bake; affect channel), and the current content shape (`simulator/clips.py`,
|
||||
`manifest.json`, `setup_scales_media.py`). Confirmed the key simplifier: the Right
|
||||
dream is real-time, so the pipeline is transcode + authoring, **not** ML baking.
|
||||
|
||||
**Brainstorming (careful, agree-up-front → draft-whole → review-whole).** Walked
|
||||
the four launch-prompt decisions as `AskUserQuestion` checkpoints:
|
||||
1. Scale set → **lean 5** (cosmos · orbit · forest/land · reef · abyss; microscopic
|
||||
dropped — thinnest PD-video pool).
|
||||
2. Loop → **crossfade loop** (tail→head overlap).
|
||||
3. Labels → **motion-tracked, HYBRID** (ML proposes where it can; hand-seed +
|
||||
classical tracker elsewhere). Surfaced that label *semantics* must always be
|
||||
hand-authored (a generic detector can't produce "siphonophore"/"z ≈ 0.04");
|
||||
tracking only propagates box geometry.
|
||||
4. Format → **master + 1080p H.264 proxy**.
|
||||
|
||||
Drafted the full spec `docs/superpowers/specs/2026-06-24-content-pipeline-design.md`,
|
||||
pushed to `feature/content-pipeline`, handed over the rendered Gitea URL for
|
||||
whole-document review. Operator chose **"Approve + plan now"**.
|
||||
|
||||
**Planning.** Wrote `docs/superpowers/plans/2026-06-24-content-pipeline-increment-1.md`
|
||||
(Increment 1 = real footage, no ML): 9 bite-sized TDD tasks. Self-reviewed against
|
||||
the spec (coverage, types, one dead-variable fix). Operator chose **"Execute now
|
||||
(subagent-driven)"**.
|
||||
|
||||
**Execution (subagent-driven, 2-stage review per group).** Implementer + reviewer
|
||||
subagents (sonnet), grouped by file coupling:
|
||||
- Tasks 1–3 (`tools/pipeline/ffmpeg_ops.py` pure builders) — DONE; review added an
|
||||
`overlap<=0` test.
|
||||
- Tasks 4–5 (`provenance.py`, `manifest.py`) — DONE; review added append-fallback +
|
||||
N=1 self-wrap tests.
|
||||
- Task 6 (`run.py` runner + integration test) — the test gated on `shutil.which`
|
||||
and SKIPPED (ffmpeg not on PATH). Increment 1's goal is to *prove the pipeline on
|
||||
real footage*, so I hardened the runner to resolve the **bundled imageio-ffmpeg**
|
||||
(matching `setup_scales_media.py`) and switched duration probing to **cv2** (no
|
||||
ffprobe dep — Pi-friendly). Re-run **actually exercised real ffmpeg on the real
|
||||
forest base and surfaced a genuine bug**: `trim` yields `1/0` framerate which
|
||||
`xfade` rejects (needs CFR) — fixed with `fps=30` after each trim segment. Test
|
||||
now PASSES (proxy 1920×1080 verified via cv2). Closed an ffprobe-gap minor too.
|
||||
- Task 7 (client annotation-track interpolation, `app.js`) — DONE; `node --check`
|
||||
clean, API serves the track. Visual drift deferred to operator by-eye.
|
||||
- Task 8 (ring 3→5: orbit + reef placeholders) — DONE; `/api/ring` shows 5 scales
|
||||
+ 5 edges. Ring spin deferred to operator by-eye.
|
||||
- Task 9 (sourcing doc + ROADMAP + stale-docstring fix) — DONE.
|
||||
|
||||
Final whole-branch review: **READY TO MERGE** (no critical/blocking). 246 passed /
|
||||
2 skipped. Operator chose **"Merge to main now"** → pushed, created **PR #13** via
|
||||
the Gitea API, merged (delete branch), synced main, deleted local branch. Stamped
|
||||
the spec **graduated** on main.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Content-pipeline **spec** (graduated) + **Increment-1 plan** (archived in-repo)
|
||||
+ **Increment 1 implementation** all merged to `main` (PR #13, merge `bb5ecf0`;
|
||||
spec stamp `73e5c4a`). Suite **246 passed / 2 skipped**.
|
||||
- New `tools/pipeline/` package; keyframed annotation-`track` schema
|
||||
(client-interpolated, backward compatible); scale ring extended 3→5.
|
||||
- `docs/content-sourcing.md` (strict-PD sourcing procedure); ROADMAP updated.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **R1 anchor (no Gitea issue):** the brainstorming gate wants a non-`epic`
|
||||
`type/*` tracker issue as the design anchor. This repo has never used a Gitea
|
||||
issue tracker (roadmap-driven, 0001–0013), so the spec is anchored to
|
||||
`docs/ROADMAP.md` sub-project 3 — matching every prior brainstorming session
|
||||
here (0005/0007/0008/0013). Flagged rather than force-filing an issue that would
|
||||
break established repo practice; operator can request issue-tracking if wanted.
|
||||
- **Session-type blur (operator-directed):** a brainstorming session normally ends
|
||||
at the spec; the operator explicitly extended it through plan → execute → merge.
|
||||
Each transition was an explicit operator choice via `AskUserQuestion`, so the
|
||||
careful-posture gates were honored, not bypassed.
|
||||
- **Crossfade duration source (minor, non-blocking):** `process_clip` probes the
|
||||
duration from the *source*, not the post-frame mezzanine; GOP alignment can make
|
||||
the tail segment ±~33 ms off. ffmpeg handles EOF gracefully; imperceptible on
|
||||
calm footage. Noted for a future fast-motion clip.
|
||||
|
||||
## Pending for next session
|
||||
|
||||
- **Operator by-eye review of Increment 1** in the sim: the track drift
|
||||
(water/fish boxes follow playback + loop seamlessly) and spinning the 5-scale
|
||||
ring cosmos→orbit→forest→reef→abyss→wrap. A dev sim (session-0013 live-reload)
|
||||
was running on :8000 during the session.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal content-pipeline Increment 2 — source real strict-PD bases per docs/content-sourcing.md (run each through tools/pipeline/run.py process_clip) + build the hybrid motion-track pass tools/pipeline/track.py + a simulator author mode; FIRST do the pending by-eye review of Increment 1. Per docs/superpowers/specs/2026-06-24-content-pipeline-design.md
|
||||
```
|
||||
|
||||
> Still-deferred (tracked in `docs/ROADMAP.md`, this repo's queue): real i2v ring
|
||||
> transitions (need a generative-video model + adjacent real bases); Pi renderer +
|
||||
> serial/firmware (simulator-first).
|
||||
@@ -0,0 +1,85 @@
|
||||
# Session 0015.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-24T13-16 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 50301aba-c534-4a7c-9f33-8b0881920ab2
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0015 for human-experience-filter-art. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0015.0-TRANSCRIPT-2026-06-24T13-16--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Process 5 candidate ORBIT (Earth-from-space) clips for the human-experience-filter art installation: resolve URLs, download, vet for text + humans, trim to a word-free calm window, transcode. Clips: orbit_planetearth, orbit_crewobs, orbit_aurora_perth (SVS 31281), orbit_westcoast (SVS 30180), orbit_bluemarble (SVS 4550).
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
Process 5 candidate ORBIT (Earth-from-space) clips: resolve URLs, download, vet for text/telemetry/humans via contact sheets, trim to word-free 24s window, transcode via process_clip.
|
||||
|
||||
Tasks:
|
||||
1. Resolve direct mp4 URLs for SVS pages (aurora_perth/31281, westcoast/30180, bluemarble/4550)
|
||||
2. Download all 5 clips
|
||||
3. Build contact sheets (overview + first-8s + last-8s) for each clip
|
||||
4. Vet each clip for text overlays, telemetry, ISS hardware, humans
|
||||
5. Determine clean window or mark UNUSABLE
|
||||
6. process_clip → simulator/sample_media/<id>/ for usable clips
|
||||
7. Generate preview frames
|
||||
|
||||
## Clip results
|
||||
|
||||
### orbit_planetearth
|
||||
- URL: https://svs.gsfc.nasa.gov/vis/a030000/a030700/a030771/ISS_View_of_Planet_Earth_2160p.mp4
|
||||
- Duration: 97.2s
|
||||
- Text/telemetry: "INTERNATIONAL SPACE STATION" title card t=0–8s; ISS logo end card t≈80–97s
|
||||
- Humans: none
|
||||
- Window: 10–34s (24s, clean daytime Earth clouds)
|
||||
- Status: USABLE
|
||||
- Output: simulator/sample_media/orbit_planetearth/
|
||||
- Preview: /tmp/hef-cand/orbit_planetearth/preview.png
|
||||
|
||||
### orbit_crewobs
|
||||
- URL: https://svs.gsfc.nasa.gov/vis/a030000/a030700/a030771/ISS_Crew_Earth_Observations_2160p.mp4
|
||||
- Duration: 66.4s
|
||||
- Text/telemetry: "INTERNATIONAL SPACE STATION" + date/location title card t=0–8s; ISS logo end card t≈55–66s
|
||||
- Humans: none
|
||||
- Window: 9–33s (24s, clean desert/cloud Earth)
|
||||
- Status: USABLE
|
||||
- Output: simulator/sample_media/orbit_crewobs/
|
||||
- Preview: /tmp/hef-cand/orbit_crewobs/preview.png
|
||||
|
||||
### orbit_aurora_perth
|
||||
- URL: https://svs.gsfc.nasa.gov/vis/a030000/a031200/a031281/ISS067_20220817_aurora_1080p25.mp4
|
||||
- Duration: 49.6s
|
||||
- Text/telemetry: Date+timestamp burned in top-left throughout (e.g. "2022-08-17 19:15:25", updating every second)
|
||||
- Humans: none
|
||||
- Window: none (telemetry throughout)
|
||||
- Status: UNUSABLE
|
||||
|
||||
### orbit_westcoast
|
||||
- URL: https://svs.gsfc.nasa.gov/vis/a030000/a030100/a030180/iss028_nighttime_20110819_720p.mp4
|
||||
- Duration: 54.7s
|
||||
- Text/telemetry: none
|
||||
- Humans: none
|
||||
- ISS hardware: cylindrical ISS module + solar panel grid visible in top-right corner throughout entire clip (confirmed by exhaustive per-second crop inspection t=14–44s)
|
||||
- Window: none (ISS hardware throughout)
|
||||
- Status: UNUSABLE — orbit_westcoast output dir deleted
|
||||
|
||||
### orbit_bluemarble
|
||||
- URL: https://svs.gsfc.nasa.gov/vis/a000000/a004500/a004550/BlueMarble_starfield_4k_2160p30_v2.mp4
|
||||
- Duration: 121.0s
|
||||
- Text/telemetry: none (CG visualization, no overlays anywhere)
|
||||
- Humans: none
|
||||
- Window: 10–34s (24s, rotating Earth globe on starfield)
|
||||
- Status: USABLE
|
||||
- Output: simulator/sample_media/orbit_bluemarble/
|
||||
- Preview: /tmp/hef-cand/orbit_bluemarble/preview.png
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- orbit_westcoast output directory (simulator/sample_media/orbit_westcoast/) was written before the ISS hardware was confirmed throughout. It needs to be deleted. The rm -rf was attempted but permission was denied in the sandbox — operator should delete it manually: `rm -rf simulator/sample_media/orbit_westcoast`
|
||||
@@ -0,0 +1,117 @@
|
||||
# Session 0016.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-24T17-48 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 0fde1d36-7c64-414f-8f49-8669b5dd2263
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/goal` Content pipeline Increment 2 (expanded) — rotating clip pools + time-windowed
|
||||
TRACKED labels + progressive left-brain detail + progressive right-brain emotions.
|
||||
Short design pass first (schema changes), then build.
|
||||
|
||||
> Anchor: design `docs/superpowers/specs/2026-06-24-content-pipeline-design.md`
|
||||
> (graduated, R2a-eligible). Repo is roadmap-anchored, no Gitea issue tracker (R1
|
||||
> deviation, as all prior sessions). Increment 2 of that design.
|
||||
|
||||
## Pre-state
|
||||
|
||||
`main` at `b28f930` (PR #15 — operator-curated rotating-pool doc merged). All 19
|
||||
vetted pool clips on disk (gitignored) plus the leftovers to drop. The ring was the
|
||||
Increment-1 one-base-per-scale model (cosmos/orbit/forest/reef/abyss), flat
|
||||
`min_level` label gating, static or simple-track boxes, scene-level affect gated by
|
||||
`min(left,right)`. `tools/pipeline/` had stages 1–4 + 6 (no `track.py`). 246 tests.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Session gate + init.** Classified the `/goal` as planning-and-executing,
|
||||
claimed session **0016** (peeked an ENDED-UNFINALIZED 0015 — its PR #15 work was
|
||||
already merged, not live — and proceeded; no worktree needed, solo session).
|
||||
Verified clean `main`, read the pool doc + spec + sub-project-3 memory + the live
|
||||
code (ring, clips, app, app.js, alteration, pipeline).
|
||||
2. **Design pass (operator asked for it — schema changes).** Appended **§11** to the
|
||||
content-pipeline spec pinning the concrete shapes: rotating pool, time-windowed
|
||||
tracks, progressive Left detail tiers (salience + tiered strings), progressive
|
||||
Right emotion tiers, and the `track.py` + author-mode tooling. Key call: tiers
|
||||
need **no engine change** — the client reads `overlay.level` (Left) and
|
||||
`dream.strength` (Right) already in the `RenderPlan`.
|
||||
3. **PR #16 — the experience.** `Scale.pool` + pure `pick_clip_id` (injected
|
||||
randomness); server-side random pick at the API boundary (delta=0 = initial pick).
|
||||
`forest`→`coast` rename + ring reorder + orphan-media cleanup. Time-windowed
|
||||
tracked labels (`appear`/`disappear`, seam-wrapping). Salience-gated +
|
||||
tier-escalating Left labels; tier-escalating Right emotions; tiered-or-static
|
||||
strings (back-compat). New `build_pool_manifest.py` emits the 19-clip pool
|
||||
manifest (abyss+reef authored as tracked/windowed showcase). Merged via Gitea API.
|
||||
4. **PR #17 — the tooling.** `tools/pipeline/track.py`: classical LK optical-flow box
|
||||
tracker (this opencv build has no CSRT) → sparse keyframed track + window; optional
|
||||
lazy ML path. Simulator AUTHOR MODE (`/author.html` + `/api/author/{track,clip}`):
|
||||
scrub, drag a seed box, run tracker, author tiers/affect, save to manifest
|
||||
(idempotent upsert, keeps provenance). Repointed the pipeline integration test off
|
||||
the retired forest base; USER_GUIDE sim section brought current. Merged.
|
||||
|
||||
## Cut state
|
||||
|
||||
`main` at `c445239` (PR #17 merge). **267 passed / 2 skipped.** Clean tree, fully
|
||||
pushed, no open PRs. Both feature branches deleted. All 5 goal items delivered.
|
||||
|
||||
Verification was API/logic-level + JS syntax + live-server probes (5 scales with
|
||||
pools, rotating picks, new edges, 19 clips served, tracker returns keyframes on real
|
||||
abyss_wow footage, committed manifest untouched by the read-only probe). **The
|
||||
VISUAL look was not screenshot-verified — no Chrome on this box** — so operator
|
||||
by-eye review is the next step (matches the established deferred-review pattern).
|
||||
|
||||
## Follow-up (post-finalize, same conversation) — PR #18
|
||||
|
||||
Operator asked to replace the scale-ring `⊖ out / in ⊕` buttons with a **circular
|
||||
"Altitude" knob** that turns all the way around (including abyss→cosmos). Built a
|
||||
turnable SVG dial in `simulator/static/app.js` (`buildDial` / `renderDial` / drag +
|
||||
wheel handlers) + `style.css`: cosmos (highest) at top, clockwise descends
|
||||
cosmos→orbit→coast→reef→abyss and **wraps** back up. Dragging accumulates rotation
|
||||
and commits whole detents on release (a big spin → one fast blended pass, reusing
|
||||
the proven wheel/detent path); scroll the knob to step; tap a label to jump the
|
||||
shortest way around. **Pure frontend** — the endless ring + wrap already existed
|
||||
server-side, so no engine/API change. JS syntax-checked; 23 sim-API tests green;
|
||||
live probe confirmed the dial replaced the buttons and the abyss→cosmos wrap is
|
||||
intact. Merged as **PR #18** (`main` at `7a32ff1`). Visual look still operator-by-eye
|
||||
(no Chrome on this box).
|
||||
|
||||
## Deployment pipeline (§9)
|
||||
|
||||
No stage run: the HEF simulator is a **local dev tool** (simulator-first directive),
|
||||
not a deployed app — it has no PPE/prod infra. Nothing shippable through the §9
|
||||
pipeline this session; the session output is the merged code on `main`.
|
||||
|
||||
## Plan archival
|
||||
|
||||
No separate `superpowers:writing-plans` artifact was produced — the design pass was a
|
||||
spec §11 edit (merged in PR #16) and execution was direct TDD tracked by the
|
||||
transcript `## Plan` block + the task list. Nothing to `submit-plan.sh`; this app has
|
||||
no content repo anyway (roadmap-anchored).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Adopting/finalizing the ENDED-UNFINALIZED session 0015 placeholder** was skipped
|
||||
— its PR #15 work is already on `main` and its outcomes are fully recorded in
|
||||
memory; finalizing a prior dead session was judged an unnecessary detour. Low
|
||||
confidence; flagging for the operator.
|
||||
- **By-eye visual review deferred** to the operator (no Chrome here) — consistent with
|
||||
the prior-session pattern, but the look of the tiers/windows/pool rotation AND the
|
||||
new Altitude knob is unconfirmed visually.
|
||||
- **Light-vs-rich label authoring:** only abyss + reef + the scale primaries are
|
||||
richly authored; the other pool members carry lighter labels. Finishing them is the
|
||||
author-mode follow-up (next goal).
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
`/goal` Operator by-eye review of Increment 2 + the Altitude knob in the sim
|
||||
(`make sim-local`, :8000): turn the Altitude knob (drag/scroll, wraps abyss→cosmos;
|
||||
pools rotate per landing), sweep Left (progressive label tiers
|
||||
general→scientific+fact), sweep Right with Left up (progressive emotions
|
||||
basic→compound), watch abyss/reef time-windowed tracked labels enter/leave + track,
|
||||
and exercise `/author.html`. Then author real labels for the remaining pool members
|
||||
via author mode. After the content is liked, the next build frontier is the deferred
|
||||
i2v ring transitions (generative-video model + adjacent real bases) and/or the Pi
|
||||
renderer + serial/firmware (per [[simulator-first-before-hardware]]), per ROADMAP.md.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Session 0017.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-25T07-41 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: bcb069f2-0fba-4a66-ae8e-f40b7917a48c
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0017 for human-experience-filter-art. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0017.0-TRANSCRIPT-2026-06-25T07-41--<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._
|
||||
@@ -0,0 +1,122 @@
|
||||
# Session 0018.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-25T09-42 (PST)
|
||||
> End: 2026-06-26T05-56 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: b96041e5-c682-49f5-88ee-380ea7abb6af
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Find a better public-domain nebula flythrough video for the cosmos scale (the current text-free SVS Orion clip lost quality when cropped to remove its text overlay), or upscale the existing one if no better source exists.
|
||||
```
|
||||
|
||||
A content-and-polish session on the simulator's cosmos scale that grew, by
|
||||
operator follow-ups, into four merged PRs: the nebula swap, two cache fixes, a
|
||||
ring-landing artifact fix, and a Right-brain affect rework.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `feature/ring-transitions-real` (later merged): HEAD `129bb23` had just
|
||||
swapped the cosmos primary off the captioned/cropped JPL "Orion: Dust and Death"
|
||||
clip onto the text-free SVS 30957 Orion flythrough.
|
||||
- Sim served by a manually-launched `uvicorn ... --port 8000` (no `--reload`),
|
||||
booted before the session's media changes.
|
||||
- Affect channel gated on `min(left, right)` (session 0013 rule).
|
||||
- Concurrent unfinalized sessions present (0015, 0017).
|
||||
|
||||
## Arc (turn by turn)
|
||||
|
||||
1. **Anchor + research.** Classified as planning-and-executing, claimed session
|
||||
0018. Found the current cosmos clip was already the uncropped text-free SVS
|
||||
30957 Orion (the crop damage the prompt described was already fixed at HEAD).
|
||||
Researched NASA SVS: (a) the same Orion clip exists in 4K; (b) the Webb
|
||||
**"Cosmic Cliffs"** Carina flythrough (SVS 31348) is a 4K PD alternative.
|
||||
Offered three options; **operator chose #2 — switch to Cosmic Cliffs.**
|
||||
|
||||
2. **Cosmos swap (PR #21).** Downloaded the Carina 4K master
|
||||
(`Clifs-3d-STScI.mp4`, 3840×2160, 60 fps, 103 s), sampled frames to find a
|
||||
clean window, trimmed **10–34 s** (clears the title card ~8 s and the
|
||||
end-credit card ~102 s), ran `tools.pipeline.run.process_clip` → 4K master +
|
||||
supersampled 1080p base (5.5 Mbps, crisper than the old 2.9 Mbps Orion encode).
|
||||
Updated `build_pool_manifest.py` META (new `CCBY_WEBB` credit, Carina labels:
|
||||
emission nebula / protostar, redshift→distance ≈7,500 ly), regenerated
|
||||
`manifest.json` (also corrected stale JPL-Orion provenance) + all ring-edge
|
||||
transitions, and `docs/content-candidate-pool.md`. Operator authorized the
|
||||
merge → opened + merged **PR #21** (the whole `feature/ring-transitions-real`
|
||||
line), synced main, deleted the local branch. Remote branch delete returned
|
||||
HTTP 405 (known Gitea perm limit).
|
||||
|
||||
3. **"Still seeing Orion" — caching (PRs #22, #23).** Diagnosed: the sim server
|
||||
(PID 33672) had been running since before the media swap and was launched
|
||||
**without `--reload`**, holding the pre-swap manifest in memory; the browser
|
||||
also held the old clip via the in-memory blob preload and a possible pre-
|
||||
`no-cache` immutable HTTP entry. Restarted the server with `make sim-local`
|
||||
(`--reload`). Shipped **PR #22** (`cache: "reload"` on the preload fetch), then
|
||||
— at operator request for a permanent fix — **PR #23**: a content-hash
|
||||
`GET /api/media-versions` endpoint (sha1[:12] per served file, cached by
|
||||
mtime/size) and client `?v=<hash>` on every /media URL, so a re-baked clip's
|
||||
URL changes with its bytes.
|
||||
|
||||
4. **Coast landing artifact (in PR #23).** Operator noticed zooming into coast
|
||||
showed birdrock then switched to another clip. Root cause: the baked ring
|
||||
transition lands on the scale **primary** (birdrock), but the rotating pool
|
||||
picks a **random member** on landing — a non-primary pick hard-cut from primary
|
||||
to chosen. Fix: `advance()` masks the swap behind a short fade through the
|
||||
existing `#black` overlay when `target_clip_id !== ring.scales[to_index].clip_id`;
|
||||
general across all pooled scales.
|
||||
|
||||
5. **Right-brain affect (PR #24).** Operator: emotions should be controlled by the
|
||||
right brain, not the left. Reversed `affect.strength = min(left, right)` →
|
||||
`strength = right`; intensity keyed off Right; Left no longer gates emotions.
|
||||
Updated engine + client comments + the affect design spec (revision note) +
|
||||
tests. Live-verified: left=0/right=4 → strength 4; left=4/right=0 → 0.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **`main` @ (session-0018 merges all landed):** PRs **#21** (`7da7446`), **#22**
|
||||
(`aa56dfe`), **#23** (`5bb762a`), **#24** (`d0ad9a9`) — all confirmed in main's
|
||||
ancestry. Session 0019 has since advanced main further (cosmos_miri pool member,
|
||||
networked-control-surface spec, sim-local venv fix).
|
||||
- **Tests:** 271 passed, 2 skipped (+ media-version tests + rewritten affect
|
||||
tests).
|
||||
- **Sim:** running on :8000 as `uvicorn --reload` (PID 50199, restarted this
|
||||
session). The cosmos Cosmic Cliffs clip, content-hash cache-bust, coast fade,
|
||||
and Right-only affect are all live and API/logic-verified.
|
||||
- **No plan artifact** archived — leaf content/polish task executed directly
|
||||
(no `superpowers:writing-plans` doc).
|
||||
|
||||
## Operator plate
|
||||
|
||||
- **Cosmic Cliffs looks "incredible"** (operator) — the cosmos primary is now the
|
||||
Webb Carina flythrough.
|
||||
- **Unverified by eye** (no Chrome on the box): the coast landing fade timing, and
|
||||
the Right-only affect look (Left=0 + Right up should show emotion words over an
|
||||
un-annotated scene). Worth a by-eye pass in `make sim-local`.
|
||||
- **Pipeline §9:** changes merged to `main` (= done); no PPE/prod run — the E2E +
|
||||
PPE machinery is still being built (§10.6), and the simulator is a local dev
|
||||
surface. Not shipped to prod.
|
||||
- **Left untouched (not this session's work):** session 0019's in-flight audio
|
||||
work is dirty in the tree (`docs/audio-candidate-pool.md`, `.gitignore` audio
|
||||
entries) — left for 0019 to finalize. Pre-existing unfinalized sessions 0015,
|
||||
0017, 0019 remain `--INPROGRESS`.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
None logged — every call this session was operator-directed (the option-2 clip
|
||||
choice, the permanent cache-bust request, the Right-brain affect direction).
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal By-eye review of session 0018's sim changes in `make sim-local` (:8000): the
|
||||
Webb Cosmic Cliffs cosmos clip, the coast/pool landing fade (no hard cut to the
|
||||
random member), and Right-only emotions (Left=0 + Right up shows feeling words over
|
||||
an un-annotated scene). Tune the ~200ms fade timing if it feels off. Then continue
|
||||
the experience/content line — deferred i2v ring transitions and/or the Pi renderer
|
||||
(per [[simulator-first-before-hardware]]). Note: session 0019 separately owns the
|
||||
audio pool + networked-control-surface work.
|
||||
```
|
||||
@@ -0,0 +1,95 @@
|
||||
# Session 0019.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T04-47 (PST)
|
||||
> End: 2026-06-26T05-47 (PST)
|
||||
> Type: brainstorming
|
||||
> Posture: careful
|
||||
> Claude-Session: d6ed1203-92b6-471f-80a4-4c773b645ca1
|
||||
> Anchor: ROADMAP sub-project 4 (Arduino Firmware / control panel) — reframed
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Let's start talking about hardware. Let's assume for the first version I'm ok having my computer or laptop do the rendering. We'd still want a controller but it could be connected to the computer via wifi, USB, or bluetooth and would send commands to the software we've already written that would adjust left brain, right brain, etc. We'd prototype this by having a controller simulator run on another device, such as an ipad (just riffing here) but eventually move to a physical remote running on an Arduino or similar
|
||||
```
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Simulator is a **FastAPI** app, **stateless**: the single browser holds knob
|
||||
state in JS and POSTs to `/api/alteration` for a `RenderPlan`. Renderer and
|
||||
controller are the same browser tab.
|
||||
- Operator-facing control surface (sim today): Content (7-way `<select>`),
|
||||
Altitude (endless circular dial across 5 scales), Left/Right (sliders 0–4),
|
||||
Mood (slider −4…+4). (Dark+Light → Mood in s0013; Altitude dial in s0018.)
|
||||
- ROADMAP sub-project 4 = "Arduino Firmware (control panel)" assuming a **Pi**
|
||||
runs the room and the panel talks **USB serial** to it.
|
||||
- Standing directive: simulator-first before hardware.
|
||||
- Checkout shared with a concurrent audio-sourcing session (0017/0018), with
|
||||
uncommitted `.gitignore` + untracked files on `main`.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Session gate / routing.** Classified the opening ("let's talk about
|
||||
hardware… just riffing") as **brainstorming**; invoked `wgl-brainstorming`,
|
||||
claimed session **0019** (`--type brainstorming`, careful posture). Three stale
|
||||
`--INPROGRESS` sessions (0015/0017/0018) noted as leftovers, not live overlaps;
|
||||
no worktree (the audio session's edits are unrelated/uncommitted).
|
||||
|
||||
2. **Orientation.** Read `docs/ROADMAP.md` + the simulator (`app.py`, `index.html`)
|
||||
to ground the discussion; confirmed the real control surface and the stateless
|
||||
architecture. Recognized the request as a **reframe** of sub-project 4.
|
||||
|
||||
3. **Brainstorming dialogue** (`superpowers:brainstorming`), decisions reached:
|
||||
- **Intent:** both — decouple control/display cleanly AND want the handheld feel.
|
||||
- **Transport (v1):** wifi/web (iPad opens a controller page off the FastAPI
|
||||
server; least friction; Arduino transport left open).
|
||||
- **Remote fidelity:** faithful twin — decide the physical panel inventory now,
|
||||
iPad mirrors it.
|
||||
- Operator refinement: **per-knob transition state** — live controls
|
||||
(Left/Right/Mood) are instant; transitional ones (Altitude, Content) carry a
|
||||
lifecycle so the remote's feedback is honest.
|
||||
- **Architecture:** approach A — server-authoritative `SessionState` + **SSE**
|
||||
push (chosen over polling / WebRTC).
|
||||
|
||||
4. **Design presented section by section** (roles → transition model → server
|
||||
contract → two pages + Arduino seam → error/test/scope), each approved.
|
||||
|
||||
5. **Spec written** to
|
||||
`docs/superpowers/specs/2026-06-26-networked-control-surface-design.md`,
|
||||
committed on `feat/networked-control-surface`, pushed; operator approved going
|
||||
straight to finalize.
|
||||
|
||||
6. **Finalize.** Single-repo app (CONTENT_REMOTE empty) → merging the branch IS
|
||||
the submission. Discovered the shared checkout had switched to `main` (the
|
||||
audio session) and `main` had advanced to PR #25; landed the spec via a safe
|
||||
local `--no-ff` merge that added only the new spec file (their uncommitted work
|
||||
untouched), pushed `main` (`229af60..f28a06c`), deleted the feature branch.
|
||||
|
||||
## Cut state
|
||||
|
||||
- `main` @ `f28a06c` — spec merged. Feature branch deleted.
|
||||
- Spec `status: proposed` (careful posture); operator reviewed it live, approving
|
||||
every section before write.
|
||||
- No code written (brainstorming session). 0 new tests (none applicable).
|
||||
- Concurrent audio session's uncommitted `.gitignore`/untracked files left intact.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — no autonomous low-confidence calls. The two flagged design choices
|
||||
(last-write-wins concurrency; no auth in v1) were presented to the operator and
|
||||
approved in-session, not deferred._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- Review the merged spec if desired:
|
||||
`docs/superpowers/specs/2026-06-26-networked-control-surface-design.md` on `main`.
|
||||
- Next: open a **writing-plans** session to turn the spec into an implementation
|
||||
plan (the `/goal` below).
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal Write the implementation plan for the networked control surface, per docs/superpowers/specs/2026-06-26-networked-control-surface-design.md
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# Session 0020.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T06-47 (PST)
|
||||
> End: 2026-06-26T07-58 (PST)
|
||||
> Type: writing-plans (extended into execution — operator approved "then build it" at the plan gate)
|
||||
> Posture: careful (execution proceeded under explicit operator approval)
|
||||
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
|
||||
> Anchor: design docs/superpowers/specs/2026-06-26-audio-video-separated-controls-design.md (R2a)
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> Write the implementation plan for the "Audio + Video — separated Visual & Audio
|
||||
> controls" feature, then build it. (Spec:
|
||||
> docs/superpowers/specs/2026-06-26-audio-video-separated-controls-design.md.)
|
||||
> Split the bundled 7-way Content selector into orthogonal Visual (on/off) ×
|
||||
> Audio (off / soundtrack / white noise). Audio follows the single Altitude dial;
|
||||
> white noise synthesized (ffmpeg anoisesrc). Reframe the (unbuilt) networked-
|
||||
> control-surface spec §3/§5 as a clean pre-implementation edit. Honor the testing
|
||||
> tiers; resolve the three §12 flagged decisions.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- `main` clean at 99a751c. Two stale `--INPROGRESS` placeholders (0015, 0017) from
|
||||
2026-06-24/25 — prior dead sessions, not live; left untouched.
|
||||
- The networked-control-surface architecture (SSE / `/remote` / renderer split) was
|
||||
**unbuilt** — the sim is a single fused page (`simulator/static/index.html`) talking
|
||||
to FastAPI via `POST /api/alteration` (knobs) + `POST /api/ring/advance` (altitude).
|
||||
- Audio assets (5 sourced ambiences) sat gitignored under `simulator/sample_media/audio/<scale>/`,
|
||||
unreferenced by the manifest, unserved; no `<audio>` element anywhere; video muted.
|
||||
|
||||
## Session arc (turn by turn)
|
||||
|
||||
1. **Session gate → writing-plans.** Classified the two-part "write the plan, then
|
||||
build it" prompt as the build track's entry; claimed session **0020** (`--type
|
||||
writing-plans`, careful posture). Baseline clean; created branch `session-0020`.
|
||||
2. **Scoping.** Read both specs + the audio candidate pool; dispatched an Explore
|
||||
agent to map the current simulator. Key finding: the control-surface architecture
|
||||
is unbuilt, so the audio spec's §6 server contract had no host. **Resolved by the
|
||||
standing simulator-first directive:** build the audio feature into the current
|
||||
single-page sim (ships sound now); reframe the control-surface spec on paper only;
|
||||
do NOT build the SSE/`/remote`/renderer split (it's "v1 hardware," deferred). This
|
||||
build is the "Content step" the audio spec §11 anticipates.
|
||||
3. **Plan.** Wrote `docs/superpowers/plans/2026-06-26-audio-video-separated-controls.md`
|
||||
(10 tasks, all testing tiers). Committed + pushed; presented at the careful-posture
|
||||
plan gate. Operator answered **"Approve — build it."** §12 decisions resolved:
|
||||
audio=live (~0.6 s crossfade), noise=pink, autoplay=tap-to-start overlay; asset
|
||||
params −18 LUFS / mp3 / loop mirrors the proven video crossfade recipe.
|
||||
4. **Build (TDD, commit per task):**
|
||||
- T1–2: pure ffmpeg audio builders (`tools/pipeline/audio_ops.py`) + runner
|
||||
(`audio_run.py`) + `simulator/build_audio_media.py`; produced the 5 normalized
|
||||
loops + pink-noise bed (gitignored).
|
||||
- T3: `Scale.audio` manifest field + read/emit/serialize + `SCALE_AUDIO`; manifest
|
||||
regenerated (clean diff — only 5 audio lines).
|
||||
- T4–6: pure `player/audio.py` resolver; retired `player/content.py` (+ test);
|
||||
reframed `player/controls.py` + `player/state.py`; `/api/alteration` validates
|
||||
visual+audio and returns `render.video` + server-resolved `render.audio.url` from
|
||||
`altitude_index`. Full Python suite green.
|
||||
- T7–8: client — Content `<select>` → Visual toggle + Audio selector; A/B `<audio>`
|
||||
gain-crossfade layer; tap-to-start gesture. Smoke-tested page + media (200s).
|
||||
- T9: reframed the networked-control-surface spec §3/§4/§5/§6; recorded §12
|
||||
resolutions; ROADMAP note.
|
||||
- T10: Playwright E2E (`[e2e]` extra). Installed Chromium and ran it for real —
|
||||
surfaced + fixed three client issues (see Cut state). **3 E2E pass**, full suite
|
||||
**288 passed, 2 skipped**.
|
||||
5. **Verify + ship.** Screenshotted the running app (cosmos plays, soundtrack audibly
|
||||
playing `cosmos/pillars.loop.mp3`, split controls visible, zero JS errors). Merged
|
||||
`session-0020` → `main` (`--no-ff`) and pushed. (`gh` PR create denied — Gitea host;
|
||||
merged locally with branch→merge discipline.)
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_No low-confidence autonomous calls. The scope decision (current-sim vs full control
|
||||
surface) was approved at the plan gate; the three §12 resolutions were operator-
|
||||
delegated by the launch prompt ("resolve the three flagged decisions"); asset params
|
||||
(−18 LUFS, mp3, pink) follow the spec's stated direction._
|
||||
|
||||
## Cut state
|
||||
|
||||
- `main` @ `aa76cd1` (merge of session-0020), clean, synced with origin.
|
||||
- **288 tests + 3 Playwright E2E pass; 2 skipped** (opt-in media integration).
|
||||
- Three client robustness fixes the E2E drove out (worth remembering):
|
||||
- `fadeVolume` uses `setInterval`, not `requestAnimationFrame` (rAF stalls when the
|
||||
tab is backgrounded → the gain ramp hung in headless).
|
||||
- The start gesture drops per-element "priming": a single page click grants Chrome
|
||||
autoplay document-wide; priming srcless `<audio>` only hung the handler / polluted
|
||||
the paused-state.
|
||||
- `applyAudio` does not `await` `play()` (awaiting a srcless/buffering play can hang).
|
||||
- Plan archival: this app has **no content repo** (`CONTENT_REMOTE` empty), so the plan
|
||||
was not archived to a `plans/` collection — it lives in-repo at
|
||||
`docs/superpowers/plans/` and is already on `main`.
|
||||
- §9 pipeline: localhost + E2E stage is **green**; there is no PPE/prod infra for this
|
||||
simulator-first art app (deferred with the Pi renderer), so no further ship stage ran.
|
||||
|
||||
## Operator plate (what's true now / next)
|
||||
|
||||
- The simulator has **sound**: per-altitude soundtracks that follow the Altitude dial +
|
||||
a pink white-noise bed, behind orthogonal **Visual** × **Audio** controls. The unheard
|
||||
audio wants a by-ear review/tune.
|
||||
- Deferred (already roadmap-tracked, not re-parked in memory): the **music** layer
|
||||
(reserved Audio position, no assets), the networked-control-surface SSE/`/remote`
|
||||
build, i2v ring transitions, the Pi renderer.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal review/tune the new audio by ear in the sim (per-altitude soundtracks + pink white-noise + the ~0.6s A/B crossfade; tap-to-start), then source/compose the deferred per-altitude music layer (the reserved Audio position), per docs/audio-candidate-pool.md
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
# Session 0021.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T08-12 (PST)
|
||||
> End: 2026-06-26T08-20 (PST)
|
||||
> Type: executing-plans (operator-driven hotfix/revision of session 0020's audio feature)
|
||||
> Posture: yolo
|
||||
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> Show video unchecked doesn't turn off the video. Make "show video" just "Video"
|
||||
> and have a toggle like "Dev Mode" has. I can't hear any audio when "soundtrack
|
||||
> (follows altitude)" is selected. Let's just have Audio as off/on like Video for
|
||||
> now and get rid of white noise.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Session 0020 had just merged the audio feature to `main` (Visual×Audio split,
|
||||
per-altitude soundtracks, pink white-noise, A/B crossfade). The operator tested it
|
||||
live and reported three issues.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Diagnosis (the key work).** Both reported bugs were reproduced *only* in the
|
||||
operator's real browser — neither appeared in headless Chromium **or** WebKit
|
||||
(installed `playwright install webkit` to test Safari's engine). Root causes:
|
||||
- **Video-off doesn't blank:** `#vid` (`<video>`) and `#paint` (WebGL `<canvas>`)
|
||||
are GPU-composited layers; the `#black` cover had *auto* z-index, so on a real
|
||||
GPU (Safari/Chrome) the video layer can composite *above* it despite DOM order.
|
||||
Headless software-raster hides this.
|
||||
- **No soundtrack audio:** real Safari/iOS unlocks an `<audio>` element only when
|
||||
`play()` is called *synchronously inside* the user gesture; the 0020 gesture
|
||||
played in an async continuation (after `await fetch`), which Chrome allows but
|
||||
Safari blocks. Headless WebKit relaxes autoplay, so it passed.
|
||||
This is the session's main lesson: **Playwright headless relaxes both GPU
|
||||
compositing and autoplay**, so the 0020 E2E couldn't have caught either — real
|
||||
device / by-eye review is required.
|
||||
2. **Fixes (branch `session-0021`):**
|
||||
- **Video** + **Audio** are now Dev-Mode-style on/off toggles (`.dev-switch`),
|
||||
wired on `change`. Audio on → soundtrack; **white-noise deferred** (removed from
|
||||
the live control; `AUDIO_SOURCES={off,soundtrack}`; the pure pink-noise
|
||||
machinery + tests stay for later).
|
||||
- Video-off: `.black` gets `z-index:50` + `translateZ(0)` (own layer) **and** the
|
||||
video layers are set `opacity:0` on video-off (belt-and-suspenders blanking).
|
||||
- Safari audio: the tap-to-start gesture now primes both A/B `<audio>` elements
|
||||
**synchronously in-gesture** (set src, vol 0, `play().then(pause)`), unlocking
|
||||
the later async crossfades.
|
||||
- Server: `player/audio.py` resolver + `controls.py`/`state.py` + `/api/alteration`
|
||||
drop white-noise; `build_audio_media.py` stops emitting the noise bed.
|
||||
3. **Verify + ship.** Updated unit/contract/E2E tests; rewrote the 3 E2E for the new
|
||||
toggles (click the visible `.dev-switch-track`; wait for the opacity transition).
|
||||
**288 passed, 2 skipped.** Re-verified in **both Chromium and WebKit**: Audio toggle
|
||||
plays `cosmos/pillars.loop.mp3`, video-off sets vid+paint opacity 0 with `#black`
|
||||
shown, zero JS errors; screenshotted the new toggle UI. Merged `session-0021` →
|
||||
`main` and pushed. (Also untracked a stray `docs/.threads/*.json` editor artifact
|
||||
swept in by `git add -A`, and gitignored `.threads/`.)
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_No low-confidence calls. All three changes were explicit operator requests; the
|
||||
root-cause fixes (GPU z-index/layer-hide; Safari in-gesture unlock) are the standard
|
||||
remedies for these well-known browser behaviors._
|
||||
|
||||
## Cut state
|
||||
|
||||
- `main` @ `fddb6d6` (merge of session-0021), clean, pushed.
|
||||
- **288 tests + 3 Playwright E2E pass** (2 skipped: opt-in media integration).
|
||||
- White-noise is deferred, not deleted: `tools/pipeline/audio_ops.white_noise_args` +
|
||||
`audio_run.generate_white_noise` + their tests remain; only the live control + the
|
||||
build-script emission were removed.
|
||||
- **Unvalidated by this session:** real-device audio (Safari autoplay) and real-GPU
|
||||
video-off — headless can't exercise either; needs the operator's by-ear/by-eye pass.
|
||||
|
||||
## Operator plate
|
||||
|
||||
- Video + Audio are simple on/off toggles (Dev-Mode style). Audio on = the current
|
||||
altitude's soundtrack. The two real-browser bugs should now be fixed — but please
|
||||
confirm on a real device (especially iPad/Safari).
|
||||
- Deferred: white-noise (easy to re-add as a 3rd Audio position), the per-altitude
|
||||
music layer, the networked-control-surface build, Pi renderer.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal by-ear/by-eye review the audio + Video/Audio toggles on a REAL device (esp. iPad/Safari — headless can't validate Safari autoplay or GPU compositing), then source/compose the deferred per-altitude music layer per docs/audio-candidate-pool.md (or re-enable white-noise as a 3rd Audio position if wanted)
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
# Session 0022.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T08-40 (PST)
|
||||
> End: 2026-06-26T08-50 (PST)
|
||||
> Type: executing-plans (operator-driven follow-up to the audio feature)
|
||||
> Posture: yolo
|
||||
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> (1) "It shouldn't say 'Tap to Start Sound'… it can start with both sound and video
|
||||
> off but immediately go to the app. We should have a 'Loading Universe…' loading
|
||||
> screen until all media has been downloaded and the experience is ready to run."
|
||||
> (2) "I still can't hear the sound, even in private mode."
|
||||
> (3) "The first time video is turned on, also turn on audio. If audio is turned on
|
||||
> before video after the app loads, just audio should turn on."
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Sessions 0020/0021 had shipped the audio feature + a 0021 "Safari fix" (tap-to-start
|
||||
overlay that primed A/B `<audio>` elements). The operator reported audio STILL
|
||||
silent on their real browser (even in private mode), plus the UX requests above.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Why the 0021 fix failed.** It primed *srcless* A/B elements in the tap gesture
|
||||
and played the real audio in an *async continuation* (after `await fetch`). Safari
|
||||
unlocks an element only when `play()` runs **synchronously inside** the gesture and
|
||||
on an element **with a src** — so neither condition was met. Headless WebKit relaxes
|
||||
autoplay, which is why automated tests never caught it.
|
||||
2. **Redesign (client-only, branch `session-0022`):**
|
||||
- **Single `<audio id="aud">`** element (dropped A/B). It's played **synchronously
|
||||
inside the toggle's own `change` handler** — the canonical Safari unlock. Altitude
|
||||
changes reuse the now-unlocked element with a brief fade-swap.
|
||||
- **"Loading Universe…" splash** (`#loading`, in the HTML so it shows pre-JS): `main()`
|
||||
now `await`s `preloadAllMedia()` (driving a progress bar), then `hideLoading()`.
|
||||
Replaces the tap-to-start wall.
|
||||
- **Both toggles default OFF** (black + silent at start) — no autoplay needed until a
|
||||
toggle is flipped.
|
||||
- **First Video-on couples Audio:** the Video `change` handler, on the first on,
|
||||
sets `#audio` checked and plays — in the same gesture (so it unlocks on Safari).
|
||||
Audio toggled on first stays audio-only.
|
||||
- Server contract unchanged (`{off,soundtrack}`); the client now drives playback
|
||||
directly from the ring's `scale.audio`, ignoring `render.audio`.
|
||||
3. **Verify + ship.** Rewrote the 5 E2E for the new flow (single `#aud`, no gesture,
|
||||
loading wait, the coupling, video-off blanking). Caught a test bug — `#black.hidden`
|
||||
is `display:none`, so `wait_for_selector` default (visible) timed out; switched to
|
||||
`state="attached"`. **290 passed, 2 skipped; 5 E2E pass.** Verified in headless
|
||||
**Chromium AND WebKit**: first-Video-on couples audio, soundtrack plays, video shows,
|
||||
zero JS errors; screenshotted the splash + running app. Merged to `main`.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_No low-confidence calls — all three were explicit operator requests. The
|
||||
single-element synchronous-in-gesture play is the standard Safari remedy._
|
||||
|
||||
## Cut state
|
||||
|
||||
- `main` @ `4ac39c9`, clean, pushed. 290 tests + 5 Playwright E2E pass.
|
||||
- **Honest caveat — still unverifiable headlessly:** every engine Playwright ships
|
||||
(Chromium, WebKit) relaxes autoplay, so I cannot *prove* real-Safari/iOS now plays.
|
||||
The fix is the canonical pattern (sync `play()` in the gesture, real src) and is a
|
||||
genuine change from 0021's broken approach — but it needs the operator's real-device ear.
|
||||
|
||||
## Operator plate
|
||||
|
||||
- No tap wall: "Loading Universe…" → app with both toggles off (black/silent). Flip
|
||||
**Video** → video + audio together; flip **Audio** alone → audio only.
|
||||
- If sound is STILL silent on the real device, that points to something beyond autoplay
|
||||
(codec/MIME, file, or element error) — next session should capture `aud.error` and the
|
||||
exact browser.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal confirm on a REAL device (esp. iPad/Safari) that audio is now audible when Video/Audio is toggled on and the "Loading Universe…" → app flow feels right; if still silent, capture aud.error + the exact browser. Then the deferred per-altitude music layer per docs/audio-candidate-pool.md
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
# Session 0023.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T09-05 (PST)
|
||||
> End: 2026-06-26T09-20 (PST)
|
||||
> Type: executing-plans (operator-driven debugging — "still can't hear" / "video didn't come on")
|
||||
> Posture: yolo
|
||||
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt (the thread)
|
||||
|
||||
> "I still can't hear" → (after diagnostics) operator screenshot: `audio: on, PAUSED
|
||||
> (readyState 0)` and "the audio check works" with `ring=server · url=NONE` → "the
|
||||
> video didn't come on when I turned it on" → after the fix: **"yes, both"** (video
|
||||
> and audio confirmed working).
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Sessions 0020–0022 had shipped the audio/video feature + three "Safari autoplay"
|
||||
fixes that were verified only in headless browsers and **did not work** for the
|
||||
operator. Audio was silent; this session set out to find the real cause.
|
||||
|
||||
## Session arc — the debugging
|
||||
|
||||
1. **Stop guessing; instrument.** Three blind fixes had failed because headless
|
||||
Playwright (Chromium *and* WebKit) relaxes autoplay, so the bug was never
|
||||
reproducible. Added a **live `#audio-status` readout** (surfacing the previously
|
||||
*swallowed* `play()` errors + `aud.error`) and a **native `<audio controls>` test
|
||||
player** that plays the file with zero app code involved. Confirmed the server
|
||||
already honors HTTP Range (`206`) + `audio/mpeg` — not a serving problem.
|
||||
2. **The decisive readout.** Operator reported: the **native player works** (file +
|
||||
codec + browser audio all fine), but the toggle showed
|
||||
**`ring=server · url=NONE`** with `readyState 0` and no media error — meaning my
|
||||
code never set a source because `soundtrackUrl()` returned null.
|
||||
3. **Root cause = a STALE uvicorn server.** The operator's Python process predated the
|
||||
0020 audio/video work, so it (a) returns `/api/ring` without the per-scale `audio`
|
||||
field, and (b) requires the old 7-way `content` — it **422s** the new
|
||||
`{visual,audio}` payload and returns `{content:{video}}` instead of
|
||||
`{render:{video:{shown}}}`. So audio had no url AND the Video toggle's POST failed
|
||||
(→ "video didn't come on"). Reloading never fixes it (only refreshes static assets,
|
||||
not the Python process).
|
||||
4. **Fix = make the client resilient to a stale server** (so no restart is needed):
|
||||
- `soundtrackUrl()` falls back to a scale-id→file map (`SCALE_AUDIO_FALLBACK`,
|
||||
mirrors `build_pool_manifest.SCALE_AUDIO`).
|
||||
- `controls()` also sends a back-compat `content` field (current server ignores it;
|
||||
an old one ignores visual/audio).
|
||||
- `update()` reads "video shown" from **either** response shape.
|
||||
Verified end-to-end against a **simulated fully-stale server** (routed old
|
||||
`/api/alteration` shape + stripped ring audio) in Chromium **and** WebKit: Video on
|
||||
→ video shows + soundtrack plays, zero errors. **Operator confirmed "yes, both".**
|
||||
5. **Cleanup.** Tucked the audio diagnostics (status readout + native player) under the
|
||||
**Dev Mode** panel — kept for future debugging, out of the installation UI.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_No low-confidence calls. One judgment worth noting: I added permanent client
|
||||
back-compat for a pre-release internal API to unblock the operator without a server
|
||||
restart. The clean long-term fix is restarting the dev server; the shim is harmless and
|
||||
removes the dependency. Flagging in case a future cleanup wants to drop it once servers
|
||||
are reliably current._
|
||||
|
||||
## Cut state
|
||||
|
||||
- `main` @ `6427ab4`, clean, pushed. **292 tests + 7 Playwright E2E pass** (2 skipped).
|
||||
- Audio + Video experience **works and is operator-confirmed** on the real device.
|
||||
- New E2E regressions: stale-ring soundtrack fallback, audio-then-video ordering.
|
||||
- The operator's actual server is still stale — recommended (not required) they restart
|
||||
it so the API is current; the client no longer depends on it.
|
||||
|
||||
## Operator plate / lesson
|
||||
|
||||
- **The big lesson (recorded in memory):** headless Playwright relaxes BOTH autoplay and
|
||||
GPU compositing, so it cannot reproduce real-browser/real-server issues. When a fix
|
||||
"passes headless" but the operator still sees the bug, **instrument with on-screen
|
||||
readouts + a native control** to get their real data — don't ship another blind guess.
|
||||
Three rounds were lost to this before instrumenting.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal source/compose the deferred per-altitude music layer (the reserved Audio dial position) per docs/audio-candidate-pool.md — the Audio+Video experience is working & operator-confirmed; OR re-enable white-noise as a 3rd Audio position if wanted
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
# Session 0024.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T21-12 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: e0aea412-c853-4236-9536-cfd85cbc4975
|
||||
> End: 2026-06-27T15-23 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
>
|
||||
> Started as writing-plans; transitioned to planning-and-executing (yolo) when the
|
||||
> operator approved the plan and asked to execute in-session.
|
||||
|
||||
## Next /goal
|
||||
|
||||
Build **scrub-driven altitude transitions** per the approved design at
|
||||
`docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md`:
|
||||
knob position continuously scrubs the morph `currentTime` + crossfades adjacent
|
||||
scale audio (hold-partway, reversible, re-roll on fresh approach, wheel auto-scrubs
|
||||
one altitude). Phase 1: interaction on current morphs. Phase 2: re-bake all 154
|
||||
morphs all-intra for smooth seeking + re-push LFS. Writing-plans → executing-plans
|
||||
in a fresh session. (Operator was handed a ready-to-paste prompt.)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> Fix altitude/video transition: video shouldn't switch when changing altitudes;
|
||||
> good zoom transition for every video combination; video must not change after
|
||||
> zooming to a new altitude.
|
||||
|
||||
Operator refinements during orientation:
|
||||
- **Lock per altitude until the altitude is changed** — on arrival, pick the
|
||||
clip once and keep it; no secondary swap, no re-roll while parked.
|
||||
- **Choose the destination clip BEFORE transitioning, then play the appropriate
|
||||
morph footage.** We need morph footage for ALL transitions between ALL videos
|
||||
in adjacent altitudes (both directions).
|
||||
|
||||
## Plan
|
||||
|
||||
Anchor: leaf change extending player `design.md §3` (scale navigation & zoom
|
||||
transitions) + content-pipeline. **Scope reality:** clip-pair-keyed morph system
|
||||
— **154 morph clips** (77 adjacent video pairs × 2 directions) up from 5 per-edge
|
||||
morphs; touches the bake pipeline (`build_pool_manifest.py`), the ring contract
|
||||
(`player/ring.py`), manifest/transition schema (`simulator/clips.py`), the
|
||||
`/api/ring/advance` endpoint (`simulator/app.py`), the frontend `advance()`/lock
|
||||
(`simulator/static/app.js`), and a new Playwright E2E tier. Writing-plans session:
|
||||
author ONE plan, stop at the review gate.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- The dial-needle **sweep** (synced to the morph) is verified working in Chromium +
|
||||
WebKit both directions, but the operator's real Safari still showed it "not
|
||||
working" — root cause likely stale cached `app.js` (Safari refresh confusion),
|
||||
never fully confirmed. Moot: the **scrub-driven redesign supersedes** the dial
|
||||
interaction entirely. The sweep commit merged to main as an interim improvement.
|
||||
- **git-LFS front-proxy upload limit** (HTTP 413 on the 68 MB base): resolved by
|
||||
transcoding rather than raising the server limit — the operator couldn't locate
|
||||
the front-proxy VM (`35.238.203.16`, nginx, not in any gcloud project I could
|
||||
reach) this session. Raising that limit is a deferred infra task.
|
||||
|
||||
## Arc
|
||||
|
||||
**Pre-state.** Altitude dial (PR #18, s0016) navigated a 5-scale ring with **5
|
||||
per-edge** morphs (scale-primary→primary). Landing picked a random pool member, but
|
||||
`advance()` played the edge morph (ending on the primary) then did a jarring
|
||||
fade-to-black **secondary swap** to the random member. Operator: "video switches when
|
||||
changing altitudes; must not change after zooming."
|
||||
|
||||
**What we built (writing-plans → executing, planning-and-executing).**
|
||||
1. Orientation + clarification: lock-per-altitude; **pick destination first then play
|
||||
the matching morph**; morphs for **all adjacent video pairs both directions** (154);
|
||||
multi-detent **chains** through each altitude.
|
||||
2. Wrote + reviewed the plan (`docs/superpowers/plans/2026-06-27-altitude-clip-pair-morph-lock.md`);
|
||||
operator approved + asked to execute in-session → switched to yolo.
|
||||
3. Executed TDD: per-clip-pair manifest + member-pair baking; `Transition`/`morph_for`/
|
||||
`resolve_move` in `player/ring.py`; `resolved_move_to_dict`; `/api/ring/advance`
|
||||
`from_clip_id`; frontend pick→morph→lock (no secondary swap); regenerated 154
|
||||
morphs. pytest 302 passed; new **Playwright E2E tier** 2 passed.
|
||||
4. **Media graduation:** operator flagged that "sample" media is actually the
|
||||
experience — committed bases + audio + 154 morphs (~526 MB) via **git-LFS → Gitea
|
||||
LFS store**. Hit HTTP 413 on the 68 MB `coast_birdrock` base at the front proxy;
|
||||
**transcoded it 68→22 MB** (re-baked its 16 morphs) to ship. Pushed; merged to main.
|
||||
5. Polish loop on the **dial/transition timing** (sweep → removed live drag pre-move),
|
||||
then operator **reframed**: knob position should *drive* the transition (scrub),
|
||||
not the dial follow the video. Brainstormed + wrote the **scrub-driven design spec**;
|
||||
handed off a prompt to build it in a fresh session.
|
||||
|
||||
**Recurring lesson re-confirmed:** the operator's mid-session "no transitions" was a
|
||||
**stale uvicorn** (served new `app.js` from disk but old in-memory API returning the
|
||||
deleted old-named morph files → 404). Fix = restart uvicorn with the venv python.
|
||||
|
||||
**Cut state.** `main` (528fb5c) has the shipped feature, LFS media, E2E tier, both the
|
||||
plan and the scrub design spec. No content repo → plan archived in-repo under `docs/`
|
||||
(gap flagged). Simulator is localhost-only (no PPE/prod) → no §9 deploy stage; verified
|
||||
green locally. Next increment (scrub) deferred to a fresh session.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Session 0025.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-27T06-05 (PST)
|
||||
> End: 2026-06-27T07-05 (PST)
|
||||
> Type: capture
|
||||
> Posture: careful
|
||||
> Claude-Session: d6ed1203-92b6-471f-80a4-4c773b645ca1
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
capture this as an issue. We'll implement the network feature later but want it as an Epic, then the remote simulator (e.g. ipad) as a feature with this design
|
||||
```
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Immediately followed session 0019 (brainstorming), which merged the
|
||||
**networked control surface** design SPEC to `main`
|
||||
(`docs/superpowers/specs/2026-06-26-networked-control-surface-design.md`).
|
||||
- Operator wants the work captured as backlog (deferred — "implement later"): an
|
||||
**Epic** for the whole networked feature, and a **Feature** for the iPad remote
|
||||
twin anchored to that spec.
|
||||
- Single-repo app: tracker = `benstull/human-experience-filter-art` on
|
||||
git.benstull.org; no separate content repo (`CONTENT_REMOTE` empty).
|
||||
- Checkout shared with concurrent sessions (0024 live on its own branch).
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Routing.** Classified the request as **capture**; invoked `wgl-capture`,
|
||||
claimed tracked-lite session **0025** (capture / careful).
|
||||
|
||||
2. **Target resolve.** Single-repo app → drafted into this repo's working-tree
|
||||
`issues/` (ephemeral, INV-8); tracker is this repo on git.benstull.org.
|
||||
|
||||
3. **Two asks identified** (per handbook §4.3 sizing): an **Epic** (multi-Feature
|
||||
umbrella — the networked control surface) and a **Feature** (single increment —
|
||||
the iPad web remote twin, the spec's v1 scope). Both type-fit cleanly.
|
||||
|
||||
4. **Drafted both** in the content repo's `issues/` at business altitude (Epic +
|
||||
Feature business sections free of implementation specifics; the design spec
|
||||
referenced as orientation). Presented for review; operator approved filing both
|
||||
at **P2** via `AskUserQuestion`.
|
||||
|
||||
5. **Filing.** Label-ensure first hit a **401** — the default issue-token service
|
||||
resolved to the wrong host. Fixed by pointing `WGL_CAPTURE_TOKEN_SERVICE` at the
|
||||
host-specific Keychain entry `wgl-gitea-issues-readwrite-token-git.benstull.org`.
|
||||
Then ensured capture labels (9 created) and filed:
|
||||
- **Epic #26** — "Networked control surface — drive the experience from a
|
||||
separate control device" (`type/epic`, P2).
|
||||
- **Feature #27** — "Remote controller simulator — drive the experience from an
|
||||
iPad web twin over wifi" (`type/feature`, P2; parent #26).
|
||||
(Typecheck advisory labels 401'd — fail-open, skipped; no effect on the issues.)
|
||||
|
||||
6. **Cleanup.** Deleted both filed drafts (Gitea = system of record, INV-8);
|
||||
removed the now-empty `issues/` dir; working tree carries no drafts.
|
||||
|
||||
## Cut state
|
||||
|
||||
- Filed: **Epic #26**, **Feature #27** (parent #26), both `priority/P2`, deferred.
|
||||
- No repo commits this session (capture is tracked-lite; drafts ephemeral). The
|
||||
dirty tree on branch `session-0024` is the **concurrent session's** work
|
||||
(`build_pool_manifest.py` + a test) — left untouched.
|
||||
- Memory updated: `networked-control-surface-spec.md` records the filed issues +
|
||||
a deferred "when resumed" pointer; `MEMORY.md` index refreshed.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — no autonomous low-confidence calls. Type assignment and P2 priority were
|
||||
operator-approved in-session._
|
||||
|
||||
## Type-fit tally
|
||||
|
||||
`type-fit: judged 2, mismatch 0, overridden 0` (judged against §4.3 informally;
|
||||
the typecheck-label markers could not be stamped — advisory token 401, fail-open).
|
||||
|
||||
## Operator plate
|
||||
|
||||
- The networked-control work is now backlog: **#26** (Epic) / **#27** (Feature),
|
||||
deferred per "implement later".
|
||||
- Token note for future capture on this host: use
|
||||
`WGL_CAPTURE_TOKEN_SERVICE=wgl-gitea-issues-readwrite-token-git.benstull.org`
|
||||
(the unscoped default resolves to the wrong host and 401s).
|
||||
|
||||
## Next /goal
|
||||
|
||||
No new immediate next from this capture — the work is parked on the tracker
|
||||
(#26/#27, deferred). When the operator resumes hardware:
|
||||
|
||||
```
|
||||
/goal Write the implementation plan for the remote controller simulator, per docs/superpowers/specs/2026-06-26-networked-control-surface-design.md (Feature #27)
|
||||
```
|
||||
|
||||
The active project frontier remains the per-altitude music layer (see the
|
||||
`audio-soundtrack-sourcing` memory), not this deferred thread.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Session 0026.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-27T15-26 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: c6b9bebd-f94a-4fc0-982d-0a9dc00b3148
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> End: 2026-06-27T18-48 (PST)
|
||||
> Status: **FINALIZED** — merged to main (PR #28) and published at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Build scrub-driven altitude transitions in the simulator, per the approved design at
|
||||
`docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md`. Write the
|
||||
implementation plan from that spec, review it, then execute it. Make the Altitude knob
|
||||
position continuously drive the transition: dragging the dial scrubs the morph video's
|
||||
currentTime and crossfades the two adjacent scale soundtracks by knob angle; hold-partway
|
||||
(no auto-complete), fully reversible, re-roll the destination clip on each fresh approach,
|
||||
scroll-wheel auto-scrubs one altitude then locks, lock-per-altitude preserved. Phase 1:
|
||||
build against current morphs. Phase 2: re-bake all 154 morphs all-intra for smooth seeking,
|
||||
re-push via git-LFS. Unit tests for pure scrub logic + extend Playwright E2E.
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: design docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md (R2a — ELIGIBLE)
|
||||
> Session type: planning-and-executing (fused) — write the plan, then execute it, ship via §9.
|
||||
|
||||
Scrub-driven altitude transitions. Continuous `pos` (float) drives morph `currentTime` +
|
||||
two-soundtrack audio crossfade by `frac(pos)`; integer crossing commits + locks + re-rolls.
|
||||
Phase 1 = interaction against current morphs; Phase 2 = all-intra re-bake (154 morphs, LFS).
|
||||
Pure scrub logic kept separable + unit-tested; Playwright E2E extended to assert currentTime
|
||||
+ audio gains track the dial and reverse on turn-back.
|
||||
|
||||
### Progress (checkpoint @ Phase-1 done)
|
||||
|
||||
- Plan: `docs/superpowers/plans/2026-06-27-scrub-driven-altitude-transitions.md` (approved "execute as-is").
|
||||
- **Phase 1 DONE** (branch `session-0026`, 5 commits):
|
||||
- T1 `simulator/static/scrub.js` (pure UMD math) + `simulator/unit/scrub.test.js` (9 `node --test`).
|
||||
- T2 two-element audio crossfade (`<audio id=aud-b>`, `blendAudio`/`restAudio`/`ensurePlaying`/`assignElements`).
|
||||
- T3 scrub engine: drag live-drives `pos`; `setPos` seeks morph currentTime (1 seek/rAF), crossfades audio,
|
||||
commits+locks+re-rolls on integer crossings; release holds (no auto-complete); client-side `pickPoolClip`.
|
||||
- T4 wheel + label-tap → `autoScrub` (animate `pos`, land exact, lock); removed dead `advance()`/`playTransition()`.
|
||||
- T5 E2E scrub tier (6 tests green): drag tracks currentTime+gains, turn-back reverses same file + re-locks start,
|
||||
detent-crossing commits+locks (via `window.__hefState` seam), wheel auto-scrubs+locks.
|
||||
- **Verification:** `node --test` 9 green · `pytest` 302 passed / 2 skipped · Playwright 6 green.
|
||||
- **Phase 2 DONE** (T7–T8): `transition_cmd`/`reverse_cmd` builders + `ALL_INTRA` (`-g 1 -keyint_min 1
|
||||
-sc_threshold 0`), unit-tested; re-baked all 154 morphs all-intra (verified 93/93 I-frames on a sample),
|
||||
173M→504M, largest clip ~5.8M; committed via git-LFS and pushed (154 LFS objects, 453MB).
|
||||
- **All 8 plan tasks complete. Branch `session-0026` pushed.** Final verification: `node --test` 9 green ·
|
||||
Playwright 6 green · `pytest` 302 passed/2 skipped.
|
||||
- **Remaining:** merge to main + §9. Operator by-eye "feel/smoothness liked?" review is the one acceptance
|
||||
criterion automated tests can't cover (simulator-first directive) — flagged for operator before/at merge.
|
||||
- **DECISION (operator): HOLD for eyeball first.** Branch `session-0026` stays UNMERGED; session NOT finalized.
|
||||
Operator pulls + runs the sim locally to judge the scrub feel/smoothness, then says merge (or requests tweaks).
|
||||
|
||||
### Eyeball round 1 — operator found 2 issues, both FIXED (2 more commits)
|
||||
|
||||
- **Fix 1 — landing jitter (frontend).** Landing jump-cut the loop back to frame 0. Each morph reproduces the
|
||||
dst clip's first 3s (`trim=0:3`), ending dst@~3s, so steady-state loop now starts at `LOOP_TAIL_S=3.0` and
|
||||
loops `[3.0, duration]` (`ensureClipMedia` custom loop; disarmed during morph scrub). Morph's last frame hands
|
||||
straight off to the loop. E2E asserts a landed clip loops from ~3s, not 0. No re-bake. (8th E2E test added.)
|
||||
- **Fix 2 — red-snapper text card (content).** `reef_snapper` opened with a black NOAA title card (~0.9–2.9s).
|
||||
Re-trimmed `reef_snapper/base.mp4` to drop the first 3.0s (23s→20s, footage clean from 3.0s) and re-baked the
|
||||
7 reef_snapper morph pairs (+reverses, all-intra) from the clean base; verified snapper morph frames text-free.
|
||||
- **Verification:** Playwright 7 green · all-intra re-confirmed (93/93 I-frames). Pushed to `session-0026`.
|
||||
- **D2 (deferred) — reef_snapper tracked test-label.** The trim shifts the footage, so reef_snapper's one
|
||||
loop-normalized tracked label (`appear 0.0`/`disappear 0.7` + 3-keyframe track) is now slightly misaligned to
|
||||
the subject. It's demo/test material gated behind both knobs up; left as-is — re-author via `/author.html` if
|
||||
the operator wants it pixel-accurate.
|
||||
### Eyeball round 2 — 2 more items, both FIXED (2 commits)
|
||||
|
||||
- **Fix 3 — morph→loop load jolt (double-buffer).** A pause/jolt when the morph ended and the next altitude's
|
||||
loop loaded: swapping `vid.src` to the base + seeking the tail reloaded the ON-SCREEN element (decode+seek
|
||||
stall). Added a dedicated `#vid-loop` element for the steady-state base loop; during a scrub it is PRELOADED to
|
||||
the clip we're heading toward (`loadLoop`, paused at the tail frame). The paint shader reads the active source
|
||||
(`displayVid() = busy ? vid : loopVid`), so landing is an instant source swap — no reload, no seek. E2E asserts
|
||||
the loop element is armed + playing from ~3s after landing.
|
||||
- **Fix 4 — audio on/off → 0–10 level dial.** Replaced the Audio checkbox with a 0–10 range slider = master gain
|
||||
(`audioVol = level/10`), scaling rest volume + the two-track scrub crossfade. First Video-on lifts audio to
|
||||
**3/10** (was full). E2E: range 0–10 default 0; first video-on → 3.
|
||||
- **Verification:** Playwright **9 green** · node unit 9 · screenshot-confirmed the dial layout.
|
||||
- **D3 residual still applies:** double-buffer kills the LOAD pause both directions; the reverse/turn-back FRAME
|
||||
asymmetry (morph src-start@0 vs loop tail@3s) and the ~loop-wrap seek remain (per-clip loop files would fully
|
||||
resolve if wanted).
|
||||
### Eyeball round 3 — audio-off bug (systematic-debugging), FIXED (1 commit)
|
||||
|
||||
- **Bug:** audio turned off *sometimes* after landing on a new altitude. **Root cause (code-traced, not guessed):**
|
||||
`fadeVolume` started a `setInterval` ramp that was **never cancelled**. `restAudio` fades the idle element to 0;
|
||||
if within FADE_MS (500ms) that same element becomes the newly-landed ACTIVE scale (`assignElements` reuses one
|
||||
element per soundtrack url), the stale fade-to-0 kept running and dragged the now-active element to silence —
|
||||
hence "only sometimes." **Fix:** `cancelFade(el)` supersedes any in-flight fade when a new fade starts
|
||||
(`fadeVolume`) or a direct volume is set (`ensurePlaying`). Added `window.__hefFade` seam + a deterministic E2E
|
||||
that PROVES it (fails with the fix disabled, passes with it). Playwright **10 green**.
|
||||
### Eyeball round 4 — transition speed (1 commit)
|
||||
|
||||
- **Request:** slow click/auto transitions ~2x and make them the SAME speed between any two altitudes (2 away =
|
||||
2× as long as 1). `autoScrub` was a fixed 600ms total regardless of distance. Now duration is PROPORTIONAL:
|
||||
`PER_ALTITUDE_MS=1200` (≈2× the old 600) × |distance|, so a label-click/wheel jump of N altitudes takes
|
||||
N×1200ms — constant per-step speed. Manual drag unaffected. E2E asserts a 2-step jump ≈ 2× a 1-step. 11 green.
|
||||
- **Resume:** merge + `wgl-session-finalize` on operator go, OR next eyeball round of tweaks.
|
||||
|
||||
## 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._
|
||||
|
||||
- **D1 (plan) — canonical segment morph file.** The spec's component 2 says resolve the
|
||||
directed morph via `morphByPair["<fromClip>→<toClip>"]`, but its turn-back decision says
|
||||
"scrubs the SAME morph in reverse" with full reversibility. These are only mutually
|
||||
consistent if a segment uses ONE file scrubbed bidirectionally. I locked the plan to:
|
||||
per segment `[lo, lo+1]`, always use the **descend/forward** morph
|
||||
`morphByPair["<clip@lo>→<clip@lo+1>"]` and drive `currentTime = frac × duration` in both
|
||||
travel directions; the `.rev` files become unused by the scrub interaction (kept baked
|
||||
for back-compat). This means the old E2E "zoom-out plays a `.rev`" assertion is replaced
|
||||
by a "turn-back seeks the same file backward" assertion. Low-confidence on whether the
|
||||
operator specifically wanted `.rev` retained in the drag path.
|
||||
- **D2 — reef_snapper tracked test-label misalignment after the text-card trim.** Trimming the snapper base by
|
||||
3.0s (to remove the title card) shifts the footage, so reef_snapper's one loop-normalized tracked label
|
||||
(`appear 0.0`/`disappear 0.7` + a 3-keyframe track) no longer follows the same subject. Left as-is: it's
|
||||
demo/test authoring gated behind both knobs up; re-author via `/author.html` if pixel-accuracy is wanted.
|
||||
- **D3 — landing-jitter fix approach.** Chose a frontend loop-from-tail (`LOOP_TAIL_S=3.0`, custom
|
||||
`[3.0, duration]` loop) over re-baking morphs or baking dedicated loop clips — cheap, no media churn,
|
||||
eye-tweakable. Residual: a one-seek hitch at each loop wrap (~every 17–20s) and scrub-START (vs landing) is
|
||||
not made seamless (a looping clip is at arbitrary phase when a drag begins). If the wrap hitch bothers the
|
||||
operator, bake per-clip loop files (`base[3:end]`) for native seamless looping.
|
||||
|
||||
## Closeout
|
||||
|
||||
**Shipped.** Branch `session-0026` (14 commits) → **PR #28 merged to `main`** (Gitea
|
||||
merge commit `108e620`, branch deleted). Working tree clean on `main`.
|
||||
|
||||
**Arc of the session:** brainstorming was pre-done (approved design existed) → claimed
|
||||
0026 (planning-and-executing, yolo) → wrote the implementation plan (reviewed: operator
|
||||
chose "execute as-is") → executed all 8 plan tasks (Phase 1 scrub interaction + Phase 2
|
||||
all-intra re-bake) → operator held for by-eye review → **4 eyeball rounds** of fixes
|
||||
(landing frame, reef_snapper text card, video double-buffer, audio dial + fade-race +
|
||||
transition speed; one bug solved via systematic-debugging with a fails-without-the-fix
|
||||
regression test) → operator said merge → merged + finalized.
|
||||
|
||||
**§9 pipeline status:** localhost + E2E green (11 Playwright + 9 node unit + 302 pytest).
|
||||
This app is **simulator-first with no PPE/prod deploy infra** (runs locally; hardware/Pi
|
||||
deferred per the standing directive) — no deploy stage to run; the merge is the ship for
|
||||
a locally-run simulator. (Plan archive skipped: app has **no content repo** in `app.json`;
|
||||
the plan stays in-repo at `docs/superpowers/plans/2026-06-27-scrub-driven-altitude-transitions.md`.)
|
||||
|
||||
**Deferred decisions** (also in the section above): D1 canonical-forward-morph-per-segment
|
||||
(scrub same file bidirectionally); D2 reef_snapper tracked test-label misaligned by the trim;
|
||||
D3 reverse/turn-back frame asymmetry + loop-wrap seek (per-clip loop files would fully fix).
|
||||
|
||||
**Next-session prompt (operator's call — nothing outstanding blocks):**
|
||||
```
|
||||
/wgl-planning-and-executing Next HEF polish: pick one of —
|
||||
(a) bake per-clip loop files base[3:end] for native seamless looping (fixes the D3
|
||||
reverse-landing frame jump + the ~17–20s loop-wrap seek), OR
|
||||
(b) continue by-eye scrub/audio feel tweaks, OR
|
||||
(c) re-author the reef_snapper tracked label for the trimmed footage (/author.html), OR
|
||||
(d) register a content repo in app.json so plans/specs archive at finalize.
|
||||
```
|
||||
@@ -28,5 +28,53 @@
|
||||
},
|
||||
"0010": {
|
||||
"title": ""
|
||||
},
|
||||
"0011": {
|
||||
"title": ""
|
||||
},
|
||||
"0012": {
|
||||
"title": ""
|
||||
},
|
||||
"0013": {
|
||||
"title": ""
|
||||
},
|
||||
"0014": {
|
||||
"title": ""
|
||||
},
|
||||
"0015": {
|
||||
"title": ""
|
||||
},
|
||||
"0016": {
|
||||
"title": ""
|
||||
},
|
||||
"0017": {
|
||||
"title": ""
|
||||
},
|
||||
"0018": {
|
||||
"title": ""
|
||||
},
|
||||
"0019": {
|
||||
"title": ""
|
||||
},
|
||||
"0020": {
|
||||
"title": ""
|
||||
},
|
||||
"0021": {
|
||||
"title": ""
|
||||
},
|
||||
"0022": {
|
||||
"title": ""
|
||||
},
|
||||
"0023": {
|
||||
"title": ""
|
||||
},
|
||||
"0024": {
|
||||
"title": ""
|
||||
},
|
||||
"0025": {
|
||||
"title": ""
|
||||
},
|
||||
"0026": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
+250
-10
@@ -7,6 +7,11 @@ endpoints (/api/select, /api/catalog/meta) and the X-ray are retired.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -21,17 +26,77 @@ from player.alteration import (
|
||||
plan_alteration,
|
||||
render_plan_to_dict,
|
||||
)
|
||||
from player.content import resolve_content
|
||||
from player.controls import CONTENT_POSITIONS
|
||||
from simulator.clips import load_manifest
|
||||
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS, resolve_audio, resolve_visual
|
||||
from player.ring import (
|
||||
DEFAULT_FAST_SPIN_THRESHOLD,
|
||||
ResolvedMove,
|
||||
advance_ring,
|
||||
pick_clip_id,
|
||||
resolve_move,
|
||||
scale_at,
|
||||
)
|
||||
from simulator.clips import (
|
||||
load_manifest,
|
||||
load_ring,
|
||||
resolved_move_to_dict,
|
||||
ring_to_dict,
|
||||
)
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
MEDIA_DIR = Path(__file__).parent / "sample_media"
|
||||
DEFAULT_MANIFEST = MEDIA_DIR / "manifest.json"
|
||||
|
||||
# Per-process boot token: changes on every server restart so the dev live-reload
|
||||
# (below) also fires when Python code changes (which needs a restart), not just
|
||||
# when a static asset's mtime changes.
|
||||
_BOOT_ID = f"{os.getpid()}-{int(time.time())}"
|
||||
|
||||
|
||||
def _asset_version() -> str:
|
||||
"""A short digest of the renderer assets' mtimes + this process's boot token.
|
||||
Changes whenever app.js/style.css/index.html change on disk OR the server is
|
||||
restarted — the signal the browser polls to know it's running stale code."""
|
||||
parts = [_BOOT_ID]
|
||||
for name in ("app.js", "style.css", "index.html"):
|
||||
try:
|
||||
parts.append(f"{name}:{(STATIC_DIR / name).stat().st_mtime_ns}")
|
||||
except OSError:
|
||||
parts.append(f"{name}:?")
|
||||
return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
# Content-hash tokens for served media, so the client can append `?v=<hash>` to a
|
||||
# /media URL. A clip re-baked under the SAME path (e.g. a re-sourced cosmos base)
|
||||
# changes its hash → its URL → a fresh fetch, busting any cached prior bytes
|
||||
# permanently (even an immutable-pinned entry a plain reload can't revalidate).
|
||||
# Cached by (mtime_ns, size) so the full-file hash is recomputed only when the
|
||||
# file actually changes — a re-bake is picked up without a server restart.
|
||||
_media_hash_cache: dict[str, tuple[int, int, str]] = {}
|
||||
|
||||
|
||||
def _media_version(rel: str) -> Optional[str]:
|
||||
"""Short content hash of the media file at `rel` under MEDIA_DIR, or None if
|
||||
it's absent. Cheap on repeat calls: re-hashes only when (mtime, size) change."""
|
||||
path = MEDIA_DIR / rel
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
cached = _media_hash_cache.get(rel)
|
||||
if cached and cached[0] == st.st_mtime_ns and cached[1] == st.st_size:
|
||||
return cached[2]
|
||||
h = hashlib.sha1()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
token = h.hexdigest()[:12]
|
||||
_media_hash_cache[rel] = (st.st_mtime_ns, st.st_size, token)
|
||||
return token
|
||||
|
||||
|
||||
class ControlsModel(BaseModel):
|
||||
content: str
|
||||
visual: str
|
||||
audio: str
|
||||
left: int = Field(ge=0, le=4)
|
||||
right: int = Field(ge=0, le=4)
|
||||
dark: int = Field(ge=0, le=4)
|
||||
@@ -43,14 +108,44 @@ class ControlsModel(BaseModel):
|
||||
class CalibrationModel(BaseModel):
|
||||
mood_gain: float = 1.0
|
||||
overlay_gain: float = 1.0
|
||||
right_variant_map: list[int] = [0, 1, 2, 3, 4]
|
||||
dream_gain: float = 1.0
|
||||
|
||||
|
||||
class AlterationRequest(BaseModel):
|
||||
controls: ControlsModel
|
||||
altitude_index: int = 0
|
||||
calibration: Optional[CalibrationModel] = None
|
||||
|
||||
|
||||
class RingAdvanceRequest(BaseModel):
|
||||
from_index: int = 0
|
||||
delta: int
|
||||
from_clip_id: str = ""
|
||||
|
||||
|
||||
class AuthorTrackRequest(BaseModel):
|
||||
"""Author mode: propagate a hand-seeded box on a clip into a keyframed track
|
||||
(content-pipeline §11.5). `seed_box` + `seed_t` are normalized."""
|
||||
|
||||
clip_id: str
|
||||
key: str
|
||||
seed_box: list[float] = Field(min_length=4, max_length=4)
|
||||
seed_t: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
salience: int = Field(default=4, ge=1, le=4)
|
||||
n_keyframes: int = Field(default=5, ge=2, le=20)
|
||||
max_frames: Optional[int] = None
|
||||
|
||||
|
||||
class AuthorClipRequest(BaseModel):
|
||||
"""Author mode: persist the authored labels/affect/strings for an existing
|
||||
pool clip into the manifest (geometry from the tracker, semantics by hand)."""
|
||||
|
||||
clip_id: str
|
||||
annotations: list = []
|
||||
affect: list = []
|
||||
strings: dict = {}
|
||||
|
||||
|
||||
def _load_clips(manifest_path: Optional[Path]):
|
||||
path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
|
||||
if path.exists():
|
||||
@@ -58,36 +153,181 @@ 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.manifest_path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
|
||||
app.state.clips = _load_clips(manifest_path)
|
||||
app.state.ring = _load_ring(manifest_path)
|
||||
|
||||
@app.post("/api/alteration")
|
||||
def api_alteration(req: AlterationRequest):
|
||||
c = req.controls
|
||||
if c.content not in CONTENT_POSITIONS:
|
||||
raise HTTPException(status_code=422, detail=f"invalid content {c.content!r}")
|
||||
if c.visual not in VISUAL_POSITIONS:
|
||||
raise HTTPException(status_code=422, detail=f"invalid visual {c.visual!r}")
|
||||
if c.audio not in AUDIO_SOURCES:
|
||||
raise HTTPException(status_code=422, detail=f"invalid audio {c.audio!r}")
|
||||
coord = Coordinate(c.left, c.right, c.dark, c.light)
|
||||
cal = (
|
||||
Calibration(
|
||||
mood_gain=req.calibration.mood_gain,
|
||||
overlay_gain=req.calibration.overlay_gain,
|
||||
right_variant_map=tuple(req.calibration.right_variant_map),
|
||||
dream_gain=req.calibration.dream_gain,
|
||||
)
|
||||
if req.calibration
|
||||
else DEFAULT_CALIBRATION
|
||||
)
|
||||
plan = plan_alteration(coord, cal)
|
||||
content = resolve_content(c.content)
|
||||
# Resolve the soundtrack url against the CURRENT altitude (server-side, audio
|
||||
# spec §6.1). White-noise/off ignore the scale; soundtrack follows it.
|
||||
scale_audio = ""
|
||||
if app.state.ring is not None:
|
||||
scale_audio = scale_at(app.state.ring, req.altitude_index).audio
|
||||
audio = resolve_audio(c.audio, scale_audio=scale_audio)
|
||||
return {
|
||||
"plan": render_plan_to_dict(plan),
|
||||
"content": {"audio_source": content.audio_source, "video": content.video},
|
||||
"render": {
|
||||
"video": {"shown": resolve_visual(c.visual)},
|
||||
"audio": {
|
||||
"source": audio.source,
|
||||
"url": audio.url,
|
||||
"altitude_coupled": audio.altitude_coupled,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@app.get("/dev/version")
|
||||
def dev_version():
|
||||
# Dev live-reload signal: the browser polls this and reloads when it
|
||||
# changes, so it never silently runs a stale renderer during iteration.
|
||||
return {"version": _asset_version()}
|
||||
|
||||
@app.get("/api/clips")
|
||||
def api_clips():
|
||||
return {"clips": [c.to_dict() for c in app.state.clips]}
|
||||
|
||||
@app.get("/api/media-versions")
|
||||
def api_media_versions():
|
||||
"""Per-file content-hash tokens the client appends to /media URLs as
|
||||
`?v=<hash>`. Covers every served file: each clip's base footage plus every
|
||||
ring transition morph (both directions are explicit entries). A re-baked
|
||||
clip's hash changes, so its URL changes and the browser refetches — a
|
||||
permanent cache-bust."""
|
||||
files = {c.base_file for c in app.state.clips}
|
||||
if app.state.ring is not None:
|
||||
for t in app.state.ring.transitions:
|
||||
files.add(t.file)
|
||||
versions = {}
|
||||
for f in sorted(files):
|
||||
v = _media_version(f)
|
||||
if v:
|
||||
versions[f] = v
|
||||
return {"versions": versions}
|
||||
|
||||
@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,
|
||||
)
|
||||
if move.steps:
|
||||
# Choose the destination member of each crossed altitude FIRST (one
|
||||
# random draw per step — content-pipeline §11.1), then resolve the
|
||||
# morph from the prior clip to it, so the footage matches what we land
|
||||
# on. The currently-shown clip threads in as `from_clip_id`.
|
||||
src = req.from_clip_id or scale_at(app.state.ring, move.from_index).clip_id
|
||||
picks = tuple(random.random() for _ in move.steps)
|
||||
resolved = resolve_move(app.state.ring, move, src, picks)
|
||||
else:
|
||||
# A no-op move (delta=0) is the initial / re-roll pick: a fresh random
|
||||
# member of the current scale, no morph.
|
||||
landed = scale_at(app.state.ring, move.to_index)
|
||||
resolved = ResolvedMove(steps=(), target_clip_id=pick_clip_id(landed, random.random()))
|
||||
return resolved_move_to_dict(move, resolved)
|
||||
|
||||
@app.post("/api/author/track")
|
||||
def api_author_track(req: AuthorTrackRequest):
|
||||
# Author mode (§11.5): run the classical optical-flow tracker on a clip's
|
||||
# base footage, returning the keyframed track geometry for the author to
|
||||
# accept/correct. Semantics (key/strings/tiers) stay the author's.
|
||||
clip = next((c for c in app.state.clips if c.id == req.clip_id), None)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown clip {req.clip_id!r}")
|
||||
base = MEDIA_DIR / clip.base_file
|
||||
if not base.exists():
|
||||
raise HTTPException(status_code=404, detail=f"media absent: {clip.base_file}")
|
||||
try:
|
||||
import cv2
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=503, detail="opencv-python not installed")
|
||||
from tools.pipeline.track import track_seed
|
||||
|
||||
cap = cv2.VideoCapture(str(base))
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
||||
cap.release()
|
||||
seed_frame = max(0, min(total - 1, round(req.seed_t * total))) if total else 0
|
||||
return track_seed(
|
||||
base, req.key, tuple(req.seed_box), seed_frame=seed_frame,
|
||||
salience=req.salience, n_keyframes=req.n_keyframes, max_frames=req.max_frames,
|
||||
)
|
||||
|
||||
@app.post("/api/author/clip")
|
||||
def api_author_clip(req: AuthorClipRequest):
|
||||
# Author mode (§11.5 / stage 6): persist authored labels/affect/strings for
|
||||
# an EXISTING pool clip — idempotent upsert that keeps the clip's media +
|
||||
# provenance and replaces only the authored content. Reloads in place so
|
||||
# the preview reflects the edit without a restart.
|
||||
from tools.pipeline.manifest import upsert_clip
|
||||
|
||||
path = app.state.manifest_path
|
||||
data = json.loads(path.read_text())
|
||||
existing = next((c for c in data.get("clips", []) if c["id"] == req.clip_id), None)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown clip {req.clip_id!r}")
|
||||
merged = dict(existing)
|
||||
merged["annotations"] = req.annotations
|
||||
merged["affect"] = req.affect
|
||||
if req.strings:
|
||||
merged["strings"] = req.strings
|
||||
data = upsert_clip(data, merged)
|
||||
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
||||
app.state.clips = load_manifest(path)
|
||||
app.state.ring = load_ring(path)
|
||||
return {"ok": True, "clip": merged}
|
||||
|
||||
@app.middleware("http")
|
||||
async def _cache_policy(request, call_next):
|
||||
# Dev preview server: never let a browser serve a stale app.js/style.css —
|
||||
# by-eye iteration needs a plain refresh to always get the latest assets.
|
||||
# Media uses `no-cache` (store, but REVALIDATE every load): instant altitude
|
||||
# swaps already come from the in-memory blob preload (static/app.js), so the
|
||||
# HTTP cache only matters on (re)load — where a conditional request returns a
|
||||
# cheap 304 when unchanged, yet picks up a re-sourced/re-cropped clip
|
||||
# immediately. `immutable` was wrong here: it pinned stale footage for a year
|
||||
# so edited clips never showed without a manual cache clear.
|
||||
response = await call_next(request)
|
||||
if request.url.path.startswith("/media/"):
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
return response
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Production pass for the 5 per-altitude soundtracks (audio spec §5.3). The audio
|
||||
analogue of build_pool_manifest.py --media: read the sourced ambience clips
|
||||
(re-downloadable per docs/audio-candidate-pool.md), and make each a seamless
|
||||
loudness-normalized loop.
|
||||
|
||||
Media is gitignored; this script is the reproducible record. Outputs land under
|
||||
simulator/sample_media/audio/<scale>/<name>.loop.mp3, served at /media/audio/...
|
||||
Run: python simulator/build_audio_media.py
|
||||
|
||||
(White-noise is deferred — the live Audio control is a simple on/off soundtrack
|
||||
toggle. `tools.pipeline.audio_run.generate_white_noise` remains for when it's
|
||||
wanted, but no noise bed is built here.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tools.pipeline.audio_run import process_soundtrack
|
||||
|
||||
AUDIO = Path(__file__).parent / "sample_media" / "audio"
|
||||
|
||||
# scale -> (sourced raw file, production output file), both under audio/<scale>/.
|
||||
# Sources per docs/audio-candidate-pool.md (the "✓ KEEP" picks).
|
||||
SOURCES: dict[str, tuple[str, str]] = {
|
||||
"cosmos": ("pillars.mp3", "pillars.loop.mp3"),
|
||||
"orbit": ("spaceamb.mp3", "spaceamb.loop.mp3"),
|
||||
"coast": ("waves.mp3", "waves.loop.mp3"),
|
||||
"reef": ("soundscape.mp3", "soundscape.loop.mp3"),
|
||||
"abyss": ("whale.wav", "whale.loop.mp3"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for scale, (raw, out) in SOURCES.items():
|
||||
src = AUDIO / scale / raw
|
||||
if not src.exists():
|
||||
print(f" SKIP {scale}: source missing ({src}) — re-source per audio-candidate-pool.md")
|
||||
continue
|
||||
dst = process_soundtrack(src, AUDIO / scale / out)
|
||||
print(f" produced {dst.relative_to(AUDIO.parent)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,469 @@
|
||||
"""Build the rotating-pool manifest for the 5 ring scales (content-pipeline §11).
|
||||
|
||||
This is the reproducible, hand-authored BASELINE for Increment 2's manifest:
|
||||
each ring scale carries a POOL of vetted clips (docs/content-candidate-pool.md);
|
||||
the player picks one member at random on landing (player.ring.pick_clip_id). The
|
||||
authored label/affect content lives here in readable Python and is emitted to
|
||||
`simulator/sample_media/manifest.json` — far less error-prone than hand-editing 19
|
||||
clips of JSON, and a clean place for the simulator author mode to regenerate from.
|
||||
|
||||
Authoring model (design §11.3 / §11.4):
|
||||
- LEFT detail tiers: each label carries `salience` (1-4; first shows at Left
|
||||
level `5 - salience`) and a tiered string list (general -> specific ->
|
||||
scientific -> +fact) that escalates with the Left knob.
|
||||
- RIGHT emotion tiers: affect words are a tiered list per feeling (basic ->
|
||||
compound) escalating with the Right knob; still gated on min(Left, Right).
|
||||
- TIME-WINDOWED + TRACKED labels: a label may carry `appear`/`disappear`
|
||||
(loop-normalized) and a keyframed `track`; it shows only while on screen and
|
||||
its box follows the subject. The abyss + reef pools are the test material.
|
||||
|
||||
Strings: a value may be a LIST (indexed by tier-1) or a plain string (static,
|
||||
back-compat). Affect appears only when both knobs are up.
|
||||
|
||||
Usage:
|
||||
python simulator/build_pool_manifest.py # write manifest.json
|
||||
python simulator/build_pool_manifest.py --media # + generate new transition placeholders
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MEDIA = Path(__file__).parent / "sample_media"
|
||||
MANIFEST = MEDIA / "manifest.json"
|
||||
|
||||
# --- Pools: scale id -> ordered clip ids (primary first) (content-candidate-pool.md) ---
|
||||
POOLS: dict[str, list[str]] = {
|
||||
"cosmos": ["cosmos", "cosmos_miri", "cosmos_galaxies", "cosmos_hudf", "cosmos_xdf"],
|
||||
"orbit": ["orbit_planetearth", "orbit_crewobs", "orbit_bluemarble"],
|
||||
"coast": ["coast_birdrock", "coast_surfgrass", "coast_elkbeach", "coast_drakesbeach"],
|
||||
"reef": ["reef_lionfish", "reef_spawning", "reef_hawkfish", "reef_snapper", "reef_coralspacific"],
|
||||
"abyss": ["abyss_wow", "abyss_midwaterexp", "abyss_hiding"],
|
||||
}
|
||||
|
||||
# Ring order (large -> small, wraps): cosmos -> orbit -> coast -> reef -> abyss -> cosmos.
|
||||
RING_ORDER = ["cosmos", "orbit", "coast", "reef", "abyss"]
|
||||
|
||||
# Per-scale soundtrack (audio spec §5.1) — the production-pass output of
|
||||
# simulator/build_audio_media.py, served at /media/audio/<path>.
|
||||
SCALE_AUDIO: dict[str, str] = {
|
||||
"cosmos": "cosmos/pillars.loop.mp3",
|
||||
"orbit": "orbit/spaceamb.loop.mp3",
|
||||
"coast": "coast/waves.loop.mp3",
|
||||
"reef": "reef/soundscape.loop.mp3",
|
||||
"abyss": "abyss/whale.loop.mp3",
|
||||
}
|
||||
|
||||
PD = "public-domain (US Gov, 17 U.S.C. §105)"
|
||||
PD_NPS = "public-domain (NPS, no © — 17 U.S.C. §105)"
|
||||
CCBY_STSCI = "CC-BY-class — credit NASA, ESA, STScI"
|
||||
CCBY_WEBB = "CC-BY-class — credit NASA, ESA, CSA, STScI"
|
||||
|
||||
# --- Per-clip provenance: id -> (title, license, source) ---
|
||||
META: dict[str, tuple[str, str, str]] = {
|
||||
# cosmos
|
||||
"cosmos": ("Cosmic Cliffs — Carina Nebula flythrough (NASA/ESA/CSA/STScI)", CCBY_WEBB,
|
||||
"NASA SVS 31348 Clifs-3d-STScI (Exploring the Cosmic Cliffs in 3D, Webb NIRCam); "
|
||||
"4K source, trim 10–34s, crossfade-loop. Text-free — replaces the Orion SVS 30957 flythrough."),
|
||||
"cosmos_miri": ("Cosmic Cliffs — Carina Nebula, mid-infrared (NASA/ESA/CSA/STScI)", CCBY_WEBB,
|
||||
"ESA/Webb weic2205c (Pan of the Carina Nebula, combined NIRCam + MIRI); 4K source, "
|
||||
"trim 4–26s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold "
|
||||
"dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free."),
|
||||
"cosmos_galaxies": ("Flying Through Galaxies (NASA SVS)", PD,
|
||||
"NASA SVS a014950 14950_Galaxies_FlyThrough_4k; trim 8–32s, crossfade-loop"),
|
||||
"cosmos_hudf": ("Hubble Ultra Deep Field zoom (NASA/ESA/STScI)", CCBY_STSCI,
|
||||
"NASA SVS a030687 hudf-b-1920x1080p30; trim 20–44s, crossfade-loop"),
|
||||
"cosmos_xdf": ("eXtreme Deep Field flythrough (NASA/ESA/STScI)", CCBY_STSCI,
|
||||
"NASA SVS a030681 hxdf_fly-b-1920x1080p30; trim 2–26s, crossfade-loop"),
|
||||
# orbit
|
||||
"orbit_planetearth": ("ISS — View of Planet Earth (NASA SVS)", PD,
|
||||
"NASA SVS a030771 ISS_View_of_Planet_Earth_2160p; trim 10–34s, crossfade-loop"),
|
||||
"orbit_crewobs": ("ISS — Crew Earth Observations (NASA SVS)", PD,
|
||||
"NASA SVS a030771 ISS_Crew_Earth_Observations_2160p; trim 9–33s, crossfade-loop"),
|
||||
"orbit_bluemarble": ("NPP “Blue Marble” rotating globe (NASA SVS)", PD,
|
||||
"NASA SVS a004550 BlueMarble_starfield_4k_2160p30_v2; trim 10–34s, crossfade-loop"),
|
||||
# coast
|
||||
"coast_birdrock": ("Bird rock + ocean, aerial (NPS Channel Islands)", PD_NPS,
|
||||
"NPS Channel Islands AV 663a2f13; trim 3–27s, crossfade-loop"),
|
||||
"coast_surfgrass": ("Surfgrass / coralline tidepool (NPS Cabrillo)", PD_NPS,
|
||||
"NPS Cabrillo AV de7d1cf2; trim 2–26s, crossfade-loop"),
|
||||
"coast_elkbeach": ("Elk resting in coastal fog (NPS Redwood)", PD_NPS,
|
||||
"NPS Redwood redw-elkbeach_1280x720; trim 20–38s, crossfade-loop (720p source)"),
|
||||
"coast_drakesbeach": ("Elephant seals on dark sand (NPS Point Reyes)", PD_NPS,
|
||||
"NPS Point Reyes AV 1cfd8165; trim 20–35s, crossfade-loop"),
|
||||
# reef
|
||||
"reef_lionfish": ("Lionfish hovering over reef (NOAA Fisheries)", PD,
|
||||
"NOAA Fisheries b-roll VIDEO_ID 4088881464001; trim 12–36s, crossfade-loop"),
|
||||
"reef_spawning": ("Snapper school over sunlit reef (NOAA Fisheries)", PD,
|
||||
"NOAA Fisheries b-roll VIDEO_ID 1553921798001; trim 50–78s, crossfade-loop"),
|
||||
"reef_hawkfish": ("Humphead parrotfish over reef (NOAA Fisheries)", PD,
|
||||
"NOAA Fisheries b-roll VIDEO_ID 6039896460001; trim 250–274s, crossfade-loop"),
|
||||
"reef_snapper": ("Red snapper schooling (NOAA Fisheries)", PD,
|
||||
"NOAA Fisheries b-roll VIDEO_ID 5305427942001; trim 7–31s, crossfade-loop"),
|
||||
"reef_coralspacific": ("Pacific coral macro (NOAA Fisheries)", PD,
|
||||
"NOAA Fisheries b-roll VIDEO_ID 5231464663001; trim 30–54s, crossfade-loop"),
|
||||
# abyss
|
||||
"abyss_wow": ("“World of Water” (NOAA Ocean Exploration)", PD,
|
||||
"NOAA Ocean Exploration wow-1280x720-1; trim 10–34s, crossfade-loop"),
|
||||
"abyss_midwaterexp": ("“Midwater Exploration” (NOAA Ocean Exploration)", PD,
|
||||
"NOAA Ocean Exploration midwater-exploration-1920x1080-1; trim 16–40s, crossfade-loop"),
|
||||
"abyss_hiding": ("“Hiding in the Dark” (NOAA Ocean Exploration)", PD,
|
||||
"NOAA Ocean Exploration dark-1280x720-1; trim 20–44s, crossfade-loop"),
|
||||
}
|
||||
|
||||
# --- Affect vocabulary per SCALE (shared by all the scale's pool members) -------
|
||||
# Each: (key, [x, y], min_level, [tier1 basic .. tier4 compound]). The word shown
|
||||
# escalates with the RIGHT knob; appears only when both knobs are up (§11.4).
|
||||
AFFECT: dict[str, list[tuple]] = {
|
||||
"cosmos": [
|
||||
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
|
||||
("feel.vastness", [0.20, 0.70], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
|
||||
("feel.insignificance", [0.66, 0.60], 3, ["small", "smallness", "insignificance", "a humbling insignificance"]),
|
||||
("feel.longing", [0.42, 0.84], 4, ["want", "longing", "yearning", "an aching yearning"]),
|
||||
],
|
||||
"orbit": [
|
||||
("feel.serenity", [0.50, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
|
||||
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
|
||||
("feel.unity", [0.66, 0.58], 3, ["one", "unity", "belonging", "a borderless belonging"]),
|
||||
("feel.distance", [0.42, 0.82], 4, ["far", "distance", "remoteness", "an exquisite remoteness"]),
|
||||
],
|
||||
"coast": [
|
||||
("feel.ease", [0.50, 0.44], 1, ["nice", "ease", "calm", "an unhurried calm"]),
|
||||
("feel.nostalgia", [0.22, 0.68], 2, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
|
||||
("feel.belonging", [0.66, 0.58], 3, ["home", "belonging", "rootedness", "a tidal rootedness"]),
|
||||
("feel.melancholy", [0.42, 0.82], 4, ["sad", "melancholy", "longing", "a soft seaward longing"]),
|
||||
],
|
||||
"reef": [
|
||||
("feel.delight", [0.50, 0.44], 1, ["fun", "delight", "joy", "a darting, bright joy"]),
|
||||
("feel.abundance", [0.22, 0.68], 2, ["full", "abundance", "richness", "a teeming richness"]),
|
||||
("feel.curiosity", [0.66, 0.58], 3, ["look", "curiosity", "fascination", "an absorbed fascination"]),
|
||||
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
|
||||
],
|
||||
"abyss": [
|
||||
("feel.unease", [0.50, 0.44], 1, ["uh", "unease", "disquiet", "a creeping disquiet"]),
|
||||
("feel.fascination", [0.22, 0.68], 2, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
|
||||
("feel.isolation", [0.66, 0.58], 3, ["alone", "isolation", "solitude", "a crushing solitude"]),
|
||||
("feel.dread", [0.42, 0.82], 4, ["fear", "dread", "foreboding", "a slow, cold foreboding"]),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def static_label(key, salience, tiers, box):
|
||||
"""A fixed-box tiered label (always on-screen, salience-gated by Left)."""
|
||||
return {"key": key, "salience": salience, "box": box, "_tiers": tiers}
|
||||
|
||||
|
||||
def tracked_label(key, salience, tiers, appear, disappear, track):
|
||||
"""A time-windowed tracked label: shows only in [appear, disappear] and its
|
||||
box follows the subject (§11.2). `track` = [(t, [x,y,w,h]), ...]."""
|
||||
return {
|
||||
"key": key, "salience": salience, "appear": appear, "disappear": disappear,
|
||||
"track": [{"t": t, "box": b} for t, b in track], "_tiers": tiers,
|
||||
}
|
||||
|
||||
|
||||
def measure(key, value, box, min_level):
|
||||
"""A single-tier measurement readout (not a tiered object), gated by Left."""
|
||||
return {"key": key, "box": box, "min_level": min_level, "_tiers": value}
|
||||
|
||||
|
||||
# --- Labels per clip. `_tiers` is lifted into the strings table by build(). ------
|
||||
# Showcase tracked/windowed labels live on the abyss + reef pools (the creatures
|
||||
# drift through frame). Other clips get a few salience-gated static tiered labels.
|
||||
LABELS: dict[str, list[dict]] = {
|
||||
# ---------- cosmos ----------
|
||||
"cosmos": [
|
||||
static_label("detected.nebula", 4, ["cloud", "nebula", "emission nebula", "emission nebula · the Carina star-forming region"], [0.20, 0.42, 0.5, 0.42]),
|
||||
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · a newborn star, <1 Myr old"], [0.54, 0.12, 0.12, 0.14]),
|
||||
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
|
||||
],
|
||||
"cosmos_miri": [
|
||||
static_label("detected.nebula", 4, ["dust", "nebula", "dust ridge", "dust ridge · mid-infrared glow of the Carina cliffs"], [0.10, 0.45, 0.7, 0.45]),
|
||||
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · its dust laid bare in mid-infrared"], [0.48, 0.18, 0.14, 0.16]),
|
||||
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
|
||||
],
|
||||
"cosmos_galaxies": [
|
||||
static_label("detected.galaxy", 4, ["galaxy", "spiral galaxy", "barred spiral", "barred spiral · ~10¹¹ stars"], [0.34, 0.30, 0.30, 0.34]),
|
||||
measure("measure.distance", "~Mly", [0.06, 0.06, 0.18, 0.08], 3),
|
||||
],
|
||||
"cosmos_hudf": [
|
||||
static_label("detected.galaxy", 4, ["smudge", "galaxy", "early galaxy", "early galaxy · z ≈ 1–6, light Gyr-old"], [0.40, 0.36, 0.20, 0.22]),
|
||||
measure("measure.field", "deep field", [0.06, 0.06, 0.2, 0.08], 3),
|
||||
],
|
||||
"cosmos_xdf": [
|
||||
static_label("detected.galaxy", 4, ["dot", "galaxy", "faint galaxy", "faint galaxy · among the earliest seen"], [0.42, 0.38, 0.18, 0.2]),
|
||||
],
|
||||
# ---------- orbit ----------
|
||||
"orbit_planetearth": [
|
||||
static_label("detected.cloud_band", 4, ["clouds", "cloud band", "cumulus field", "cumulus field · tropical convection"], [0.30, 0.30, 0.30, 0.20]),
|
||||
static_label("detected.limb", 2, ["edge", "Earth’s limb", "atmospheric limb", "atmospheric limb · ~100 km of air"], [0.05, 0.70, 0.9, 0.12]),
|
||||
measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3),
|
||||
],
|
||||
"orbit_crewobs": [
|
||||
static_label("detected.coastline", 4, ["land", "coastline", "continental margin", "continental margin · land meets sea"], [0.30, 0.34, 0.34, 0.26]),
|
||||
measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3),
|
||||
],
|
||||
"orbit_bluemarble": [
|
||||
static_label("detected.globe", 4, ["Earth", "the globe", "terrestrial planet", "terrestrial planet · 12,742 km across"], [0.28, 0.18, 0.44, 0.6]),
|
||||
],
|
||||
# ---------- coast ----------
|
||||
"coast_birdrock": [
|
||||
static_label("detected.surf", 4, ["waves", "surf", "breaking swell", "breaking swell · wind-driven, ~10 s period"], [0.20, 0.55, 0.6, 0.3]),
|
||||
static_label("detected.searock", 2, ["rock", "sea stack", "coastal sea stack", "sea stack · wave-cut residual rock"], [0.30, 0.25, 0.22, 0.3]),
|
||||
],
|
||||
"coast_surfgrass": [
|
||||
static_label("detected.surfgrass", 4, ["grass", "surfgrass", "Phyllospadix", "Phyllospadix · a marine seagrass, not algae"], [0.18, 0.40, 0.5, 0.4]),
|
||||
static_label("detected.coralline", 2, ["pink", "coralline algae", "crustose coralline", "crustose coralline · calcified red algae"], [0.6, 0.55, 0.2, 0.2]),
|
||||
],
|
||||
"coast_elkbeach": [
|
||||
static_label("detected.elk", 4, ["elk", "Roosevelt elk", "Cervus canadensis roosevelti", "C. c. roosevelti · largest elk subspecies"], [0.34, 0.42, 0.3, 0.3]),
|
||||
static_label("detected.fog", 2, ["fog", "coastal fog", "advection fog", "advection fog · warm air over cold upwelling"], [0.05, 0.05, 0.9, 0.25]),
|
||||
],
|
||||
"coast_drakesbeach": [
|
||||
static_label("detected.seal", 4, ["seals", "elephant seals", "Mirounga angustirostris", "M. angustirostris · males to 2,000 kg"], [0.25, 0.55, 0.5, 0.3]),
|
||||
static_label("detected.sand", 1, ["sand", "dark sand", "mineral-dark beach", "dark beach · eroded coastal sediment"], [0.05, 0.8, 0.9, 0.15]),
|
||||
],
|
||||
# ---------- reef (showcase: tracked + windowed) ----------
|
||||
"reef_lionfish": [
|
||||
tracked_label(
|
||||
"detected.lionfish", 4,
|
||||
["fish", "lionfish", "Pterois volitans", "Pterois volitans · venomous spines, invasive in the Atlantic"],
|
||||
0.05, 0.95,
|
||||
[(0.05, [0.15, 0.30, 0.16, 0.18]), (0.5, [0.50, 0.38, 0.16, 0.18]), (0.95, [0.72, 0.34, 0.16, 0.18])],
|
||||
),
|
||||
tracked_label(
|
||||
"detected.snapper", 2,
|
||||
["fish", "blue snapper", "Lutjanus", "Lutjanus · schools over reef structure"],
|
||||
0.0, 0.6,
|
||||
[(0.0, [0.05, 0.55, 0.12, 0.1]), (0.3, [0.22, 0.5, 0.12, 0.1]), (0.6, [0.40, 0.56, 0.12, 0.1])],
|
||||
),
|
||||
measure("measure.depth", "−18 m", [0.06, 0.06, 0.16, 0.08], 3),
|
||||
],
|
||||
"reef_spawning": [
|
||||
tracked_label(
|
||||
"detected.school", 4,
|
||||
["fish", "fish school", "snapper aggregation", "snapper aggregation · synchronized spawning run"],
|
||||
0.0, 1.0,
|
||||
[(0.0, [0.20, 0.30, 0.2, 0.18]), (0.5, [0.55, 0.34, 0.2, 0.18]), (1.0, [0.20, 0.30, 0.2, 0.18])],
|
||||
),
|
||||
static_label("detected.coral", 2, ["coral", "reef coral", "scleractinian", "scleractinian · reef-building stony coral"], [0.15, 0.6, 0.35, 0.3]),
|
||||
measure("measure.depth", "−22 m", [0.06, 0.06, 0.16, 0.08], 3),
|
||||
],
|
||||
"reef_hawkfish": [
|
||||
tracked_label(
|
||||
"detected.parrotfish", 4,
|
||||
["fish", "parrotfish", "Bolbometopon muricatum", "B. muricatum · humphead, grazes reef into sand"],
|
||||
0.1, 0.9,
|
||||
[(0.1, [0.55, 0.25, 0.2, 0.22]), (0.5, [0.35, 0.34, 0.2, 0.22]), (0.9, [0.12, 0.40, 0.2, 0.22])],
|
||||
),
|
||||
measure("measure.depth", "−12 m", [0.06, 0.06, 0.16, 0.08], 3),
|
||||
],
|
||||
"reef_snapper": [
|
||||
tracked_label(
|
||||
"detected.snapper", 4,
|
||||
["fish", "red snapper", "Lutjanus campechanus", "L. campechanus · long-lived, can reach 50+ yr"],
|
||||
0.0, 0.7,
|
||||
[(0.0, [0.10, 0.3, 0.16, 0.14]), (0.35, [0.40, 0.36, 0.16, 0.14]), (0.7, [0.68, 0.30, 0.16, 0.14])],
|
||||
),
|
||||
],
|
||||
"reef_coralspacific": [
|
||||
static_label("detected.coral", 4, ["coral", "coral colony", "Pacific scleractinian", "Pacific scleractinian · a symbiosis with algae"], [0.2, 0.4, 0.4, 0.4]),
|
||||
tracked_label(
|
||||
"detected.spotfish", 2,
|
||||
["fish", "spotted fish", "reef damselfish", "reef damselfish · territorial over its coral"],
|
||||
0.2, 0.8,
|
||||
[(0.2, [0.6, 0.25, 0.1, 0.09]), (0.5, [0.45, 0.3, 0.1, 0.09]), (0.8, [0.3, 0.26, 0.1, 0.09])],
|
||||
),
|
||||
],
|
||||
# ---------- abyss (showcase: tracked + windowed; creatures drift through) ----------
|
||||
"abyss_wow": [
|
||||
tracked_label(
|
||||
"detected.jelly", 4,
|
||||
["jelly", "comb jelly", "Ctenophora", "Ctenophora · swims by beating rows of cilia"],
|
||||
0.0, 0.55,
|
||||
[(0.0, [0.05, 0.25, 0.14, 0.18]), (0.3, [0.30, 0.32, 0.14, 0.18]), (0.55, [0.52, 0.28, 0.14, 0.18])],
|
||||
),
|
||||
tracked_label(
|
||||
"detected.siphonophore", 3,
|
||||
["chain", "siphonophore", "Siphonophorae", "Siphonophorae · a colony of clones, can exceed 40 m"],
|
||||
0.45, 1.0,
|
||||
[(0.45, [0.80, 0.6, 0.1, 0.3]), (0.7, [0.6, 0.45, 0.1, 0.34]), (1.0, [0.45, 0.3, 0.1, 0.38])],
|
||||
),
|
||||
measure("measure.depth", "−1,200 m", [0.06, 0.06, 0.16, 0.08], 2),
|
||||
],
|
||||
"abyss_midwaterexp": [
|
||||
tracked_label(
|
||||
"detected.medusa", 4,
|
||||
["jelly", "white medusa", "Scyphozoa", "Scyphozoa · the swimming medusa stage of a jellyfish"],
|
||||
0.0, 0.6,
|
||||
[(0.0, [0.55, 0.20, 0.16, 0.2]), (0.3, [0.40, 0.34, 0.16, 0.2]), (0.6, [0.22, 0.30, 0.16, 0.2])],
|
||||
),
|
||||
tracked_label(
|
||||
"detected.worm", 2,
|
||||
["worm", "red worm", "polychaete", "polychaete · a free-swimming bristle worm"],
|
||||
0.5, 1.0,
|
||||
[(0.5, [0.10, 0.7, 0.12, 0.1]), (0.75, [0.3, 0.6, 0.12, 0.1]), (1.0, [0.5, 0.66, 0.12, 0.1])],
|
||||
),
|
||||
measure("measure.depth", "−1,600 m", [0.06, 0.06, 0.16, 0.08], 2),
|
||||
],
|
||||
"abyss_hiding": [
|
||||
tracked_label(
|
||||
"detected.jelly", 4,
|
||||
["jelly", "crimson jelly", "Scyphozoa", "Scyphozoa · red is invisible in the lightless deep"],
|
||||
0.0, 0.7,
|
||||
[(0.0, [0.15, 0.28, 0.16, 0.2]), (0.35, [0.42, 0.36, 0.16, 0.2]), (0.7, [0.66, 0.30, 0.16, 0.2])],
|
||||
),
|
||||
measure("measure.depth", "−2,140 m", [0.06, 0.06, 0.16, 0.08], 2),
|
||||
],
|
||||
}
|
||||
|
||||
# Human-readable strings for affect/measurement keys are produced from the tiered
|
||||
# data above; this maps measurement keys to nothing extra (their value IS the string).
|
||||
|
||||
|
||||
def _affect_for_clip(scale: str) -> tuple[list, dict]:
|
||||
"""Build the affect list + its strings for a scale's pool member."""
|
||||
entries, strings = [], {}
|
||||
for key, at, min_level, tiers in AFFECT[scale]:
|
||||
entries.append({"key": key, "at": at, "min_level": min_level})
|
||||
strings[key] = tiers
|
||||
return entries, strings
|
||||
|
||||
|
||||
def _labels_for_clip(clip_id: str) -> tuple[list, dict]:
|
||||
"""Build the annotation list + its strings, lifting `_tiers` into the table."""
|
||||
anns, strings = [], {}
|
||||
for raw in LABELS.get(clip_id, []):
|
||||
a = {k: v for k, v in raw.items() if k != "_tiers"}
|
||||
anns.append(a)
|
||||
strings[a["key"]] = raw["_tiers"]
|
||||
return anns, strings
|
||||
|
||||
|
||||
def _clip_entry(scale: str, clip_id: str) -> dict:
|
||||
title, license_, source = META[clip_id]
|
||||
anns, lab_strings = _labels_for_clip(clip_id)
|
||||
affect, aff_strings = _affect_for_clip(scale)
|
||||
return {
|
||||
"id": clip_id,
|
||||
"title": title,
|
||||
"base_file": f"{clip_id}/base.mp4",
|
||||
"license": license_,
|
||||
"source": source,
|
||||
"right_variants": {},
|
||||
"annotations": anns,
|
||||
"affect": affect,
|
||||
"strings": {"en": {**lab_strings, **aff_strings}},
|
||||
}
|
||||
|
||||
|
||||
def build_manifest() -> dict:
|
||||
"""Assemble the full rotating-pool manifest dict."""
|
||||
clips = []
|
||||
for scale in RING_ORDER:
|
||||
for clip_id in POOLS[scale]:
|
||||
clips.append(_clip_entry(scale, clip_id))
|
||||
scales = [
|
||||
{"id": s, "clip_id": POOLS[s][0], "pool": POOLS[s], "audio": SCALE_AUDIO[s]}
|
||||
for s in RING_ORDER
|
||||
]
|
||||
transitions = []
|
||||
for H, L in _adjacent_edges(): # H higher altitude, L lower
|
||||
for h in POOLS[H]:
|
||||
for l in POOLS[L]:
|
||||
transitions.append({"from": h, "to": l,
|
||||
"file": f"transitions/{h}__{l}.mp4", # zoom IN (descend)
|
||||
"model": "placeholder-zoom"})
|
||||
transitions.append({"from": l, "to": h,
|
||||
"file": f"transitions/{h}__{l}.rev.mp4", # zoom OUT (ascend)
|
||||
"model": "placeholder-zoom"})
|
||||
return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}}
|
||||
|
||||
|
||||
# --- Generate the per-edge transition clips (zoom morph + reversed companion) ---
|
||||
|
||||
def _ffmpeg() -> str:
|
||||
if shutil.which("ffmpeg"):
|
||||
return "ffmpeg"
|
||||
import imageio_ffmpeg
|
||||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||
|
||||
|
||||
def _adjacent_edges() -> list[tuple[str, str]]:
|
||||
"""Ordered (higher, lower) altitude edges around the ring, including the wrap."""
|
||||
n = len(RING_ORDER)
|
||||
return [(RING_ORDER[i], RING_ORDER[(i + 1) % n]) for i in range(n)]
|
||||
|
||||
|
||||
# All-intra H.264: every frame a keyframe, so the scrub interaction can seek to an
|
||||
# arbitrary frame smoothly (a sparse GOP scrubs "steppy"). Files grow, but each clip
|
||||
# stays well under the LFS/proxy ceiling. (Scrub-driven-transitions design §4.)
|
||||
ALL_INTRA = ["-c:v", "libx264", "-g", "1", "-keyint_min", "1", "-sc_threshold", "0", "-pix_fmt", "yuv420p"]
|
||||
|
||||
|
||||
def transition_cmd(ff: str, a: str, b: str, out: str) -> list[str]:
|
||||
"""The ffmpeg argv for a forward (zoom-in) member-pair morph, baked all-intra."""
|
||||
norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
|
||||
return [
|
||||
ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
|
||||
f"[0:v]{norm}[a];[1:v]{norm}[b];"
|
||||
"[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]",
|
||||
"-map", "[v]", "-an", *ALL_INTRA, str(out),
|
||||
]
|
||||
|
||||
|
||||
def reverse_cmd(ff: str, forward: str, out: str) -> list[str]:
|
||||
"""The ffmpeg argv for the zoom-OUT companion (forward played backward), all-intra."""
|
||||
return [ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", *ALL_INTRA, str(out)]
|
||||
|
||||
|
||||
def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path:
|
||||
"""A zoom/warp morph between two CLIP MEMBERS' base footage (the well-liked
|
||||
orbit-coast recipe), keyed by clip id so every adjacent member pair gets its
|
||||
own morph. Forward = zoom IN (descend src->dst)."""
|
||||
a = MEDIA / src_clip / "base.mp4"
|
||||
b = MEDIA / dst_clip / "base.mp4"
|
||||
out = MEDIA / "transitions" / f"{src_clip}__{dst_clip}.mp4"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(transition_cmd(ff, str(a), str(b), str(out)), check=True, capture_output=True)
|
||||
return out
|
||||
|
||||
|
||||
def _make_reverse(ff: str, forward: Path) -> Path:
|
||||
"""The zoom-OUT companion of a forward (zoom-in) edge: the same clip played
|
||||
backward, written alongside it as `<edge>.rev.mp4`. An ascending ring move
|
||||
crosses its edge `reversed`; the renderer then plays this file, so zooming out
|
||||
recedes from the current scale back to the higher one instead of zooming in."""
|
||||
out = forward.with_suffix(".rev.mp4")
|
||||
subprocess.run(reverse_cmd(ff, str(forward), str(out)), check=True, capture_output=True)
|
||||
return out
|
||||
|
||||
|
||||
def generate_media() -> None:
|
||||
"""Bake EVERY adjacent member-pair morph from real bases — forward (zoom in,
|
||||
descend) plus its reversed companion (zoom out, ascend), for every pair of
|
||||
clips in adjacent altitudes."""
|
||||
ff = _ffmpeg()
|
||||
for H, L in _adjacent_edges():
|
||||
for h in POOLS[H]:
|
||||
for l in POOLS[L]:
|
||||
fwd = _make_transition(ff, h, l)
|
||||
_make_reverse(ff, fwd)
|
||||
print(f"generated {len(POOLS[H]) * len(POOLS[L])} {H}__{L} morphs (+ .rev) from real bases")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> None:
|
||||
manifest = build_manifest()
|
||||
MANIFEST.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
|
||||
print(f"wrote {MANIFEST} — {len(manifest['clips'])} clips, "
|
||||
f"{len(manifest['ring']['scales'])} ring scales")
|
||||
if "--media" in argv:
|
||||
generate_media()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
@@ -14,6 +14,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from player.ring import ResolvedMove, RingMove, Scale, ScaleRing, Transition
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Clip:
|
||||
@@ -24,6 +26,7 @@ class Clip:
|
||||
source: str
|
||||
right_variants: dict # {"1": {"file": ...}, "4": {...}} (no "0")
|
||||
annotations: list # [{"key", "box":[x,y,w,h], "min_level"}, ...]
|
||||
affect: list # [{"key", "at":[x,y], "min_level"}, ...] (Left x Right)
|
||||
strings: dict # {"en": {key: text}}
|
||||
|
||||
def variant_file(self, strength: int) -> str:
|
||||
@@ -44,6 +47,7 @@ class Clip:
|
||||
"source": self.source,
|
||||
"right_variants": variants,
|
||||
"annotations": self.annotations,
|
||||
"affect": self.affect,
|
||||
"strings": self.strings,
|
||||
}
|
||||
|
||||
@@ -57,6 +61,7 @@ def _clip_from_dict(d: dict[str, Any]) -> Clip:
|
||||
source=d.get("source", ""),
|
||||
right_variants=d.get("right_variants", {}),
|
||||
annotations=d.get("annotations", []),
|
||||
affect=d.get("affect", []),
|
||||
strings=d.get("strings", {}),
|
||||
)
|
||||
|
||||
@@ -66,3 +71,96 @@ 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 _scale_from_dict(s: dict[str, Any]) -> Scale:
|
||||
"""A ring scale node, with its rotating clip pool (content-pipeline §11.1).
|
||||
|
||||
Accepts a `pool` list (the rotating model) and/or a legacy single `clip_id`.
|
||||
The primary `clip_id` is the explicit one if given, else the first pool
|
||||
member; `pool` defaults to `(clip_id,)` for a pre-pool manifest.
|
||||
"""
|
||||
pool = tuple(s.get("pool", []))
|
||||
clip_id = s.get("clip_id") or (pool[0] if pool else "")
|
||||
return Scale(id=s["id"], clip_id=clip_id, pool=pool, audio=s.get("audio", ""))
|
||||
|
||||
|
||||
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_from_dict(s) for s in ring.get("scales", []))
|
||||
transitions = tuple(
|
||||
Transition(
|
||||
from_clip=t.get("from", ""),
|
||||
to_clip=t.get("to", ""),
|
||||
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.
|
||||
|
||||
Each scale carries its full rotating `pool` (clip id + title per member) plus
|
||||
the primary `clip_id`/`title` for back-compat. The client lands on a member
|
||||
chosen at the API boundary (`/api/ring/advance` → `target_clip_id`)."""
|
||||
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),
|
||||
"audio": s.audio,
|
||||
"pool": [
|
||||
{"clip_id": cid, "title": titles.get(cid, cid)}
|
||||
for cid in s.members
|
||||
],
|
||||
}
|
||||
for s in ring.scales
|
||||
],
|
||||
"transitions": [
|
||||
{"from": t.from_clip, "to": t.to_clip, "file": t.file, "model": t.model}
|
||||
for t in ring.transitions
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def resolved_move_to_dict(move: RingMove, resolved: ResolvedMove) -> dict:
|
||||
"""JSON form of a RESOLVED encoder move: where it lands, the locked clip, and
|
||||
the ordered chained morphs. Each step carries the clip it morphs FROM (the
|
||||
clip currently shown), the chosen clip it morphs TO, and the matching morph
|
||||
file (None if no morph was baked — the renderer plain-cuts). `fast` marks a
|
||||
fast spin; each step then carries `blended` so the renderer accelerates it.
|
||||
|
||||
`target_clip_id` is the final clip the viewer locks onto."""
|
||||
return {
|
||||
"from_index": move.from_index,
|
||||
"to_index": move.to_index,
|
||||
"wrapped": move.wrapped,
|
||||
"fast": move.fast,
|
||||
"target_clip_id": resolved.target_clip_id,
|
||||
"steps": [
|
||||
{
|
||||
"from_clip": s.from_clip,
|
||||
"to_clip": s.to_clip,
|
||||
"file": s.file,
|
||||
"to_index": s.to_index,
|
||||
"blended": s.blended,
|
||||
}
|
||||
for s in resolved.steps
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Simulator E2E (Playwright)
|
||||
|
||||
Browser end-to-end tests for the Human Experience Filter simulator. The §9
|
||||
pipeline requires an E2E browser tier for any UI surface; this is that tier.
|
||||
|
||||
## What it covers
|
||||
|
||||
`tests/altitude-lock.spec.ts` drives the real simulator and asserts the
|
||||
altitude-morph behavior:
|
||||
|
||||
- Zooming to a new altitude plays the morph that **matches the clip it lands on**
|
||||
(forward zoom-in, and `.rev` zoom-out).
|
||||
- After landing, the clip is **locked** — it does not change while parked (no
|
||||
secondary swap, no re-roll).
|
||||
|
||||
The played morph paths are read from `window.__hefMorphs` (a diagnostic array
|
||||
populated by `advance()` in `static/app.js`), which is reliable even when a morph
|
||||
is served from the in-memory blob cache. The locked clip is read from the
|
||||
Dev-Mode `#scale-name` readout.
|
||||
|
||||
## Running
|
||||
|
||||
Prerequisites: the project Python venv on PATH (the Playwright `webServer` boots
|
||||
`uvicorn simulator.app:app` from the repo root), Node, and the Chromium browser.
|
||||
|
||||
```bash
|
||||
# from this directory (simulator/e2e)
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
npm test
|
||||
```
|
||||
|
||||
`playwright.config.ts` starts uvicorn on port 8099 automatically (`reuseExistingServer`
|
||||
locally) and points the tests at it.
|
||||
+1
@@ -0,0 +1 @@
|
||||
../@playwright/test/cli.js
|
||||
+1
@@ -0,0 +1 @@
|
||||
../playwright-core/cli.js
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "hef-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Portions Copyright (c) Microsoft Corporation.
|
||||
Portions Copyright 2017 Google Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Playwright
|
||||
Copyright (c) Microsoft Corporation
|
||||
|
||||
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
|
||||
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
# 🎭 Playwright
|
||||
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](https://aka.ms/playwright/discord)
|
||||
|
||||
## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
|
||||
|
||||
Playwright is a framework for web automation and testing. It drives Chromium, Firefox, and WebKit with a single API — in your tests, in your scripts, and as a tool for AI agents.
|
||||
|
||||
## Get Started
|
||||
|
||||
Choose the path that fits your workflow:
|
||||
|
||||
| | Best for | Install |
|
||||
|---|---|---|
|
||||
| **[Playwright Test](#playwright-test)** | End-to-end testing | `npm init playwright@latest` |
|
||||
| **[Playwright CLI](#playwright-cli)** | Coding agents (Claude Code, Copilot) | `npm i -g @playwright/cli@latest` |
|
||||
| **[Playwright MCP](#playwright-mcp)** | AI agents and LLM-driven automation | `npx @playwright/mcp@latest` |
|
||||
| **[Playwright Library](#playwright-library)** | Browser automation scripts | `npm i playwright` |
|
||||
| **[VS Code Extension](#vs-code-extension)** | Test authoring and debugging in VS Code | [Install from Marketplace](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) |
|
||||
|
||||
---
|
||||
|
||||
## Playwright Test
|
||||
|
||||
Playwright Test is a full-featured test runner built for end-to-end testing. It runs tests across Chromium, Firefox, and WebKit with full browser isolation, auto-waiting, and web-first assertions.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
```
|
||||
|
||||
Or add manually:
|
||||
|
||||
```bash
|
||||
npm i -D @playwright/test
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### Write a test
|
||||
|
||||
```TypeScript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('has title', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
await expect(page).toHaveTitle(/Playwright/);
|
||||
});
|
||||
|
||||
test('get started link', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.getByRole('link', { name: 'Get started' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
Tests run in parallel across all configured browsers, in headless mode by default. Each test gets a fresh browser context — full isolation with near-zero overhead.
|
||||
|
||||
### Key capabilities
|
||||
|
||||
**Auto-wait and web-first assertions.** No artificial timeouts. Playwright waits for elements to be actionable, and assertions automatically retry until conditions are met.
|
||||
|
||||
**Locators.** Find elements with resilient locators that mirror how users see the page:
|
||||
|
||||
```TypeScript
|
||||
page.getByRole('button', { name: 'Submit' })
|
||||
page.getByLabel('Email')
|
||||
page.getByPlaceholder('Search...')
|
||||
page.getByTestId('login-form')
|
||||
```
|
||||
|
||||
**Test isolation.** Each test runs in its own browser context — equivalent to a fresh browser profile. Save authentication state once and reuse it across tests:
|
||||
|
||||
```TypeScript
|
||||
// Save state after login
|
||||
await page.context().storageState({ path: 'auth.json' });
|
||||
|
||||
// Reuse in other tests
|
||||
test.use({ storageState: 'auth.json' });
|
||||
```
|
||||
|
||||
**Tracing.** Capture execution traces, screenshots, and videos on failure. Inspect every action, DOM snapshot, network request, and console message in the [Trace Viewer](https://playwright.dev/docs/trace-viewer):
|
||||
|
||||
```TypeScript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
npx playwright show-trace trace.zip
|
||||
```
|
||||
|
||||
<!-- TODO: screenshot of trace viewer -->
|
||||
|
||||
**Parallelism.** Tests run in parallel by default across all configured browsers.
|
||||
|
||||
[Full testing documentation](https://playwright.dev/docs/intro)
|
||||
|
||||
---
|
||||
|
||||
## Playwright CLI
|
||||
|
||||
[Playwright CLI](https://github.com/microsoft/playwright-cli) is a command-line interface for browser automation designed for coding agents. It's more token-efficient than MCP — commands avoid loading large tool schemas and accessibility trees into the model context.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install -g @playwright/cli@latest
|
||||
```
|
||||
|
||||
Optionally install skills for richer agent integration:
|
||||
|
||||
```bash
|
||||
playwright-cli install --skills
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
Point your coding agent at a task:
|
||||
|
||||
```
|
||||
Test the "add todo" flow on https://demo.playwright.dev/todomvc using playwright-cli.
|
||||
Take screenshots for all successful and failing scenarios.
|
||||
```
|
||||
|
||||
Or run commands directly:
|
||||
|
||||
```bash
|
||||
playwright-cli open https://demo.playwright.dev/todomvc/ --headed
|
||||
playwright-cli type "Buy groceries"
|
||||
playwright-cli press Enter
|
||||
playwright-cli screenshot
|
||||
```
|
||||
|
||||
### Session monitoring
|
||||
|
||||
Use `playwright-cli show` to open a visual dashboard with live screencast previews of all running browser sessions. Click any session to zoom in and take remote control.
|
||||
|
||||
```bash
|
||||
playwright-cli show
|
||||
```
|
||||
|
||||
<!-- TODO: screenshot of playwright-cli show dashboard -->
|
||||
|
||||
[Full CLI documentation](https://playwright.dev/agent-cli/introduction) | [GitHub](https://github.com/microsoft/playwright-cli)
|
||||
|
||||
---
|
||||
|
||||
## Playwright MCP
|
||||
|
||||
The [Playwright MCP server](https://github.com/microsoft/playwright-mcp) gives AI agents full browser control through the [Model Context Protocol](https://modelcontextprotocol.io). Agents interact with pages using structured accessibility snapshots — no vision models or screenshots required.
|
||||
|
||||
### Setup
|
||||
|
||||
Add to your MCP client (VS Code, Cursor, Claude Desktop, Windsurf, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**One-click install for VS Code:**
|
||||
|
||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20MCP%20Server&color=0098FF" alt="Install in VS Code" />](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||
|
||||
**For Claude Code:**
|
||||
|
||||
```bash
|
||||
claude mcp add playwright npx @playwright/mcp@latest
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
Ask your AI assistant to interact with any web page:
|
||||
|
||||
```
|
||||
Navigate to https://demo.playwright.dev/todomvc and add a few todo items.
|
||||
```
|
||||
|
||||
The agent sees the page as a structured accessibility tree:
|
||||
|
||||
```
|
||||
- heading "todos" [level=1]
|
||||
- textbox "What needs to be done?" [ref=e5]
|
||||
- listitem:
|
||||
- checkbox "Toggle Todo" [ref=e10]
|
||||
- text: "Buy groceries"
|
||||
```
|
||||
|
||||
It uses element refs like `e5` and `e10` to click, type, and interact — deterministically and without visual ambiguity. Tools cover navigation, form filling, screenshots, network mocking, storage management, and more.
|
||||
|
||||
[Full MCP documentation](https://playwright.dev/mcp/introduction) | [GitHub](https://github.com/microsoft/playwright-mcp)
|
||||
|
||||
---
|
||||
|
||||
## Playwright Library
|
||||
|
||||
Use `playwright` as a library for browser automation scripts — web scraping, PDF generation, screenshot capture, and any workflow that needs programmatic browser control without a test runner.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm i playwright
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**Take a screenshot:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.screenshot({ path: 'screenshot.png' });
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
**Generate a PDF:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.pdf({ path: 'page.pdf', format: 'A4' });
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
**Emulate a mobile device:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium, devices } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext(devices['iPhone 15']);
|
||||
const page = await context.newPage();
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.screenshot({ path: 'mobile.png' });
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
**Intercept network requests:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());
|
||||
await page.goto('https://playwright.dev/');
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
[Library documentation](https://playwright.dev/docs/library) | [API reference](https://playwright.dev/docs/api/class-playwright)
|
||||
|
||||
---
|
||||
|
||||
## VS Code Extension
|
||||
|
||||
The [Playwright VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) brings test running, debugging, and code generation directly into your editor.
|
||||
|
||||
<!-- TODO: hero screenshot of VS Code with Playwright sidebar -->
|
||||
|
||||
**Run and debug tests** from the editor with a single click. Set breakpoints, inspect variables, and step through test execution with a live browser view.
|
||||
|
||||
**Generate tests with CodeGen.** Click "Record new" to open a browser — navigate and interact with your app while Playwright writes the test code for you.
|
||||
|
||||
**Pick locators.** Hover over any element in the browser to see the best available locator, then click to copy it to your clipboard.
|
||||
|
||||
**Trace Viewer integration.** Enable "Show Trace Viewer" in the sidebar to get a full execution trace after each test run — DOM snapshots, network requests, console logs, and screenshots at every step.
|
||||
|
||||
[Install the extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) | [VS Code guide](https://playwright.dev/docs/getting-started-vscode)
|
||||
|
||||
---
|
||||
|
||||
## Cross-Browser Support
|
||||
|
||||
| | Linux | macOS | Windows |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| Chromium<sup>1</sup> <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| WebKit <!-- GEN:webkit-version -->26.5<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| Firefox <!-- GEN:firefox-version -->151.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
|
||||
Headless and headed execution on all platforms. <sup>1</sup> Uses [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing) by default.
|
||||
|
||||
## Other Languages
|
||||
|
||||
Playwright is also available for [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), and [Java](https://playwright.dev/java/docs/intro).
|
||||
|
||||
## Resources
|
||||
|
||||
* [Documentation](https://playwright.dev)
|
||||
* [API reference](https://playwright.dev/docs/api/class-playwright)
|
||||
* [MCP server](https://github.com/microsoft/playwright-mcp)
|
||||
* [CLI for coding agents](https://github.com/microsoft/playwright-cli)
|
||||
* [VS Code extension](https://github.com/microsoft/playwright-vscode)
|
||||
* [Contribution guide](CONTRIBUTING.md)
|
||||
* [Changelog](https://github.com/microsoft/playwright/releases)
|
||||
* [Discord](https://aka.ms/playwright/discord)
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const { program } = require('playwright/lib/program');
|
||||
program.parse(process.argv);
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from 'playwright/test';
|
||||
export { default } from 'playwright/test';
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
module.exports = require('playwright/test');
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from 'playwright/test';
|
||||
export { default } from 'playwright/test';
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@playwright/test",
|
||||
"version": "1.61.1",
|
||||
"description": "A high-level API to automate web browsers",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/microsoft/playwright.git"
|
||||
},
|
||||
"homepage": "https://playwright.dev",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./index.mjs",
|
||||
"require": "./index.js",
|
||||
"default": "./index.js"
|
||||
},
|
||||
"./cli": "./cli.js",
|
||||
"./package.json": "./package.json",
|
||||
"./reporter": "./reporter.js"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from 'playwright/types/testReporter';
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// We only export types in reporter.d.ts.
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// We only export types in reporter.d.ts.
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Portions Copyright (c) Microsoft Corporation.
|
||||
Portions Copyright 2017 Google Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Playwright
|
||||
Copyright (c) Microsoft Corporation
|
||||
|
||||
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
|
||||
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# playwright-core
|
||||
|
||||
This package contains the no-browser flavor of [Playwright](http://github.com/microsoft/playwright).
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
microsoft/playwright-core
|
||||
|
||||
THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
|
||||
|
||||
This package bundles third-party software inside individual files under
|
||||
`lib/`. Each bundled output has a sidecar `<bundle>.js.LICENSE` file next
|
||||
to it listing every npm package whose source was inlined into that
|
||||
bundle, together with the full license text for each.
|
||||
|
||||
For example:
|
||||
- lib/utilsBundle.js.LICENSE
|
||||
|
||||
This project incorporates components from the projects listed below. The original copyright notices and the licenses under which Microsoft received such components are set forth below. Microsoft reserves all rights not expressly granted herein, whether by implication, estoppel or otherwise.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
$osInfo = Get-WmiObject -Class Win32_OperatingSystem
|
||||
# check if running on Windows Server
|
||||
if ($osInfo.ProductType -eq 3) {
|
||||
Install-WindowsFeature Server-Media-Foundation
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# This script sets up a WSL distribution that will be used to run WebKit.
|
||||
|
||||
$Distribution = "playwright"
|
||||
$Username = "pwuser"
|
||||
|
||||
$distributions = (wsl --list --quiet) -split "\r?\n"
|
||||
if ($distributions -contains $Distribution) {
|
||||
Write-Host "WSL distribution '$Distribution' already exists. Skipping installation."
|
||||
} else {
|
||||
Write-Host "Installing new WSL distribution '$Distribution'..."
|
||||
$VhdSize = "10GB"
|
||||
wsl --install -d Ubuntu-24.04 --name $Distribution --no-launch --vhd-size $VhdSize
|
||||
wsl -d $Distribution -u root adduser --gecos GECOS --disabled-password $Username
|
||||
}
|
||||
|
||||
$pwshDirname = (Resolve-Path -Path $PSScriptRoot).Path;
|
||||
$playwrightCoreRoot = Resolve-Path (Join-Path $pwshDirname "..")
|
||||
|
||||
$initScript = @"
|
||||
if [ ! -f "/home/$Username/node/bin/node" ]; then
|
||||
mkdir -p /home/$Username/node
|
||||
curl -fsSL https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz -o /home/$Username/node/node-v22.17.0-linux-x64.tar.xz
|
||||
tar -xJf /home/$Username/node/node-v22.17.0-linux-x64.tar.xz -C /home/$Username/node --strip-components=1
|
||||
sudo -u $Username echo 'export PATH=/home/$Username/node/bin:\`$PATH' >> /home/$Username/.profile
|
||||
fi
|
||||
/home/$Username/node/bin/node cli.js install-deps webkit
|
||||
sudo -u $Username PLAYWRIGHT_SKIP_BROWSER_GC=1 /home/$Username/node/bin/node cli.js install webkit
|
||||
"@ -replace "\r\n", "`n"
|
||||
|
||||
wsl -d $Distribution --cd $playwrightCoreRoot -u root -- bash -c "$initScript"
|
||||
Write-Host "Done!"
|
||||
Generated
Vendored
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old beta if any.
|
||||
if dpkg --get-selections | grep -q "^google-chrome-beta[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y google-chrome-beta
|
||||
fi
|
||||
|
||||
# 2. Update apt lists (needed to install curl and chrome dependencies)
|
||||
apt-get update
|
||||
|
||||
# 3. Install curl to download chrome
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# 4. download chrome beta from dl.google.com and install it.
|
||||
cd /tmp
|
||||
curl -L -O https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb
|
||||
apt-get install -y ./google-chrome-beta_current_amd64.deb
|
||||
rm -rf ./google-chrome-beta_current_amd64.deb
|
||||
cd -
|
||||
google-chrome-beta --version
|
||||
Generated
Vendored
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
rm -rf "/Applications/Google Chrome Beta.app"
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./googlechromebeta.dmg https://dl.google.com/chrome/mac/universal/beta/googlechromebeta.dmg
|
||||
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechromebeta.dmg ./googlechromebeta.dmg
|
||||
cp -pR "/Volumes/googlechromebeta.dmg/Google Chrome Beta.app" /Applications
|
||||
hdiutil detach /Volumes/googlechromebeta.dmg
|
||||
rm -rf /tmp/googlechromebeta.dmg
|
||||
|
||||
/Applications/Google\ Chrome\ Beta.app/Contents/MacOS/Google\ Chrome\ Beta --version
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$url = 'https://dl.google.com/tag/s/dl/chrome/install/beta/googlechromebetastandaloneenterprise64.msi'
|
||||
|
||||
Write-Host "Downloading Google Chrome Beta"
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\google-chrome-beta.msi"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Google Chrome Beta"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
$suffix = "\\Google\\Chrome Beta\\Application\\chrome.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Google Chrome Beta."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
Generated
Vendored
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old stable if any.
|
||||
if dpkg --get-selections | grep -q "^google-chrome[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y google-chrome
|
||||
fi
|
||||
|
||||
# 2. Update apt lists (needed to install curl and chrome dependencies)
|
||||
apt-get update
|
||||
|
||||
# 3. Install curl to download chrome
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# 4. download chrome stable from dl.google.com and install it.
|
||||
cd /tmp
|
||||
curl -L -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
apt-get install -y ./google-chrome-stable_current_amd64.deb
|
||||
rm -rf ./google-chrome-stable_current_amd64.deb
|
||||
cd -
|
||||
google-chrome --version
|
||||
Generated
Vendored
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
rm -rf "/Applications/Google Chrome.app"
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./googlechrome.dmg https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg
|
||||
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechrome.dmg ./googlechrome.dmg
|
||||
cp -pR "/Volumes/googlechrome.dmg/Google Chrome.app" /Applications
|
||||
hdiutil detach /Volumes/googlechrome.dmg
|
||||
rm -rf /tmp/googlechrome.dmg
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$url = 'https://dl.google.com/tag/s/dl/chrome/install/googlechromestandaloneenterprise64.msi'
|
||||
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\google-chrome.msi"
|
||||
Write-Host "Downloading Google Chrome"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Google Chrome"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
|
||||
$suffix = "\\Google\\Chrome\\Application\\chrome.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Google Chrome."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
Generated
Vendored
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old beta if any.
|
||||
if dpkg --get-selections | grep -q "^microsoft-edge-beta[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y microsoft-edge-beta
|
||||
fi
|
||||
|
||||
# 2. Install curl to download Microsoft gpg key
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# GnuPG is not preinstalled in slim images
|
||||
if ! command -v gpg >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y gpg
|
||||
fi
|
||||
|
||||
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
|
||||
rm /tmp/microsoft.gpg
|
||||
apt-get update && apt-get install -y microsoft-edge-beta
|
||||
|
||||
microsoft-edge-beta --version
|
||||
Generated
Vendored
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./msedge_beta.pkg "$1"
|
||||
# Note: there's no way to uninstall previously installed MSEdge.
|
||||
# However, running PKG again seems to update installation.
|
||||
sudo installer -pkg /tmp/msedge_beta.pkg -target /
|
||||
rm -rf /tmp/msedge_beta.pkg
|
||||
/Applications/Microsoft\ Edge\ Beta.app/Contents/MacOS/Microsoft\ Edge\ Beta --version
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$url = $args[0]
|
||||
|
||||
Write-Host "Downloading Microsoft Edge Beta"
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\microsoft-edge-beta.msi"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Microsoft Edge Beta"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
$suffix = "\\Microsoft\\Edge Beta\\Application\\msedge.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Microsoft Edge Beta."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
Generated
Vendored
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old dev if any.
|
||||
if dpkg --get-selections | grep -q "^microsoft-edge-dev[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y microsoft-edge-dev
|
||||
fi
|
||||
|
||||
# 2. Install curl to download Microsoft gpg key
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# GnuPG is not preinstalled in slim images
|
||||
if ! command -v gpg >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y gpg
|
||||
fi
|
||||
|
||||
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
|
||||
rm /tmp/microsoft.gpg
|
||||
apt-get update && apt-get install -y microsoft-edge-dev
|
||||
|
||||
microsoft-edge-dev --version
|
||||
Generated
Vendored
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./msedge_dev.pkg "$1"
|
||||
# Note: there's no way to uninstall previously installed MSEdge.
|
||||
# However, running PKG again seems to update installation.
|
||||
sudo installer -pkg /tmp/msedge_dev.pkg -target /
|
||||
rm -rf /tmp/msedge_dev.pkg
|
||||
/Applications/Microsoft\ Edge\ Dev.app/Contents/MacOS/Microsoft\ Edge\ Dev --version
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$url = $args[0]
|
||||
|
||||
Write-Host "Downloading Microsoft Edge Dev"
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\microsoft-edge-dev.msi"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Microsoft Edge Dev"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
$suffix = "\\Microsoft\\Edge Dev\\Application\\msedge.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Microsoft Edge Dev."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
Generated
Vendored
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old stable if any.
|
||||
if dpkg --get-selections | grep -q "^microsoft-edge-stable[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y microsoft-edge-stable
|
||||
fi
|
||||
|
||||
# 2. Install curl to download Microsoft gpg key
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# GnuPG is not preinstalled in slim images
|
||||
if ! command -v gpg >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y gpg
|
||||
fi
|
||||
|
||||
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-stable.list'
|
||||
rm /tmp/microsoft.gpg
|
||||
apt-get update && apt-get install -y microsoft-edge-stable
|
||||
|
||||
microsoft-edge-stable --version
|
||||
Generated
Vendored
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./msedge_stable.pkg "$1"
|
||||
# Note: there's no way to uninstall previously installed MSEdge.
|
||||
# However, running PKG again seems to update installation.
|
||||
sudo installer -pkg /tmp/msedge_stable.pkg -target /
|
||||
rm -rf /tmp/msedge_stable.pkg
|
||||
/Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --version
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$url = $args[0]
|
||||
|
||||
Write-Host "Downloading Microsoft Edge"
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\microsoft-edge-stable.msi"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Microsoft Edge"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
$suffix = "\\Microsoft\\Edge\\Application\\msedge.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Microsoft Edge."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"comment": "Do not edit this file, use utils/roll_browser.js",
|
||||
"browsers": [
|
||||
{
|
||||
"name": "chromium",
|
||||
"revision": "1228",
|
||||
"installByDefault": true,
|
||||
"browserVersion": "149.0.7827.55",
|
||||
"title": "Chrome for Testing"
|
||||
},
|
||||
{
|
||||
"name": "chromium-headless-shell",
|
||||
"revision": "1228",
|
||||
"installByDefault": true,
|
||||
"browserVersion": "149.0.7827.55",
|
||||
"title": "Chrome Headless Shell"
|
||||
},
|
||||
{
|
||||
"name": "chromium-tip-of-tree",
|
||||
"revision": "1432",
|
||||
"installByDefault": false,
|
||||
"browserVersion": "151.0.7886.0",
|
||||
"title": "Chrome Canary for Testing"
|
||||
},
|
||||
{
|
||||
"name": "chromium-tip-of-tree-headless-shell",
|
||||
"revision": "1432",
|
||||
"installByDefault": false,
|
||||
"browserVersion": "151.0.7886.0",
|
||||
"title": "Chrome Canary Headless Shell"
|
||||
},
|
||||
{
|
||||
"name": "firefox",
|
||||
"revision": "1532",
|
||||
"installByDefault": true,
|
||||
"browserVersion": "151.0",
|
||||
"title": "Firefox"
|
||||
},
|
||||
{
|
||||
"name": "firefox-beta",
|
||||
"revision": "1526",
|
||||
"installByDefault": false,
|
||||
"browserVersion": "152.0b1",
|
||||
"title": "Firefox Beta"
|
||||
},
|
||||
{
|
||||
"name": "webkit",
|
||||
"revision": "2311",
|
||||
"installByDefault": true,
|
||||
"revisionOverrides": {
|
||||
"mac14": "2251",
|
||||
"mac14-arm64": "2251",
|
||||
"debian11-x64": "2105",
|
||||
"debian11-arm64": "2105",
|
||||
"ubuntu20.04-x64": "2092",
|
||||
"ubuntu20.04-arm64": "2092"
|
||||
},
|
||||
"browserVersion": "26.5",
|
||||
"title": "WebKit"
|
||||
},
|
||||
{
|
||||
"name": "ffmpeg",
|
||||
"revision": "1011",
|
||||
"installByDefault": true,
|
||||
"revisionOverrides": {
|
||||
"mac12": "1010",
|
||||
"mac12-arm64": "1010"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "winldd",
|
||||
"revision": "1007",
|
||||
"installByDefault": false
|
||||
},
|
||||
{
|
||||
"name": "android",
|
||||
"revision": "1001",
|
||||
"installByDefault": false
|
||||
}
|
||||
]
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
const { libCli, libCliTestStub } = require('./lib/coreBundle');
|
||||
const { program } = require('./lib/utilsBundle');
|
||||
libCli.decorateProgram(program);
|
||||
libCliTestStub.decorateProgram(program);
|
||||
program.parse(process.argv);
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './types/types';
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
require('./lib/bootstrap');
|
||||
module.exports = require('./lib/coreBundle').inprocess.playwright;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import playwright from './index.js';
|
||||
|
||||
export const chromium = playwright.chromium;
|
||||
export const firefox = playwright.firefox;
|
||||
export const webkit = playwright.webkit;
|
||||
export const selectors = playwright.selectors;
|
||||
export const devices = playwright.devices;
|
||||
export const errors = playwright.errors;
|
||||
export const request = playwright.request;
|
||||
export const _electron = playwright._electron;
|
||||
export const _android = playwright._android;
|
||||
export default playwright;
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"use strict";
|
||||
const minimumMajorNodeVersion = 18;
|
||||
const currentNodeVersion = process.versions.node;
|
||||
const major = +currentNodeVersion.split(".")[0];
|
||||
if (major < minimumMajorNodeVersion) {
|
||||
console.error(
|
||||
"You are running Node.js " + currentNodeVersion + `.
|
||||
Playwright requires Node.js ${minimumMajorNodeVersion} or higher.
|
||||
Please update your version of Node.js.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.env.PW_INSTRUMENT_MODULES) {
|
||||
const Module = require("module");
|
||||
const originalLoad = Module._load;
|
||||
const root = { name: "<root>", selfMs: 0, totalMs: 0, childrenMs: 0, children: [] };
|
||||
let current = root;
|
||||
const stack = [];
|
||||
Module._load = function(request, _parent, _isMain) {
|
||||
const node = { name: request, selfMs: 0, totalMs: 0, childrenMs: 0, children: [] };
|
||||
current.children.push(node);
|
||||
stack.push(current);
|
||||
current = node;
|
||||
const start = performance.now();
|
||||
let result;
|
||||
try {
|
||||
result = originalLoad.apply(this, arguments);
|
||||
} catch (e) {
|
||||
current = stack.pop();
|
||||
current.children.pop();
|
||||
throw e;
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
node.totalMs = duration;
|
||||
node.selfMs = Math.max(0, duration - node.childrenMs);
|
||||
current = stack.pop();
|
||||
current.childrenMs += duration;
|
||||
return result;
|
||||
};
|
||||
process.on("exit", () => {
|
||||
function printTree(node, prefix, isLast, lines2, depth) {
|
||||
if (node.totalMs < 1 && depth > 0)
|
||||
return;
|
||||
const connector = depth === 0 ? "" : isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
|
||||
const time = `${node.totalMs.toFixed(1).padStart(8)}ms`;
|
||||
const self = node.children.length ? ` (self: ${node.selfMs.toFixed(1)}ms)` : "";
|
||||
lines2.push(`${time} ${prefix}${connector}${node.name}${self}`);
|
||||
const childPrefix = prefix + (depth === 0 ? "" : isLast ? " " : "\u2502 ");
|
||||
const sorted2 = node.children.slice().sort((a, b) => b.totalMs - a.totalMs);
|
||||
for (let i = 0; i < sorted2.length; i++)
|
||||
printTree(sorted2[i], childPrefix, i === sorted2.length - 1, lines2, depth + 1);
|
||||
}
|
||||
let totalModules = 0;
|
||||
function count(n) {
|
||||
totalModules++;
|
||||
n.children.forEach(count);
|
||||
}
|
||||
root.children.forEach(count);
|
||||
const lines = [];
|
||||
const sorted = root.children.slice().sort((a, b) => b.totalMs - a.totalMs);
|
||||
for (let i = 0; i < sorted.length; i++)
|
||||
printTree(sorted[i], "", i === sorted.length - 1, lines, 0);
|
||||
const totalMs = root.children.reduce((s, c) => s + c.totalMs, 0);
|
||||
process.stderr.write(`
|
||||
--- Module load tree: ${totalModules} modules, ${totalMs.toFixed(0)}ms total ---
|
||||
` + lines.join("\n") + "\n");
|
||||
const flat = /* @__PURE__ */ new Map();
|
||||
function gather(n) {
|
||||
const existing = flat.get(n.name);
|
||||
if (existing) {
|
||||
existing.selfMs += n.selfMs;
|
||||
existing.totalMs += n.totalMs;
|
||||
existing.count++;
|
||||
} else {
|
||||
flat.set(n.name, { selfMs: n.selfMs, totalMs: n.totalMs, count: 1 });
|
||||
}
|
||||
n.children.forEach(gather);
|
||||
}
|
||||
root.children.forEach(gather);
|
||||
const top50 = [...flat.entries()].sort((a, b) => b[1].selfMs - a[1].selfMs).slice(0, 50);
|
||||
const flatLines = top50.map(
|
||||
([mod, { selfMs, totalMs: totalMs2, count: count2 }]) => `${selfMs.toFixed(1).padStart(8)}ms self ${totalMs2.toFixed(1).padStart(8)}ms total (x${String(count2).padStart(3)}) ${mod}`
|
||||
);
|
||||
process.stderr.write(`
|
||||
--- Top 50 modules by self time ---
|
||||
` + flatLines.join("\n") + "\n");
|
||||
});
|
||||
}
|
||||
+73386
File diff suppressed because one or more lines are too long
+5
@@ -0,0 +1,5 @@
|
||||
"use strict";
|
||||
var import_coreBundle = require("../coreBundle");
|
||||
const { program } = require("../utilsBundle");
|
||||
import_coreBundle.tools.decorateCliDaemonProgram(program);
|
||||
void program.parseAsync();
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
var import_coreBundle = require("../coreBundle");
|
||||
import_coreBundle.tools.openDashboardApp();
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
"use strict";
|
||||
var import_coreBundle = require("../coreBundle");
|
||||
var import_package = require("../package");
|
||||
const { program } = require("../utilsBundle");
|
||||
const p = program.version("Version " + import_package.packageJSON.version).name("Playwright MCP");
|
||||
import_coreBundle.tools.decorateMCPCommand(p);
|
||||
program.parseAsync(process.argv).catch((e) => {
|
||||
console.error(e.message);
|
||||
import_coreBundle.utils.gracefullyProcessExitDoNotHang(1);
|
||||
});
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
var import_coreBundle = require("../coreBundle");
|
||||
import_coreBundle.registry.runOopDownloadBrowserMain();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user