Compare commits

...

23 Commits

Author SHA1 Message Date
BenStullsBets 61bd4a7943 Merge remote-tracking branch 'origin/main' into worktree-fix+audio-and-tracking-labels 2026-06-30 09:36:17 -07:00
BenStullsBets 2d54023f94 chore(i18n): re-extract en.json catalog to match per-clip affect
Keeps the translator source-of-record in sync with the manifest's English.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:33:46 -07:00
BenStullsBets bf1013be74 feat(content): per-clip right-brain feelings drawn from each clip's footage
Affect was authored per SCALE — every clip in an altitude shared the same 4
emotion words. Now every clip carries its OWN feelings, read from that specific
footage (mood, motion, light), so e.g. the violent Tarantula nebula evokes "a
violent ferment" while the dying-star Southern Ring evokes "a star's slow dying /
a luminous farewell," and a drowsing seal haul-out evokes "a sun-warmed drowse."

Adds AFFECT_CLIP (per-clip override; falls back to the scale register) covering
all 41 clips — 41 distinct feeling-sets, one per clip. Keeps each scale's
emotional family as a base and swaps in 1-2 footage-specific feelings, reusing the
existing feel.* palette where it fits and adding 19 new nuance keys (sublime,
tenderness, turbulence, radiance, mortality, poignancy, eeriness, purity,
solitude, verdancy, freshness, repose, hush, poise, flow, intricacy, spectral,
alienness, strangeness) with full es/fr/ja. 42 distinct feeling keys now in use.

Drafted by viewing each clip's footage (per-scale passes); first-pass reads that
want operator by-eye review. Data-only (build_pool_manifest.py + manifest + i18n
catalogs). Verified: 34/34 catalog+i18n+build pytest, 16/16 node unit, 4-language
parity for every clip, live render shows distinct feelings per clip (incl. new
keys) with Think/Feel overlap still 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:33:21 -07:00
BenStullsBets fa3bbef0d6 add sessions/0032/SESSION-0032.0-TRANSCRIPT-2026-06-30T09-20--2026-06-30T09-24.md + replace placeholder/variant SESSION-0032.0-TRANSCRIPT-2026-06-30T09-20--INPROGRESS.md 2026-06-30 09:26:01 -07:00
BenStullsBets 64e8f658f6 claim human-experience-filter-art session 0032 (placeholder) + sessions.json entry 2026-06-30 09:21:27 -07:00
BenStullsBets 760fdc7550 add sessions/0031/SESSION-0031.0-TRANSCRIPT-2026-06-30T09-06--2026-06-30T09-11.md + replace placeholder/variant SESSION-0031.0-TRANSCRIPT-2026-06-30T09-06--INPROGRESS.md 2026-06-30 09:13:37 -07:00
BenStullsBets e62382c5e5 claim human-experience-filter-art session 0031 (placeholder) + sessions.json entry 2026-06-30 09:08:16 -07:00
BenStullsBets 041fcdeb7d fix(sim): keep Feel (affect) words from overlapping Think (label) chips
Denser left labels (now >=3/clip) plus the always-4 right affect words made the
two HUD layers collide. renderAffect now nudges each Feel word vertically off the
Think chip plates (and off already-placed words + the ANALYSIS status tag),
staying on-screen, keeping the least-overlapping spot if nothing is fully clear.

Feelings are scene-level (soft position) so we move THEM, never a chip off its
subject. Obstacles are computed from the manifest, not the DOM, and a tracked
label's path is sampled across the loop — so the clearance holds for the whole
loop even though the per-frame track re-render keeps moving the chips (no per-frame
re-layout, no jitter). New diagnostic seam window.__hefNoDeconflict (sibling of
__hefState) A/B-toggles it.

Verified across all 6 scales at max knobs: authored overlaps drop to 0 with none
introduced (cosmos 3->0), and a tracked abyss clip stays at 0 overlaps sampled
over loop time as its chip drifts. No affect-position test exists to break;
16/16 node unit + frontend pytest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:06:36 -07:00
BenStullsBets 31956bfdfc add sessions/0030/SESSION-0030.0-TRANSCRIPT-2026-06-30T08-10--2026-06-30T08-50.md + replace placeholder/variant SESSION-0030.0-TRANSCRIPT-2026-06-30T08-10--INPROGRESS.md 2026-06-30 08:53:05 -07:00
BenStullsBets 5a08d1dd35 claim human-experience-filter-art session 0030 (placeholder) + sessions.json entry 2026-06-30 08:50:53 -07:00
BenStullsBets 432fbd7703 add sessions/0029/SESSION-0029.0-TRANSCRIPT-2026-06-30T08-09--2026-06-30T08-41.md + replace placeholder/variant SESSION-0029.0-TRANSCRIPT-2026-06-30T08-09--INPROGRESS.md 2026-06-30 08:43:06 -07:00
BenStullsBets 4e4a5c256d Merge remote-tracking branch 'origin/main' into worktree-fix+audio-and-tracking-labels 2026-06-30 08:36:40 -07:00
BenStullsBets 8cfceffb76 feat(content): enrich left (Think) labels to >=3/clip + motion-track abyss creatures
Every clip now carries at least 3 factual LEFT annotations to match the 4 RIGHT
(Feel) affect words — fixes the "many clips missing Think labels" asymmetry
(right read "everywhere", left felt sparse). Adds one researched factual label to
each of the 23 thin clips (orbit_bluemarble had only 1; 22 had 2), with full
es/fr/ja parity via the catalogs, and converts the 4 still-static abyss creature
subjects (octopus, bigfin squid, dandelion siphonophore, sea pig) from fixed
boxes to 3-keyframe motion-tracked labels (tracked clips 7 -> 11).

Boxes/tracks are a first pass sampled by eye from base footage (t=0.05/0.5/0.95);
they want operator by-eye review in author mode to fine-tune positions. Remaining
static clips are landscapes (correctly static) plus 2 diffuse fish schools and 2
surface-animal scenes, available for author-mode tracking if wanted.

Data-only change (build_pool_manifest.py LABELS + manifest + i18n catalogs); no
app/engine code touched. Verified: 43/43 manifest+catalog+i18n pytest, 16/16 node
unit, live render shows 3 chips/clip in all 4 languages with no JS errors. (The 3
failing pytest are pre-existing cv2/ffmpeg-absent env limits, unrelated.)

Note: the reported "audio not playing" was a false alarm — exhaustive repro
(Chromium+WebKit, strict autoplay, both servers, RMS-verified non-silent files)
found the audio engine correct; the operator's Safari output was muted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:36:05 -07:00
BenStullsBets 5f8b8f14e6 claim human-experience-filter-art session 0029 (placeholder) + sessions.json entry 2026-06-30 08:12:39 -07:00
BenStullsBets 59c1c3a895 add sessions/0028/SESSION-0028.0-TRANSCRIPT-2026-06-30T06-53--2026-06-30T07-21.md + replace placeholder/variant SESSION-0028.0-TRANSCRIPT-2026-06-30T06-53--INPROGRESS.md 2026-06-30 07:23:18 -07:00
benstull 582183db2f Merge pull request 'fix(sim): kill the reverse-landing frame jump (D3)' (#30) from fix/d3-reverse-landing-jump into main 2026-06-30 14:17:56 +00:00
BenStullsBets c13ce36787 fix(sim): kill the reverse-landing frame jump (D3)
Ascending the altitude dial and landing on the higher-altitude clip jumped ~3
seconds. A baked morph `transitions/{h}__{l}.mp4` spans src@0 (frac 0) ->
dst@~LOOP_TAIL_S (frac 1) and is scrubbed bidirectionally, but the steady loop
element always seeked LOOP_TAIL_S (3.0s) on landing. Descending lands on dst at
its tail (matches the seek — the s0026 loop-from-tail fix); ascending lands on
src at the morph's HEAD (src@0), so seeking the tail was a 3s jump.

Make the loop landing frame direction-aware: pure `HEFScrub.loopLandFrame(dir,
loopTailS)` returns 0 when ascending, loopTailS otherwise. `loadLoop` takes a
`landFrame`, stores it as `dataset.loopStart`, and the timeupdate/ended wrap
handlers honor it (the base clips are already seamless crossfade-loops, so a
reverse landing wraps [0,dur] cleanly). The settle path lands explicitly on the
direction's frame so a fast ascent never jumps even if the heading clip wasn't
preloaded in time. App-side only — no 154-morph LFS re-bake.

Verify: node unit (loopLandFrame) + a new reverse-landing e2e asserting the loop
anchors at the head (loopStart "0"), and the existing forward "loops from tail"
e2e still green (12/12 altitude-lock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:16:47 -07:00
BenStullsBets 21830069ad claim human-experience-filter-art session 0028 (placeholder) + sessions.json entry 2026-06-30 06:56:42 -07:00
BenStullsBets 50366c056e add sessions/0027/SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--2026-06-30T06-47.md + replace placeholder/variant SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--INPROGRESS.md 2026-06-30 06:49:13 -07:00
BenStullsBets 934f60cbd2 Merge feat/left-brain-annotations: factual labels + localization for 28 pool clips 2026-06-30 06:44:37 -07:00
BenStullsBets 7995ba4069 feat(content): left-brain factual labels for the 28 pool clips that lacked them
Every rotating-pool clip shipped with right-brain `affect` (auto-generated per
scale by _affect_for_clip) but only 13 carried LEFT-brain factual `LABELS`. The
other 28 rendered with no detected.*/measure.* labels at any Left knob level.

Authors static tiered labels (general → specific → scientific → +fact) for all
28 — 5 cosmos, 4 orbit, 8 sky, 5 coast, 2 reef, 4 abyss — in build_pool_manifest.py
following the existing static_label pattern, plus their es/fr/ja translations in
tools/i18n/catalogs (binomials/measurements kept verbatim; units localized per
each catalog's convention). Rebuilds the manifest and merges all four languages.

Result: 41/41 clips now have left+right annotations with full en/es/fr/ja parity.

Labels are static (footage not eyeballed); the abyss/reef creature clips could
later be upgraded to tracked labels via author mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 06:44:36 -07:00
BenStullsBets 7bd6c9a57b Merge fix/boot-silent-no-autostart: boot silent, no un-gestured auto-start 2026-06-30 06:32:12 -07:00
BenStullsBets e5fe00e948 fix(sim): boot silent (video off, audio 0) — no un-gestured auto-start
The post-load autoStart() flipped Video on and lifted audio to 3/10 with no
user gesture. Browser autoplay policy blocks an audio play() made outside a
gesture (muted video survives, sound does not), so auto-start showed video but
stayed silent. Boot SILENT instead and wait for the operator to turn Video on —
that click IS the gesture, and its handler couples audio to 3/10 and starts the
soundtrack in-gesture, so sound reliably comes up with video.

Updates the pytest e2e + Playwright e2e specs to assert boot-silent + the
video-on-couples-audio wiring instead of the old auto-start wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 06:31:38 -07:00
19 changed files with 10375 additions and 2902 deletions
@@ -0,0 +1,94 @@
# Session 0027.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-29T22-04 (PST)
> End: 2026-06-30T06-47 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 3e51cce1-9023-4a16-b607-99894dfe0597
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **FINALIZED**
## Launch prompt
```
Add left and right brain annotations (including localization) for any that are missing.
```
## Plan
> Anchor: operator direct instruction (standalone leaf content-fill task, §4.3 R2b).
**Goal:** Author the missing **left-brain** factual `LABELS` (tiered general→scientific→+fact
labels) for the rotating-pool clips that lack them, plus their **es/fr/ja** translations,
then rebuild + merge the manifest. Right-brain `affect` is auto-generated per scale for
every clip via `_affect_for_clip` — nothing missing there.
## Pre-state
- `main` at `5beee55` locally but **diverged from `origin/main`**: 11 merged-but-unpushed
commits (the entire i18n localization feature + a video-loop safety-net fix) stranded
locally; `origin/main` still at `eff43bd` (PR #29). Plus 3 files with an uncommitted
"boots-silent / no auto-start" change. Two stale `--INPROGRESS` placeholders (0015, 0017).
- Annotation reality (from a read-only survey of `build_pool_manifest.py` + catalogs):
right-brain `affect` present on all 41 clips (auto-per-scale); left-brain `LABELS`
present on only 13 of 41. 28 pool clips had `"annotations": []`.
## Arc
1. **Scoped the task** — explored the annotation/i18n system; established that the gap was
left-brain only (right-brain is auto-generated), 28 clips, + their es/fr/ja strings.
2. **Claimed session 0027** (peek showed the two stale placeholders; noted + proceeded).
3. **Surfaced the unclean baseline**; operator chose *"Push stranded, also commit dirty."*
- Stashed the 3 dirty files, merged `origin/main`'s claim commit, **pushed the 11
stranded commits** (`020219f`).
- Committed the boots-silent change on `fix/boot-silent-no-autostart`, verified
(299 pytest pass), **merged + pushed** (`7bd6c9a`).
4. **Annotation work** on `feat/left-brain-annotations`:
- Added 28 `LABELS` entries (static tiered labels) to `build_pool_manifest.py`
(Round-5 block); rebuilt manifest → all 41 clips annotated; extracted en catalog.
- Dispatched **3 parallel translator subagents** (es/fr/ja) for the 67 new keys,
matching existing catalog voice; fixed 2 ja `measure.*` values to the `約`/localized
convention; merged fragments into catalogs; merged all 3 langs into the manifest.
- Verified: 41 clips × en/es/fr/ja full key + tier-length parity; 299 pytest pass
(3 pre-existing `cv2` env failures only); 37 i18n/manifest tests green; spot-checked
translations. **Merged + pushed** (`934f60c`), deleted both branches.
## Cut state
- `main` == `origin/main` @ `934f60c`, clean tree, branches removed.
- 41/41 clips carry left+right annotations with full en/es/fr/ja parity.
- §9: local tests green. No PPE/E2E machinery exists for this app yet (pipeline is policy,
not automated — §10.6); prod promotion stays operator-gated. Visual review deferred to
operator (no Chrome on box).
## Operator plate
- **Review by eye** the 28 newly-labelled clips across all 4 languages in the running sim.
- The labels are **static**; the abyss/reef *creature* clips would benefit from a later
**tracked-label** upgrade via author mode (couldn't auto-file as a tracker issue — the
capture token returned 401 in this env; full context is in the session-0027 memory note).
- A few species IDs were inferred from clip META, not the frame (`reef_redsea`→anthias,
`reef_flowergarden`→jack/scad+sergeant-major) — worth confirming at review.
- Two stale `--INPROGRESS` sessions (0015, 0017) remain unfinalized — likely the crashed
sessions that stranded the 11 commits; left untouched for the operator.
## Next-session prompt
```
/goal Review the new left-brain annotations (all 28 newly-labelled pool clips, across en/es/fr/ja) by eye in the running simulator; correct any species/box that reads wrong, and decide whether the abyss/reef creature clips should get tracked labels via author mode.
```
## Deferred decisions
- **Static labels, not tracked.** Authored all 28 clips' left-brain labels as fixed-box
`static_label`s rather than motion-tracked labels. Alternative: tracked labels (as the
reef/abyss showcase clips use) so the box follows drifting creatures. Chose static because
the footage wasn't eyeballed — fabricating track coordinates would claim precision I don't
have. The abyss/reef creature clips are the ones that would most benefit from a later
tracked-label upgrade via author mode.
- **Some species IDs inferred from clip metadata, not the footage.** `reef_redsea`→anthias,
`reef_flowergarden`→jack/scad + sergeant-major — read from the clip META title/source.
High-confidence from provenance, but worth an eyeball check.
- **`O-type` kept verbatim in es/fr/ja** (per the translation brief) rather than the more
natural `tipo O` / `type O`. Minor; flagged by the es translator.
@@ -1,28 +0,0 @@
# Session 0027.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-29T22-04 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 3e51cce1-9023-4a16-b607-99894dfe0597
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0027 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--<end>.md form at session end.
## Launch prompt
```
Add left and right brain annotations (including localization) for any that are missing.
Resolved scope: right-brain `affect` is auto-generated per scale for every clip (nothing missing). The gap is left-brain `LABELS` (factual/scientific tiered labels) on 28 rotating-pool clips that have no entry in simulator/build_pool_manifest.py, plus their es/fr/ja translations in tools/i18n/catalogs. Author them following the existing static_label pattern, rebuild the manifest, merge translations, run tests.
```
## 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,125 @@
# Session 0028.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T06-53 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: d9da7994-e036-4b7c-b2d4-d2675ac4e5a1
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **FINALIZED.**
> Worktree: `worktree-session-0028` (isolated; concurrent session in flight). Torn down at finalize.
> Outcome: D3 reverse-landing frame jump FIXED, app-side. PR #30 → main (`582183d`).
## Launch prompt
`/goal next` — operator then directed: "There's a concurrent session — do this one out
of a worktree in a different dir." Session 0028 runs isolated in worktree
`worktree-session-0028` while a concurrent session owns the simulator startup ("Run
simulation" button: `index.html`/`style.css`/`i18n.js`).
## Plan
> Anchor: leaf bug — D3 reverse-landing frame jump (no tracker issue; the s0027 capture
> token returned 401. Residual flagged in the altitude-morph-and-scrub memory note +
> scrub-driven-transitions design.)
**Goal:** kill the ~3-second frame jump when ASCENDING the altitude dial and landing on
the higher-altitude clip.
**Root cause (verified by reading the bake + player):** each baked morph
`transitions/{h}__{l}.mp4` spans `src(h)@0s` (frac 0) → `dst(l)@~LOOP_TAIL_S` (frac 1),
and the scrub uses this single forward file in BOTH directions. The steady loop element
always seeks to `LOOP_TAIL_S` (3.0s) on landing. So:
- DESCEND (dir>0) lands on `dst` at frac 1 → morph ends `dst@3` ≈ loop@3 → seamless ✓
(the s0026 "loop-from-tail" fix).
- ASCEND (dir<0) lands on `src` at frac 0 → morph shows `src@0`, but loop seeks `src@3`
→ ~3s jump ✗ (D3).
**Fix (app-side, no media re-bake — beats re-baking 154 LFS morphs):** make the loop
landing frame DIRECTION-AWARE. On a reverse landing seek the loop to 0 (= the morph's
frac-0 frame) and wrap `[0,dur]` (the base is already a seamless crossfade-loop); forward
keeps `[LOOP_TAIL_S,dur]`. Pure decision extracted to `scrub.js loopLandFrame(dir,
loopTailS)` + node tests; wired through `loadLoop`/settle/wrap handlers in `app.js`.
**Tasks:**
1. TDD `HEFScrub.loopLandFrame(dir, loopTailS)` in `scrub.js` (+ `scrub.test.js`).
2. Wire `app.js`: `loadLoop(clip, landFrame)`, directional settle seek, `dataset.loopStart`
in the `timeupdate`/`ended` wrap handlers.
3. Add a reverse-landing e2e (mirror of the existing forward "loops from tail" test).
4. Verify: node unit tests + pytest + e2e (best-effort) all green.
5. Branch → PR → merge (yolo). PPE/E2E machinery not built for this app (§10.6) — local
green is the bar; prod promotion stays operator-gated.
## 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._
- **Fix shape: app-side direction-aware loop landing, NOT a 154-morph re-bake.** The
s0026 memory note guessed the D3 fix would be "per-clip loop files." Reading the bake +
player showed the root cause is simpler: the morph's frac-0 frame is `src@0` but the
loop always seeked the tail (`LOOP_TAIL_S`). Fixed entirely in `app.js`/`scrub.js` by
landing the loop on the morph's actual frac-0 frame (0) when ascending. This avoids
re-baking 154 LFS morphs (no media churn, a tiny reviewable diff, the base clips are
already seamless crossfade-loops so `[0,dur]` wraps cleanly). Alternative considered:
re-bake morphs to start at `src@LOOP_TAIL_S` — heavier, no benefit.
- **Pre-existing e2e failure left as-is:** `loop-recovery.spec.ts` fails in this
environment on the CLEAN baseline too (verified by stashing my changes). It boots
without enabling video, so the loop's `loopTail` is never armed and the `ended` guard
returns early. Not introduced here; my change preserves that guard exactly. Worth a
separate look (the test likely needs to `enableVideo` first), but out of scope for D3.
- **Could not verify by eye.** The fix is logic- and test-verified (node + the new
reverse-landing e2e asserting the loop anchors at the head, not the tail); the actual
visual seamlessness of an ascending landing still wants an operator eyeball in the
running sim.
## Session arc
1. Claimed session **0028** (race-free, via git). Two stale `--INPROGRESS` placeholders
(0015, 0017) noted as orphaned, left untouched.
2. Baseline survey surfaced uncommitted `run-sim` button edits + main behind by 2
(session commits). Operator confirmed a **concurrent session** owns that startup work
and directed isolation → created worktree `worktree-session-0028` off `origin/main`,
leaving the concurrent session's edits in the canonical clone untouched.
3. Orientation: in-repo `docs/ROADMAP.md` is stale (formal frontier = deferred hardware);
live frontier is in memory. Stored `/goal next` (0027's by-eye annotation review) is
operator-eyes work I can't complete; the run-sim button is the concurrent session's.
Asked the operator to pick 0028's disjoint item → **D3 reverse-landing fix**.
4. Read the bake (`build_pool_manifest.transition_cmd`) + player (`app.js` loop/morph) to
pin the root cause (morph spans `src@0``dst@tail`; loop always seeks the tail).
5. TDD: `HEFScrub.loopLandFrame` (red → green), then wired `app.js`
(`loadLoop(clip,landFrame)`, `dataset.loopStart`, directional settle, wrap handlers).
6. Added a reverse-landing e2e. Verified: node 16/16, **altitude-lock e2e 12/12** (forward
no-regression + new reverse test), pytest 299 pass. Confirmed `loop-recovery.spec.ts`
fails on the clean baseline too (pre-existing, not mine) by stashing.
7. Committed → PR #30 → merged to `main` (`582183d`) → deleted branch. Updated memory.
## Cut state
- **On `main` (`582183d`):** D3 fix — `simulator/static/scrub.js` (+`loopLandFrame`),
`simulator/static/app.js` (direction-aware loop landing), `simulator/unit/scrub.test.js`,
`simulator/e2e/tests/altitude-lock.spec.ts` (reverse-landing test).
- **Tests:** node 16/16; altitude-lock e2e 12/12; pytest 299 pass / 3 pre-existing
env-only failures (real-ffprobe/ffmpeg + ML detect; untouched — no Python changed) / 4
skipped. `loop-recovery.spec.ts` is a pre-existing baseline failure in this env.
- **§9 / deploy:** this whole project is **exempt** from flotilla/PPE/§9 (memory:
project-exempt-from-wiggleverse-deploy; public target is static Cloudflare). Local-green
is the bar — met. No PPE stage to run.
- **No plan artifact archived:** fused leaf fix, plan inline in this transcript; app has
**no content repo** (`CONTENT_REMOTE` empty) — nothing to archive.
## Operator plate
- **Review by eye** an ASCENDING altitude landing in the running sim — confirm the
~3s reverse-landing jump is gone (logic/test-verified only; no Chrome on box here).
- Still standing from s0027: by-eye review of the 28 left-brain annotations (4 langs);
decide on tracked labels for abyss/reef creature clips.
- A concurrent session was shipping the "Run simulation" startup button — reconcile/land
that separately (its edits are in the canonical clone, not touched here).
## Next-session prompt
```
/goal Review by eye in the running simulator: (1) an ASCENDING altitude landing — confirm the D3 reverse-landing frame jump is gone; (2) the 28 left-brain annotations across en/es/fr/ja, correcting any species/box that reads wrong; then decide whether the abyss/reef creature clips should get tracked labels via author mode.
```
@@ -0,0 +1,76 @@
# Session 0029.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T08-09 (PST)
> End: 2026-06-30T08-41 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Branch: design/cloudflare-static-publish
> Status: **FINALIZED**
## Launch prompt
"We need to make sure credits for Creative Commons licensed videos are compliant.
What are some options?" → after presenting options the operator chose **Option A**
(manifest-driven static credits/colophon page surfaced to the public) + **Option D1**
(declare the altered footage CC BY-SA 4.0). Non-commercial.
## Pre-state
- Branch `design/cloudflare-static-publish` (static-publish work, pushed). Working
tree had uncommitted, unrelated in-flight work: a "Run simulation" start button
(6 files) **plus** an `r2-cors.json` reformat (surfaced via stash, 7 total).
- Per-clip `license`/`source` already in `clips.json` (baked from `/api/clips`) but
only shown in the dev panel + internal review pages — public experience showed none.
- A concurrent session was live in worktree `fix+audio-and-tracking-labels` (off main);
the operator was also committing directly to `design/cloudflare-static-publish`.
## Arc
1. **Audited** the license surface: `tools/licensing.py`, `pipeline/provenance.py`,
manifest, frontend. Found credits exist in data + review tooling but not the public
experience → that's the gap. Presented options AD; operator chose A + D1.
2. **Claimed session 0029** (planning-and-executing, yolo). Two stale `--INPROGRESS`
placeholders (0015, 0017) noted as long-dead, left alone.
3. **Operator-requested pre-work:** the uncommitted in-flight work. Stashed it,
created an isolated worktree (`.claude/worktrees/session-0029` off the branch HEAD,
since the static work lives there not on main), applied the stash.
- Discovered the branch had advanced under me to `8cc472c` (operator's direct
commit). It **intentionally** keeps `r2-cors.json` in S3-array form alongside a
new `r2-cors.wrangler.json` (runbook documents both) → **dropped** my conflicting
`r2-cors.json` reformat commit; kept + rebased only the Run-simulation button.
- Committed the button (`3a45e33`), ff-merged + pushed.
4. **Built Option A + D1** (TDD): node test for the pure credits logic first (red) →
`credits.js` (UMD: `creditEntries`/`creditsListHtml`, escaped, sorted) → `credits.html`
colophon (D1 CC BY-SA declaration + "altered" note; JS-filled video attributions;
PD/CC0 audio courtesy block; back link) → header "ⓘ Credits" link (`credits.link`
i18n en/es/fr/ja) → `style.css``PUBLIC_ASSETS` in `build_static.py` + its test →
Playwright e2e (`tests/test_e2e_credits.py`).
5. **Verified:** node 28/28, pytest 315 passed / 2 skipped. Real build render: 41/41
clips credited, all 7 CC-BY/BY-SA clips show required attribution, 0 missing.
Committed (`1c7f2b7`), ff-merged + pushed, removed the worktree, deleted the branch.
## End state
- `design/cloudflare-static-publish` @ `1c7f2b7`, clean, pushed (origin in sync).
- Public site now attribution-compliant: credits reachable from the work; altered-work
license declared. §9 is **exempt** for this project (static Cloudflare target), so
merged+pushed to the design branch is the completion state — no PPE/prod.
## Deferred decisions
- **Dropped my `r2-cors.json` reformat** rather than merging it — concluded from the
operator's `8cc472c` + runbook that the S3-array form is intentional. (Alternative:
keep both forms / ask. Chose to honor the documented two-file split.)
- **Audio credits as a static courtesy block** in `credits.html` rather than plumbing
`audio_license` through the manifest/API. Rationale: audio is all PD/CC0 (no legal
attribution duty) and not machine-readable; data-plumbing was out of scope. (Alternative:
add audio license fields — deferred, low value for non-commercial PD/CC0.)
## Next /goal
```
/goal operator by-eye review of the credits colophon + Run-simulation button on design/cloudflare-static-publish, then the actual Cloudflare deploy (provision R2 human-experience-simulator-media + CORS, run tools.build_static, Pages deploy) per deploy/cloudflare/README.md
```
@@ -0,0 +1,128 @@
# Session 0030.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T08-10 (PST)
> End: 2026-06-30T08-50 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c4cec6c6-26d8-46f3-80ee-4ff4035a4f72
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Date: 2026-06-30
> Goal: Add an obvious "Run simulation" start button to the simulator's main
> screen (shown after the "Loading Universe…" preload); it turns Video on and
> sets Audio to level 2. Turning Video on directly should also start the
> experience, snap Audio to 2, and dismiss the button. Move the default
> first-Video-on audio coupling from 3/10 → 2/10.
> Outcome: Built + verified (303 pytest, 8 pytest-playwright incl. 3 new, 12
> Playwright TS incl. 1 updated + 1 new, node unit tests green). Work committed
> as 3a45e33 and pushed to design/cloudflare-static-publish — **the commit was
> landed by a CONCURRENT session (0029)** that swept up this session's
> working-tree changes while doing the CC-credits/Cloudflare-publish work.
## Plan
1. Map the splash/loading flow and the Video/Audio toggle wiring (Explore agent
+ direct reads of `simulator/static/{index.html,app.js,style.css,i18n.js}`).
2. Add a `#run-sim` button inside the stage `.screen`, hidden until media
preloads, styled to sit above the black cover (`z-index: 60`).
3. Add a `beginExperience()` helper: Video on + audio snapped to a
`START_AUDIO_LEVEL = "2"` + button dismissed + soundtrack played in-gesture
(Safari unlock). Wire it to both the button click and the first direct
Video-on (`!videoEverOn`).
4. Reveal the button after `hideLoading()`; change the old 3/10 coupling → 2/10.
5. Localize `run.button` in en/es/fr/ja (4-lang parity invariant).
6. Update E2E coverage (pytest-playwright + Playwright TS) from the old
"couples to 3" assertions to 2; add button-shown / button-starts /
direct-video-on tests.
7. Run the full suite + confirm a pre-existing failure is not mine.
## Pre-session state
- Branch `main` clean at `934f60c` (left-brain annotations merged). Simulator
boots SILENT (video off, audio 0) and waited for the operator to flip the
unlabelled **Video** toggle as the start gesture — not obvious to a first-time
viewer. First-Video-on coupled audio to **3/10**.
- No session-init gate was run at the top of this session — it was treated as a
small, fully-specified frontend change (see Deferred decisions).
## Turn-by-turn arc
1. **Explore + direct reads.** Dispatched an `Explore` subagent to map the
splash + AV-toggle code; in parallel read `index.html`, `app.js`
(`main()`/`hideLoading()`/`beginExperience` region, the audio dial, the
`videoEverOn` coupling), `style.css` (`#loading`, `.black z-index:50`,
`.hidden`), and `i18n.js` (UI_STRINGS shape, 4 languages). Confirmed the
stage `.screen` is `position: relative` and the black cover is `z-index: 50`,
so a start button needs `z-index > 50` inside the screen and the side panel
(Video toggle) stays reachable.
2. **Implement.** Added `#run-sim` to the stage; `.run-sim` CSS (centered pill,
`z-index: 60`); `run.button` i18n string (en/es/fr/ja); `beginExperience()`
unifying both entry paths; revealed the button after `hideLoading()`;
simplified the Video-toggle handler to call `beginExperience()` on first
on; changed coupling 3 → 2.
3. **Update tests.** Replaced the old `test_video_on_couples_audio_to_three…`
(pytest) and the "couples audio to 3/10" Playwright TS test with 2/10
variants; added button-shown-after-load, button-starts-video+audio, and
direct-Video-on-dismisses-button cases on both harnesses.
4. **Verify.** `303 passed, 2 skipped` (non-E2E pytest); `8 passed`
pytest-playwright (incl. 3 new); `12 passed` Playwright `altitude-lock.spec`
(incl. updated + new). Node unit tests (i18n / index-i18n / scrub) green; the
i18n completeness invariant confirms `run.button` has all 4 langs.
5. **Isolate a failure.** `loop-recovery.spec.ts` failed; stashed only the
source files and re-ran on a clean baseline — it **fails identically without
my changes**, so it's pre-existing, not introduced here. (During that stash
round-trip a `test-results/.last-run.json` artifact briefly blocked
`git stash pop`; recovered by `git checkout`-ing the artifact and popping —
no source lost.)
6. **Handed the working tree back uncommitted** for an operator by-eye review
(the project's usual cadence — no Chrome on the box). Offered to branch + PR.
## Cut state (end of session)
| Item | State |
| --- | --- |
| Run-simulation button + audio-2 change | Committed `3a45e33` on `design/cloudflare-static-publish`, **pushed** |
| Committed by | Concurrent **session 0029** (swept up this session's working-tree changes) |
| Files | `simulator/static/{index.html,app.js,style.css,i18n.js}`, `simulator/e2e/tests/altitude-lock.spec.ts`, `tests/test_e2e_audio.py` |
| This session's working tree | Clean; nothing of mine left to commit/push/merge |
| Branch `design/cloudflare-static-publish` | Not merged to `main` — intentional; awaits operator review + Cloudflare deploy (session 0029's frontier) |
| Deploy pipeline (§9) | **N/A** — project is exempt (static Cloudflare target; see memory `project-exempt-from-wiggleverse-deploy`) |
| Verification | 303 pytest + 8 pytest-playwright + 12 Playwright TS + node units green; `loop-recovery.spec.ts` red **pre-existing** |
## What lands on the operator's plate
- **By-eye / by-ear review** of the Run-simulation button in a real browser —
especially the **in-gesture audio unlock on real Safari/iOS** (headless tests
relax autoplay, so it's wired-but-unconfirmed on a real device).
- The button work lives on `design/cloudflare-static-publish` alongside the
CC-credits + Cloudflare-publish work; it merges to `main` / ships when that
branch does (session 0029's frontier), not separately.
- Note the concurrency: this session authored the change; session 0029 committed
it. Two transcripts (0029, 0030) touch the same single commit `3a45e33`
expected, not a duplicate of the commit.
## Prompt the operator can paste into the next session
```
/goal operator eyeball review of the static build (incl. the new Run-simulation button) + execute the real Cloudflare deploy to benstull.art/human-experience-simulator, per the cloudflare-static-publish design on branch design/cloudflare-static-publish
```
## Deferred decisions
_Autonomous-mode low-confidence calls; surfaced at finalize._
1. **No session-init gate at the top.** Treated the task as a small,
fully-specified frontend tweak and implemented directly without claiming a
transcript first. Consequence: the work landed on a shared checkout and was
committed by a concurrent session (0029) rather than carrying its own
end-to-end branch→PR. Alternative: claim a session + isolated worktree up
front. Flagged because shared-checkout work is what the worktree rule guards
against.
2. **`beginExperience()` snaps audio to 2 unconditionally** on first Video-on,
overriding a viewer who pre-set a higher level before pressing start /
flipping Video. Matched the operator's explicit "snap to audio level 2"
wording; alternative was to only raise from 0 (the prior 3/10 behavior). Easy
to soften to "only if currently 0" if undesired.
3. **Real-device audio autoplay unverified.** Headless Chromium ran with
`--autoplay-policy=no-user-gesture-required`, masking real Safari/iOS
behavior; the in-gesture unlock is wired but needs a by-ear check.
@@ -0,0 +1,80 @@
# Session 0031.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T09-06 (PST)
> End: 2026-06-30T09-11 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Branch: design/cloudflare-static-publish
> Status: **FINALIZED**
## Launch prompt
Follow-up to session 0029's credits work. Operator's first ask was to rename the
credits URL → "about" (one page holding both about + credits) and add an
Accessibility table. Mid-clarification the operator redirected: **keep About and
Credits separate** (the About page is owned by a parallel session) and simply
**"Remove the 'About this work' from the credits page. I'll merge that into the
About page on the other session."**
## What I found before acting
- The Cloudflare site is now **LIVE** (`benstull.art/human-experience-simulator`);
the branch had advanced several commits since s0029 (live deploy, progressive
boot, an audio mediaBase fix), and moved again mid-session (`a68d0bc``d10ffca`).
- A **live concurrent session** owns `feat/accessibility-pass` with an
approved-pending design spec (`docs/superpowers/specs/2026-06-30-accessibility-and-about-page-design.md`):
a full WCAG 2.1 AA pass + a **separate `about.html`**. I surfaced the overlap with
the operator's original (one-page) ask; the operator resolved it by keeping the
pages separate and narrowing my task to the section removal.
## Arc
1. Surveyed git + the concurrent worktrees; read the a11y design spec.
2. Started to raise a coordination question; operator interrupted with the narrow
instruction (remove "About this work" from credits; they move it to About).
3. Claimed **session 0031**, isolated worktree off the live branch tip (`d10ffca`)
— only `credits.html` + its tests touched, so no overlap with the a11y session's
files (`index.html`/`style.css`/`app.js`/`about.html`).
4. Removed the `<section class="declaration">` "About this work" block from
`credits.html`; rewrote the footage note to be self-contained ("All footage is
altered in real time in the experience" — keeps the per-clip modification cue,
drops the dangling "as described above").
5. Updated tests to the new intent: `credits.test.js` and `tests/test_e2e_credits.py`
now assert the page notes footage is altered but the **CC BY-SA 4.0 declaration is
NOT** on credits (it moved to About).
6. Left the `.declaration` CSS rule in `style.css` (the About page reuses these styles).
7. Verified: node 28/28, pytest 315 passed / 2 skipped. Committed `8277417`,
ff-merged + pushed, removed the worktree, deleted the branch.
## End state
- `design/cloudflare-static-publish` @ `8277417`, clean, pushed.
- Credits page = per-clip attributions + "footage altered" note + audio courtesy
block. The CC BY-SA work-license declaration (D1) is **no longer on credits**
it now belongs on the About page (operator + `feat/accessibility-pass` own it).
- Site is live; this change reaches `benstull.art` only after a **redeploy**.
## Deferred decisions
- **Kept a brief "footage is altered" note on the credits page** rather than
stripping all alteration mention. Rationale: CC BY/BY-SA attribution wants the
modification indicated alongside the credit; the full license declaration is what
moved to About. (Alternative: remove every alteration mention from credits —
judged worse for the per-clip attribution's "indicate changes" cue.)
- **Left `.declaration` CSS in `style.css`** though now unused on credits — the
About page (other session) will reuse it; removing it would break that page.
## Compliance note
D1 (the altered-work CC BY-SA declaration) now depends on the About page actually
carrying it. **Confirm it landed on about.html before considering D1 done** — it is
currently in neither place on the live site until the About page ships + a redeploy.
## Next /goal
```
/goal confirm the About page (feat/accessibility-pass) carries the CC BY-SA 4.0 work-license declaration (D1, moved off credits in s0031), then redeploy design/cloudflare-static-publish to benstull.art per deploy/cloudflare/README.md
```
@@ -0,0 +1,56 @@
# Session 0032.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T09-20 (PST)
> End: 2026-06-30T09-24 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Branch: design/cloudflare-static-publish
> Status: **FINALIZED**
## Launch prompt
After confirming that the "About this work" section removed in s0031 *was* the D1
compliance (the CC BY-SA share-alike declaration, not the per-clip attribution),
the operator: "Ok let's add that content back but can we name the section something
other than 'About This Work' so it isn't confused with the about page?"
## Arc
1. Confirmed for the operator that "About this work" carried D1 specifically — the
ShareAlike license declaration for the BY-SA source clips — while the per-clip
attribution (the original goal) was untouched and stayed on credits.
2. Claimed **session 0032**, isolated worktree off the branch tip (`8277417`).
3. Re-added the CC BY-SA 4.0 declaration to `credits.html` under the heading
**"License & reuse"** (deliberately not "About this work" — that name is reserved
for the separate About page from `feat/accessibility-pass`). Restored the footage
note's "altered as described above" now that the section is back above it. Reused
the `.declaration` CSS left in place by s0031.
4. Reverted the s0031 test changes: `credits.test.js` + `tests/test_e2e_credits.py`
now assert the declaration IS present, plus pin the rename (heading ≠ "About this
work", contains "License & reuse").
5. Verified: node 28/28, pytest 315 passed / 2 skipped (e2e confirms "CC BY-SA 4.0"
visible in-browser again). Committed `ee7c65a`, ff-merged + pushed, removed the
worktree, deleted the branch.
## End state
- `design/cloudflare-static-publish` @ `ee7c65a`, clean, pushed.
- Credits page now carries BOTH halves of CC compliance: per-clip CC-BY/BY-SA
attribution **and** the CC BY-SA 4.0 derivative declaration (under "License &
reuse"). The s0031 gap (live site missing the share-alike declaration) is closed.
- Site is live; reaches `benstull.art` only after a **redeploy**.
## Deferred decisions
- **Section heading "License & reuse"** chosen (operator delegated the name). It pairs
with the "Credits & licenses" page title and is unambiguous vs. the About page.
(Alternatives weighed: "This work's license", "License & alteration".)
## Next /goal
```
/goal redeploy design/cloudflare-static-publish to benstull.art (CC-BY/BY-SA credits + "License & reuse" D1 declaration now both on the credits page) per deploy/cloudflare/README.md; the About page lands separately via the feat/accessibility-pass session
```
+15
View File
@@ -79,5 +79,20 @@
},
"0027": {
"title": ""
},
"0028": {
"title": ""
},
"0029": {
"title": ""
},
"0030": {
"title": ""
},
"0031": {
"title": ""
},
"0032": {
"title": ""
}
}
+449 -4
View File
@@ -233,6 +233,268 @@ AFFECT: dict[str, list[tuple]] = {
],
}
# --- Per-CLIP affect override (feelings drawn from THAT specific footage) --------
# When a clip is here, these feelings replace the scale's shared register for it —
# tailored to what the actual video evokes (mood, motion, light), keeping the
# scale's emotional family as a base. Reuses the scale `feel.*` keys where they fit
# (their es/fr/ja already exist) and adds new keys (with catalog translations) for
# nuances a clip needs. Same tuple shape: (key, [x,y], min_level, [4 tiers]).
AFFECT_CLIP: dict[str, list[tuple]] = {
"orbit_planetearth": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.radiance", [0.22, 0.68], 2, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"orbit_bluemarble": [
("feel.unity", [0.5, 0.44], 1, ["one", "unity", "belonging", "a borderless belonging"]),
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.distance", [0.66, 0.58], 3, ["far", "distance", "remoteness", "an exquisite remoteness"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
"orbit_aurora2025": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.eeriness", [0.66, 0.58], 3, ["strange", "eeriness", "an otherworldly charge", "an electric, otherworldly hush"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"orbit_citylights": [
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.longing", [0.22, 0.68], 2, ["want", "longing", "yearning", "an aching yearning"]),
("feel.fragility", [0.66, 0.58], 3, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.unity", [0.42, 0.82], 4, ["one", "unity", "belonging", "a borderless belonging"]),
],
"orbit_helene": [
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.turbulence", [0.22, 0.68], 2, ["churn", "turbulence", "ferment", "a violent ferment"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"orbit_epic": [
("feel.distance", [0.5, 0.44], 1, ["far", "distance", "remoteness", "an exquisite remoteness"]),
("feel.insignificance", [0.22, 0.68], 2, ["small", "smallness", "insignificance", "a humbling insignificance"]),
("feel.unity", [0.66, 0.58], 3, ["one", "unity", "belonging", "a borderless belonging"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"sky_grca_templesa": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.exhilaration", [0.42, 0.82], 4, ["whee", "exhilaration", "elation", "a soaring elation"]),
],
"sky_greenland_landice": [
("feel.exhilaration", [0.5, 0.44], 1, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.purity", [0.66, 0.58], 3, ["pure", "purity", "stillness", "a glacial hush"]),
("feel.sublime", [0.42, 0.82], 4, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
],
"sky_greenland_suture": [
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.solitude", [0.66, 0.58], 3, ["alone", "solitude", "isolation", "a vast frozen solitude"]),
("feel.lightness", [0.42, 0.82], 4, ["light", "lightness", "buoyancy", "a weightless buoyancy"]),
],
"sky_jungle_amazon": [
("feel.freedom", [0.5, 0.44], 1, ["free", "freedom", "release", "a boundless release"]),
("feel.verdancy", [0.22, 0.68], 2, ["lush", "verdancy", "abundance", "a teeming green abundance"]),
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"sky_jungle_waterfall": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.vertigo", [0.22, 0.68], 2, ["whoa", "vertigo", "giddiness", "a giddy, falling vertigo"]),
("feel.freshness", [0.66, 0.58], 3, ["cool", "freshness", "vitality", "a cool, cascading freshness"]),
("feel.exhilaration", [0.42, 0.82], 4, ["whee", "exhilaration", "elation", "a soaring elation"]),
],
"sky_coast_cliffspain": [
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.exhilaration", [0.22, 0.68], 2, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.turbulence", [0.66, 0.58], 3, ["churn", "turbulence", "ferment", "a violent ferment"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
"sky_mtn_castlecrags": [
("feel.freedom", [0.5, 0.44], 1, ["free", "freedom", "release", "a boundless release"]),
("feel.serenity", [0.22, 0.68], 2, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.lightness", [0.42, 0.82], 4, ["light", "lightness", "buoyancy", "a weightless buoyancy"]),
],
"sky_mtn_rocky": [
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.vertigo", [0.22, 0.68], 2, ["whoa", "vertigo", "giddiness", "a giddy, falling vertigo"]),
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
"coast_birdrock": [
("feel.melancholy", [0.5, 0.44], 1, ["sad", "melancholy", "longing", "a soft seaward longing"]),
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.nostalgia", [0.66, 0.58], 3, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"coast_surfgrass": [
("feel.abundance", [0.5, 0.44], 1, ["full", "abundance", "richness", "a teeming richness"]),
("feel.immersion", [0.22, 0.68], 2, ["in", "immersion", "absorption", "a held, breathless absorption"]),
("feel.curiosity", [0.66, 0.58], 3, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.ease", [0.42, 0.82], 4, ["nice", "ease", "calm", "an unhurried calm"]),
],
"coast_kelp": [
("feel.immersion", [0.5, 0.44], 1, ["in", "immersion", "absorption", "a held, breathless absorption"]),
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.freedom", [0.42, 0.82], 4, ["free", "freedom", "release", "a boundless release"]),
],
"coast_otters": [
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.delight", [0.22, 0.68], 2, ["fun", "delight", "joy", "a darting, bright joy"]),
("feel.ease", [0.66, 0.58], 3, ["nice", "ease", "calm", "an unhurried calm"]),
("feel.belonging", [0.42, 0.82], 4, ["home", "belonging", "rootedness", "a tidal rootedness"]),
],
"coast_kalaloch": [
("feel.nostalgia", [0.5, 0.44], 1, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
("feel.melancholy", [0.22, 0.68], 2, ["sad", "melancholy", "longing", "a soft seaward longing"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.belonging", [0.42, 0.82], 4, ["home", "belonging", "rootedness", "a tidal rootedness"]),
],
"coast_seals": [
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.repose", [0.22, 0.68], 2, ["rest", "repose", "drowse", "a sun-warmed drowse"]),
("feel.belonging", [0.66, 0.58], 3, ["home", "belonging", "rootedness", "a tidal rootedness"]),
("feel.delight", [0.42, 0.82], 4, ["fun", "delight", "joy", "a darting, bright joy"]),
],
"coast_mist": [
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.hush", [0.66, 0.58], 3, ["quiet", "hush", "stillness", "a breath-held hush"]),
("feel.nostalgia", [0.42, 0.82], 4, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
],
"reef_lionfish": [
("feel.curiosity", [0.5, 0.44], 1, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.poise", [0.22, 0.68], 2, ["still", "poise", "grace", "a hovering, ornate poise"]),
("feel.fragility", [0.66, 0.58], 3, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"reef_spawning": [
("feel.abundance", [0.5, 0.44], 1, ["full", "abundance", "richness", "a teeming richness"]),
("feel.flow", [0.22, 0.68], 2, ["go", "flow", "streaming", "a sweeping, schooling flow"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"reef_hawkfish": [
("feel.radiance", [0.5, 0.44], 1, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.curiosity", [0.22, 0.68], 2, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.delight", [0.66, 0.58], 3, ["fun", "delight", "joy", "a darting, bright joy"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"reef_coralspacific": [
("feel.intricacy", [0.5, 0.44], 1, ["fine", "intricacy", "detail", "an intricate, woven density"]),
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.abundance", [0.66, 0.58], 3, ["full", "abundance", "richness", "a teeming richness"]),
("feel.curiosity", [0.42, 0.82], 4, ["look", "curiosity", "fascination", "an absorbed fascination"]),
],
"reef_redsea": [
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.abundance", [0.22, 0.68], 2, ["full", "abundance", "richness", "a teeming richness"]),
("feel.radiance", [0.66, 0.58], 3, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.delight", [0.42, 0.82], 4, ["fun", "delight", "joy", "a darting, bright joy"]),
],
"reef_flowergarden": [
("feel.delight", [0.5, 0.44], 1, ["fun", "delight", "joy", "a darting, bright joy"]),
("feel.abundance", [0.22, 0.68], 2, ["full", "abundance", "richness", "a teeming richness"]),
("feel.freedom", [0.66, 0.58], 3, ["free", "freedom", "release", "a boundless release"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"abyss_wow": [
("feel.vastness", [0.5, 0.44], 1, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.isolation", [0.22, 0.68], 2, ["alone", "isolation", "solitude", "a crushing solitude"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.unease", [0.42, 0.82], 4, ["uh", "unease", "disquiet", "a creeping disquiet"]),
],
"abyss_midwaterexp": [
("feel.fragility", [0.5, 0.44], 1, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.fascination", [0.22, 0.68], 2, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.poignancy", [0.66, 0.58], 3, ["ache", "poignancy", "bittersweetness", "a luminous farewell"]),
("feel.isolation", [0.42, 0.82], 4, ["alone", "isolation", "solitude", "a crushing solitude"]),
],
"abyss_hiding": [
("feel.spectral", [0.5, 0.44], 1, ["ghost", "the spectral", "hauntedness", "a ghost in the water"]),
("feel.unease", [0.22, 0.68], 2, ["uh", "unease", "disquiet", "a creeping disquiet"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"abyss_bigfin": [
("feel.alienness", [0.5, 0.44], 1, ["odd", "alienness", "strangeness", "an alien grace"]),
("feel.fascination", [0.22, 0.68], 2, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.unease", [0.66, 0.58], 3, ["uh", "unease", "disquiet", "a creeping disquiet"]),
("feel.isolation", [0.42, 0.82], 4, ["alone", "isolation", "solitude", "a crushing solitude"]),
],
"abyss_dandelion": [
("feel.radiance", [0.5, 0.44], 1, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"abyss_octopus": [
("feel.curiosity", [0.5, 0.44], 1, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.isolation", [0.22, 0.68], 2, ["alone", "isolation", "solitude", "a crushing solitude"]),
("feel.tenderness", [0.66, 0.58], 3, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.unease", [0.42, 0.82], 4, ["uh", "unease", "disquiet", "a creeping disquiet"]),
],
"abyss_seapig": [
("feel.strangeness", [0.5, 0.44], 1, ["weird", "strangeness", "the uncanny", "a fleshy strangeness"]),
("feel.unease", [0.22, 0.68], 2, ["uh", "unease", "disquiet", "a creeping disquiet"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.curiosity", [0.42, 0.82], 4, ["look", "curiosity", "fascination", "an absorbed fascination"]),
],
# cosmos — Webb "Cosmic Cliffs": monumental golden ridges, cathedral-scale.
"cosmos": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
],
# cosmos_galaxies — drifting through a dark field of galaxies: lonely, remote.
"cosmos_galaxies": [
("feel.vastness", [0.50, 0.44], 1, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.distance", [0.22, 0.68], 2, ["far", "distance", "remoteness", "an exquisite remoteness"]),
("feel.insignificance", [0.66, 0.58], 3, ["small", "smallness", "insignificance", "a humbling insignificance"]),
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
],
# cosmos_orion — soft rose-lit star nursery: tender, dreamy, warm.
"cosmos_orion": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.tenderness", [0.22, 0.68], 2, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
],
# cosmos_tarantula — chaotic, fierce star-forming nebula: turbulent, intense.
"cosmos_tarantula": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.turbulence", [0.22, 0.68], 2, ["churn", "turbulence", "ferment", "a violent ferment"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
],
# cosmos_westerlund — sparkling jewel-box star cluster: dazzling, radiant.
"cosmos_westerlund": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.radiance", [0.22, 0.68], 2, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
# cosmos_southernring — gas shed by a dying star: elegiac, mortal, poignant.
"cosmos_southernring": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.mortality", [0.22, 0.68], 2, ["end", "mortality", "impermanence", "a stars slow dying"]),
("feel.poignancy", [0.66, 0.58], 3, ["ache", "poignancy", "bittersweetness", "a luminous farewell"]),
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
],
# cosmos_carina_eso — wide teeming Milky Way star field: panoramic richness.
"cosmos_carina_eso": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.abundance", [0.66, 0.58], 3, ["full", "abundance", "richness", "a teeming richness"]),
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
],
}
def static_label(key, salience, tiers, box):
"""A fixed-box tiered label (always on-screen, salience-gated by Left)."""
@@ -269,6 +531,7 @@ LABELS: dict[str, list[dict]] = {
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
"cosmos_galaxies": [
measure("measure.count", "~2T galaxies", [0.06, 0.16, 0.24, 0.08], 2),
static_label("detected.galaxy", 4, ["galaxy", "spiral galaxy", "barred spiral", "barred spiral · ~10¹¹ stars"], [0.34, 0.30, 0.30, 0.34]),
measure("measure.distance", "~Mly", [0.06, 0.06, 0.18, 0.08], 3),
],
@@ -291,13 +554,19 @@ LABELS: dict[str, list[dict]] = {
],
"orbit_bluemarble": [
static_label("detected.globe", 4, ["Earth", "the globe", "terrestrial planet", "terrestrial planet · 12,742 km across"], [0.28, 0.18, 0.44, 0.6]),
# Rotating full-disk globe: continents drift through frame, so label the
# PERSISTENT features (clouds, starfield) with static boxes, not a continent.
static_label("detected.cloud_band", 3, ["clouds", "weather systems", "cloud systems", "global cloud systems · clouds cover ~67% of Earth"], [0.34, 0.42, 0.34, 0.32]),
static_label("detected.starfield", 1, ["stars", "starfield", "background stars", "background starfield · a rendered backdrop, not to scale"], [0.74, 0.05, 0.22, 0.30]),
],
# ---------- coast ----------
"coast_birdrock": [
static_label("detected.seabirds", 2, ["birds", "seabirds", "nesting seabirds", "seabird colony · the rookery that names the rock"], [0.35, 0.18, 0.25, 0.2]),
static_label("detected.surf", 4, ["waves", "surf", "breaking swell", "breaking swell · wind-driven, ~10 s period"], [0.20, 0.55, 0.6, 0.3]),
static_label("detected.searock", 2, ["rock", "sea stack", "coastal sea stack", "sea stack · wave-cut residual rock"], [0.30, 0.25, 0.22, 0.3]),
],
"coast_surfgrass": [
static_label("detected.tidepool", 2, ["pool", "tidepool", "intertidal pool", "tidepool · a pocket sea bared at low tide"], [0.15, 0.6, 0.4, 0.25]),
static_label("detected.surfgrass", 4, ["grass", "surfgrass", "Phyllospadix", "Phyllospadix · a marine seagrass, not algae"], [0.18, 0.40, 0.5, 0.4]),
static_label("detected.coralline", 2, ["pink", "coralline algae", "crustose coralline", "crustose coralline · calcified red algae"], [0.6, 0.55, 0.2, 0.2]),
],
@@ -336,6 +605,7 @@ LABELS: dict[str, list[dict]] = {
measure("measure.depth", "22 m", [0.06, 0.06, 0.16, 0.08], 3),
],
"reef_hawkfish": [
static_label("detected.coral", 2, ["coral", "reef coral", "hard coral", "hard coral · the reef this fish grazes into sand"], [0.1, 0.6, 0.55, 0.3]),
tracked_label(
"detected.parrotfish", 4,
["fish", "parrotfish", "Bolbometopon muricatum", "B. muricatum · humphead, grazes reef into sand"],
@@ -353,6 +623,7 @@ LABELS: dict[str, list[dict]] = {
),
],
"reef_coralspacific": [
static_label("detected.polyp", 1, ["polyps", "coral polyps", "living polyps", "polyps · each a tiny animal, the colonys builders"], [0.25, 0.4, 0.3, 0.3]),
static_label("detected.coral", 4, ["coral", "coral colony", "Pacific scleractinian", "Pacific scleractinian · a symbiosis with algae"], [0.2, 0.4, 0.4, 0.4]),
tracked_label(
"detected.spotfish", 2,
@@ -393,6 +664,7 @@ LABELS: dict[str, list[dict]] = {
measure("measure.depth", "1,600 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_hiding": [
static_label("detected.marinesnow", 1, ["specks", "marine snow", "falling detritus", "marine snow · organic debris drifting down from above"], [0.1, 0.1, 0.8, 0.6]),
tracked_label(
"detected.jelly", 4,
["jelly", "crimson jelly", "Scyphozoa", "Scyphozoa · red is invisible in the lightless deep"],
@@ -401,16 +673,189 @@ LABELS: dict[str, list[dict]] = {
),
measure("measure.depth", "2,140 m", [0.06, 0.06, 0.16, 0.08], 2),
],
# --- Round-5 left-brain fill (2026-06-30, session 0027): the pool members that
# shipped with affect (right-brain) but no factual LEFT labels. Static tiered
# labels (footage not eyeballed); the abyss/reef creature clips could later be
# upgraded to tracked labels via author mode. ---
# ---------- cosmos ----------
"cosmos_orion": [
static_label("detected.nebula", 4, ["cloud", "nebula", "emission nebula", "emission nebula · M42, the Orion star-forming region"], [0.18, 0.30, 0.6, 0.5]),
static_label("detected.star", 2, ["star", "young star", "Trapezium star", "Trapezium · hot O-stars lighting the nebula"], [0.44, 0.40, 0.14, 0.16]),
measure("measure.distance", "≈1,344 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
"cosmos_tarantula": [
static_label("detected.nebula", 4, ["cloud", "nebula", "Tarantula Nebula", "30 Doradus · the Local Groups brightest star-forming region"], [0.16, 0.28, 0.64, 0.5]),
static_label("detected.cluster", 2, ["stars", "star cluster", "R136", "R136 · packs some of the most massive stars known"], [0.42, 0.40, 0.16, 0.18]),
measure("measure.distance", "≈160,000 ly", [0.06, 0.06, 0.22, 0.08], 3),
],
"cosmos_westerlund": [
static_label("detected.cluster", 4, ["stars", "star cluster", "Westerlund 2", "Westerlund 2 · a young cluster, ~12 Myr old"], [0.30, 0.30, 0.4, 0.4]),
static_label("detected.star", 2, ["star", "massive star", "O-type star", "O-type · blue-white, tens of solar masses"], [0.46, 0.42, 0.12, 0.14]),
measure("measure.distance", "≈20,000 ly", [0.06, 0.06, 0.22, 0.08], 3),
],
"cosmos_southernring": [
static_label("detected.nebula", 4, ["ring", "nebula", "planetary nebula", "NGC 3132 · gas shed by a dying Sun-like star"], [0.24, 0.24, 0.5, 0.5]),
static_label("detected.star", 2, ["star", "central star", "white dwarf", "white dwarf · the hot stellar core left behind"], [0.46, 0.44, 0.1, 0.12]),
measure("measure.distance", "≈2,500 ly", [0.06, 0.06, 0.22, 0.08], 3),
],
"cosmos_carina_eso": [
static_label("detected.nebula", 4, ["cloud", "nebula", "emission nebula", "emission nebula · the Carina star-forming complex"], [0.16, 0.30, 0.64, 0.5]),
static_label("detected.star", 2, ["star", "young star", "massive star", "massive star · the kind that sculpts the Carina cliffs"], [0.44, 0.40, 0.14, 0.16]),
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
# ---------- orbit ----------
"orbit_aurora2025": [
static_label("detected.aurora", 4, ["glow", "aurora", "the aurora", "aurora · solar particles exciting upper-atmosphere oxygen"], [0.10, 0.45, 0.8, 0.35]),
static_label("detected.limb", 2, ["edge", "Earths limb", "atmospheric limb", "atmospheric limb · ~100 km of air, glowing on edge"], [0.05, 0.66, 0.9, 0.12]),
measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3),
],
"orbit_citylights": [
static_label("detected.citylights", 4, ["lights", "city lights", "urban grid", "urban grid · sodium/LED glow tracing the coastline"], [0.20, 0.45, 0.6, 0.4]),
static_label("detected.airglow", 2, ["band", "airglow", "atmospheric airglow", "airglow · the faint nighttime emission of the upper air"], [0.05, 0.30, 0.9, 0.1]),
measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3),
],
"orbit_helene": [
static_label("detected.hurricane", 4, ["storm", "hurricane", "tropical cyclone", "tropical cyclone · a warm-core spiral, eye at its center"], [0.22, 0.30, 0.5, 0.5]),
static_label("detected.limb", 2, ["edge", "Earths limb", "atmospheric limb", "atmospheric limb · the thin shell weather lives in"], [0.05, 0.10, 0.9, 0.12]),
measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3),
],
"orbit_epic": [
static_label("detected.globe", 4, ["Earth", "the globe", "sunlit disk", "full disk · the whole daylit Earth, seen from L1"], [0.24, 0.14, 0.52, 0.66]),
static_label("detected.cloud_band", 2, ["clouds", "cloud band", "weather systems", "weather systems · swirling across a turning planet"], [0.30, 0.34, 0.34, 0.26]),
measure("measure.distance", "~1.5M km", [0.06, 0.06, 0.2, 0.08], 3),
],
# ---------- sky ----------
"sky_grca_templesa": [
static_label("detected.butte", 2, ["tower", "butte", "rock temple", "rock temple · an erosional butte left standing"], [0.4, 0.3, 0.2, 0.35]),
static_label("detected.canyon", 4, ["canyon", "gorge", "the Grand Canyon", "Grand Canyon · ~1.8 Gyr of rock, cut by the Colorado"], [0.10, 0.38, 0.8, 0.52]),
static_label("detected.strata", 2, ["layers", "rock strata", "sedimentary beds", "strata · stacked epochs of deposition"], [0.15, 0.55, 0.6, 0.25]),
],
"sky_greenland_landice": [
static_label("detected.crevasse", 2, ["cracks", "crevasses", "glacial crevasses", "crevasses · the ice fracturing as it flows"], [0.2, 0.55, 0.5, 0.25]),
static_label("detected.icesheet", 4, ["ice", "ice sheet", "the Greenland ice sheet", "ice sheet · up to ~3 km thick, flowing slowly seaward"], [0.08, 0.40, 0.84, 0.5]),
static_label("detected.snow", 2, ["snow", "snowfield", "firn", "firn · old snow compacting toward glacial ice"], [0.20, 0.20, 0.6, 0.25]),
],
"sky_greenland_suture": [
static_label("detected.floe", 2, ["plates", "ice floes", "pack-ice floes", "pack-ice floes · drifting plates of frozen sea"], [0.15, 0.2, 0.3, 0.3]),
static_label("detected.seaice", 4, ["ice", "sea ice", "drift ice", "drift ice · a frozen ocean skin, cracking and refreezing"], [0.08, 0.10, 0.84, 0.8]),
static_label("detected.lead", 2, ["crack", "lead", "open lead", "lead · a fracture of open water between floes"], [0.30, 0.40, 0.4, 0.2]),
],
"sky_jungle_amazon": [
static_label("detected.mist", 1, ["haze", "canopy mist", "transpiration haze", "transpiration haze · the forest exhaling water vapor"], [0.06, 0.1, 0.88, 0.18]),
static_label("detected.canopy", 4, ["trees", "forest canopy", "the Amazon canopy", "rainforest canopy · among the densest biodiversity on Earth"], [0.06, 0.30, 0.88, 0.6]),
static_label("detected.emergent", 2, ["tree", "tall tree", "emergent tree", "emergent · a giant breaking above the canopy roof"], [0.42, 0.40, 0.16, 0.2]),
],
"sky_jungle_waterfall": [
static_label("detected.spray", 2, ["mist", "spray", "plunge spray", "plunge spray · the river aerosolized on impact"], [0.36, 0.62, 0.28, 0.25]),
static_label("detected.waterfall", 4, ["falls", "waterfall", "cataract", "cataract · a river plunging off the canopys edge"], [0.34, 0.20, 0.3, 0.7]),
static_label("detected.canopy", 2, ["trees", "jungle canopy", "rainforest canopy", "canopy · dense forest crowding the gorge"], [0.05, 0.30, 0.9, 0.5]),
],
"sky_coast_cliffspain": [
static_label("detected.headland", 1, ["point", "headland", "rock promontory", "promontory · a cliffed arm of land into the sea"], [0.1, 0.2, 0.3, 0.4]),
static_label("detected.seacliff", 4, ["cliff", "sea cliff", "coastal headland", "sea cliff · Atlantic rock cut back by the surf"], [0.10, 0.25, 0.8, 0.55]),
static_label("detected.surf", 2, ["waves", "surf", "breaking swell", "breaking swell · ocean meeting stone"], [0.15, 0.70, 0.7, 0.25]),
],
"sky_mtn_castlecrags": [
static_label("detected.talus", 2, ["scree", "talus", "talus slope", "talus · frost-shattered rock piled below the crags"], [0.3, 0.55, 0.4, 0.25]),
static_label("detected.spire", 4, ["rock", "spire", "granite spire", "granite spire · a glacier-carved pluton, exhumed and weathered"], [0.30, 0.20, 0.4, 0.6]),
static_label("detected.forest", 2, ["trees", "conifer forest", "montane forest", "montane forest · cloaking the slopes below the crags"], [0.05, 0.60, 0.9, 0.35]),
],
"sky_mtn_rocky": [
static_label("detected.snowfield", 2, ["snow", "snowfield", "alpine snowfield", "alpine snowfield · lingering high-elevation snow"], [0.3, 0.22, 0.3, 0.22]),
static_label("detected.summit", 4, ["peak", "summit", "alpine summit", "alpine summit · above tree line, snow-streaked granite"], [0.25, 0.20, 0.5, 0.5]),
static_label("detected.tundra", 2, ["meadow", "alpine tundra", "alpine tundra", "alpine tundra · low cushion plants above the trees"], [0.10, 0.62, 0.8, 0.3]),
],
# ---------- coast ----------
"coast_kelp": [
static_label("detected.pneumatocyst", 1, ["floats", "gas bladders", "pneumatocysts", "pneumatocysts · gas floats lifting the blades to light"], [0.4, 0.18, 0.2, 0.25]),
static_label("detected.kelp", 4, ["kelp", "giant kelp", "Macrocystis pyrifera", "Macrocystis · can grow ~0.5 m a day toward the light"], [0.20, 0.10, 0.6, 0.8]),
static_label("detected.frond", 2, ["leaf", "blade", "kelp frond", "frond · gas-filled floats hold it upright"], [0.40, 0.30, 0.2, 0.4]),
],
"coast_otters": [
static_label("detected.fur", 1, ["fur", "dense fur", "the densest fur", "densest fur on Earth · ~1M hairs/in², no blubber"], [0.34, 0.38, 0.3, 0.25]),
static_label("detected.otter", 4, ["otter", "sea otter", "Enhydra lutris", "Enhydra lutris · eats ~25% of its weight a day to stay warm"], [0.30, 0.35, 0.4, 0.35]),
static_label("detected.water", 2, ["water", "estuary", "tidal slough", "tidal slough · sheltered nursery water"], [0.05, 0.05, 0.9, 0.25]),
],
"coast_kalaloch": [
static_label("detected.sunset", 2, ["glow", "sunset", "golden hour", "golden hour · low sun reddened through more air"], [0.05, 0.05, 0.9, 0.22]),
static_label("detected.surf", 4, ["waves", "surf", "breaking swell", "breaking swell · long-period Pacific swell"], [0.15, 0.50, 0.7, 0.35]),
static_label("detected.searock", 2, ["rock", "sea stack", "coastal sea stack", "sea stack · wave-cut residual rock"], [0.30, 0.25, 0.3, 0.3]),
],
"coast_seals": [
static_label("detected.whiskers", 1, ["whiskers", "vibrissae", "sensing whiskers", "vibrissae · whiskers that feel prey in murky water"], [0.3, 0.42, 0.2, 0.15]),
static_label("detected.seal", 4, ["seals", "harbor seals", "Phoca vitulina", "Phoca vitulina · hauls out on rock to rest and warm"], [0.20, 0.40, 0.6, 0.35]),
static_label("detected.searock", 2, ["rock", "haul-out rock", "intertidal rock", "haul-out · a tide-washed resting ledge"], [0.05, 0.6, 0.9, 0.3]),
],
"coast_mist": [
static_label("detected.swell", 2, ["waves", "swell", "ocean swell", "ocean swell · wind-built waves from distant storms"], [0.1, 0.58, 0.8, 0.25]),
static_label("detected.mist", 4, ["mist", "sea mist", "advection fog", "advection fog · warm air cooling over cold upwelling"], [0.05, 0.10, 0.9, 0.5]),
static_label("detected.searock", 2, ["rock", "shore rock", "coastal rock", "coastal rock · the standing edge of the land"], [0.20, 0.55, 0.6, 0.35]),
],
# ---------- reef ----------
"reef_redsea": [
static_label("detected.coral", 4, ["coral", "reef coral", "scleractinian", "scleractinian · stony reef-builder in symbiosis with algae"], [0.15, 0.35, 0.7, 0.5]),
static_label("detected.reeffish", 2, ["fish", "reef fish", "anthias", "anthias · orange clouds of plankton-pickers over the reef"], [0.30, 0.20, 0.4, 0.3]),
measure("measure.depth", "10 m", [0.06, 0.06, 0.16, 0.08], 3),
],
"reef_flowergarden": [
static_label("detected.school", 4, ["fish", "fish school", "jack & scad school", "carangid school · jacks and scad sweeping the reef"], [0.20, 0.25, 0.55, 0.4]),
static_label("detected.sergeant", 2, ["fish", "sergeant major", "Abudefduf saxatilis", "Abudefduf · the striped “sergeant major” damselfish"], [0.55, 0.45, 0.18, 0.2]),
measure("measure.depth", "20 m", [0.06, 0.06, 0.16, 0.08], 3),
],
# ---------- abyss ----------
"abyss_bigfin": [
static_label("detected.arms", 2, ["arms", "elbowed arms", "trailing filaments", "elbowed arms · held out, then trailing meters of thread"], [0.35, 0.5, 0.3, 0.4]),
tracked_label(
"detected.squid", 4,
["squid", "bigfin squid", "Magnapinna", "Magnapinna · elbowed arms trailing meters into the dark"],
0.0, 1.0,
[(0.0, [0.3, 0.36, 0.28, 0.3]), (0.5, [0.42, 0.22, 0.26, 0.28]), (1.0, [0.55, 0.1, 0.26, 0.3])],
),
measure("measure.depth", "2,000 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_dandelion": [
static_label("detected.tentacle", 2, ["threads", "tentacles", "feeding tentacles", "feeding tentacles · a drifting net for prey"], [0.3, 0.45, 0.4, 0.3]),
tracked_label(
"detected.siphonophore", 4,
["orb", "siphonophore", "dandelion siphonophore", "Rhodaliidae · a colony of clones tethered to the seabed"],
0.0, 1.0,
[(0.0, [0.38, 0.28, 0.26, 0.36]), (0.5, [0.3, 0.2, 0.34, 0.46]), (1.0, [0.18, 0.1, 0.45, 0.7])],
),
measure("measure.depth", "2,500 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_octopus": [
static_label("detected.arms", 2, ["arms", "eight arms", "sucker-lined arms", "eight arms · sucker-lined, tasting what they touch"], [0.25, 0.45, 0.5, 0.3]),
tracked_label(
"detected.octopus", 4,
["octopus", "deep-sea octopus", "Graneledone", "Graneledone boreopacifica · broods its eggs for ~4.5 years"],
0.0, 1.0,
[(0.0, [0.5, 0.1, 0.35, 0.55]), (0.5, [0.42, 0.16, 0.36, 0.52]), (1.0, [0.05, 0.02, 0.92, 0.95])],
),
measure("measure.depth", "2,500 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_seapig": [
static_label("detected.veil", 2, ["veil", "oral veil", "swimming veil", "oral veil · sweeps sediment, and flaps to swim"], [0.3, 0.3, 0.35, 0.25]),
tracked_label(
"detected.seacucumber", 4,
["blob", "sea cucumber", "Enypniastes eximia", "Enypniastes · a swimming sea cucumber, the “headless chicken”"],
0.0, 1.0,
[(0.0, [0.58, 0.38, 0.18, 0.3]), (0.5, [0.46, 0.32, 0.19, 0.32]), (1.0, [0.38, 0.28, 0.2, 0.36])],
),
measure("measure.depth", "2,700 m", [0.06, 0.06, 0.16, 0.08], 2),
],
}
# Human-readable strings for affect/measurement keys are produced from the tiered
# data above; this maps measurement keys to nothing extra (their value IS the string).
def _affect_for_clip(scale: str) -> tuple[list, dict]:
"""Build the affect list + its strings for a scale's pool member."""
def _affect_for_clip(scale: str, clip_id: str) -> tuple[list, dict]:
"""Build the affect list + its strings for a clip. A per-clip override in
AFFECT_CLIP (feelings drawn from THAT footage) wins; otherwise the scale's
shared register applies."""
source = AFFECT_CLIP.get(clip_id, AFFECT[scale])
entries, strings = [], {}
for key, at, min_level, tiers in AFFECT[scale]:
for key, at, min_level, tiers in source:
entries.append({"key": key, "at": at, "min_level": min_level})
strings[key] = tiers
return entries, strings
@@ -429,7 +874,7 @@ def _labels_for_clip(clip_id: str) -> tuple[list, dict]:
def _clip_entry(scale: str, clip_id: str) -> dict:
title, license_, source = META[clip_id]
anns, lab_strings = _labels_for_clip(clip_id)
affect, aff_strings = _affect_for_clip(scale)
affect, aff_strings = _affect_for_clip(scale, clip_id)
return {
"id": clip_id,
"title": title,
+38 -5
View File
@@ -83,13 +83,14 @@ test("audio is a 0-10 level dial", async ({ page }) => {
expect(ctl.max).toBe("10");
});
test("the experience auto-starts once loaded: video on + audio at 3/10", async ({ page }) => {
test("boots silent (video off, audio 0); turning video on couples audio to 3/10", async ({ page }) => {
await boot(page);
// No gesture needed — autoStart() flips Video on and lifts audio to a gentle level
// the moment the preload finishes.
await expect.poll(() => page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(true);
// Boots SILENT — no un-gestured auto-start, so the browser autoplay block never bites.
expect(await page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(false);
expect(await page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("0");
// Turning Video on (a click gesture) lifts the audio dial to a gentle 3/10.
await enableVideo(page);
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3");
// The level label reflects it.
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3");
});
@@ -163,6 +164,38 @@ test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)",
expect(v.t).toBeGreaterThanOrEqual(2.5); // looping from ~3s, not 0
});
test("ascending re-lands anchored at the clip head (no reverse-landing jump)", async ({ page }) => {
// D3: a morph spans src@0 (frac 0) -> dst@~3s (frac 1). Descending lands on dst at its
// tail (~3s); ASCENDING lands on src at its HEAD (~0s). The steady loop must continue
// from 0 there — seeking to the tail (~3s) was the ~3s reverse-landing jump.
await boot(page);
await enableVideo(page); // base loop only loads when video is shown
// Descend one altitude (cosmos -> orbit), then ascend back (orbit -> cosmos): the
// ascent is the reverse landing under test.
const start = (await page.locator("#scale-name").textContent())!;
await wheelOnStage(page, 60); // descend
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
const mid = (await page.locator("#scale-name").textContent())!;
expect(mid).toContain("orbit");
await wheelOnStage(page, -60); // ascend back
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, mid, { timeout: 20000 });
expect(await page.locator("#scale-name").textContent()).toContain("cosmos");
// The loop element is now anchored at the HEAD (loopStart "0"), playing — not jumped to
// the ~3s tail the descending case uses.
await page.waitForFunction(
() => { const v = document.querySelector("#vid-loop") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.dataset.loopStart === "0"; },
null,
{ timeout: 10000 },
);
const v = await page.evaluate(() => {
const el = document.querySelector("#vid-loop") as HTMLVideoElement;
return { loopStart: el.dataset.loopStart, loopTail: el.dataset.loopTail, paused: el.paused };
});
expect(v.loopTail).toBe("1"); // tail-loop machinery armed
expect(v.loopStart).toBe("0"); // anchored at the head, not the morph tail
expect(v.paused).toBe(false); // the loop is actually playing
});
test("zoom in plays the matching morph, lands locked, and stays put while parked", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
File diff suppressed because it is too large Load Diff
+122 -36
View File
@@ -238,20 +238,42 @@ function preloadChip() {
// to the loop's first frame, and the morph-covered intro never re-shows at rest.
const LOOP_TAIL_S = 3.0;
// Load a clip's base into the LOOP element (#vid-loop), seeked to the tail and PAUSED
// on that exact frame. Idempotent per clip. Called during a scrub to PRELOAD the clip
// we're about to land on, so settle just plays an already-decoded element at the morph's
// last frame — no reload, no seek, no stall. `playLoop()` starts it at landing.
function loadLoop(clip) {
if (!clip || loopVid.dataset.clip === clip.id) return;
// The frame the current loop both LANDS on and WRAPS back to — stored per load so the
// wrap handlers honor a direction-aware landing (forward=LOOP_TAIL_S, reverse=0).
function loopStartFrame() {
const s = parseFloat(loopVid.dataset.loopStart);
return Number.isFinite(s) ? s : LOOP_TAIL_S;
}
// Load a clip's base into the LOOP element (#vid-loop), seeked to `landFrame` and PAUSED
// on that exact frame. `landFrame` is the frame the morph last showed for THIS landing:
// LOOP_TAIL_S when descending (lands on the morph's dst-tail), 0 when ascending (lands on
// the morph's src-head — fixes the D3 reverse-landing jump). Omitted → LOOP_TAIL_S, the
// forward default for non-scrub paths (initial load, knob changes). Idempotent per clip;
// an explicit `landFrame` re-seeks the already-loaded clip if the travel direction flipped
// the landing phase, but a bare call (ensureClipMedia) never clobbers a phase a scrub set.
// Called during a scrub to PRELOAD the clip we're about to land on, so settle just plays
// an already-decoded element at the right frame — no reload, no seek, no stall.
function loadLoop(clip, landFrame) {
if (!clip) return;
const seekTo = (t) => { try { loopVid.currentTime = t; } catch (e) { /* not seekable yet */ } };
if (loopVid.dataset.clip === clip.id) {
if (landFrame != null && loopVid.dataset.loopStart !== String(landFrame)) {
loopVid.dataset.loopStart = String(landFrame);
if (loopVid.readyState >= 1) seekTo(landFrame);
else loopVid.addEventListener("loadedmetadata", () => seekTo(landFrame), { once: true });
}
return;
}
const lf = landFrame == null ? LOOP_TAIL_S : landFrame;
loopVid.dataset.clip = clip.id;
loopVid.dataset.loopTail = "1"; // arm the [LOOP_TAIL_S, duration] wrap handler
loopVid.dataset.loopTail = "1"; // arm the [loopStart, duration] wrap handler
loopVid.dataset.loopStart = String(lf);
loopVid.src = mediaUrl(clip.base_file);
loopVid.loop = false; // native loop restarts at 0; we loop from the tail instead
loopVid.loop = false; // native loop restarts at 0; we loop from loopStart
loopVid.muted = true;
const seek = () => { try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable yet */ } };
if (loopVid.readyState >= 1) seek();
else loopVid.addEventListener("loadedmetadata", seek, { once: true });
if (loopVid.readyState >= 1) seekTo(lf);
else loopVid.addEventListener("loadedmetadata", () => seekTo(lf), { once: true });
}
function playLoop() {
@@ -267,21 +289,22 @@ function ensureClipMedia() {
}
// The custom loop on the LOOP element: when a base loop reaches the end, jump back to
// the tail offset (not 0). The morph element (#vid) is driven directly by the scrub.
// its landing offset (LOOP_TAIL_S forward, 0 reverse — see loopStartFrame), not a fixed
// tail. The morph element (#vid) is driven directly by the scrub.
loopVid.addEventListener("timeupdate", () => {
if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) {
try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ }
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
}
});
// Safety net: the timeupdate wrap above is a narrow window (the last 0.08 s). Under
// real-browser decode/GPU load a `timeupdate` can skip past it, the clip fires
// `ended`, and — with native loop off — freezes on its last frame ("not looping").
// `ended` GUARANTEES recovery: seek back to the tail and resume. Headless rarely
// hits this; loaded real machines do.
// `ended` GUARANTEES recovery: seek back to the landing offset and resume. Headless
// rarely hits this; loaded real machines do.
loopVid.addEventListener("ended", () => {
if (loopVid.dataset.loopTail !== "1") return;
try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ }
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
loopVid.play().catch(() => {});
});
@@ -560,6 +583,49 @@ function renderOverlay(level, intensity) {
// 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.
// --- Keep Feel (affect) words clear of Think (label) chips -------------------
// Feelings are scene-level (soft position), so we nudge THEM off the analytical
// chip plates rather than move a chip off its subject. Obstacles are computed
// from the manifest (not the DOM) so the clearance holds for the WHOLE loop: a
// tracked chip's drift is sampled, so the per-frame track re-render never slides
// a chip under an already-placed word. Mirrors chip()'s plate geometry.
const FS_CHIP = 2.4, PAD_CHIP = 0.6;
function chipPlateRect(bx, by, textLen) {
const w = textLen * FS_CHIP * 0.6 + PAD_CHIP * 2;
const h = FS_CHIP + PAD_CHIP * 1.4;
const cy = Math.max(by - h - 0.6, 0.4); // plate sits just above the box top
return { x: bx, y: cy, w, h };
}
function rectsOverlap(a, b, pad) {
pad = pad || 0;
return a.x < b.x + b.w + pad && a.x + a.w + pad > b.x &&
a.y < b.y + b.h + pad && a.y + a.h + pad > b.y;
}
// Plate rects for every left label visible at `level`; a tracked label's path is
// sampled across the loop so its whole drift envelope is treated as occupied.
function leftLabelPlates(clip, level) {
const out = [];
if (!clip || !clip.annotations || level <= 0) return out;
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
for (const a of clip.annotations) {
if (level < firstLevel(a)) continue;
const measure = a.key.startsWith("measure.");
const raw = strings[a.key];
const tier = measure ? 1 : clamp(level - firstLevel(a) + 1, 1, tierCount(raw));
const label = String(pickTier(raw !== undefined ? raw : a.key, tier) || "");
const len = label.length + (measure ? 0 : 5); // detections append " 0.xx"
const ts = (a.track && a.track.length) ? [0, 0.2, 0.4, 0.6, 0.8, 1] : [0];
for (const tt of ts) {
const b = boxAt(a, tt);
out.push(chipPlateRect(b[0] * 100, b[1] * 100, len));
}
}
// The global "◉ ANALYSIS · L… · … OBJ" status tag (top-right) shows whenever any
// label does — reserve its corner so feelings don't collide with it either.
if (out.length) out.push({ x: 64, y: 2, w: 34, h: 6 });
return out;
}
function renderAffect(strength, intensity, right) {
lastAffect = { strength, intensity, right };
const clip = activeClip();
@@ -568,14 +634,36 @@ function renderAffect(strength, intensity, right) {
if (!clip || !clip.affect || strength <= 0) { affectLayer.style.opacity = "0"; return; }
affectLayer.style.opacity = String(intensity);
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
// Chip plates to avoid (left labels visible at the current Left level), plus the
// words placed so far so feelings don't stack on each other either.
const obstacles = leftLabelPlates(clip, lastOverlay ? lastOverlay.level : 0);
const placed = [];
for (const f of clip.affect) {
if (f.min_level > strength) continue;
const [x, y] = f.at.map((n) => n * 100);
const t = svg("text", { x, y, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
const [x, y0] = f.at.map((n) => n * 100);
const t = svg("text", { x, y: y0, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
// RIGHT emotion tier: the vocabulary escalates basic -> compound as the Right
// 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)));
// Nudge vertically off any chip plate / already-placed word, staying on-screen;
// keep the least-overlapping spot if nothing is fully clear.
const bb0 = t.getBBox();
// Diagnostic seam (sibling of window.__hefState): disable nudging to A/B the
// overlap-avoidance in tests.
const deconflict = !(typeof window !== "undefined" && window.__hefNoDeconflict);
if (deconflict && bb0.width) {
let bestY = y0, bestHits = Infinity;
for (const dy of [0, 4, -4, 8, -8, 12, -12, 16, -16, 20, -20]) {
const yy = clamp(y0 + dy, 4, 96);
const r = { x: bb0.x, y: bb0.y + (yy - y0), w: bb0.width, h: bb0.height };
const hits = obstacles.concat(placed).filter((o) => rectsOverlap(r, o, 0.4)).length;
if (hits < bestHits) { bestHits = hits; bestY = yy; if (hits === 0) break; }
}
if (bestY !== y0) t.setAttribute("y", bestY);
}
const bb = t.getBBox();
placed.push({ x: bb.x, y: bb.y, w: bb.width, h: bb.height });
}
}
@@ -835,7 +923,12 @@ function setPos(next) {
busy = false;
delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base)
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
playLoop(); // the loop element was preloaded to this clip @ tail during the scrub → instant, no reload
// Land the loop on the frame the morph last showed for THIS travel direction:
// descending lands on the morph's dst-tail (LOOP_TAIL_S), ascending on its src-head
// (0). Explicit (not relying on the scrub's preload) so a reverse landing never
// jumps even if the heading clip wasn't warmed in time. (D3 fix.)
loadLoop(activeClip(), HEFScrub.loopLandFrame(scrubDir, LOOP_TAIL_S));
playLoop(); // the loop element was preloaded to this clip during the scrub → instant, no reload
showActiveSource();
setNeedle(ringIndex * dialStep());
renderScaleReadout();
@@ -846,10 +939,11 @@ function setPos(next) {
}
busy = true; // mid-morph: block update() from reloading the base clip under us
if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex);
// Preload the clip we're heading toward into the LOOP element (paused @ tail) so the
// landing is a swap, not a reload+seek. dir>0 lands on clipHi, dir<0 on clipLo.
// Preload the clip we're heading toward into the LOOP element (paused on its landing
// frame) so the landing is a swap, not a reload+seek. dir>0 lands on clipHi at the
// morph's dst-tail (LOOP_TAIL_S); dir<0 lands on clipLo at the morph's src-head (0).
const headingId = dir > 0 ? activeSeg.clipHi : activeSeg.clipLo;
loadLoop(clipsById[headingId]);
loadLoop(clipsById[headingId], HEFScrub.loopLandFrame(dir, LOOP_TAIL_S));
showActiveSource(); // morph element is the live source while scrubbing
setNeedle(pos * dialStep());
blendAudio(lo, frac);
@@ -1260,8 +1354,12 @@ async function main() {
$("stage").addEventListener("wheel", onWheel, { passive: false });
update(); // render the initial state (both toggles off → black, silent)
await preloadAllMedia(); // download all media, updating the loading bar
hideLoading(); // experience is ready to run smoothly
autoStart(); // once loaded: video on + gentle audio (3/10), no gesture needed
hideLoading(); // experience is ready — boots SILENT (video off, audio 0)
// No un-gestured auto-start: a browser autoplay policy blocks an audio play() made
// outside a user gesture (muted video survives, sound does not), so an auto-start
// would show video but stay silent. Instead the experience waits for the operator to
// turn Video on — that click IS the gesture, and its handler (above) lifts audio to
// 3/10 and starts the soundtrack in-gesture, so sound reliably comes up with video.
}
// Populate the language dropdown from the registry and wire live switching.
@@ -1297,16 +1395,4 @@ function setLanguage(lang) {
if (lastAffect) renderAffect(lastAffect.strength, lastAffect.intensity, lastAffect.right);
}
// Auto-start the experience the moment media is ready: bring Video on and the audio
// level to 3/10 (the same gentle default the first manual Video-on couples in). The
// audio <audio> elements ideally unlock inside a user gesture (Safari); this fires
// outside one, so a browser with a strict autoplay policy may BLOCK the first audio
// play() until any interaction — the on-screen audio status readout will show it.
function autoStart() {
$("visual").checked = true;
videoEverOn = true;
if (audioLevel() === 0) { $("audio").value = "3"; updateAudioLevelLabel(); }
applyAudio();
update();
}
main();
+13 -1
View File
@@ -29,6 +29,18 @@
return { from: 1 - f, to: f };
}
// The base-loop frame a landing must continue from so the steady loop picks up
// the EXACT frame the morph last showed — no jump. A morph spans src@0 (frac 0)
// -> dst@loopTailS (frac 1), scrubbed bidirectionally. Descending (dir>0) lands
// on dst at frac 1, whose frame is dst@loopTailS, so the loop resumes at loopTailS
// (and the morph-covered intro never re-shows). Ascending (dir<0) lands on src at
// frac 0, whose frame is src@0, so the loop must resume at 0 — seeking to loopTailS
// there is the ~3s reverse-landing jump (D3). dir 0 defaults to the forward/tail
// case (matches the loadLoop default used by non-scrub paths).
function loopLandFrame(dir, loopTailS) {
return dir < 0 ? 0 : loopTailS;
}
// Integers strictly crossed moving prevPos -> pos, in travel order. Landing
// exactly on an integer counts as crossing it (it commits that altitude).
function integerCrossings(prevPos, pos) {
@@ -41,5 +53,5 @@
return out;
}
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, integerCrossings };
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
});
+14
View File
@@ -58,3 +58,17 @@ test("integerCrossings: multiple crossings in travel order", () => {
assert.deepEqual(S.integerCrossings(2.4, 4.1), [{ index: 3, dir: 1 }, { index: 4, dir: 1 }]);
assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]);
});
test("loopLandFrame: descend lands on the morph's tail frame, ascend on its head", () => {
// A morph spans src@0 (frac 0) -> dst@loopTailS (frac 1). Descending (dir>0) lands
// on dst at frac 1 -> the base loop must continue from loopTailS. Ascending (dir<0)
// lands on src at frac 0 -> the loop must continue from 0 (NOT loopTailS — that was
// the D3 reverse-landing jump).
assert.equal(S.loopLandFrame(1, 3), 3); // descend -> tail
assert.equal(S.loopLandFrame(-1, 3), 0); // ascend -> head
// dir 0 (no travel) is treated as a forward/tail landing (the default everywhere else).
assert.equal(S.loopLandFrame(0, 3), 3);
// honors any tail value
assert.equal(S.loopLandFrame(1, 2.5), 2.5);
assert.equal(S.loopLandFrame(-1, 2.5), 0);
});
+31 -12
View File
@@ -2,12 +2,15 @@
cleanly when Playwright or its browser binary is absent.
NOTE: headless engines relax BOTH autoplay and GPU compositing, so these assert
the WIRING (auto-start on load, dial→play/volume, video-off blanking) — real
Safari/iOS autoplay and real-GPU compositing still need a device by-ear/eye check.
the WIRING (boot-silent, video-on couples audio, dial→play/volume, video-off
blanking) — real Safari/iOS autoplay and real-GPU compositing still need a device
by-ear/eye check.
The experience AUTO-STARTS once the preload finishes: Video turns on and the audio
level dial rises to 3/10 with no user gesture (app.js autoStart()). Audio is a 010
range dial (not a checkbox); set it by writing `value` + firing an `input` event.
The experience BOOTS SILENT — video off, audio dial at 0 — so nothing plays until a
real user gesture. Turning Video ON (a click) couples the audio dial up to 3/10 and
starts the soundtrack IN that gesture, sidestepping the browser autoplay block that
swallowed an un-gestured auto-start. Audio is a 010 range dial (not a checkbox);
set it by writing `value` + firing an `input` event.
Install: pip install -e '.[e2e]' && python -m playwright install chromium
"""
@@ -63,7 +66,7 @@ def page(app_url):
pg = browser.new_page()
pg.goto(app_url)
# wait out the "Loading Universe…" splash (it overlays + blocks clicks); the
# auto-start fires as it dismisses.
# experience boots silent (video off, audio 0) as it dismisses.
pg.wait_for_selector("#loading", state="detached", timeout=60000)
yield pg
browser.close()
@@ -84,10 +87,23 @@ def _set_audio(page, level):
)
def test_app_auto_starts_video_and_audio(page):
# Once the preload finishes the experience auto-starts: Video on + audio at 3/10,
# video showing (black hidden), a scale soundtrack playing — no tap-to-start wall.
assert page.is_checked("#visual") is True
def test_app_boots_silent_video_off(page):
# The experience boots SILENT: Video off, audio dial at 0, screen black, both
# crossfade elements paused — nothing plays until a real user gesture.
assert page.is_checked("#visual") is False
assert page.evaluate("document.getElementById('audio').value") == "0"
assert page.evaluate("document.getElementById('audio-level-val').textContent") == "0"
page.wait_for_selector("#black:not(.hidden)") # screen blanked
assert page.evaluate(
"(() => { const a = document.getElementById('aud'), b = document.getElementById('aud-b');"
" return a.paused && b.paused; })()"
) is True
def test_video_on_couples_audio_to_three_and_plays(page):
# Turning Video on (a click gesture) lifts the audio dial to 3/10 and starts the
# soundtrack IN that gesture: video shows (black hidden), a scale soundtrack plays.
_toggle_visual(page) # off -> on
page.wait_for_function("document.getElementById('audio').value === '3'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '3'")
page.wait_for_selector("#black.hidden", state="attached") # video showing
@@ -113,8 +129,11 @@ def test_audio_dial_zero_silences(page):
assert page.evaluate("document.getElementById('audio').value") == "0"
def test_video_off_blanks_after_autostart(page):
# Video is auto-on; turning it off blanks the screen and hides the GPU layers.
def test_video_off_blanks(page):
# Turn Video on (boot is off), then off again: that blanks the screen and hides
# the GPU layers.
_toggle_visual(page) # off -> on
page.wait_for_selector("#black.hidden", state="attached")
_toggle_visual(page) # on -> off
page.wait_for_selector("#black:not(.hidden)")
page.wait_for_function(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff