Compare commits

...

11 Commits

Author SHA1 Message Date
BenStullsBets f28a06cc7f Merge feat/networked-control-surface: networked control surface spec (session 0019) 2026-06-26 05:46:25 -07:00
benstull 229af60f1f Merge pull request 'feat(content): add cosmos_miri pool member (Carina mid-infrared pan)' (#25) from feat/cosmos-miri-pool-member into main
feat(content): add cosmos_miri pool member (Carina mid-infrared pan) (#25)
2026-06-26 12:39:34 +00:00
BenStullsBets c389e2a834 docs(spec): networked control surface (renderer/controller split) — session 0019
v1 hardware step: split the simulator into a laptop RENDERER and a separate
touch CONTROLLER over wifi/LAN. Server-authoritative SessionState + SSE push;
one /api/control contract any "control source" speaks (iPad web twin now,
Arduino later). Live knobs (Left/Right/Mood) vs transitional controls
(Altitude, Content) with per-knob transition state + renderer settled-ack.

Reframes ROADMAP sub-project 4 and absorbs sub-project 3's serial-input slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:39:12 -07:00
BenStullsBets 32e74ca446 fix(sim): make sim-local use the venv interpreter
`make sim-local` called bare `python`, which isn't on this box's PATH (only
python3 / .venv/bin/python exist) — so it failed instantly and "couldn't connect"
to the server. Resolve PYTHON to the venv interpreter, falling back to
python3 then python.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:28:02 -07:00
BenStullsBets e3a9b7cff6 feat(content): add cosmos_miri pool member (Carina mid-infrared pan)
Operator wanted more Cosmic Cliffs footage and a different-color rendering.
Keeps the NIRCam flythrough as the cosmos primary and adds ESA/Webb weic2205c
("Pan of the Carina Nebula, combined NIRCam + MIRI") as a new cosmos pool
member — the same Carina towers in the muted gray/teal/gold MIRI mid-infrared
palette, distinct from the iconic orange/blue flythrough.

- POOLS["cosmos"] grows 4 -> 5 (cosmos, cosmos_miri, galaxies, hudf, xdf);
  primary unchanged.
- META + LABELS authored (nebula/star/distance, salience-gated); affect inherits
  the cosmos scale vocabulary.
- manifest.json regenerated (19 -> 20 clips).
- Source: 4K master, text-free/human-free, trim 4-26s (clears the fade-from-
  black), 1 s crossfade-loop (~21 s) -> 1080p base + 4K master (gitignored).
- docs/content-candidate-pool.md updated (cosmos pool of 5 + sourcing note).

271 passed, 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:07:13 -07:00
BenStullsBets 7a2f37af7e claim human-experience-filter-art session 0019 (placeholder) + sessions.json entry 2026-06-26 04:47:47 -07:00
benstull d0ad9a9ac9 Merge pull request 'feat(sim): emotions are a right-brain output (Right knob alone)' (#24) from feat/affect-right-brain-only into main 2026-06-26 11:29:35 +00:00
BenStullsBets c3aa419c53 feat(sim): emotions are a right-brain output (Right knob alone)
The affect channel previously gated on BOTH knobs (strength = min(left, right)),
so the analytical Left brain co-controlled the emotion words. Per operator
direction, emotions are now driven by the Right (dreamlike) knob ALONE — Left no
longer gates them. This sharpens the split: Left = measurement (detections +
measurements), Right = the dream AND the feeling it evokes.

- player/alteration.py: `_affect_strength(right) = right` (was min(left,right));
  `_affect_intensity` keyed off Right. AffectOverlay + is_identity docs updated
  (is_identity still holds: right>0 ⇒ dream.strength>0).
- simulator/static/app.js: renderAffect comments (gating is Right via `strength`);
  the client logic already keyed min_level/tier off the passed strength + right.
- Affect design spec: revision note + every min(left,right) → right.
- Tests rewritten: affect is Right-driven (full Right + Left 0 surfaces affect;
  Right 0 + Left 4 surfaces none).

271 passed, 2 skipped. Live API verified: left=0/right=4 → strength 4; left=4/
right=0 → strength 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:29:21 -07:00
benstull 5bb762ae53 Merge pull request 'fix(sim): content-hash media URLs + fade pool-landing clip swap' (#23) from fix/media-versions-and-pool-landing into main 2026-06-25 20:04:15 +00:00
BenStullsBets 2eb752b5bc fix(sim): content-hash media URLs + fade pool-landing clip swap
Two fixes to the simulator's media handling, both surfaced by the cosmos clip swap.

1. Permanent cache-bust via content hash. New `GET /api/media-versions` returns a
   short sha1 token per served file (clip bases + ring transitions + reverses),
   cached by (mtime, size) so a re-bake is picked up without a restart. The client
   fetches it at boot and appends `?v=<hash>` to every /media URL, so a clip
   re-baked under the same path (e.g. a re-sourced cosmos base) gets a fresh URL
   the browser can't serve stale — fixing the recurring stale-clip problem at the
   root (supersedes the `cache: "reload"` belt-and-suspenders, which stays).

2. Fade the pool-landing swap. The baked ring transition zooms into the scale
   PRIMARY, but the rotating pool picks a random member on landing — so a
   non-primary pick hard-cut from the primary to the chosen clip (e.g. coast
   birdrock -> surfgrass, the artifact the operator saw). On landing, when the pick
   differs from the primary, mask the swap behind a short fade through the existing
   #black overlay (CSS opacity transition); same-clip landings still do a plain
   (re)load. General across all pooled scales.

271 passed, 2 skipped (+2: media-version content-hash + absent-file omission).
app.js syntax-checked; endpoint verified live (29 files; token matches a manual
sha1 of the bytes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:03:56 -07:00
benstull aa56dfe145 Merge pull request 'fix(sim): bust HTTP cache on media preload so re-baked clips show' (#22) from fix/preload-cache-bust into main 2026-06-25 19:40:50 +00:00
14 changed files with 681 additions and 69 deletions
+5 -1
View File
@@ -1,7 +1,11 @@
.PHONY: sim sim-local
# Prefer the project venv's interpreter (this box has no bare `python` on PATH),
# fall back to python3, then python.
PYTHON ?= $(firstword $(wildcard .venv/bin/python) python3 python)
sim:
docker compose -f simulator/docker-compose.yml up --build
sim-local:
python -m uvicorn simulator.app:app --reload --port 8000
$(PYTHON) -m uvicorn simulator.app:app --reload --port 8000
+13 -1
View File
@@ -23,11 +23,12 @@ a credit line** (our overlays + dream are derivatives, which these allow);
**CCBYND is NOT usable** (no derivatives); **CCBYNC only if the installation
stays noncommercial**.
## 🌌 cosmos (pool of 4)
## 🌌 cosmos (pool of 5)
| id | clip | source | window (s) | license / credit |
|----|------|--------|-----------|------------------|
| `cosmos` | Cosmic Cliffs — Carina Nebula flythrough | NASA SVS 31348 — https://svs.gsfc.nasa.gov/31348/ (`Clifs-3d-STScI.mp4`, *Exploring the Cosmic Cliffs in 3D*, Webb NIRCam, **4K**) | 1034 | CCBYclass — credit **NASA, ESA, CSA, STScI** |
| `cosmos_miri` | Cosmic Cliffs — Carina Nebula, **mid-infrared** (same towers, muted gray/teal/gold dust palette) | ESA/Webb weic2205c — https://esawebb.org/videos/weic2205c/ (*Pan of the Carina Nebula, combined NIRCam + MIRI*, **4K** `weic2205c.mp4`) | 426 | CCBYclass — 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
@@ -43,6 +44,17 @@ stays noncommercial**.
| `cosmos_hudf` | Hubble Ultra Deep Field zoom | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030687/hudf-b-1920x1080p30.mov | 2044 | CCBYclass — 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 | 226 | CCBYclass — credit **NASA, ESA, STScI** |
> **`cosmos_miri` added (2026-06-26).** Operator wanted more *Cosmic Cliffs*
> footage and a different-color rendering. The keep-the-flythrough-primary +
> add-the-mid-infrared-pan pair: ESA/Webb's **weic2205c** is the *same* Carina
> towers shot with NIRCam **+ MIRI**, so it renders in a muted gray/teal/gold dust
> palette instead of the iconic orange/blue — a distinct look from the same scene.
> Sourced text-free/human-free from the **4K** master, trim 426s (clears the
> opening fade-from-black, ends before any tail fade) → 1080p base + 4K master,
> 1 s crossfade-loop (~21 s). ESA/Webb's narration-free *"Diving Into the Cosmic
> Cliffs"* pan (`carina_revisit_pan`, NIRCam palette) was the runner-up — left
> out for now since it repeats the existing palette; easy to add if wanted.
> ⚠️ **DISCREPANCY to fix next session.** Operator selected cosmos **#1, #3
> (Orion), #4, #5**. During processing, **#6 Galaxy Traverse was run by mistake
> instead of #3 Orion**. The cosmos primary now lives at
@@ -1,27 +1,36 @@
# Affect channel — emotions in the Left-brain HUD
**Status:** approved (session 0013, 2026-06-22)
**Status:** approved (session 0013, 2026-06-22); **revised session 0018, 2026-06-26**
**Surface:** simulator preview (`simulator/`, `player/alteration.py`)
**Builds on:** the machine-altered-perception design SPEC; the Left HUD look-and-feel
pass (same session).
> **Revision (session 0018) — affect is a RIGHT-brain output.** The original design
> gated emotions on *both* knobs (`strength = min(left, right)`). Per operator
> direction, **emotions are now controlled by the Right (dreamlike) knob alone** —
> the analytical Left knob no longer gates them. This sharpens the left/right split:
> Left = measurement (detections + measurements), Right = the dream **and** the
> feeling it evokes. Every `min(left, right)` below now reads `right`. The data
> shape, rendering, and per-word `min_level` mechanic are unchanged — only the knob
> the strength is keyed off.
## Idea
A third HUD channel beside the cyan *detections* and amber *measurements*: soft,
glowing **emotion-words** that surface the feelings the experience may invoke.
They 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.
They are a **right-brain output: driven by the dreamlike Right knob alone** (the
analytical Left knob does not gate them) — the dream surfacing as the machine's
felt reading. This is the uncanny edge of the thesis: the machine trying to
quantify not just what is there, but how you are meant to feel.
## Behaviour
- **Trigger / intensity.** Affect strength = `min(left, right)` (04). 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.
- **Trigger / intensity.** Affect strength = `right` (04). Right at 0 → no
emotions at all, regardless of Left. Opacity scales with that strength ×
`overlay_gain`.
- **Escalation.** A **stable emotional palette per scene** — higher Right reveals
*more* words at higher opacity. Each word carries a `min_level` (the same
mechanic the detection channel uses), compared against the `right` strength.
- **Rendering.** Floating words at authored scene positions — **no boxes or
reticles**, lowercase, semi-transparent with a soft glow, in a distinct **violet**
register so they read as affective and clearly *softer* than the hard clinical
@@ -33,15 +42,15 @@ Consistent with the existing split (alteration math in Python, label-selection i
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.
`RenderPlan`. `strength = right`; `intensity = clamp(overlay_gain * right /
KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under `"affect"`. Pure,
unit-tested.
- The simulator client picks *which* words to show by comparing each affect
entry's `min_level` to `plan.affect.strength`, and renders them at
`plan.affect.intensity` opacity.
`is_identity` is unaffected: `min(left,right) > 0` implies `left > 0`, which already
makes `overlay.level > 0`.
`is_identity` is unaffected: `affect.strength == right`, and `right > 0` already
makes `dream.strength > 0`.
## Data
@@ -0,0 +1,311 @@
# Networked Control Surface — design
**Status:** proposed
**Date:** 2026-06-26
**Session:** 0019
**Supersedes/reframes:** ROADMAP sub-project 4 (Arduino Firmware / control panel) and
the "serial input" slice of sub-project 3 (Player Runtime).
---
## 1. Goal
Split the existing simulator into a **renderer** (the laptop full-screen
projection — "the experience") and a separate **controller** (a touch remote),
talking over the local network, so the experience can be driven from a handheld
device the way the real installation's panel drives its screen.
This is **v1 hardware**, deliberately simulator-friendly:
- The **laptop/computer renders** (no Raspberry Pi yet).
- The controller is a **separate device** over **wifi** (LAN), not a USB-serial
link to a Pi.
- The controller is **prototyped as a web page** (an iPad/tablet/phone twin),
and a **physical Arduino remote** drops in later as just another control source
speaking the same contract.
Two goals, equally weighted (operator, session 0019): **decouple control from
display cleanly**, *and* get the **handheld "stand back and drive it" feel**.
## 2. Core idea: one server of truth, interchangeable control sources
```
┌──────────────────┐ POST /api/control ┌─────────────────────┐
│ CONTROL SOURCE │ ───── (knob change) ────▶ │ FastAPI server │
│ │ │ (laptop) │
│ • iPad web remote│ ◀──── GET /api/state ──── │ ┌───────────────┐ │
│ (v1) │ (sync on load) │ │ SessionState │ │
│ • Arduino (later)│ │ │ controls + │ │
└──────────────────┘ │ │ ring index + │ │
│ │ transitions + │ │
┌──────────────────┐ GET /api/events (SSE) │ │ seq (version) │ │
│ RENDERER │ ◀═══ state/ring/mode ════ │ └───────┬───────┘ │
│ laptop full- │ (live push) └──────────┼──────────┘
│ screen │ │
│ (the experience) │ ──── POST /api/render/event ────────▶│ (settled ack)
└──────────────────┘ recompute RenderPlan + ring move
```
- **Server** owns the single authoritative `SessionState` (current controls,
altitude/ring index, per-field transition state, a monotonic `seq`). Every
change recomputes the derived view and broadcasts it.
- **Control source** = anything that `POST`s a control change. v1 has exactly
one: the iPad web remote. A future Arduino is *another instance of the same
role* — no new server concept.
- **Renderer** = the laptop projection. It holds **no** input UI; it subscribes
to the SSE stream and renders whatever state arrives. It is today's
`simulator/static/index.html`, stripped of its sliders, given a `state`
listener plus one small upstream ack channel.
The property that matters: controller and renderer are now **separate web pages
off the same server**, decoupled exactly as a real installation's panel and
screen are. (Chosen architecture: server-authoritative session + **Server-Sent
Events** push. SSE fits because the directions are asymmetric — the renderer only
*receives*, the control source only *sends* — so no bidirectional WebSocket is
needed. Alternatives considered and rejected: renderer polling — laggy/wasteful;
peer-to-peer WebRTC — most plumbing and it breaks the clean single-contract seam
that makes the Arduino a drop-in.)
## 3. Control surface: the five controls (the faithful twin)
The iPad remote is a **faithful on-screen twin** of the intended physical panel —
what you like on the iPad is the spec for the Arduino panel. The control
inventory is exactly what the sim exposes today:
| Control | Sim today | Physical form (later) |
|---|---|---|
| **Content** | 7-way `<select>` (video / audio+video / music+video / off / white noise / music / audio track) | rotary selector switch (7 pos) |
| **Altitude** | endless circular dial — walks the 5 scales (cosmos→abyss), wraps | rotary encoder (endless, detented) |
| **Left** (analytical) | slider 04 | knob/pot, 5 detents |
| **Right** (dreamlike) | slider 04 | knob/pot, 5 detents |
| **Mood** | slider 4…+4 (dark↔light) | center-detented knob |
(This is the original sub-project-4 inventory, *updated*: Dark+Light collapsed
into one **Mood** knob in session 0013, and the **Altitude** encoder added in
session 0018.)
## 4. Live vs. transitional controls (per-knob transition state)
The five controls split into two classes by how a **human** perceives the change:
- **Live controls — Left, Right, Mood.** The alteration engine treats these as
`LIVE_UPDATE` (the WebGL Kuwahara dream + HUD overlay + color grade re-tune
within a frame). Effectively instantaneous → these are *always idle*; the
remote just shows the value.
- **Transitional controls — Altitude, Content/mode.** These trigger a renderer
animation that takes real, perceptible time: Altitude plays ring-transition
clips (zoom/warp between scales); Content/mode does fade-to-black / crossfade.
During that window the control is **busy**, and the control source must model
that so its feedback is honest.
Per-transitional-control lifecycle:
```
idle ──(source sends)──▶ pending ──(renderer starts)──▶ transitioning ──(renderer settles)──▶ idle
```
- **pending** — set locally by the remote the instant the knob is turned
(immediate tactile feedback before the server replies).
- **transitioning** — the server flags the field once the renderer actually
begins the animation; broadcast to *all* control sources.
- **idle** — cleared when the **renderer acks completion** (the only party that
knows real playback timing), with a server-side max-duration timeout as a
safety net so a dropped renderer can't wedge a knob.
Hardware payoff: a physical Altitude encoder can light an LED while
`transitioning`; the iPad twin shows the matching spinner/disabled-detent.
`SessionState` therefore carries
`transitions: { altitude: idle|transitioning, content: idle|transitioning }`
alongside the controls and `seq`. (Live controls need no entry.)
## 5. Server contract
Four endpoints carry the whole thing. The existing `/api/alteration`,
`/api/ring`, `/api/ring/advance` remain as **internal helpers the server calls**;
control sources never hit them directly.
### 5.1 `GET /api/state` — snapshot (sync-on-load / polling fallback)
```json
{
"seq": 42,
"controls": { "content": "video", "left": 3, "right": 1, "mood": -2 },
"altitude": { "index": 2, "scale": "forest", "clip_id": "forest_07" },
"transitions": { "altitude": "idle", "content": "idle" },
"render": { "plan": { }, "content": { "audio_source": null, "video": true } }
}
```
A control source GETs this on load to draw its knobs in the right positions; the
renderer can use it to cold-start or resync after a reconnect.
### 5.2 `POST /api/control` — the one control-source contract
A partial patch — only the fields that changed:
```json
{ "set": { "left": 4 }, "altitude_delta": 0 } // live knob
{ "set": { "content": "off" }, "altitude_delta": 0 } // transitional (mode)
{ "set": {}, "altitude_delta": -1 } // one encoder tick down
```
Server applies it, bumps `seq`, recomputes, and:
- **live field** → broadcasts a `state` event immediately;
- **transitional field** (a `content` change, or a non-zero `altitude_delta`) →
computes the ring move / fade, marks that field `transitioning`, broadcasts a
`ring` (or `mode`) event for the renderer to animate.
Returns the new snapshot (an immediate authoritative echo for the poster).
Absolute `set` values make retries idempotent; `altitude_delta` is relative
(encoder ticks) and the server coalesces rapid ticks via the existing
`advance_ring` fast-spin logic.
### 5.3 `GET /api/events` — SSE stream (renderer subscribes)
Event types:
- `state` — full snapshot; sent on connect and on every live change.
- `ring` — an altitude move: the transition clip(s) to play + the landed
scale/clip. Renderer plays them, then settles.
- `mode` — a content/mode change: the fade the renderer should run.
- `ping` — keepalive.
The SSE event payloads reuse the existing serializers
(`ring_move_to_dict`, `render_plan_to_dict`, etc.).
### 5.4 `POST /api/render/event` — the renderer's one upstream ack
```json
{ "type": "settled", "seq": 42, "field": "altitude" }
```
Sent when the renderer finishes a transition. Server flips that field `idle` and
broadcasts a `state` so every remote clears its spinner. A server-side timeout
(max transition duration + margin) clears it anyway if the renderer never acks.
### 5.5 End-to-end flow for one altitude tick
```
iPad: turn dial → field=pending locally → POST /api/control {altitude_delta:-1}
server: apply, seq++, transitions.altitude="transitioning"
→ SSE "ring" to renderer → SSE "state" to all remotes (altitude busy)
renderer: play transition clip(s), settle on forest_07
→ POST /api/render/event {type:"settled", field:"altitude"}
server: transitions.altitude="idle" → SSE "state" to all → iPad clears spinner
```
### 5.6 Decisions flagged (not assumed silently)
- **Concurrency = last-write-wins.** Multiple control sources are allowed;
per-field last write wins, `seq` orders everything. No locking in v1.
- **No auth.** v1 trusts the LAN — any device that can reach the laptop can drive
it. A room-token is deferred until wanted.
## 6. The two pages
### 6.1 Renderer — `/` (display), refactored from today's `index.html`
- **Removes** the input controls (the `<select>`, the Altitude SVG, the three
sliders) and their handlers.
- **Keeps** everything that draws the experience: the video element, the WebGL
Kuwahara dream shader, the HUD/annotation + affect overlay, the
ring-transition playback, the media preload pool, the crossfade-loop logic.
- **Adds** a thin `state` client: open `EventSource('/api/events')`; on `state`
apply controls→render live; on `ring` play the transition clip(s) then settle
and `POST /api/render/event {settled}`; on `mode` run the fade. On (re)connect,
pull `GET /api/state` to resync. SSE auto-reconnects, so a flaky link
self-heals.
- Runs full-screen on the laptop driving the projector; no keyboard/mouse needed
once launched.
### 6.2 Controller — `/remote`, a new touch-first page (the faithful twin)
- The five controls laid out like the intended physical panel: **Content**
selector, **Altitude** encoder dial (reuse the existing SVG dial component),
**Left** / **Right** / **Mood** knobs.
- On any input → `POST /api/control` with just the changed field. Live knobs post
on drag; Altitude posts encoder ticks; Content posts on selection.
- Per-knob transition state (§4): a transitional control goes **pending** on
touch, reflects **transitioning** from the SSE feed, clears on **settled**.
Altitude shows a settling indicator and its detents soft-lock (further ticks
queue as the next target rather than stacking); Content shows the selection as
pending until the fade completes.
- It **also** subscribes to `/api/events` so it stays in sync with other
sources (turn the future Arduino's knob and the iPad reflects it, and
vice-versa). On load it GETs `/api/state` to draw current positions.
- Touch-sized hit targets, no hover dependence, works in mobile Safari with no
install.
## 7. The future-Arduino seam (contract only in v1)
No firmware in this feature, but the design **proves the seam** so sub-project 4
is a true drop-in:
- **ESP32 (wifi Arduino)** → posts straight to `POST /api/control` over the LAN.
Zero server change. Recommended hardware target when the time comes.
- **Classic USB Arduino** → emits serial frames to a tiny `bridge.py` on the
laptop that translates each frame into a `POST /api/control`. The old roadmap
"3⇄4 serial framing contract" shrinks to *just* the Arduino↔bridge link; the
renderer/server never know the difference.
- Either way the Arduino sends **absolute pot values** (`set`) + **encoder
ticks** (`altitude_delta`) — exactly the iPad's message shape.
This is what makes "faithful twin" real: the iPad and the Arduino are the same
control source speaking the same contract.
## 8. Error handling / resilience
- **Renderer drops:** SSE auto-reconnects; on reconnect it GETs `/api/state` and
resyncs. The per-field transition timeout clears any knob left `transitioning`
by a renderer that vanished mid-animation.
- **Controller drops / flaky wifi:** POSTs are fire-and-retry; absolute `set`
values are idempotent, so a retried knob value is harmless. `altitude_delta` is
the one relative input — on a failed POST the remote keeps the tick in its
local pending target and resends rather than double-counting.
- **No renderer connected:** the server still accepts control changes and holds
state; a renderer that connects later catches up from the snapshot.
- **Stale control source:** every event carries `seq`; a source ignores events
older than what it has applied.
## 9. Testing (satisfies the mandatory pipeline tiers)
- **Pure unit** — a `SessionState` store in Python: apply patch → live vs.
transitional classification; `altitude_delta` → ring move via the existing
`advance_ring`; `seq` bump; transition flag set/clear; timeout. No I/O, tested
like the rest of `player/`.
- **API/contract** — `GET /api/state`; `POST /api/control` (live + transitional +
bad input → 422); `POST /api/render/event` clears the flag; the SSE
event-builder tested as a pure function.
- **E2E (Playwright, required for the UI surface)** — open the renderer and
`/remote` as two pages: move a live knob → assert the renderer's render
updates; tick Altitude → assert a transition plays, the remote shows
`transitioning`, then clears on settle.
## 10. Scope
**In v1:**
- Server `SessionState` + the four endpoints (`/api/state`, `/api/control`,
`/api/events`, `/api/render/event`).
- Renderer refactor to display-only + SSE client + settled ack.
- The `/remote` iPad twin: the five controls + per-knob transition state.
- Wifi/LAN only; laptop renders.
**Explicitly NOT in v1 (deferred):**
- Any Arduino/firmware (contract defined here; hardware later).
- Bluetooth / USB transport.
- Auth / room-token.
- The Pi renderer.
- Multi-room / multi-renderer fan-out (the SSE design allows it; not a v1 goal).
## 11. Roadmap impact
- **Sub-project 4** is reframed: the panel is now "any control source over the
`/api/control` contract," not USB-serial-to-Pi first. The iPad twin is the v1
deliverable; the Arduino is a later drop-in.
- **Sub-project 3's "serial input" slice** is absorbed into a transport detail
(the Arduino↔bridge link), no longer a Pi-coupled concern.
- `ROADMAP.md` to be updated at implementation time.
+17 -14
View File
@@ -88,11 +88,12 @@ class AnalyticalOverlay:
@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)."""
"""Affect channel (RIGHT brain): emotion-words surfaced by the Right (dreamlike)
knob alone — the analytical Left knob does NOT gate them. `strength` is the Right
knob (0..4) — a runtime affect track picks which feeling words appear by it;
`intensity` 0..1 is their opacity. Feeling is the right brain's output, surfaced
as the machine's felt reading (affect-channel design, session 0013; Right-only
since session 0018)."""
strength: int
intensity: float
@@ -122,8 +123,9 @@ class RenderPlan:
@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."""
clause: `affect.strength == right`, and `right > 0` already makes
`dream.strength > 0`, so an active-affect plan is non-identity via the
dream check below."""
return (
self.grade.is_identity
and self.overlay.level == 0
@@ -135,13 +137,14 @@ def _overlay_intensity(left: int, cal: Calibration) -> float:
return _clamp(cal.overlay_gain * left / KNOB_MAX, 0.0, 1.0)
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 _affect_strength(right: int) -> int:
"""Affect is a RIGHT-brain output: the emotion channel is driven by the Right
(dreamlike) knob alone — the analytical Left knob no longer gates it."""
return right
def _affect_intensity(left: int, right: int, cal: Calibration) -> float:
return _clamp(cal.overlay_gain * _affect_strength(left, right) / KNOB_MAX, 0.0, 1.0)
def _affect_intensity(right: int, cal: Calibration) -> float:
return _clamp(cal.overlay_gain * _affect_strength(right) / KNOB_MAX, 0.0, 1.0)
def _dream_intensity(right: int, cal: Calibration) -> float:
@@ -163,8 +166,8 @@ def plan_alteration(
intensity=_overlay_intensity(coord.left, calibration),
),
affect=AffectOverlay(
strength=_affect_strength(coord.left, coord.right),
intensity=_affect_intensity(coord.left, coord.right, calibration),
strength=_affect_strength(coord.right),
intensity=_affect_intensity(coord.right, calibration),
),
dream=Dream(
strength=coord.right,
@@ -0,0 +1,25 @@
# Session 0019.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-26T04-47 (PST)
> Type: brainstorming
> Posture: careful
> Claude-Session: d6ed1203-92b6-471f-80a4-4c773b645ca1
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0019 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0019.0-TRANSCRIPT-2026-06-26T04-47--<end>.md form at session end.
## Launch prompt
```
Let's start talking about hardware. Let's assume for the first version I'm ok having my computer or laptop do the rendering. We'd still want a controller but it could be connected to the computer via wifi, USB, or bluetooth and would send commands to the software we've already written that would adjust left brain, right brain, etc. We'd prototype this by having a controller simulator run on another device, such as an ipad (just riffing here) but eventually move to a physical remote running on an Arduino or similar
```
## 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._
+3
View File
@@ -52,5 +52,8 @@
},
"0018": {
"title": ""
},
"0019": {
"title": ""
}
}
+53
View File
@@ -59,6 +59,41 @@ def _asset_version() -> str:
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
left: int = Field(ge=0, le=4)
@@ -160,6 +195,24 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
def api_clips():
return {"clips": [c.to_dict() for c in app.state.clips]}
@app.get("/api/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:
+10 -1
View File
@@ -38,7 +38,7 @@ 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"],
"cosmos": ["cosmos", "cosmos_miri", "cosmos_galaxies", "cosmos_hudf", "cosmos_xdf"],
"orbit": ["orbit_planetearth", "orbit_crewobs", "orbit_bluemarble"],
"coast": ["coast_birdrock", "coast_surfgrass", "coast_elkbeach", "coast_drakesbeach"],
"reef": ["reef_lionfish", "reef_spawning", "reef_hawkfish", "reef_snapper", "reef_coralspacific"],
@@ -59,6 +59,10 @@ META: dict[str, tuple[str, str, str]] = {
"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 1034s, crossfade-loop. Text-free — replaces the Orion SVS 30957 flythrough."),
"cosmos_miri": ("Cosmic Cliffs — Carina Nebula, mid-infrared (NASA/ESA/CSA/STScI)", CCBY_WEBB,
"ESA/Webb weic2205c (Pan of the Carina Nebula, combined NIRCam + MIRI); 4K source, "
"trim 426s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold "
"dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free."),
"cosmos_galaxies": ("Flying Through Galaxies (NASA SVS)", PD,
"NASA SVS a014950 14950_Galaxies_FlyThrough_4k; trim 832s, crossfade-loop"),
"cosmos_hudf": ("Hubble Ultra Deep Field zoom (NASA/ESA/STScI)", CCBY_STSCI,
@@ -167,6 +171,11 @@ LABELS: dict[str, list[dict]] = {
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · a newborn star, <1 Myr old"], [0.54, 0.12, 0.12, 0.14]),
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
"cosmos_miri": [
static_label("detected.nebula", 4, ["dust", "nebula", "dust ridge", "dust ridge · mid-infrared glow of the Carina cliffs"], [0.10, 0.45, 0.7, 0.45]),
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · its dust laid bare in mid-infrared"], [0.48, 0.18, 0.14, 0.16]),
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
"cosmos_galaxies": [
static_label("detected.galaxy", 4, ["galaxy", "spiral galaxy", "barred spiral", "barred spiral · ~10¹¹ stars"], [0.34, 0.30, 0.30, 0.34]),
measure("measure.distance", "~Mly", [0.06, 0.06, 0.18, 0.08], 3),
+116
View File
@@ -115,6 +115,121 @@
}
}
},
{
"id": "cosmos_miri",
"title": "Cosmic Cliffs — Carina Nebula, mid-infrared (NASA/ESA/CSA/STScI)",
"base_file": "cosmos_miri/base.mp4",
"license": "CC-BY-class — credit NASA, ESA, CSA, STScI",
"source": "ESA/Webb weic2205c (Pan of the Carina Nebula, combined NIRCam + MIRI); 4K source, trim 426s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free.",
"right_variants": {},
"annotations": [
{
"key": "detected.nebula",
"salience": 4,
"box": [
0.1,
0.45,
0.7,
0.45
]
},
{
"key": "detected.star",
"salience": 2,
"box": [
0.48,
0.18,
0.14,
0.16
]
},
{
"key": "measure.distance",
"box": [
0.06,
0.06,
0.2,
0.08
],
"min_level": 3
}
],
"affect": [
{
"key": "feel.wonder",
"at": [
0.5,
0.44
],
"min_level": 1
},
{
"key": "feel.vastness",
"at": [
0.2,
0.7
],
"min_level": 2
},
{
"key": "feel.insignificance",
"at": [
0.66,
0.6
],
"min_level": 3
},
{
"key": "feel.longing",
"at": [
0.42,
0.84
],
"min_level": 4
}
],
"strings": {
"en": {
"detected.nebula": [
"dust",
"nebula",
"dust ridge",
"dust ridge · mid-infrared glow of the Carina cliffs"
],
"detected.star": [
"star",
"young star",
"protostar",
"protostar · its dust laid bare in mid-infrared"
],
"measure.distance": "≈7,500 ly",
"feel.wonder": [
"wow",
"wonder",
"awe",
"transcendent awe"
],
"feel.vastness": [
"big",
"vastness",
"immensity",
"a dizzying immensity"
],
"feel.insignificance": [
"small",
"smallness",
"insignificance",
"a humbling insignificance"
],
"feel.longing": [
"want",
"longing",
"yearning",
"an aching yearning"
]
}
}
},
{
"id": "cosmos_galaxies",
"title": "Flying Through Galaxies (NASA SVS)",
@@ -2252,6 +2367,7 @@
"clip_id": "cosmos",
"pool": [
"cosmos",
"cosmos_miri",
"cosmos_galaxies",
"cosmos_hudf",
"cosmos_xdf"
+55 -14
View File
@@ -42,6 +42,11 @@ let lastOverlay = null; // {level, intensity} from the most-recent renderOver
async function loadData() {
const clips = (await (await fetch("/api/clips")).json()).clips || [];
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
// Per-file content-hash tokens → appended to /media URLs as ?v=<hash> so a
// re-baked clip (new bytes, same path) gets a fresh URL the browser can't serve
// stale. Best-effort: an empty map just yields un-versioned URLs.
try { mediaVersions = (await (await fetch("/api/media-versions")).json()).versions || {}; }
catch (_) { mediaVersions = {}; }
const r = await fetch("/api/ring");
serverRing = r.ok;
ring = r.ok ? await r.json() : null;
@@ -86,7 +91,12 @@ function activeClip() {
// path, so swapping the altitude scale loads near-instantly (no fetch round-trip).
// Falls back to the network path for anything not yet (or never) cached.
const mediaBlobs = {};
function mediaUrl(file) { return mediaBlobs[file] || ("/media/" + file); }
let mediaVersions = {}; // file -> content-hash token (from /api/media-versions)
// Network path for a media file, content-hash-versioned so a re-baked clip's URL
// changes with its bytes (permanent cache-bust). The blob cache, when present,
// wins — those bytes are already the current ones for this session.
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
function mediaUrl(file) { return mediaBlobs[file] || mediaNetUrl(file); }
// Every media file the ring can show: each scale-pool clip's base footage plus the
// per-edge transition clips. This is the full preload set (~two dozen files).
@@ -136,13 +146,12 @@ async function preloadAllMedia(concurrency = 4) {
const file = files[i++];
if (!mediaBlobs[file]) {
try {
// `cache: "reload"` bypasses the browser HTTP cache and refetches from the
// network, then updates the cache entry. This busts a clip whose bytes were
// re-baked under the SAME path (e.g. a re-sourced cosmos base) — including
// one a pre-`no-cache` build had pinned `immutable, max-age=1y`, which a
// plain reload would never revalidate. The blob cache above still gives
// The content-hash `?v=` already makes a re-baked clip's URL unique, but
// `cache: "reload"` is belt-and-suspenders: it bypasses the HTTP cache for
// this fetch (so even an un-versioned or immutable-pinned prior entry can't
// serve stale) and refreshes the entry. The blob cache above still gives
// instant in-session swaps; this only affects the one fetch per (re)load.
const blob = await (await fetch("/media/" + file, { cache: "reload" })).blob();
const blob = await (await fetch(mediaNetUrl(file), { cache: "reload" })).blob();
mediaBlobs[file] = URL.createObjectURL(blob);
} catch (_) { /* leave it to the network path on demand */ }
}
@@ -447,10 +456,11 @@ function renderOverlay(level, intensity) {
lastOverlay = { level, intensity };
}
// Affect channel: soft, glowing emotion-words surfaced only when BOTH knobs are
// up. `strength` = min(left, right); `intensity` is the layer opacity. Words are
// placed at authored scene points (no boxes — feelings are scene-level) and read
// softer than the clinical reticles — the dream leaking into the machine's read.
// Affect channel: soft, glowing emotion-words — a RIGHT-brain output, surfaced by
// the Right (dreamlike) knob alone (`strength` = right; the Left analytical knob no
// longer gates them). `intensity` is the layer opacity. Words are placed at authored
// scene points (no boxes — feelings are scene-level) and read softer than the
// clinical reticles — feeling, not measurement.
function renderAffect(strength, intensity, right) {
const clip = activeClip();
if (!affectLayer) return; // tolerate a stale page missing the affect layer
@@ -463,7 +473,7 @@ function renderAffect(strength, intensity, right) {
const [x, y] = f.at.map((n) => n * 100);
const t = svg("text", { x, y, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
// RIGHT emotion tier: the vocabulary escalates basic -> compound as the Right
// knob rises (appearance still gated by min(left,right) above).
// knob rises (appearance is gated by the Right knob via `strength` above).
const raw = strings[f.key];
t.textContent = pickTier(raw !== undefined ? raw : f.key, clamp(right || 0, 1, tierCount(raw)));
}
@@ -504,7 +514,7 @@ async function update() {
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; }
if (!data.content.video) { black.style.opacity = "1"; black.classList.remove("hidden"); return; }
black.classList.add("hidden");
try {
ensureClipMedia();
@@ -559,6 +569,29 @@ function playTransition(file, blended, reversed) {
});
}
// Briefly fade through the #black overlay while `swap` changes the displayed clip,
// so a content swap reads as a deliberate cut, not a jarring jump. Restores #black
// to its hidden/opaque resting state (the video-off use) afterward.
function fadeThroughBlack(swap) {
return new Promise((resolve) => {
black.style.opacity = "0";
black.classList.remove("hidden");
void black.offsetWidth; // reflow so the 0 -> 1 transition runs
black.style.opacity = "1";
setTimeout(() => {
try { swap(); } catch (_) {}
setTimeout(() => {
black.style.opacity = "0";
setTimeout(() => {
black.classList.add("hidden");
black.style.opacity = ""; // back to CSS default for the video-off use
resolve();
}, 210);
}, 160); // let the chosen clip decode under black
}, 210);
});
}
async function advance(delta) {
if (busy || !ring || ring.scales.length < 2) return;
busy = true;
@@ -576,8 +609,16 @@ async function advance(delta) {
}
ringIndex = move.to_index;
activeClipId = move.target_clip_id; // the randomly-picked pool member to load
currentClipId = null; // force the target scale's base media to (re)load
renderScaleReadout();
// The baked transition lands on the scale PRIMARY; if the rotating pool picked a
// DIFFERENT member, mask the swap behind a fade to black so it doesn't hard-cut
// from the primary to the chosen clip (e.g. coast birdrock -> surfgrass).
const landedPrimary = (ring.scales[move.to_index] || {}).clip_id;
if (move.target_clip_id && move.target_clip_id !== landedPrimary) {
await fadeThroughBlack(() => { currentClipId = null; ensureClipMedia(); });
} else {
currentClipId = null; // same clip the transition ended on — plain (re)load
}
} finally {
busy = false;
update();
+1 -1
View File
@@ -39,7 +39,7 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; }
.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; }
.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; }
+23 -21
View File
@@ -55,42 +55,44 @@ def test_left_and_right_stack_not_cancel():
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.
def test_affect_is_driven_by_the_right_knob_alone():
# Right-brain affect (session 0018): emotion-words are controlled by the Right
# knob alone — Right at 0 means no affect regardless of Left, and Left no longer
# gates the channel (full Right with Left at 0 still surfaces affect).
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
assert plan_alteration(_coord(left=0, right=4)).affect.strength == 4
assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 1.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
def test_affect_strength_is_the_right_knob():
# strength = right — Left is irrelevant to the affect channel.
assert plan_alteration(_coord(left=2, right=4)).affect.strength == 4
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
assert plan_alteration(_coord(left=0, right=3)).affect.strength == 3
assert plan_alteration(_coord(left=4, 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_scales_with_right_over_full_scale():
for r in range(5):
plan = plan_alteration(_coord(left=0, right=r))
assert plan.affect.intensity == pytest.approx(r / 4)
assert plan_alteration(_coord(left=0, 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.
# affect text rides the overlay-opacity gain, but is keyed off the Right knob.
cal = Calibration(overlay_gain=0.5)
assert plan_alteration(_coord(left=4, right=4), cal).affect.intensity == pytest.approx(0.5)
assert plan_alteration(_coord(left=0, 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
assert plan_alteration(_coord(left=0, 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
# affect.strength == right, and right > 0 makes dream.strength > 0, so an active
# affect plan (even with Left at 0) is never identity.
plan = plan_alteration(_coord(left=0, right=3))
assert plan.affect.strength == 3
assert not plan.is_identity
+24
View File
@@ -76,6 +76,30 @@ def test_clips_returns_the_manifest(client):
assert data["clips"][0]["annotations"][0]["key"] == "detected.water"
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.