Compare commits

..

26 Commits

Author SHA1 Message Date
benstull 229af60f1f Merge pull request 'feat(content): add cosmos_miri pool member (Carina mid-infrared pan)' (#25) from feat/cosmos-miri-pool-member into main
feat(content): add cosmos_miri pool member (Carina mid-infrared pan) (#25)
2026-06-26 12:39:34 +00:00
BenStullsBets 32e74ca446 fix(sim): make sim-local use the venv interpreter
`make sim-local` called bare `python`, which isn't on this box's PATH (only
python3 / .venv/bin/python exist) — so it failed instantly and "couldn't connect"
to the server. Resolve PYTHON to the venv interpreter, falling back to
python3 then python.

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

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

271 passed, 2 skipped.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:03:56 -07:00
benstull aa56dfe145 Merge pull request 'fix(sim): bust HTTP cache on media preload so re-baked clips show' (#22) from fix/preload-cache-bust into main 2026-06-25 19:40:50 +00:00
BenStullsBets ed267c554c fix(sim): bust HTTP cache on media preload so re-baked clips show
The preload fetched each clip with the default HTTP cache, so a clip re-baked
under the same path (e.g. the cosmos base re-sourced to the Webb Cosmic Cliffs
flythrough) kept showing stale footage after a reload — worst case forever, when
a pre-`no-cache` build had pinned it `immutable, max-age=1y` (a plain reload never
revalidates an immutable entry). Fetch the preload with `cache: "reload"`, which
bypasses the HTTP cache, pulls fresh bytes, and updates the cache entry; the
in-memory blob cache still gives instant in-session altitude swaps. One fetch per
(re)load, so no steady-state cost.

269 passed, 2 skipped. app.js syntax-checked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:40:34 -07:00
benstull 7da7446740 Merge pull request 'feat(sim): real ring zoom transitions + Dev Mode + cosmos → Webb Cosmic Cliffs' (#21) from feature/ring-transitions-real into main 2026-06-25 19:31:22 +00:00
BenStullsBets d24f829276 feat(content): swap cosmos primary to the Webb "Cosmic Cliffs" flythrough
Operator found the Orion SVS 30957 flythrough merely good and asked for a more
dramatic nebula. Replaces the cosmos-scale primary with STScI's "Exploring the
Cosmic Cliffs in 3D" (Carina Nebula, Webb NIRCam — NASA SVS 31348), sourced from
its 4K master (Clifs-3d-STScI.mp4, 3840x2160) via the content pipeline:
trim 10-34s (clears the title card ~8s and end-credit card ~102s) + crossfade-loop
-> 4K master + supersampled 1080p base (5.5 Mbps, crisper than the old 2.9 Mbps
1080p-native Orion encode).

- build_pool_manifest META: cosmos -> SVS 31348 Cosmic Cliffs, new CCBY_WEBB
  credit (NASA, ESA, CSA, STScI). Labels retuned for the Carina framing
  (emission nebula / protostar; redshift -> distance ~7,500 ly).
- Regenerated manifest.json (also corrects stale JPL-Orion provenance left when
  129bb23 updated META but not the manifest) + all ring-edge transitions from the
  new base (cosmos-orbit / abyss-cosmos now zoom from the Cosmic Cliffs).
- docs/content-candidate-pool.md: record the swap + history.

269 passed, 2 skipped. New base verified text-free + full-frame by-eye across the
loop; final look to be confirmed by the operator (no Chrome on the box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:48:44 -07:00
BenStullsBets de33cbe86d claim human-experience-filter-art session 0018 (placeholder) + sessions.json entry 2026-06-25 09:43:05 -07:00
BenStullsBets 129bb23cb9 feat(content): swap cosmos Orion to a text-free SVS flythrough
Replaces the JPL "Orion: Dust and Death" primary (captioned throughout — we'd
cropped it, losing framing) with STScI's "Flight Through the Orion Nebula"
(visible light, NASA SVS 30957) — a full-frame, text-free flythrough of the same
nebula. Sourced via the content pipeline (trim 10–34s + crossfade-loop → 1080p
master + base); cosmos-orbit / abyss-cosmos transitions regenerated from it.

- build_pool_manifest META: cosmos -> SVS 30957, license CC-BY-class (NASA/ESA/
  STScI), new trim window.
- Remove the now-obsolete decaption_cosmos() crop helper (the new source needs
  no caption removal).
- docs/content-candidate-pool.md: record the source swap; drop the crop note.

269 passed, 2 skipped. New clip verified text-free + full-frame by-eye;
final look to be confirmed by the operator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:28:10 -07:00
BenStullsBets a2ef792f0f fix(sim): revalidate /media instead of pinning it immutable
The `immutable, max-age=1y` media header pinned stale footage in the browser for
a year, so a re-sourced or re-cropped clip (e.g. the de-captioned cosmos) never
showed without a manual cache clear. Switch /media to `no-cache` (store, but
revalidate every load): instant altitude swaps already come from the in-memory
blob preload, so the HTTP cache only matters on (re)load — where a conditional
request is a cheap 304 when unchanged but picks up edited clips immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:20:14 -07:00
BenStullsBets 53cb89b4c6 fix(content): crop NASA burned-in captions off the cosmos Orion clip
The cosmos primary is NASA/JPL's "Orion: Dust and Death" — an annotated
visualization with lower-third captions ("DUST FILAMENT", "BLUE BUBBLE", …)
burned in throughout, so trimming can't avoid them (only ~4s of our window is
caption-free). The candidate-pool doc wrongly called it text-free.

- build_pool_manifest.decaption_cosmos(): crops the cosmos base/master to the
  top 66% of frame height (16:9 preserved, center-x, ~1.5x tighter framing),
  re-fit to 1920x1080 — the recorded, reproducible form of the fix (media is
  gitignored). Applied in place; re-ran generate_media() so the cosmos-orbit /
  abyss-cosmos transitions use the cropped base.
- docs/content-candidate-pool.md: correct the "text-free" note; document the crop.

269 passed, 2 skipped. De-caption verified by-eye on both caption moments;
final look to be confirmed by the operator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:16:57 -07:00
BenStullsBets 0201690ce0 feat(sim): real zoom transitions on every ring edge, both directions
The cosmos→orbit, reef→abyss and abyss→cosmos transition clips were stale
leftovers from an earlier 5-scale ring, built against old/placeholder footage —
so those edges flashed placeholder frames mid-zoom. Only orbit-coast and
coast-reef had been rebuilt from the current real bases.

- build_pool_manifest.generate_media(): now rebuilds EVERY ring edge from the
  current real primary pool bases (the orbit-coast recipe applied uniformly),
  overwriting the stale clips, and bakes a reversed companion (<edge>.rev.mp4)
  per edge via an ffmpeg `reverse` pass.
- app.js: a `reversed` step (an ascending / zoom-OUT ring move) now plays the
  baked .rev clip instead of the forward zoom-in, so going up recedes from the
  current scale. Both directions are added to the in-memory preload set.

Media is gitignored (generated locally / on deploy); regenerated all 10 clips
locally. 269 passed, 2 skipped; node --check clean; clips serve 200.
Visual result to be verified by-eye by the operator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:34:50 -07:00
BenStullsBets 0928988abc fix(sim): collapse Dev Mode panel when disabled
The generic .hidden { display:none } is defined earlier in the stylesheet than
.dev-panel { display:flex }; equal specificity meant source order won and the
panel stayed visible with the toggle off. A two-class .dev-panel.hidden rule
wins, so all dev controls/data truly hide when Dev Mode is disabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:26:10 -07:00
BenStullsBets a1c9c5c760 feat(sim): Dev Mode — pool clip picker + live analysis under one toggle
Adds a single "Dev Mode" switch at the bottom of the control panel (off by
default, persisted in localStorage). When on, a panel appears below it with all
dev controls and data; when off the default view is just the experience + the
three control fieldsets.

Dev panel:
- Pool picker: a <select> of the current altitude's pool members — choose any
  clip to override the random landing pick; a "Re-roll random" button re-runs
  the server-canonical pick. Rebuilds on each landing.
- Active clip metadata (id, title, source, license, base_file).
- Annotations & affect: keys with salience/window/tier counts (read-only;
  editing stays in /author.html).
- RenderPlan JSON — relocated here from the always-visible panel.
- Cache & playback: preload status (N/total), memory-vs-network for the active
  clip, live resolution/duration/loop position.

Frontend only — no API changes; everything is read from data the renderer
already has. Factored pickRandomMember() out of landScale() so the initial
landing, ring advances, and the re-roll button share one pick.

Design: docs/superpowers/specs/2026-06-25-simulator-dev-mode-design.md
269 passed, 2 skipped; node --check + server smoke clean. Frontend behavior
to be verified by-eye by the operator (no headless browser in repo yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:19:39 -07:00
benstull ab2edf77bb Merge pull request 'perf(sim): preload all clips into memory for instant altitude swaps' (#19) from feature/preload-media-cache into main 2026-06-25 14:48:44 +00:00
BenStullsBets d19fe4e8fa perf(sim): preload all clips into memory for instant altitude swaps
Each altitude change reset the single <video> element's src and re-fetched
the scale's footage over the network, compounded by the dev no-store policy
applying to /media too — so every scale change paid a full fetch+decode.

- simulator/app.py: split the cache middleware — app shell/JS/CSS stay
  no-store (by-eye dev iteration), but /media is now cacheable
  (public, max-age, immutable) since the clips are static.
- simulator/static/app.js: preload every ring clip (19 base + 5 transition,
  ~350 MB) into in-memory blob object URLs at startup — non-blocking, the
  first scale plays immediately while the rest stream in, ordered current
  scale outward. mediaUrl() serves the cached blob when ready, else falls
  back to the network. A small progress chip self-removes when done.
- tests: assert app assets stay no-store and /media is cacheable.

269 passed, 2 skipped. Frontend verified by-eye by the operator (no headless
browser in repo yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:47:32 -07:00
BenStullsBets 9a83c0132d claim human-experience-filter-art session 0017 (placeholder) + sessions.json entry 2026-06-25 07:41:36 -07:00
BenStullsBets 7fd9aabd0e add sessions/0016/SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--2026-06-24T21-46.md + replace placeholder/variant SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--2026-06-24T18-18.md 2026-06-24 21:46:35 -07:00
benstull 7a32ff1acc Merge pull request 'feat(sim): turnable Altitude knob (replaces ring buttons)' (#18) from feature/altitude-knob into main 2026-06-25 04:45:09 +00:00
BenStullsBets 7628a5321f add sessions/0016/SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--2026-06-24T18-18.md + replace placeholder/variant SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--INPROGRESS.md 2026-06-24 18:18:13 -07:00
BenStullsBets bcd4d32b19 update sessions/0016/SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--INPROGRESS.md 2026-06-24 18:17:37 -07:00
18 changed files with 996 additions and 125 deletions
+5 -1
View File
@@ -1,7 +1,11 @@
.PHONY: sim sim-local
# Prefer the project venv's interpreter (this box has no bare `python` on PATH),
# fall back to python3, then python.
PYTHON ?= $(firstword $(wildcard .venv/bin/python) python3 python)
sim:
docker compose -f simulator/docker-compose.yml up --build
sim-local:
python -m uvicorn simulator.app:app --reload --port 8000
$(PYTHON) -m uvicorn simulator.app:app --reload --port 8000
+29 -6
View File
@@ -23,20 +23,43 @@ a credit line** (our overlays + dream are derivatives, which these allow);
**CCBYND is NOT usable** (no derivatives); **CCBYNC only if the installation
stays noncommercial**.
## 🌌 cosmos (pool of 4)
## 🌌 cosmos (pool of 5)
| id | clip | source | window (s) | license / credit |
|----|------|--------|-----------|------------------|
| `cosmos` | Orion Nebula flythrough | NASA/JPLCaltech — https://images.nasa.gov/details/JPL-20221122-SOLSYSf-0001-Orion%20Dust%20and%20Death | ~3054 | 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**) | 1034 | CCBYclass — credit **NASA, ESA, CSA, STScI** |
| `cosmos_miri` | Cosmic Cliffs — Carina Nebula, **mid-infrared** (same towers, muted gray/teal/gold dust palette) | ESA/Webb weic2205c — https://esawebb.org/videos/weic2205c/ (*Pan of the Carina Nebula, combined NIRCam + MIRI*, **4K** `weic2205c.mp4`) | 426 | CCBYclass — credit **NASA, ESA, CSA, STScI** |
> ️ **Cosmos primary swapped to the Webb "Cosmic Cliffs" flythrough (2026-06-25, session 0018).**
> History: the original primary was NASA/JPL's *"Orion: Dust and Death"* (captions
> 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 1034s 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 | 832 | PD |
| `cosmos_hudf` | Hubble Ultra Deep Field zoom | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030687/hudf-b-1920x1080p30.mov | 2044 | CCBYclass — credit **NASA, ESA, STScI** |
| `cosmos_xdf` | eXtreme Deep Field flythrough | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030681/hxdf_fly-b-1920x1080p30.mov | 226 | CCBYclass — credit **NASA, ESA, STScI** |
> **`cosmos_miri` added (2026-06-26).** Operator wanted more *Cosmic Cliffs*
> footage and a different-color rendering. The keep-the-flythrough-primary +
> add-the-mid-infrared-pan pair: ESA/Webb's **weic2205c** is the *same* Carina
> towers shot with NIRCam **+ MIRI**, so it renders in a muted gray/teal/gold dust
> palette instead of the iconic orange/blue — a distinct look from the same scene.
> Sourced text-free/human-free from the **4K** master, trim 426s (clears the
> opening fade-from-black, ends before any tail fade) → 1080p base + 4K master,
> 1 s crossfade-loop (~21 s). ESA/Webb's narration-free *"Diving Into the Cosmic
> Cliffs"* pan (`carina_revisit_pan`, NIRCam palette) was the runner-up — left
> out for now since it repeats the existing palette; easy to add if wanted.
> ⚠️ **DISCREPANCY to fix next session.** Operator selected cosmos **#1, #3
> (Orion), #4, #5**. During processing, **#6 Galaxy Traverse was run by mistake
> instead of #3 Orion**. The Orion clip (#3) is textfree 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 Increment1 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 (localonly, rebuildable).
@@ -1,27 +1,36 @@
# Affect channel — emotions in the Left-brain HUD
**Status:** approved (session 0013, 2026-06-22)
**Status:** approved (session 0013, 2026-06-22); **revised session 0018, 2026-06-26**
**Surface:** simulator preview (`simulator/`, `player/alteration.py`)
**Builds on:** the machine-altered-perception design SPEC; the Left HUD look-and-feel
pass (same session).
> **Revision (session 0018) — affect is a RIGHT-brain output.** The original design
> gated emotions on *both* knobs (`strength = min(left, right)`). Per operator
> direction, **emotions are now controlled by the Right (dreamlike) knob alone** —
> the analytical Left knob no longer gates them. This sharpens the left/right split:
> Left = measurement (detections + measurements), Right = the dream **and** the
> feeling it evokes. Every `min(left, right)` below now reads `right`. The data
> shape, rendering, and per-word `min_level` mechanic are unchanged — only the knob
> the strength is keyed off.
## Idea
A third HUD channel beside the cyan *detections* and amber *measurements*: soft,
glowing **emotion-words** that surface the feelings the experience may invoke.
They appear only when *both* the analytical (Left) and dreamlike (Right) knobs are
up — the dream leaking into the machine's reading. This is the uncanny edge of the
thesis: the machine trying to quantify not just what is there, but how you are
meant to feel.
They are a **right-brain output: driven by the dreamlike Right knob alone** (the
analytical Left knob does not gate them) — the dream surfacing as the machine's
felt reading. This is the uncanny edge of the thesis: the machine trying to
quantify not just what is there, but how you are meant to feel.
## Behaviour
- **Trigger / intensity.** Affect strength = `min(left, right)` (04). Either knob
at 0 → no emotions at all. Opacity scales with that strength × `overlay_gain`.
- **Escalation.** A **stable emotional palette per scene** — higher combined
strength reveals *more* words at higher opacity. Each word carries a `min_level`
(the same mechanic the detection channel uses), but compared against the
*combined* `min(left, right)` strength rather than Left alone.
- **Trigger / intensity.** Affect strength = `right` (04). Right at 0 → no
emotions at all, regardless of Left. Opacity scales with that strength ×
`overlay_gain`.
- **Escalation.** A **stable emotional palette per scene** — higher Right reveals
*more* words at higher opacity. Each word carries a `min_level` (the same
mechanic the detection channel uses), compared against the `right` strength.
- **Rendering.** Floating words at authored scene positions — **no boxes or
reticles**, lowercase, semi-transparent with a soft glow, in a distinct **violet**
register so they read as affective and clearly *softer* than the hard clinical
@@ -33,15 +42,15 @@ Consistent with the existing split (alteration math in Python, label-selection i
the client):
- `player/alteration.py` gains an `AffectOverlay(strength, intensity)` on the
`RenderPlan`. `strength = min(left, right)`; `intensity = clamp(overlay_gain *
min(left,right) / KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under
`"affect"`. Pure, unit-tested.
`RenderPlan`. `strength = right`; `intensity = clamp(overlay_gain * right /
KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under `"affect"`. Pure,
unit-tested.
- The simulator client picks *which* words to show by comparing each affect
entry's `min_level` to `plan.affect.strength`, and renders them at
`plan.affect.intensity` opacity.
`is_identity` is unaffected: `min(left,right) > 0` implies `left > 0`, which already
makes `overlay.level > 0`.
`is_identity` is unaffected: `affect.strength == right`, and `right > 0` already
makes `dream.strength > 0`.
## Data
@@ -0,0 +1,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).
+17 -14
View File
@@ -88,11 +88,12 @@ class AnalyticalOverlay:
@dataclass(frozen=True)
class AffectOverlay:
"""Affect channel (Left x Right): emotion-words surfaced only when BOTH the
analytical (Left) and dreamlike (Right) knobs are up. `strength` is
`min(left, right)` (0..4) — a runtime affect track picks which feeling words
appear by it; `intensity` 0..1 is their opacity. The dream leaking into the
machine's reading (affect-channel design, session 0013)."""
"""Affect channel (RIGHT brain): emotion-words surfaced by the Right (dreamlike)
knob alone — the analytical Left knob does NOT gate them. `strength` is the Right
knob (0..4) — a runtime affect track picks which feeling words appear by it;
`intensity` 0..1 is their opacity. Feeling is the right brain's output, surfaced
as the machine's felt reading (affect-channel design, session 0013; Right-only
since session 0018)."""
strength: int
intensity: float
@@ -122,8 +123,9 @@ class RenderPlan:
@property
def is_identity(self) -> bool:
"""True when the plan leaves the neutral base un-altered. Affect needs no
clause: `min(left,right) > 0` implies `left > 0`, so `overlay.level > 0`
already makes a plan with active affect non-identity."""
clause: `affect.strength == right`, and `right > 0` already makes
`dream.strength > 0`, so an active-affect plan is non-identity via the
dream check below."""
return (
self.grade.is_identity
and self.overlay.level == 0
@@ -135,13 +137,14 @@ def _overlay_intensity(left: int, cal: Calibration) -> float:
return _clamp(cal.overlay_gain * left / KNOB_MAX, 0.0, 1.0)
def _affect_strength(left: int, right: int) -> int:
"""Affect surfaces only when BOTH knobs are up — gated by the smaller one."""
return min(left, right)
def _affect_strength(right: int) -> int:
"""Affect is a RIGHT-brain output: the emotion channel is driven by the Right
(dreamlike) knob alone — the analytical Left knob no longer gates it."""
return right
def _affect_intensity(left: int, right: int, cal: Calibration) -> float:
return _clamp(cal.overlay_gain * _affect_strength(left, right) / KNOB_MAX, 0.0, 1.0)
def _affect_intensity(right: int, cal: Calibration) -> float:
return _clamp(cal.overlay_gain * _affect_strength(right) / KNOB_MAX, 0.0, 1.0)
def _dream_intensity(right: int, cal: Calibration) -> float:
@@ -163,8 +166,8 @@ def plan_alteration(
intensity=_overlay_intensity(coord.left, calibration),
),
affect=AffectOverlay(
strength=_affect_strength(coord.left, coord.right),
intensity=_affect_intensity(coord.left, coord.right, calibration),
strength=_affect_strength(coord.right),
intensity=_affect_intensity(coord.right, calibration),
),
dream=Dream(
strength=coord.right,
@@ -0,0 +1,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 14 + 6 (no `track.py`). 246 tests.
## Arc
1. **Session gate + init.** Classified the `/goal` as planning-and-executing,
claimed session **0016** (peeked an ENDED-UNFINALIZED 0015 — its PR #15 work was
already merged, not live — and proceeded; no worktree needed, solo session).
Verified clean `main`, read the pool doc + spec + sub-project-3 memory + the live
code (ring, clips, app, app.js, alteration, pipeline).
2. **Design pass (operator asked for it — schema changes).** Appended **§11** to the
content-pipeline spec pinning the concrete shapes: rotating pool, time-windowed
tracks, progressive Left detail tiers (salience + tiered strings), progressive
Right emotion tiers, and the `track.py` + author-mode tooling. Key call: tiers
need **no engine change** — the client reads `overlay.level` (Left) and
`dream.strength` (Right) already in the `RenderPlan`.
3. **PR #16 — the experience.** `Scale.pool` + pure `pick_clip_id` (injected
randomness); server-side random pick at the API boundary (delta=0 = initial pick).
`forest``coast` rename + ring reorder + orphan-media cleanup. Time-windowed
tracked labels (`appear`/`disappear`, seam-wrapping). Salience-gated +
tier-escalating Left labels; tier-escalating Right emotions; tiered-or-static
strings (back-compat). New `build_pool_manifest.py` emits the 19-clip pool
manifest (abyss+reef authored as tracked/windowed showcase). Merged via Gitea API.
4. **PR #17 — the tooling.** `tools/pipeline/track.py`: classical LK optical-flow box
tracker (this opencv build has no CSRT) → sparse keyframed track + window; optional
lazy ML path. Simulator AUTHOR MODE (`/author.html` + `/api/author/{track,clip}`):
scrub, drag a seed box, run tracker, author tiers/affect, save to manifest
(idempotent upsert, keeps provenance). Repointed the pipeline integration test off
the retired forest base; USER_GUIDE sim section brought current. Merged.
## Cut state
`main` at `c445239` (PR #17 merge). **267 passed / 2 skipped.** Clean tree, fully
pushed, no open PRs. Both feature branches deleted. All 5 goal items delivered.
Verification was API/logic-level + JS syntax + live-server probes (5 scales with
pools, rotating picks, new edges, 19 clips served, tracker returns keyframes on real
abyss_wow footage, committed manifest untouched by the read-only probe). **The
VISUAL look was not screenshot-verified — no Chrome on this box** — so operator
by-eye review is the next step (matches the established deferred-review pattern).
## Follow-up (post-finalize, same conversation) — PR #18
Operator asked to replace the scale-ring `⊖ out / in ⊕` buttons with a **circular
"Altitude" knob** that turns all the way around (including abyss→cosmos). Built a
turnable SVG dial in `simulator/static/app.js` (`buildDial` / `renderDial` / drag +
wheel handlers) + `style.css`: cosmos (highest) at top, clockwise descends
cosmos→orbit→coast→reef→abyss and **wraps** back up. Dragging accumulates rotation
and commits whole detents on release (a big spin → one fast blended pass, reusing
the proven wheel/detent path); scroll the knob to step; tap a label to jump the
shortest way around. **Pure frontend** — the endless ring + wrap already existed
server-side, so no engine/API change. JS syntax-checked; 23 sim-API tests green;
live probe confirmed the dial replaced the buttons and the abyss→cosmos wrap is
intact. Merged as **PR #18** (`main` at `7a32ff1`). Visual look still operator-by-eye
(no Chrome on this box).
## Deployment pipeline (§9)
No stage run: the HEF simulator is a **local dev tool** (simulator-first directive),
not a deployed app — it has no PPE/prod infra. Nothing shippable through the §9
pipeline this session; the session output is the merged code on `main`.
## Plan archival
No separate `superpowers:writing-plans` artifact was produced — the design pass was a
spec §11 edit (merged in PR #16) and execution was direct TDD tracked by the
transcript `## Plan` block + the task list. Nothing to `submit-plan.sh`; this app has
no content repo anyway (roadmap-anchored).
## Deferred decisions
- **Adopting/finalizing the ENDED-UNFINALIZED session 0015 placeholder** was skipped
— its PR #15 work is already on `main` and its outcomes are fully recorded in
memory; finalizing a prior dead session was judged an unnecessary detour. Low
confidence; flagging for the operator.
- **By-eye visual review deferred** to the operator (no Chrome here) — consistent with
the prior-session pattern, but the look of the tiers/windows/pool rotation AND the
new Altitude knob is unconfirmed visually.
- **Light-vs-rich label authoring:** only abyss + reef + the scale primaries are
richly authored; the other pool members carry lighter labels. Finishing them is the
author-mode follow-up (next goal).
## Next-session prompt
`/goal` Operator by-eye review of Increment 2 + the Altitude knob in the sim
(`make sim-local`, :8000): turn the Altitude knob (drag/scroll, wraps abyss→cosmos;
pools rotate per landing), sweep Left (progressive label tiers
general→scientific+fact), sweep Right with Left up (progressive emotions
basic→compound), watch abyss/reef time-windowed tracked labels enter/leave + track,
and exercise `/author.html`. Then author real labels for the remaining pool members
via author mode. After the content is liked, the next build frontier is the deferred
i2v ring transitions (generative-video model + adjacent real bases) and/or the Pi
renderer + serial/firmware (per [[simulator-first-before-hardware]]), per ROADMAP.md.
@@ -1,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,25 @@
# Session 0018.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-25T09-42 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: b96041e5-c682-49f5-88ee-380ea7abb6af
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0018 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0018.0-TRANSCRIPT-2026-06-25T09-42--<end>.md form at session end.
## Launch prompt
```
Find a better public-domain nebula flythrough video for the cosmos scale (the current text-free SVS Orion clip lost quality when cropped to remove its text overlay), or upscale the existing one if no better source exists.
```
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -0,0 +1,25 @@
# Session 0019.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-26T04-47 (PST)
> Type: brainstorming
> Posture: careful
> Claude-Session: d6ed1203-92b6-471f-80a4-4c773b645ca1
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0019 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0019.0-TRANSCRIPT-2026-06-26T04-47--<end>.md form at session end.
## Launch prompt
```
Let's start talking about hardware. Let's assume for the first version I'm ok having my computer or laptop do the rendering. We'd still want a controller but it could be connected to the computer via wifi, USB, or bluetooth and would send commands to the software we've already written that would adjust left brain, right brain, etc. We'd prototype this by having a controller simulator run on another device, such as an ipad (just riffing here) but eventually move to a physical remote running on an Arduino or similar
```
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
+9
View File
@@ -46,5 +46,14 @@
},
"0016": {
"title": ""
},
"0017": {
"title": ""
},
"0018": {
"title": ""
},
"0019": {
"title": ""
}
}
+66 -5
View File
@@ -59,6 +59,41 @@ def _asset_version() -> str:
return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16]
# Content-hash tokens for served media, so the client can append `?v=<hash>` to a
# /media URL. A clip re-baked under the SAME path (e.g. a re-sourced cosmos base)
# changes its hash → its URL → a fresh fetch, busting any cached prior bytes
# permanently (even an immutable-pinned entry a plain reload can't revalidate).
# Cached by (mtime_ns, size) so the full-file hash is recomputed only when the
# file actually changes — a re-bake is picked up without a server restart.
_media_hash_cache: dict[str, tuple[int, int, str]] = {}
def _media_version(rel: str) -> Optional[str]:
"""Short content hash of the media file at `rel` under MEDIA_DIR, or None if
it's absent. Cheap on repeat calls: re-hashes only when (mtime, size) change."""
path = MEDIA_DIR / rel
try:
st = path.stat()
except OSError:
return None
cached = _media_hash_cache.get(rel)
if cached and cached[0] == st.st_mtime_ns and cached[1] == st.st_size:
return cached[2]
h = hashlib.sha1()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
token = h.hexdigest()[:12]
_media_hash_cache[rel] = (st.st_mtime_ns, st.st_size, token)
return token
def _rev_file(file: str) -> str:
"""The baked zoom-out companion path for a transition file (mirrors the
client's `reverseFile`): `<edge>.mp4` -> `<edge>.rev.mp4`."""
return file[:-4] + ".rev.mp4" if file.endswith(".mp4") else file
class ControlsModel(BaseModel):
content: str
left: int = Field(ge=0, le=4)
@@ -160,6 +195,24 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
def api_clips():
return {"clips": [c.to_dict() for c in app.state.clips]}
@app.get("/api/media-versions")
def api_media_versions():
"""Per-file content-hash tokens the client appends to /media URLs as
`?v=<hash>`. Covers every served file: each clip's base footage plus each
ring transition and its baked reverse. A re-baked clip's hash changes, so
its URL changes and the browser refetches — a permanent cache-bust."""
files = {c.base_file for c in app.state.clips}
if app.state.ring is not None:
for t in app.state.ring.transitions:
files.add(t.file)
files.add(_rev_file(t.file))
versions = {}
for f in sorted(files):
v = _media_version(f)
if v:
versions[f] = v
return {"versions": versions}
@app.get("/api/ring")
def api_ring():
if app.state.ring is None:
@@ -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():
+43 -14
View File
@@ -38,7 +38,7 @@ MANIFEST = MEDIA / "manifest.json"
# --- Pools: scale id -> ordered clip ids (primary first) (content-candidate-pool.md) ---
POOLS: dict[str, list[str]] = {
"cosmos": ["cosmos", "cosmos_galaxies", "cosmos_hudf", "cosmos_xdf"],
"cosmos": ["cosmos", "cosmos_miri", "cosmos_galaxies", "cosmos_hudf", "cosmos_xdf"],
"orbit": ["orbit_planetearth", "orbit_crewobs", "orbit_bluemarble"],
"coast": ["coast_birdrock", "coast_surfgrass", "coast_elkbeach", "coast_drakesbeach"],
"reef": ["reef_lionfish", "reef_spawning", "reef_hawkfish", "reef_snapper", "reef_coralspacific"],
@@ -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 ~3054s, 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 1034s, crossfade-loop. Text-free — replaces the Orion SVS 30957 flythrough."),
"cosmos_miri": ("Cosmic Cliffs — Carina Nebula, mid-infrared (NASA/ESA/CSA/STScI)", CCBY_WEBB,
"ESA/Webb weic2205c (Pan of the Carina Nebula, combined NIRCam + MIRI); 4K source, "
"trim 426s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold "
"dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free."),
"cosmos_galaxies": ("Flying Through Galaxies (NASA SVS)", PD,
"NASA SVS a014950 14950_Galaxies_FlyThrough_4k; trim 832s, crossfade-loop"),
"cosmos_hudf": ("Hubble Ultra Deep Field zoom (NASA/ESA/STScI)", CCBY_STSCI,
@@ -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:
+135 -19
View File
@@ -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 ~3054s, 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 1034s, 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 426s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free.",
"right_variants": {},
"annotations": [
{
"key": "detected.nebula",
"salience": 4,
"box": [
0.1,
0.45,
0.7,
0.45
]
},
{
"key": "detected.star",
"salience": 2,
"box": [
0.48,
0.18,
0.14,
0.16
]
},
{
"key": "measure.distance",
"box": [
0.06,
0.06,
0.2,
0.08
],
"min_level": 3
}
],
"affect": [
{
"key": "feel.wonder",
"at": [
0.5,
0.44
],
"min_level": 1
},
{
"key": "feel.vastness",
"at": [
0.2,
0.7
],
"min_level": 2
},
{
"key": "feel.insignificance",
"at": [
0.66,
0.6
],
"min_level": 3
},
{
"key": "feel.longing",
"at": [
0.42,
0.84
],
"min_level": 4
}
],
"strings": {
"en": {
"detected.nebula": [
"dust",
"nebula",
"dust ridge",
"dust ridge · mid-infrared glow of the Carina cliffs"
],
"detected.star": [
"star",
"young star",
"protostar",
"protostar · its dust laid bare in mid-infrared"
],
"measure.distance": "≈7,500 ly",
"feel.wonder": [
"wow",
"wonder",
@@ -2252,6 +2367,7 @@
"clip_id": "cosmos",
"pool": [
"cosmos",
"cosmos_miri",
"cosmos_galaxies",
"cosmos_hudf",
"cosmos_xdf"
+287 -19
View File
@@ -42,6 +42,11 @@ let lastOverlay = null; // {level, intensity} from the most-recent renderOver
async function loadData() {
const clips = (await (await fetch("/api/clips")).json()).clips || [];
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
// Per-file content-hash tokens → appended to /media URLs as ?v=<hash> so a
// re-baked clip (new bytes, same path) gets a fresh URL the browser can't serve
// stale. Best-effort: an empty map just yields un-versioned URLs.
try { mediaVersions = (await (await fetch("/api/media-versions")).json()).versions || {}; }
catch (_) { mediaVersions = {}; }
const r = await fetch("/api/ring");
serverRing = r.ok;
ring = r.ok ? await r.json() : null;
@@ -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)));
}
@@ -390,6 +488,7 @@ function renderScaleReadout() {
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() {
@@ -415,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();
@@ -436,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";
@@ -448,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 = () => {
@@ -464,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;
@@ -477,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();
@@ -613,6 +749,136 @@ function jumpToScale(idx) {
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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
@@ -633,7 +899,9 @@ 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);
+33 -4
View File
@@ -50,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 &amp; affect</legend>
<div id="clip-anno" class="dev-anno"></div>
</fieldset>
<fieldset>
<legend>RenderPlan</legend>
<pre id="readout"></pre>
</fieldset>
<fieldset>
<legend>Cache &amp; playback</legend>
<dl id="dev-stats" class="dev-dl"></dl>
</fieldset>
</div>
</section>
</main>
<script src="/app.js"></script>
+35 -1
View File
@@ -39,7 +39,7 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; }
.hud-chip-text.measure { fill: #ffd79a; }
.hud-conf { fill: #6cf; opacity: 0.8; }
.hud-status { fill: #8fdcff; font: 2.4px monospace; opacity: 0.9; letter-spacing: 0.15px; }
.black { position: absolute; inset: 0; background: #000; }
.black { position: absolute; inset: 0; background: #000; opacity: 1; transition: opacity 200ms ease; }
.hidden { display: none; }
.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem; }
fieldset { border: 1px solid #333; border-radius: 6px; }
@@ -66,3 +66,37 @@ input[type=range], select { width: 100%; }
.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; }
+23 -21
View File
@@ -55,42 +55,44 @@ def test_left_and_right_stack_not_cancel():
assert plan.dream.intensity == 1.0
def test_affect_needs_both_knobs_up():
# affect-channel design (session 0013): emotion-words surface only when BOTH
# the Left and Right knobs are up — either at 0 means no affect at all.
def test_affect_is_driven_by_the_right_knob_alone():
# Right-brain affect (session 0018): emotion-words are controlled by the Right
# knob alone — Right at 0 means no affect regardless of Left, and Left no longer
# gates the channel (full Right with Left at 0 still surfaces affect).
assert plan_alteration(_coord(left=4, right=0)).affect.strength == 0
assert plan_alteration(_coord(left=0, right=4)).affect.strength == 0
assert plan_alteration(_coord(left=4, right=0)).affect.intensity == 0.0
assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 0.0
assert plan_alteration(_coord(left=0, right=4)).affect.strength == 4
assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 1.0
def test_affect_strength_is_the_smaller_knob():
# strength = min(left, right)the smaller knob gates the channel.
assert plan_alteration(_coord(left=2, right=4)).affect.strength == 2
def test_affect_strength_is_the_right_knob():
# strength = right — Left is irrelevant to the affect channel.
assert plan_alteration(_coord(left=2, right=4)).affect.strength == 4
assert plan_alteration(_coord(left=4, right=2)).affect.strength == 2
assert plan_alteration(_coord(left=4, right=4)).affect.strength == 4
assert plan_alteration(_coord(left=3, right=1)).affect.strength == 1
assert plan_alteration(_coord(left=0, right=3)).affect.strength == 3
assert plan_alteration(_coord(left=4, right=1)).affect.strength == 1
def test_affect_intensity_scales_with_strength_over_full_scale():
for lo in range(5):
plan = plan_alteration(_coord(left=lo, right=4))
assert plan.affect.intensity == pytest.approx(lo / 4)
assert plan_alteration(_coord(left=4, right=4)).affect.intensity == 1.0
def test_affect_intensity_scales_with_right_over_full_scale():
for r in range(5):
plan = plan_alteration(_coord(left=0, right=r))
assert plan.affect.intensity == pytest.approx(r / 4)
assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 1.0
def test_affect_intensity_honors_overlay_gain():
# affect rides the same overlay_gain as the analytical HUD it belongs to.
# affect text rides the overlay-opacity gain, but is keyed off the Right knob.
cal = Calibration(overlay_gain=0.5)
assert plan_alteration(_coord(left=4, right=4), cal).affect.intensity == pytest.approx(0.5)
assert plan_alteration(_coord(left=0, right=4), cal).affect.intensity == pytest.approx(0.5)
clamp = Calibration(overlay_gain=10.0)
assert plan_alteration(_coord(left=4, right=4), clamp).affect.intensity == 1.0
assert plan_alteration(_coord(left=0, right=4), clamp).affect.intensity == 1.0
def test_affect_active_implies_non_identity():
# min(left,right) > 0 implies left > 0, so an active affect plan is never identity.
plan = plan_alteration(_coord(left=2, right=3))
assert plan.affect.strength == 2
# affect.strength == right, and right > 0 makes dream.strength > 0, so an active
# affect plan (even with Left at 0) is never identity.
plan = plan_alteration(_coord(left=0, right=3))
assert plan.affect.strength == 3
assert not plan.is_identity
+53
View File
@@ -76,6 +76,30 @@ def test_clips_returns_the_manifest(client):
assert data["clips"][0]["annotations"][0]["key"] == "detected.water"
def test_media_versions_endpoint_shape(client):
resp = client.get("/api/media-versions")
assert resp.status_code == 200
assert isinstance(resp.json()["versions"], dict)
def test_media_version_is_a_content_hash(tmp_path, monkeypatch):
import hashlib
import simulator.app as appmod
# Point the media root at a temp dir (the served dir is otherwise fixed).
monkeypatch.setattr(appmod, "MEDIA_DIR", tmp_path)
(tmp_path / "clip").mkdir()
f = tmp_path / "clip" / "base.mp4"
f.write_bytes(b"hello-bytes")
assert appmod._media_version("clip/base.mp4") == hashlib.sha1(b"hello-bytes").hexdigest()[:12]
# Absent file -> None (it's simply omitted from the versions map).
assert appmod._media_version("clip/missing.mp4") is None
# Re-baked under the same path (new bytes) -> a new token, not the cached one.
f.write_bytes(b"different-bytes-entirely")
assert appmod._media_version("clip/base.mp4") == hashlib.sha1(b"different-bytes-entirely").hexdigest()[:12]
def test_retired_selection_endpoints_are_gone(client):
# The route no longer exists; the static catch-all yields 404 on GET and
# 405 on the (now-unrouted) POST. Either proves the endpoint is gone.
@@ -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"