Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0b29c032c | |||
| 1b96e7065b | |||
| f28a06cc7f | |||
| 229af60f1f | |||
| c389e2a834 | |||
| 32e74ca446 | |||
| e3a9b7cff6 | |||
| 7a2f37af7e | |||
| d0ad9a9ac9 | |||
| c3aa419c53 | |||
| 5bb762ae53 | |||
| 2eb752b5bc | |||
| aa56dfe145 | |||
| ed267c554c | |||
| 7da7446740 | |||
| d24f829276 | |||
| de33cbe86d | |||
| 129bb23cb9 | |||
| a2ef792f0f | |||
| 53cb89b4c6 | |||
| 0201690ce0 | |||
| 0928988abc | |||
| a1c9c5c760 | |||
| ab2edf77bb | |||
| d19fe4e8fa | |||
| 9a83c0132d | |||
| 7fd9aabd0e | |||
| 7a32ff1acc | |||
| 5fae4f9005 | |||
| 7628a5321f | |||
| bcd4d32b19 | |||
| c4452396a1 |
@@ -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
|
||||
|
||||
@@ -23,20 +23,43 @@ a credit line** (our overlays + dream are derivatives, which these allow);
|
||||
**CC‑BY‑ND is NOT usable** (no derivatives); **CC‑BY‑NC only if the installation
|
||||
stays non‑commercial**.
|
||||
|
||||
## 🌌 cosmos (pool of 4)
|
||||
## 🌌 cosmos (pool of 5)
|
||||
|
||||
| id | clip | source | window (s) | license / credit |
|
||||
|----|------|--------|-----------|------------------|
|
||||
| `cosmos` | Orion Nebula flythrough | NASA/JPL‑Caltech — https://images.nasa.gov/details/JPL-20221122-SOLSYSf-0001-Orion%20Dust%20and%20Death | ~30–54 | PD (17 U.S.C. §105) |
|
||||
| `cosmos` | Cosmic Cliffs — Carina Nebula flythrough | NASA SVS 31348 — https://svs.gsfc.nasa.gov/31348/ (`Clifs-3d-STScI.mp4`, *Exploring the Cosmic Cliffs in 3D*, Webb NIRCam, **4K**) | 10–34 | CC‑BY‑class — credit **NASA, ESA, CSA, STScI** |
|
||||
| `cosmos_miri` | Cosmic Cliffs — Carina Nebula, **mid-infrared** (same towers, muted gray/teal/gold dust palette) | ESA/Webb weic2205c — https://esawebb.org/videos/weic2205c/ (*Pan of the Carina Nebula, combined NIRCam + MIRI*, **4K** `weic2205c.mp4`) | 4–26 | CC‑BY‑class — credit **NASA, ESA, CSA, STScI** |
|
||||
|
||||
> ℹ️ **Cosmos primary swapped to the Webb "Cosmic Cliffs" flythrough (2026-06-25, session 0018).**
|
||||
> History: the original primary was NASA/JPL's *"Orion: Dust and Death"* (captions
|
||||
> burned in → cropping lost framing); it was then swapped to STScI's text-free
|
||||
> *Flight Through the Orion Nebula* (SVS 30957, visible light). The operator found
|
||||
> the Orion clip merely good and asked for a more dramatic nebula, so the primary is
|
||||
> now STScI's **Exploring the Cosmic Cliffs in 3D** (Carina Nebula, Webb NIRCam,
|
||||
> **SVS 31348**) — a full-frame, text-free 3D flythrough sourced from its **4K**
|
||||
> master (`Clifs-3d-STScI.mp4`, 3840×2160) → supersampled 1080p base + 4K master.
|
||||
> Trim 10–34s clears the opening title card (gone by ~8s) and the end-credit card
|
||||
> (~102s). The earlier `decaption_cosmos` crop helper remains removed.
|
||||
| `cosmos_galaxies` | Flying Through Galaxies | NASA SVS — https://svs.gsfc.nasa.gov/vis/a010000/a014900/a014950/14950_Galaxies_FlyThrough_4k.mp4 | 8–32 | PD |
|
||||
| `cosmos_hudf` | Hubble Ultra Deep Field zoom | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030687/hudf-b-1920x1080p30.mov | 20–44 | CC‑BY‑class — credit **NASA, ESA, STScI** |
|
||||
| `cosmos_xdf` | eXtreme Deep Field flythrough | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030681/hxdf_fly-b-1920x1080p30.mov | 2–26 | CC‑BY‑class — credit **NASA, ESA, STScI** |
|
||||
|
||||
> ➕ **`cosmos_miri` added (2026-06-26).** Operator wanted more *Cosmic Cliffs*
|
||||
> footage and a different-color rendering. The keep-the-flythrough-primary +
|
||||
> add-the-mid-infrared-pan pair: ESA/Webb's **weic2205c** is the *same* Carina
|
||||
> towers shot with NIRCam **+ MIRI**, so it renders in a muted gray/teal/gold dust
|
||||
> palette instead of the iconic orange/blue — a distinct look from the same scene.
|
||||
> Sourced text-free/human-free from the **4K** master, trim 4–26s (clears the
|
||||
> opening fade-from-black, ends before any tail fade) → 1080p base + 4K master,
|
||||
> 1 s crossfade-loop (~21 s). ESA/Webb's narration-free *"Diving Into the Cosmic
|
||||
> Cliffs"* pan (`carina_revisit_pan`, NIRCam palette) was the runner-up — left
|
||||
> out for now since it repeats the existing palette; easy to add if wanted.
|
||||
|
||||
> ⚠️ **DISCREPANCY to fix next session.** Operator selected cosmos **#1, #3
|
||||
> (Orion), #4, #5**. During processing, **#6 Galaxy Traverse was run by mistake
|
||||
> instead of #3 Orion**. The Orion clip (#3) is text‑free and already lives at
|
||||
> `simulator/sample_media/cosmos/base.mp4` (the prior real base), so the pool above
|
||||
> is correct. **`cosmos_traverse` is an UNSELECTED extra on disk — drop it** (or
|
||||
> instead of #3 Orion**. The cosmos primary now lives at
|
||||
> `simulator/sample_media/cosmos/base.mp4` as the Webb Cosmic Cliffs flythrough
|
||||
> (SVS 31348 — see the cosmos-primary note above). **`cosmos_traverse` is an UNSELECTED extra on disk — drop it** (or
|
||||
> confirm with operator). The current ring scale id is `cosmos`; when the pool is
|
||||
> wired, `cosmos` becomes one pool member.
|
||||
|
||||
@@ -108,5 +131,5 @@ Removed from local media (all were gitignored, so repo-invisible):
|
||||
- `forest` — the old Yosemite base, superseded by the `coast` pool. **dropped.**
|
||||
- `reef` — the original placeholder/early reef base, superseded by `reef_*`. **dropped.**
|
||||
- `orbit` / `abyss` — the Increment‑1 single real bases (NASA ISS Exp65 / ctenophore),
|
||||
not in the new pools. **dropped** (the cosmos Orion base stays as the cosmos pool primary).
|
||||
not in the new pools. **dropped** (the cosmos base — now the Webb Cosmic Cliffs flythrough, SVS 31348 — stays as the cosmos pool primary).
|
||||
- `review.html` gallery — now `.gitignore`d (local‑only, re‑buildable).
|
||||
|
||||
@@ -1,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)` (0–4). Either knob
|
||||
at 0 → no emotions at all. Opacity scales with that strength × `overlay_gain`.
|
||||
- **Escalation.** A **stable emotional palette per scene** — higher combined
|
||||
strength reveals *more* words at higher opacity. Each word carries a `min_level`
|
||||
(the same mechanic the detection channel uses), but compared against the
|
||||
*combined* `min(left, right)` strength rather than Left alone.
|
||||
- **Trigger / intensity.** Affect strength = `right` (0–4). Right at 0 → no
|
||||
emotions at all, regardless of Left. Opacity scales with that strength ×
|
||||
`overlay_gain`.
|
||||
- **Escalation.** A **stable emotional palette per scene** — higher Right reveals
|
||||
*more* words at higher opacity. Each word carries a `min_level` (the same
|
||||
mechanic the detection channel uses), compared against the `right` strength.
|
||||
- **Rendering.** Floating words at authored scene positions — **no boxes or
|
||||
reticles**, lowercase, semi-transparent with a soft glow, in a distinct **violet**
|
||||
register so they read as affective and clearly *softer* than the hard clinical
|
||||
@@ -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,64 @@
|
||||
# Simulator Dev Mode — design
|
||||
|
||||
> 2026-06-25 · session 0017 · planning-and-executing
|
||||
> Anchor: leaf task (simulator dev-tooling — R2b, no upstream design needed)
|
||||
|
||||
## Problem
|
||||
|
||||
While previewing the experience, the operator can't choose *which* clip in an
|
||||
altitude's rotating pool is shown — the server picks a random pool member on each
|
||||
landing (content-pipeline §11.1). For by-eye review they want to step through every
|
||||
clip in a pool on demand, and to see the engine/clip data behind what's on screen.
|
||||
The RenderPlan JSON readout is also always visible, cluttering what should read as
|
||||
an audience-facing preview.
|
||||
|
||||
## Goal
|
||||
|
||||
A single **Dev Mode** toggle at the bottom of the control panel. Off by default
|
||||
(persisted in `localStorage`). When on, a panel appears directly below it with all
|
||||
dev controls and data; when off, the normal view is just the experience + the three
|
||||
control fieldsets.
|
||||
|
||||
## Scope
|
||||
|
||||
Frontend only — `simulator/static/{index.html,app.js,style.css}`. No API changes:
|
||||
every datum Dev Mode shows is already available client-side (`/api/clips`,
|
||||
`/api/ring`, the `/api/alteration` response, and the in-memory preload cache). The
|
||||
separate `/author.html` remains the place to *edit* labels; Dev Mode is
|
||||
inspect-and-select only.
|
||||
|
||||
## Dev panel contents (top → bottom)
|
||||
|
||||
1. **Pool picker** — a `<select>` of the current altitude's pool members
|
||||
(`clip_id · title`), its value reflecting the clip currently playing. Choosing a
|
||||
member sets `activeClipId` to it and reloads the base media, overriding the random
|
||||
landing pick. A `🎲 Re-roll random` button re-runs the server-canonical random
|
||||
pick for the current scale. The dropdown is rebuilt on every scale landing, so it
|
||||
always lists the current pool; single-clip scales (cosmos/orbit) show one entry.
|
||||
2. **Active clip** — `id`, `title`, `source`, `license`, `base_file`.
|
||||
3. **Annotations & affect** — each annotation key with salience (or legacy
|
||||
`min_level`), time window (`appear`–`disappear`), and tier count; each affect key
|
||||
with `min_level` and tier count. Read-only.
|
||||
4. **RenderPlan** — the relocated `#readout` (the `/api/alteration` JSON).
|
||||
5. **Cache & playback** — preload status (`N/total cached`), whether the active
|
||||
clip is served **from memory** (a preloaded blob) vs the network, and live video
|
||||
resolution / duration / loop position.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Pool override is per-landing.** Changing altitude re-rolls randomly as today;
|
||||
the operator then picks again from the new scale's pool. The pick is not persisted
|
||||
across landings (YAGNI — "select any video while on an altitude").
|
||||
- **Refresh points.** Pool picker + clip metadata + annotations rebuild whenever the
|
||||
scale lands (hooked into `renderScaleReadout()`) and after a manual pick/re-roll.
|
||||
The RenderPlan readout updates on each `update()` (knob change) as today. The
|
||||
cache/playback stats refresh on a light interval (~500 ms) while Dev Mode is on,
|
||||
and the cache count also ticks up as the preloader fills.
|
||||
- **`pickRandomMember()`** is factored out of `landScale()` so the initial landing,
|
||||
ring advances, and the Re-roll button all share the one server-canonical pick.
|
||||
|
||||
## Testing
|
||||
|
||||
No JS unit harness in the repo and no API change, so verification is: `node --check`
|
||||
on `app.js`, the Python suite stays green (unaffected), and by-eye review by the
|
||||
operator (no headless browser yet — §9 E2E policy noted, machinery pending).
|
||||
@@ -0,0 +1,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 0–4 | knob/pot, 5 detents |
|
||||
| **Right** (dreamlike) | slider 0–4 | knob/pot, 5 detents |
|
||||
| **Mood** | slider −4…+4 (dark↔light) | center-detented knob |
|
||||
|
||||
(This is the original sub-project-4 inventory, *updated*: Dark+Light collapsed
|
||||
into one **Mood** knob in session 0013, 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
@@ -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,117 @@
|
||||
# Session 0016.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-24T17-48 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 0fde1d36-7c64-414f-8f49-8669b5dd2263
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/goal` Content pipeline Increment 2 (expanded) — rotating clip pools + time-windowed
|
||||
TRACKED labels + progressive left-brain detail + progressive right-brain emotions.
|
||||
Short design pass first (schema changes), then build.
|
||||
|
||||
> Anchor: design `docs/superpowers/specs/2026-06-24-content-pipeline-design.md`
|
||||
> (graduated, R2a-eligible). Repo is roadmap-anchored, no Gitea issue tracker (R1
|
||||
> deviation, as all prior sessions). Increment 2 of that design.
|
||||
|
||||
## Pre-state
|
||||
|
||||
`main` at `b28f930` (PR #15 — operator-curated rotating-pool doc merged). All 19
|
||||
vetted pool clips on disk (gitignored) plus the leftovers to drop. The ring was the
|
||||
Increment-1 one-base-per-scale model (cosmos/orbit/forest/reef/abyss), flat
|
||||
`min_level` label gating, static or simple-track boxes, scene-level affect gated by
|
||||
`min(left,right)`. `tools/pipeline/` had stages 1–4 + 6 (no `track.py`). 246 tests.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Session gate + init.** Classified the `/goal` as planning-and-executing,
|
||||
claimed session **0016** (peeked an ENDED-UNFINALIZED 0015 — its PR #15 work was
|
||||
already merged, not live — and proceeded; no worktree needed, solo session).
|
||||
Verified clean `main`, read the pool doc + spec + sub-project-3 memory + the live
|
||||
code (ring, clips, app, app.js, alteration, pipeline).
|
||||
2. **Design pass (operator asked for it — schema changes).** Appended **§11** to the
|
||||
content-pipeline spec pinning the concrete shapes: rotating pool, time-windowed
|
||||
tracks, progressive Left detail tiers (salience + tiered strings), progressive
|
||||
Right emotion tiers, and the `track.py` + author-mode tooling. Key call: tiers
|
||||
need **no engine change** — the client reads `overlay.level` (Left) and
|
||||
`dream.strength` (Right) already in the `RenderPlan`.
|
||||
3. **PR #16 — the experience.** `Scale.pool` + pure `pick_clip_id` (injected
|
||||
randomness); server-side random pick at the API boundary (delta=0 = initial pick).
|
||||
`forest`→`coast` rename + ring reorder + orphan-media cleanup. Time-windowed
|
||||
tracked labels (`appear`/`disappear`, seam-wrapping). Salience-gated +
|
||||
tier-escalating Left labels; tier-escalating Right emotions; tiered-or-static
|
||||
strings (back-compat). New `build_pool_manifest.py` emits the 19-clip pool
|
||||
manifest (abyss+reef authored as tracked/windowed showcase). Merged via Gitea API.
|
||||
4. **PR #17 — the tooling.** `tools/pipeline/track.py`: classical LK optical-flow box
|
||||
tracker (this opencv build has no CSRT) → sparse keyframed track + window; optional
|
||||
lazy ML path. Simulator AUTHOR MODE (`/author.html` + `/api/author/{track,clip}`):
|
||||
scrub, drag a seed box, run tracker, author tiers/affect, save to manifest
|
||||
(idempotent upsert, keeps provenance). Repointed the pipeline integration test off
|
||||
the retired forest base; USER_GUIDE sim section brought current. Merged.
|
||||
|
||||
## Cut state
|
||||
|
||||
`main` at `c445239` (PR #17 merge). **267 passed / 2 skipped.** Clean tree, fully
|
||||
pushed, no open PRs. Both feature branches deleted. All 5 goal items delivered.
|
||||
|
||||
Verification was API/logic-level + JS syntax + live-server probes (5 scales with
|
||||
pools, rotating picks, new edges, 19 clips served, tracker returns keyframes on real
|
||||
abyss_wow footage, committed manifest untouched by the read-only probe). **The
|
||||
VISUAL look was not screenshot-verified — no Chrome on this box** — so operator
|
||||
by-eye review is the next step (matches the established deferred-review pattern).
|
||||
|
||||
## Follow-up (post-finalize, same conversation) — PR #18
|
||||
|
||||
Operator asked to replace the scale-ring `⊖ out / in ⊕` buttons with a **circular
|
||||
"Altitude" knob** that turns all the way around (including abyss→cosmos). Built a
|
||||
turnable SVG dial in `simulator/static/app.js` (`buildDial` / `renderDial` / drag +
|
||||
wheel handlers) + `style.css`: cosmos (highest) at top, clockwise descends
|
||||
cosmos→orbit→coast→reef→abyss and **wraps** back up. Dragging accumulates rotation
|
||||
and commits whole detents on release (a big spin → one fast blended pass, reusing
|
||||
the proven wheel/detent path); scroll the knob to step; tap a label to jump the
|
||||
shortest way around. **Pure frontend** — the endless ring + wrap already existed
|
||||
server-side, so no engine/API change. JS syntax-checked; 23 sim-API tests green;
|
||||
live probe confirmed the dial replaced the buttons and the abyss→cosmos wrap is
|
||||
intact. Merged as **PR #18** (`main` at `7a32ff1`). Visual look still operator-by-eye
|
||||
(no Chrome on this box).
|
||||
|
||||
## Deployment pipeline (§9)
|
||||
|
||||
No stage run: the HEF simulator is a **local dev tool** (simulator-first directive),
|
||||
not a deployed app — it has no PPE/prod infra. Nothing shippable through the §9
|
||||
pipeline this session; the session output is the merged code on `main`.
|
||||
|
||||
## Plan archival
|
||||
|
||||
No separate `superpowers:writing-plans` artifact was produced — the design pass was a
|
||||
spec §11 edit (merged in PR #16) and execution was direct TDD tracked by the
|
||||
transcript `## Plan` block + the task list. Nothing to `submit-plan.sh`; this app has
|
||||
no content repo anyway (roadmap-anchored).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Adopting/finalizing the ENDED-UNFINALIZED session 0015 placeholder** was skipped
|
||||
— its PR #15 work is already on `main` and its outcomes are fully recorded in
|
||||
memory; finalizing a prior dead session was judged an unnecessary detour. Low
|
||||
confidence; flagging for the operator.
|
||||
- **By-eye visual review deferred** to the operator (no Chrome here) — consistent with
|
||||
the prior-session pattern, but the look of the tiers/windows/pool rotation AND the
|
||||
new Altitude knob is unconfirmed visually.
|
||||
- **Light-vs-rich label authoring:** only abyss + reef + the scale primaries are
|
||||
richly authored; the other pool members carry lighter labels. Finishing them is the
|
||||
author-mode follow-up (next goal).
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
`/goal` Operator by-eye review of Increment 2 + the Altitude knob in the sim
|
||||
(`make sim-local`, :8000): turn the Altitude knob (drag/scroll, wraps abyss→cosmos;
|
||||
pools rotate per landing), sweep Left (progressive label tiers
|
||||
general→scientific+fact), sweep Right with Left up (progressive emotions
|
||||
basic→compound), watch abyss/reef time-windowed tracked labels enter/leave + track,
|
||||
and exercise `/author.html`. Then author real labels for the remaining pool members
|
||||
via author mode. After the content is liked, the next build frontier is the deferred
|
||||
i2v ring transitions (generative-video model + adjacent real bases) and/or the Pi
|
||||
renderer + serial/firmware (per [[simulator-first-before-hardware]]), per ROADMAP.md.
|
||||
+5
-5
@@ -1,15 +1,15 @@
|
||||
# Session 0016.0 — Transcript
|
||||
# Session 0017.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-24T17-48 (PST)
|
||||
> Start: 2026-06-25T07-41 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 0fde1d36-7c64-414f-8f49-8669b5dd2263
|
||||
> Claude-Session: bcb069f2-0fba-4a66-ae8e-f40b7917a48c
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0016 for human-experience-filter-art. The driver replaces this
|
||||
> This file reserves session ID 0017 for human-experience-filter-art. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--<end>.md form at session end.
|
||||
> SESSION-0017.0-TRANSCRIPT-2026-06-25T07-41--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Session 0018.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-25T09-42 (PST)
|
||||
> End: 2026-06-26T05-56 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: b96041e5-c682-49f5-88ee-380ea7abb6af
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Find a better public-domain nebula flythrough video for the cosmos scale (the current text-free SVS Orion clip lost quality when cropped to remove its text overlay), or upscale the existing one if no better source exists.
|
||||
```
|
||||
|
||||
A content-and-polish session on the simulator's cosmos scale that grew, by
|
||||
operator follow-ups, into four merged PRs: the nebula swap, two cache fixes, a
|
||||
ring-landing artifact fix, and a Right-brain affect rework.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `feature/ring-transitions-real` (later merged): HEAD `129bb23` had just
|
||||
swapped the cosmos primary off the captioned/cropped JPL "Orion: Dust and Death"
|
||||
clip onto the text-free SVS 30957 Orion flythrough.
|
||||
- Sim served by a manually-launched `uvicorn ... --port 8000` (no `--reload`),
|
||||
booted before the session's media changes.
|
||||
- Affect channel gated on `min(left, right)` (session 0013 rule).
|
||||
- Concurrent unfinalized sessions present (0015, 0017).
|
||||
|
||||
## Arc (turn by turn)
|
||||
|
||||
1. **Anchor + research.** Classified as planning-and-executing, claimed session
|
||||
0018. Found the current cosmos clip was already the uncropped text-free SVS
|
||||
30957 Orion (the crop damage the prompt described was already fixed at HEAD).
|
||||
Researched NASA SVS: (a) the same Orion clip exists in 4K; (b) the Webb
|
||||
**"Cosmic Cliffs"** Carina flythrough (SVS 31348) is a 4K PD alternative.
|
||||
Offered three options; **operator chose #2 — switch to Cosmic Cliffs.**
|
||||
|
||||
2. **Cosmos swap (PR #21).** Downloaded the Carina 4K master
|
||||
(`Clifs-3d-STScI.mp4`, 3840×2160, 60 fps, 103 s), sampled frames to find a
|
||||
clean window, trimmed **10–34 s** (clears the title card ~8 s and the
|
||||
end-credit card ~102 s), ran `tools.pipeline.run.process_clip` → 4K master +
|
||||
supersampled 1080p base (5.5 Mbps, crisper than the old 2.9 Mbps Orion encode).
|
||||
Updated `build_pool_manifest.py` META (new `CCBY_WEBB` credit, Carina labels:
|
||||
emission nebula / protostar, redshift→distance ≈7,500 ly), regenerated
|
||||
`manifest.json` (also corrected stale JPL-Orion provenance) + all ring-edge
|
||||
transitions, and `docs/content-candidate-pool.md`. Operator authorized the
|
||||
merge → opened + merged **PR #21** (the whole `feature/ring-transitions-real`
|
||||
line), synced main, deleted the local branch. Remote branch delete returned
|
||||
HTTP 405 (known Gitea perm limit).
|
||||
|
||||
3. **"Still seeing Orion" — caching (PRs #22, #23).** Diagnosed: the sim server
|
||||
(PID 33672) had been running since before the media swap and was launched
|
||||
**without `--reload`**, holding the pre-swap manifest in memory; the browser
|
||||
also held the old clip via the in-memory blob preload and a possible pre-
|
||||
`no-cache` immutable HTTP entry. Restarted the server with `make sim-local`
|
||||
(`--reload`). Shipped **PR #22** (`cache: "reload"` on the preload fetch), then
|
||||
— at operator request for a permanent fix — **PR #23**: a content-hash
|
||||
`GET /api/media-versions` endpoint (sha1[:12] per served file, cached by
|
||||
mtime/size) and client `?v=<hash>` on every /media URL, so a re-baked clip's
|
||||
URL changes with its bytes.
|
||||
|
||||
4. **Coast landing artifact (in PR #23).** Operator noticed zooming into coast
|
||||
showed birdrock then switched to another clip. Root cause: the baked ring
|
||||
transition lands on the scale **primary** (birdrock), but the rotating pool
|
||||
picks a **random member** on landing — a non-primary pick hard-cut from primary
|
||||
to chosen. Fix: `advance()` masks the swap behind a short fade through the
|
||||
existing `#black` overlay when `target_clip_id !== ring.scales[to_index].clip_id`;
|
||||
general across all pooled scales.
|
||||
|
||||
5. **Right-brain affect (PR #24).** Operator: emotions should be controlled by the
|
||||
right brain, not the left. Reversed `affect.strength = min(left, right)` →
|
||||
`strength = right`; intensity keyed off Right; Left no longer gates emotions.
|
||||
Updated engine + client comments + the affect design spec (revision note) +
|
||||
tests. Live-verified: left=0/right=4 → strength 4; left=4/right=0 → 0.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **`main` @ (session-0018 merges all landed):** PRs **#21** (`7da7446`), **#22**
|
||||
(`aa56dfe`), **#23** (`5bb762a`), **#24** (`d0ad9a9`) — all confirmed in main's
|
||||
ancestry. Session 0019 has since advanced main further (cosmos_miri pool member,
|
||||
networked-control-surface spec, sim-local venv fix).
|
||||
- **Tests:** 271 passed, 2 skipped (+ media-version tests + rewritten affect
|
||||
tests).
|
||||
- **Sim:** running on :8000 as `uvicorn --reload` (PID 50199, restarted this
|
||||
session). The cosmos Cosmic Cliffs clip, content-hash cache-bust, coast fade,
|
||||
and Right-only affect are all live and API/logic-verified.
|
||||
- **No plan artifact** archived — leaf content/polish task executed directly
|
||||
(no `superpowers:writing-plans` doc).
|
||||
|
||||
## Operator plate
|
||||
|
||||
- **Cosmic Cliffs looks "incredible"** (operator) — the cosmos primary is now the
|
||||
Webb Carina flythrough.
|
||||
- **Unverified by eye** (no Chrome on the box): the coast landing fade timing, and
|
||||
the Right-only affect look (Left=0 + Right up should show emotion words over an
|
||||
un-annotated scene). Worth a by-eye pass in `make sim-local`.
|
||||
- **Pipeline §9:** changes merged to `main` (= done); no PPE/prod run — the E2E +
|
||||
PPE machinery is still being built (§10.6), and the simulator is a local dev
|
||||
surface. Not shipped to prod.
|
||||
- **Left untouched (not this session's work):** session 0019's in-flight audio
|
||||
work is dirty in the tree (`docs/audio-candidate-pool.md`, `.gitignore` audio
|
||||
entries) — left for 0019 to finalize. Pre-existing unfinalized sessions 0015,
|
||||
0017, 0019 remain `--INPROGRESS`.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
None logged — every call this session was operator-directed (the option-2 clip
|
||||
choice, the permanent cache-bust request, the Right-brain affect direction).
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal By-eye review of session 0018's sim changes in `make sim-local` (:8000): the
|
||||
Webb Cosmic Cliffs cosmos clip, the coast/pool landing fade (no hard cut to the
|
||||
random member), and Right-only emotions (Left=0 + Right up shows feeling words over
|
||||
an un-annotated scene). Tune the ~200ms fade timing if it feels off. Then continue
|
||||
the experience/content line — deferred i2v ring transitions and/or the Pi renderer
|
||||
(per [[simulator-first-before-hardware]]). Note: session 0019 separately owns the
|
||||
audio pool + networked-control-surface work.
|
||||
```
|
||||
@@ -0,0 +1,95 @@
|
||||
# Session 0019.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T04-47 (PST)
|
||||
> End: 2026-06-26T05-47 (PST)
|
||||
> Type: brainstorming
|
||||
> Posture: careful
|
||||
> Claude-Session: d6ed1203-92b6-471f-80a4-4c773b645ca1
|
||||
> Anchor: ROADMAP sub-project 4 (Arduino Firmware / control panel) — reframed
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Let's start talking about hardware. Let's assume for the first version I'm ok having my computer or laptop do the rendering. We'd still want a controller but it could be connected to the computer via wifi, USB, or bluetooth and would send commands to the software we've already written that would adjust left brain, right brain, etc. We'd prototype this by having a controller simulator run on another device, such as an ipad (just riffing here) but eventually move to a physical remote running on an Arduino or similar
|
||||
```
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Simulator is a **FastAPI** app, **stateless**: the single browser holds knob
|
||||
state in JS and POSTs to `/api/alteration` for a `RenderPlan`. Renderer and
|
||||
controller are the same browser tab.
|
||||
- Operator-facing control surface (sim today): Content (7-way `<select>`),
|
||||
Altitude (endless circular dial across 5 scales), Left/Right (sliders 0–4),
|
||||
Mood (slider −4…+4). (Dark+Light → Mood in s0013; Altitude dial in s0018.)
|
||||
- ROADMAP sub-project 4 = "Arduino Firmware (control panel)" assuming a **Pi**
|
||||
runs the room and the panel talks **USB serial** to it.
|
||||
- Standing directive: simulator-first before hardware.
|
||||
- Checkout shared with a concurrent audio-sourcing session (0017/0018), with
|
||||
uncommitted `.gitignore` + untracked files on `main`.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Session gate / routing.** Classified the opening ("let's talk about
|
||||
hardware… just riffing") as **brainstorming**; invoked `wgl-brainstorming`,
|
||||
claimed session **0019** (`--type brainstorming`, careful posture). Three stale
|
||||
`--INPROGRESS` sessions (0015/0017/0018) noted as leftovers, not live overlaps;
|
||||
no worktree (the audio session's edits are unrelated/uncommitted).
|
||||
|
||||
2. **Orientation.** Read `docs/ROADMAP.md` + the simulator (`app.py`, `index.html`)
|
||||
to ground the discussion; confirmed the real control surface and the stateless
|
||||
architecture. Recognized the request as a **reframe** of sub-project 4.
|
||||
|
||||
3. **Brainstorming dialogue** (`superpowers:brainstorming`), decisions reached:
|
||||
- **Intent:** both — decouple control/display cleanly AND want the handheld feel.
|
||||
- **Transport (v1):** wifi/web (iPad opens a controller page off the FastAPI
|
||||
server; least friction; Arduino transport left open).
|
||||
- **Remote fidelity:** faithful twin — decide the physical panel inventory now,
|
||||
iPad mirrors it.
|
||||
- Operator refinement: **per-knob transition state** — live controls
|
||||
(Left/Right/Mood) are instant; transitional ones (Altitude, Content) carry a
|
||||
lifecycle so the remote's feedback is honest.
|
||||
- **Architecture:** approach A — server-authoritative `SessionState` + **SSE**
|
||||
push (chosen over polling / WebRTC).
|
||||
|
||||
4. **Design presented section by section** (roles → transition model → server
|
||||
contract → two pages + Arduino seam → error/test/scope), each approved.
|
||||
|
||||
5. **Spec written** to
|
||||
`docs/superpowers/specs/2026-06-26-networked-control-surface-design.md`,
|
||||
committed on `feat/networked-control-surface`, pushed; operator approved going
|
||||
straight to finalize.
|
||||
|
||||
6. **Finalize.** Single-repo app (CONTENT_REMOTE empty) → merging the branch IS
|
||||
the submission. Discovered the shared checkout had switched to `main` (the
|
||||
audio session) and `main` had advanced to PR #25; landed the spec via a safe
|
||||
local `--no-ff` merge that added only the new spec file (their uncommitted work
|
||||
untouched), pushed `main` (`229af60..f28a06c`), deleted the feature branch.
|
||||
|
||||
## Cut state
|
||||
|
||||
- `main` @ `f28a06c` — spec merged. Feature branch deleted.
|
||||
- Spec `status: proposed` (careful posture); operator reviewed it live, approving
|
||||
every section before write.
|
||||
- No code written (brainstorming session). 0 new tests (none applicable).
|
||||
- Concurrent audio session's uncommitted `.gitignore`/untracked files left intact.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — no autonomous low-confidence calls. The two flagged design choices
|
||||
(last-write-wins concurrency; no auth in v1) were presented to the operator and
|
||||
approved in-session, not deferred._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- Review the merged spec if desired:
|
||||
`docs/superpowers/specs/2026-06-26-networked-control-surface-design.md` on `main`.
|
||||
- Next: open a **writing-plans** session to turn the spec into an implementation
|
||||
plan (the `/goal` below).
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal Write the implementation plan for the networked control surface, per docs/superpowers/specs/2026-06-26-networked-control-surface-design.md
|
||||
```
|
||||
@@ -46,5 +46,14 @@
|
||||
},
|
||||
"0016": {
|
||||
"title": ""
|
||||
},
|
||||
"0017": {
|
||||
"title": ""
|
||||
},
|
||||
"0018": {
|
||||
"title": ""
|
||||
},
|
||||
"0019": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
+66
-5
@@ -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:
|
||||
@@ -234,12 +287,20 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
|
||||
return {"ok": True, "clip": merged}
|
||||
|
||||
@app.middleware("http")
|
||||
async def _no_cache(request, call_next):
|
||||
# Dev preview server: never let a browser serve a stale app.js/style.css.
|
||||
# The whole point is by-eye iteration, so a plain refresh must always get
|
||||
# the latest assets (the heuristic cache otherwise hides JS/CSS edits).
|
||||
async def _cache_policy(request, call_next):
|
||||
# Dev preview server: never let a browser serve a stale app.js/style.css —
|
||||
# by-eye iteration needs a plain refresh to always get the latest assets.
|
||||
# Media uses `no-cache` (store, but REVALIDATE every load): instant altitude
|
||||
# swaps already come from the in-memory blob preload (static/app.js), so the
|
||||
# HTTP cache only matters on (re)load — where a conditional request returns a
|
||||
# cheap 304 when unchanged, yet picks up a re-sourced/re-cropped clip
|
||||
# immediately. `immutable` was wrong here: it pinned stale footage for a year
|
||||
# so edited clips never showed without a manual cache clear.
|
||||
response = await call_next(request)
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
if request.url.path.startswith("/media/"):
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
return response
|
||||
|
||||
if MEDIA_DIR.exists():
|
||||
|
||||
@@ -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"],
|
||||
@@ -51,12 +51,18 @@ RING_ORDER = ["cosmos", "orbit", "coast", "reef", "abyss"]
|
||||
PD = "public-domain (US Gov, 17 U.S.C. §105)"
|
||||
PD_NPS = "public-domain (NPS, no © — 17 U.S.C. §105)"
|
||||
CCBY_STSCI = "CC-BY-class — credit NASA, ESA, STScI"
|
||||
CCBY_WEBB = "CC-BY-class — credit NASA, ESA, CSA, STScI"
|
||||
|
||||
# --- Per-clip provenance: id -> (title, license, source) ---
|
||||
META: dict[str, tuple[str, str, str]] = {
|
||||
# cosmos
|
||||
"cosmos": ("Orion Nebula flythrough (NASA/JPL-Caltech)", PD,
|
||||
"NASA/JPL-Caltech — images.nasa.gov JPL-20221122-SOLSYSf-0001 (Orion); trim ~30–54s, crossfade-loop"),
|
||||
"cosmos": ("Cosmic Cliffs — Carina Nebula flythrough (NASA/ESA/CSA/STScI)", CCBY_WEBB,
|
||||
"NASA SVS 31348 Clifs-3d-STScI (Exploring the Cosmic Cliffs in 3D, Webb NIRCam); "
|
||||
"4K source, trim 10–34s, crossfade-loop. Text-free — replaces the Orion SVS 30957 flythrough."),
|
||||
"cosmos_miri": ("Cosmic Cliffs — Carina Nebula, mid-infrared (NASA/ESA/CSA/STScI)", CCBY_WEBB,
|
||||
"ESA/Webb weic2205c (Pan of the Carina Nebula, combined NIRCam + MIRI); 4K source, "
|
||||
"trim 4–26s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold "
|
||||
"dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free."),
|
||||
"cosmos_galaxies": ("Flying Through Galaxies (NASA SVS)", PD,
|
||||
"NASA SVS a014950 14950_Galaxies_FlyThrough_4k; trim 8–32s, crossfade-loop"),
|
||||
"cosmos_hudf": ("Hubble Ultra Deep Field zoom (NASA/ESA/STScI)", CCBY_STSCI,
|
||||
@@ -161,9 +167,14 @@ def measure(key, value, box, min_level):
|
||||
LABELS: dict[str, list[dict]] = {
|
||||
# ---------- cosmos ----------
|
||||
"cosmos": [
|
||||
static_label("detected.nebula", 4, ["cloud", "nebula", "emission nebula", "emission nebula · ionized H II region"], [0.32, 0.28, 0.34, 0.36]),
|
||||
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · <1 Myr old"], [0.60, 0.30, 0.10, 0.12]),
|
||||
measure("measure.redshift", "z ≈ 0.0", [0.36, 0.70, 0.18, 0.08], 4),
|
||||
static_label("detected.nebula", 4, ["cloud", "nebula", "emission nebula", "emission nebula · the Carina star-forming region"], [0.20, 0.42, 0.5, 0.42]),
|
||||
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · a newborn star, <1 Myr old"], [0.54, 0.12, 0.12, 0.14]),
|
||||
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
|
||||
],
|
||||
"cosmos_miri": [
|
||||
static_label("detected.nebula", 4, ["dust", "nebula", "dust ridge", "dust ridge · mid-infrared glow of the Carina cliffs"], [0.10, 0.45, 0.7, 0.45]),
|
||||
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · its dust laid bare in mid-infrared"], [0.48, 0.18, 0.14, 0.16]),
|
||||
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
|
||||
],
|
||||
"cosmos_galaxies": [
|
||||
static_label("detected.galaxy", 4, ["galaxy", "spiral galaxy", "barred spiral", "barred spiral · ~10¹¹ stars"], [0.34, 0.30, 0.30, 0.34]),
|
||||
@@ -355,7 +366,7 @@ def build_manifest() -> dict:
|
||||
return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}}
|
||||
|
||||
|
||||
# --- Optional: generate the NEW transition placeholders (orbit-coast, coast-reef) ---
|
||||
# --- Generate the per-edge transition clips (zoom morph + reversed companion) ---
|
||||
|
||||
def _ffmpeg() -> str:
|
||||
if shutil.which("ffmpeg"):
|
||||
@@ -365,8 +376,10 @@ def _ffmpeg() -> str:
|
||||
|
||||
|
||||
def _make_transition(ff: str, scale_a: str, scale_b: str) -> Path:
|
||||
"""A placeholder zoom/warp morph between two scales, from their PRIMARY pool
|
||||
members' bases (mirrors setup_scales_media.py but keyed by scale->primary)."""
|
||||
"""A zoom/warp morph between two scales, from their PRIMARY pool members'
|
||||
bases (mirrors setup_scales_media.py but keyed by scale->primary). This is the
|
||||
recipe behind the well-liked orbit-coast edge; generate_media() now applies it
|
||||
uniformly to every edge so none carry stale footage from an earlier ring."""
|
||||
a = MEDIA / POOLS[scale_a][0] / "base.mp4"
|
||||
b = MEDIA / POOLS[scale_b][0] / "base.mp4"
|
||||
out = MEDIA / "transitions" / f"{scale_a}-{scale_b}.mp4"
|
||||
@@ -381,13 +394,29 @@ def _make_transition(ff: str, scale_a: str, scale_b: str) -> Path:
|
||||
return out
|
||||
|
||||
|
||||
def _make_reverse(ff: str, forward: Path) -> Path:
|
||||
"""The zoom-OUT companion of a forward (zoom-in) edge: the same clip played
|
||||
backward, written alongside it as `<edge>.rev.mp4`. An ascending ring move
|
||||
crosses its edge `reversed`; the renderer then plays this file, so zooming out
|
||||
recedes from the current scale back to the higher one instead of zooming in."""
|
||||
out = forward.with_suffix(".rev.mp4")
|
||||
subprocess.run([
|
||||
ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", str(out),
|
||||
], check=True, capture_output=True)
|
||||
return out
|
||||
|
||||
|
||||
def generate_media() -> None:
|
||||
"""Generate the new ring edges introduced by the coast scale; the cosmos-orbit,
|
||||
reef-abyss and abyss-cosmos edges already exist from the prior 5-scale ring."""
|
||||
"""(Re)build EVERY ring-edge transition from the current real primary bases —
|
||||
the orbit-coast recipe applied uniformly, overwriting any stale clips left from
|
||||
an earlier ring — plus a reversed companion per edge for zoom-out moves."""
|
||||
ff = _ffmpeg()
|
||||
for a, b in [("orbit", "coast"), ("coast", "reef")]:
|
||||
_make_transition(ff, a, b)
|
||||
print(f"generated transitions/{a}-{b}.mp4 (placeholder zoom)")
|
||||
n = len(RING_ORDER)
|
||||
for i in range(n):
|
||||
a, b = RING_ORDER[i], RING_ORDER[(i + 1) % n]
|
||||
fwd = _make_transition(ff, a, b)
|
||||
_make_reverse(ff, fwd)
|
||||
print(f"generated transitions/{a}-{b}.mp4 (+ .rev) from real bases")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> None:
|
||||
|
||||
@@ -2,41 +2,41 @@
|
||||
"clips": [
|
||||
{
|
||||
"id": "cosmos",
|
||||
"title": "Orion Nebula flythrough (NASA/JPL-Caltech)",
|
||||
"title": "Cosmic Cliffs — Carina Nebula flythrough (NASA/ESA/CSA/STScI)",
|
||||
"base_file": "cosmos/base.mp4",
|
||||
"license": "public-domain (US Gov, 17 U.S.C. §105)",
|
||||
"source": "NASA/JPL-Caltech — images.nasa.gov JPL-20221122-SOLSYSf-0001 (Orion); trim ~30–54s, crossfade-loop",
|
||||
"license": "CC-BY-class — credit NASA, ESA, CSA, STScI",
|
||||
"source": "NASA SVS 31348 Clifs-3d-STScI (Exploring the Cosmic Cliffs in 3D, Webb NIRCam); 4K source, trim 10–34s, crossfade-loop. Text-free — replaces the Orion SVS 30957 flythrough.",
|
||||
"right_variants": {},
|
||||
"annotations": [
|
||||
{
|
||||
"key": "detected.nebula",
|
||||
"salience": 4,
|
||||
"box": [
|
||||
0.32,
|
||||
0.28,
|
||||
0.34,
|
||||
0.36
|
||||
0.2,
|
||||
0.42,
|
||||
0.5,
|
||||
0.42
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "detected.star",
|
||||
"salience": 2,
|
||||
"box": [
|
||||
0.6,
|
||||
0.3,
|
||||
0.1,
|
||||
0.12
|
||||
0.54,
|
||||
0.12,
|
||||
0.12,
|
||||
0.14
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "measure.redshift",
|
||||
"key": "measure.distance",
|
||||
"box": [
|
||||
0.36,
|
||||
0.7,
|
||||
0.18,
|
||||
0.06,
|
||||
0.06,
|
||||
0.2,
|
||||
0.08
|
||||
],
|
||||
"min_level": 4
|
||||
"min_level": 3
|
||||
}
|
||||
],
|
||||
"affect": [
|
||||
@@ -79,15 +79,130 @@
|
||||
"cloud",
|
||||
"nebula",
|
||||
"emission nebula",
|
||||
"emission nebula · ionized H II region"
|
||||
"emission nebula · the Carina star-forming region"
|
||||
],
|
||||
"detected.star": [
|
||||
"star",
|
||||
"young star",
|
||||
"protostar",
|
||||
"protostar · <1 Myr old"
|
||||
"protostar · a newborn star, <1 Myr old"
|
||||
],
|
||||
"measure.redshift": "z ≈ 0.0",
|
||||
"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_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 4–26s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free.",
|
||||
"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",
|
||||
@@ -2252,6 +2367,7 @@
|
||||
"clip_id": "cosmos",
|
||||
"pool": [
|
||||
"cosmos",
|
||||
"cosmos_miri",
|
||||
"cosmos_galaxies",
|
||||
"cosmos_hudf",
|
||||
"cosmos_xdf"
|
||||
|
||||
+406
-21
@@ -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;
|
||||
@@ -52,22 +57,28 @@ async function loadData() {
|
||||
ringIndex = 0;
|
||||
}
|
||||
|
||||
// Land on the current scale: pick a random pool member (content-pipeline §11.1).
|
||||
// A real server ring picks via a delta=0 advance (Python-canonical pick); the
|
||||
// synthesized fallback ring has a pool of one, so it just takes that member.
|
||||
async function landScale() {
|
||||
// Server-canonical random pick of a pool member for the current scale: a delta=0
|
||||
// advance (content-pipeline §11.1, Python owns the randomness). The synthesized
|
||||
// fallback ring has a pool of one, so it just returns that member. Returns a
|
||||
// clip_id or null. Shared by the initial landing AND the Dev Mode re-roll button.
|
||||
async function pickRandomMember() {
|
||||
const scale = ring && ring.scales[ringIndex];
|
||||
if (!scale) { activeClipId = null; return; }
|
||||
if (!scale) return null;
|
||||
if (serverRing) {
|
||||
try {
|
||||
const resp = await fetch("/api/ring/advance", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ from_index: ringIndex, delta: 0 }),
|
||||
});
|
||||
if (resp.ok) { activeClipId = (await resp.json()).target_clip_id; currentClipId = null; return; }
|
||||
if (resp.ok) return (await resp.json()).target_clip_id;
|
||||
} catch (_) { /* fall through to the primary member */ }
|
||||
}
|
||||
activeClipId = scale.clip_id;
|
||||
return scale.clip_id;
|
||||
}
|
||||
|
||||
// Land on the current scale: pick a random pool member and force its media to load.
|
||||
async function landScale() {
|
||||
activeClipId = await pickRandomMember();
|
||||
currentClipId = null;
|
||||
}
|
||||
|
||||
@@ -75,7 +86,93 @@ function activeClip() {
|
||||
return clipsById[activeClipId] || null;
|
||||
}
|
||||
|
||||
function mediaUrl(file) { return "/media/" + file; }
|
||||
// In-memory preload cache: media file path -> blob object URL. Once a clip's bytes
|
||||
// are cached, mediaUrl() hands the <video> an in-memory blob instead of a network
|
||||
// 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 = {};
|
||||
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).
|
||||
function preloadList() {
|
||||
const files = new Set();
|
||||
for (const c of Object.values(clipsById)) if (c && c.base_file) files.add(c.base_file);
|
||||
// Each ring edge ships both directions: the forward (zoom-in) clip and its baked
|
||||
// reverse (zoom-out) companion — cache both so up and down moves are instant.
|
||||
if (ring && ring.transitions) for (const t of ring.transitions) {
|
||||
if (t && t.file) { files.add(t.file); files.add(reverseFile(t.file)); }
|
||||
}
|
||||
return [...files];
|
||||
}
|
||||
|
||||
// Order the preload so the clips most likely to be shown next come first: the
|
||||
// current scale's pool, then outward to neighboring scales, then transitions.
|
||||
function preloadOrder() {
|
||||
if (!ring || !ring.scales) return preloadList();
|
||||
const n = ring.scales.length, seen = new Set(), ordered = [];
|
||||
const add = (f) => { if (f && !seen.has(f)) { seen.add(f); ordered.push(f); } };
|
||||
for (let d = 0; d < n; d++) {
|
||||
for (const sign of d === 0 ? [0] : [1, -1]) {
|
||||
const s = ring.scales[((ringIndex + sign * d) % n + n) % n];
|
||||
if (s && s.pool) for (const m of s.pool) add((clipsById[m.clip_id] || {}).base_file);
|
||||
else if (s) add((clipsById[s.clip_id] || {}).base_file);
|
||||
}
|
||||
}
|
||||
if (ring.transitions) for (const t of ring.transitions) { add(t.file); add(reverseFile(t.file)); }
|
||||
for (const f of preloadList()) add(f); // sweep up anything not on the ring
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// Fetch every clip into the blob cache in the background — non-blocking, so the
|
||||
// first scale plays immediately while the rest stream in. Bounded concurrency keeps
|
||||
// the ~350 MB fetch from stampeding. A tiny progress chip self-removes when done.
|
||||
async function preloadAllMedia(concurrency = 4) {
|
||||
const files = preloadOrder();
|
||||
if (!files.length) return;
|
||||
let done = 0;
|
||||
const total = files.length;
|
||||
const chip = preloadChip();
|
||||
const tick = () => { if (chip) chip.textContent = `⬇ caching clips ${done}/${total}`; };
|
||||
tick();
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < files.length) {
|
||||
const file = files[i++];
|
||||
if (!mediaBlobs[file]) {
|
||||
try {
|
||||
// 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(mediaNetUrl(file), { cache: "reload" })).blob();
|
||||
mediaBlobs[file] = URL.createObjectURL(blob);
|
||||
} catch (_) { /* leave it to the network path on demand */ }
|
||||
}
|
||||
done++; tick();
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
|
||||
if (chip) chip.remove();
|
||||
}
|
||||
|
||||
function preloadChip() {
|
||||
let c = document.getElementById("preload-chip");
|
||||
if (!c) {
|
||||
c = document.createElement("div");
|
||||
c.id = "preload-chip";
|
||||
c.style.cssText = "position:fixed;bottom:8px;right:10px;z-index:9998;background:rgba(0,0,0,.55);" +
|
||||
"color:#9fe;font:11px/1.4 monospace;padding:4px 8px;border-radius:4px;pointer-events:none;";
|
||||
document.body.appendChild(c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// Load the active scale's BASE footage into the video (the painterly canvas reads
|
||||
// its frames live). The Right knob no longer swaps the source — it only drives the
|
||||
@@ -359,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
|
||||
@@ -375,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)));
|
||||
}
|
||||
@@ -389,6 +487,8 @@ function renderScaleReadout() {
|
||||
// scale id · the chosen pool member · position on the ring (pool size if >1)
|
||||
const poolTag = poolN > 1 ? ` · pool ${poolN}` : "";
|
||||
$("scale-name").textContent = `${s.id} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
|
||||
renderDial();
|
||||
refreshDevClip(); // keep the Dev Mode pool picker + clip data in sync with the landing
|
||||
}
|
||||
|
||||
function controls() {
|
||||
@@ -414,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();
|
||||
@@ -435,11 +535,17 @@ function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }
|
||||
|
||||
const FAST_BLEND_RATE = 2.5; // playback speed for a collapsed fast-spin pass
|
||||
|
||||
function playTransition(file, blended) {
|
||||
// The zoom-OUT companion of an edge clip: the baked `<edge>.rev.mp4` reverse. A
|
||||
// `reversed` step (an ascending / zoom-out ring move) plays this instead of the
|
||||
// forward zoom-in clip, so going up recedes from the current scale.
|
||||
function reverseFile(file) { return file.replace(/\.mp4$/, ".rev.mp4"); }
|
||||
|
||||
function playTransition(file, blended, reversed) {
|
||||
// Play one zoom/warp transition clip start-to-finish, with the alteration
|
||||
// layers hidden. A `blended` (fast-spin) pass runs at FAST_BLEND_RATE so a
|
||||
// quick encoder spin lands fast instead of grinding through every morph.
|
||||
// Resolves on 'ended' (or a safety timeout that scales with playback rate).
|
||||
// Zoom-out moves (`reversed`) play the baked reverse clip. Resolves on 'ended'
|
||||
// (or a safety timeout that scales with playback rate).
|
||||
return new Promise((resolve) => {
|
||||
overlay.style.opacity = "0";
|
||||
affectLayer.style.opacity = "0";
|
||||
@@ -447,7 +553,7 @@ function playTransition(file, blended) {
|
||||
vid.style.filter = "none";
|
||||
vid.loop = false;
|
||||
vid.style.opacity = "1";
|
||||
vid.src = mediaUrl(file);
|
||||
vid.src = mediaUrl(reversed ? reverseFile(file) : file);
|
||||
vid.playbackRate = blended ? FAST_BLEND_RATE : 1;
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
@@ -463,6 +569,29 @@ function playTransition(file, blended) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -476,12 +605,20 @@ async function advance(delta) {
|
||||
// A fast spin comes back collapsed to a single blended step (move.fast);
|
||||
// a slow spin chains one full transition per scale crossed.
|
||||
for (const step of move.steps) {
|
||||
await playTransition(step.file, step.blended);
|
||||
await playTransition(step.file, step.blended, step.reversed);
|
||||
}
|
||||
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();
|
||||
@@ -500,6 +637,248 @@ function onWheel(e) {
|
||||
}, 90);
|
||||
}
|
||||
|
||||
// --- Altitude knob: the endless rotary encoder, drawn as a turnable dial ---
|
||||
// cosmos (highest) sits at the top; turning CLOCKWISE descends through the scales
|
||||
// (cosmos → orbit → coast → reef → abyss) and past the deepest WRAPS back to the
|
||||
// top — the same endless ring the server already models. The drag accumulates a
|
||||
// rotation and commits whole detents on release (so a big spin becomes one fast
|
||||
// blended pass, exactly like the wheel); a tap on a label jumps to that scale.
|
||||
const dial = $("dial");
|
||||
const DIAL_C = 50, DIAL_LABEL_R = 41, DIAL_BODY_R = 27;
|
||||
let needleDeg = 0;
|
||||
let dialDrag = null; // {lastAng, accum, moved} while turning
|
||||
|
||||
function dialStep() { return ring && ring.scales.length ? 360 / ring.scales.length : 360; }
|
||||
|
||||
function _xy(r, deg) {
|
||||
const a = (deg - 90) * Math.PI / 180; // -90° so 0° points UP
|
||||
return [DIAL_C + r * Math.cos(a), DIAL_C + r * Math.sin(a)];
|
||||
}
|
||||
|
||||
function buildDial() {
|
||||
if (!dial || !ring) return;
|
||||
dial.innerHTML = "";
|
||||
const n = ring.scales.length, step = 360 / n;
|
||||
svg("circle", { cx: DIAL_C, cy: DIAL_C, r: DIAL_LABEL_R + 6, class: "dial-rim" }, dial);
|
||||
svg("circle", { cx: DIAL_C, cy: DIAL_C, r: DIAL_BODY_R, class: "dial-body" }, dial);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const deg = i * step;
|
||||
const [tx0, ty0] = _xy(DIAL_BODY_R - 1.5, deg);
|
||||
const [tx1, ty1] = _xy(DIAL_BODY_R + 2.5, deg);
|
||||
svg("line", { x1: tx0, y1: ty0, x2: tx1, y2: ty1, class: "dial-tick" }, dial);
|
||||
const [lx, ly] = _xy(DIAL_LABEL_R, deg);
|
||||
const t = svg("text", {
|
||||
x: lx, y: ly, "text-anchor": "middle", "dominant-baseline": "central",
|
||||
class: "dial-label", "data-index": String(i),
|
||||
}, dial);
|
||||
t.textContent = ring.scales[i].id;
|
||||
}
|
||||
svg("text", { x: DIAL_C, y: DIAL_C - 7, "text-anchor": "middle",
|
||||
"dominant-baseline": "central", class: "dial-caption" }, dial)
|
||||
.textContent = "ALTITUDE";
|
||||
const needle = svg("g", { id: "needle", class: "dial-needle" }, dial);
|
||||
const tip = DIAL_C - (DIAL_BODY_R - 4);
|
||||
svg("polygon", { points: `${DIAL_C - 2.4},${DIAL_C} ${DIAL_C + 2.4},${DIAL_C} ${DIAL_C},${tip}` }, needle);
|
||||
svg("circle", { cx: DIAL_C, cy: DIAL_C, r: 3, class: "dial-hub" }, dial);
|
||||
renderDial();
|
||||
}
|
||||
|
||||
function setNeedle(deg) {
|
||||
const needle = $("needle");
|
||||
if (needle) needle.setAttribute("transform", `rotate(${deg} ${DIAL_C} ${DIAL_C})`);
|
||||
}
|
||||
|
||||
function renderDial() {
|
||||
if (!dial || !ring) return;
|
||||
needleDeg = ringIndex * dialStep();
|
||||
setNeedle(needleDeg);
|
||||
for (const el of dial.querySelectorAll(".dial-label")) {
|
||||
el.classList.toggle("active", +el.getAttribute("data-index") === ringIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function dialAngle(e) {
|
||||
const r = dial.getBoundingClientRect();
|
||||
const dx = e.clientX - (r.left + r.width / 2);
|
||||
const dy = e.clientY - (r.top + r.height / 2);
|
||||
return (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360; // 0 at top, clockwise +
|
||||
}
|
||||
function angDelta(a, b) {
|
||||
let d = a - b;
|
||||
while (d > 180) d -= 360;
|
||||
while (d < -180) d += 360;
|
||||
return d;
|
||||
}
|
||||
|
||||
function onDialDown(e) {
|
||||
if (busy || !ring || ring.scales.length < 2) return;
|
||||
e.preventDefault();
|
||||
dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target };
|
||||
}
|
||||
function onDialMove(e) {
|
||||
if (!dialDrag) return;
|
||||
const a = dialAngle(e);
|
||||
const d = angDelta(a, dialDrag.lastAng);
|
||||
dialDrag.lastAng = a;
|
||||
dialDrag.accum += d;
|
||||
dialDrag.moved += Math.abs(d);
|
||||
setNeedle(ringIndex * dialStep() + dialDrag.accum); // live feedback while turning
|
||||
}
|
||||
function onDialUp(e) {
|
||||
if (!dialDrag) return;
|
||||
const { accum, moved, target } = dialDrag;
|
||||
dialDrag = null;
|
||||
if (moved < 6) { // a tap, not a turn
|
||||
if (target && target.classList && target.classList.contains("dial-label")) {
|
||||
jumpToScale(+target.getAttribute("data-index"));
|
||||
}
|
||||
renderDial();
|
||||
return;
|
||||
}
|
||||
const detents = Math.round(accum / dialStep());
|
||||
if (detents) advance(detents); else renderDial(); // snap back if it didn't cross a detent
|
||||
}
|
||||
|
||||
// Click a label → travel the SHORTEST signed way around the ring to that scale.
|
||||
function jumpToScale(idx) {
|
||||
if (!ring) return;
|
||||
const n = ring.scales.length;
|
||||
let d = (idx - ringIndex) % n;
|
||||
if (d > n / 2) d -= n;
|
||||
if (d < -n / 2) d += n;
|
||||
if (d) advance(d);
|
||||
}
|
||||
|
||||
// --- Dev Mode: pool picker + live analysis, all under one toggle ---
|
||||
// Off by default, state persisted in localStorage. Everything here is read from
|
||||
// data the renderer already has (clips, ring, the alteration response, the preload
|
||||
// cache) — no server endpoints. Editing labels stays in /author.html.
|
||||
const DEV_KEY = "hef.devMode";
|
||||
let devMode = false;
|
||||
let devStatsTimer = null;
|
||||
|
||||
const escapeHtml = (s) => String(s).replace(/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
function applyDevVisibility() {
|
||||
const panel = $("dev-panel");
|
||||
if (panel) panel.classList.toggle("hidden", !devMode);
|
||||
if (devMode) { refreshDevClip(); renderDevStats(); }
|
||||
}
|
||||
|
||||
function initDev() {
|
||||
const toggle = $("dev-mode");
|
||||
if (!toggle) return;
|
||||
try { devMode = localStorage.getItem(DEV_KEY) === "1"; } catch (_) { devMode = false; }
|
||||
toggle.checked = devMode;
|
||||
toggle.addEventListener("change", () => {
|
||||
devMode = toggle.checked;
|
||||
try { localStorage.setItem(DEV_KEY, devMode ? "1" : "0"); } catch (_) {}
|
||||
applyDevVisibility();
|
||||
});
|
||||
$("pool-select").addEventListener("change", onPoolSelect);
|
||||
$("pool-reroll").addEventListener("click", reroll);
|
||||
// Cache + playback stats are live-ish; cheap to repaint twice a second when shown.
|
||||
devStatsTimer = setInterval(() => { if (devMode) renderDevStats(); }, 500);
|
||||
applyDevVisibility();
|
||||
}
|
||||
|
||||
// Rebuild the pool picker for the current scale and repaint the clip data. Cheap
|
||||
// (pools are <= a handful), so just rebuild on every landing/pick.
|
||||
function refreshDevClip() {
|
||||
if (!devMode) return;
|
||||
const sel = $("pool-select");
|
||||
const scale = ring && ring.scales[ringIndex];
|
||||
if (sel && scale) {
|
||||
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id, title: scale.title }];
|
||||
sel.innerHTML = "";
|
||||
for (const m of pool) {
|
||||
const o = document.createElement("option");
|
||||
o.value = m.clip_id;
|
||||
o.textContent = `${m.clip_id} · ${m.title || (clipsById[m.clip_id] || {}).title || ""}`;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
if (activeClipId) sel.value = activeClipId;
|
||||
sel.disabled = pool.length < 2;
|
||||
}
|
||||
renderDevMeta();
|
||||
renderDevAnno();
|
||||
}
|
||||
|
||||
function renderDevMeta() {
|
||||
const dl = $("clip-meta");
|
||||
const c = activeClip();
|
||||
if (!dl) return;
|
||||
if (!c) { dl.innerHTML = "<dt>—</dt><dd>no clip</dd>"; return; }
|
||||
const rows = [["id", c.id], ["title", c.title], ["source", c.source],
|
||||
["license", c.license], ["base_file", c.base_file]];
|
||||
dl.innerHTML = rows.map(([k, v]) =>
|
||||
`<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v != null ? v : "—")}</dd>`).join("");
|
||||
}
|
||||
|
||||
function renderDevAnno() {
|
||||
const box = $("clip-anno");
|
||||
const c = activeClip();
|
||||
if (!box) return;
|
||||
const strings = (c && c.strings && c.strings.en) || {};
|
||||
const win = (a) => (typeof a.appear === "number" || typeof a.disappear === "number")
|
||||
? ` win ${(a.appear ?? 0).toFixed(2)}–${(a.disappear ?? 1).toFixed(2)}` : "";
|
||||
const rows = [];
|
||||
for (const a of (c && c.annotations) || []) {
|
||||
const measure = a.key.startsWith("measure.");
|
||||
const sal = typeof a.salience === "number" ? `sal ${a.salience}`
|
||||
: typeof a.min_level === "number" ? `min L${a.min_level}` : "sal 1";
|
||||
const tiers = tierCount(strings[a.key]);
|
||||
rows.push(`<div class="anno-row"><span class="anno-key ${measure ? "measure" : ""}">${escapeHtml(a.key)}</span>` +
|
||||
`<span class="anno-meta"> · ${sal} · ${tiers} tier${tiers > 1 ? "s" : ""}${win(a)}</span></div>`);
|
||||
}
|
||||
for (const f of (c && c.affect) || []) {
|
||||
const tiers = tierCount(strings[f.key]);
|
||||
rows.push(`<div class="anno-row"><span class="anno-key affect">${escapeHtml(f.key)}</span>` +
|
||||
`<span class="anno-meta"> · min L${f.min_level ?? 1} · ${tiers} tier${tiers > 1 ? "s" : ""}</span></div>`);
|
||||
}
|
||||
box.innerHTML = rows.length ? rows.join("") : `<div class="anno-empty">no annotations or affect</div>`;
|
||||
}
|
||||
|
||||
function renderDevStats() {
|
||||
const dl = $("dev-stats");
|
||||
if (!dl) return;
|
||||
const c = activeClip();
|
||||
const total = preloadList().length;
|
||||
const cached = Object.keys(mediaBlobs).length;
|
||||
const fromMem = !!(c && mediaBlobs[c.base_file]);
|
||||
const res = vid.videoWidth ? `${vid.videoWidth}×${vid.videoHeight}` : "—";
|
||||
const dur = vid.duration ? `${vid.duration.toFixed(1)}s` : "—";
|
||||
const loop = vid.duration ? `${Math.round(loopT() * 100)}%` : "—";
|
||||
dl.innerHTML =
|
||||
`<dt>cache</dt><dd>${cached}/${total} cached</dd>` +
|
||||
`<dt>active source</dt><dd class="${fromMem ? "mem" : "net"}">${fromMem ? "memory (blob)" : "network"}</dd>` +
|
||||
`<dt>resolution</dt><dd>${res}</dd>` +
|
||||
`<dt>duration</dt><dd>${dur}</dd>` +
|
||||
`<dt>loop pos</dt><dd>${loop}</dd>`;
|
||||
}
|
||||
|
||||
// Pick a specific pool member (Dev Mode override of the random landing pick).
|
||||
function onPoolSelect() {
|
||||
const id = $("pool-select").value;
|
||||
if (!id || id === activeClipId) return;
|
||||
activeClipId = id;
|
||||
currentClipId = null; // force the base media to reload
|
||||
renderScaleReadout(); // refresh scale name + dev clip data
|
||||
update(); // reload media + plan
|
||||
}
|
||||
|
||||
// Re-roll: a fresh server-canonical random pick for the current scale.
|
||||
async function reroll() {
|
||||
if (busy) return;
|
||||
const id = await pickRandomMember();
|
||||
if (!id) return;
|
||||
activeClipId = id;
|
||||
currentClipId = null;
|
||||
renderScaleReadout();
|
||||
update();
|
||||
}
|
||||
|
||||
// Dev live-reload: poll the asset version and reload when it changes, so an open
|
||||
// tab never keeps running a stale renderer while we iterate (the readout updates
|
||||
// from the live API and masks it otherwise). Reloads on my edits AND on a server
|
||||
@@ -520,12 +899,18 @@ async function main() {
|
||||
try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); }
|
||||
await loadData();
|
||||
await landScale(); // pick the initial scale's pool member before first render
|
||||
preloadAllMedia(); // background: cache every clip in memory for instant altitude swaps
|
||||
buildDial(); // draw the altitude knob from the ring's scales
|
||||
initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state)
|
||||
renderScaleReadout();
|
||||
for (const id of ["content", "left", "right", "mood"]) {
|
||||
$(id).addEventListener("input", debounced);
|
||||
}
|
||||
$("zoom-in").addEventListener("click", () => advance(1));
|
||||
$("zoom-out").addEventListener("click", () => advance(-1));
|
||||
// Altitude knob: drag to turn (commit detents on release), scroll to step, tap a label to jump.
|
||||
dial.addEventListener("pointerdown", onDialDown);
|
||||
window.addEventListener("pointermove", onDialMove);
|
||||
window.addEventListener("pointerup", onDialUp);
|
||||
dial.addEventListener("wheel", onWheel, { passive: false });
|
||||
$("stage").addEventListener("wheel", onWheel, { passive: false });
|
||||
update();
|
||||
}
|
||||
|
||||
+38
-10
@@ -35,13 +35,12 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Scale ring (endless encoder)</legend>
|
||||
<div class="ring-control">
|
||||
<button type="button" id="zoom-out" title="zoom out (toward cosmos)">⊖ out</button>
|
||||
<span id="scale-name" class="scale-name">—</span>
|
||||
<button type="button" id="zoom-in" title="zoom in (toward microscopic)">in ⊕</button>
|
||||
<legend>Altitude</legend>
|
||||
<div class="dial-wrap">
|
||||
<svg id="dial" viewBox="0 0 100 100" aria-label="Altitude knob (turn to change scale)"></svg>
|
||||
</div>
|
||||
<p class="hint">Relative, endless — wraps past the smallest back to the largest. Scroll the stage too.</p>
|
||||
<span id="scale-name" class="scale-name">—</span>
|
||||
<p class="hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
@@ -51,11 +50,40 @@
|
||||
<label>Mood — dark ◀ 0 ▶ light <input type="range" id="mood" min="-4" max="4" value="0" /></label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Dev Mode: a single toggle; everything dev (pool picker + analysis) lives below it. -->
|
||||
<label class="dev-switch" for="dev-mode">
|
||||
<input type="checkbox" id="dev-mode" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label">Dev Mode</span>
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>RenderPlan readout</legend>
|
||||
<pre id="readout">—</pre>
|
||||
</fieldset>
|
||||
<div id="dev-panel" class="dev-panel hidden">
|
||||
<fieldset>
|
||||
<legend>Pool — pick a clip</legend>
|
||||
<select id="pool-select" aria-label="Choose a clip from this altitude's pool"></select>
|
||||
<button type="button" id="pool-reroll" class="dev-btn">🎲 Re-roll random</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Active clip</legend>
|
||||
<dl id="clip-meta" class="dev-dl"></dl>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Annotations & affect</legend>
|
||||
<div id="clip-anno" class="dev-anno"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>RenderPlan</legend>
|
||||
<pre id="readout">—</pre>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Cache & playback</legend>
|
||||
<dl id="dev-stats" class="dev-dl"></dl>
|
||||
</fieldset>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
|
||||
@@ -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; }
|
||||
@@ -48,10 +48,55 @@ label { display: block; margin: 0.4rem 0; }
|
||||
input[type=range], select { width: 100%; }
|
||||
#readout { background: #000; padding: 0.5rem; border-radius: 4px; font-size: 12px;
|
||||
white-space: pre-wrap; max-height: 240px; overflow: auto; }
|
||||
.ring-control { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.ring-control button { flex: 0 0 auto; background: #1a2436; color: #9af;
|
||||
border: 1px solid #345; border-radius: 4px; padding: 0.3rem 0.5rem;
|
||||
cursor: pointer; font-size: 13px; }
|
||||
.ring-control button:hover { background: #243352; }
|
||||
.scale-name { flex: 1 1 auto; text-align: center; font-size: 12px; color: #cde; }
|
||||
/* Altitude knob */
|
||||
.dial-wrap { display: flex; justify-content: center; padding: 0.3rem 0 0.1rem; }
|
||||
#dial { width: 190px; height: 190px; touch-action: none; cursor: grab; user-select: none; }
|
||||
#dial:active { cursor: grabbing; }
|
||||
.dial-rim { fill: #0d1320; stroke: #243352; stroke-width: 1.2; }
|
||||
.dial-body { fill: #16203200; stroke: #2c3c5c; stroke-width: 1; }
|
||||
.dial-tick { stroke: #3a4d70; stroke-width: 0.8; }
|
||||
.dial-label { fill: #789ac0; font-size: 6px; font-family: ui-monospace, monospace;
|
||||
letter-spacing: 0.2px; cursor: pointer; }
|
||||
.dial-label:hover { fill: #cde; }
|
||||
.dial-label.active { fill: #9cf; font-weight: 700; }
|
||||
.dial-caption { fill: #4d6184; font-size: 4.4px; letter-spacing: 1.2px;
|
||||
font-family: ui-monospace, monospace; }
|
||||
.dial-needle { fill: #9cf; }
|
||||
.dial-needle polygon { filter: drop-shadow(0 0 1px #9cf); }
|
||||
.dial-hub { fill: #2c3c5c; stroke: #9cf; stroke-width: 0.6; }
|
||||
.scale-name { display: block; text-align: center; font-size: 12px; color: #cde; }
|
||||
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #789; }
|
||||
|
||||
/* --- Dev Mode --- a single switch; all dev controls/data live in #dev-panel below it. */
|
||||
.dev-switch { display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
|
||||
padding: 0.5rem 0.2rem 0.2rem; border-top: 1px solid #222; user-select: none; }
|
||||
.dev-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.dev-switch-track { position: relative; width: 34px; height: 18px; border-radius: 9px;
|
||||
background: #2a2a2a; border: 1px solid #444; transition: background 0.15s; }
|
||||
.dev-switch-thumb { position: absolute; top: 1px; left: 1px; width: 14px; height: 14px;
|
||||
border-radius: 50%; background: #888; transition: transform 0.15s, background 0.15s; }
|
||||
.dev-switch input:checked + .dev-switch-track { background: #1d3a2a; border-color: #2e6; }
|
||||
.dev-switch input:checked + .dev-switch-track .dev-switch-thumb { transform: translateX(16px); background: #4e9; }
|
||||
.dev-switch input:focus-visible + .dev-switch-track { outline: 2px solid #9af; outline-offset: 1px; }
|
||||
.dev-switch-label { color: #9af; font-weight: 600; letter-spacing: 0.3px; }
|
||||
.dev-panel { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
/* Beat the generic `.hidden` (defined earlier, equal specificity): two classes
|
||||
win, so the panel truly collapses when Dev Mode is off. */
|
||||
.dev-panel.hidden { display: none; }
|
||||
.dev-panel legend { color: #4e9; }
|
||||
.dev-btn { margin-top: 0.5rem; width: 100%; padding: 0.4rem; cursor: pointer;
|
||||
background: #182028; color: #cde; border: 1px solid #2c3c5c; border-radius: 4px; }
|
||||
.dev-btn:hover { background: #20303f; }
|
||||
.dev-dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.15rem 0.6rem; font-size: 12px; }
|
||||
.dev-dl dt { color: #789; }
|
||||
.dev-dl dd { margin: 0; color: #dde; word-break: break-word; }
|
||||
.dev-dl dd.mem { color: #4e9; }
|
||||
.dev-dl dd.net { color: #fc6; }
|
||||
.dev-anno { font-size: 11px; max-height: 220px; overflow: auto;
|
||||
font-family: ui-monospace, monospace; }
|
||||
.dev-anno .anno-row { padding: 0.2rem 0; border-bottom: 1px solid #1d1d1d; }
|
||||
.dev-anno .anno-key { color: #aee6ff; }
|
||||
.dev-anno .anno-key.affect { color: #e3cfff; }
|
||||
.dev-anno .anno-key.measure { color: #ffd79a; }
|
||||
.dev-anno .anno-meta { color: #678; }
|
||||
.dev-anno .anno-empty { color: #567; font-style: italic; }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -91,6 +115,35 @@ def test_index_is_served():
|
||||
assert "Alteration" in resp.text
|
||||
|
||||
|
||||
def test_app_assets_are_not_cached(client):
|
||||
# Dev iteration relies on the browser never serving stale renderer code.
|
||||
resp = client.get("/")
|
||||
assert "no-store" in resp.headers.get("cache-control", "")
|
||||
|
||||
|
||||
def test_media_is_cacheable(tmp_path, monkeypatch):
|
||||
# Media is static: it must be browser-cacheable so the preloader's fetch (and
|
||||
# any fallback) doesn't re-hit the network on every altitude change.
|
||||
import simulator.app as appmod
|
||||
|
||||
media = tmp_path / "media"
|
||||
(media / "cosmos").mkdir(parents=True)
|
||||
(media / "cosmos" / "base.mp4").write_bytes(b"\x00\x00\x00\x18ftyp")
|
||||
monkeypatch.setattr(appmod, "MEDIA_DIR", media)
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text(json.dumps({"clips": []}))
|
||||
|
||||
client = TestClient(create_app(manifest_path=manifest))
|
||||
resp = client.get("/media/cosmos/base.mp4")
|
||||
assert resp.status_code == 200
|
||||
cc = resp.headers.get("cache-control", "")
|
||||
# Stored but revalidated: not no-store (so the blob preload can cache it), and
|
||||
# not pinned immutable (so a re-sourced/re-cropped clip shows on a plain reload).
|
||||
assert "no-store" not in cc
|
||||
assert "immutable" not in cc
|
||||
assert "no-cache" in cc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ring_manifest_path(tmp_path):
|
||||
p = tmp_path / "manifest.json"
|
||||
|
||||
Reference in New Issue
Block a user