Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61bd4a7943 | |||
| 2d54023f94 | |||
| bf1013be74 | |||
| fa3bbef0d6 | |||
| 64e8f658f6 | |||
| 760fdc7550 | |||
| e62382c5e5 | |||
| 041fcdeb7d | |||
| 31956bfdfc | |||
| 5a08d1dd35 | |||
| 432fbd7703 | |||
| 4e4a5c256d | |||
| 8cfceffb76 | |||
| 5f8b8f14e6 | |||
| 59c1c3a895 | |||
| 582183db2f | |||
| c13ce36787 | |||
| 21830069ad | |||
| 50366c056e | |||
| 934f60cbd2 | |||
| 7995ba4069 | |||
| 7bd6c9a57b | |||
| e5fe00e948 |
@@ -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 A–D; 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
|
||||
```
|
||||
@@ -79,5 +79,20 @@
|
||||
},
|
||||
"0027": {
|
||||
"title": ""
|
||||
},
|
||||
"0028": {
|
||||
"title": ""
|
||||
},
|
||||
"0029": {
|
||||
"title": ""
|
||||
},
|
||||
"0030": {
|
||||
"title": ""
|
||||
},
|
||||
"0031": {
|
||||
"title": ""
|
||||
},
|
||||
"0032": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 cradle’s 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 cradle’s 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 cradle’s 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 cradle’s 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 cradle’s 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 star’s 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 colony’s 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 Group’s 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, ~1–2 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", "Earth’s 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", "Earth’s 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 canopy’s 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,
|
||||
|
||||
@@ -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())!;
|
||||
|
||||
+5228
-2292
File diff suppressed because it is too large
Load Diff
+122
-36
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 0–10
|
||||
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 0–10 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(
|
||||
|
||||
+996
-524
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
Reference in New Issue
Block a user