Compare commits

..

19 Commits

Author SHA1 Message Date
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 5fae4f9005 feat(sim): replace ring buttons with a turnable "Altitude" knob
The scale ring's ⊖ out / in ⊕ buttons are replaced by a circular **Altitude**
knob (the endless rotary encoder, drawn as a dial). cosmos (highest) sits at the
top; turning clockwise descends cosmos → orbit → coast → reef → abyss and past
the deepest **wraps back to the top** — the same endless ring the server already
models (advance + wrap unchanged).

- Built dynamically from `ring.scales` (labels + ticks per scale, a needle that
  points at the current scale, an ALTITUDE caption).
- Drag to turn: the rotation accumulates and commits whole detents on release, so
  a big spin becomes one fast blended pass (reuses the proven wheel/detent path);
  scroll the knob to step; tap a label to jump the shortest way around.
- Pure frontend — no engine/API change. JS syntax-checked; live server probed
  (assets serve, dial present, abyss→cosmos wrap intact). 23 sim-API tests green.
  By-eye review deferred to the operator (no Chrome on this box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:44:51 -07: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
benstull c4452396a1 Merge pull request 'tools(pipeline): hybrid track.py + simulator label author mode' (#17) from feature/content-pipeline-track-author into main 2026-06-25 01:14:59 +00:00
13 changed files with 774 additions and 77 deletions
+16 -5
View File
@@ -27,16 +27,27 @@ stays noncommercial**.
| 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 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** |
> ⚠️ **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 +119,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).
@@ -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,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._
+6
View File
@@ -46,5 +46,11 @@
},
"0016": {
"title": ""
},
"0017": {
"title": ""
},
"0018": {
"title": ""
}
}
+13 -5
View File
@@ -234,12 +234,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():
+33 -13
View File
@@ -51,12 +51,14 @@ 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_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 +163,9 @@ 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_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 +357,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 +367,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 +385,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:
+19 -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,15 @@
"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",
+358 -14
View File
@@ -52,22 +52,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 +81,89 @@ 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 = {};
function mediaUrl(file) { return mediaBlobs[file] || ("/media/" + 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 {
// `cache: "reload"` bypasses the browser HTTP cache and refetches from the
// network, then updates the cache entry. This busts a clip whose bytes were
// re-baked under the SAME path (e.g. a re-sourced cosmos base) — including
// one a pre-`no-cache` build had pinned `immutable, max-age=1y`, which a
// plain reload would never revalidate. The blob cache above still gives
// instant in-session swaps; this only affects the one fetch per (re)load.
const blob = await (await fetch("/media/" + 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
@@ -389,6 +477,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() {
@@ -435,11 +525,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 +543,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 = () => {
@@ -476,7 +572,7 @@ 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
@@ -500,6 +596,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) => ({ "&": "&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
@@ -520,12 +858,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
View File
@@ -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 &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>
+51 -6
View File
@@ -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; }
+29
View File
@@ -91,6 +91,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"