Merge pull request 'feat: reconciled simulator-first alteration slice (sessions 0007+0008)' (#7) from feature/reconciled-simulator-alteration-slice into main

This commit was merged in pull request #7.
This commit is contained in:
2026-06-08 06:00:31 +00:00
23 changed files with 2432 additions and 465 deletions
+2
View File
@@ -5,3 +5,5 @@ __pycache__/
media/
.superpowers/
*.egg-info/
# Simulator sample media (look-tuning only; populate via setup_sample_media.py)
simulator/sample_media/forest/*.mp4
+22 -1
View File
@@ -123,6 +123,22 @@ 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).
**Remaining slices (not started):**
- **Runtime renderer** — drive the single panoramic projector via mpv/ffmpeg;
@@ -134,7 +150,12 @@ Plan: [`2026-06-05-player-alteration-core.md`](./superpowers/plans/2026-06-05-pl
`Controls` stream).
- **White-noise generation** — runtime white/pink noise for that content position.
- **Offline v2v variant pipeline** — author the pre-baked Right restyle variants
(the only paid AI step, design §9) + the multilingual label/string tables + TTS.
via the local flow-stabilized SD pipeline (now ~free, not a paid API — see the
scales-library/right-axis design §1/§4); a real multi-strength flow-stabilized
re-bake per base clip + the multilingual label/string tables + TTS.
- **Scale-ring navigation** — the endless rotary encoder + short pre-baked AI
zoom/warp transitions between neutral "scales of nature" clips on a closed ring
(scales-library design §3); a new control + offline pipeline element.
- **Catalog model changes** — audio *source* + "neutral base" vs "altered
variant" flag (sub-project 2 territory, design §13).
+33 -10
View File
@@ -287,11 +287,23 @@ 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 (curator's X-ray)
## Playing with the simulator (alteration preview)
The simulator is a web stand-in for the installation's control panel. It runs the
real `hef.selection` code against a synthetic catalog so you can feel whether the
dials surface fitting pieces before any hardware exists.
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):
python simulator/setup_sample_media.py
This copies the session-0008 POC artifacts (`~/hef-poc/out/`) into
`simulator/sample_media/forest/` — the neutral base clip and the real
flow-stabilized Right restyle — and generates placeholder intermediate Right
strengths. The `.mp4` binaries are gitignored.
**Run it (Docker):**
@@ -304,11 +316,22 @@ then open http://localhost:8000.
pip install -e ".[sim]"
make sim-local
**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.
**What you see and can do:**
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).
- **Content dial** — picks audio/video channel; "off" and audio-only positions go
to black walls.
- **Four experience knobs (04):**
- **Dark / Light** — a live runtime color grade (cool/dark ↔ warm/bright; equal
or zero = the raw footage).
- **Right (dreamlike)** — selects a discrete pre-baked, flow-stabilized restyle
variant and crossfades to it (strength 0 = raw base).
- **Left (analytical)** — a live overlay: labelled boxes from the clip's authored
annotation track, with more annotations appearing at higher levels. Text is
shaped live (the simulator analogue of the Pi's Pango/HarfBuzz path).
- **Calibration sliders** — adjust the grade/overlay gain curves live; once a look
is liked, bake the values into `DEFAULT_CALIBRATION` in `player/alteration.py`.
- **RenderPlan readout** — always shows the exact numbers the engine produced (the
project's honesty "X-ray," now over the alteration model).
The base clips, Right variants, Left annotation track, and string tables come from
`simulator/sample_media/manifest.json`.
File diff suppressed because it is too large Load Diff
@@ -121,6 +121,13 @@ 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.
@@ -235,6 +242,13 @@ 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*
@@ -0,0 +1,242 @@
# Human Experience Filter — Simulator Alteration Preview (Design)
**Date:** 2026-06-06
**Status:** Approved design (pre-implementation)
**Repo:** `human-experience-filter-art`
**Builds on:** the alteration engine of
[`2026-06-05-machine-altered-perception-design.md`](./2026-06-05-machine-altered-perception-design.md)
(the "design" below) and the simulator scaffold of
[`2026-06-04-experience-simulator-design.md`](./2026-06-04-experience-simulator-design.md).
**Revises the design:** §4.3 and §10 — the Left analytical HUD is **no longer a
runtime overlay**; it is **baked into authored variant videos** (see §8 below).
This is a deliberate trade of near-free multilingual support for authorial
precision over the HUD.
**Retires:** the simulator's selection-era "curator's X-ray" view and its
`/api/select` + `/api/catalog/meta` endpoints (the selection model they
visualize was superseded by the alteration model).
> **Why this exists.** Operator directive (session 0007): *build and design only
> things that run in the simulator, and get the whole experience working the way
> we like in the simulator before moving to hardware.* The slice-1 alteration
> engine (PR #5) is pure logic with **no simulator surface** — you cannot yet turn
> the experience knobs and *see* the result. This design brings the alteration
> into the simulator so the look can be tuned and liked before any Pi/serial work.
---
## 1. Scope
A browser **alteration preview**: turn the four experience knobs and the content
dial, and see the neutral base footage altered toward the knob state, in real
time, on a looping clip. The purpose is to **tune the look** of the filter and to
**settle the knob→strength calibration by eye** (the open §3-vs-§4.2/§5 question
from session 0006).
**In scope**
- Live preview of the four experience knobs (Left/Right/Dark/Light) on a looping
base clip.
- **Dark/Light** rendered as a live, deterministic runtime color grade.
- **Left/Right** rendered by selecting a **pre-baked authored variant clip** from
a 5×5 grid and crossfading on change.
- A **calibration panel** that adjusts the grade curve live; the chosen values are
baked back into `player/alteration.py` defaults.
- A **RenderPlan readout** (the project's "X-ray" honesty): always show the exact
numbers the engine produced.
- The content dial's **video on/off** behavior (so "Off" goes to black), driven by
the existing `resolve_content`.
- Placeholder variant generation so the mechanism is testable before the operator
authors real videos.
**Out of scope (this slice)** — real generative v2v; serial input / the 3⇄4
framing contract; audio playback (music / white-noise / audio-track); the Pi/mpv
runtime renderer; the full transition/crossfade timing engine; multilingual label
tables; catalog-model changes. These remain later roadmap slices.
---
## 2. Architecture — Python-canonical, thin browser renderer
`player/alteration.py` stays the **single source of truth** for the alteration
math (handbook §4.2 — deterministic core, thin I/O). The browser owns only
*rendering*. Data flow:
```mermaid
flowchart LR
subgraph Browser
K[4 experience knobs +<br/>content dial +<br/>calibration sliders]
V["&lt;video&gt; variant + canvas grade"]
RO[RenderPlan readout]
end
subgraph FastAPI [simulator/app.py]
EP["POST /api/alteration"]
CL["GET /api/clips"]
end
ENG["player.alteration.plan_alteration(coord, calibration)"]
K -- "debounced POST {controls, calibration}" --> EP
EP --> ENG --> EP -- "RenderPlan {variant, grade}" --> V
EP --> RO
CL -- "base clip + variant manifest" --> V
```
- The browser sends control/calibration changes (debounced) and receives a
`RenderPlan`. Video filtering itself runs continuously in the browser; only a
*plan recompute* makes a round-trip. On localhost these JSON round-trips are
imperceptible.
- "Bake the calibration winner in" = change the `Calibration` defaults in Python.
Nothing in the browser is canonical.
---
## 3. The four axes — how each is rendered
| Axis | Engine output | Browser rendering |
|---|---|---|
| **Dark / Light** | `ColorGrade.tone` ∈ [1, 1], center = identity | live color grade on the `<video>`: Light → warm/yellow + negative space toward white; Dark → cool/blue + negative space toward black; 0 = raw. (Confirmed direction in brainstorm.) |
| **Left / Right** | `VariantRef(left, right)` | select the authored variant clip for `(left, right)`; **crossfade** when it changes; `(0,0)` → raw base. The analytical HUD (Left) and dreamlike restyle (Right) are **baked into the clip** (§8). |
Dark/Light is the only continuously-tunable axis in this slice; Left/Right is a
discrete selection by coordinate.
---
## 4. The variant grid (per base clip)
Each base clip carries a 5×5 grid keyed by the two baked axes:
- Rows = **Left** (analytical / HUD), 04. Columns = **Right** (artistic /
dreamlike restyle), 04.
- `(0,0)` = the raw base clip — no authored file needed.
- The other **24 cells are authored videos** (4 analytical-edge + 4 dreamlike-edge
+ 16 combined core). This is **per base clip**; authoring load multiplies by the
number of base clips (and by language, since the HUD is baked — §8).
The grid is **data**, not code: a manifest the simulator reads (see §6).
---
## 5. Engine reconciliation (`player/alteration.py`)
The slice-1 engine modeled Left/Right as continuous runtime layers
(`AnalyticalOverlay.intensity`, `Restyle.blend`). Baked variants make that
obsolete. Changes:
- **`RenderPlan`** becomes `{ grade: ColorGrade, variant: VariantRef }`.
- `ColorGrade` — unchanged.
- `VariantRef(left, right)` — new; identity selection by coordinate.
- **Remove** `AnalyticalOverlay` and `Restyle` (their content is now baked into the
variant clip) rather than leaving dead fields.
- **`Calibration`** — a new frozen dataclass parameterizing the grade curve (e.g.
`mood_center`, per-axis curve), defaulting to **today's exact behavior**
(behavior-preserving). `plan_alteration(coord, calibration=DEFAULT_CALIBRATION)`.
- **`player/state.py`** — the `CROSSFADE` trigger switches from "restyle changed"
to "variant changed"; `LIVE_UPDATE` still covers a grade-only change. Video
on/off fades unchanged.
These are framework-code changes that keep the engine pure and unit-tested; they
do **not** bake any deployment-shape decision into the engine.
---
## 6. Data & endpoints
- **`simulator/clips.py`** (replaces `simulator/fixtures.py`): reads a base-clip +
variant manifest. Each base clip: `{ id, title, base_file, license, source,
variants: { "L,R": { file, model?, hud_lang? } } }`. Missing cells fall back to
the raw base (and are flagged in the readout as "raw / unauthored").
- **`POST /api/alteration`**: body `{ controls, calibration }``RenderPlan`
(plus the `ContentResolution` from `resolve_content`, so the dial's video-on/off
is honored). The endpoint calls the real `plan_alteration`.
- **`GET /api/clips`**: the base-clip list + variant manifest for the active base.
- Video files served as static assets from a sample-media directory.
- **Removed**: `POST /api/select`, `GET /api/catalog/meta`.
- `hef.selection` (the library, incl. `Coordinate`) is **untouched**; only the
simulator's selection view/endpoints retire. (`ranked_candidates`, added for the
old X-ray, may become unused by the simulator — noted, not removed here.)
---
## 7. Calibration tuning
The calibration panel exposes the grade-curve parameters (the two conventions in
tension: `(2,2,2,2)`-centered vs the `value/4` reading currently implemented, plus
curve shape). Changing them re-requests the plan and the footage responds live.
When the operator is happy, the chosen values are written into
`DEFAULT_CALIBRATION` in `player/alteration.py` and locked in by a unit test.
---
## 8. Baked HUD — the §4.3/§10 revision (accepted trade)
The design's §4.3/§10 kept the Left HUD a **runtime overlay** specifically so the
v2v substrate is language-agnostic and multilingual support is near-free. This
design **reverses that for the Left/Right plane**: the operator authors the HUD
**into** the variant videos to get pixel-precise control over the HUD and the
analytical↔feeling balance.
**Consequences (accepted):**
- Multilingual support is **no longer near-free**: baked HUD text means a new
language re-renders every label-bearing variant (the §10 "budget landmine").
The piece is English-first; broad i18n is deferred/expensive.
- The live Dark/Light grade is applied **on top** of the baked cell, so it **tints
the baked HUD** too (a "Dark" mood cools/darkens the HUD). With baked overlays
the grade cannot skip the HUD. Escape hatch: author HUD colors that survive
grading, or (future) reinstate a runtime HUD layer.
The parent design's §4.3/§10 must be updated to point at this revision (a task for
the implementation plan).
---
## 9. Bootstrapping before authored videos exist
The operator will author the 24 variants per base clip; none exist yet. So the
mechanism is testable immediately, the build includes a **placeholder generator**:
for at least one base clip, produce the 24 cells by burning the cell's `L,R` (and
a stub HUD caption) into the base loop via ffmpeg. Real authored clips drop into
the manifest with **no code change**.
---
## 10. Testing
- **`player/` unit tests:** `DEFAULT_CALIBRATION` is behavior-preserving vs the
current helpers; the two calibration conventions produce the expected plans;
`VariantRef` selection (incl. `(0,0)` → raw) and the `state.py` crossfade
trigger on variant change.
- **Simulator API tests** (rewrite `tests/test_simulator_api.py`):
`POST /api/alteration` returns the engine's plan for given controls/calibration;
`GET /api/clips` returns the manifest; the removed endpoints are gone.
- **No browser/E2E automation** this slice (manual visual tuning is the point);
the rendering JS is kept thin and the engine logic stays in tested Python.
---
## 11. What ships
- `player/alteration.py` (parameterized `Calibration`, `VariantRef`, slimmed
`RenderPlan`) + `player/state.py` crossfade-trigger update.
- `simulator/clips.py` (variant manifest) replacing `fixtures.py`.
- `simulator/app.py` new endpoints; selection endpoints removed.
- `simulator/static/` rewritten as the Player preview (variant `<video>` +
crossfade, live grade, calibration panel, RenderPlan readout, content dial).
- Placeholder-variant generator + a sample base clip.
- Tests above; `docs/USER_GUIDE.md` "Playing with the simulator" rewritten;
parent design §4.3/§10 pointer updated; `docs/ROADMAP.md` §3 updated.
---
## 12. Open questions (for the plan, not blockers)
- **Grade vs baked HUD interaction** — accepted that the grade tints the HUD;
revisit only if it reads badly once real authored clips exist.
- **Sample base clip** — pick one CC0 nature loop for the placeholder grid.
- **Crossfade timing in the browser** — a simple opacity crossfade is enough for
tuning; the real timing engine is a later slice.
---
## 13. Out of scope (YAGNI)
Real generative v2v; serial / firmware; audio playback; the Pi renderer; the full
transition engine; multilingual tables; catalog-model changes; retiring
`hef.selection.ranked_candidates`. All remain later roadmap work.
@@ -0,0 +1,285 @@
# 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 04) 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
(04), 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` (04) + keeps `intensity` | `level` added so the renderer selects which annotations show |
| `restyle: Restyle` | Right | **discrete** `variant: int` (04); 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`** (04) 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, `(lightdark)/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 (13)** = 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** — the panel exposes the params; the final
`DEFAULT_CALIBRATION` values are settled by eye, not fixed here.
- **Grade vs. Left overlay interaction** — with a runtime overlay the grade need *not*
tint the HUD (unlike the baked path); decide in the renderer whether the Left overlay
sits above or below the grade. Default: **above** (HUD stays legible regardless of
mood). Revisit by eye.
- **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-13 placeholders should look to real
restyle; cheap stand-ins are fine for mechanism + calibration.
+67 -36
View File
@@ -1,22 +1,18 @@
"""The alteration engine: a knob vector -> a layered RenderPlan (design §4, §5).
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:
Reconciled slice (2026-06-07): the Right axis is a DISCRETE selection of a
pre-baked, flow-stabilized restyle variant (not a continuous blend), the Left
axis carries its knob LEVEL so a runtime annotation track can pick which labels
show, and a frozen `Calibration` parameterizes the knob->strength curves so they
can be tuned by eye in the simulator and baked into DEFAULT_CALIBRATION.
- 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.
Layers compose per §4.2:
- Substrate: ColorGrade (Dark/Light mood, center = identity §5) + a pre-baked
Right restyle variant.
- Overlay: AnalyticalOverlay (Left), composited on top at runtime.
Left and Right stack (different layers); Dark/Light are the two poles of one
mood grade. See docs/superpowers/specs/2026-06-07-reconciled-simulator-
alteration-slice-design.md.
"""
from __future__ import annotations
@@ -28,6 +24,27 @@ 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 intensity (clamped to [0, 1]).
- right_variant_map: knob value (0..4) -> pre-baked Right variant index.
"""
mood_gain: float = 1.0
overlay_gain: float = 1.0
right_variant_map: tuple = (0, 1, 2, 3, 4)
DEFAULT_CALIBRATION = Calibration()
@dataclass(frozen=True)
class ColorGrade:
"""Mood-axis grade (§5). `tone` is signed: >0 warm yellow->white (light),
@@ -42,18 +59,20 @@ class ColorGrade:
@dataclass(frozen=True)
class AnalyticalOverlay:
"""Left axis (§4.1): analytical HUD/annotation/labels, composited on top.
`intensity` 0..1 (0 = no overlay)."""
"""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."""
level: int
intensity: float
@dataclass(frozen=True)
class Restyle:
"""Right axis (§4.1): pre-baked generative v2v dreamlike substrate.
`blend` 0..1 (0 = raw substrate, no restyle)."""
"""Right axis (§4.1): selects a pre-baked, flow-stabilized restyle variant.
`variant` is a discrete index (0 = raw base, no restyle)."""
blend: float
variant: int
@dataclass(frozen=True)
@@ -69,30 +88,42 @@ class RenderPlan:
"""True when the plan leaves the neutral base un-altered."""
return (
self.grade.is_identity
and self.overlay.intensity == 0.0
and self.restyle.blend == 0.0
and self.overlay.level == 0
and self.restyle.variant == 0
)
def _overlay_intensity(left: int) -> float:
"""Left knob -> analytical-overlay intensity (0..1)."""
return left / KNOB_MAX
def _overlay_intensity(left: int, cal: Calibration) -> float:
return _clamp(cal.overlay_gain * left / KNOB_MAX, 0.0, 1.0)
def _restyle_blend(right: int) -> float:
"""Right knob -> v2v restyle blend (0..1)."""
return right / KNOB_MAX
def _right_variant(right: int, cal: Calibration) -> int:
return cal.right_variant_map[right]
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 _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) -> RenderPlan:
def plan_alteration(
coord: Coordinate, calibration: Calibration = DEFAULT_CALIBRATION
) -> RenderPlan:
"""Map a knob vector to its layered RenderPlan (design §4)."""
return RenderPlan(
grade=ColorGrade(tone=_mood_tone(coord.dark, coord.light)),
overlay=AnalyticalOverlay(intensity=_overlay_intensity(coord.left)),
restyle=Restyle(blend=_restyle_blend(coord.right)),
grade=ColorGrade(tone=_mood_tone(coord.dark, coord.light, calibration)),
overlay=AnalyticalOverlay(
level=coord.left,
intensity=_overlay_intensity(coord.left, calibration),
),
restyle=Restyle(variant=_right_variant(coord.right, calibration)),
)
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},
"restyle": {"variant": plan.restyle.variant},
"is_identity": plan.is_identity,
}
+2 -2
View File
@@ -5,8 +5,8 @@ desired Playback (which neutral base clip, how it is altered, what audio plays,
at what levels) and returns the Transition from the previous Playback. The
transition KIND encodes design §4.3: the Dark/Light grade and the Left overlay
are continuous runtime ops (LIVE_UPDATE), whereas swapping the clip or the
pre-baked Right v2v variant needs a CROSSFADE, and toggling video on/off
fades to/from black.
pre-baked Right restyle variant (a discrete index) 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
+65 -62
View File
@@ -1,92 +1,95 @@
"""FastAPI service: dials -> real hef.selection -> X-ray (pick + ranked pool)."""
"""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.
"""
from __future__ import annotations
import os
from collections import Counter
from pathlib import Path
from typing import Literal, Optional
from typing import Optional
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from hef.catalog import load_catalog, record_to_dict
from hef.selection import (
Coordinate,
Weights,
candidates_for_mode,
ranked_candidates,
select,
from hef.selection import Coordinate
from player.alteration import (
DEFAULT_CALIBRATION,
Calibration,
plan_alteration,
render_plan_to_dict,
)
from simulator.fixtures import generate_fixture_catalog
from player.content import resolve_content
from player.controls import CONTENT_POSITIONS
from simulator.clips import load_manifest
STATIC_DIR = Path(__file__).parent / "static"
MEDIA_DIR = Path(__file__).parent / "sample_media"
DEFAULT_MANIFEST = MEDIA_DIR / "manifest.json"
class SelectRequest(BaseModel):
class ControlsModel(BaseModel):
content: str
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)
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
volume: int = Field(ge=0, le=4)
brightness: int = Field(ge=0, le=4)
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 CalibrationModel(BaseModel):
mood_gain: float = 1.0
overlay_gain: float = 1.0
right_variant_map: list[int] = [0, 1, 2, 3, 4]
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 AlterationRequest(BaseModel):
controls: ControlsModel
calibration: Optional[CalibrationModel] = None
@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,
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 create_app(manifest_path: Optional[Path] = None) -> FastAPI:
app = FastAPI(title="HEF Alteration Simulator")
app.state.clips = _load_clips(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,
right_variant_map=tuple(req.calibration.right_variant_map),
)
if req.calibration
else DEFAULT_CALIBRATION
)
plan = plan_alteration(coord, cal)
content = resolve_content(c.content)
return {
"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)},
"plan": render_plan_to_dict(plan),
"content": {"audio_source": content.audio_source, "video": content.video},
}
@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]}
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")
+68
View File
@@ -0,0 +1,68 @@
"""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
@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"}, ...]
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,
"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", []),
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"]]
-59
View File
@@ -1,59 +0,0 @@
"""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
+12
View File
@@ -0,0 +1,12 @@
# Simulator sample media
`manifest.json` is committed; the `.mp4` binaries are **not** (gitignored). They
are look-tuning samples, not shipped installation content.
Populate them from the session-0008 POC artifacts:
python simulator/setup_sample_media.py
This 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`.
+31
View File
@@ -0,0 +1,31 @@
{
"clips": [
{
"id": "forest",
"title": "Yosemite Falls (neutral base, POC sample)",
"base_file": "forest/base.mp4",
"license": "poc-sample (look-tuning only; not shipped content)",
"source": "hef-poc/out/neutral.mp4",
"right_variants": {
"1": {"file": "forest/right1.mp4", "model": "placeholder"},
"2": {"file": "forest/right2.mp4", "model": "placeholder"},
"3": {"file": "forest/right3.mp4", "model": "placeholder"},
"4": {"file": "forest/right4.mp4", "model": "sd-turbo+farneback-flow"}
},
"annotations": [
{"key": "detected.water", "box": [0.30, 0.10, 0.18, 0.70], "min_level": 1},
{"key": "detected.rock_face", "box": [0.05, 0.30, 0.20, 0.55], "min_level": 2},
{"key": "detected.conifer", "box": [0.70, 0.20, 0.22, 0.45], "min_level": 3},
{"key": "measure.flow_rate", "box": [0.34, 0.55, 0.14, 0.08], "min_level": 4}
],
"strings": {
"en": {
"detected.water": "flowing water",
"detected.rock_face": "granite face",
"detected.conifer": "conifer stand",
"measure.flow_rate": "~2.1 m³/s"
}
}
}
]
}
+53
View File
@@ -0,0 +1,53 @@
"""Populate simulator/sample_media/forest/ from the session-0008 POC artifacts.
Copies the real neutral base + the real flow-stabilized Right restyle out of
~/hef-poc/out/, and generates placeholder intermediate Right strengths (1..3) by
blending the base toward the real restyle with ffmpeg. The media binaries are
gitignored; only the manifest is committed. Sample footage is for look-tuning
only, not shipped content.
Usage: python simulator/setup_sample_media.py
Requires: ffmpeg on PATH (or `pip install imageio-ffmpeg`), and ~/hef-poc/out/.
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
POC = Path.home() / "hef-poc" / "out"
DEST = Path(__file__).parent / "sample_media" / "forest"
def _ffmpeg() -> str:
if shutil.which("ffmpeg"):
return "ffmpeg"
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def main() -> None:
DEST.mkdir(parents=True, exist_ok=True)
base = DEST / "base.mp4"
right4 = DEST / "right4.mp4"
shutil.copyfile(POC / "neutral.mp4", base)
shutil.copyfile(POC / "right_flow.mp4", right4)
ff = _ffmpeg()
# Placeholder strengths 1..3: opacity-blend base toward the real restyle.
for strength, alpha in ((1, 0.25), (2, 0.5), (3, 0.75)):
out = DEST / f"right{strength}.mp4"
subprocess.run(
[ff, "-y", "-i", str(base), "-i", str(right4),
"-filter_complex",
f"[1:v]format=yuva444p,colorchannelmixer=aa={alpha}[top];"
f"[0:v][top]overlay=shortest=1[v]",
"-map", "[v]", "-an", str(out)],
check=True,
)
print(f"generated {out.name} (alpha {alpha})")
print(f"sample media ready in {DEST}")
if __name__ == "__main__":
main()
+88 -98
View File
@@ -1,111 +1,101 @@
const DIALS = ["left", "right", "dark", "light"];
const MODEL = ["brain_weight", "mood_weight", "pool_size"];
// Thin renderer: post controls+calibration -> RenderPlan; render grade, Right
// variant crossfade, and the live Left overlay. All math stays in Python.
const $ = (id) => document.getElementById(id);
const vid = $("vid"), overlay = $("overlay"), black = $("black"), readout = $("readout");
function buildGrid(el) {
el.innerHTML = "";
// rows = first axis 0..4 top->bottom, cols = second axis 0..4 left->right
for (let a = 0; a < 5; a++) {
for (let b = 0; b < 5; b++) {
const cell = document.createElement("div");
cell.className = "cell";
cell.dataset.a = a;
cell.dataset.b = b;
el.appendChild(cell);
}
let clip = null; // active clip manifest entry
let currentVariant = -1; // last loaded Right strength
async function loadClips() {
const data = await (await fetch("/api/clips")).json();
clip = data.clips[0] || null;
}
function mediaUrl(file) { return "/media/" + file; }
function variantFile(strength) {
const v = clip.right_variants[String(strength)];
return v ? v.file : clip.base_file;
}
function applyGrade(tone) {
// Light: warm + brighten; Dark: cool + darken; 0: raw.
const warm = tone > 0 ? tone : 0, cool = tone < 0 ? -tone : 0;
const bright = 1 + 0.25 * tone, sat = 1 + 0.15 * Math.abs(tone);
vid.style.filter =
`brightness(${bright}) saturate(${sat}) ` +
`sepia(${(warm * 0.5).toFixed(3)}) hue-rotate(${(-cool * 200).toFixed(0)}deg)`;
}
function loadVariant(strength) {
if (strength === currentVariant) return;
currentVariant = strength;
vid.style.opacity = "0";
setTimeout(() => {
vid.src = mediaUrl(variantFile(strength));
vid.play().catch(() => {});
vid.style.opacity = "1";
}, 150);
}
function renderOverlay(level, intensity) {
overlay.innerHTML = "";
if (!clip || level <= 0) { overlay.style.opacity = "0"; return; }
overlay.style.opacity = String(intensity);
const strings = (clip.strings && clip.strings.en) || {};
for (const a of clip.annotations) {
if (a.min_level > level) continue;
const [x, y, w, h] = a.box.map((n) => n * 100);
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", x); rect.setAttribute("y", y);
rect.setAttribute("width", w); rect.setAttribute("height", h);
rect.setAttribute("class", "anno-box");
overlay.appendChild(rect);
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.setAttribute("x", x + 0.5); text.setAttribute("y", Math.max(y - 0.5, 2));
text.setAttribute("class", "anno-label");
text.textContent = strings[a.key] || a.key;
overlay.appendChild(text);
}
}
function paintGrid(el, axisA, axisB, point, pool) {
// clear
el.querySelectorAll(".cell").forEach((c) => {
c.className = "cell";
c.innerHTML = "";
});
const counts = {};
pool.forEach((c) => {
const r = c.record;
const key = `${r[axisA]},${r[axisB]}`;
counts[key] = (counts[key] || 0) + 1;
});
el.querySelectorAll(".cell").forEach((c) => {
const a = +c.dataset.a, b = +c.dataset.b;
const key = `${a},${b}`;
if (counts[key]) {
c.classList.add("cand");
const n = document.createElement("span");
n.className = "n";
n.textContent = counts[key];
c.appendChild(n);
}
if (a === point[axisA] && b === point[axisB]) c.classList.add("point");
});
function controls() {
return {
content: $("content").value,
left: +$("left").value, right: +$("right").value,
dark: +$("dark").value, light: +$("light").value,
volume: 2, brightness: 2,
};
}
function readState() {
const s = { mode: document.getElementById("mode").value, approved_only: document.getElementById("approved_only").checked };
DIALS.forEach((d) => (s[d] = +document.getElementById(d).value));
s.brain_weight = +document.getElementById("brain_weight").value;
s.mood_weight = +document.getElementById("mood_weight").value;
s.pool_size = +document.getElementById("pool_size").value;
return s;
function calibration() {
return { mood_gain: +$("mood_gain").value, overlay_gain: +$("overlay_gain").value,
right_variant_map: [0, 1, 2, 3, 4] };
}
function syncOutputs() {
[...DIALS, ...MODEL].forEach((id) => {
const out = document.getElementById(`${id}-out`);
if (out) out.textContent = document.getElementById(id).value;
});
}
async function refresh() {
syncOutputs();
const state = readState();
const resp = await fetch("/api/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(state),
let timer = null;
async function update() {
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), calibration: calibration() }),
});
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
const data = await resp.json();
readout.textContent = JSON.stringify(data, null, 2);
if (!data.content.video) { black.classList.remove("hidden"); return; }
black.classList.add("hidden");
applyGrade(data.plan.grade.tone);
loadVariant(data.plan.restyle.variant);
renderOverlay(data.plan.overlay.level, data.plan.overlay.intensity);
}
const pickEl = document.getElementById("pick");
if (!data.pick) {
pickEl.innerHTML = '<div class="void">∅ Void / rest — walls dark, audio silent.</div>';
} else {
const p = data.pick;
pickEl.innerHTML =
`<div class="title">${p.title}</div>` +
`<div>mode ${p.mode} · coord (${p.left},${p.right},${p.dark},${p.light})</div>` +
`<div>${p.rationale || ""}</div>`;
function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }
async function main() {
await loadClips();
for (const id of ["content", "left", "right", "dark", "light", "mood_gain", "overlay_gain"]) {
$(id).addEventListener("input", debounced);
}
const poolEl = document.getElementById("pool");
poolEl.innerHTML = "";
data.pool.forEach((c, i) => {
const li = document.createElement("li");
if (i === 0) li.className = "winner";
li.innerHTML = `${c.record.title} <span class="dist">d=${c.distance.toFixed(2)}</span>`;
poolEl.appendChild(li);
});
const point = { left: state.left, right: state.right, dark: state.dark, light: state.light };
paintGrid(document.getElementById("brain-grid"), "left", "right", point, data.pool);
paintGrid(document.getElementById("mood-grid"), "dark", "light", point, data.pool);
update();
}
async function loadMeta() {
const data = await (await fetch("/api/catalog/meta")).json();
const byMode = Object.entries(data.by_mode).map(([k, v]) => `${k}:${v}`).join(" ");
document.getElementById("meta").textContent = `${data.total} records · ${byMode}`;
}
function init() {
buildGrid(document.getElementById("brain-grid"));
buildGrid(document.getElementById("mood-grid"));
document.querySelectorAll("input, select").forEach((el) =>
el.addEventListener("input", refresh)
);
loadMeta();
refresh();
}
document.addEventListener("DOMContentLoaded", init);
main();
+44 -52
View File
@@ -1,64 +1,56 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<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">
<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" />
</head>
<body>
<header>
<h1>Experience Filter — Curator's X-ray</h1>
<div id="meta" class="meta"></div>
</header>
<header><h1>Human Experience Filter — Alteration Preview</h1></header>
<main>
<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 class="stage">
<div class="screen">
<video id="vid" loop muted playsinline></video>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
<div id="black" class="black hidden"></div>
</div>
</section>
<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 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>Experience knobs (04)</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>Dark <input type="range" id="dark" min="0" max="4" value="0" /></label>
<label>Light <input type="range" id="light" min="0" max="4" value="0" /></label>
</fieldset>
<fieldset>
<legend>Calibration</legend>
<label>mood gain <input type="range" id="mood_gain" min="0" max="2" step="0.05" value="1" /></label>
<label>overlay gain <input type="range" id="overlay_gain" min="0" max="2" step="0.05" value="1" /></label>
</fieldset>
<fieldset>
<legend>RenderPlan readout</legend>
<pre id="readout"></pre>
</fieldset>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
+21 -25
View File
@@ -1,26 +1,22 @@
:root { color-scheme: dark; }
* { box-sizing: border-box; }
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; }
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; }
#overlay { position: absolute; inset: 0; width: 100%; height: 100%;
pointer-events: none; transition: opacity 0.2s ease; }
.anno-box { fill: none; stroke: #6cf; stroke-width: 0.4; vector-effect: non-scaling-stroke; }
.anno-label { fill: #6cf; font-size: 3px; font-family: monospace; }
.black { position: absolute; inset: 0; background: #000; }
.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; }
+69
View File
@@ -0,0 +1,69 @@
import json
import pytest
from simulator.clips import Clip, load_manifest
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")
-40
View File
@@ -1,40 +0,0 @@
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]
+50 -12
View File
@@ -2,11 +2,14 @@ import pytest
from hef.selection import Coordinate
from player.alteration import (
DEFAULT_CALIBRATION,
AnalyticalOverlay,
Calibration,
ColorGrade,
RenderPlan,
Restyle,
plan_alteration,
render_plan_to_dict,
)
@@ -17,30 +20,32 @@ 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.restyle.blend == 0.0
assert plan.restyle.variant == 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.restyle.blend == 0.0 # Left does not touch the substrate
assert plan.restyle.variant == 0 # Left does not touch the substrate
assert plan.grade.tone == 0.0
def test_right_drives_the_restyle_substrate_only():
def test_right_selects_a_discrete_restyle_variant_only():
plan = plan_alteration(_coord(right=2))
assert plan.restyle.blend == 0.5
assert plan.overlay.intensity == 0.0 # Right does not add overlay
assert plan.restyle.variant == 2
assert plan.overlay.level == 0 # Right does not add overlay
def test_left_and_right_stack_not_cancel():
# design §4.2: whole-brain corner = dreamlike substrate WITH labels on top
plan = plan_alteration(_coord(left=4, right=4))
assert plan.overlay.intensity == 1.0
assert plan.restyle.blend == 1.0
assert plan.overlay.level == 4
assert plan.restyle.variant == 4
def test_light_pole_grades_warm_positive_tone():
@@ -50,8 +55,7 @@ def test_light_pole_grades_warm_positive_tone():
def test_dark_pole_grades_cool_negative_tone():
plan = plan_alteration(_coord(dark=4))
assert plan.grade.tone == -1.0
assert plan_alteration(_coord(dark=4)).grade.tone == -1.0
def test_equal_dark_and_light_is_identity_grade():
@@ -66,14 +70,48 @@ 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.intensity == 1.0
assert plan.restyle.blend == 0.5
assert plan.overlay.level == 4
assert plan.restyle.variant == 2
assert plan.grade.tone == -1.0
assert not plan.is_identity
def test_default_calibration_is_behavior_preserving():
# DEFAULT_CALIBRATION must reproduce the original three 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)).restyle.variant == right
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_and_overlay():
cal = Calibration(mood_gain=0.5, overlay_gain=0.5, right_variant_map=(0, 0, 1, 1, 2))
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=3), cal).restyle.variant == 1
def test_calibration_gain_is_clamped_to_unit_range():
cal = Calibration(mood_gain=10.0, overlay_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
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},
"restyle": {"variant": 2},
"is_identity": False,
}
def test_render_plan_is_frozen():
plan = plan_alteration(_coord())
with pytest.raises(Exception):
+2 -2
View File
@@ -56,7 +56,7 @@ 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.intensity == 1.0
assert t.playback.plan.overlay.level == 4
def test_restyle_change_crossfades_the_substrate():
@@ -65,7 +65,7 @@ def test_restyle_change_crossfades_the_substrate():
p.update(_controls(content="video", right=0))
t = p.update(_controls(content="video", right=4))
assert t.kind == TransitionKind.CROSSFADE
assert t.playback.plan.restyle.blend == 1.0
assert t.playback.plan.restyle.variant == 4
def test_volume_only_change_is_a_live_update():
+54 -66
View File
@@ -1,102 +1,90 @@
import json
import pytest
from fastapi.testclient import TestClient
from hef.catalog import Record
from simulator.app import create_app
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 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
@pytest.fixture
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 client(manifest_path):
return TestClient(create_app(manifest_path=manifest_path))
def _body(**overrides):
base = dict(left=0, right=0, dark=0, light=0, mode="video")
base.update(overrides)
return base
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 test_select_returns_pick_and_ranked_pool(client):
resp = client.post("/api/select", json=_body(mode="video", pool_size=4))
def test_alteration_returns_the_engine_plan(client):
resp = client.post("/api/alteration", json={"controls": _controls(left=4, right=2, dark=4)})
assert resp.status_code == 200
data = resp.json()
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))
assert data["plan"]["overlay"]["level"] == 4
assert data["plan"]["restyle"]["variant"] == 2
assert data["plan"]["grade"]["tone"] == -1.0
assert data["content"]["video"] is True
def test_none_mode_is_the_void(client):
resp = client.post("/api/select", json=_body(mode="none"))
assert resp.status_code == 200
def test_alteration_honors_off_as_black(client):
resp = client.post("/api/alteration", json={"controls": _controls(content="off")})
data = resp.json()
assert data["pick"] is None
assert data["pool"] == []
assert data["content"]["video"] is False
def test_dial_out_of_range_is_rejected(client):
resp = client.post("/api/select", json=_body(left=7))
def test_alteration_accepts_calibration(client):
body = {"controls": _controls(light=4),
"calibration": {"mood_gain": 0.5, "overlay_gain": 1.0, "right_variant_map": [0, 1, 2, 3, 4]}}
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)})
assert resp.status_code == 422
def test_bad_mode_is_rejected(client):
resp = client.post("/api/select", json=_body(mode="banana"))
def test_alteration_rejects_bad_content(client):
resp = client.post("/api/alteration", json={"controls": _controls(content="banana")})
assert resp.status_code == 422
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")
def test_clips_returns_the_manifest(client):
resp = client.get("/api/clips")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 5
assert data["by_mode"]["video"] == 4
assert data["by_mode"]["audio"] == 1
assert set(data["by_status"]) == {"proposed", "approved"}
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"
from simulator.app import create_app as _create_app_for_static
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
def test_index_is_served():
# The default app mounts the real static dir.
client = TestClient(_create_app_for_static())
client = TestClient(create_app())
resp = client.get("/")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
assert "X-ray" in resp.text
assert "Alteration" in resp.text