Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ef21fb7f7 |
@@ -5,8 +5,3 @@ __pycache__/
|
||||
media/
|
||||
.superpowers/
|
||||
*.egg-info/
|
||||
# Simulator sample media (look-tuning only; populate via setup_sample_media.py
|
||||
# / build_pool_manifest.py). All scale + transition binaries are gitignored.
|
||||
simulator/sample_media/**/*.mp4
|
||||
# Local-only candidate review gallery (re-buildable; not shipped content).
|
||||
simulator/static/review.html
|
||||
|
||||
+2
-65
@@ -123,43 +123,6 @@ longer *select* a pre-tagged clip; they drive an **alteration engine** (design
|
||||
|
||||
Plan: [`2026-06-05-player-alteration-core.md`](./superpowers/plans/2026-06-05-player-alteration-core.md).
|
||||
|
||||
**Slice 2 — simulator-first alteration preview ✅ Done.** Merged to `main`
|
||||
(session 0009). Wires the alteration engine into the web simulator so the look is
|
||||
tunable by eye before any hardware, and **reconciles** the unmerged session-0007
|
||||
design with the merged session-0008 design (the Left-HUD conflict): Left is a
|
||||
**runtime overlay** (authored annotation track + per-language string tables),
|
||||
Right is a **discrete pre-baked** flow-stabilized variant, Dark/Light a **live**
|
||||
grade — superseding 0007's baked-HUD 5×5 grid. Engine: a parameterized
|
||||
`Calibration` (tuned by eye, baked into `DEFAULT_CALIBRATION`), `Restyle.variant`
|
||||
(discrete) replacing the continuous blend, and `AnalyticalOverlay.level`. Sim:
|
||||
`/api/alteration` + `/api/clips` over `simulator/clips.py`, an alteration-preview
|
||||
UI, and one neutral clip with a real flow-stabilized Right variant from the POC.
|
||||
Design:
|
||||
[`2026-06-07-reconciled-simulator-alteration-slice-design.md`](./superpowers/specs/2026-06-07-reconciled-simulator-alteration-slice-design.md);
|
||||
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;
|
||||
@@ -170,34 +133,8 @@ every transition, so fast navigation stays responsive on placeholder media.
|
||||
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.
|
||||
- **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).
|
||||
|
||||
- **Still deferred:** real **i2v ring transitions** (need a generative-video
|
||||
model + adjacent real bases); Pi renderer + serial/firmware.
|
||||
- **Offline v2v variant pipeline** — author the pre-baked Right restyle variants
|
||||
(the only paid AI step, design §9) + the multilingual label/string tables + TTS.
|
||||
- **Catalog model changes** — audio *source* + "neutral base" vs "altered
|
||||
variant" flag (sub-project 2 territory, design §13).
|
||||
|
||||
|
||||
+10
-64
@@ -287,26 +287,11 @@ Records point at files via `file_path`. Those files live on the player's drive,
|
||||
`file_path` values consistent with wherever you mount that drive on the machine
|
||||
that will eventually run the room.
|
||||
|
||||
## Playing with the simulator (alteration preview)
|
||||
## Playing with the simulator (curator's X-ray)
|
||||
|
||||
The simulator is a web stand-in for the installation's control panel. It runs the
|
||||
real `player.alteration` engine and **alters** a neutral base clip toward the knob
|
||||
state in the browser, so you can tune the *look* of the filter before any hardware
|
||||
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). 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/build_pool_manifest.py --media # write the pool manifest + coast-edge transitions
|
||||
|
||||
`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.)
|
||||
real `hef.selection` code against a synthetic catalog so you can feel whether the
|
||||
dials surface fitting pieces before any hardware exists.
|
||||
|
||||
**Run it (Docker):**
|
||||
|
||||
@@ -319,50 +304,11 @@ then open http://localhost:8000.
|
||||
pip install -e ".[sim]"
|
||||
make sim-local
|
||||
|
||||
**What you see and can do:**
|
||||
**What you see:** the five real dials (mode + Left/Right/Dark/Light), the model
|
||||
knobs (brain/mood weights, pool size, approved-only), and the X-ray — the picked
|
||||
piece, the ranked candidate pool with distances, and brain/mood coordinate maps
|
||||
showing where your knob point and the candidates sit.
|
||||
|
||||
- **Content dial** — picks audio/video channel; "off" and audio-only positions go
|
||||
to black walls.
|
||||
- **Scale ring (endless encoder)** — `⊖ out` / `in ⊕` (or scroll the stage) walk a
|
||||
*closed ring* of neutral "scales of nature" — **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," over the alteration model).
|
||||
|
||||
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.
|
||||
By default it loads a generated fixture catalog. To point it at a real catalog,
|
||||
set `HEF_SIM_CATALOG=catalog/library.jsonl` (used automatically when that file is
|
||||
non-empty).
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# 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 4)
|
||||
|
||||
| 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 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** |
|
||||
|
||||
> ⚠️ **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).
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,97 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,239 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,929 +0,0 @@
|
||||
# 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.
|
||||
```
|
||||
@@ -121,13 +121,6 @@ layers. This single rule covers the whole `5×5×5×5` space:
|
||||
|
||||
### 4.3 Where each transform runs
|
||||
|
||||
> **Reconciled (2026-06-07, session 0009):** the Left HUD is a **runtime overlay**
|
||||
> driven by an authored annotation track + per-language string tables (text shaped
|
||||
> live); the Right axis selects a **discrete pre-baked** flow-stabilized variant
|
||||
> (not a continuous blend). See
|
||||
> [`2026-06-07-reconciled-simulator-alteration-slice-design.md`](./2026-06-07-reconciled-simulator-alteration-slice-design.md)
|
||||
> §1, which supersedes the session-0007 baked-HUD / 5×5-grid proposal.
|
||||
|
||||
- **Runtime, on the Pi (free, continuous, full-res):** the Dark/Light color grade
|
||||
and the Left analytical overlay. These are cheap (LUT/curves + luma key, and
|
||||
text/graphics compositing) and can move continuously with the knob.
|
||||
@@ -242,13 +235,6 @@ one-time build.
|
||||
|
||||
## 10. Accessibility, i18n, and the translation-cost finding
|
||||
|
||||
> **Reconciled (2026-06-07, session 0009):** the near-free-i18n path is kept — the
|
||||
> Left HUD is a runtime overlay (authored annotation track + per-language string
|
||||
> tables, shaped live via Pango/HarfBuzz on the Pi). The session-0007 baked-HUD
|
||||
> reversal is **not** adopted. See
|
||||
> [`2026-06-07-reconciled-simulator-alteration-slice-design.md`](./2026-06-07-reconciled-simulator-alteration-slice-design.md)
|
||||
> §1.
|
||||
|
||||
The piece is operable **blind, in the dark, in your language**, via four redundant
|
||||
channels on the control panel: **touch** (engraved symbol shape), **low-light
|
||||
color** (LEDs), **braille**, and **audio** (a read-aloud button on a small *local*
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
# HEF — Reconciled Simulator-First Alteration Slice (Design)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Approved design (pre-implementation) — reconciliation approved this session (0009)
|
||||
**Repo:** `human-experience-filter-art`
|
||||
**Reconciles:**
|
||||
[`2026-06-06-simulator-alteration-preview-design.md`](./2026-06-06-simulator-alteration-preview-design.md)
|
||||
(session 0007, the *unmerged* `feature/simulator-alteration-preview` branch) **with**
|
||||
[`2026-06-07-scales-library-and-right-axis-pipeline-design.md`](./2026-06-07-scales-library-and-right-axis-pipeline-design.md)
|
||||
(session 0008, merged to `main`).
|
||||
**Parents:**
|
||||
[`2026-06-05-machine-altered-perception-design.md`](./2026-06-05-machine-altered-perception-design.md)
|
||||
(the alteration engine) and
|
||||
[`2026-06-04-experience-simulator-design.md`](./2026-06-04-experience-simulator-design.md)
|
||||
(the simulator scaffold).
|
||||
**Supersedes:** the **baked-HUD / 5×5-grid** position of the 0007 design (§4, §5, §8 there).
|
||||
|
||||
> **Why this exists.** Sessions 0007 and 0008 left two unmerged design threads that
|
||||
> disagree on one load-bearing point — how the **Left** analytical HUD is rendered —
|
||||
> and, downstream of that, on the **shape of the pre-baked variant set**. 0007 baked
|
||||
> the Left HUD into a 5×5 grid of authored Left×Right variant clips (pixel-precise,
|
||||
> but i18n becomes expensive). 0008 (the later, merged design) kept the Left HUD a
|
||||
> **runtime overlay** driven by an authored annotation track + per-language string
|
||||
> tables (near-free i18n). This document picks the runtime-overlay position, follows
|
||||
> its consequences through the engine and the simulator, and scopes the first
|
||||
> **simulator-runnable** slice that realizes it. It then hands off to an
|
||||
> implementation plan.
|
||||
|
||||
---
|
||||
|
||||
## 1. The decision (operator-approved this session)
|
||||
|
||||
**Left is a runtime overlay, not baked pixels.** Concretely:
|
||||
|
||||
- **Left axis** → a **runtime `AnalyticalOverlay`** driven by an **offline-authored
|
||||
annotation track** (box positions, anchor points, and which label *keys* appear at
|
||||
each Left level 0–4) plus a per-language **string table** (key → translated text).
|
||||
Text is **shaped live** — natively by the browser in the simulator; by
|
||||
**Pango + HarfBuzz + Noto** on the Pi at runtime (the 0008 §1.2 requirement). This
|
||||
is the "authored box positions + runtime-shaped text" hybrid: authorial control
|
||||
over *layout*, near-free *i18n*.
|
||||
- **Right axis** → the **only pre-baked axis**: a small set of **flow-stabilized
|
||||
restyle-strength variants** (0008 §1), selected **discretely** by the Right knob
|
||||
(0–4), with a **crossfade** on change.
|
||||
- **Dark/Light** → a **live runtime `ColorGrade`** (mood center = identity, §5 of the
|
||||
parent).
|
||||
|
||||
**What this supersedes.** The 0007 design's 5×5 Left×Right **grid of 24 authored
|
||||
clips** and its **removal of `AnalyticalOverlay`/`Restyle` from `RenderPlan`** are
|
||||
dropped. The Left HUD is *not* baked; the variant set is **1-D over Right strength**,
|
||||
not a 2-D grid. (Accepted loss vs. fully-baked HUD: no pixel-painted HUD *artwork* —
|
||||
the HUD is shaped text + drawn boxes. Accepted gain: near-free i18n + far less
|
||||
authoring — N Right variants per clip, not 24.)
|
||||
|
||||
**What it preserves from 0007.** The valuable, non-conflicting parts: Python-canonical
|
||||
engine with a thin browser renderer; a parameterized **`Calibration`** tuned by eye in
|
||||
the sim; retiring the simulator's selection-era surface; placeholder-variant generation
|
||||
so the mechanism is testable before real authored media exists.
|
||||
|
||||
### 1.1 The merged engine is already most of the way there
|
||||
|
||||
The slice-1 engine on `main` (`player/alteration.py`) **already** models
|
||||
`RenderPlan = { grade, overlay, restyle }` with a runtime `AnalyticalOverlay` — only
|
||||
0007's *unmerged doc* proposed removing it. So this reconciliation is **surgical**, not
|
||||
a rewrite: keep `grade` and `overlay`; change only how the **Right** axis and the
|
||||
**calibration** are modeled.
|
||||
|
||||
---
|
||||
|
||||
## 2. Engine reconciliation (`player/alteration.py`, `player/state.py`)
|
||||
|
||||
### 2.1 `RenderPlan` layers
|
||||
|
||||
| Layer | Axis | Type | Change from `main` |
|
||||
|---|---|---|---|
|
||||
| `grade: ColorGrade` | Dark/Light | `tone ∈ [−1,1]`, 0 = identity | unchanged shape; curve now from `Calibration` |
|
||||
| `overlay: AnalyticalOverlay` | Left | gains discrete `level: int` (0–4) + keeps `intensity` | `level` added so the renderer selects which annotations show |
|
||||
| `restyle: Restyle` | Right | **discrete** `variant: int` (0–4); 0 = raw | replaces continuous `blend: float` |
|
||||
|
||||
- **`Restyle.variant`** is a discrete index selecting a **pre-baked** Right-strength
|
||||
clip. `variant == 0` means the raw base (no restyle). This matches "select a
|
||||
pre-baked variant and crossfade," and replaces the continuous `blend` that no longer
|
||||
has a runtime meaning (restyle is pre-baked, not blended live).
|
||||
- **`AnalyticalOverlay.level`** (0–4) is the Left knob value; the renderer uses it to
|
||||
choose which annotations from the authored track are active. `intensity` (0..1)
|
||||
stays for overlay opacity/strength and is derived from `level` via `Calibration`.
|
||||
- **`RenderPlan.is_identity`** holds when `grade.is_identity and overlay.level == 0
|
||||
and restyle.variant == 0`.
|
||||
|
||||
### 2.2 `Calibration` (new frozen dataclass)
|
||||
|
||||
A frozen `Calibration` parameterizes the knob→strength maps so they can be tuned **by
|
||||
eye in the sim** and then baked into a `DEFAULT_CALIBRATION` constant:
|
||||
|
||||
- `mood_center` and a per-axis curve for `_mood_tone` (Dark/Light).
|
||||
- the Left `level → intensity` curve.
|
||||
- the Right `knob → variant` map (which knob positions select which pre-baked
|
||||
strength; identity-preserving so knob 0 → variant 0).
|
||||
|
||||
`plan_alteration(coord, calibration: Calibration = DEFAULT_CALIBRATION) -> RenderPlan`.
|
||||
**`DEFAULT_CALIBRATION` reproduces today's exact behavior** (the three current helpers:
|
||||
`value/4` for Left intensity, `(light−dark)/4` for mood, `right` → `variant=right`), so
|
||||
the change is behavior-preserving until the operator tunes it. The session-0006
|
||||
knob→strength open decision is then settled **by eye** in the sim's calibration panel
|
||||
and locked into `DEFAULT_CALIBRATION` by a unit test.
|
||||
|
||||
### 2.3 `player/state.py`
|
||||
|
||||
`state.py` already classifies a change to `plan.restyle` as a `CROSSFADE` and a grade-/
|
||||
overlay-only change as `LIVE_UPDATE`. With `Restyle.variant` discrete, the same
|
||||
classifier works unchanged: a new Right variant → `CROSSFADE`; a grade or Left-overlay
|
||||
change → `LIVE_UPDATE`; clip swap or video on/off → crossfade / fade-to-black as today.
|
||||
The only edit is to the `_classify` comparison if the field name changes
|
||||
(`restyle.blend` → `restyle.variant`).
|
||||
|
||||
These are pure framework-code changes; no deployment-shape decision enters the engine.
|
||||
|
||||
---
|
||||
|
||||
## 3. Simulator (`simulator/`)
|
||||
|
||||
### 3.1 Retire the selection era
|
||||
|
||||
The simulator currently visualizes the **old selection model** (the "curator's X-ray").
|
||||
Remove:
|
||||
|
||||
- `POST /api/select`, `GET /api/catalog/meta`;
|
||||
- the X-ray static UI (`simulator/static/*` rewritten, see §3.4);
|
||||
- `simulator/fixtures.py` (the 625-record synthetic *catalog*) — replaced by
|
||||
`simulator/clips.py`.
|
||||
|
||||
`hef.selection` (the library, incl. `Coordinate`, `ranked_candidates`) is **untouched**;
|
||||
only the simulator's selection *surface* retires. `Coordinate` is still used by the
|
||||
alteration engine.
|
||||
|
||||
### 3.2 `simulator/clips.py` (replaces `fixtures.py`)
|
||||
|
||||
Reads a base-clip + variant manifest. Per base clip:
|
||||
|
||||
```
|
||||
{
|
||||
"id": "forest",
|
||||
"title": "...",
|
||||
"base_file": "forest/base.mp4",
|
||||
"license": "...", "source": "...",
|
||||
"right_variants": { "1": {"file": "forest/right1.mp4", "model": "..."},
|
||||
"...": {...}, "4": {"file": "forest/right4.mp4"} },
|
||||
"annotations": [ {"key": "detected.conifer", "box": [x,y,w,h], "min_level": 1}, ... ],
|
||||
"strings": { "en": { "detected.conifer": "conifer", ... } }
|
||||
}
|
||||
```
|
||||
|
||||
- `right_variants` is keyed by Right strength `1..4` (strength `0` = raw `base_file`).
|
||||
Missing strengths fall back to the raw base and are flagged "raw / unauthored" in the
|
||||
readout.
|
||||
- `annotations` is the **authored annotation track** (box + label key + the minimum
|
||||
Left level at which it appears). `strings` is the per-language table (English only
|
||||
this slice).
|
||||
|
||||
### 3.3 Endpoints
|
||||
|
||||
- **`POST /api/alteration`** — body `{ controls, calibration? }` → `RenderPlan`
|
||||
(serialized) **plus** the `ContentResolution` from `resolve_content` (so the content
|
||||
dial's video-on/off is honored — "Off" → black). Calls the real
|
||||
`plan_alteration(coord, calibration)`.
|
||||
- **`GET /api/clips`** — the base-clip list + the active clip's manifest (variants +
|
||||
annotation track + string table).
|
||||
- Video served as static assets from a sample-media directory.
|
||||
- **Removed:** `POST /api/select`, `GET /api/catalog/meta`.
|
||||
|
||||
### 3.4 Browser preview (`simulator/static/`)
|
||||
|
||||
Thin renderer; all alteration math stays in Python.
|
||||
|
||||
- **Right** — `<video>` showing the selected Right-variant file; **opacity crossfade**
|
||||
to the new file when `restyle.variant` changes; variant 0 → the raw base.
|
||||
- **Dark/Light** — **live color grade** over the video via CSS/canvas filters: Light →
|
||||
warm + lifted toward white; Dark → cool + crushed toward black; tone 0 → raw.
|
||||
- **Left** — overlay drawn **live** from the annotation track: for each annotation with
|
||||
`min_level ≤ overlay.level`, draw its box and the **shaped** string (browser-native
|
||||
shaping) for the active language; opacity from `overlay.intensity`. No baked HUD.
|
||||
- **Content dial** — drives `<video>` visibility; "Off"/audio-only → black walls.
|
||||
- **Calibration panel** — sliders for the `Calibration` params; changing them
|
||||
re-requests the plan and the footage responds live.
|
||||
- **RenderPlan readout** — always shows the exact engine numbers (grade tone, overlay
|
||||
level/intensity, restyle variant) — the project's honesty "X-ray," now over the
|
||||
alteration model.
|
||||
|
||||
The Left overlay being browser-drawn is the **simulator analogue** of the Pi's
|
||||
Pango/HarfBuzz path: both take the *same* annotation track + string table; the browser
|
||||
shapes natively, the Pi shapes with HarfBuzz. The manifest is the shared contract.
|
||||
|
||||
---
|
||||
|
||||
## 4. The sample clip + a real Right variant (this slice)
|
||||
|
||||
To make the look **evaluable now**, wire one real clip end-to-end using the session-0008
|
||||
POC artifacts (`~/hef-poc/out/`, outside the repo):
|
||||
|
||||
- **Base clip** = the POC's `neutral.mp4` (an 8 s nature loop) → copied to the
|
||||
sample-media dir as the one base clip.
|
||||
- **Right variant (top strength)** = the POC's **`right_flow.mp4`** — the real,
|
||||
operator-approved **flow-stabilized** restyle — wired as Right strength 4.
|
||||
- **Intermediate Right strengths (1–3)** = generated by a small **ffmpeg placeholder
|
||||
generator** (e.g. graded/blended stand-ins) so the crossfade mechanism is exercised
|
||||
across the full knob range; the real high-end look is present for tuning.
|
||||
- **Left annotation track** = a minimal authored track (a few boxes + English label
|
||||
keys) for that clip, so Left renders as real shaped text over drawn boxes.
|
||||
|
||||
> **Licensing note.** The simulator sample footage exists **only to tune the look**; it
|
||||
> is not shipped installation content. Strict-PD scale-library sourcing (NASA/NOAA/NPS
|
||||
> per 0008 §2.1) remains a later slice and is unaffected by this choice.
|
||||
|
||||
A real multi-strength SD re-bake (4 genuine flow-stabilized strengths) is **out of scope
|
||||
this slice** — one real strength + placeholders is enough to settle the mechanism and the
|
||||
calibration. The re-bake is a later offline-pipeline task.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing
|
||||
|
||||
- **`player/` unit tests** (`tests/test_player_alteration.py`, `test_player_state.py`):
|
||||
- `DEFAULT_CALIBRATION` reproduces the current helpers exactly (behavior-preserving).
|
||||
- `Restyle.variant` is discrete; knob 0 → variant 0 (identity); a non-default
|
||||
`Calibration` changes the plan as specified.
|
||||
- `AnalyticalOverlay.level` maps from the Left knob; `intensity` derives from it.
|
||||
- `state.py`: a Right-variant change → `CROSSFADE`; a grade-/overlay-only change →
|
||||
`LIVE_UPDATE`; video on/off unchanged.
|
||||
- **Simulator API tests** (rewrite `tests/test_simulator_api.py`):
|
||||
- `POST /api/alteration` returns the engine's plan (+ `ContentResolution`) for given
|
||||
controls/calibration.
|
||||
- `GET /api/clips` returns the manifest.
|
||||
- the removed endpoints (`/api/select`, `/api/catalog/meta`) are gone (404).
|
||||
- `test_fixtures.py` retired/rewritten for `clips.py`.
|
||||
- **No browser/E2E automation** this slice — manual visual tuning is the point; the JS
|
||||
stays thin and the tested logic stays in Python.
|
||||
|
||||
---
|
||||
|
||||
## 6. What ships
|
||||
|
||||
- `player/alteration.py` — `Calibration` + `DEFAULT_CALIBRATION`, discrete
|
||||
`Restyle.variant`, `AnalyticalOverlay.level`; `plan_alteration(coord, calibration)`.
|
||||
- `player/state.py` — crossfade-trigger field rename only.
|
||||
- `simulator/clips.py` (variant + annotation manifest) replacing `fixtures.py`.
|
||||
- `simulator/app.py` — `/api/alteration` + `/api/clips`; selection endpoints removed.
|
||||
- `simulator/static/` — rewritten as the alteration preview (variant `<video>` +
|
||||
crossfade, live grade, live Left overlay, content dial, calibration panel,
|
||||
RenderPlan readout).
|
||||
- Sample base clip + one real Right variant + placeholder generator + minimal Left
|
||||
annotation track / English strings.
|
||||
- Tests above; `docs/USER_GUIDE.md` "Playing with the simulator" rewritten; the parent
|
||||
design §4.3/§10 pointer updated to cite this reconciliation; `docs/ROADMAP.md` §3
|
||||
updated.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope (YAGNI) — later slices
|
||||
|
||||
- Serial input / the 3⇄4 framing contract; the Pi/mpv/GPU runtime renderer (deferred by
|
||||
`simulator-first-before-hardware`).
|
||||
- Audio playback (music / white-noise / audio-track).
|
||||
- The **endless rotary encoder** + **AI zoom/warp transitions** between scales
|
||||
(0008 §3) — a separate control + offline pipeline element.
|
||||
- A real multi-strength SD flow-stabilized re-bake; strict-PD scale-library sourcing
|
||||
(0008 §2.1).
|
||||
- Catalog-model changes (audio source / neutral-vs-variant flag); retiring
|
||||
`hef.selection.ranked_candidates`.
|
||||
- Broad multilingual string tables (English-first; the runtime path keeps i18n cheap,
|
||||
but authoring other languages is later).
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions (for the plan, not blockers)
|
||||
|
||||
- **Calibration curve shape** — **RESOLVED (session 0010, by eye).**
|
||||
`DEFAULT_CALIBRATION` is **locked** to unity gains + a linear variant map
|
||||
(`mood_gain=1.0`, `overlay_gain=1.0`, `right_variant_map=(0,1,2,3,4)`), as a
|
||||
deliberate choice: with the dark-grade fix below, full knob is peaceful on
|
||||
every axis (POC + sim), so full tilt = full look and the 5 notches map 1:1 to
|
||||
the 5 discrete Right bakes. This also closes the **session-0006** convention
|
||||
question — knobs run 0=off..4=max, equal Dark/Light = identity; no
|
||||
"centered at 2 = no push." Guarded by `test_default_calibration_is_locked`.
|
||||
- **Grade vs. Left overlay interaction** — **RESOLVED: overlay above the grade.**
|
||||
The simulator composites the Left HUD (SVG) above the mood grade and the cool
|
||||
tint, so the HUD stays legible regardless of mood. The Pi renderer should do
|
||||
the same.
|
||||
- **Dark-pole grade look** — **FIXED (session 0010).** The first by-eye pass found
|
||||
the sim's dark grade used a full-frame `hue-rotate(-200deg)`, which turned the
|
||||
rock orange and trees purple — the disorienting look rejected in 0008, not the
|
||||
peaceful POC `dark_frame`. Replaced with darken + slight desaturate on the video
|
||||
filter plus a `multiply`-blended deep-blue wash (`#tint`) that lifts shadows
|
||||
toward blue while preserving natural greens. The Pi renderer (later slice) will
|
||||
do proper grading; this matches the approved POC dark look closely enough to tune
|
||||
by eye in the sim.
|
||||
- **Crossfade timing in the browser** — a simple opacity crossfade is enough for tuning;
|
||||
the real timing engine is a later slice.
|
||||
- **Placeholder fidelity** — how close the strength-1–3 placeholders should look to real
|
||||
restyle; cheap stand-ins are fine for mechanism + calibration.
|
||||
@@ -1,247 +0,0 @@
|
||||
# HEF — Scales-of-Nature Library + Stabilized Right-Axis Pipeline (Design Revision)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Approved design (pre-implementation) — operator-approved this session (0008)
|
||||
**Repo:** `human-experience-filter-art`
|
||||
**Refines:** [`2026-06-05-machine-altered-perception-design.md`](./2026-06-05-machine-altered-perception-design.md)
|
||||
— specifically its Right-axis pipeline (§4.1/§4.3), content sourcing/model (§6/§8),
|
||||
and economics (§9), and it **adds a scale-navigation control + zoom transitions**
|
||||
to the §2 selector / §11 control panel. The thesis (§1), coordinate model (§3),
|
||||
Dark/Light/Left treatment, and accessibility (§10) are **preserved**.
|
||||
**Grounded in:** a local proof-of-concept run this session on the operator's Mac
|
||||
mini (M4 Pro, 64 GB, MPS) — all numbers below are measured, not estimated.
|
||||
|
||||
> **Why this revision exists.** The 2026-06-05 design specified the Right axis as
|
||||
> "generative video-to-video restyle, pre-baked offline" and assumed that meant a
|
||||
> **paid cloud API** (§9 priced Kling/Sora/Veo/Runway at $0.05–0.10/s). A POC this
|
||||
> session established two things that change the design: (1) the Right restyle runs
|
||||
> **entirely locally and offline** on the operator's existing hardware, for the
|
||||
> cost of electricity; and (2) naïve per-frame restyle **boils/flickers** in a way
|
||||
> the operator found disorienting — disqualifying for a piece meant to be peaceful
|
||||
> — and the fix is **optical-flow keyframe propagation**. Separately, the operator
|
||||
> chose how the "scales of nature" idea enters the piece: as the curatorial theme
|
||||
> of a **small neutral base library**, not a single fixed journey.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Right axis is a local, flow-stabilized restyle (refines §4.1, §4.3)
|
||||
|
||||
The §4.1 mapping is unchanged in spirit — **Right = dissolve realism toward
|
||||
painterly/dreamlike via generative video-to-video** — but the *implementation* is
|
||||
now pinned:
|
||||
|
||||
- **Engine:** Stable Diffusion **img2img** (POC used `stabilityai/sd-turbo`) run on
|
||||
**Apple MPS**, locally, offline, at authoring time. No cloud API.
|
||||
- **Temporal coherence is a hard requirement, not a nicety.** Per-frame img2img
|
||||
independently re-imagines each frame, producing a shimmering "boil" that reads as
|
||||
disorienting — the **opposite** of the piece's peaceful intent. This was caught
|
||||
in the POC and is now a named design constraint: *the Right substrate must be
|
||||
temporally coherent.*
|
||||
- **Stabilization: optical-flow keyframe propagation.** Fully stylize **keyframes**
|
||||
at a fixed interval; for in-between frames, **warp the previous stylized frame
|
||||
forward by optical flow** (so motion is continuous) and apply only a *light*
|
||||
diffusion refine. The flow warp removes the boil; periodic keyframes bound drift.
|
||||
(This is the EbSynth principle. Genuine `ebsynth`/`ezsynth` are NVIDIA/Windows-
|
||||
leaning and don't install cleanly on Apple Silicon, so the POC implemented the
|
||||
same idea directly with OpenCV Farneback flow + the existing diffusers pipeline.)
|
||||
|
||||
This stays consistent with §4.3's crucial invariant: **the Left analytical labels
|
||||
remain a runtime overlay, never baked into the restyled pixels** — the POC's Left
|
||||
HUD is composited deterministically on top, preserving the near-free i18n of §10.
|
||||
|
||||
### 1.1 Where each transform runs (updated §4.3 table)
|
||||
|
||||
| Pole | Operation | Where it runs | Measured cost (8 s, 1080p clip) |
|
||||
|---|---|---|---|
|
||||
| **Dark** | color grade | runtime, live on the Pi | ~2.4 s offline; live at runtime |
|
||||
| **Light** | color grade | runtime, live on the Pi | ~2.5 s offline; live at runtime |
|
||||
| **Left** | analytical overlay (HUD) | runtime, live on the Pi | ~2.2 s offline; live at runtime |
|
||||
| **Right** | local generative restyle + flow propagation | **pre-baked offline, locally** | ~2.7 min/restyle-strength |
|
||||
|
||||
The three deterministic axes are confirmed cheap enough to run **live**; only the
|
||||
Right restyle is pre-baked. A peaceful **deterministic** alternative for Right (soft
|
||||
edge-preserving smoothing + bloom, zero flicker by construction, ~5 s/clip) was
|
||||
prototyped and set aside — the operator preferred the true generative repaint once
|
||||
the flow stabilization made it calm. It remains a documented fallback.
|
||||
|
||||
### 1.2 Runtime label rendering & i18n (sharpens §10)
|
||||
|
||||
The Left analytical labels are drawn **live by the Pi as a 2D graphics overlay** —
|
||||
**not** baked into video, and specifically **not** a pre-rendered overlay *video*. A
|
||||
baked overlay would have to exist per language × per Left level × per scale, which
|
||||
re-introduces the "× number of languages" cost §10 exists to avoid. So the runtime
|
||||
path is:
|
||||
|
||||
- **Architecture.** Per base clip, an offline-authored **annotation track** (box
|
||||
positions, anchor points, which annotations appear at each Left level) referencing
|
||||
language-agnostic label **keys** (e.g. `detected.conifer`). Per language, a cheap
|
||||
**string table** (key → translated text) + font + TTS voice. At runtime the Pi
|
||||
reads the Left knob, selects the active annotations, **shapes** the current
|
||||
language's strings, and composites over the altered video — updating only on change
|
||||
(knob move, language switch, timeline cue), not every frame.
|
||||
- **Correctness needs a real shaping stack.** Rendering *every* language correctly
|
||||
(Arabic joining, Indic conjuncts, CJK, RTL) requires **Pango + HarfBuzz + Noto
|
||||
fonts**, not naïve text drawing. (The POC's HUD used ffmpeg `drawtext`/Menlo —
|
||||
Latin-only; it would mis-render complex scripts and is **not** the runtime path.)
|
||||
This shaping stack is the load-bearing requirement behind §10's "label things
|
||||
correctly."
|
||||
- **Feasibility.** This is OSD/subtitle-class compositing; a Pi 5 (VideoCore VII,
|
||||
GLES/Vulkan, hardware decode) handles it. Headroom at the *panoramic* resolution is
|
||||
the one unmeasured variable — see §6.
|
||||
|
||||
---
|
||||
|
||||
## 2. Content = a small NEUTRAL "scales of nature" library (refines §6, §8)
|
||||
|
||||
The operator's "cosmic zoom" concept (space → continents → birds → ocean → abyss →
|
||||
microscopic → galaxy) enters the piece **as a curatorial theme, not a fixed film.**
|
||||
|
||||
- **Structure:** a **small library (~4–6 to start) of calm, neutral base clips**,
|
||||
each drawn from a *different scale of nature* — e.g. an orbital Earth, a forest, a
|
||||
coral reef, the deep-sea abyss, the microscopic, the cosmos. The machine alters
|
||||
whichever clip is playing, exactly as for any neutral base.
|
||||
- **Why this and not a single stitched journey.** A literal galaxy→cell "how small
|
||||
we are" journey carries its **own** emotion (awe, cosmic insignificance) *before
|
||||
the machine acts*, which contradicts the §1 neutral-base thesis, and as a fixed
|
||||
film it becomes "a journey you watch" rather than "a reality you bend." Keeping the
|
||||
scales as the *theme of a neutral library* preserves the awe-of-scale richness and
|
||||
the piece's coherence **while keeping the base neutral and the experience
|
||||
interactive.** (Rejected alternatives: single stitched base; cosmic-zoom as
|
||||
intro/reset; rethinking the thesis.)
|
||||
- **Cost is not the constraint.** Per §4 below, ~5 base clips is ~1 hour of overnight
|
||||
local pre-bake — so this is an *artistic* choice, made on artistic grounds.
|
||||
- **Mechanism unchanged:** this slots into the existing §6 content model and the
|
||||
sub-project-2 ingest/tagging/review tooling; "scales of nature" is simply the
|
||||
selection principle for which neutral clips to source.
|
||||
|
||||
### 2.1 Strict-PD sourcing map (refines §8)
|
||||
|
||||
License stance is unchanged (prefer Public Domain / CC0; record license + source per
|
||||
clip). The "scales" theme maps onto genuinely public-domain pools cleanly at the
|
||||
*ends* and is softer in the terrestrial *middle*:
|
||||
|
||||
| Scale | Best strict-PD source | Status |
|
||||
|---|---|---|
|
||||
| Cosmos / galaxy / deep space | NASA, Hubble, JWST | 🟢 abundant, true PD |
|
||||
| Earth from orbit / continents | NASA / ISS | 🟢 true PD |
|
||||
| Ocean & **deep sea / abyss** (global) | **NOAA Ocean Exploration** | 🟢 true PD, worldwide |
|
||||
| Microscopic / single-celled | NIH / NSF | 🟢 thinner but PD |
|
||||
| US land / wildlife | NPS, USGS, USFWS | 🟢 true PD (US locations only) |
|
||||
| **Non-US terrestrial, high-flying birds** | — | 🟡 mostly CC-BY; the PD soft spot |
|
||||
|
||||
US-government works are public domain by statute (17 U.S.C. §105); the installation
|
||||
is US-based, so this is the cleanest possible legal footing. **Caveat the design
|
||||
must respect:** "free stock" sites (Pexels, Pixabay, Mitch Martinez's free 4K, etc.)
|
||||
are *royalty-free but NOT public domain* — they restrict redistribution and retain
|
||||
copyright. The ingest tool's "no explicit license → assume PD, verify before use"
|
||||
flag exists precisely for this trap and must not be trusted blindly.
|
||||
|
||||
---
|
||||
|
||||
## 3. Scale navigation & zoom transitions (new element; refines §2 selector, §11)
|
||||
|
||||
The scales-of-nature library is navigated as a **closed loop (a ring), not a line.**
|
||||
A dedicated **scale ("zoom") control** lets the viewer journey through scales;
|
||||
advancing it triggers a short **AI zoom/warp transition** to the next scale,
|
||||
pre-baked offline. Diving past the smallest (single-celled) **wraps around** to the
|
||||
largest (cosmos) — the infinite-zoom payoff that unifies micro and macro and makes
|
||||
the ring continuous.
|
||||
|
||||
- **The control is an *endless* rotary encoder — infinitely turnable, no end stops.**
|
||||
The form embodies the concept: a ring of scales has no beginning or end, so neither
|
||||
does the knob. Keep turning one way and you zoom inward forever (…reef →
|
||||
microscopic → **cosmos** → continents → …); turn back to zoom out. This sets it
|
||||
apart from the four **experience knobs**, which are *absolute* 0–4 pots: the zoom
|
||||
control reports **relative** rotation (encoder detents), and the player/firmware
|
||||
advances or retreats one ring-step per increment. It is distinct too from the §6
|
||||
content dial (audio/video channel) — it chooses *where in the ring* you are, while
|
||||
the knobs still bend whichever scale is present.
|
||||
- **Transitions:** between each adjacent pair of scale clips, a short (~few-second)
|
||||
generative morph — **first-last-frame-conditioned image-to-video** (Wan/LTX-class)
|
||||
or SD "infinite-zoom" outpainting for the literal zoom-through. Pre-baked offline,
|
||||
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.
|
||||
- **Heavier than the restyle, still bounded:** generative video synthesis costs more
|
||||
per second than img2img, but transitions are few and short — a handful of ~3–5 s
|
||||
morphs is an overnight local batch.
|
||||
|
||||
---
|
||||
|
||||
## 4. Economics update — local authoring ≈ free (refines §9)
|
||||
|
||||
§9 priced the Right axis at **~$300–3k** of cloud generative-v2v API. The POC
|
||||
collapses that: the restyle runs on **hardware the operator already owns**, offline,
|
||||
so the marginal cost is electricity.
|
||||
|
||||
- **Per base clip:** ~4 Right restyle strengths × ~2.7 min ≈ **~11 min of local AI
|
||||
pre-bake**, plus seconds for the deterministic Dark/Light/Left grid.
|
||||
- **Whole small library (~5 clips):** **~1 hour** of overnight batch rendering.
|
||||
- **Scale transitions:** ~N short generative-video morphs (one per ring edge), a few
|
||||
seconds each — a separate, heavier offline batch (video synthesis > img2img), but
|
||||
still overnight-local.
|
||||
- **Cloud API: no longer required** for the build. (It remains an option if a
|
||||
higher-quality video model than a local one is wanted for a final pass.)
|
||||
|
||||
This also tightens the piece's "all-local" ethos: not just *runtime* is local
|
||||
(§1 of the prior design) — now *authoring* is too.
|
||||
|
||||
---
|
||||
|
||||
## 5. POC evidence (this session)
|
||||
|
||||
A throwaway spike (outside the repo, `~/hef-poc/`) validated the full engine on one
|
||||
real nature clip (a 4K Yosemite waterfall, trimmed to 8 s @ 1080p):
|
||||
|
||||
- **All four axes rendered** and read as distinct: Dark (cold/somber), Light
|
||||
(warm/serene), Left (analytical HUD overlay), Right (painterly).
|
||||
- **Right per-frame:** ~3.4 min/8 s clip, **flickers badly** (disqualifying).
|
||||
- **Right flow-propagated:** ~2.7 min/8 s clip, **calm** (operator-approved).
|
||||
- **Deterministic axes:** ~2.4 s each, ~3× faster than real-time → confirmed
|
||||
runtime-capable.
|
||||
- **Stack:** `imageio-ffmpeg`, `diffusers` + `sd-turbo` on MPS, OpenCV Farneback
|
||||
flow; Python 3.13; 64 GB unified memory comfortably ran models that OOM consumer
|
||||
GPUs.
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions (for the plan, not blockers)
|
||||
|
||||
- **Flow quality at scale:** the OpenCV flow propagation was validated on one short
|
||||
clip; longer clips / faster motion may need shorter keyframe intervals, bidirectional
|
||||
blending, or a stronger flow model (RAFT).
|
||||
- **Painterly strength:** the POC kept the restyle gentle; the dreamlike *range* and
|
||||
the per-axis restyle-strength count (the §4.3 "small set of variants") are
|
||||
unfixed.
|
||||
- **Base-clip sourcing:** select and ingest the actual ~4–6 strictly-PD neutral
|
||||
scale clips (NASA/NOAA/NPS) via sub-project 2.
|
||||
- **Model choice:** `sd-turbo` was the POC's speed pick; a higher-quality local
|
||||
model (or a final cloud pass) may be worth a comparison for the shipped variants.
|
||||
- **Scale transitions:** generation method (first-last-frame i2v vs. infinite-zoom
|
||||
outpainting) and local model; per-transition length; whether transitions carry the
|
||||
current mood grade; the ring ordering of the scales; and behavior on fast or
|
||||
continuous spins of the endless encoder (chain transitions vs. blended skip).
|
||||
- **Pi compositing headroom:** confirm the Pi 5 can decode the altered video **and**
|
||||
render the live Pango/HarfBuzz label overlay at the *actual* panoramic projector
|
||||
resolution (ultra-wide / high-res) — low risk but unmeasured (see §1.2).
|
||||
|
||||
## 7. Out of scope (YAGNI)
|
||||
|
||||
- A single continuous **one-take** zoom through *all* scales (we use discrete neutral
|
||||
clips joined by short AI transitions on a navigable ring — not one unbroken shot).
|
||||
- Audio-axis alteration (already deferred by the prior §6).
|
||||
- Cloud rendering pipeline (local supersedes it for the base build).
|
||||
@@ -1,85 +0,0 @@
|
||||
# Affect channel — emotions in the Left-brain HUD
|
||||
|
||||
**Status:** approved (session 0013, 2026-06-22)
|
||||
**Surface:** simulator preview (`simulator/`, `player/alteration.py`)
|
||||
**Builds on:** the machine-altered-perception design SPEC; the Left HUD look-and-feel
|
||||
pass (same session).
|
||||
|
||||
## 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 appear only when *both* the analytical (Left) and dreamlike (Right) knobs are
|
||||
up — the dream leaking into the machine's 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 = `min(left, right)` (0–4). Either knob
|
||||
at 0 → no emotions at all. Opacity scales with that strength × `overlay_gain`.
|
||||
- **Escalation.** A **stable emotional palette per scene** — higher combined
|
||||
strength reveals *more* words at higher opacity. Each word carries a `min_level`
|
||||
(the same mechanic the detection channel uses), but compared against the
|
||||
*combined* `min(left, right)` strength rather than Left alone.
|
||||
- **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 = min(left, right)`; `intensity = clamp(overlay_gain *
|
||||
min(left,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: `min(left,right) > 0` implies `left > 0`, which already
|
||||
makes `overlay.level > 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).
|
||||
@@ -1,109 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,355 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,64 +0,0 @@
|
||||
# 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).
|
||||
+39
-125
@@ -1,19 +1,22 @@
|
||||
"""The alteration engine: a knob vector -> a layered RenderPlan (design §4, §5).
|
||||
|
||||
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.
|
||||
Replaces the 2026-06-04 nearest-match *selection* with a *transformation* of a
|
||||
neutral base clip. Given the four experience knobs, it produces three layers
|
||||
that compose per §4.2:
|
||||
|
||||
Layers compose per §4.2:
|
||||
- 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.
|
||||
- Substrate transforms (alter pixels, blend with each other):
|
||||
* Restyle -- the Right axis: a pre-baked generative v2v dreamlike restyle.
|
||||
* ColorGrade -- the mood axis (Dark/Light): a deterministic color grade.
|
||||
- Overlay (composited on top of the substrate):
|
||||
* AnalyticalOverlay -- the Left axis: HUD/labels/measurement.
|
||||
|
||||
Left and Right are NOT opposites; they live on different layers and stack
|
||||
(§4.2). Dark and Light are the two poles of one mood grade whose center is the
|
||||
identity (§5).
|
||||
|
||||
Calibration note: the knob->strength curves below are the single source of
|
||||
truth for how a 0..4 position maps to a transform strength. See the session
|
||||
0006 transcript Deferred decisions for the §3-vs-§4.2/§5 reconciliation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,45 +28,6 @@ from hef.selection import Coordinate
|
||||
KNOB_MAX = 4 # knob full-scale (0..4)
|
||||
|
||||
|
||||
def _clamp(x: float, lo: float, hi: float) -> float:
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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 (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
|
||||
dream_gain: float = 1.0
|
||||
|
||||
|
||||
# The LOCKED calibration (session 0010, 2026-06-07) — settled by eye in the
|
||||
# simulator, closing the open session-0006 knob->strength decision. Convention:
|
||||
# every experience knob runs 0 = off .. 4 = max, and equal Dark/Light = identity
|
||||
# (raw footage); there is no "centered at 2 = no push" coordinate.
|
||||
# - mood_gain = 1.0: full Dark/Light knob reaches the full mood grade. By-eye
|
||||
# evidence (POC renders + sim, after the dark-grade fix this session) shows
|
||||
# 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.
|
||||
# - 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,
|
||||
dream_gain=1.0,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ColorGrade:
|
||||
"""Mood-axis grade (§5). `tone` is signed: >0 warm yellow->white (light),
|
||||
@@ -78,36 +42,18 @@ class ColorGrade:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnalyticalOverlay:
|
||||
"""Left axis (§4.1): analytical HUD/annotation, composited on top at runtime.
|
||||
`level` is the Left knob (0..4); a runtime annotation track uses it to pick
|
||||
which labels appear. `intensity` 0..1 is the overlay opacity/strength."""
|
||||
"""Left axis (§4.1): analytical HUD/annotation/labels, composited on top.
|
||||
`intensity` 0..1 (0 = no overlay)."""
|
||||
|
||||
level: int
|
||||
intensity: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AffectOverlay:
|
||||
"""Affect channel (Left x Right): emotion-words surfaced only when BOTH the
|
||||
analytical (Left) and dreamlike (Right) knobs are up. `strength` is
|
||||
`min(left, right)` (0..4) — a runtime affect track picks which feeling words
|
||||
appear by it; `intensity` 0..1 is their opacity. The dream leaking into the
|
||||
machine's reading (affect-channel design, session 0013)."""
|
||||
class Restyle:
|
||||
"""Right axis (§4.1): pre-baked generative v2v dreamlike substrate.
|
||||
`blend` 0..1 (0 = raw substrate, no restyle)."""
|
||||
|
||||
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
|
||||
blend: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -116,69 +62,37 @@ class RenderPlan:
|
||||
|
||||
grade: ColorGrade
|
||||
overlay: AnalyticalOverlay
|
||||
affect: AffectOverlay
|
||||
dream: Dream
|
||||
restyle: Restyle
|
||||
|
||||
@property
|
||||
def is_identity(self) -> bool:
|
||||
"""True when the plan leaves the neutral base un-altered. Affect needs no
|
||||
clause: `min(left,right) > 0` implies `left > 0`, so `overlay.level > 0`
|
||||
already makes a plan with active affect non-identity."""
|
||||
"""True when the plan leaves the neutral base un-altered."""
|
||||
return (
|
||||
self.grade.is_identity
|
||||
and self.overlay.level == 0
|
||||
and self.dream.strength == 0
|
||||
and self.overlay.intensity == 0.0
|
||||
and self.restyle.blend == 0.0
|
||||
)
|
||||
|
||||
|
||||
def _overlay_intensity(left: int, cal: Calibration) -> float:
|
||||
return _clamp(cal.overlay_gain * left / KNOB_MAX, 0.0, 1.0)
|
||||
def _overlay_intensity(left: int) -> float:
|
||||
"""Left knob -> analytical-overlay intensity (0..1)."""
|
||||
return left / KNOB_MAX
|
||||
|
||||
|
||||
def _affect_strength(left: int, right: int) -> int:
|
||||
"""Affect surfaces only when BOTH knobs are up — gated by the smaller one."""
|
||||
return min(left, right)
|
||||
def _restyle_blend(right: int) -> float:
|
||||
"""Right knob -> v2v restyle blend (0..1)."""
|
||||
return right / KNOB_MAX
|
||||
|
||||
|
||||
def _affect_intensity(left: int, right: int, cal: Calibration) -> float:
|
||||
return _clamp(cal.overlay_gain * _affect_strength(left, right) / KNOB_MAX, 0.0, 1.0)
|
||||
def _mood_tone(dark: int, light: int) -> float:
|
||||
"""(dark, light) -> signed mood grade in [-1, 1]; equal -> 0 identity (§5)."""
|
||||
return (light - dark) / KNOB_MAX
|
||||
|
||||
|
||||
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:
|
||||
return _clamp(cal.mood_gain * (light - dark) / KNOB_MAX, -1.0, 1.0)
|
||||
|
||||
|
||||
def plan_alteration(
|
||||
coord: Coordinate, calibration: Calibration = DEFAULT_CALIBRATION
|
||||
) -> RenderPlan:
|
||||
def plan_alteration(coord: Coordinate) -> RenderPlan:
|
||||
"""Map a knob vector to its layered RenderPlan (design §4)."""
|
||||
return RenderPlan(
|
||||
grade=ColorGrade(tone=_mood_tone(coord.dark, coord.light, calibration)),
|
||||
overlay=AnalyticalOverlay(
|
||||
level=coord.left,
|
||||
intensity=_overlay_intensity(coord.left, calibration),
|
||||
),
|
||||
affect=AffectOverlay(
|
||||
strength=_affect_strength(coord.left, coord.right),
|
||||
intensity=_affect_intensity(coord.left, coord.right, calibration),
|
||||
),
|
||||
dream=Dream(
|
||||
strength=coord.right,
|
||||
intensity=_dream_intensity(coord.right, calibration),
|
||||
),
|
||||
grade=ColorGrade(tone=_mood_tone(coord.dark, coord.light)),
|
||||
overlay=AnalyticalOverlay(intensity=_overlay_intensity(coord.left)),
|
||||
restyle=Restyle(blend=_restyle_blend(coord.right)),
|
||||
)
|
||||
|
||||
|
||||
def render_plan_to_dict(plan: RenderPlan) -> dict:
|
||||
"""JSON-serializable form for the simulator API."""
|
||||
return {
|
||||
"grade": {"tone": plan.grade.tone},
|
||||
"overlay": {"level": plan.overlay.level, "intensity": plan.overlay.intensity},
|
||||
"affect": {"strength": plan.affect.strength, "intensity": plan.affect.intensity},
|
||||
"dream": {"strength": plan.dream.strength, "intensity": plan.dream.intensity},
|
||||
"is_identity": plan.is_identity,
|
||||
}
|
||||
|
||||
-240
@@ -1,240 +0,0 @@
|
||||
"""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, ...] = ()
|
||||
|
||||
@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 along ONE ring edge.
|
||||
|
||||
`file` plays FORWARD when zooming inward (scales[i] -> scales[i+1] in ring
|
||||
order); the renderer reverses it for the outward direction, so one clip
|
||||
covers both directions of an edge.
|
||||
"""
|
||||
|
||||
file: str
|
||||
model: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScaleRing:
|
||||
"""A closed ring of scales joined by per-edge transitions.
|
||||
|
||||
Invariant: a ring of N>=2 scales has exactly N transitions (one per edge,
|
||||
including the small->large wrap seam). A degenerate single-scale ring has no
|
||||
edges. An empty ring is rejected.
|
||||
"""
|
||||
|
||||
scales: tuple[Scale, ...]
|
||||
transitions: tuple[Transition, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
n = len(self.scales)
|
||||
if n == 0:
|
||||
raise RingError("a ScaleRing needs at least one scale")
|
||||
expected = 0 if n == 1 else n
|
||||
if len(self.transitions) != expected:
|
||||
raise RingError(
|
||||
f"a {n}-scale ring needs {expected} transitions, "
|
||||
f"got {len(self.transitions)}"
|
||||
)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.scales)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransitionStep:
|
||||
"""One transition to play during a move: which edge clip, in which direction,
|
||||
and the scale index it lands on.
|
||||
|
||||
`blended` marks the single collapsed step of a fast-spin pass (see
|
||||
`advance_ring`'s `fast_spin_threshold`): the renderer plays it as one quick
|
||||
accelerated morph straight to the destination instead of a full transition.
|
||||
"""
|
||||
|
||||
edge: int
|
||||
reversed: bool
|
||||
file: str
|
||||
to_index: int
|
||||
blended: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RingMove:
|
||||
"""The result of advancing the encoder: where you end up and the ordered
|
||||
transitions to play getting there (chained for a multi-detent spin).
|
||||
|
||||
`fast` is True when the move was collapsed to a single blended pass because
|
||||
the spin crossed the speed threshold (see `advance_ring`).
|
||||
"""
|
||||
|
||||
from_index: int
|
||||
to_index: int
|
||||
steps: tuple[TransitionStep, ...]
|
||||
wrapped: bool
|
||||
fast: bool = False
|
||||
|
||||
|
||||
def scale_at(ring: ScaleRing, index: int) -> Scale:
|
||||
"""The scale at `index`, taken modulo the ring length (wraps both ways)."""
|
||||
return ring.scales[index % len(ring)]
|
||||
|
||||
|
||||
def 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_,
|
||||
file=ring.transitions[edge].file,
|
||||
to_index=nxt,
|
||||
)
|
||||
)
|
||||
index = nxt
|
||||
|
||||
# Fast spin: collapse the whole chain to a single blended arrival pass. The
|
||||
# landing index, seam-crossing, and arrival edge/direction are exactly those
|
||||
# of the full chain — only the in-between transitions are dropped.
|
||||
if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold:
|
||||
arrival = steps[-1]
|
||||
blended = TransitionStep(
|
||||
edge=arrival.edge,
|
||||
reversed=arrival.reversed,
|
||||
file=arrival.file,
|
||||
to_index=arrival.to_index,
|
||||
blended=True,
|
||||
)
|
||||
return RingMove(
|
||||
from_index=start,
|
||||
to_index=index,
|
||||
steps=(blended,),
|
||||
wrapped=wrapped,
|
||||
fast=True,
|
||||
)
|
||||
|
||||
return RingMove(
|
||||
from_index=start, to_index=index, steps=tuple(steps), wrapped=wrapped
|
||||
)
|
||||
+5
-7
@@ -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, 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.
|
||||
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 v2v variant 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
|
||||
@@ -107,9 +107,7 @@ class Player:
|
||||
if next_video and not prev_video:
|
||||
return TransitionKind.FADE_FROM_BLACK
|
||||
if next_video and prev_video:
|
||||
# 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:
|
||||
if nxt.clip_id != prev.clip_id or nxt.plan.restyle != prev.plan.restyle:
|
||||
return TransitionKind.CROSSFADE
|
||||
return TransitionKind.LIVE_UPDATE
|
||||
# both black: only audio/levels could have changed
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# Session 0007.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Date: 2026-06-06 (PST) · 14:12 → 17:57
|
||||
> Type: coding
|
||||
> Goal: continue the roadmap; reframed by the operator to **simulator-first** —
|
||||
> bring the slice-1 alteration engine into the simulator so the experience can be
|
||||
> tuned before any hardware.
|
||||
> Outcome: **design spec written, committed, and pushed** on branch
|
||||
> `feature/simulator-alteration-preview`. No code changes. Implementation deferred
|
||||
> to the next session (design + implementation to ship as one PR).
|
||||
|
||||
## Plan
|
||||
|
||||
Bring the alteration engine into the simulator (scope: tune the look), per
|
||||
`docs/superpowers/specs/2026-06-06-simulator-alteration-preview-design.md`:
|
||||
- Python-canonical engine, thin browser renderer.
|
||||
- Dark/Light = live runtime color grade; Left/Right = 5×5 grid of pre-baked
|
||||
authored variant clips selected by coordinate (24 videos/base clip).
|
||||
- Revises design §4.3/§10: HUD baked into variant videos (authorial precision)
|
||||
rather than runtime overlay — trades away near-free i18n.
|
||||
- Engine reconciliation: parameterize `Calibration`, add `VariantRef`, slim
|
||||
`RenderPlan` (drop runtime `AnalyticalOverlay`/`Restyle`); `state.py` crossfade
|
||||
on variant change.
|
||||
- Simulator: retire selection X-ray + `/api/select`; add `/api/alteration` +
|
||||
`/api/clips` + variant manifest; placeholder-variant generator.
|
||||
|
||||
## Pre-session state
|
||||
|
||||
- `main` at `5290785`, clean and pushed. Sub-projects 1 & 2 done; sub-project 3
|
||||
(Player Runtime) in progress — slice 1 (pure-logic `player/` core) merged via
|
||||
PR #5 (session 0006).
|
||||
- A **stale nested worktree** (`.worktrees/feature-BuildSimulator11`, branch
|
||||
`feature/player-alteration-core`, already merged) was present and breaking the
|
||||
session resolver ("ambiguous app.json"), exactly as the session-0006 memory
|
||||
gotcha predicted.
|
||||
|
||||
## Turn-by-turn arc
|
||||
|
||||
1. **Session gate.** Opened with "What's next?" → classified as a coding session;
|
||||
ran `wgl-session-coding-init`.
|
||||
2. **Baseline cleanup.** Claim dry-run failed on the resolver ambiguity. Verified
|
||||
the stale worktree's branch was fully merged + clean, removed the worktree and
|
||||
branch, and deleted the empty `.worktrees/` dir. Tree back to clean `main`.
|
||||
3. **Claimed session 0007** (no other sessions in flight).
|
||||
4. **Operator steer:** "only design things that will run in the simulator; get
|
||||
everything working the way we like in the simulator before hardware." Saved as
|
||||
a `feedback` memory ([[simulator-first-before-hardware]]).
|
||||
5. **Discovered** the simulator (`simulator/`, built session 0004) is fully
|
||||
decoupled from `player/` — it exercises the OLD *selection* model and has no
|
||||
alteration surface. That gap became the work.
|
||||
6. **Brainstorming** (with the visual companion). Decisions, one at a time:
|
||||
scope = tune the alteration look; medium = short looping video; Right restyle =
|
||||
labeled stand-in shader (swappable for real variants); calibration =
|
||||
live-adjustable curves; architecture = **A, Python-canonical**; integration =
|
||||
new view, **retire the selection X-ray**.
|
||||
7. **Interactive look mockup** (browser): a real nature image with the 4 knobs as
|
||||
live sliders. Operator confirmed Dark/Light read well, but said for Left/Right
|
||||
they'll **author videos with the overlay baked in** for precise HUD control.
|
||||
8. **Surfaced the tradeoff:** baking the HUD reverses design §4.3/§10's near-free
|
||||
i18n. Operator chose **precision wins**. Confirmed the variant grid as **full
|
||||
5×5 = 24 authored clips per base** (shown as a grid mockup).
|
||||
9. **Revised model** presented and approved: base clip + variant manifest;
|
||||
`RenderPlan = {grade, variant}`; runtime overlay/restyle layers retired;
|
||||
placeholder-variant bootstrapping via ffmpeg.
|
||||
10. **Wrote the spec**, self-reviewed, committed on a feature branch.
|
||||
11. Operator: "do [implementation] next session. finalize this one." →
|
||||
`wgl-session-finalize`.
|
||||
|
||||
## Cut state (end of session)
|
||||
|
||||
| Repo | Branch | Commit | State |
|
||||
|---|---|---|---|
|
||||
| human-experience-filter-art | `feature/simulator-alteration-preview` | `3ef21fb` | pushed to origin, **not merged** |
|
||||
|
||||
- `docs/superpowers/specs/2026-06-06-simulator-alteration-preview-design.md` —
|
||||
new, committed.
|
||||
- `main` unchanged at `5290785`.
|
||||
- Working tree clean. Visual-companion server stopped; `.superpowers/` gitignored.
|
||||
- No tests run (no code changed).
|
||||
|
||||
## What lands on the operator's plate
|
||||
|
||||
- **Author the 24 variant videos** per base clip (the Left×Right grid), with HUD
|
||||
baked in. The simulator will consume them via the manifest; placeholder variants
|
||||
cover the gap until then.
|
||||
- **Deferred decisions** (also surfaced in chat):
|
||||
- *knob→strength calibration* — unresolved since session 0006; will be settled
|
||||
by eye in the simulator's calibration panel next session, then baked into
|
||||
`DEFAULT_CALIBRATION`.
|
||||
- *grade tints baked HUD* — accepted under "precision wins"; revisit only if it
|
||||
reads badly once real authored clips exist.
|
||||
|
||||
## Prompt the operator can paste into the next session
|
||||
|
||||
```
|
||||
/goal Implement the simulator alteration preview on branch feature/simulator-alteration-preview, per docs/superpowers/specs/2026-06-06-simulator-alteration-preview-design.md — begin with the writing-plans skill, then build: parameterize Calibration + add VariantRef and slim RenderPlan in player/, add /api/alteration + /api/clips + simulator/clips.py variant manifest, rewrite the simulator UI as the player preview, retire the selection X-ray + /api/select, add the placeholder-variant generator, and update tests + USER_GUIDE + ROADMAP §3 + the parent design §4.3/§10 pointer.
|
||||
```
|
||||
|
||||
(Or resume with `/goal next` — the `Next /goal:` field is stored in memory
|
||||
`sub-project-3-player-progress.md`.)
|
||||
@@ -1,102 +0,0 @@
|
||||
# Session 0008.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-07T19-42 (PST)
|
||||
> End: 2026-06-07T22-37 (PST)
|
||||
> Type: spec
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Session opened as `wgl-session-none` (research errand: "Let's find a great public
|
||||
domain nature video for this project"), then upgraded mid-session to a **spec**
|
||||
session once the work became tracked. Operator chose "Spec session, comprehensive":
|
||||
fold the session's POC findings into the design AND resolve the open cosmic-zoom
|
||||
structure fork.
|
||||
|
||||
## Plan
|
||||
|
||||
Develop a design revision to the machine-altered-perception design, grounded in a
|
||||
local POC run this session: (1) Right-axis alteration pipeline; (2) content
|
||||
structure (cosmic-zoom fork); (3) strictly-PD sourcing.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- On `feature/simulator-alteration-preview` (session 0007's unmerged design), clean
|
||||
tree; `main` at `5290785`.
|
||||
- Catalog empty; `tools/ingest/internet_archive.py` is the only wired fetcher.
|
||||
- No ML stack, no ffmpeg in the venv (pure-stdlib project).
|
||||
|
||||
## Session arc (uncurated)
|
||||
|
||||
1. **PD nature-video research.** Surveyed pools and verified licenses against the
|
||||
actual metadata, not marketing. Key finding: **Pexels / Pixabay / Mitch Martinez
|
||||
"free 4K" are royalty-free but NOT public domain** (restrict redistribution,
|
||||
retain copyright) — exactly the trap the ingest tool's "no explicit license →
|
||||
assume PD, verify" flag exists for. Genuinely-PD: NASA/Hubble (cosmos), **NOAA
|
||||
Ocean Exploration** (deep sea, global), **NPS/USGS** (US land). Wikimedia nature
|
||||
4K timelapses are mostly CC-BY, not PD.
|
||||
|
||||
2. **Operator pivot → "cosmic zoom".** Operator proposed a Powers-of-Ten journey
|
||||
(space → continents → birds → ocean → abyss → microscopic → cosmos). Brainstormed
|
||||
the thesis tension (a scale-journey carries built-in awe vs. the "neutral base"
|
||||
thesis) and the cost (per-base pre-bake × altitudes). Surfaced that the *ends* of
|
||||
the zoom are strict-PD-rich; the terrestrial *middle* (birds, non-US land) is the
|
||||
CC-BY soft spot.
|
||||
|
||||
3. **"Can this run locally?" → POC.** Operator's machine: **Mac mini M4 Pro, 64 GB,
|
||||
16-core GPU.** Built a throwaway POC in `~/hef-poc/` (outside the repo).
|
||||
- *Wrong turns:* `brew install ffmpeg` and `pip install` were **denied by the
|
||||
harness** (it blocks package installs) — switched to venv-local `imageio-ffmpeg`
|
||||
run by the operator via `!`. zsh **doesn't word-split** unquoted `$EARGS` →
|
||||
mangled encoder flags; inlined them. sd-turbo img2img **crashed** ("reshape
|
||||
tensor of 0 elements") because `int(steps×strength)=int(2×0.45)=0` denoise steps
|
||||
→ guarded to ≥1.
|
||||
- *Results on an 8s/1080p clip:* deterministic **Dark/Light/Left** grades/overlay
|
||||
~2.4s each (~3× faster than realtime, runtime-capable). **Right** painterly
|
||||
restyle (SD img2img on MPS) ~3.4 min/clip and **flickers badly**.
|
||||
|
||||
4. **Flicker is disqualifying.** Operator: "crazy and disorienting … this project is
|
||||
meant to be peaceful." Prototyped two fixes: (a) a **deterministic** soft-dreamy
|
||||
filter (smartblur + RGB bloom — first attempt had a magenta cast from blending on
|
||||
YUV chroma planes; fixed by blending in RGB), ~5s, zero flicker by construction;
|
||||
(b) **optical-flow keyframe propagation** (EbSynth principle; genuine ebsynth is
|
||||
NVIDIA/Windows so implemented with OpenCV Farneback + the diffusers pipeline),
|
||||
~2.7 min/clip, **calm**. Operator chose **AI + flow**.
|
||||
|
||||
5. **Spec session (comprehensive).** Upgraded the session via
|
||||
`wgl-session-spec-init` → claimed 0008. Resolved the cosmic-zoom fork: operator
|
||||
chose a **small NEUTRAL "scales of nature" library** (not a single stitched film).
|
||||
Then operator added the **infinite-zoom ring** (AI zoom transitions between
|
||||
scales, micro→cosmos wraparound) and the **infinitely-turnable endless encoder**.
|
||||
Probed i18n feasibility → **§1.2**: Left HUD is a Pi-rendered **runtime overlay**
|
||||
(Pango/HarfBuzz + Noto, annotation-track + per-language string tables), NOT a baked
|
||||
per-language video.
|
||||
- Wrote `docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md`,
|
||||
committed on `feature/scales-library-right-axis`.
|
||||
|
||||
## Cut state (what landed)
|
||||
|
||||
- **PR #6 merged to `main`** (merge `42a72fe`): the design revision doc.
|
||||
- No spec-RFC submission: this app keeps specs in-repo (`app.json` `contains:["specs"]`),
|
||||
so `submit-spec.sh` is N/A — surfaced, not dropped.
|
||||
- POC artifacts left in `~/hef-poc/` (throwaway, outside repo): `restyle.py`,
|
||||
`flow_restyle.py`, and the comparison clips/contact sheets.
|
||||
- Memory updated: `sub-project-3-player-progress.md` (+ `MEMORY.md` index).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Left-HUD treatment conflict (low confidence — flag).** 0008 §1.2 specifies the
|
||||
Left HUD as a runtime Pango/HarfBuzz overlay (to keep i18n near-free, per parent
|
||||
§10). This **reverses session 0007's decision** to bake the HUD into the 5×5
|
||||
variant grid for authorial precision. I recommended the runtime-overlay path
|
||||
following the operator's i18n question without flagging the 0007 reversal at the
|
||||
time. `main` now carries the runtime-overlay position; the unmerged
|
||||
`feature/simulator-alteration-preview` carries baked-HUD. **Reconciliation deferred
|
||||
to next session** (pick one, or hybrid: authored positions + runtime-shaped text).
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal Reconcile the 0007 simulator-alteration-preview design (feature/simulator-alteration-preview, UNMERGED) with the merged 0008 scales-library + flow-stabilized Right-axis design — resolve the Left-HUD conflict (baked-into-5×5-grid vs runtime Pango/HarfBuzz overlay) FIRST — then writing-plans → build the simulator-first slice: deterministic Dark/Light/Left + a flow-stabilized Right restyle over ONE neutral scale clip wired into the simulator. Defer Pi renderer, endless-encoder/serial, and AI zoom-transitions per simulator-first.
|
||||
```
|
||||
@@ -1,130 +0,0 @@
|
||||
# Session 0009.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-07T22-41 (PST)
|
||||
> End: 2026-06-07T23-03 (PST)
|
||||
> Type: coding
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Opened with `/goal next` — resume the goal recorded at the 0008 finalize. The
|
||||
session gate resolved the stored `Next /goal:` from memory:
|
||||
|
||||
> Reconcile the 0007 simulator-alteration-preview design (UNMERGED) with the
|
||||
> merged 0008 scales-library + flow-stabilized Right-axis design — resolve the
|
||||
> Left-HUD conflict (baked-into-5×5-grid vs runtime Pango/HarfBuzz overlay) FIRST
|
||||
> — then writing-plans → build the simulator-first slice: deterministic
|
||||
> Dark/Light/Left + a flow-stabilized Right restyle over ONE neutral scale clip
|
||||
> wired into the simulator. Defer Pi renderer, endless-encoder/serial, AI
|
||||
> zoom-transitions.
|
||||
|
||||
## Plan
|
||||
|
||||
Brainstorm the reconciliation (resolve Left-HUD first) → write a unified design
|
||||
doc → writing-plans → build & merge the simulator-first alteration slice.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- On `main` at `c99a669`, clean tree; ran from the **main clone** (not a nested
|
||||
worktree — avoiding the session-resolver ambiguity gotcha flagged in 0008).
|
||||
- Two unmerged design threads: session-0007's
|
||||
`feature/simulator-alteration-preview` (baked-HUD 5×5 grid, design doc only, no
|
||||
code) vs. session-0008's merged scales-library/right-axis design (runtime-overlay
|
||||
HUD). The Left-HUD conflict between them was the thing to resolve.
|
||||
- Slice-1 engine (`player/`) on `main`: `RenderPlan {grade, overlay, restyle}` with
|
||||
a continuous `Restyle.blend` and a runtime `AnalyticalOverlay`. Simulator still on
|
||||
the old *selection* surface (`/api/select`, X-ray, `fixtures.py`).
|
||||
- 0008 POC artifacts present in `~/hef-poc/out/` (outside repo): real `neutral.mp4`
|
||||
base + `right_flow.mp4` flow-stabilized restyle.
|
||||
|
||||
## Session arc (uncurated)
|
||||
|
||||
1. **Claimed 0009** (coding) from the main clone; resolver worked cleanly. Read
|
||||
both specs + memory in full.
|
||||
|
||||
2. **Surfaced the conflict precisely — it was 2-D, not 1-D.** Beyond the Left-HUD
|
||||
rendering question, the two designs disagreed on the *shape of the pre-baked set*:
|
||||
0007 made BOTH Left and Right a 5×5 grid of 24 authored clips; 0008 made only
|
||||
Right pre-baked and Left a runtime overlay. Key realization: **0008 §1.2 already
|
||||
IS the hybrid the operator floated** ("authored box positions + runtime-shaped
|
||||
text") — authored annotation track for layout, runtime shaping for cheap i18n.
|
||||
|
||||
3. **Brainstorming → one operator question.** Asked the single load-bearing fork
|
||||
(Left HUD: runtime-overlay+authored-track vs fully-baked vs defer). Operator chose
|
||||
**runtime overlay + authored track** (the 0008 / hybrid position). Everything else
|
||||
followed from approved material, so I proceeded autonomously.
|
||||
|
||||
4. **Found the engine was already most of the way there.** The merged slice-1 engine
|
||||
already keeps a runtime `AnalyticalOverlay` — only 0007's *unmerged doc* proposed
|
||||
removing it. So the reconciliation was surgical: continuous `Restyle.blend` →
|
||||
discrete `Restyle.variant`; add `AnalyticalOverlay.level`; add a frozen
|
||||
`Calibration`. Wrote the reconciled design doc (brought the 0007 doc onto the
|
||||
branch so its superseded position is preserved + links resolve), committed.
|
||||
|
||||
5. **writing-plans → a full TDD plan** (`docs/superpowers/plans/...`), then executed
|
||||
it task-by-task with executing-plans:
|
||||
- **Task 1–2 (engine):** `Calibration`+`DEFAULT_CALIBRATION` (behavior-preserving),
|
||||
`Restyle.variant`, `AnalyticalOverlay.level`, `render_plan_to_dict`. `state.py`
|
||||
needed no logic change (compares whole `Restyle`). Tests rewritten/green.
|
||||
- **Task 3 (clips):** `simulator/clips.py` manifest model; retired `fixtures.py` +
|
||||
`test_fixtures.py`.
|
||||
- **Task 4 (API):** `/api/alteration` + `/api/clips`; removed `/api/select` +
|
||||
`/api/catalog/meta`. *Wrong turn:* asserted retired POST returns 404, but the
|
||||
static catch-all yields **405** for an unrouted POST — relaxed the assertion to
|
||||
"404 or 405 = gone."
|
||||
- **Task 5 (media):** `sample_media/manifest.json` + `setup_sample_media.py` that
|
||||
copies the real POC `neutral.mp4`→base and `right_flow.mp4`→Right-strength-4 and
|
||||
ffmpeg-blends placeholder strengths 1–3. mp4s gitignored. Ran it — 5 files
|
||||
produced.
|
||||
- **Task 6 (UI):** rewrote `simulator/static/` as the alteration preview (live
|
||||
grade via CSS filters, Right-variant `<video>` crossfade, live SVG Left overlay
|
||||
from the annotation track + string table, calibration panel, RenderPlan readout).
|
||||
- **Task 7 (docs):** parent design §4.3/§10 pointers; ROADMAP slice 2 done +
|
||||
deferred slices; USER_GUIDE simulator section rewritten.
|
||||
- *Environment:* `python` not on PATH; used `.venv/bin/python` throughout.
|
||||
|
||||
6. **Verified end-to-end.** `pytest -q` → **192 passed, 2 skipped**. Booted the sim
|
||||
on a scratch port: `/api/clips` returns the manifest (variant 0→base), the engine
|
||||
plan is correct by eye (left=3→level 3/intensity 0.75, right=4→variant 4,
|
||||
dark=4→tone −1.0), and the **real** `right4.mp4` + base served as `video/mp4`.
|
||||
|
||||
7. **Shipped.** Pushed the branch, created **Gitea PR #7** via the keychain-token API
|
||||
helper, **merged** it (autonomous posture), synced `main`, re-verified tests green
|
||||
on the merged result, deleted my merged feature branch (local + remote).
|
||||
|
||||
## Cut state (what landed)
|
||||
|
||||
- **PR #7 merged to `main`** (merge `554eb50`): the reconciliation design + the built
|
||||
simulator-first alteration slice (engine + simulator + media tooling + docs). 9
|
||||
commits.
|
||||
- `main` green: 192 passed / 2 skipped. Sim verified booting + serving real media.
|
||||
- Memory updated: `sub-project-3-player-progress.md` (+ `MEMORY.md` index) — conflict
|
||||
resolved, slice 2 shipped, new Next /goal.
|
||||
- POC artifacts untouched in `~/hef-poc/` (still the source for `setup_sample_media.py`).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Brought the 0007 design doc into `main` + intended to delete the superseded
|
||||
branch.** Decided autonomously to carry `2026-06-06-simulator-alteration-preview-design.md`
|
||||
forward (so the reconciliation's links resolve and the superseded position is
|
||||
preserved) and to delete the now-stale `feature/simulator-alteration-preview`.
|
||||
**The branch deletion (`git branch -D` / `push --delete`) was permission-denied by
|
||||
the harness** — left in place. Safe to delete later (its content is in `main`); or
|
||||
the operator may want to keep it. Flagging rather than forcing.
|
||||
- **Merged under autonomous posture without a separate `/code-review`.** TDD + the
|
||||
written plan were the quality gates; no independent review pass was run before
|
||||
merge. Low risk for a slice this size, but noting it.
|
||||
- **Used the POC's Yosemite clip as the sim's sample base.** It's a "forest"-scale
|
||||
neutral clip, good enough to tune the look; framed in the manifest/USER_GUIDE as
|
||||
**look-tuning only, not shipped content**. Strict-PD scale-library sourcing stays a
|
||||
later slice — unaffected.
|
||||
- **`Calibration` defaults are still behavior-preserving, not operator-tuned.** The
|
||||
knob→strength calibration (open since session 0006) is now tunable by eye in the
|
||||
sim but **not yet locked** — that's the next goal.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal 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.
|
||||
```
|
||||
@@ -1,129 +0,0 @@
|
||||
# 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,167 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,113 +0,0 @@
|
||||
# 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.
|
||||
```
|
||||
@@ -1,115 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,129 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,117 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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._
|
||||
@@ -1,25 +0,0 @@
|
||||
# Session 0018.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-25T09-42 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: b96041e5-c682-49f5-88ee-380ea7abb6af
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0018 for human-experience-filter-art. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0018.0-TRANSCRIPT-2026-06-25T09-42--<end>.md form at session end.
|
||||
|
||||
## 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.
|
||||
|
||||
```
|
||||
|
||||
## 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._
|
||||
@@ -16,41 +16,5 @@
|
||||
},
|
||||
"0006": {
|
||||
"title": ""
|
||||
},
|
||||
"0007": {
|
||||
"title": ""
|
||||
},
|
||||
"0008": {
|
||||
"title": ""
|
||||
},
|
||||
"0009": {
|
||||
"title": ""
|
||||
},
|
||||
"0010": {
|
||||
"title": ""
|
||||
},
|
||||
"0011": {
|
||||
"title": ""
|
||||
},
|
||||
"0012": {
|
||||
"title": ""
|
||||
},
|
||||
"0013": {
|
||||
"title": ""
|
||||
},
|
||||
"0014": {
|
||||
"title": ""
|
||||
},
|
||||
"0015": {
|
||||
"title": ""
|
||||
},
|
||||
"0016": {
|
||||
"title": ""
|
||||
},
|
||||
"0017": {
|
||||
"title": ""
|
||||
},
|
||||
"0018": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
+61
-279
@@ -1,310 +1,92 @@
|
||||
"""FastAPI service: controls -> the real alteration engine -> a RenderPlan.
|
||||
|
||||
The simulator's alteration surface (reconciled slice). It calls the canonical
|
||||
player.alteration.plan_alteration; the browser only renders. The selection-era
|
||||
endpoints (/api/select, /api/catalog/meta) and the X-ray are retired.
|
||||
"""
|
||||
"""FastAPI service: dials -> real hef.selection -> X-ray (pick + ranked pool)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from hef.selection import Coordinate
|
||||
from player.alteration import (
|
||||
DEFAULT_CALIBRATION,
|
||||
Calibration,
|
||||
plan_alteration,
|
||||
render_plan_to_dict,
|
||||
from hef.catalog import load_catalog, record_to_dict
|
||||
from hef.selection import (
|
||||
Coordinate,
|
||||
Weights,
|
||||
candidates_for_mode,
|
||||
ranked_candidates,
|
||||
select,
|
||||
)
|
||||
from player.content import resolve_content
|
||||
from player.controls import CONTENT_POSITIONS
|
||||
from player.ring import (
|
||||
DEFAULT_FAST_SPIN_THRESHOLD,
|
||||
advance_ring,
|
||||
pick_clip_id,
|
||||
scale_at,
|
||||
)
|
||||
from simulator.clips import load_manifest, load_ring, ring_move_to_dict, ring_to_dict
|
||||
from simulator.fixtures import generate_fixture_catalog
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _rev_file(file: str) -> str:
|
||||
"""The baked zoom-out companion path for a transition file (mirrors the
|
||||
client's `reverseFile`): `<edge>.mp4` -> `<edge>.rev.mp4`."""
|
||||
return file[:-4] + ".rev.mp4" if file.endswith(".mp4") else file
|
||||
|
||||
|
||||
class ControlsModel(BaseModel):
|
||||
content: str
|
||||
class SelectRequest(BaseModel):
|
||||
left: int = Field(ge=0, le=4)
|
||||
right: int = Field(ge=0, le=4)
|
||||
dark: int = Field(ge=0, le=4)
|
||||
light: int = Field(ge=0, le=4)
|
||||
volume: int = Field(ge=0, le=4)
|
||||
brightness: int = Field(ge=0, le=4)
|
||||
mode: Literal["none", "audio", "video", "av"]
|
||||
pool_size: int = Field(default=4, ge=1, le=25)
|
||||
brain_weight: float = Field(default=1.0, ge=0.0)
|
||||
mood_weight: float = Field(default=1.0, ge=0.0)
|
||||
approved_only: bool = False
|
||||
|
||||
|
||||
class CalibrationModel(BaseModel):
|
||||
mood_gain: float = 1.0
|
||||
overlay_gain: float = 1.0
|
||||
dream_gain: float = 1.0
|
||||
def load_catalog_or_fixtures() -> list:
|
||||
"""Use the real catalog if a non-empty one is configured/exists, else fixtures."""
|
||||
configured = os.environ.get("HEF_SIM_CATALOG")
|
||||
path = Path(configured) if configured else Path("catalog/library.jsonl")
|
||||
if path.exists() and path.stat().st_size > 0:
|
||||
return load_catalog(path)
|
||||
return generate_fixture_catalog()
|
||||
|
||||
|
||||
class AlterationRequest(BaseModel):
|
||||
controls: ControlsModel
|
||||
calibration: Optional[CalibrationModel] = None
|
||||
def create_app(records: Optional[list] = None) -> FastAPI:
|
||||
app = FastAPI(title="HEF Experience Simulator")
|
||||
app.state.catalog = records if records is not None else load_catalog_or_fixtures()
|
||||
|
||||
|
||||
class RingAdvanceRequest(BaseModel):
|
||||
from_index: int = 0
|
||||
delta: int
|
||||
|
||||
|
||||
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():
|
||||
return load_manifest(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}")
|
||||
coord = Coordinate(c.left, c.right, c.dark, c.light)
|
||||
cal = (
|
||||
Calibration(
|
||||
mood_gain=req.calibration.mood_gain,
|
||||
overlay_gain=req.calibration.overlay_gain,
|
||||
dream_gain=req.calibration.dream_gain,
|
||||
)
|
||||
if req.calibration
|
||||
else DEFAULT_CALIBRATION
|
||||
@app.post("/api/select")
|
||||
def api_select(req: SelectRequest):
|
||||
catalog = app.state.catalog
|
||||
coord = Coordinate(req.left, req.right, req.dark, req.light)
|
||||
weights = Weights(brain=req.brain_weight, mood=req.mood_weight)
|
||||
if req.mode == "none":
|
||||
return {"pick": None, "pool": [], "coverage": {"candidates_in_mode": 0}}
|
||||
pool = catalog
|
||||
if req.approved_only:
|
||||
pool = [r for r in pool if r.review_status == "approved"]
|
||||
eligible = candidates_for_mode(pool, req.mode, req.pool_size)
|
||||
ranked = ranked_candidates(
|
||||
catalog, coord, req.mode,
|
||||
pool_size=req.pool_size, weights=weights, approved_only=req.approved_only,
|
||||
)
|
||||
pick = select(
|
||||
catalog, coord, req.mode,
|
||||
pool_size=req.pool_size, weights=weights, approved_only=req.approved_only,
|
||||
rng=None,
|
||||
)
|
||||
plan = plan_alteration(coord, cal)
|
||||
content = resolve_content(c.content)
|
||||
return {
|
||||
"plan": render_plan_to_dict(plan),
|
||||
"content": {"audio_source": content.audio_source, "video": content.video},
|
||||
"pick": record_to_dict(pick) if pick else None,
|
||||
"pool": [
|
||||
{"record": record_to_dict(r), "distance": d, "rank": i + 1}
|
||||
for i, (r, d) in enumerate(ranked)
|
||||
],
|
||||
"coverage": {"candidates_in_mode": len(eligible)},
|
||||
}
|
||||
|
||||
@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/catalog/meta")
|
||||
def api_meta():
|
||||
catalog = app.state.catalog
|
||||
return {
|
||||
"total": len(catalog),
|
||||
"by_mode": dict(Counter(r.mode for r in catalog)),
|
||||
"by_status": dict(Counter(r.review_status for r in catalog)),
|
||||
}
|
||||
|
||||
@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 each
|
||||
ring transition and its baked reverse. 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)
|
||||
files.add(_rev_file(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,
|
||||
)
|
||||
# Rotating pool: pick a random member of the LANDED scale (content-pipeline
|
||||
# §11.1). A delta=0 advance is the initial / re-roll pick (a no-op move that
|
||||
# still yields a fresh random clip). The pure pick takes injected randomness.
|
||||
landed = scale_at(app.state.ring, move.to_index)
|
||||
chosen = pick_clip_id(landed, random.random())
|
||||
return ring_move_to_dict(move, app.state.ring, chosen)
|
||||
|
||||
@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():
|
||||
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,423 +0,0 @@
|
||||
"""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_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"]
|
||||
|
||||
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_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_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]} for s in RING_ORDER]
|
||||
transitions = []
|
||||
n = len(RING_ORDER)
|
||||
for i in range(n):
|
||||
a, b = RING_ORDER[i], RING_ORDER[(i + 1) % n]
|
||||
transitions.append({"file": f"transitions/{a}-{b}.mp4", "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 _make_transition(ff: str, scale_a: str, scale_b: str) -> Path:
|
||||
"""A zoom/warp morph between two scales, from their PRIMARY pool members'
|
||||
bases (mirrors setup_scales_media.py but keyed by scale->primary). This is the
|
||||
recipe behind the well-liked orbit-coast edge; generate_media() now applies it
|
||||
uniformly to every edge so none carry stale footage from an earlier ring."""
|
||||
a = MEDIA / POOLS[scale_a][0] / "base.mp4"
|
||||
b = MEDIA / POOLS[scale_b][0] / "base.mp4"
|
||||
out = MEDIA / "transitions" / f"{scale_a}-{scale_b}.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 _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([
|
||||
ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", str(out),
|
||||
], check=True, capture_output=True)
|
||||
return out
|
||||
|
||||
|
||||
def generate_media() -> None:
|
||||
"""(Re)build EVERY ring-edge transition from the current real primary bases —
|
||||
the orbit-coast recipe applied uniformly, overwriting any stale clips left from
|
||||
an earlier ring — plus a reversed companion per edge for zoom-out moves."""
|
||||
ff = _ffmpeg()
|
||||
n = len(RING_ORDER)
|
||||
for i in range(n):
|
||||
a, b = RING_ORDER[i], RING_ORDER[(i + 1) % n]
|
||||
fwd = _make_transition(ff, a, b)
|
||||
_make_reverse(ff, fwd)
|
||||
print(f"generated transitions/{a}-{b}.mp4 (+ .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:])
|
||||
@@ -1,160 +0,0 @@
|
||||
"""The base-clip + variant + annotation manifest the simulator renders.
|
||||
|
||||
Replaces simulator/fixtures.py (the selection-era synthetic catalog). Each base
|
||||
clip carries: the raw base file, a map of pre-baked Right-strength variant files
|
||||
(strength 0 is always the raw base), an authored Left annotation track (box +
|
||||
label key + the minimum Left level at which it appears), and per-language string
|
||||
tables. See the reconciled-simulator-alteration-slice design §3.2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from player.ring import RingMove, Scale, ScaleRing, Transition, scale_at
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Clip:
|
||||
id: str
|
||||
title: str
|
||||
base_file: str
|
||||
license: str
|
||||
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:
|
||||
"""The video file for a Right strength; 0 and any unauthored strength
|
||||
fall back to the raw base file."""
|
||||
entry = self.right_variants.get(str(strength))
|
||||
return entry["file"] if entry else self.base_file
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
variants = {"0": {"file": self.base_file, "raw": True}}
|
||||
for k, v in self.right_variants.items():
|
||||
variants[k] = v
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"base_file": self.base_file,
|
||||
"license": self.license,
|
||||
"source": self.source,
|
||||
"right_variants": variants,
|
||||
"annotations": self.annotations,
|
||||
"affect": self.affect,
|
||||
"strings": self.strings,
|
||||
}
|
||||
|
||||
|
||||
def _clip_from_dict(d: dict[str, Any]) -> Clip:
|
||||
return Clip(
|
||||
id=d["id"],
|
||||
title=d["title"],
|
||||
base_file=d["base_file"],
|
||||
license=d.get("license", ""),
|
||||
source=d.get("source", ""),
|
||||
right_variants=d.get("right_variants", {}),
|
||||
annotations=d.get("annotations", []),
|
||||
affect=d.get("affect", []),
|
||||
strings=d.get("strings", {}),
|
||||
)
|
||||
|
||||
|
||||
def load_manifest(path: str | Path) -> list[Clip]:
|
||||
"""Load the base-clip manifest. Raises FileNotFoundError if missing."""
|
||||
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)
|
||||
|
||||
|
||||
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(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),
|
||||
"pool": [
|
||||
{"clip_id": cid, "title": titles.get(cid, cid)}
|
||||
for cid in s.members
|
||||
],
|
||||
}
|
||||
for s in ring.scales
|
||||
],
|
||||
"transitions": [{"file": t.file, "model": t.model} for t in ring.transitions],
|
||||
}
|
||||
|
||||
|
||||
def ring_move_to_dict(
|
||||
move: RingMove, ring: ScaleRing, chosen_clip_id: str | None = None
|
||||
) -> dict:
|
||||
"""JSON form of an encoder move: the landing scale's clip and the ordered
|
||||
transition clips to play (with direction). `fast` flags a collapsed fast-spin
|
||||
pass; the single step then carries `blended` so the renderer plays it quick.
|
||||
|
||||
`target_clip_id` is the pool member the player should load on landing. The
|
||||
caller passes the random pick (`pick_clip_id(landed_scale, random.random())`,
|
||||
content-pipeline §11.1); when omitted it falls back to the scale's primary
|
||||
`clip_id` (deterministic — keeps pre-pool callers/tests working)."""
|
||||
return {
|
||||
"from_index": move.from_index,
|
||||
"to_index": move.to_index,
|
||||
"wrapped": move.wrapped,
|
||||
"fast": move.fast,
|
||||
"target_clip_id": chosen_clip_id or scale_at(ring, move.to_index).clip_id,
|
||||
"steps": [
|
||||
{
|
||||
"edge": st.edge,
|
||||
"reversed": st.reversed,
|
||||
"file": st.file,
|
||||
"to_index": st.to_index,
|
||||
"blended": st.blended,
|
||||
}
|
||||
for st in move.steps
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Deterministic synthetic catalog so the selection model can be felt everywhere.
|
||||
|
||||
The real catalog (catalog/library.jsonl) is empty; this generates one record per
|
||||
cell of the 5x5 brain x 5x5 mood coordinate space (625 records), with a seeded
|
||||
mix of content modes and review statuses and no real media attached.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from hef.catalog import Record
|
||||
|
||||
MODES = ("audio", "video", "av")
|
||||
ARCHIVES = ("internet_archive", "musopen", "librivox", "nasa", "freesound")
|
||||
|
||||
_LEFT_WORDS = ("Treatise", "Lecture", "Field Notes", "Reading", "Documentary")
|
||||
_RIGHT_WORDS = ("Reverie", "Nocturne", "Bloom", "Drift", "Aurora")
|
||||
|
||||
|
||||
def _title(left: int, right: int, dark: int, light: int, mode: str) -> str:
|
||||
a = _LEFT_WORDS[left] if left >= right else _RIGHT_WORDS[right]
|
||||
return f"{a} ({mode}) L{left}R{right}D{dark}Li{light}"
|
||||
|
||||
|
||||
def generate_fixture_catalog(seed: int = 1729) -> list[Record]:
|
||||
"""One valid Record per coordinate cell (625 total), deterministic for a seed."""
|
||||
rng = random.Random(seed)
|
||||
records: list[Record] = []
|
||||
n = 0
|
||||
for left in range(5):
|
||||
for right in range(5):
|
||||
for dark in range(5):
|
||||
for light in range(5):
|
||||
mode = rng.choice(MODES)
|
||||
status = rng.choice(("proposed", "approved"))
|
||||
is_video = mode in ("video", "av")
|
||||
records.append(
|
||||
Record(
|
||||
id=f"fx-{n:04d}",
|
||||
title=_title(left, right, dark, light, mode),
|
||||
source_url=f"https://example.test/fx/{n:04d}",
|
||||
source_archive=rng.choice(ARCHIVES),
|
||||
license="public_domain",
|
||||
mode=mode,
|
||||
left=left,
|
||||
right=right,
|
||||
dark=dark,
|
||||
light=light,
|
||||
duration_s=rng.choice((300, 480, 600, 720, 900)),
|
||||
file_path="",
|
||||
review_status=status,
|
||||
resolution="1920x1080" if is_video else "",
|
||||
rationale=f"fixture at ({left},{right},{dark},{light})",
|
||||
reviewed_at="2026-06-04T00:00:00Z" if status == "approved" else None,
|
||||
)
|
||||
)
|
||||
n += 1
|
||||
return records
|
||||
@@ -1,21 +0,0 @@
|
||||
# Simulator sample media
|
||||
|
||||
`manifest.json` is committed; the `.mp4` binaries are **not** (gitignored). They
|
||||
are look-tuning samples, not shipped installation content.
|
||||
|
||||
Populate them in two steps:
|
||||
|
||||
python simulator/setup_sample_media.py # forest (the real POC base + Right variants)
|
||||
python simulator/setup_scales_media.py # cosmos + abyss scales + ring transitions
|
||||
|
||||
`setup_sample_media.py` copies `~/hef-poc/out/neutral.mp4` → `forest/base.mp4`
|
||||
and `~/hef-poc/out/right_flow.mp4` → `forest/right4.mp4` (the real flow-stabilized
|
||||
restyle), and generates placeholder strengths `forest/right1..3.mp4`.
|
||||
|
||||
`setup_scales_media.py` makes the scale **ring** demonstrable: cheap synthetic
|
||||
placeholder bases for the two true-PD scales — `cosmos/base.mp4` (NASA/Hubble)
|
||||
and `abyss/base.mp4` (NOAA Ocean Exploration) — plus the per-edge zoom/warp
|
||||
**transition** clips under `transitions/`. The manifest records the real true-PD
|
||||
provenance; these bytes are labelled placeholders for tuning ring navigation.
|
||||
The new scales carry a raw base only — the expensive multi-strength
|
||||
flow-stabilized Right re-bake is **deferred** until the ring is liked.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
"""Stage the forest scale's neutral BASE clip from the session-0008 POC artifacts.
|
||||
|
||||
Copies the real neutral Yosemite base out of ~/hef-poc/out/ into
|
||||
simulator/sample_media/forest/. The real multi-strength Right variants are then
|
||||
produced by the offline baker (`simulator/bake_right_variants.py`) — this script
|
||||
no longer generates placeholder Right strengths, so re-running it never clobbers
|
||||
a real bake. The media binaries are gitignored; only the manifest is committed.
|
||||
Sample footage is for look-tuning only, not shipped content.
|
||||
|
||||
Usage:
|
||||
python simulator/setup_sample_media.py # stage the base
|
||||
python -m simulator.bake_right_variants --scale forest # then bake variants
|
||||
Requires: ~/hef-poc/out/neutral.mp4 (the POC neutral base).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
POC = Path.home() / "hef-poc" / "out"
|
||||
DEST = Path(__file__).parent / "sample_media" / "forest"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
DEST.mkdir(parents=True, exist_ok=True)
|
||||
base = DEST / "base.mp4"
|
||||
shutil.copyfile(POC / "neutral.mp4", base)
|
||||
print(f"staged {base} (real POC neutral base)")
|
||||
print("next: python -m simulator.bake_right_variants --scale forest")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Generate cheap placeholder media for the full 5-scale RING (scales design §3).
|
||||
|
||||
SUPERSEDED (session 0016): the ring now uses real strict-PD footage in a ROTATING
|
||||
POOL per scale, regenerated by `simulator/build_pool_manifest.py` (which also makes
|
||||
the new coast-edge transition placeholders). This generator built the older
|
||||
one-base-per-scale forest ring (cosmos/orbit/forest/reef/abyss) and is kept only
|
||||
for reference / a clean-room placeholder rebuild; it does NOT match the current
|
||||
pool manifest or ring order (cosmos → orbit → coast → reef → abyss).
|
||||
|
||||
|
||||
Produces offline synthetic placeholders for all five ring scales — **cosmos**
|
||||
(NASA/Hubble), **orbit** (NASA/ISS), **forest** (NPS/USGS), **reef** (NOAA Ocean
|
||||
Exploration), and **abyss** (NOAA Ocean Exploration) — plus the short zoom/warp
|
||||
**transition** clips between adjacent scales. Forest reuses the real POC base from
|
||||
`setup_sample_media.py` (or a synthetic placeholder if absent); all others are
|
||||
generated as labelled solid-tint clips.
|
||||
|
||||
This is deliberately the CHEAP path: the manifest records the real true-PD
|
||||
provenance (NASA / NPS / USGS / NOAA, 17 U.S.C. §105), but the bytes here are
|
||||
labelled synthetic placeholders for look-tuning the ring navigation. The Right dream
|
||||
is real-time (Kuwahara WebGL shader), so no per-clip ML bake is needed; each scale
|
||||
carries a raw base only. Real strict-PD bases replace these placeholders via the
|
||||
content pipeline (see `docs/content-sourcing.md`).
|
||||
|
||||
Usage: python simulator/setup_scales_media.py
|
||||
Requires: ffmpeg on PATH (or `pip install imageio-ffmpeg`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
MEDIA = Path(__file__).parent / "sample_media"
|
||||
DUR = 6 # base clip seconds
|
||||
XDUR = 1.5 # transition seconds
|
||||
SIZE = "1280x720"
|
||||
FPS = 25
|
||||
|
||||
# Each scale: (dir, base color, on-screen placeholder label).
|
||||
SCALES = {
|
||||
"cosmos": ("0x05060f", "COSMOS — NASA/Hubble (PD placeholder)"),
|
||||
"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)"),
|
||||
}
|
||||
|
||||
# Ring edges (adjacent pairs + the abyss->cosmos wrap), matching the manifest.
|
||||
EDGES = [("cosmos", "orbit"), ("orbit", "forest"), ("forest", "reef"),
|
||||
("reef", "abyss"), ("abyss", "cosmos")]
|
||||
|
||||
|
||||
def _ffmpeg() -> str:
|
||||
if shutil.which("ffmpeg"):
|
||||
return "ffmpeg"
|
||||
import imageio_ffmpeg
|
||||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||
|
||||
|
||||
def _run(args: list[str]) -> None:
|
||||
subprocess.run(args, check=True, capture_output=True)
|
||||
|
||||
|
||||
def _make_base(ff: str, name: str, color: str, label: str) -> Path:
|
||||
out = MEDIA / name / "base.mp4"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A calm solid tint + faint noise grain (reads as stars/particles), with a
|
||||
# placeholder label so it is never mistaken for shipped footage.
|
||||
vf = (
|
||||
"noise=alls=8:allf=t,"
|
||||
"drawtext=text='" + label + "':fontcolor=white@0.55:fontsize=24:"
|
||||
"x=(w-text_w)/2:y=h-48"
|
||||
)
|
||||
_run([
|
||||
ff, "-y", "-f", "lavfi", "-i",
|
||||
f"color=c={color}:s={SIZE}:d={DUR}:r={FPS}",
|
||||
"-vf", vf, "-pix_fmt", "yuv420p", "-an", str(out),
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
def _make_transition(ff: str, src: str, dst: str) -> Path:
|
||||
a = MEDIA / src / "base.mp4"
|
||||
b = MEDIA / dst / "base.mp4"
|
||||
out = MEDIA / "transitions" / f"{src}-{dst}.mp4"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
w, h = SIZE.split("x")
|
||||
# A placeholder zoom/warp morph: zoom-in crossfade from src into dst. Both
|
||||
# bases are normalized first (size/fps/sar/format) because the real forest
|
||||
# POC base differs in resolution + fps from the synthetic scale placeholders,
|
||||
# and xfade requires identical frame geometry on both inputs.
|
||||
norm = f"trim=0:3,setpts=PTS-STARTPTS,scale={w}:{h},fps={FPS},setsar=1,format=yuv420p"
|
||||
_run([
|
||||
ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
|
||||
f"[0:v]{norm}[a];[1:v]{norm}[b];"
|
||||
f"[a][b]xfade=transition=zoomin:duration={XDUR}:offset=0.75,"
|
||||
"format=yuv420p[v]",
|
||||
"-map", "[v]", "-an", str(out),
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ff = _ffmpeg()
|
||||
for name, (color, label) in SCALES.items():
|
||||
base = MEDIA / name / "base.mp4"
|
||||
if name == "forest" and base.exists():
|
||||
print(f"forest/base.mp4 present (real POC base) — keeping")
|
||||
continue
|
||||
_make_base(ff, name, color, label)
|
||||
print(f"generated {name}/base.mp4 (synthetic placeholder)")
|
||||
for src, dst in EDGES:
|
||||
_make_transition(ff, src, dst)
|
||||
print(f"generated transitions/{src}-{dst}.mp4 (placeholder zoom)")
|
||||
print(f"scale-ring media ready in {MEDIA}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+89
-894
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
||||
/* Author-mode-only styling (content-pipeline §11.5). Reuses style.css for the
|
||||
shared stage/panel chrome; this adds the box-draw layer + editor widgets. */
|
||||
|
||||
.author #draw {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.author #draw .seed-box {
|
||||
fill: rgba(80, 200, 255, 0.12);
|
||||
stroke: #50c8ff;
|
||||
stroke-width: 0.4;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.author #draw .track-box {
|
||||
fill: rgba(120, 255, 160, 0.10);
|
||||
stroke: #78ffa0;
|
||||
stroke-width: 0.4;
|
||||
stroke-dasharray: 1.5 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.author #draw .affect-dot {
|
||||
fill: rgba(190, 150, 255, 0.85);
|
||||
stroke: #fff;
|
||||
stroke-width: 0.25;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.author .scrub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.author .scrub input[type="range"] { flex: 1; }
|
||||
.author .mono { font-family: ui-monospace, monospace; font-size: 12px; opacity: 0.85; }
|
||||
|
||||
.author .row { display: flex; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
|
||||
.author fieldset label { display: block; margin: 4px 0; font-size: 13px; }
|
||||
.author fieldset input[type="text"],
|
||||
.author fieldset input[type="number"] { width: 100%; box-sizing: border-box; }
|
||||
.author fieldset input[type="number"] { width: 5em; }
|
||||
|
||||
.author ul.authored { list-style: none; padding: 0; margin: 6px 0; }
|
||||
.author ul.authored li {
|
||||
font-size: 12px;
|
||||
padding: 3px 6px;
|
||||
margin: 2px 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
.author ul.authored .rm {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #f88;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.author header .hint { font-size: 12px; opacity: 0.8; max-width: 70ch; }
|
||||
@@ -1,79 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>HEF — Label Author Mode</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="stylesheet" href="/author.css" />
|
||||
</head>
|
||||
<body class="author">
|
||||
<header><h1>HEF — Label Author Mode</h1>
|
||||
<p class="hint">Scrub a pool clip · drag a box on a subject · run the tracker · author the tiers · save to the manifest. Geometry is propagated; <strong>semantics are hand-authored</strong> (content-pipeline §11.5). <a href="/">← back to preview</a></p>
|
||||
</header>
|
||||
<main>
|
||||
<section class="stage" id="stage">
|
||||
<div class="screen">
|
||||
<video id="vid" muted playsinline></video>
|
||||
<svg id="draw" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
|
||||
</div>
|
||||
<div class="scrub">
|
||||
<button type="button" id="play">▶/⏸</button>
|
||||
<input type="range" id="seek" min="0" max="1000" value="0" />
|
||||
<span id="time" class="mono">0.00</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<fieldset>
|
||||
<legend>Clip</legend>
|
||||
<select id="clip"></select>
|
||||
<p class="hint" id="clip-meta">—</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Seed box (drag on the video)</legend>
|
||||
<p class="mono" id="seed-readout">no box — drag on the stage</p>
|
||||
<label>label key <input type="text" id="ann-key" placeholder="detected.jelly" /></label>
|
||||
<label>salience (1–4; 4 = shows first/at low Left)
|
||||
<input type="number" id="ann-salience" min="1" max="4" value="4" /></label>
|
||||
<label>tier 1 — general <input type="text" id="t1" placeholder="jelly" /></label>
|
||||
<label>tier 2 — specific <input type="text" id="t2" placeholder="comb jelly" /></label>
|
||||
<label>tier 3 — scientific <input type="text" id="t3" placeholder="Ctenophora" /></label>
|
||||
<label>tier 4 — +fact <input type="text" id="t4" placeholder="Ctenophora · beats rows of cilia" /></label>
|
||||
<label>keyframes <input type="number" id="ann-keyframes" min="2" max="20" value="5" /></label>
|
||||
<div class="row">
|
||||
<button type="button" id="run-track">Run tracker → track</button>
|
||||
<button type="button" id="add-static">Add as static box</button>
|
||||
</div>
|
||||
<p class="mono" id="track-readout"></p>
|
||||
<button type="button" id="add-ann" disabled>+ Add label</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Affect anchor (click the stage to place)</legend>
|
||||
<p class="mono" id="affect-readout">no point — shift-click the stage</p>
|
||||
<label>feel key <input type="text" id="aff-key" placeholder="feel.unease" /></label>
|
||||
<label>min strength (1–4) <input type="number" id="aff-min" min="1" max="4" value="1" /></label>
|
||||
<label>tier 1 — basic <input type="text" id="a1" placeholder="uh" /></label>
|
||||
<label>tier 2 <input type="text" id="a2" placeholder="unease" /></label>
|
||||
<label>tier 3 <input type="text" id="a3" placeholder="disquiet" /></label>
|
||||
<label>tier 4 — compound <input type="text" id="a4" placeholder="a creeping disquiet" /></label>
|
||||
<button type="button" id="add-aff" disabled>+ Add emotion</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Authored for this clip</legend>
|
||||
<ul id="ann-list" class="authored"></ul>
|
||||
<ul id="aff-list" class="authored"></ul>
|
||||
<div class="row">
|
||||
<button type="button" id="load-existing">Load existing</button>
|
||||
<button type="button" id="save">Save to manifest</button>
|
||||
</div>
|
||||
<p class="mono" id="save-readout"></p>
|
||||
</fieldset>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/author.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,254 +0,0 @@
|
||||
// Label author mode (content-pipeline §11.5): scrub a pool clip, drag a seed box
|
||||
// on a subject, run the classical tracker to propagate a keyframed track, author
|
||||
// the LEFT detail tiers + RIGHT emotion tiers, and save the entry to the manifest.
|
||||
// Geometry comes from the tracker; SEMANTICS (keys, strings, tiers) are hand-typed.
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const vid = $("vid"), draw = $("draw");
|
||||
const SVGNS = "http://www.w3.org/2000/svg";
|
||||
|
||||
let clips = [];
|
||||
let currentClip = null;
|
||||
let seedBox = null; // [x,y,w,h] normalized, from a drag
|
||||
let pendingTrack = null; // {track, appear, disappear} from the tracker (or static)
|
||||
let affectPoint = null; // [x,y] normalized, from a shift-click
|
||||
let annotations = []; // authored annotation dicts (carry _tiers)
|
||||
let affect = []; // authored affect dicts (carry _tiers)
|
||||
|
||||
function svg(tag, attrs, parent) {
|
||||
const el = document.createElementNS(SVGNS, tag);
|
||||
for (const k in attrs) el.setAttribute(k, attrs[k]);
|
||||
if (parent) parent.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
const mono = (n) => Number(n).toFixed(3);
|
||||
|
||||
async function loadClips() {
|
||||
clips = (await (await fetch("/api/clips")).json()).clips || [];
|
||||
const sel = $("clip");
|
||||
sel.innerHTML = "";
|
||||
for (const c of clips) {
|
||||
const o = document.createElement("option");
|
||||
o.value = c.id; o.textContent = c.id;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
if (clips.length) selectClip(clips[0].id);
|
||||
}
|
||||
|
||||
function selectClip(id) {
|
||||
currentClip = clips.find((c) => c.id === id) || null;
|
||||
if (!currentClip) return;
|
||||
$("clip-meta").textContent = `${currentClip.title} — ${currentClip.license}`;
|
||||
vid.src = "/media/" + currentClip.base_file;
|
||||
vid.currentTime = 0;
|
||||
annotations = []; affect = []; seedBox = null; pendingTrack = null; affectPoint = null;
|
||||
renderLists(); renderDrawLayer();
|
||||
$("seed-readout").textContent = "no box — drag on the stage";
|
||||
$("track-readout").textContent = "";
|
||||
$("affect-readout").textContent = "no point — shift-click the stage";
|
||||
}
|
||||
|
||||
// --- scrub ---
|
||||
function seekT() { return vid.duration ? vid.currentTime / vid.duration : 0; }
|
||||
$("play").addEventListener("click", () => { vid.paused ? vid.play() : vid.pause(); });
|
||||
$("seek").addEventListener("input", () => {
|
||||
if (vid.duration) vid.currentTime = (+$("seek").value / 1000) * vid.duration;
|
||||
});
|
||||
vid.addEventListener("timeupdate", () => {
|
||||
if (!vid.duration) return;
|
||||
$("seek").value = String(Math.round(seekT() * 1000));
|
||||
$("time").textContent = seekT().toFixed(3);
|
||||
});
|
||||
|
||||
// --- draw a seed box / place an affect point on the stage ---
|
||||
function evtNorm(e) {
|
||||
const r = draw.getBoundingClientRect();
|
||||
return [(e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height];
|
||||
}
|
||||
let dragStart = null;
|
||||
draw.addEventListener("mousedown", (e) => {
|
||||
if (e.shiftKey) { // shift-click = affect anchor
|
||||
affectPoint = evtNorm(e).map((v) => Math.min(Math.max(v, 0), 1));
|
||||
$("affect-readout").textContent = `affect @ ${affectPoint.map(mono).join(", ")}`;
|
||||
$("add-aff").disabled = false;
|
||||
renderDrawLayer();
|
||||
return;
|
||||
}
|
||||
dragStart = evtNorm(e);
|
||||
});
|
||||
draw.addEventListener("mousemove", (e) => {
|
||||
if (!dragStart) return;
|
||||
seedBox = boxFrom(dragStart, evtNorm(e));
|
||||
renderDrawLayer();
|
||||
});
|
||||
window.addEventListener("mouseup", (e) => {
|
||||
if (!dragStart) return;
|
||||
seedBox = boxFrom(dragStart, evtNorm(e));
|
||||
dragStart = null;
|
||||
pendingTrack = null;
|
||||
$("seed-readout").textContent = `seed [${seedBox.map(mono).join(", ")}] @ t=${seekT().toFixed(3)}`;
|
||||
$("add-ann").disabled = false;
|
||||
renderDrawLayer();
|
||||
});
|
||||
function boxFrom(a, b) {
|
||||
const x = Math.min(a[0], b[0]), y = Math.min(a[1], b[1]);
|
||||
const w = Math.abs(b[0] - a[0]), h = Math.abs(b[1] - a[1]);
|
||||
const cl = (v) => Math.min(Math.max(v, 0), 1);
|
||||
return [cl(x), cl(y), Math.min(w, 1 - cl(x)), Math.min(h, 1 - cl(y))];
|
||||
}
|
||||
|
||||
function renderDrawLayer() {
|
||||
draw.innerHTML = "";
|
||||
if (seedBox) {
|
||||
const [x, y, w, h] = seedBox.map((n) => n * 100);
|
||||
svg("rect", { x, y, width: w, height: h, class: "seed-box" }, draw);
|
||||
}
|
||||
if (pendingTrack && pendingTrack.track && vid.duration) {
|
||||
const b = boxAt(pendingTrack.track, seekT());
|
||||
if (b) {
|
||||
const [x, y, w, h] = b.map((n) => n * 100);
|
||||
svg("rect", { x, y, width: w, height: h, class: "track-box" }, draw);
|
||||
}
|
||||
}
|
||||
if (affectPoint) {
|
||||
const [x, y] = affectPoint.map((n) => n * 100);
|
||||
svg("circle", { cx: x, cy: y, r: 1.2, class: "affect-dot" }, draw);
|
||||
}
|
||||
}
|
||||
// Interpolate a track box at loop-normalized t (mirrors the preview renderer).
|
||||
function boxAt(track, t) {
|
||||
if (!track || !track.length) return null;
|
||||
if (t <= track[0].t) return track[0].box;
|
||||
if (t >= track[track.length - 1].t) return track[track.length - 1].box;
|
||||
for (let i = 1; i < track.length; i++) {
|
||||
if (t <= track[i].t) {
|
||||
const a = track[i - 1], b = track[i], f = (t - a.t) / (b.t - a.t);
|
||||
return a.box.map((v, j) => v + (b.box[j] - v) * f);
|
||||
}
|
||||
}
|
||||
return track[track.length - 1].box;
|
||||
}
|
||||
// Animate the pending track preview while playing.
|
||||
function previewLoop() { renderDrawLayer(); requestAnimationFrame(previewLoop); }
|
||||
|
||||
// --- run the tracker on the seed box ---
|
||||
$("run-track").addEventListener("click", async () => {
|
||||
if (!seedBox || !currentClip) { $("track-readout").textContent = "draw a seed box first"; return; }
|
||||
const key = $("ann-key").value.trim() || "detected.object";
|
||||
$("track-readout").textContent = "tracking…";
|
||||
const resp = await fetch("/api/author/track", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clip_id: currentClip.id, key, seed_box: seedBox, seed_t: seekT(),
|
||||
salience: +$("ann-salience").value, n_keyframes: +$("ann-keyframes").value,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) { $("track-readout").textContent = "tracker error " + resp.status; return; }
|
||||
const ann = await resp.json();
|
||||
pendingTrack = { track: ann.track, appear: ann.appear, disappear: ann.disappear };
|
||||
$("track-readout").textContent =
|
||||
`track: ${ann.track.length} keyframes · window ${mono(ann.appear)}–${mono(ann.disappear)}`;
|
||||
$("add-ann").disabled = false;
|
||||
});
|
||||
|
||||
// Add the current seed as a STATIC (untracked) box at the scrub position.
|
||||
$("add-static").addEventListener("click", () => {
|
||||
if (!seedBox) return;
|
||||
pendingTrack = { static: true, box: seedBox.slice() };
|
||||
$("track-readout").textContent = "static box (no track) at the drawn position";
|
||||
$("add-ann").disabled = false;
|
||||
});
|
||||
|
||||
function tiers(ids) {
|
||||
const vals = ids.map((id) => $(id).value.trim());
|
||||
while (vals.length && !vals[vals.length - 1]) vals.pop(); // drop empty trailing tiers
|
||||
return vals.length ? vals : null;
|
||||
}
|
||||
|
||||
$("add-ann").addEventListener("click", () => {
|
||||
const key = $("ann-key").value.trim();
|
||||
if (!key) { $("track-readout").textContent = "a label key is required"; return; }
|
||||
const t = tiers(["t1", "t2", "t3", "t4"]);
|
||||
const ann = { key, salience: +$("ann-salience").value, _tiers: t || key };
|
||||
if (pendingTrack && pendingTrack.track) {
|
||||
ann.track = pendingTrack.track;
|
||||
ann.appear = pendingTrack.appear; ann.disappear = pendingTrack.disappear;
|
||||
} else if (pendingTrack && pendingTrack.static) {
|
||||
ann.box = pendingTrack.box;
|
||||
} else if (seedBox) {
|
||||
ann.box = seedBox.slice();
|
||||
} else { $("track-readout").textContent = "draw + (optionally) track a box first"; return; }
|
||||
annotations.push(ann);
|
||||
seedBox = null; pendingTrack = null; $("add-ann").disabled = true;
|
||||
renderLists(); renderDrawLayer();
|
||||
});
|
||||
|
||||
$("add-aff").addEventListener("click", () => {
|
||||
const key = $("aff-key").value.trim();
|
||||
if (!key || !affectPoint) return;
|
||||
const t = tiers(["a1", "a2", "a3", "a4"]);
|
||||
affect.push({ key, at: affectPoint.slice(), min_level: +$("aff-min").value, _tiers: t || key });
|
||||
affectPoint = null; $("add-aff").disabled = true;
|
||||
renderLists(); renderDrawLayer();
|
||||
});
|
||||
|
||||
function renderLists() {
|
||||
const al = $("ann-list"); al.innerHTML = "";
|
||||
annotations.forEach((a, i) => {
|
||||
const li = document.createElement("li");
|
||||
const kind = a.track ? `track ${a.track.length}kf ${mono(a.appear)}–${mono(a.disappear)}` : "static";
|
||||
const tip = Array.isArray(a._tiers) ? a._tiers.join(" → ") : a._tiers;
|
||||
li.textContent = `${a.key} (s${a.salience}, ${kind}) — ${tip}`;
|
||||
li.appendChild(rm(() => { annotations.splice(i, 1); renderLists(); }));
|
||||
al.appendChild(li);
|
||||
});
|
||||
const fl = $("aff-list"); fl.innerHTML = "";
|
||||
affect.forEach((f, i) => {
|
||||
const li = document.createElement("li");
|
||||
const tip = Array.isArray(f._tiers) ? f._tiers.join(" → ") : f._tiers;
|
||||
li.textContent = `${f.key} (min ${f.min_level}) — ${tip}`;
|
||||
li.appendChild(rm(() => { affect.splice(i, 1); renderLists(); }));
|
||||
fl.appendChild(li);
|
||||
});
|
||||
}
|
||||
function rm(fn) {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = "✕"; b.className = "rm"; b.onclick = fn;
|
||||
return b;
|
||||
}
|
||||
|
||||
// Load whatever is already authored for this clip into the editor.
|
||||
$("load-existing").addEventListener("click", () => {
|
||||
if (!currentClip) return;
|
||||
const strings = (currentClip.strings && currentClip.strings.en) || {};
|
||||
annotations = (currentClip.annotations || []).map((a) => ({ ...a, _tiers: strings[a.key] || a.key }));
|
||||
affect = (currentClip.affect || []).map((f) => ({ ...f, _tiers: strings[f.key] || f.key }));
|
||||
renderLists();
|
||||
});
|
||||
|
||||
// Assemble strings.en from each entry's _tiers and POST to the manifest.
|
||||
$("save").addEventListener("click", async () => {
|
||||
if (!currentClip) return;
|
||||
const en = {};
|
||||
const strip = (x) => { const { _tiers, ...rest } = x; return rest; };
|
||||
const anns = annotations.map((a) => { en[a.key] = a._tiers; return strip(a); });
|
||||
const affs = affect.map((f) => { en[f.key] = f._tiers; return strip(f); });
|
||||
const resp = await fetch("/api/author/clip", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ clip_id: currentClip.id, annotations: anns, affect: affs, strings: { en } }),
|
||||
});
|
||||
$("save-readout").textContent = resp.ok
|
||||
? `saved ${anns.length} labels + ${affs.length} emotions to the manifest ✓`
|
||||
: "save error " + resp.status;
|
||||
if (resp.ok) { // refresh the in-memory clip copy
|
||||
clips = (await (await fetch("/api/clips")).json()).clips || [];
|
||||
currentClip = clips.find((c) => c.id === currentClip.id) || currentClip;
|
||||
}
|
||||
});
|
||||
|
||||
$("clip").addEventListener("change", (e) => selectClip(e.target.value));
|
||||
|
||||
async function main() {
|
||||
await loadClips();
|
||||
requestAnimationFrame(previewLoop);
|
||||
}
|
||||
main();
|
||||
+51
-78
@@ -1,91 +1,64 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>HEF — Alteration Simulator</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HEF — Curator's X-ray</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>Human Experience Filter — Alteration Preview</h1></header>
|
||||
<header>
|
||||
<h1>Experience Filter — Curator's X-ray</h1>
|
||||
<div id="meta" class="meta"></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="stage" id="stage">
|
||||
<div class="screen">
|
||||
<video id="vid" loop muted playsinline></video>
|
||||
<canvas id="paint"></canvas>
|
||||
<div id="tint"></div>
|
||||
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
|
||||
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
|
||||
<div id="black" class="black hidden"></div>
|
||||
</div>
|
||||
<section class="controls">
|
||||
<h2>Dials</h2>
|
||||
<label>Mode
|
||||
<select id="mode">
|
||||
<option value="none">None</option>
|
||||
<option value="audio">Audio</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="av" selected>A+V</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Left (analytical) <output id="left-out">0</output>
|
||||
<input type="range" id="left" min="0" max="4" step="1" value="0"></label>
|
||||
<label>Right (artistic) <output id="right-out">0</output>
|
||||
<input type="range" id="right" min="0" max="4" step="1" value="0"></label>
|
||||
<label>Dark (somber) <output id="dark-out">0</output>
|
||||
<input type="range" id="dark" min="0" max="4" step="1" value="0"></label>
|
||||
<label>Light (uplifting) <output id="light-out">0</output>
|
||||
<input type="range" id="light" min="0" max="4" step="1" value="0"></label>
|
||||
|
||||
<h2>Model knobs</h2>
|
||||
<label>Brain weight <output id="brain_weight-out">1</output>
|
||||
<input type="range" id="brain_weight" min="0" max="4" step="0.5" value="1"></label>
|
||||
<label>Mood weight <output id="mood_weight-out">1</output>
|
||||
<input type="range" id="mood_weight" min="0" max="4" step="0.5" value="1"></label>
|
||||
<label>Pool size <output id="pool_size-out">4</output>
|
||||
<input type="range" id="pool_size" min="1" max="10" step="1" value="4"></label>
|
||||
<label class="check"><input type="checkbox" id="approved_only"> Approved only</label>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<fieldset>
|
||||
<legend>Content dial</legend>
|
||||
<select id="content">
|
||||
<option value="video">video</option>
|
||||
<option value="audio_video">audio + video</option>
|
||||
<option value="music_video">music + video</option>
|
||||
<option value="off">off (black)</option>
|
||||
<option value="white_noise">white noise (no video)</option>
|
||||
<option value="music">music (no video)</option>
|
||||
<option value="audio_track">audio track (no video)</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Altitude</legend>
|
||||
<div class="dial-wrap">
|
||||
<svg id="dial" viewBox="0 0 100 100" aria-label="Altitude knob (turn to change scale)"></svg>
|
||||
</div>
|
||||
<span id="scale-name" class="scale-name">—</span>
|
||||
<p class="hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Experience knobs (0–4)</legend>
|
||||
<label>Left (analytical) <input type="range" id="left" min="0" max="4" value="0" /></label>
|
||||
<label>Right (dreamlike) <input type="range" id="right" min="0" max="4" value="0" /></label>
|
||||
<label>Mood — dark ◀ 0 ▶ light <input type="range" id="mood" min="-4" max="4" value="0" /></label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Dev Mode: a single toggle; everything dev (pool picker + analysis) lives below it. -->
|
||||
<label class="dev-switch" for="dev-mode">
|
||||
<input type="checkbox" id="dev-mode" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label">Dev Mode</span>
|
||||
</label>
|
||||
|
||||
<div id="dev-panel" class="dev-panel hidden">
|
||||
<fieldset>
|
||||
<legend>Pool — pick a clip</legend>
|
||||
<select id="pool-select" aria-label="Choose a clip from this altitude's pool"></select>
|
||||
<button type="button" id="pool-reroll" class="dev-btn">🎲 Re-roll random</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Active clip</legend>
|
||||
<dl id="clip-meta" class="dev-dl"></dl>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Annotations & affect</legend>
|
||||
<div id="clip-anno" class="dev-anno"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>RenderPlan</legend>
|
||||
<pre id="readout">—</pre>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Cache & playback</legend>
|
||||
<dl id="dev-stats" class="dev-dl"></dl>
|
||||
</fieldset>
|
||||
<section class="xray">
|
||||
<div class="pick">
|
||||
<h2>Picked</h2>
|
||||
<div id="pick"></div>
|
||||
</div>
|
||||
<div class="pool">
|
||||
<h2>Pool (nearest first)</h2>
|
||||
<ol id="pool"></ol>
|
||||
</div>
|
||||
<div class="maps">
|
||||
<h2>Coordinate maps</h2>
|
||||
<div class="map"><div class="label">Brain — Left × Right</div><div id="brain-grid" class="grid5"></div></div>
|
||||
<div class="map"><div class="label">Mood — Dark × Light</div><div id="mood-grid" class="grid5"></div></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+25
-101
@@ -1,102 +1,26 @@
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: #111; color: #eee; }
|
||||
header { padding: 0.6rem 1rem; background: #000; }
|
||||
h1 { font-size: 1rem; margin: 0; font-weight: 600; }
|
||||
main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; }
|
||||
.stage { flex: 1 1 640px; }
|
||||
.screen { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000;
|
||||
border-radius: 6px; overflow: hidden; }
|
||||
#vid { width: 100%; height: 100%; object-fit: cover; transition: opacity 0.15s ease; }
|
||||
/* Right-dream painterly canvas: a WebGL Kuwahara restyle of the LIVE video frames,
|
||||
drawn over the base. Edge-preserving, so motion stays as crisp as the source —
|
||||
the dream is the same footage, just stylized. The mood grade rides as a CSS
|
||||
filter here. Hidden when WebGL is unavailable (CSS fallback shows #vid). */
|
||||
#paint { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
|
||||
pointer-events: none; transition: filter 0.2s ease; }
|
||||
#tint { position: absolute; inset: 0; pointer-events: none; opacity: 0;
|
||||
background: #28425f; mix-blend-mode: multiply;
|
||||
transition: opacity 0.2s ease; }
|
||||
#overlay { position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
pointer-events: none; transition: opacity 0.2s ease; }
|
||||
/* Affect channel — its own layer so its opacity tracks min(left,right), not Left.
|
||||
Soft violet glow, distinct from the clinical reticles (the dream leaking in). */
|
||||
#affect { position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
pointer-events: none; transition: opacity 0.35s ease;
|
||||
filter: drop-shadow(0 0 0.9px rgba(176, 120, 255, 0.95)); }
|
||||
.hud-affect { fill: #e3cfff; font: italic 600 3.6px Georgia, "Times New Roman", serif;
|
||||
letter-spacing: 0.06px; }
|
||||
/* Left-brain analytical HUD. Two sensor channels: detection (cyan) + measurement
|
||||
(amber). Corner-bracket reticles, translucent label chips, a status tag. */
|
||||
.hud-reticle { fill: none; stroke-width: 0.6; vector-effect: non-scaling-stroke;
|
||||
stroke-linecap: square; }
|
||||
.hud-reticle.detect { stroke: #6cf; }
|
||||
.hud-reticle.measure { stroke: #ffc46b; }
|
||||
.hud-chip-bg { fill-opacity: 0.72; }
|
||||
.hud-chip-bg.detect { fill: #001722; }
|
||||
.hud-chip-bg.measure { fill: #1c1200; }
|
||||
.hud-chip-text { font: 2.4px monospace; letter-spacing: 0.04px; }
|
||||
.hud-chip-text.detect { fill: #aee6ff; }
|
||||
.hud-chip-text.measure { fill: #ffd79a; }
|
||||
.hud-conf { fill: #6cf; opacity: 0.8; }
|
||||
.hud-status { fill: #8fdcff; font: 2.4px monospace; opacity: 0.9; letter-spacing: 0.15px; }
|
||||
.black { position: absolute; inset: 0; background: #000; opacity: 1; transition: opacity 200ms ease; }
|
||||
.hidden { display: none; }
|
||||
.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
fieldset { border: 1px solid #333; border-radius: 6px; }
|
||||
legend { color: #9af; padding: 0 0.4rem; }
|
||||
label { display: block; margin: 0.4rem 0; }
|
||||
input[type=range], select { width: 100%; }
|
||||
#readout { background: #000; padding: 0.5rem; border-radius: 4px; font-size: 12px;
|
||||
white-space: pre-wrap; max-height: 240px; overflow: auto; }
|
||||
/* Altitude knob */
|
||||
.dial-wrap { display: flex; justify-content: center; padding: 0.3rem 0 0.1rem; }
|
||||
#dial { width: 190px; height: 190px; touch-action: none; cursor: grab; user-select: none; }
|
||||
#dial:active { cursor: grabbing; }
|
||||
.dial-rim { fill: #0d1320; stroke: #243352; stroke-width: 1.2; }
|
||||
.dial-body { fill: #16203200; stroke: #2c3c5c; stroke-width: 1; }
|
||||
.dial-tick { stroke: #3a4d70; stroke-width: 0.8; }
|
||||
.dial-label { fill: #789ac0; font-size: 6px; font-family: ui-monospace, monospace;
|
||||
letter-spacing: 0.2px; cursor: pointer; }
|
||||
.dial-label:hover { fill: #cde; }
|
||||
.dial-label.active { fill: #9cf; font-weight: 700; }
|
||||
.dial-caption { fill: #4d6184; font-size: 4.4px; letter-spacing: 1.2px;
|
||||
font-family: ui-monospace, monospace; }
|
||||
.dial-needle { fill: #9cf; }
|
||||
.dial-needle polygon { filter: drop-shadow(0 0 1px #9cf); }
|
||||
.dial-hub { fill: #2c3c5c; stroke: #9cf; stroke-width: 0.6; }
|
||||
.scale-name { display: block; text-align: center; font-size: 12px; color: #cde; }
|
||||
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #789; }
|
||||
|
||||
/* --- Dev Mode --- a single switch; all dev controls/data live in #dev-panel below it. */
|
||||
.dev-switch { display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
|
||||
padding: 0.5rem 0.2rem 0.2rem; border-top: 1px solid #222; user-select: none; }
|
||||
.dev-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.dev-switch-track { position: relative; width: 34px; height: 18px; border-radius: 9px;
|
||||
background: #2a2a2a; border: 1px solid #444; transition: background 0.15s; }
|
||||
.dev-switch-thumb { position: absolute; top: 1px; left: 1px; width: 14px; height: 14px;
|
||||
border-radius: 50%; background: #888; transition: transform 0.15s, background 0.15s; }
|
||||
.dev-switch input:checked + .dev-switch-track { background: #1d3a2a; border-color: #2e6; }
|
||||
.dev-switch input:checked + .dev-switch-track .dev-switch-thumb { transform: translateX(16px); background: #4e9; }
|
||||
.dev-switch input:focus-visible + .dev-switch-track { outline: 2px solid #9af; outline-offset: 1px; }
|
||||
.dev-switch-label { color: #9af; font-weight: 600; letter-spacing: 0.3px; }
|
||||
.dev-panel { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
/* Beat the generic `.hidden` (defined earlier, equal specificity): two classes
|
||||
win, so the panel truly collapses when Dev Mode is off. */
|
||||
.dev-panel.hidden { display: none; }
|
||||
.dev-panel legend { color: #4e9; }
|
||||
.dev-btn { margin-top: 0.5rem; width: 100%; padding: 0.4rem; cursor: pointer;
|
||||
background: #182028; color: #cde; border: 1px solid #2c3c5c; border-radius: 4px; }
|
||||
.dev-btn:hover { background: #20303f; }
|
||||
.dev-dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.15rem 0.6rem; font-size: 12px; }
|
||||
.dev-dl dt { color: #789; }
|
||||
.dev-dl dd { margin: 0; color: #dde; word-break: break-word; }
|
||||
.dev-dl dd.mem { color: #4e9; }
|
||||
.dev-dl dd.net { color: #fc6; }
|
||||
.dev-anno { font-size: 11px; max-height: 220px; overflow: auto;
|
||||
font-family: ui-monospace, monospace; }
|
||||
.dev-anno .anno-row { padding: 0.2rem 0; border-bottom: 1px solid #1d1d1d; }
|
||||
.dev-anno .anno-key { color: #aee6ff; }
|
||||
.dev-anno .anno-key.affect { color: #e3cfff; }
|
||||
.dev-anno .anno-key.measure { color: #ffd79a; }
|
||||
.dev-anno .anno-meta { color: #678; }
|
||||
.dev-anno .anno-empty { color: #567; font-style: italic; }
|
||||
body { margin: 0; font-family: -apple-system, system-ui, sans-serif; background: #0e0e16; color: #e6e6ee; }
|
||||
header { padding: 12px 20px; border-bottom: 1px solid #2a2a3a; display: flex; justify-content: space-between; align-items: baseline; }
|
||||
header h1 { font-size: 18px; margin: 0; }
|
||||
.meta { font-size: 12px; color: #9a9ab0; }
|
||||
main { display: grid; grid-template-columns: 280px 1fr; gap: 20px; padding: 20px; }
|
||||
.controls label { display: block; margin: 8px 0; font-size: 13px; }
|
||||
.controls input[type=range] { width: 100%; }
|
||||
.controls .check { display: flex; gap: 6px; align-items: center; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .5px; color: #9a9ab0; }
|
||||
.xray { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
|
||||
.pick #pick { background: #1a1a2e; border: 1px solid #33334a; border-radius: 8px; padding: 14px; min-height: 80px; }
|
||||
.pick .title { font-size: 16px; font-weight: 600; }
|
||||
.pick .void { color: #7777aa; font-style: italic; }
|
||||
.pool ol { margin: 0; padding-left: 18px; font-size: 13px; }
|
||||
.pool li { margin: 4px 0; }
|
||||
.pool li.winner { color: #7fffd4; font-weight: 600; }
|
||||
.pool .dist { color: #9a9ab0; }
|
||||
.maps { grid-column: 1 / -1; display: flex; gap: 40px; }
|
||||
.grid5 { display: grid; grid-template-columns: repeat(5, 28px); grid-template-rows: repeat(5, 28px); gap: 3px; }
|
||||
.grid5 .cell { background: #1c1c2c; border: 1px solid #2a2a3a; border-radius: 3px; position: relative; }
|
||||
.grid5 .cell.cand { background: #3a3a66; }
|
||||
.grid5 .cell.point { outline: 2px solid #7fffd4; }
|
||||
.grid5 .cell .n { position: absolute; right: 2px; bottom: 1px; font-size: 9px; color: #aab; }
|
||||
.label { font-size: 11px; color: #9a9ab0; margin-bottom: 4px; }
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from simulator.bake_right_variants import RIGHT_LEVELS, right_strength_params
|
||||
|
||||
|
||||
def test_levels_are_one_through_four():
|
||||
assert RIGHT_LEVELS == (1, 2, 3, 4)
|
||||
|
||||
|
||||
def test_strength_increases_with_level():
|
||||
keys = [right_strength_params(l)[0] for l in RIGHT_LEVELS]
|
||||
assert keys == sorted(keys) # monotonic ramp
|
||||
assert keys[0] < keys[-1] # subtle -> strong
|
||||
|
||||
|
||||
def test_tween_strength_is_a_fraction_of_key_strength():
|
||||
for l in RIGHT_LEVELS:
|
||||
key, tween = right_strength_params(l)
|
||||
assert 0.0 < tween < key # tween is a light refine only
|
||||
|
||||
|
||||
def test_rejects_out_of_range_level():
|
||||
with pytest.raises(ValueError):
|
||||
right_strength_params(0)
|
||||
with pytest.raises(ValueError):
|
||||
right_strength_params(5)
|
||||
@@ -1,158 +0,0 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from player.ring import RingMove, ScaleRing, TransitionStep, advance_ring
|
||||
from simulator.clips import (
|
||||
Clip,
|
||||
load_manifest,
|
||||
load_ring,
|
||||
ring_move_to_dict,
|
||||
ring_to_dict,
|
||||
)
|
||||
|
||||
|
||||
def _manifest_dict():
|
||||
return {
|
||||
"clips": [
|
||||
{
|
||||
"id": "forest",
|
||||
"title": "Yosemite Falls (neutral)",
|
||||
"base_file": "forest/base.mp4",
|
||||
"license": "poc-sample",
|
||||
"source": "hef-poc",
|
||||
"right_variants": {
|
||||
"4": {"file": "forest/right4.mp4", "model": "sd-turbo+flow"},
|
||||
"1": {"file": "forest/right1.mp4"},
|
||||
},
|
||||
"annotations": [
|
||||
{"key": "detected.water", "box": [0.1, 0.2, 0.3, 0.4], "min_level": 1},
|
||||
{"key": "detected.conifer", "box": [0.6, 0.1, 0.2, 0.2], "min_level": 3},
|
||||
],
|
||||
"strings": {"en": {"detected.water": "flowing water", "detected.conifer": "conifer"}},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_load_manifest_parses_clips(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_manifest_dict()))
|
||||
clips = load_manifest(p)
|
||||
assert len(clips) == 1
|
||||
c = clips[0]
|
||||
assert isinstance(c, Clip)
|
||||
assert c.id == "forest"
|
||||
assert c.base_file == "forest/base.mp4"
|
||||
|
||||
|
||||
def test_clip_lists_variant_files_by_strength(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_manifest_dict()))
|
||||
c = load_manifest(p)[0]
|
||||
# variant 0 is always the raw base; authored strengths come from the manifest
|
||||
assert c.variant_file(0) == "forest/base.mp4"
|
||||
assert c.variant_file(4) == "forest/right4.mp4"
|
||||
assert c.variant_file(1) == "forest/right1.mp4"
|
||||
# an unauthored strength falls back to the raw base
|
||||
assert c.variant_file(2) == "forest/base.mp4"
|
||||
|
||||
|
||||
def test_clip_serializes_to_dict_for_the_api(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_manifest_dict()))
|
||||
d = load_manifest(p)[0].to_dict()
|
||||
assert d["id"] == "forest"
|
||||
assert d["base_file"] == "forest/base.mp4"
|
||||
assert d["annotations"][0]["key"] == "detected.water"
|
||||
assert d["strings"]["en"]["detected.water"] == "flowing water"
|
||||
# variant map is exposed keyed by strength string, including 0 -> base
|
||||
assert d["right_variants"]["0"]["file"] == "forest/base.mp4"
|
||||
assert d["right_variants"]["4"]["file"] == "forest/right4.mp4"
|
||||
|
||||
|
||||
def test_missing_manifest_raises(tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_manifest(tmp_path / "nope.json")
|
||||
|
||||
|
||||
def _ring_manifest_dict():
|
||||
return {
|
||||
"clips": [],
|
||||
"ring": {
|
||||
"scales": [
|
||||
{"id": "cosmos", "clip_id": "cosmos"},
|
||||
{"id": "forest", "clip_id": "forest"},
|
||||
{"id": "abyss", "clip_id": "abyss"},
|
||||
],
|
||||
"transitions": [
|
||||
{"file": "transitions/cosmos-forest.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/forest-abyss.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/abyss-cosmos.mp4", "model": "placeholder"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_load_ring_builds_a_scale_ring(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_ring_manifest_dict()))
|
||||
ring = load_ring(p)
|
||||
assert isinstance(ring, ScaleRing)
|
||||
assert len(ring) == 3
|
||||
assert ring.scales[0].id == "cosmos"
|
||||
assert ring.scales[0].clip_id == "cosmos"
|
||||
assert ring.transitions[2].file == "transitions/abyss-cosmos.mp4"
|
||||
assert ring.transitions[0].model == "placeholder"
|
||||
|
||||
|
||||
def test_load_ring_is_none_when_absent(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_manifest_dict())) # no "ring" key
|
||||
assert load_ring(p) is None
|
||||
|
||||
|
||||
def test_ring_to_dict_carries_scale_titles_from_clips(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
md = _ring_manifest_dict()
|
||||
md["clips"] = [
|
||||
{"id": "cosmos", "title": "NASA cosmos", "base_file": "cosmos/base.mp4"},
|
||||
{"id": "forest", "title": "Yosemite", "base_file": "forest/base.mp4"},
|
||||
{"id": "abyss", "title": "NOAA abyss", "base_file": "abyss/base.mp4"},
|
||||
]
|
||||
p.write_text(json.dumps(md))
|
||||
ring = load_ring(p)
|
||||
clips = load_manifest(p)
|
||||
d = ring_to_dict(ring, clips)
|
||||
assert [s["id"] for s in d["scales"]] == ["cosmos", "forest", "abyss"]
|
||||
assert d["scales"][0]["title"] == "NASA cosmos"
|
||||
assert d["scales"][0]["clip_id"] == "cosmos"
|
||||
assert d["transitions"][2]["file"] == "transitions/abyss-cosmos.mp4"
|
||||
|
||||
|
||||
def test_ring_move_to_dict_serializes_steps(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_ring_manifest_dict()))
|
||||
ring = load_ring(p)
|
||||
move = advance_ring(ring, from_index=2, delta=1) # abyss -> wrap -> cosmos
|
||||
d = ring_move_to_dict(move, ring)
|
||||
assert d["from_index"] == 2
|
||||
assert d["to_index"] == 0
|
||||
assert d["wrapped"] is True
|
||||
assert d["target_clip_id"] == "cosmos"
|
||||
assert d["steps"][0]["file"] == "transitions/abyss-cosmos.mp4"
|
||||
assert d["steps"][0]["reversed"] is False
|
||||
assert d["steps"][0]["to_index"] == 0
|
||||
assert d["fast"] is False
|
||||
assert d["steps"][0]["blended"] is False
|
||||
|
||||
|
||||
def test_ring_move_to_dict_marks_fast_blended_pass(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps(_ring_manifest_dict()))
|
||||
ring = load_ring(p)
|
||||
move = advance_ring(ring, from_index=0, delta=4, fast_spin_threshold=3)
|
||||
d = ring_move_to_dict(move, ring)
|
||||
assert d["fast"] is True
|
||||
assert len(d["steps"]) == 1
|
||||
assert d["steps"][0]["blended"] is True
|
||||
@@ -0,0 +1,40 @@
|
||||
from hef.catalog import validate_catalog
|
||||
from hef.selection import CONTENT_MODES
|
||||
from simulator.fixtures import generate_fixture_catalog
|
||||
|
||||
|
||||
def test_fixture_catalog_is_valid():
|
||||
records = generate_fixture_catalog()
|
||||
validate_catalog(records) # raises on any invalid record or duplicate id
|
||||
|
||||
|
||||
def test_fixture_catalog_spans_the_coordinate_space():
|
||||
records = generate_fixture_catalog()
|
||||
coords = {(r.left, r.right, r.dark, r.light) for r in records}
|
||||
# all 625 cells of the 5x5 brain x 5x5 mood space are present
|
||||
assert len(coords) == 625
|
||||
|
||||
|
||||
def test_fixture_catalog_has_every_content_mode():
|
||||
records = generate_fixture_catalog()
|
||||
present = {r.mode for r in records}
|
||||
assert CONTENT_MODES <= present
|
||||
|
||||
|
||||
def test_fixture_catalog_mixes_review_statuses():
|
||||
records = generate_fixture_catalog()
|
||||
statuses = {r.review_status for r in records}
|
||||
assert statuses == {"proposed", "approved"}
|
||||
|
||||
|
||||
def test_fixture_catalog_references_no_real_media():
|
||||
records = generate_fixture_catalog()
|
||||
assert all(r.file_path == "" for r in records)
|
||||
|
||||
|
||||
def test_fixture_catalog_is_deterministic():
|
||||
a = generate_fixture_catalog(seed=42)
|
||||
b = generate_fixture_catalog(seed=42)
|
||||
assert [r.id for r in a] == [r.id for r in b]
|
||||
assert [r.mode for r in a] == [r.mode for r in b]
|
||||
assert [r.review_status for r in a] == [r.review_status for r in b]
|
||||
@@ -1,54 +0,0 @@
|
||||
from tools.pipeline.ffmpeg_ops import frame_args, crossfade_loop_args, transcode_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"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def test_crossfade_loop_rejects_nonpositive_overlap():
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
crossfade_loop_args("in.mp4", "loop.mp4", duration=8.0, overlap=0.0)
|
||||
|
||||
|
||||
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"
|
||||
@@ -1,42 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.pipeline.run import probe_duration, process_clip, resolve_ffmpeg
|
||||
|
||||
# A real pool base (forest was retired with the rotating-pool rename, session 0016);
|
||||
# cosmos/base.mp4 is the cosmos pool primary and the most stable PD clip on disk.
|
||||
_BASE = Path("simulator/sample_media/cosmos/base.mp4")
|
||||
|
||||
|
||||
def _ffmpeg_available() -> bool:
|
||||
try:
|
||||
resolve_ffmpeg()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
_HAS_FF = _ffmpeg_available()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _BASE.exists(),
|
||||
reason="cosmos base absent (run simulator/build_pool_manifest.py)")
|
||||
def test_probe_duration_reads_real_base():
|
||||
assert probe_duration(_BASE) > 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_FF,
|
||||
reason="neither system ffmpeg nor imageio-ffmpeg available")
|
||||
@pytest.mark.skipif(not _BASE.exists(),
|
||||
reason="cosmos base absent (run simulator/build_pool_manifest.py)")
|
||||
def test_process_clip_emits_proxy_and_master(tmp_path):
|
||||
out = process_clip(_BASE, tmp_path, overlap=0.5, start=0.0, duration=4.0)
|
||||
assert out["proxy"].exists() and out["master"].exists()
|
||||
# Proxy is exactly 1920×1080 — verified with cv2, no ffprobe dependency.
|
||||
import cv2
|
||||
cap = cv2.VideoCapture(str(out["proxy"]))
|
||||
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
cap.release()
|
||||
assert (w, h) == (1920, 1080)
|
||||
@@ -1,47 +0,0 @@
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
def test_add_ring_scale_appends_when_after_missing():
|
||||
m = _base()
|
||||
m = add_ring_scale(m, "abyss", "abyss", after="nope")
|
||||
assert [s["id"] for s in m["ring"]["scales"]] == ["cosmos", "abyss"]
|
||||
|
||||
|
||||
def test_rebuild_ring_edges_single_scale_self_wrap():
|
||||
m = _base() # one scale: cosmos
|
||||
m = rebuild_ring_edges(m)
|
||||
assert [t["file"] for t in m["ring"]["transitions"]] == ["transitions/cosmos-cosmos.mp4"]
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
@@ -1,120 +0,0 @@
|
||||
"""tools/pipeline/track.py — the hybrid motion-track pass (content-pipeline §11.5).
|
||||
|
||||
Pure geometry helpers are unit-tested here; the cv2 optical-flow propagation is an
|
||||
opt-in integration test that runs on a real pool clip when present."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.pipeline.track import (
|
||||
clamp01_box,
|
||||
denormalize_box,
|
||||
infer_window,
|
||||
loop_t,
|
||||
normalize_box,
|
||||
sample_track,
|
||||
track_to_annotation,
|
||||
)
|
||||
|
||||
|
||||
# --- pure helpers -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_clamp01_box_keeps_box_on_screen():
|
||||
assert clamp01_box((-0.2, 0.1, 0.5, 0.5)) == [0.0, 0.1, 0.5, 0.5]
|
||||
# size trimmed so the box never runs past the right/bottom edge
|
||||
assert clamp01_box((0.8, 0.8, 0.5, 0.5)) == [0.8, 0.8, pytest.approx(0.2), pytest.approx(0.2)]
|
||||
|
||||
|
||||
def test_normalize_denormalize_round_trip():
|
||||
px = (192, 108, 384, 216)
|
||||
norm = normalize_box(px, 1920, 1080)
|
||||
assert norm == [pytest.approx(0.1), pytest.approx(0.1), pytest.approx(0.2), pytest.approx(0.2)]
|
||||
assert denormalize_box(norm, 1920, 1080) == (192, 108, 384, 216)
|
||||
|
||||
|
||||
def test_loop_t_is_frame_over_total():
|
||||
assert loop_t(0, 100) == 0.0
|
||||
assert loop_t(50, 100) == 0.5
|
||||
assert loop_t(5, 0) == 0.0 # guard divide-by-zero
|
||||
|
||||
|
||||
def test_infer_window_spans_tracked_frames():
|
||||
appear, disappear = infer_window([10, 11, 30, 31], 100)
|
||||
assert appear == pytest.approx(0.1)
|
||||
assert disappear == pytest.approx(0.31)
|
||||
# no frames -> full clip
|
||||
assert infer_window([], 100) == (0.0, 1.0)
|
||||
|
||||
|
||||
def test_sample_track_downsamples_to_sparse_keyframes():
|
||||
# 21 dense frames -> at most n_keyframes, including the first and last
|
||||
per_frame = {i: [i / 100, 0.2, 0.1, 0.1] for i in range(0, 21)}
|
||||
track = sample_track(per_frame, total_frames=100, n_keyframes=5)
|
||||
assert len(track) <= 5
|
||||
ts = [k["t"] for k in track]
|
||||
assert ts == sorted(ts) # sorted by t
|
||||
assert ts[0] == pytest.approx(0.0) # first tracked frame
|
||||
assert ts[-1] == pytest.approx(0.2) # last tracked frame (frame 20 / 100)
|
||||
assert len(set(ts)) == len(ts) # de-duplicated on t
|
||||
|
||||
|
||||
def test_sample_track_keeps_all_when_already_sparse():
|
||||
per_frame = {0: [0.1, 0.1, 0.1, 0.1], 60: [0.5, 0.3, 0.1, 0.1]}
|
||||
track = sample_track(per_frame, total_frames=120, n_keyframes=5)
|
||||
assert [k["t"] for k in track] == [pytest.approx(0.0), pytest.approx(0.5)]
|
||||
|
||||
|
||||
def test_sample_track_empty_is_empty():
|
||||
assert sample_track({}, total_frames=100) == []
|
||||
|
||||
|
||||
def test_track_to_annotation_assembles_geometry_only():
|
||||
track = [{"t": 0.0, "box": [0.1, 0.2, 0.1, 0.1]}, {"t": 0.5, "box": [0.4, 0.2, 0.1, 0.1]}]
|
||||
ann = track_to_annotation("detected.jelly", track, salience=3, appear=0.0, disappear=0.55)
|
||||
assert ann["key"] == "detected.jelly"
|
||||
assert ann["salience"] == 3
|
||||
assert ann["track"] == track
|
||||
assert ann["appear"] == 0.0 and ann["disappear"] == 0.55
|
||||
# no semantics leaked in — strings/tiers are the author's, added elsewhere
|
||||
assert "strings" not in ann and "_tiers" not in ann
|
||||
|
||||
|
||||
def test_track_to_annotation_omits_window_when_absent():
|
||||
ann = track_to_annotation("detected.x", [], salience=4)
|
||||
assert "appear" not in ann and "disappear" not in ann
|
||||
|
||||
|
||||
# --- opt-in: classical optical-flow propagation on a real clip --------------
|
||||
|
||||
_CLIP = Path("simulator/sample_media/abyss_wow/base.mp4")
|
||||
|
||||
|
||||
def _cv2_available() -> bool:
|
||||
try:
|
||||
import cv2 # noqa: F401
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _CLIP.exists(),
|
||||
reason="abyss_wow base absent (run simulator/build_pool_manifest.py)")
|
||||
@pytest.mark.skipif(not _cv2_available(), reason="opencv-python not available")
|
||||
def test_track_seed_produces_a_sparse_annotation_on_real_footage():
|
||||
from tools.pipeline.track import track_box, track_seed
|
||||
|
||||
seed = (0.2, 0.3, 0.15, 0.18)
|
||||
per_frame, total = track_box(_CLIP, seed, seed_frame=0, max_frames=40)
|
||||
assert total > 0
|
||||
assert per_frame[0] == pytest.approx(list(seed), abs=0.02)
|
||||
assert len(per_frame) > 1 # propagated beyond the seed frame
|
||||
|
||||
ann = track_seed(_CLIP, "detected.jelly", seed, seed_frame=0, n_keyframes=5, max_frames=40)
|
||||
assert ann["key"] == "detected.jelly"
|
||||
assert 2 <= len(ann["track"]) <= 5 # sparse keyframes
|
||||
for kf in ann["track"]:
|
||||
assert 0.0 <= kf["t"] <= 1.0
|
||||
assert all(0.0 <= v <= 1.0 for v in kf["box"])
|
||||
assert 0.0 <= ann["appear"] <= ann["disappear"] <= 1.0
|
||||
+14
-112
@@ -2,15 +2,11 @@ import pytest
|
||||
|
||||
from hef.selection import Coordinate
|
||||
from player.alteration import (
|
||||
DEFAULT_CALIBRATION,
|
||||
AffectOverlay,
|
||||
AnalyticalOverlay,
|
||||
Calibration,
|
||||
ColorGrade,
|
||||
Dream,
|
||||
RenderPlan,
|
||||
Restyle,
|
||||
plan_alteration,
|
||||
render_plan_to_dict,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,77 +17,30 @@ def _coord(left=0, right=0, dark=0, light=0):
|
||||
def test_all_zero_knobs_is_the_unaltered_base():
|
||||
plan = plan_alteration(_coord())
|
||||
assert plan.is_identity
|
||||
assert plan.overlay.level == 0
|
||||
assert plan.overlay.intensity == 0.0
|
||||
assert plan.dream.strength == 0
|
||||
assert plan.dream.intensity == 0.0
|
||||
assert plan.restyle.blend == 0.0
|
||||
assert plan.grade.tone == 0.0
|
||||
assert plan.grade.is_identity
|
||||
|
||||
|
||||
def test_left_drives_the_analytical_overlay_only():
|
||||
plan = plan_alteration(_coord(left=4))
|
||||
assert plan.overlay.level == 4
|
||||
assert plan.overlay.intensity == 1.0
|
||||
assert plan.dream.strength == 0 # Left does not touch the substrate
|
||||
assert plan.dream.intensity == 0.0
|
||||
assert plan.restyle.blend == 0.0 # Left does not touch the substrate
|
||||
assert plan.grade.tone == 0.0
|
||||
|
||||
|
||||
def test_right_drives_the_deterministic_dream_only():
|
||||
# Right-axis dream reframe (session 0013): Right is a live deterministic
|
||||
# haze, carried as strength (0..4) + intensity (0..1), not a baked variant.
|
||||
def test_right_drives_the_restyle_substrate_only():
|
||||
plan = plan_alteration(_coord(right=2))
|
||||
assert plan.dream.strength == 2
|
||||
assert plan.dream.intensity == pytest.approx(0.5)
|
||||
assert plan.overlay.level == 0 # Right does not add overlay
|
||||
assert plan.restyle.blend == 0.5
|
||||
assert plan.overlay.intensity == 0.0 # Right does not add overlay
|
||||
|
||||
|
||||
def test_left_and_right_stack_not_cancel():
|
||||
# design §4.2: whole-brain corner = dream substrate WITH labels on top
|
||||
# design §4.2: whole-brain corner = dreamlike substrate WITH labels on top
|
||||
plan = plan_alteration(_coord(left=4, right=4))
|
||||
assert plan.overlay.level == 4
|
||||
assert plan.dream.strength == 4
|
||||
assert plan.dream.intensity == 1.0
|
||||
|
||||
|
||||
def test_affect_needs_both_knobs_up():
|
||||
# affect-channel design (session 0013): emotion-words surface only when BOTH
|
||||
# the Left and Right knobs are up — either at 0 means no affect at all.
|
||||
assert plan_alteration(_coord(left=4, right=0)).affect.strength == 0
|
||||
assert plan_alteration(_coord(left=0, right=4)).affect.strength == 0
|
||||
assert plan_alteration(_coord(left=4, right=0)).affect.intensity == 0.0
|
||||
assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 0.0
|
||||
|
||||
|
||||
def test_affect_strength_is_the_smaller_knob():
|
||||
# strength = min(left, right) — the smaller knob gates the channel.
|
||||
assert plan_alteration(_coord(left=2, right=4)).affect.strength == 2
|
||||
assert plan_alteration(_coord(left=4, right=2)).affect.strength == 2
|
||||
assert plan_alteration(_coord(left=4, right=4)).affect.strength == 4
|
||||
assert plan_alteration(_coord(left=3, right=1)).affect.strength == 1
|
||||
|
||||
|
||||
def test_affect_intensity_scales_with_strength_over_full_scale():
|
||||
for lo in range(5):
|
||||
plan = plan_alteration(_coord(left=lo, right=4))
|
||||
assert plan.affect.intensity == pytest.approx(lo / 4)
|
||||
assert plan_alteration(_coord(left=4, right=4)).affect.intensity == 1.0
|
||||
|
||||
|
||||
def test_affect_intensity_honors_overlay_gain():
|
||||
# affect rides the same overlay_gain as the analytical HUD it belongs to.
|
||||
cal = Calibration(overlay_gain=0.5)
|
||||
assert plan_alteration(_coord(left=4, right=4), cal).affect.intensity == pytest.approx(0.5)
|
||||
clamp = Calibration(overlay_gain=10.0)
|
||||
assert plan_alteration(_coord(left=4, right=4), clamp).affect.intensity == 1.0
|
||||
|
||||
|
||||
def test_affect_active_implies_non_identity():
|
||||
# min(left,right) > 0 implies left > 0, so an active affect plan is never identity.
|
||||
plan = plan_alteration(_coord(left=2, right=3))
|
||||
assert plan.affect.strength == 2
|
||||
assert not plan.is_identity
|
||||
assert plan.overlay.intensity == 1.0
|
||||
assert plan.restyle.blend == 1.0
|
||||
|
||||
|
||||
def test_light_pole_grades_warm_positive_tone():
|
||||
@@ -101,7 +50,8 @@ def test_light_pole_grades_warm_positive_tone():
|
||||
|
||||
|
||||
def test_dark_pole_grades_cool_negative_tone():
|
||||
assert plan_alteration(_coord(dark=4)).grade.tone == -1.0
|
||||
plan = plan_alteration(_coord(dark=4))
|
||||
assert plan.grade.tone == -1.0
|
||||
|
||||
|
||||
def test_equal_dark_and_light_is_identity_grade():
|
||||
@@ -116,62 +66,14 @@ def test_dark_minus_light_sets_intermediate_tone():
|
||||
|
||||
|
||||
def test_whole_brain_dark_corner_stacks_grade_substrate_and_overlay():
|
||||
# design §4.2 "Dark + analytical": cold measurement over a melancholy scene
|
||||
plan = plan_alteration(_coord(left=4, right=2, dark=4, light=0))
|
||||
assert plan.overlay.level == 4
|
||||
assert plan.dream.strength == 2
|
||||
assert plan.dream.intensity == pytest.approx(0.5)
|
||||
assert plan.overlay.intensity == 1.0
|
||||
assert plan.restyle.blend == 0.5
|
||||
assert plan.grade.tone == -1.0
|
||||
assert not plan.is_identity
|
||||
|
||||
|
||||
def test_default_calibration_is_locked():
|
||||
# Session 0010: the knob->strength calibration is LOCKED to these values,
|
||||
# settled by eye in the simulator (closes the open session-0006 decision).
|
||||
# This pins the literal constants so they can't drift silently; changing the
|
||||
# locked feel is a deliberate edit here + in alteration.py.
|
||||
assert DEFAULT_CALIBRATION.mood_gain == 1.0
|
||||
assert DEFAULT_CALIBRATION.overlay_gain == 1.0
|
||||
assert DEFAULT_CALIBRATION.dream_gain == 1.0
|
||||
|
||||
|
||||
def test_default_calibration_is_behavior_preserving():
|
||||
# DEFAULT_CALIBRATION reproduces the per-axis helpers exactly.
|
||||
for left in range(5):
|
||||
assert plan_alteration(_coord(left=left)).overlay.intensity == pytest.approx(left / 4)
|
||||
for right in range(5):
|
||||
assert plan_alteration(_coord(right=right)).dream.strength == right
|
||||
assert plan_alteration(_coord(right=right)).dream.intensity == pytest.approx(right / 4)
|
||||
for dark in range(5):
|
||||
for light in range(5):
|
||||
expected = (light - dark) / 4
|
||||
assert plan_alteration(_coord(dark=dark, light=light)).grade.tone == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_custom_calibration_scales_mood_overlay_and_dream():
|
||||
cal = Calibration(mood_gain=0.5, overlay_gain=0.5, dream_gain=0.5)
|
||||
assert plan_alteration(_coord(light=4), cal).grade.tone == pytest.approx(0.5)
|
||||
assert plan_alteration(_coord(left=4), cal).overlay.intensity == pytest.approx(0.5)
|
||||
assert plan_alteration(_coord(right=4), cal).dream.intensity == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_calibration_gain_is_clamped_to_unit_range():
|
||||
cal = Calibration(mood_gain=10.0, overlay_gain=10.0, dream_gain=10.0)
|
||||
assert plan_alteration(_coord(light=4), cal).grade.tone == 1.0 # clamped, not 10
|
||||
assert plan_alteration(_coord(left=4), cal).overlay.intensity == 1.0
|
||||
assert plan_alteration(_coord(right=4), cal).dream.intensity == 1.0
|
||||
|
||||
|
||||
def test_render_plan_to_dict_round_trips_the_numbers():
|
||||
d = render_plan_to_dict(plan_alteration(_coord(left=4, right=2, dark=4, light=0)))
|
||||
assert d == {
|
||||
"grade": {"tone": -1.0},
|
||||
"overlay": {"level": 4, "intensity": 1.0},
|
||||
"affect": {"strength": 2, "intensity": 0.5},
|
||||
"dream": {"strength": 2, "intensity": 0.5},
|
||||
"is_identity": False,
|
||||
}
|
||||
|
||||
|
||||
def test_render_plan_is_frozen():
|
||||
plan = plan_alteration(_coord())
|
||||
with pytest.raises(Exception):
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from player.ring import (
|
||||
RingError,
|
||||
RingMove,
|
||||
Scale,
|
||||
ScaleRing,
|
||||
Transition,
|
||||
TransitionStep,
|
||||
advance_ring,
|
||||
pick_clip_id,
|
||||
scale_at,
|
||||
)
|
||||
|
||||
|
||||
def _ring():
|
||||
# cosmos (largest, idx 0) -> forest -> abyss (smallest) -> wraps to cosmos
|
||||
return ScaleRing(
|
||||
scales=(
|
||||
Scale(id="cosmos", clip_id="cosmos"),
|
||||
Scale(id="forest", clip_id="forest"),
|
||||
Scale(id="abyss", clip_id="abyss"),
|
||||
),
|
||||
transitions=(
|
||||
Transition(file="t/cosmos-forest.mp4"), # edge 0: cosmos->forest
|
||||
Transition(file="t/forest-abyss.mp4"), # edge 1: forest->abyss
|
||||
Transition(file="t/abyss-cosmos.mp4"), # edge 2: abyss->cosmos (wrap)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_ring_needs_one_transition_per_scale():
|
||||
with pytest.raises(RingError):
|
||||
ScaleRing(
|
||||
scales=(Scale("a", "a"), Scale("b", "b")),
|
||||
transitions=(Transition("t/ab.mp4"),), # 1 edge for 2 scales
|
||||
)
|
||||
|
||||
|
||||
def test_ring_rejects_empty():
|
||||
with pytest.raises(RingError):
|
||||
ScaleRing(scales=(), transitions=())
|
||||
|
||||
|
||||
def test_scale_at_wraps_modulo():
|
||||
r = _ring()
|
||||
assert scale_at(r, 0).id == "cosmos"
|
||||
assert scale_at(r, 3).id == "cosmos" # wraps
|
||||
assert scale_at(r, -1).id == "abyss" # negative wraps
|
||||
assert scale_at(r, 4).id == "forest"
|
||||
|
||||
|
||||
def test_advance_one_step_inward_plays_edge_forward():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=1)
|
||||
assert isinstance(move, RingMove)
|
||||
assert move.from_index == 0
|
||||
assert move.to_index == 1
|
||||
assert move.wrapped is False
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=0, reversed=False, file="t/cosmos-forest.mp4", to_index=1),
|
||||
)
|
||||
|
||||
|
||||
def test_advance_one_step_outward_plays_edge_reversed():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=1, delta=-1)
|
||||
assert move.to_index == 0
|
||||
assert move.wrapped is False
|
||||
# going forest(1) -> cosmos(0) is edge 0 played in reverse
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=0, reversed=True, file="t/cosmos-forest.mp4", to_index=0),
|
||||
)
|
||||
|
||||
|
||||
def test_advance_inward_past_smallest_wraps_to_largest():
|
||||
r = _ring()
|
||||
# at abyss (smallest, idx 2), zoom in -> wraps to cosmos (idx 0) via edge 2
|
||||
move = advance_ring(r, from_index=2, delta=1)
|
||||
assert move.to_index == 0
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=2, reversed=False, file="t/abyss-cosmos.mp4", to_index=0),
|
||||
)
|
||||
|
||||
|
||||
def test_advance_outward_past_largest_wraps_to_smallest():
|
||||
r = _ring()
|
||||
# at cosmos (idx 0), zoom out -> wraps to abyss (idx 2) via edge 2 reversed
|
||||
move = advance_ring(r, from_index=0, delta=-1)
|
||||
assert move.to_index == 2
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=2, reversed=True, file="t/abyss-cosmos.mp4", to_index=2),
|
||||
)
|
||||
|
||||
|
||||
def test_advance_multi_detent_chains_transitions():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=2)
|
||||
assert move.to_index == 2
|
||||
assert move.wrapped is False
|
||||
assert move.steps == (
|
||||
TransitionStep(edge=0, reversed=False, file="t/cosmos-forest.mp4", to_index=1),
|
||||
TransitionStep(edge=1, reversed=False, file="t/forest-abyss.mp4", to_index=2),
|
||||
)
|
||||
|
||||
|
||||
def test_advance_full_loop_returns_to_start_and_marks_wrap():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=3)
|
||||
assert move.to_index == 0
|
||||
assert move.wrapped is True
|
||||
assert len(move.steps) == 3
|
||||
|
||||
|
||||
def test_advance_zero_is_a_noop():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=1, delta=0)
|
||||
assert move.from_index == 1
|
||||
assert move.to_index == 1
|
||||
assert move.steps == ()
|
||||
assert move.wrapped is False
|
||||
|
||||
|
||||
def test_advance_normalizes_from_index():
|
||||
r = _ring()
|
||||
# from_index out of range is taken modulo N first
|
||||
move = advance_ring(r, from_index=3, delta=1)
|
||||
assert move.from_index == 0
|
||||
assert move.to_index == 1
|
||||
|
||||
|
||||
def test_degenerate_single_scale_ring_is_a_noop():
|
||||
r = ScaleRing(scales=(Scale("solo", "solo"),), transitions=())
|
||||
move = advance_ring(r, from_index=0, delta=5)
|
||||
assert move.to_index == 0
|
||||
assert move.steps == ()
|
||||
assert move.wrapped is False
|
||||
|
||||
|
||||
# --- fast-spin: a faster blended pass past a speed threshold (scales design §3) ---
|
||||
#
|
||||
# A fast encoder spin batches many detents into one advance() call, so |delta| is
|
||||
# the input adapter's proxy for spin speed. Past a configured threshold the move
|
||||
# collapses to a SINGLE blended pass (the arrival-edge clip, played fast) instead
|
||||
# of chaining N full transitions. The policy is opt-in (default off) because the
|
||||
# pure function cannot know wall-clock spin speed — the input layer enables it.
|
||||
|
||||
|
||||
def test_fast_spin_disabled_by_default_chains_every_detent():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=5)
|
||||
assert move.fast is False
|
||||
assert len(move.steps) == 5
|
||||
assert all(not s.blended for s in move.steps)
|
||||
|
||||
|
||||
def test_fast_spin_below_threshold_still_chains():
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=2, fast_spin_threshold=3)
|
||||
assert move.fast is False
|
||||
assert len(move.steps) == 2
|
||||
assert move.steps[0].edge == 0 and move.steps[1].edge == 1
|
||||
|
||||
|
||||
def test_fast_spin_at_or_above_threshold_collapses_to_one_blended_step():
|
||||
r = _ring()
|
||||
# 4 inward detents from cosmos(0): 0->1->2->0->1, lands on forest(1),
|
||||
# crosses the abyss->cosmos seam, last edge crossed is edge 0 (forward).
|
||||
move = advance_ring(r, from_index=0, delta=4, fast_spin_threshold=3)
|
||||
assert move.fast is True
|
||||
assert move.to_index == 1
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(
|
||||
edge=0, reversed=False, file="t/cosmos-forest.mp4", to_index=1, blended=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_fast_spin_outward_blended_step_is_reversed():
|
||||
r = _ring()
|
||||
# 4 outward detents from cosmos(0): 0->2->1->0->2, lands on abyss(2);
|
||||
# outward arrival crosses edge 2 (abyss->cosmos) reversed.
|
||||
move = advance_ring(r, from_index=0, delta=-4, fast_spin_threshold=3)
|
||||
assert move.fast is True
|
||||
assert move.to_index == 2
|
||||
assert move.wrapped is True
|
||||
assert move.steps == (
|
||||
TransitionStep(
|
||||
edge=2, reversed=True, file="t/abyss-cosmos.mp4", to_index=2, blended=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_fast_spin_threshold_below_two_is_ignored():
|
||||
# A threshold of 0 or 1 would blend even single deliberate steps — treat as off.
|
||||
r = _ring()
|
||||
move = advance_ring(r, from_index=0, delta=3, fast_spin_threshold=1)
|
||||
assert move.fast is False
|
||||
assert len(move.steps) == 3
|
||||
|
||||
|
||||
def test_fast_spin_noop_on_degenerate_ring():
|
||||
r = ScaleRing(scales=(Scale("solo", "solo"),), transitions=())
|
||||
move = advance_ring(r, from_index=0, delta=9, fast_spin_threshold=3)
|
||||
assert move.fast is False
|
||||
assert move.steps == ()
|
||||
|
||||
|
||||
# --- rotating clip pool (content-pipeline design §11.1) ---
|
||||
|
||||
|
||||
def test_scale_without_pool_is_a_pool_of_one():
|
||||
s = Scale(id="cosmos", clip_id="cosmos")
|
||||
assert s.members == ("cosmos",)
|
||||
# any r maps to the single member
|
||||
assert pick_clip_id(s, 0.0) == "cosmos"
|
||||
assert pick_clip_id(s, 0.99) == "cosmos"
|
||||
|
||||
|
||||
def test_scale_with_pool_exposes_all_members():
|
||||
s = Scale(id="coast", clip_id="coast_a", pool=("coast_a", "coast_b", "coast_c"))
|
||||
assert s.members == ("coast_a", "coast_b", "coast_c")
|
||||
|
||||
|
||||
def test_pick_clip_id_selects_member_by_injected_r():
|
||||
s = Scale(id="coast", clip_id="a", pool=("a", "b", "c", "d"))
|
||||
# r in [0,1) partitions the pool into len equal buckets
|
||||
assert pick_clip_id(s, 0.0) == "a"
|
||||
assert pick_clip_id(s, 0.24) == "a"
|
||||
assert pick_clip_id(s, 0.25) == "b"
|
||||
assert pick_clip_id(s, 0.5) == "c"
|
||||
assert pick_clip_id(s, 0.75) == "d"
|
||||
|
||||
|
||||
def test_pick_clip_id_clamps_out_of_range_r():
|
||||
s = Scale(id="coast", clip_id="a", pool=("a", "b", "c"))
|
||||
assert pick_clip_id(s, -5.0) == "a" # below 0 clamps to first
|
||||
assert pick_clip_id(s, 1.0) == "c" # 1.0 must not index past the end
|
||||
assert pick_clip_id(s, 999.0) == "c"
|
||||
@@ -56,18 +56,16 @@ def test_overlay_change_is_a_live_update():
|
||||
p.update(_controls(content="video", left=0))
|
||||
t = p.update(_controls(content="video", left=4))
|
||||
assert t.kind == TransitionKind.LIVE_UPDATE
|
||||
assert t.playback.plan.overlay.level == 4
|
||||
assert t.playback.plan.overlay.intensity == 1.0
|
||||
|
||||
|
||||
def test_dream_change_is_a_live_update_not_a_crossfade():
|
||||
# Right-axis dream reframe (session 0013): the Right dream is a deterministic
|
||||
# LIVE filter now, so changing it no longer crossfades — only a clip swap does.
|
||||
def test_restyle_change_crossfades_the_substrate():
|
||||
# design §4.3: the Right v2v substrate is a pre-baked variant swap
|
||||
p = Player(LIB)
|
||||
p.update(_controls(content="video", right=0))
|
||||
t = p.update(_controls(content="video", right=4))
|
||||
assert t.kind == TransitionKind.LIVE_UPDATE
|
||||
assert t.playback.plan.dream.strength == 4
|
||||
assert t.playback.plan.dream.intensity == 1.0
|
||||
assert t.kind == TransitionKind.CROSSFADE
|
||||
assert t.playback.plan.restyle.blend == 1.0
|
||||
|
||||
|
||||
def test_volume_only_change_is_a_live_update():
|
||||
|
||||
+66
-344
@@ -1,380 +1,102 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hef.catalog import Record
|
||||
from simulator.app import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manifest_path(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps({
|
||||
"clips": [{
|
||||
"id": "forest",
|
||||
"title": "neutral forest",
|
||||
"base_file": "forest/base.mp4",
|
||||
"license": "poc", "source": "hef-poc",
|
||||
"right_variants": {"4": {"file": "forest/right4.mp4"}},
|
||||
"annotations": [{"key": "detected.water", "box": [0.1, 0.2, 0.3, 0.4], "min_level": 1}],
|
||||
"strings": {"en": {"detected.water": "flowing water"}},
|
||||
}]
|
||||
}))
|
||||
return p
|
||||
def make_record(**overrides):
|
||||
base = dict(
|
||||
id="r",
|
||||
title="t",
|
||||
source_url="u",
|
||||
source_archive="internet_archive",
|
||||
license="public_domain",
|
||||
mode="video",
|
||||
left=0,
|
||||
right=0,
|
||||
dark=0,
|
||||
light=0,
|
||||
duration_s=600,
|
||||
file_path="",
|
||||
)
|
||||
base.update(overrides)
|
||||
return Record(**base)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(manifest_path):
|
||||
return TestClient(create_app(manifest_path=manifest_path))
|
||||
def client():
|
||||
records = [
|
||||
make_record(id="v-near", mode="video", left=0, right=0, dark=0, light=0),
|
||||
make_record(id="v-far", mode="video", left=4, right=4, dark=4, light=4),
|
||||
make_record(id="a-one", mode="audio", left=1, right=1, dark=1, light=1),
|
||||
make_record(id="prop", mode="video", left=0, right=0, dark=0, light=1,
|
||||
review_status="proposed"),
|
||||
make_record(id="appr", mode="video", left=0, right=0, dark=0, light=1,
|
||||
review_status="approved"),
|
||||
]
|
||||
return TestClient(create_app(records=records))
|
||||
|
||||
|
||||
def _controls(content="video", left=0, right=0, dark=0, light=0, volume=2, brightness=2):
|
||||
return dict(content=content, left=left, right=right, dark=dark,
|
||||
light=light, volume=volume, brightness=brightness)
|
||||
def _body(**overrides):
|
||||
base = dict(left=0, right=0, dark=0, light=0, mode="video")
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_alteration_returns_the_engine_plan(client):
|
||||
resp = client.post("/api/alteration", json={"controls": _controls(left=4, right=2, dark=4)})
|
||||
def test_select_returns_pick_and_ranked_pool(client):
|
||||
resp = client.post("/api/select", json=_body(mode="video", pool_size=4))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["plan"]["overlay"]["level"] == 4
|
||||
assert data["plan"]["dream"]["strength"] == 2
|
||||
assert data["plan"]["dream"]["intensity"] == 0.5
|
||||
assert data["plan"]["grade"]["tone"] == -1.0
|
||||
assert data["content"]["video"] is True
|
||||
assert data["pick"]["id"] == "v-near"
|
||||
ids = [c["record"]["id"] for c in data["pool"]]
|
||||
assert ids[0] == "v-near"
|
||||
assert all("distance" in c and "rank" in c for c in data["pool"])
|
||||
assert [c["rank"] for c in data["pool"]] == list(range(1, len(data["pool"]) + 1))
|
||||
|
||||
|
||||
def test_alteration_honors_off_as_black(client):
|
||||
resp = client.post("/api/alteration", json={"controls": _controls(content="off")})
|
||||
def test_none_mode_is_the_void(client):
|
||||
resp = client.post("/api/select", json=_body(mode="none"))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["content"]["video"] is False
|
||||
assert data["pick"] is None
|
||||
assert data["pool"] == []
|
||||
|
||||
|
||||
def test_alteration_accepts_calibration(client):
|
||||
body = {"controls": _controls(light=4),
|
||||
"calibration": {"mood_gain": 0.5, "overlay_gain": 1.0, "dream_gain": 1.0}}
|
||||
resp = client.post("/api/alteration", json=body)
|
||||
assert resp.json()["plan"]["grade"]["tone"] == 0.5
|
||||
|
||||
|
||||
def test_alteration_rejects_out_of_range_knob(client):
|
||||
resp = client.post("/api/alteration", json={"controls": _controls(left=7)})
|
||||
def test_dial_out_of_range_is_rejected(client):
|
||||
resp = client.post("/api/select", json=_body(left=7))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_alteration_rejects_bad_content(client):
|
||||
resp = client.post("/api/alteration", json={"controls": _controls(content="banana")})
|
||||
def test_bad_mode_is_rejected(client):
|
||||
resp = client.post("/api/select", json=_body(mode="banana"))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_clips_returns_the_manifest(client):
|
||||
resp = client.get("/api/clips")
|
||||
def test_approved_only_narrows_pool(client):
|
||||
resp = client.post("/api/select", json=_body(left=0, right=0, dark=0, light=1,
|
||||
mode="video", approved_only=True))
|
||||
data = resp.json()
|
||||
assert all(c["record"]["review_status"] == "approved" for c in data["pool"])
|
||||
|
||||
|
||||
def test_catalog_meta_reports_counts(client):
|
||||
resp = client.get("/api/catalog/meta")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["clips"][0]["id"] == "forest"
|
||||
assert data["clips"][0]["right_variants"]["0"]["file"] == "forest/base.mp4"
|
||||
assert data["clips"][0]["annotations"][0]["key"] == "detected.water"
|
||||
assert data["total"] == 5
|
||||
assert data["by_mode"]["video"] == 4
|
||||
assert data["by_mode"]["audio"] == 1
|
||||
assert set(data["by_status"]) == {"proposed", "approved"}
|
||||
|
||||
|
||||
def test_media_versions_endpoint_shape(client):
|
||||
resp = client.get("/api/media-versions")
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json()["versions"], dict)
|
||||
|
||||
|
||||
def test_media_version_is_a_content_hash(tmp_path, monkeypatch):
|
||||
import hashlib
|
||||
|
||||
import simulator.app as appmod
|
||||
|
||||
# Point the media root at a temp dir (the served dir is otherwise fixed).
|
||||
monkeypatch.setattr(appmod, "MEDIA_DIR", tmp_path)
|
||||
(tmp_path / "clip").mkdir()
|
||||
f = tmp_path / "clip" / "base.mp4"
|
||||
f.write_bytes(b"hello-bytes")
|
||||
assert appmod._media_version("clip/base.mp4") == hashlib.sha1(b"hello-bytes").hexdigest()[:12]
|
||||
# Absent file -> None (it's simply omitted from the versions map).
|
||||
assert appmod._media_version("clip/missing.mp4") is None
|
||||
# Re-baked under the same path (new bytes) -> a new token, not the cached one.
|
||||
f.write_bytes(b"different-bytes-entirely")
|
||||
assert appmod._media_version("clip/base.mp4") == hashlib.sha1(b"different-bytes-entirely").hexdigest()[:12]
|
||||
|
||||
|
||||
def test_retired_selection_endpoints_are_gone(client):
|
||||
# The route no longer exists; the static catch-all yields 404 on GET and
|
||||
# 405 on the (now-unrouted) POST. Either proves the endpoint is gone.
|
||||
assert client.post("/api/select", json={}).status_code in (404, 405)
|
||||
assert client.get("/api/catalog/meta").status_code == 404
|
||||
from simulator.app import create_app as _create_app_for_static
|
||||
|
||||
|
||||
def test_index_is_served():
|
||||
client = TestClient(create_app())
|
||||
# The default app mounts the real static dir.
|
||||
client = TestClient(_create_app_for_static())
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
assert "Alteration" in resp.text
|
||||
|
||||
|
||||
def test_app_assets_are_not_cached(client):
|
||||
# Dev iteration relies on the browser never serving stale renderer code.
|
||||
resp = client.get("/")
|
||||
assert "no-store" in resp.headers.get("cache-control", "")
|
||||
|
||||
|
||||
def test_media_is_cacheable(tmp_path, monkeypatch):
|
||||
# Media is static: it must be browser-cacheable so the preloader's fetch (and
|
||||
# any fallback) doesn't re-hit the network on every altitude change.
|
||||
import simulator.app as appmod
|
||||
|
||||
media = tmp_path / "media"
|
||||
(media / "cosmos").mkdir(parents=True)
|
||||
(media / "cosmos" / "base.mp4").write_bytes(b"\x00\x00\x00\x18ftyp")
|
||||
monkeypatch.setattr(appmod, "MEDIA_DIR", media)
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text(json.dumps({"clips": []}))
|
||||
|
||||
client = TestClient(create_app(manifest_path=manifest))
|
||||
resp = client.get("/media/cosmos/base.mp4")
|
||||
assert resp.status_code == 200
|
||||
cc = resp.headers.get("cache-control", "")
|
||||
# Stored but revalidated: not no-store (so the blob preload can cache it), and
|
||||
# not pinned immutable (so a re-sourced/re-cropped clip shows on a plain reload).
|
||||
assert "no-store" not in cc
|
||||
assert "immutable" not in cc
|
||||
assert "no-cache" in cc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ring_manifest_path(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps({
|
||||
"clips": [
|
||||
{"id": "cosmos", "title": "NASA cosmos", "base_file": "cosmos/base.mp4",
|
||||
"license": "PD", "source": "NASA", "right_variants": {},
|
||||
"annotations": [], "strings": {}},
|
||||
{"id": "forest", "title": "Yosemite", "base_file": "forest/base.mp4",
|
||||
"license": "poc", "source": "hef-poc",
|
||||
"right_variants": {"4": {"file": "forest/right4.mp4"}},
|
||||
"annotations": [], "strings": {}},
|
||||
{"id": "abyss", "title": "NOAA abyss", "base_file": "abyss/base.mp4",
|
||||
"license": "PD", "source": "NOAA", "right_variants": {},
|
||||
"annotations": [], "strings": {}},
|
||||
],
|
||||
"ring": {
|
||||
"scales": [
|
||||
{"id": "cosmos", "clip_id": "cosmos"},
|
||||
{"id": "forest", "clip_id": "forest"},
|
||||
{"id": "abyss", "clip_id": "abyss"},
|
||||
],
|
||||
"transitions": [
|
||||
{"file": "transitions/cosmos-forest.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/forest-abyss.mp4", "model": "placeholder"},
|
||||
{"file": "transitions/abyss-cosmos.mp4", "model": "placeholder"},
|
||||
],
|
||||
},
|
||||
}))
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ring_client(ring_manifest_path):
|
||||
return TestClient(create_app(manifest_path=ring_manifest_path))
|
||||
|
||||
|
||||
def test_ring_returns_scales_and_transitions(ring_client):
|
||||
resp = ring_client.get("/api/ring")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert [s["id"] for s in data["scales"]] == ["cosmos", "forest", "abyss"]
|
||||
assert data["scales"][0]["title"] == "NASA cosmos"
|
||||
assert len(data["transitions"]) == 3
|
||||
|
||||
|
||||
def test_ring_advance_inward_one_step(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 1})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["to_index"] == 1
|
||||
assert data["target_clip_id"] == "forest"
|
||||
assert data["wrapped"] is False
|
||||
assert data["steps"][0]["file"] == "transitions/cosmos-forest.mp4"
|
||||
assert data["steps"][0]["reversed"] is False
|
||||
|
||||
|
||||
def test_ring_advance_wraps_smallest_to_largest(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 2, "delta": 1})
|
||||
data = resp.json()
|
||||
assert data["to_index"] == 0
|
||||
assert data["target_clip_id"] == "cosmos"
|
||||
assert data["wrapped"] is True
|
||||
|
||||
|
||||
def test_ring_advance_outward_reverses_edge(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 1, "delta": -1})
|
||||
data = resp.json()
|
||||
assert data["to_index"] == 0
|
||||
assert data["steps"][0]["reversed"] is True
|
||||
|
||||
|
||||
def test_ring_advance_small_delta_chains_not_fast(ring_client):
|
||||
# 2 detents is below the simulator's fast-spin threshold (3) -> full chain.
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 2})
|
||||
data = resp.json()
|
||||
assert data["fast"] is False
|
||||
assert len(data["steps"]) == 2
|
||||
|
||||
|
||||
def test_ring_advance_fast_spin_collapses_to_one_blended_pass(ring_client):
|
||||
# A fast spin (4 detents batched) crosses the threshold -> one blended pass.
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 4})
|
||||
data = resp.json()
|
||||
assert data["fast"] is True
|
||||
assert len(data["steps"]) == 1
|
||||
assert data["steps"][0]["blended"] is True
|
||||
assert data["to_index"] == 1
|
||||
|
||||
|
||||
def test_ring_404_when_no_ring_in_manifest(client):
|
||||
assert client.get("/api/ring").status_code == 404
|
||||
assert client.post("/api/ring/advance", json={"from_index": 0, "delta": 1}).status_code == 404
|
||||
|
||||
|
||||
def test_ring_advance_rejects_bad_delta(ring_client):
|
||||
resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": "x"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# --- rotating clip pool (content-pipeline §11.1) ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pool_client(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
members = ["coast_a", "coast_b", "coast_c", "coast_d"]
|
||||
clips = [{"id": "cosmos", "title": "Cosmos", "base_file": "cosmos/base.mp4",
|
||||
"license": "PD", "source": "NASA", "right_variants": {},
|
||||
"annotations": [], "affect": [], "strings": {}}]
|
||||
clips += [{"id": m, "title": m, "base_file": f"{m}/base.mp4", "license": "PD",
|
||||
"source": "NPS", "right_variants": {}, "annotations": [], "affect": [],
|
||||
"strings": {}} for m in members]
|
||||
p.write_text(json.dumps({
|
||||
"clips": clips,
|
||||
"ring": {
|
||||
"scales": [
|
||||
{"id": "cosmos", "clip_id": "cosmos"},
|
||||
{"id": "coast", "clip_id": members[0], "pool": members},
|
||||
],
|
||||
"transitions": [
|
||||
{"file": "transitions/cosmos-coast.mp4"},
|
||||
{"file": "transitions/coast-cosmos.mp4"},
|
||||
],
|
||||
},
|
||||
}))
|
||||
return TestClient(create_app(manifest_path=p)), members
|
||||
|
||||
|
||||
def test_ring_exposes_each_scales_pool(pool_client):
|
||||
client, members = pool_client
|
||||
data = client.get("/api/ring").json()
|
||||
coast = next(s for s in data["scales"] if s["id"] == "coast")
|
||||
assert [m["clip_id"] for m in coast["pool"]] == members
|
||||
# a single-clip scale still reports a pool of one
|
||||
cosmos = next(s for s in data["scales"] if s["id"] == "cosmos")
|
||||
assert [m["clip_id"] for m in cosmos["pool"]] == ["cosmos"]
|
||||
|
||||
|
||||
def test_advance_into_a_pool_lands_on_a_member(pool_client):
|
||||
client, members = pool_client
|
||||
# landing on the coast scale (index 1) must pick one of its pool members
|
||||
seen = set()
|
||||
for _ in range(40):
|
||||
data = client.post("/api/ring/advance", json={"from_index": 0, "delta": 1}).json()
|
||||
assert data["to_index"] == 1
|
||||
assert data["target_clip_id"] in members
|
||||
seen.add(data["target_clip_id"])
|
||||
# over many landings the random pick should cover more than one member
|
||||
assert len(seen) > 1
|
||||
|
||||
|
||||
def test_delta_zero_is_an_initial_pool_pick(pool_client):
|
||||
client, members = pool_client
|
||||
# a no-op (delta 0) advance on the pool scale is the initial / re-roll pick
|
||||
data = client.post("/api/ring/advance", json={"from_index": 1, "delta": 0}).json()
|
||||
assert data["to_index"] == 1
|
||||
assert data["steps"] == []
|
||||
assert data["target_clip_id"] in members
|
||||
|
||||
|
||||
# --- author mode (content-pipeline §11.5) ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def author_client(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
p.write_text(json.dumps({
|
||||
"clips": [{
|
||||
"id": "abyss_wow", "title": "World of Water", "base_file": "abyss_wow/base.mp4",
|
||||
"license": "PD", "source": "NOAA", "right_variants": {},
|
||||
"annotations": [], "affect": [], "strings": {"en": {}},
|
||||
}],
|
||||
}))
|
||||
return TestClient(create_app(manifest_path=p)), p
|
||||
|
||||
|
||||
def test_author_clip_persists_labels_and_keeps_provenance(author_client):
|
||||
client, path = author_client
|
||||
body = {
|
||||
"clip_id": "abyss_wow",
|
||||
"annotations": [{"key": "detected.jelly", "salience": 4,
|
||||
"appear": 0.0, "disappear": 0.5,
|
||||
"track": [{"t": 0.0, "box": [0.1, 0.2, 0.1, 0.1]}]}],
|
||||
"affect": [{"key": "feel.unease", "at": [0.5, 0.5], "min_level": 1}],
|
||||
"strings": {"en": {"detected.jelly": ["jelly", "comb jelly", "Ctenophora", "Ctenophora · cilia rows"],
|
||||
"feel.unease": ["uh", "unease", "disquiet", "a creeping disquiet"]}},
|
||||
}
|
||||
resp = client.post("/api/author/clip", json=body)
|
||||
assert resp.status_code == 200 and resp.json()["ok"] is True
|
||||
# persisted to disk
|
||||
saved = json.loads(path.read_text())["clips"][0]
|
||||
assert saved["annotations"][0]["key"] == "detected.jelly"
|
||||
assert saved["affect"][0]["key"] == "feel.unease"
|
||||
assert saved["strings"]["en"]["detected.jelly"][2] == "Ctenophora"
|
||||
# provenance preserved (only authored content replaced)
|
||||
assert saved["base_file"] == "abyss_wow/base.mp4"
|
||||
assert saved["license"] == "PD"
|
||||
# reflected by /api/clips without a restart
|
||||
served = client.get("/api/clips").json()["clips"][0]
|
||||
assert served["annotations"][0]["key"] == "detected.jelly"
|
||||
|
||||
|
||||
def test_author_clip_unknown_clip_404(author_client):
|
||||
client, _ = author_client
|
||||
resp = client.post("/api/author/clip", json={"clip_id": "nope", "annotations": []})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_author_track_unknown_clip_404(author_client):
|
||||
client, _ = author_client
|
||||
resp = client.post("/api/author/track", json={
|
||||
"clip_id": "nope", "key": "detected.x", "seed_box": [0.1, 0.1, 0.1, 0.1]})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
_REAL_CLIP = __import__("pathlib").Path("simulator/sample_media/abyss_wow/base.mp4")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _REAL_CLIP.exists(),
|
||||
reason="abyss_wow base absent (run simulator/build_pool_manifest.py)")
|
||||
def test_author_track_on_real_clip_returns_keyframes():
|
||||
# Against the real default manifest + footage: the tracker yields a sparse track.
|
||||
client = TestClient(create_app())
|
||||
resp = client.post("/api/author/track", json={
|
||||
"clip_id": "abyss_wow", "key": "detected.jelly",
|
||||
"seed_box": [0.2, 0.3, 0.15, 0.18], "seed_t": 0.0,
|
||||
"salience": 4, "n_keyframes": 5, "max_frames": 30,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
ann = resp.json()
|
||||
assert ann["key"] == "detected.jelly"
|
||||
assert 2 <= len(ann["track"]) <= 5
|
||||
assert 0.0 <= ann["appear"] <= ann["disappear"] <= 1.0
|
||||
assert "X-ray" in resp.text
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""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.
|
||||
"""
|
||||
@@ -1,74 +0,0 @@
|
||||
"""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),
|
||||
]
|
||||
|
||||
|
||||
def crossfade_loop_args(src, dst, *, duration: float, overlap: float,
|
||||
fps: int = 30, 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.
|
||||
|
||||
fps is applied after each trim so xfade receives a constant-frame-rate stream
|
||||
(xfade requires CFR; a raw trim output reports rate 1/0 which xfade rejects)."""
|
||||
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,fps={fps}[head];"
|
||||
f"[0:v]trim={o}:{d - o},setpts=PTS-STARTPTS,fps={fps}[mid];"
|
||||
f"[0:v]trim={d - o}:{d},setpts=PTS-STARTPTS,fps={fps}[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),
|
||||
]
|
||||
|
||||
|
||||
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),
|
||||
]
|
||||
@@ -1,55 +0,0 @@
|
||||
"""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
|
||||
@@ -1,29 +0,0 @@
|
||||
"""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"],
|
||||
)
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Thin runner: probe the source duration, 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 shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .ffmpeg_ops import crossfade_loop_args, frame_args, transcode_args
|
||||
|
||||
MASTER_MAX_W = 3840
|
||||
|
||||
|
||||
def resolve_ffmpeg() -> str:
|
||||
"""ffmpeg from PATH, else the bundled imageio-ffmpeg binary (matches
|
||||
simulator/setup_scales_media.py). The Pi/dev box may lack a system ffmpeg."""
|
||||
if shutil.which("ffmpeg"):
|
||||
return "ffmpeg"
|
||||
import imageio_ffmpeg
|
||||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||
|
||||
|
||||
def probe_duration(src) -> float:
|
||||
"""Clip duration in seconds via cv2 (frame_count / fps) — avoids a system
|
||||
ffprobe dependency (imageio-ffmpeg ships ffmpeg only; the Pi has no ffprobe)."""
|
||||
import cv2
|
||||
cap = cv2.VideoCapture(str(src))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
|
||||
frames = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0
|
||||
cap.release()
|
||||
if fps <= 0:
|
||||
raise ValueError(f"could not read fps from {src}")
|
||||
return frames / fps
|
||||
|
||||
|
||||
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 = MASTER_MAX_W, master_h: int = 2160,
|
||||
ff: str | None = None) -> 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).
|
||||
Note: `crossfade_loop_args` uses a fixed fps only to satisfy xfade's
|
||||
constant-frame-rate requirement; the final output fps is set by `transcode_args`
|
||||
(both default 30 — keep in sync if overridden)."""
|
||||
ff = ff or resolve_ffmpeg()
|
||||
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}
|
||||
@@ -1,297 +0,0 @@
|
||||
"""Stage 5 geometry — the hybrid motion-track pass (content-pipeline §3.5 / §11.5).
|
||||
|
||||
Turns a hand-seeded box (or an ML detection) on one keyframe into the sparse,
|
||||
loop-normalized keyframed `track` the manifest stores (design §4) plus an
|
||||
appear/disappear window (§11.2). SEMANTICS are never produced here — the author
|
||||
owns the `key`, strings, `salience` and tiers; this module only propagates BOX
|
||||
GEOMETRY.
|
||||
|
||||
Two geometry paths, same artifact:
|
||||
- **classical** (always available): a hand-seeded box propagated by OpenCV
|
||||
Lucas-Kanade optical flow (a CSRT tracker is used instead when a contrib
|
||||
build provides one). Translation model — right for calm drifting subjects
|
||||
(the abyss/reef creatures). Re-seeds features when too few survive.
|
||||
- **ML detect+track** (optional, lazy-imported): a YOLO+tracker pass proposing
|
||||
boxes the author maps to a key. Imported only on demand so the base install
|
||||
needs no torch/ultralytics; a clear ImportError tells you what to `pip
|
||||
install` if you ask for it without it present.
|
||||
|
||||
The PURE helpers (normalization, keyframe sampling, window inference, annotation
|
||||
assembly) are unit-tested; the cv2/ML propagation is covered by the opt-in
|
||||
integration test on real footage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
Box = tuple[float, float, float, float] # (x, y, w, h)
|
||||
|
||||
|
||||
# --- pure geometry helpers --------------------------------------------------
|
||||
|
||||
def clamp01_box(box: Box) -> list[float]:
|
||||
"""Clamp a normalized box into the visible [0,1] frame, keeping it on-screen
|
||||
(origin clamped, then size trimmed so x+w<=1, y+h<=1)."""
|
||||
x, y, w, h = box
|
||||
x = min(max(x, 0.0), 1.0)
|
||||
y = min(max(y, 0.0), 1.0)
|
||||
w = min(max(w, 0.0), 1.0 - x)
|
||||
h = min(max(h, 0.0), 1.0 - y)
|
||||
return [x, y, w, h]
|
||||
|
||||
|
||||
def normalize_box(box_px: Box, width: int, height: int) -> list[float]:
|
||||
"""Pixel box -> normalized [0,1] box (the manifest's resolution-independent
|
||||
form). Clamped on-screen."""
|
||||
x, y, w, h = box_px
|
||||
return clamp01_box((x / width, y / height, w / width, h / height))
|
||||
|
||||
|
||||
def denormalize_box(box_norm: Box, width: int, height: int) -> tuple[int, int, int, int]:
|
||||
"""Normalized [0,1] box -> integer pixel box for the cv2 tracker."""
|
||||
x, y, w, h = box_norm
|
||||
return (round(x * width), round(y * height), round(w * width), round(h * height))
|
||||
|
||||
|
||||
def loop_t(frame_idx: int, total_frames: int) -> float:
|
||||
"""Loop-normalized time of a frame in [0,1), matching the client's
|
||||
`currentTime / duration` clock (design §4)."""
|
||||
if total_frames <= 0:
|
||||
return 0.0
|
||||
return frame_idx / total_frames
|
||||
|
||||
|
||||
def infer_window(tracked_frames: list[int], total_frames: int) -> tuple[float, float]:
|
||||
"""The appear/disappear window (§11.2) covering the frames where a box was
|
||||
tracked, as loop-normalized t. The first/last tracked frame bound it."""
|
||||
if not tracked_frames:
|
||||
return (0.0, 1.0)
|
||||
lo, hi = min(tracked_frames), max(tracked_frames)
|
||||
return (loop_t(lo, total_frames), loop_t(hi, total_frames))
|
||||
|
||||
|
||||
def sample_track(
|
||||
per_frame: dict[int, Box], total_frames: int, n_keyframes: int = 5
|
||||
) -> list[dict]:
|
||||
"""Down-sample a dense per-frame box map to a SPARSE keyframed track
|
||||
(design §4: sparse keyframes interpolated at runtime, never dense per-frame).
|
||||
|
||||
Picks `n_keyframes` frames evenly across the tracked span (always including
|
||||
the first and last tracked frame) and emits `[{t, box}, ...]` sorted by t,
|
||||
de-duplicated on t. Pure — takes already-normalized boxes."""
|
||||
if not per_frame:
|
||||
return []
|
||||
frames = sorted(per_frame)
|
||||
n = max(2, n_keyframes)
|
||||
if len(frames) <= n:
|
||||
picks = frames
|
||||
else:
|
||||
lo, hi = frames[0], frames[-1]
|
||||
step = (hi - lo) / (n - 1)
|
||||
targets = [lo + round(step * i) for i in range(n)]
|
||||
# snap each target to the nearest actually-tracked frame
|
||||
picks = sorted({min(frames, key=lambda f: abs(f - t)) for t in targets})
|
||||
out, seen = [], set()
|
||||
for f in picks:
|
||||
t = round(loop_t(f, total_frames), 4)
|
||||
if t in seen:
|
||||
continue
|
||||
seen.add(t)
|
||||
out.append({"t": t, "box": clamp01_box(per_frame[f])})
|
||||
return out
|
||||
|
||||
|
||||
def track_to_annotation(
|
||||
key: str,
|
||||
track: list[dict],
|
||||
*,
|
||||
salience: int = 4,
|
||||
appear: Optional[float] = None,
|
||||
disappear: Optional[float] = None,
|
||||
) -> dict:
|
||||
"""Assemble a manifest annotation from a propagated track + the author's
|
||||
semantics. Geometry (track + window) comes from this module; `key`,
|
||||
`salience` and the tiered strings are the author's (added separately)."""
|
||||
ann: dict = {"key": key, "salience": salience, "track": track}
|
||||
if appear is not None:
|
||||
ann["appear"] = round(appear, 4)
|
||||
if disappear is not None:
|
||||
ann["disappear"] = round(disappear, 4)
|
||||
return ann
|
||||
|
||||
|
||||
# --- classical propagation (cv2; covered by the opt-in integration test) -----
|
||||
|
||||
def _new_csrt():
|
||||
"""A CSRT tracker if this OpenCV build ships one (contrib), else None — then
|
||||
the optical-flow path is used. Kept tiny so the import stays lazy."""
|
||||
import cv2
|
||||
for factory in ("TrackerCSRT_create",):
|
||||
if hasattr(cv2, factory):
|
||||
return getattr(cv2, factory)()
|
||||
legacy = getattr(cv2, "legacy", None)
|
||||
if legacy is not None and hasattr(legacy, "TrackerCSRT_create"):
|
||||
return legacy.TrackerCSRT_create()
|
||||
return None
|
||||
|
||||
|
||||
def _features_in_box(gray, box_px, max_corners: int = 60):
|
||||
"""Good-feature points inside a pixel box, in full-frame coords (for LK)."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
x, y, w, h = (int(v) for v in box_px)
|
||||
h_img, w_img = gray.shape[:2]
|
||||
x0, y0 = max(x, 0), max(y, 0)
|
||||
x1, y1 = min(x + w, w_img), min(y + h, h_img)
|
||||
if x1 - x0 < 4 or y1 - y0 < 4:
|
||||
return None
|
||||
roi = gray[y0:y1, x0:x1]
|
||||
pts = cv2.goodFeaturesToTrack(roi, max_corners, 0.01, 5)
|
||||
if pts is None:
|
||||
return None
|
||||
pts = pts.reshape(-1, 2) + np.array([x0, y0], dtype="float32")
|
||||
return pts.reshape(-1, 1, 2)
|
||||
|
||||
|
||||
def track_box(
|
||||
video_path,
|
||||
seed_box_norm: Box,
|
||||
*,
|
||||
seed_frame: int = 0,
|
||||
max_frames: Optional[int] = None,
|
||||
min_features: int = 8,
|
||||
) -> tuple[dict[int, list[float]], int]:
|
||||
"""Propagate a hand-seeded normalized box FORWARD from `seed_frame` with
|
||||
optical flow (or CSRT when available). Returns `(per_frame_norm_boxes,
|
||||
total_frames)` — feed `per_frame` to `sample_track`. Translation model: the
|
||||
box follows the median displacement of features inside it, re-seeding when too
|
||||
few survive. Impure (reads the video) — opt-in integration test."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
cap = cv2.VideoCapture(str(video_path))
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
||||
if seed_frame:
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, seed_frame)
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
cap.release()
|
||||
raise ValueError(f"could not read frame {seed_frame} of {video_path}")
|
||||
h_img, w_img = frame.shape[:2]
|
||||
box = list(denormalize_box(seed_box_norm, w_img, h_img)) # px [x,y,w,h]
|
||||
|
||||
csrt = _new_csrt()
|
||||
if csrt is not None:
|
||||
csrt.init(frame, tuple(int(v) for v in box))
|
||||
|
||||
prev_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
pts = None if csrt is not None else _features_in_box(prev_gray, box)
|
||||
out: dict[int, list[float]] = {seed_frame: normalize_box(box, w_img, h_img)}
|
||||
|
||||
fidx = seed_frame
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
fidx += 1
|
||||
if csrt is not None:
|
||||
ok2, b = csrt.update(frame)
|
||||
if ok2:
|
||||
box = [b[0], b[1], b[2], b[3]]
|
||||
else:
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
if pts is None or len(pts) < min_features:
|
||||
pts = _features_in_box(prev_gray, box)
|
||||
if pts is not None and len(pts):
|
||||
nxt, status, _ = cv2.calcOpticalFlowPyrLK(prev_gray, gray, pts, None)
|
||||
if nxt is not None:
|
||||
good_old = pts[status == 1]
|
||||
good_new = nxt[status == 1]
|
||||
if len(good_new) >= 3:
|
||||
dx = float(np.median(good_new[:, 0] - good_old[:, 0]))
|
||||
dy = float(np.median(good_new[:, 1] - good_old[:, 1]))
|
||||
box[0] += dx
|
||||
box[1] += dy
|
||||
pts = good_new.reshape(-1, 1, 2)
|
||||
else:
|
||||
pts = None
|
||||
prev_gray = gray
|
||||
out[fidx] = normalize_box(box, w_img, h_img)
|
||||
if max_frames is not None and fidx - seed_frame >= max_frames:
|
||||
break
|
||||
cap.release()
|
||||
return out, total
|
||||
|
||||
|
||||
def track_seed(
|
||||
video_path,
|
||||
key: str,
|
||||
seed_box_norm: Box,
|
||||
*,
|
||||
seed_frame: int = 0,
|
||||
salience: int = 4,
|
||||
n_keyframes: int = 5,
|
||||
max_frames: Optional[int] = None,
|
||||
) -> dict:
|
||||
"""End-to-end classical pass: seed -> propagate -> sample -> annotation
|
||||
(geometry only; the author adds strings/tiers). Impure (reads the video)."""
|
||||
per_frame, total = track_box(
|
||||
video_path, seed_box_norm, seed_frame=seed_frame, max_frames=max_frames
|
||||
)
|
||||
track = sample_track(per_frame, total, n_keyframes=n_keyframes)
|
||||
appear, disappear = infer_window(list(per_frame), total)
|
||||
return track_to_annotation(
|
||||
key, track, salience=salience, appear=appear, disappear=disappear
|
||||
)
|
||||
|
||||
|
||||
# --- optional ML detect+track (lazy-imported) --------------------------------
|
||||
|
||||
def detect_and_track(video_path, *, model: str = "yolov8n.pt", classes=None) -> list[dict]:
|
||||
"""Optional ML path (content-pipeline §3.5): run a detect+track model and
|
||||
return candidate tracks `[{track_id, label, salience, track:[{t,box}],
|
||||
appear, disappear}]` for the author to map to keys. Heavy deps are
|
||||
lazy-imported so the base install stays light.
|
||||
|
||||
Raises a clear ImportError naming the install if `ultralytics` is absent —
|
||||
this path is opt-in by design; the classical `track_seed` needs only cv2."""
|
||||
try:
|
||||
from ultralytics import YOLO # noqa: F401
|
||||
except ImportError as e: # pragma: no cover - exercised only without the dep
|
||||
raise ImportError(
|
||||
"the ML detect+track path needs ultralytics — "
|
||||
"`pip install ultralytics` (optional; the classical track_seed path "
|
||||
"uses only opencv-python)."
|
||||
) from e
|
||||
import cv2 # noqa: F401 (pragma: no cover below — needs the real model)
|
||||
|
||||
yolo = YOLO(model) # pragma: no cover
|
||||
cap = cv2.VideoCapture(str(video_path)) # pragma: no cover
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) # pragma: no cover
|
||||
cap.release() # pragma: no cover
|
||||
per_track: dict[int, dict[int, list[float]]] = {} # pragma: no cover
|
||||
labels: dict[int, str] = {} # pragma: no cover
|
||||
for fidx, res in enumerate( # pragma: no cover
|
||||
yolo.track(source=str(video_path), persist=True, classes=classes, stream=True)
|
||||
):
|
||||
if res.boxes is None or res.boxes.id is None: # pragma: no cover
|
||||
continue
|
||||
w_img, h_img = res.orig_shape[1], res.orig_shape[0] # pragma: no cover
|
||||
for box, tid, cls in zip( # pragma: no cover
|
||||
res.boxes.xywh.tolist(), res.boxes.id.tolist(), res.boxes.cls.tolist()
|
||||
):
|
||||
cx, cy, bw, bh = box # pragma: no cover
|
||||
nb = normalize_box((cx - bw / 2, cy - bh / 2, bw, bh), w_img, h_img)
|
||||
per_track.setdefault(int(tid), {})[fidx] = nb # pragma: no cover
|
||||
labels[int(tid)] = yolo.names.get(int(cls), str(cls))
|
||||
out = [] # pragma: no cover
|
||||
for tid, frames in per_track.items(): # pragma: no cover
|
||||
track = sample_track(frames, total) # pragma: no cover
|
||||
appear, disappear = infer_window(list(frames), total) # pragma: no cover
|
||||
out.append({ # pragma: no cover
|
||||
"track_id": tid, "label": labels.get(tid, ""), "salience": 4,
|
||||
"track": track, "appear": round(appear, 4), "disappear": round(disappear, 4),
|
||||
})
|
||||
return out # pragma: no cover
|
||||
Reference in New Issue
Block a user