Compare commits
54 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 | |||
| 020219f9a6 | |||
| 534c54fe12 | |||
| 5beee55119 | |||
| ec660c4880 | |||
| a2d8179507 | |||
| 7090d3a836 | |||
| 8df7859c5a | |||
| dcdc3d62f7 | |||
| 1977e679ee | |||
| 49b77e0700 | |||
| 00534e116c | |||
| 696f8f0901 | |||
| 43370292e3 | |||
| eff43bd148 | |||
| 73d92b90d0 | |||
| ed5ce09a3c | |||
| 108e6207ba | |||
| 0e401c9a42 | |||
| 4b1d3afe7b | |||
| e862fb498b | |||
| ea530b3df6 | |||
| 0752746f7b | |||
| 614411b5e4 | |||
| 70fc9a46ab | |||
| f2f242a411 | |||
| 31a3fd733f | |||
| 8458ab59eb | |||
| 20f49e936a | |||
| 0548e9a82b | |||
| 3dc3e3491f | |||
| 937461e3ba |
@@ -63,6 +63,258 @@ stays non‑commercial**.
|
||||
> confirm with operator). The current ring scale id is `cosmos`; when the pool is
|
||||
> wired, `cosmos` becomes one pool member.
|
||||
|
||||
## 🏗️ BUILD (2026-06-28, branch `feat/sky-scale-expanded-pools`) — curated pools + sky scale
|
||||
|
||||
> **Decision:** add the `sky` scale + **curate every pool to ~7–8 clips** (operator chose
|
||||
> "curate, expand from the sim" over a full ~2,000-morph bake). Morphs are **cheap and
|
||||
> length-independent** — `transition_cmd` does `trim=0:3`, so each morph uses only the
|
||||
> first 3s of each clip (2–6MB all-intra), so the curated bake is ~616 morphs ≈ ~1.8GB /
|
||||
> ~30 min. **Long clips (45–60s) validated:** a 53s loop bitrate-capped (libx264 crf 23,
|
||||
> `-maxrate 2.8M -bufsize 5.6M`, 1080p) = ~20MB, under the ~25MB front-proxy/LFS ceiling.
|
||||
> The pool processor downloads each source, trims its long window, [vidstab-stabilizes /
|
||||
> xfade-splices where flagged], crossfade-loops, and writes the capped proxy to
|
||||
> `simulator/sample_media/<id>/base.mp4`.
|
||||
>
|
||||
> **Curated lineup (~43 clips; 30 new + 13 existing reused; prune/expand from the sim):**
|
||||
> - **cosmos (7):** cosmos, cosmos_galaxies + cosmos_orion, _tarantula, _westerlund, _southernring, _carina_eso
|
||||
> - **orbit (7):** orbit_planetearth, _bluemarble + orbit_aurora2025, _citylights, _helene, _epic, _illuminated
|
||||
> - **sky (8) NEW SCALE** (between orbit & coast): sky_grca_templesa, sky_greenland_landice (stab), sky_greenland_suture (stab), sky_jungle_amazon (amazon+canopy splice), sky_jungle_waterfall, sky_coast_cliffspain, sky_mtn_castlecrags, sky_mtn_rocky
|
||||
> - **coast (7):** coast_birdrock, _surfgrass + coast_kelp, _otters, _kalaloch, _seals, _mist (dropped short drakesbeach/elkbeach)
|
||||
> - **reef (6):** reef_lionfish, _spawning, _hawkfish, _coralspacific + reef_redsea, _flowergarden (dropped murky reef_snapper; dropped reef_hawaiian — text overlay)
|
||||
> - **abyss (7):** abyss_wow, _midwaterexp, _hiding + abyss_bigfin, _dandelion, _octopus, _seapig
|
||||
>
|
||||
> **Remaining build steps:** (1) processor finishes the 30 new bases → (2) wire
|
||||
> `build_pool_manifest.py` (RING_ORDER + `sky`, POOLS, SCALE_AUDIO, META/AFFECT/LABELS for
|
||||
> new clips) → (3) regenerate manifest + `--media` morph bake (~616) → (4) update
|
||||
> `test_build_pool_manifest.py` morph-count assertion + run pytest/playwright → (5) ship via
|
||||
> §9 (localhost+E2E → PPE). All dropped/unpicked Round-4 candidates remain in
|
||||
> `/review_pool.html` for easy add-back.
|
||||
|
||||
## 📜 License policy FINALIZED (2026-06-28) + Round-4 expanded candidates
|
||||
|
||||
> **License policy (supersedes the "strict PD" framing above).** The operator confirmed
|
||||
> the installation **alters** footage (needs derivative rights), **will keep a credits
|
||||
> list**, and **may be shown commercially**. **Usable:** `CC0`, US-gov **Public Domain**,
|
||||
> **CC BY**, **CC BY-SA** (share-alike accepted), **Pexels License**. **Never usable:**
|
||||
> **CC BY-ND** (no derivatives) and **all NonCommercial (`CC BY-NC*`)** variants. For CC
|
||||
> BY / BY-SA, credit the author + note "footage altered." Pexels needs no attribution.
|
||||
> Recorded in agent memory `media-license-policy`.
|
||||
>
|
||||
> ⚠️ **License correction (abyss sourcing):** **Schmidt Ocean Institute, Nautilus Live /
|
||||
> Ocean Exploration Trust, and MBARI are NonCommercial / all-rights-reserved → NOT usable.**
|
||||
> Deep-sea picks are NOAA Ocean Exploration (PD) / CC0 only.
|
||||
>
|
||||
> **Round-4 (2026-06-28): ~10 fresh candidates PER altitude**, sourced under the widened
|
||||
> licenses and chosen for **long clean windows (45–60s)** per the operator's "make the
|
||||
> videos longer" ask (we cap bitrate to keep LFS objects <25MB). All 56 are HEAD-verified
|
||||
> and live in the streaming gallery **`/review_pool.html`** (the URLs/windows/licenses are
|
||||
> recorded in that committed file). Awaiting operator picks per altitude. Highlights:
|
||||
> cosmos = Orion/Tarantula/Westerlund/Bubble/Carina-ESO/Stephan's-Quintet/Southern-Ring
|
||||
> flythroughs (NASA SVS PD + ESA/ESO CC BY); orbit = ISS auroras + city-lights + storm-limb
|
||||
> + EPIC full-disk + ESA Sentinel (PD/CC0/CC BY-SA); sky = volcano/savanna/autumn/sea-of-clouds/
|
||||
> dunes/salt-flats/vineyards/badlands/boreal terrains; coast = kelp/otters/tidepool-crab/
|
||||
> surf-on-rock/gulls/seals; reef = Red Sea (CC BY-SA) + 6 NOAA reels (clear-water windows);
|
||||
> abyss = bigfin squid/dandelion siphonophore/psychedelic medusa/ghost shark/octopus/
|
||||
> CC0 sea-pig/bamboo-coral forest/vents/dragonfish/isopod.
|
||||
|
||||
## ☁️ sky — PROPOSED NEW scale (candidates, awaiting operator review)
|
||||
|
||||
> ➕ **New altitude: "up in the clouds, flying over the earth" (sourced 2026-06-27).**
|
||||
> A new ring scale that sits **between `orbit` (ISS / space) and `coast` (ground)** —
|
||||
> the airplane-window / low-flight layer. Proposed slug **`sky`**, emoji ☁️; ring
|
||||
> order would become cosmos → orbit → **sky** → coast → reef → abyss (operator to
|
||||
> confirm slug + placement). The 20 candidates below are **NOT yet downloaded or
|
||||
> processed** — they are a review list. Watch them streaming from the source at
|
||||
> **`/review_sky.html`** (served by the sim; nothing downloads until you press
|
||||
> play), then pick the keepers and we'll trim → crossfade-loop → 1080p them into a
|
||||
> pool like every other scale. All are strict US-gov **public domain** unless noted;
|
||||
> all direct URLs HEAD-verified 200 + `video/mp4` on 2026-06-27.
|
||||
|
||||
> ✅ **OPERATOR SELECTED (2026-06-28) — 15 clips, SOURCING CLOSED** (Selected band on `/review_sky.html`):
|
||||
> - *Aircraft aerials (4):* `sky_grca_templesa`, `sky_grca_templesb`,
|
||||
> `sky_greenland_landice` (**stabilize**, vidstab — wobbly), `sky_greenland_suture` (**stabilize**).
|
||||
> *(`sky_greenland_gopro` removed.)*
|
||||
> - *Rainforest (4):* `sky_jungle_amazon` (**spliced with `sky_jungle_canopy`** into one clip,
|
||||
> short crossfade at the seam), `sky_jungle_river`, `sky_jungle_waterfall`,
|
||||
> `sky_jungle_warmlight`. *(All Pexels License.)*
|
||||
> - *Coast (3):* `sky_coast_dunes` (CC-BY — credit; downscale the 4K),
|
||||
> `sky_coast_turquoise`, `sky_coast_cliffspain` (Pexels).
|
||||
> *(`sky_coast_norway` and `sky_coast_cliffsurf2` removed.)*
|
||||
> - *Mountains (4):* `sky_mtn_rocky`, `sky_mtn_castlecrags`, `sky_mtn_granite`,
|
||||
> `sky_mtn_sierra` (swapped in for `sky_mtn_snowfield`).
|
||||
>
|
||||
> Balance: aircraft 4 · rainforest 4 · coast 3 · mountains 4. The Round-3b/3c
|
||||
> rainforest candidates not listed above (`misty`/`clouds`/`riverhd`/`textured` +
|
||||
> `_alt`s) were **not chosen**.
|
||||
>
|
||||
> **Clip-length audit (2026-06-28, measured via cv2 on the on-disk pool):** shortest
|
||||
> existing loops are `coast_drakesbeach` **14s** and `coast_elkbeach` **17s** (both the
|
||||
> ground-level coast scale), then `reef_snapper` 20s, `cosmos_miri` 21s; everything else
|
||||
> ~23s (`reef_spawning` 27s longest). The new `sky` clips all have ≥24s windows → bake to
|
||||
> ~22–23s. Open option: re-trim the two short coast outliers to longer windows during the
|
||||
> sky processing pass.
|
||||
>
|
||||
> **Dropped / superseded:** the other 15 round-1 candidates; round-2 coast
|
||||
> `sky_coast_icefjord` + `sky_coast_moher`; round-2 mountains `sky_mtn_shasta` +
|
||||
> `sky_mtn_snowfield`; the round-2 rainforest batch (lidar/DVIDS jungle — all rejected);
|
||||
> round-3 coast `sky_coast_reef` (unpicked) + `sky_coast_oregon` (humans + nauseating
|
||||
> FPV). `sky_coast_reeflagoon`/`sky_coast_cliffsurf` (too short) → replaced; the short
|
||||
> round-3 rainforest clips (`amazon`/`canopy`/`river` kept; `misty`/`clouds`/`riverhd`
|
||||
> too short) → replaced in Round-3b. Live review page: `/review_sky.html`.
|
||||
|
||||
**Group A — real aerial, from aircraft** (the heart of the scale):
|
||||
|
||||
| id | clip | source | direct media | window (s) | notes |
|
||||
|----|------|--------|--------------|-----------|-------|
|
||||
| `sky_grca_pivot` | Grand Canyon — high-altitude, straight down + slow 360° pivot | NPS GRCA b-roll hd09 — https://www.nps.gov/grca/learn/photosmultimedia/b-roll_hd09.htm | https://www.nps.gov/nps-audiovideo/audiovideo/302c9dbf-f9ad-4964-bbf3-a21b536f36ed1080p.mp4 | 0–24 | PD · 1080p · text/human-free; ~25s clip (use whole) |
|
||||
| `sky_grca_upstream` | Grand Canyon — high-elevation glide tracking upstream | NPS GRCA hd09 (same page) | https://www.nps.gov/nps-audiovideo/audiovideo/237ed336-b101-4a44-81f1-36d692c08a7a1080p.mp4 | 12–36 | PD · 1080p · trim before the mid-clip zoom |
|
||||
| `sky_grca_templesa` | Grand Canyon — banking aerial over cliffs & "temples" (A) | NPS GRCA hd10 — https://www.nps.gov/grca/learn/photosmultimedia/b-roll_hd10.htm | https://www.nps.gov/nps-audiovideo/audiovideo/0121cbe5-b185-4f9f-94af-e55f3ade40161080p.mp4 | 0–24 | PD · 1080p |
|
||||
| `sky_grca_templesb` | Grand Canyon — cliffs & "temples" aerial (B, alt angle) | NPS GRCA hd10 (same page) | https://www.nps.gov/nps-audiovideo/audiovideo/0b014199-8287-441c-b535-2416d7b049521080p.mp4 | 0–24 | PD · 1080p |
|
||||
| `sky_grca_river` | Grand Canyon — low-elevation aerial skimming the Colorado River | NPS GRCA hd08 — https://www.nps.gov/grca/learn/photosmultimedia/b-roll_hd08.htm | https://www.nps.gov/nps-audiovideo/audiovideo/008a5147-7b14-49a1-9871-13aeb03325aa1080p.mp4 | 0–24 | PD · 1080p · lower/faster altitude variety |
|
||||
| `sky_greenland_gopro` | IceBridge — Western Greenland, forward flight (GoPro) | NASA SVS 13474 — https://svs.gsfc.nasa.gov/13474 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013474/YOUTUBE_1080_13474_2019_GoPro_Front_4K_youtube_1080.mp4 | 30–54 | PD · 1080p (4K master on page) |
|
||||
| `sky_greenland_landice` | IceBridge — N. Greenland, low flight over snowy land ice | NASA SVS 13472 — https://svs.gsfc.nasa.gov/13472 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013472/YOUTUBE_1080_13472_Panasonic_2016_Airborne_LandIce_4K_youtube_1080.mp4 | 20–44 | PD · 1080p |
|
||||
| `sky_svalbard_land` | IceBridge — Svalbard, over glaciated mountains | NASA SVS 13473 — https://svs.gsfc.nasa.gov/13473 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013473/YOUTUBE_1080_13473_LandIce_2017_iPhone_youtube_1080.mp4 | 25–49 | PD · 1080p |
|
||||
| `sky_svalbard_seaice` | IceBridge — Svalbard, banking over fractured pack ice | NASA SVS 13477 — https://svs.gsfc.nasa.gov/13477 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013477/YOUTUBE_1080_13477_iPhone_2017_SeaIce_Broken_youtube_1080.mp4 | 20–44 | PD · 1080p · abstract ice leads |
|
||||
| `sky_petermann_icebergs` | IceBridge — over icebergs toward Petermann calving front | NASA SVS 13471 — https://svs.gsfc.nasa.gov/13471 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013471/YOUTUBE_1080_3471_4K_SeaIce_2017_Icebergs_CalvingFront_youtube_1080.mp4 | 30–54 | PD · 1080p (4K master) |
|
||||
| `sky_greenland_suture` | IceBridge — N. Greenland, overhead sea-ice patterning ("Suture") | NASA SVS 13472 — https://svs.gsfc.nasa.gov/13472 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013472/YOUTUBE_1080_13472_4K_SeaIce_2017_Suture_youtube_1080.mp4 | 15–39 | PD · 1080p (4K master) · nadir ice texture |
|
||||
| `sky_helheim` | "The Helheim Experience" — scenic C-130 flight near Helheim Glacier | NASA SVS 11862 — https://svs.gsfc.nasa.gov/11862/ | https://svs.gsfc.nasa.gov/vis/a010000/a011800/a011862/Helheim_scenic_youtube_hq.mov | 40–64 | PD · ⚠ **720p only** · source has a music bed (mute it) |
|
||||
| `sky_clouds_mountains` | "Cloudy with a Chance of Mountains" — clouds + mountains aerial | US DoD / DVIDS 464215 — https://www.dvidshub.net/video/464215/cloudy-with-chance-mountains-no-lower-thirds | https://d34w7g4gy10iej.cloudfront.net/video/1604/DOD_103314280/DOD_103314280-1024x576-1769k.mp4 | scrub | PD (DoD) · ⚠ **576p** · "no lower thirds" cut; scrub to an aerial-only segment |
|
||||
|
||||
**Group B — cloudscapes & atmosphere (CG renders, "in/above the clouds"):**
|
||||
|
||||
| id | clip | source | direct media | window (s) | notes |
|
||||
|----|------|--------|--------------|-----------|-------|
|
||||
| `sky_clouds_na` | Clouds over North America — cinematic cloud-layer sweep | NASA SVS 3860 — https://svs.gsfc.nasa.gov/3860/ | https://svs.gsfc.nasa.gov/vis/a000000/a003800/a003860/hsymph_na.mp4 | 3–27 | PD · 1080p · closest "sea of clouds" beauty shot (~115 MB) |
|
||||
| `sky_clouds_gulf` | Simulated clouds over the Gulf — artistic "beauty" render | NASA SVS 3921 — https://svs.gsfc.nasa.gov/3921/ | https://svs.gsfc.nasa.gov/vis/a000000/a003900/a003921/michael_beauty_v08_1080p.mp4 | 2–26 | PD · 1080p · art-lit towering clouds (~118 MB) |
|
||||
| `sky_geos_7km` | GEOS-5 modeled clouds — 7 km global resolution | NASA SVS 3657 — https://svs.gsfc.nasa.gov/3657/ | https://svs.gsfc.nasa.gov/vis/a000000/a003600/a003657/comp_geos5_7km.mp4 | 3–27 | PD · 1080p · small file (~23 MB, LFS-friendly) |
|
||||
| `sky_geos_35km` | GEOS-5 modeled clouds — 3.5 km global resolution | NASA SVS 3659 — https://svs.gsfc.nasa.gov/3659/ | https://svs.gsfc.nasa.gov/vis/a000000/a003600/a003659/comp_geos5_3.5km_new.mp4 | 4–28 | PD · 1080p · ⚠ white flashes at loop-restart — land window between them (~141 MB) |
|
||||
| `sky_geos_visible` | GEOS-5 "visible" — photoreal clouds + aerosols deck | NASA SVS 30591 — https://svs.gsfc.nasa.gov/30591/ | https://svs.gsfc.nasa.gov/vis/a030000/a030500/a030591/geos_visible_1080p.mp4 | 10–34 | PD · 1080p (4K master) · ⚠ **large ~573 MB**; scrub for any colorbar |
|
||||
|
||||
**Group C — atmospheric limb / full-disk (orbit-adjacent, optional — may overlap `orbit`):**
|
||||
|
||||
| id | clip | source | direct media | window (s) | notes |
|
||||
|----|------|--------|--------------|-----------|-------|
|
||||
| `sky_earth_atmos` | Spinning Earth with atmosphere, clouds & night lights | NASA SVS 5570 — https://svs.gsfc.nasa.gov/5570/ | https://svs.gsfc.nasa.gov/vis/a000000/a005500/a005570/Earth_wAtmos_spin_02_1080p60.mp4 | 0–24 | PD · 1080p60 (4K master) · visible atmospheric limb + cloud layer (~33 MB) |
|
||||
| `sky_goes_fulldisk` | GOES-16 — first full-disk Earth (true color) | NOAA GOES-R — https://www.goes-r.gov/multimedia/dataAndImageryVideosGoes-16.html | https://www.goes-r.gov/multimedia/originalVideoCopies/dataAndImagery/GOES16/ABI_Jan15_2017_fulldisk_loop.mp4 | 0–7 (loop) | PD (NOAA/NASA) · 1440×1080 · ⚠ tiny NOAA+NASA logo bottom-right (croppable) |
|
||||
|
||||
### Round-2 candidates (2026-06-28) — aerial over rainforest / coast / mountains
|
||||
|
||||
All direct URLs HEAD-verified 200 on 2026-06-28. Strict US-gov **public domain**
|
||||
unless a license is given; CC-BY/CC-BY-SA usable with the credit shown; ⚠ Pexels
|
||||
clips use a custom non-PD license (flagged). "Human-free" on the CC-BY/Pexels clips
|
||||
is from descriptions — eyeball the window before baking.
|
||||
|
||||
**🌴 rainforest (aerial over canopy)** — *clean strict-PD photographic rainforest aerial is thin; NASA's are lidar renders, the DVIDS clips need windowing:*
|
||||
|
||||
| id | clip | source | direct media | window (s) | notes |
|
||||
|----|------|--------|--------------|-----------|-------|
|
||||
| `sky_canopy_transect` | 50 km of Brazilian forest canopy — continuous flyover | NASA SVS 4530 — https://svs.gsfc.nasa.gov/4530 | https://svs.gsfc.nasa.gov/vis/a000000/a004500/a004530/transect2014_1080p30.mp4 | 18–42 | PD · 1080p (4K avail) · ⚠ lidar render, not photographic |
|
||||
| `sky_canopy_flythrough` | Flying through LIDAR canopy — into the leaves | NASA SVS 4532 — https://svs.gsfc.nasa.gov/4532/ | https://svs.gsfc.nasa.gov/vis/a000000/a004500/a004532/flythrough_1080p30.mp4 | 10–34 | PD · 1080p · ⚠ lidar render; abstract |
|
||||
| `sky_jungle_pr` | Puerto Rico — helicopter aerial over jungle mountains | DVIDS 584062 — https://www.dvidshub.net/video/584062/ | https://d34w7g4gy10iej.cloudfront.net/video/1802/DOD_105308416/DOD_105308416.mp4 | 34–58 | PD (USACE) · 1080p, no audio · ⚠ recovery b-roll — only mountain-overflight is clean |
|
||||
| `sky_jungle_thai` | Thailand — aerial over dense jungle canopy (Cobra Gold) | DVIDS 585804 — https://www.dvidshub.net/video/585804/ | https://d34w7g4gy10iej.cloudfront.net/video/1802/DOD_105335187/DOD_105335187.mp4 | 0–24 | PD (USMC) · ≤1080p · ⚠ live-fire exercise — pick early canopy beat |
|
||||
|
||||
**🌊 coast — no humans (aerial):**
|
||||
|
||||
| id | clip | source | direct media | window (s) | license / notes |
|
||||
|----|------|--------|--------------|-----------|-----------------|
|
||||
| `sky_coast_icefjord` | Greenland — Ilulissat ice fjord, glaciers meeting the sea | NASA SVS 11982 — https://svs.gsfc.nasa.gov/11982/ | https://svs.gsfc.nasa.gov/vis/a010000/a011900/a011982/Aerials.webm | 30–54 | PD · 4K webm ~56 MB · real helicopter aerials (1080p mp4 `Aerials_2.mp4` also live, ~282 MB) |
|
||||
| `sky_coast_moher` | Cliffs of Moher — drone over Atlantic sea cliffs | Wikimedia — https://commons.wikimedia.org/wiki/File:Cliffs_of_Moher_drone-video.webm | https://upload.wikimedia.org/wikipedia/commons/6/62/Cliffs_of_Moher_drone-video.webm | 15–39 | **CC BY-SA 4.0 — credit Wiebe de Jager** · 1080p · use over-water window |
|
||||
| `sky_coast_norway` | Arctic Norway — fjords & coastal waterways (FPV drone) | Wikimedia — https://commons.wikimedia.org/wiki/File:Aerial_imagery_of_fjords_and_rivers_in_Arctic_Norway.webm | https://upload.wikimedia.org/wikipedia/commons/8/80/Aerial_imagery_of_fjords_and_rivers_in_Arctic_Norway.webm | 60–84 | **CC BY 3.0 — credit anotherview** · 4K ⚠ ~485 MB, trim |
|
||||
| `sky_coast_dunes` | The Slufter, Texel — coastal dunes & tidal channels | Wikimedia — https://commons.wikimedia.org/wiki/File:The_Slufter_from_above.webm | https://upload.wikimedia.org/wikipedia/commons/1/12/The_Slufter_from_above.webm | 30–54 | **CC BY 3.0 — credit Sander van Schie – De Verfilmer** · 4K ⚠ ~436 MB, trim |
|
||||
| `sky_coast_reeflagoon` | Coral reef / turquoise lagoon from above | Pexels 12952333 — https://www.pexels.com/video/drone-video-of-coral-reefs-under-turquoise-sea-water-12952333/ | https://videos.pexels.com/video-files/12952333/12952333-hd_1920_1080_25fps.mp4 | 0–24 | ⚠ **Pexels License (not PD)** · 1080p (1440p avail) · "No People" tag — confirm |
|
||||
| `sky_coast_cliffsurf` | Rugged cliff coastline above the surf | Pexels 5619876 — https://www.pexels.com/video/drone-footage-of-a-cliff-coastline-above-the-sea-water-5619876/ | https://videos.pexels.com/video-files/5619876/5619876-hd_1920_1080_25fps.mp4 | 0–24 | ⚠ **Pexels License (not PD)** · 1080p ~8 MB · confirm no surfers/boats |
|
||||
|
||||
**⛰️ mountains (aerial over peaks & ridgelines)** — *the richest strict-PD vein (NPS + US Forest Service):*
|
||||
|
||||
| id | clip | source | direct media | window (s) | notes |
|
||||
|----|------|--------|--------------|-----------|-------|
|
||||
| `sky_mtn_rocky` | Rocky Mountain NP — aerial over alpine summits & tundra | NPS — https://www.nps.gov/media/video/view.htm?id=8E66DB80-64C2-4258-B878-5F9244FB6C60 | https://www.nps.gov/nps-audiovideo/audiovideo/188046ff-c9d6-4859-8c7a-1e0d3d306ed61080p.mp4 | 60–84 | PD · 1080p · pick peaks segment (Trail Ridge Rd in some shots) |
|
||||
| `sky_mtn_castlecrags` | Castle Crags — past jagged granite spires & ridgelines | DVIDS 998726 — https://www.dvidshub.net/video/998726/ | https://d34w7g4gy10iej.cloudfront.net/video/2603/DOD_111563303/DOD_111563303.mp4 | 120–144 | PD (USFS) · 1080p · confirmed no text/people — strongest pick |
|
||||
| `sky_mtn_shasta` | Mount Shasta — drone aerial of the snow-capped volcano | DVIDS 998733 — https://www.dvidshub.net/video/998733/ | https://d34w7g4gy10iej.cloudfront.net/video/2603/DOD_111563312/DOD_111563312.mp4 | 30–54 | PD (USFS) · 1080p · distant town in part of clip |
|
||||
| `sky_mtn_granite` | Shasta-Trinity NF — high alpine terrain near Granite Peak | DVIDS 998735 — https://www.dvidshub.net/video/998735/ | https://d34w7g4gy10iej.cloudfront.net/video/2603/DOD_111563314/DOD_111563314.mp4 | 90–114 | PD (USFS) · 1080p · ridgelines, no text/people noted |
|
||||
| `sky_mtn_snowfield` | Mount Shasta slopes — over white alpine snowfields | DVIDS 998725 — https://www.dvidshub.net/video/998725/ | https://d34w7g4gy10iej.cloudfront.net/video/2603/DOD_111563300/DOD_111563300.mp4 | 30–54 | PD (USFS) · 1080p · favor higher-altitude snow (trailhead may appear low) |
|
||||
| `sky_mtn_sierra` | Sierra near Peddler Hill — snow-blanketed high country | DVIDS 996457 — https://www.dvidshub.net/video/996457/ | https://d34w7g4gy10iej.cloudfront.net/video/2602/DOD_111530221/DOD_111530221.mp4 | 40–64 | PD (USFS) · 1080p · softer snowy ridges |
|
||||
|
||||
### Round-3 candidates (2026-06-28) — fresh rainforest + longer reef/cliff
|
||||
|
||||
Replaces the rejected round-2 rainforest batch, and gives the two "liked but too
|
||||
short" coast subjects proper length. All HEAD-verified 200 on 2026-06-28; all
|
||||
durations support a 24s+ window. **Mostly Pexels License** (free to use, **not**
|
||||
PD/CC — flagged) since clean strict-PD photographic aerial in these subjects is
|
||||
effectively absent; one CC-BY option included. Human-free is per description —
|
||||
eyeball the window before baking.
|
||||
|
||||
**🌴 rainforest — fresh batch (photographic drone, wild/uninhabited):**
|
||||
|
||||
| id | clip | direct media | window (s) | license / notes |
|
||||
|----|------|--------------|-----------|-----------------|
|
||||
| `sky_jungle_amazon` | Lush Amazon rainforest canopy — slow aerial push | https://videos.pexels.com/video-files/31291128/13360655_3840_2160_60fps.mp4 | 3–27 | ⚠ Pexels · 4K60 · confirmed no people/buildings/roads/watermark — **top pick** · [page](https://www.pexels.com/video/aerial-view-of-lush-amazon-rainforest-canopy-31291128/) |
|
||||
| `sky_jungle_canopy` | Lush green jungle canopy — gliding aerial | https://videos.pexels.com/video-files/29780524/12796533_1920_1080_25fps.mp4 | 2–26 | ⚠ Pexels · native 1080p ~61 MB · [page](https://www.pexels.com/video/aerial-view-of-lush-green-jungle-canopy-29780524/) |
|
||||
| `sky_jungle_river` | Amazon rainforest river through unbroken jungle | https://videos.pexels.com/video-files/35969886/15251199_3840_2160_30fps.mp4 | 3–27 | ⚠ Pexels · 4K · the "jungle river" angle · [page](https://www.pexels.com/video/stunning-aerial-view-of-amazon-rainforest-river-35969886/) |
|
||||
| `sky_jungle_misty` | Misty mountain forest — low cloud over jungle hills | https://videos.pexels.com/video-files/34697024/14706326_3840_2160_30fps.mp4 | 3–27 | ⚠ Pexels · 4K · cloud-forest mood · [page](https://www.pexels.com/video/misty-mountain-forest-aerial-view-in-dense-jungle-34697024/) |
|
||||
| `sky_jungle_clouds` | Lush greenery shrouded in clouds — aerial | https://videos.pexels.com/video-files/36998596/15673868_3840_2160_60fps.mp4 | 3–27 | ⚠ Pexels · 4K60 ~19 MB (small) · [page](https://www.pexels.com/video/aerial-view-of-lush-greenery-shrouded-in-clouds-36998596/) |
|
||||
| `sky_jungle_riverhd` | Serene jungle river landscape — aerial | https://videos.pexels.com/video-files/35897960/15226438_1920_1080_30fps.mp4 | 0–24 | ⚠ Pexels · native 1080p ~25 MB (fits LFS as-is) · [page](https://www.pexels.com/video/serene-aerial-view-of-jungle-river-landscape-35897960/) |
|
||||
|
||||
**🌊 coast — longer replacements (≥26s of footage):**
|
||||
|
||||
| id | clip | direct media | window (s) | dur | license / notes |
|
||||
|----|------|--------------|-----------|-----|-----------------|
|
||||
| `sky_coast_reef` | Coral reef + blue lagoon, Balicasag (replaces `reeflagoon`) | https://videos.pexels.com/video-files/36379860/15428423_3840_2160_60fps.mp4 | 4–28 | 32s | ⚠ Pexels · 4K60 · truest coral match · ⚠ dive site — check window for boats · [page](https://www.pexels.com/video/aerial-drone-view-of-balicasag-island-coral-reefs-36379860/) |
|
||||
| `sky_coast_turquoise` | Rocks in shallow turquoise water, straight down (reeflagoon alt) | https://videos.pexels.com/video-files/12738229/12738229-uhd_2560_1440_30fps.mp4 | 4–28 | 32s | ⚠ Pexels · 1440p · cleanest turquoise (rocky seabed > coral) · [page](https://www.pexels.com/video/drone-view-of-rocks-in-shallow-turquoise-water-12738229/) |
|
||||
| `sky_coast_cliffsurf2` | Mountain cliffs above the sea, crashing surf (replaces `cliffsurf`) | https://videos.pexels.com/video-files/4385207/4385207-hd_1920_1080_30fps.mp4 | 8–32 | 50s | ⚠ Pexels · native 1080p · rugged cliffs + surf, no man-made · [page](https://www.pexels.com/video/drone-footage-of-mountain-cliffs-above-the-sea-water-4385207/) |
|
||||
| `sky_coast_cliffspain` | Dramatic Atlantic sea cliffs, N. Spain (cliffsurf alt) | https://videos.pexels.com/video-files/36668415/15545045_3840_2160_60fps.mp4 | 1–25 | 26s | ⚠ Pexels · 4K60 · page-confirmed no people/boats/buildings/text · [page](https://www.pexels.com/video/dramatic-cliffs-in-northern-spain-with-ocean-views-36668415/) |
|
||||
| `sky_coast_oregon` | Oregon Coast — rugged Pacific sea cliffs & surf (FPV drone) | https://upload.wikimedia.org/wikipedia/commons/4/40/Oregon_Coast_Odyssey-_FPV_Drone_Captures_Stunning_Ocean_Views.webm | 20–44 | 96s | **CC BY 3.0 — credit EagleView** · 4K · the one clean CC cliff option · ⚠ FPV = faster/tilted motion · [page](https://commons.wikimedia.org/wiki/File:Oregon_Coast_Odyssey-_FPV_Drone_Captures_Stunning_Ocean_Views.webm) |
|
||||
|
||||
**🌴 Round-3b rainforest — longer versions of the three liked-but-too-short moods (still open):**
|
||||
|
||||
Durations measured via ffprobe; all comfortably support a 24s window. All ⚠ Pexels License.
|
||||
|
||||
| id | clip | direct media | window (s) | dur | notes |
|
||||
|----|------|--------------|-----------|-----|-------|
|
||||
| `sky_jungle_misty` | Misty rainforest in dense fog — fog rolling over canopy | https://videos.pexels.com/video-files/30770305/13162099_3840_2160_30fps.mp4 | 10–34 | 120s | 4K · top misty pick, no flags · [page](https://www.pexels.com/video/misty-rainforest-aerial-view-in-dense-fog-30770305/) |
|
||||
| `sky_jungle_misty_alt` | Misty mountainous jungle, Vietnam — mist on ridgelines | https://videos.pexels.com/video-files/30575002/13092740_3840_2160_60fps.mp4 | 3–27 | 31s | 4K60 · cloud-forest ridgelines · [page](https://www.pexels.com/video/misty-mountainous-jungle-in-vietnam-30575002/) |
|
||||
| `sky_jungle_clouds` | Serene misty green hills — same shoot as original, longer | https://videos.pexels.com/video-files/36998553/15673828_3840_2160_60fps.mp4 | 5–29 | 38s | 4K60 · adjacent ID to the liked clip · [page](https://www.pexels.com/video/serene-aerial-view-of-misty-green-hills-36998553/) |
|
||||
| `sky_jungle_clouds_alt` | Flying through clouds over green hills | https://videos.pexels.com/video-files/28499216/12400645_3840_2160_25fps.mp4 | 12–36 | 49s | 4K · strongest "shrouded" feel · [page](https://www.pexels.com/video/aerial-view-through-clouds-over-green-hills-28499216/) |
|
||||
| `sky_jungle_riverhd` | Lush green forest & river — river through canopy | https://videos.pexels.com/video-files/35612503/15091993_3840_2160_30fps.mp4 | 10–34 | 54s | 4K · 2nd river (redundant if one river is enough) · [page](https://www.pexels.com/video/aerial-view-of-lush-green-forest-and-river-35612503/) |
|
||||
|
||||
**✨ Round-3c rainforest — more distinct looks (balance rainforest to ~4, still open):**
|
||||
|
||||
Distinct subjects beyond canopy/river/misty. All ⚠ Pexels License, all ≥33s.
|
||||
Gaps the search could NOT fill at the 28s + clean bar: golden-hour raking light,
|
||||
volcanic-rainforest ridges, dramatic oxbow S-curves, mist-in-columns (closest
|
||||
options were <28s, vertical, contaminated with villages/canoes, or temperate biome).
|
||||
|
||||
| id | clip | direct media | window (s) | dur | look |
|
||||
|----|------|--------------|-----------|-----|------|
|
||||
| `sky_jungle_waterfall` | Waterfall plunging out of dense jungle canopy | https://videos.pexels.com/video-files/8334944/8334944-uhd_4096_2160_24fps.mp4 | 10–34 | 46s | WATERFALL — fully distinct, strongest new look · [page](https://www.pexels.com/video/drone-footage-of-a-forest-with-waterfalls-8334944/) |
|
||||
| `sky_jungle_warmlight` | Tropical jungle canopy in warm hazy morning light | https://videos.pexels.com/video-files/26829790/12015586_3840_2160_30fps.mp4 | 4–28 | ~33s | warm low-light (closest to golden-hour found) · [page](https://www.pexels.com/video/aerial-view-of-a-tropical-jungle-with-fog-and-trees-26829790/) |
|
||||
| `sky_jungle_textured` | Varied jungle canopy — mixed greens & emergent texture | https://videos.pexels.com/video-files/2317719/2317719-uhd_3840_2160_24fps.mp4 | 6–30 | 37s | textured/emergent canopy, richer than the plain glide · [page](https://www.pexels.com/video/aerial-footage-of-a-jungle-2317719/) |
|
||||
|
||||
> **Round-3 sourcing note:** reef/lagoon clips ≥25s are scarce (most are 12–20s); the
|
||||
> two above clear the bar. Cliff-coast supply is good. The genuine remaining gap is
|
||||
> *strict-PD / CC* reef + rainforest — Commons has no clean aerial reef video and only
|
||||
> short/credit-bound rainforest, so Pexels is the pragmatic floor here.
|
||||
|
||||
> **Round-2 sourcing notes:** NPS bans in-park drone ops (2014) → almost no NPS
|
||||
> *aerial* coast/forest; DVIDS coast aerials nearly all had people/boats/vehicles;
|
||||
> photographic strict-PD rainforest aerial is the genuine gap (NASA's are lidar
|
||||
> renders) — Pixabay/Pexels drone canopy is the fallback if none of the above land.
|
||||
> DVIDS direct files serve as `binary/octet-stream` but are valid mp4 (the page uses
|
||||
> `<source type="video/mp4">` so they preview inline).
|
||||
|
||||
> **Sources considered & rejected** (so a future session doesn't re-chase them):
|
||||
> NOAA's high-res GOES satellite loops (FOUREASTER full-disk, Irma/Maria geocolor,
|
||||
> "Clouds with Precipitation", "Satellite Models") all carry burned-in telemetry
|
||||
> bars / political borders / date stamps / colorbars → **unusable**. NOAA's clean
|
||||
> globes (SOS "Clouds real-time", "Blue Marble") are text-free but only **400×400**
|
||||
> *and* overlap the existing `orbit` blue-marble. NESDIS "Earth from Orbit" and the
|
||||
> Ocean-Today "Hurricane Hunters" piece are produced/narrated packages with
|
||||
> text+people. NASA "Fly Above Alaskan Glaciers" is 360°/equirectangular (not a flat
|
||||
> clip). The IceBridge cockpit "banking over Disko Bay" cut has windshield/panel in
|
||||
> frame. The full **NASA SVS IceBridge 13471–13477** series is a rich vein if more
|
||||
> aerial-ice clips are wanted later (each page also offers multi-GB 4K `.mov`
|
||||
> masters).
|
||||
|
||||
## 🛰️ orbit (pool of 3)
|
||||
|
||||
| id | clip | source | window (s) | license |
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
# Scrub-driven Altitude Transitions Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the Altitude knob position *continuously* drive the scale transition — dragging the dial scrubs the morph video's `currentTime` and crossfades the two adjacent scale soundtracks by knob angle, holding wherever the knob stops, fully reversible.
|
||||
|
||||
**Architecture:** Introduce a continuous float **position** `pos` (integers = altitudes/detents, fractions = mid-morph blend). A small pure module (`scrub.js`) owns the math (position→segment, frac→currentTime, audio gains, integer-crossing detection); `app.js` becomes the DOM/event glue that drives `pos` from the dial, seeks the morph `<video>`, crossfades two `<audio>` elements, and commits/locks/re-rolls on integer crossings. Phase 1 builds the interaction against the *current* (sparse-GOP) morphs to validate feel; Phase 2 re-bakes all 154 morphs all-intra for smooth seeking.
|
||||
|
||||
**Tech Stack:** Vanilla JS (no framework, plain `<script>`), Node built-in `node --test` for pure-logic unit tests, Playwright for E2E, Python + ffmpeg/x264 for the morph re-bake (`simulator/build_pool_manifest.py`), git-LFS for media.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Spec contract:** `docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md` — implement its locked decisions verbatim.
|
||||
- **In-between state:** hold the blend wherever the knob stops — **no auto-complete** (continuous-encoder model).
|
||||
- **Turn-back:** scrubs the **same** segment morph in reverse; landing back on the start integer re-locks **exactly** the clip you left (fully reversible).
|
||||
- **Destination randomness:** the destination altitude's clip is a random pick from its pool, **fixed for a single continuous gesture**, **re-rolled on each fresh approach** (each fresh entry into a segment from an integer).
|
||||
- **Scroll wheel:** auto-scrub one altitude over ~0.6 s, then lock. **Tap a dial label:** auto-scrub to that altitude the shortest way around.
|
||||
- **Lock-per-altitude** still holds: a resting altitude's clip stays locked until you commit into a different one.
|
||||
- **Canonical segment file (locked interpretation):** for the segment between altitude indices `lo` and `lo+1`, always use the **descend/forward** morph `morphByPair["<clip@lo>→<clip@lo+1>"]`, with `currentTime = frac × duration`. Reverse travel seeks the **same** file backward. `.rev` files are not used by the scrub interaction (they remain baked for back-compat).
|
||||
- **Git transport:** SSH only (`ssh://git@git.benstull.org/...`). No inline trailing comments on shell/CLI command lines.
|
||||
- **Pipeline:** ship via §9 — localhost + E2E green → PPE + E2E green → prod (prod human-gated). Every UI change carries its E2E browser tests as first-class tasks.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create** `simulator/static/scrub.js` — pure scrub math, UMD (browser global `HEFScrub` + CommonJS `module.exports`). No DOM.
|
||||
- **Create** `simulator/unit/scrub.test.js` — `node --test` unit suite for `scrub.js`.
|
||||
- **Create** `simulator/unit/README.md` — one-liner on running the unit suite.
|
||||
- **Modify** `simulator/static/index.html` — load `scrub.js` before `app.js`; add a second `<audio id="aud-b" loop preload="auto">`.
|
||||
- **Modify** `simulator/static/app.js` — replace the discrete drag/wheel/tap `advance()` flow with the scrub engine (`setPos`, `rebuildSegment`, `commitCrossings`, auto-scrub animator); two-element audio crossfade; client-side clip pick from `scale.pool`.
|
||||
- **Modify** `simulator/e2e/tests/altitude-lock.spec.ts` (or add `scrub.spec.ts`) — assert `currentTime` + the two audio gains track the dial angle and reverse on turn-back; full turn commits+locks; wheel auto-scrubs one altitude and locks.
|
||||
- **Modify** `simulator/build_pool_manifest.py` — `_make_transition` / `_make_reverse` emit **all-intra** H.264 (`-g 1 -keyint_min 1 -sc_threshold 0`) for seekable frames.
|
||||
- **Modify** `tests/test_build_pool_manifest.py` — assert the ffmpeg arg list carries the all-intra flags.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Scrub interaction against current morphs
|
||||
|
||||
### Task 1: Pure scrub math module (`scrub.js`) + Node unit tests
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/static/scrub.js`
|
||||
- Create: `simulator/unit/scrub.test.js`
|
||||
- Create: `simulator/unit/README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (all pure, no DOM), exported on `HEFScrub` and `module.exports`:
|
||||
- `clamp01(x: number): number`
|
||||
- `wrapIndex(i: number, n: number): number` — `((i % n) + n) % n`
|
||||
- `segmentOf(pos: number): { lo: number, frac: number }` — `lo = floor(pos)`, `frac = pos - lo`
|
||||
- `accumToPos(restPos: number, accumDeg: number, stepDeg: number): number` — `restPos + accumDeg/stepDeg`
|
||||
- `fracToTime(frac: number, duration: number): number` — `clamp01(frac) * (duration || 0)`
|
||||
- `crossfadeGains(frac: number): { from: number, to: number }` — `{ from: 1-clamp01(frac), to: clamp01(frac) }`
|
||||
- `integerCrossings(prevPos: number, pos: number): Array<{ index: number, dir: number }>` — integers strictly crossed going `prevPos → pos`, in travel order, `dir` = `+1` descending (pos increasing) / `-1` ascending. Landing exactly on an integer counts as crossing it.
|
||||
|
||||
- [ ] **Step 1: Write the failing unit suite**
|
||||
|
||||
Create `simulator/unit/scrub.test.js`:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const S = require("../static/scrub.js");
|
||||
|
||||
test("clamp01 bounds to [0,1]", () => {
|
||||
assert.equal(S.clamp01(-0.2), 0);
|
||||
assert.equal(S.clamp01(0.4), 0.4);
|
||||
assert.equal(S.clamp01(1.5), 1);
|
||||
});
|
||||
|
||||
test("wrapIndex wraps both directions", () => {
|
||||
assert.equal(S.wrapIndex(0, 5), 0);
|
||||
assert.equal(S.wrapIndex(5, 5), 0);
|
||||
assert.equal(S.wrapIndex(-1, 5), 4);
|
||||
assert.equal(S.wrapIndex(7, 5), 2);
|
||||
});
|
||||
|
||||
test("segmentOf splits floor and fraction", () => {
|
||||
assert.deepEqual(S.segmentOf(2), { lo: 2, frac: 0 });
|
||||
const s = S.segmentOf(2.4);
|
||||
assert.equal(s.lo, 2);
|
||||
assert.ok(Math.abs(s.frac - 0.4) < 1e-9);
|
||||
});
|
||||
|
||||
test("accumToPos maps knob degrees to position", () => {
|
||||
assert.equal(S.accumToPos(2, 0, 72), 2);
|
||||
assert.equal(S.accumToPos(2, 72, 72), 3);
|
||||
assert.equal(S.accumToPos(2, -36, 72), 1.5);
|
||||
});
|
||||
|
||||
test("fracToTime clamps and scales by duration", () => {
|
||||
assert.equal(S.fracToTime(0, 1.5), 0);
|
||||
assert.equal(S.fracToTime(0.5, 1.5), 0.75);
|
||||
assert.equal(S.fracToTime(1.2, 1.5), 1.5);
|
||||
assert.equal(S.fracToTime(0.5, 0), 0);
|
||||
});
|
||||
|
||||
test("crossfadeGains splits energy by fraction", () => {
|
||||
assert.deepEqual(S.crossfadeGains(0), { from: 1, to: 0 });
|
||||
assert.deepEqual(S.crossfadeGains(1), { from: 0, to: 1 });
|
||||
assert.deepEqual(S.crossfadeGains(0.25), { from: 0.75, to: 0.25 });
|
||||
});
|
||||
|
||||
test("integerCrossings: none while inside a segment", () => {
|
||||
assert.deepEqual(S.integerCrossings(2.1, 2.9), []);
|
||||
assert.deepEqual(S.integerCrossings(2.0, 2.5), []);
|
||||
assert.deepEqual(S.integerCrossings(3.0, 2.5), []);
|
||||
});
|
||||
|
||||
test("integerCrossings: ascending and descending single crossings", () => {
|
||||
assert.deepEqual(S.integerCrossings(2.4, 3.0), [{ index: 3, dir: 1 }]);
|
||||
assert.deepEqual(S.integerCrossings(2.0, 3.0), [{ index: 3, dir: 1 }]);
|
||||
assert.deepEqual(S.integerCrossings(3.4, 3.0), [{ index: 3, dir: -1 }]);
|
||||
});
|
||||
|
||||
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 }]);
|
||||
});
|
||||
```
|
||||
|
||||
Create `simulator/unit/README.md`:
|
||||
|
||||
```markdown
|
||||
# Simulator unit tests
|
||||
|
||||
Pure-logic JS unit tests (no browser), run with Node's built-in test runner:
|
||||
|
||||
node --test simulator/unit/
|
||||
|
||||
`scrub.test.js` covers `simulator/static/scrub.js` — the pure altitude-scrub math
|
||||
(position→segment, frac→currentTime, audio gains, integer-crossing detection).
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the suite to verify it fails**
|
||||
|
||||
Run: `node --test simulator/unit/scrub.test.js`
|
||||
Expected: FAIL — `Cannot find module '../static/scrub.js'`.
|
||||
|
||||
- [ ] **Step 3: Write `scrub.js` to pass**
|
||||
|
||||
Create `simulator/static/scrub.js`:
|
||||
|
||||
```js
|
||||
// Pure altitude-scrub math — no DOM. A continuous `pos` (float) is the knob:
|
||||
// integers are altitudes/detents, fractions are mid-morph blend. UMD so the
|
||||
// browser gets a `HEFScrub` global and `node --test` can require() it.
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFScrub = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
const wrapIndex = (i, n) => ((i % n) + n) % n;
|
||||
|
||||
function segmentOf(pos) {
|
||||
const lo = Math.floor(pos);
|
||||
return { lo, frac: pos - lo };
|
||||
}
|
||||
|
||||
function accumToPos(restPos, accumDeg, stepDeg) {
|
||||
return restPos + accumDeg / stepDeg;
|
||||
}
|
||||
|
||||
function fracToTime(frac, duration) {
|
||||
return clamp01(frac) * (duration || 0);
|
||||
}
|
||||
|
||||
function crossfadeGains(frac) {
|
||||
const f = clamp01(frac);
|
||||
return { from: 1 - f, to: f };
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const out = [];
|
||||
if (pos > prevPos) {
|
||||
for (let k = Math.floor(prevPos) + 1; k <= pos + 1e-9; k++) out.push({ index: k, dir: 1 });
|
||||
} else if (pos < prevPos) {
|
||||
for (let k = Math.ceil(prevPos) - 1; k >= pos - 1e-9; k--) out.push({ index: k, dir: -1 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, integerCrossings };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the suite to verify it passes**
|
||||
|
||||
Run: `node --test simulator/unit/scrub.test.js`
|
||||
Expected: PASS — all tests, 0 failures.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/scrub.js simulator/unit/scrub.test.js simulator/unit/README.md
|
||||
git commit -m "feat(sim): pure altitude-scrub math module + node unit suite"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Two-element audio crossfade
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html` (add `<audio id="aud-b">`; load `scrub.js`)
|
||||
- Modify: `simulator/static/app.js` (audio layer ~lines 950–1045)
|
||||
- Test: `simulator/unit/scrub.test.js` (gains already covered in Task 1 — no new pure logic)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HEFScrub.crossfadeGains`, `soundtrackUrl()`-style per-scale URL resolution.
|
||||
- Produces:
|
||||
- `scaleAudioUrl(index: number): string|null` — the soundtrack URL for ring scale `index` (wrapped), using the ring `audio` field then `SCALE_AUDIO_FALLBACK`.
|
||||
- `blendAudio(loIndex: number, frac: number): void` — element A plays scale `loIndex`, element B plays scale `loIndex+1` (wrapped), gains `crossfadeGains(frac)`; loads each element's src on first use per gesture. No-op when Audio toggle is off.
|
||||
- `restAudio(index: number): void` — settle to a single scale at rest (full gain on the element holding `index`, fade the other to 0). Replaces the tail of `applyAudio()` for the at-rest case.
|
||||
|
||||
- [ ] **Step 1: Add the second audio element + scrub.js script tag**
|
||||
|
||||
In `simulator/static/index.html`, beside the existing `<audio id="aud" loop preload="auto"></audio>` (line ~21), add:
|
||||
|
||||
```html
|
||||
<audio id="aud" loop preload="auto"></audio>
|
||||
<audio id="aud-b" loop preload="auto"></audio>
|
||||
```
|
||||
|
||||
And load the pure module before `app.js` (line ~112):
|
||||
|
||||
```html
|
||||
<script src="/scrub.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing E2E expectation (gains exist on two elements)**
|
||||
|
||||
Add to `simulator/e2e/tests/altitude-lock.spec.ts` a focused check (full assertions land in Task 5; this verifies the element + hook exist):
|
||||
|
||||
```ts
|
||||
test("two audio elements exist for crossfade", async ({ page }) => {
|
||||
await boot(page);
|
||||
const n = await page.evaluate(() => document.querySelectorAll("audio#aud, audio#aud-b").length);
|
||||
expect(n).toBe(2);
|
||||
});
|
||||
```
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test -g "two audio elements"`
|
||||
Expected: FAIL until the markup change is loaded (and PASS once the running server serves the new `index.html`).
|
||||
|
||||
- [ ] **Step 3: Implement the crossfade audio layer in `app.js`**
|
||||
|
||||
Replace the single-element logic around `playUrl`/`applyAudio` (app.js ~1019–1045). Keep `aud` as element A and add `audB` as element B; generalize URL resolution by index:
|
||||
|
||||
```js
|
||||
const aud = $("aud");
|
||||
const audB = $("aud-b");
|
||||
let audLastErr = "";
|
||||
|
||||
// Soundtrack URL for ring scale `index` (wrapped), ring `audio` field then fallback.
|
||||
function scaleAudioUrl(index) {
|
||||
if (!ring || !ring.scales.length) return null;
|
||||
const s = ring.scales[HEFScrub.wrapIndex(index, ring.scales.length)];
|
||||
const a = s.audio || SCALE_AUDIO_FALLBACK[s.id];
|
||||
return a ? "/media/audio/" + a : null;
|
||||
}
|
||||
|
||||
// Start (once) and set the volume of one element to a given url. Safari needs the
|
||||
// first play() inside a user gesture; later programmatic plays reuse the element.
|
||||
function ensurePlaying(el, url, vol) {
|
||||
if (!url) { el.volume = 0; if (!el.paused) el.pause(); return; }
|
||||
if (el.dataset.url !== url) {
|
||||
el.dataset.url = url;
|
||||
el.src = mediaUrl(url);
|
||||
}
|
||||
if (el.paused) {
|
||||
const pr = el.play();
|
||||
if (pr) pr.then(() => { audLastErr = ""; updateAudioStatus(); })
|
||||
.catch((e) => { audLastErr = "play BLOCKED: " + ((e && e.name) || e); updateAudioStatus(); });
|
||||
}
|
||||
el.volume = HEFScrub.clamp01(vol);
|
||||
}
|
||||
|
||||
// Mid-segment: A = scale `loIndex`, B = scale loIndex+1, gains by frac.
|
||||
function blendAudio(loIndex, frac) {
|
||||
if (!$("audio").checked) return;
|
||||
const g = HEFScrub.crossfadeGains(frac);
|
||||
ensurePlaying(aud, scaleAudioUrl(loIndex), g.from);
|
||||
ensurePlaying(audB, scaleAudioUrl(loIndex + 1), g.to);
|
||||
updateAudioStatus();
|
||||
}
|
||||
|
||||
// At rest on a single altitude: the element already holding it stays at full gain,
|
||||
// the other fades to 0. Picks whichever element currently carries `index`'s url.
|
||||
function restAudio(index) {
|
||||
if (!$("audio").checked) { fadeVolume(aud, 0, FADE_MS, () => aud.pause()); fadeVolume(audB, 0, FADE_MS, () => audB.pause()); return; }
|
||||
const url = scaleAudioUrl(index);
|
||||
const onB = audB.dataset.url === url && url;
|
||||
const active = onB ? audB : aud;
|
||||
const idle = onB ? aud : audB;
|
||||
ensurePlaying(active, url, 1);
|
||||
fadeVolume(idle, 0, FADE_MS);
|
||||
}
|
||||
```
|
||||
|
||||
Then make the Audio toggle and altitude-rest paths call `restAudio(ringIndex)` instead of the old `applyAudio()` swap. Keep `applyAudio()` as a thin shim that calls `restAudio(ringIndex)` so existing call sites (e.g. the toggle handler at ~1073, `update()`) keep working:
|
||||
|
||||
```js
|
||||
function applyAudio() { restAudio(ringIndex); }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run unit suite + the focused E2E**
|
||||
|
||||
Run: `node --test simulator/unit/scrub.test.js`
|
||||
Expected: PASS.
|
||||
Run: `cd simulator/e2e && npx playwright test -g "two audio elements"` (with the dev server running)
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/altitude-lock.spec.ts
|
||||
git commit -m "feat(sim): two-element audio crossfade keyed by scrub fraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Scrub engine — drag drives `pos`, seeks the morph, commits/locks/re-rolls
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` (`onDialDown/Move/Up` ~766–795; `advance` ~640–679; `playTransition` ~595–636; needle/render ~739–751)
|
||||
- Test: `simulator/unit/scrub.test.js` (pure parts covered; engine glue is DOM, covered by E2E in Task 5)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HEFScrub.*`, `morphByPair`, `scale.pool` (client ring), `mediaUrl`, `setNeedle`, `dialStep`, `ensureClipMedia`/`update` (base-clip loop), `blendAudio`/`restAudio`.
|
||||
- Produces:
|
||||
- `pos` (module-level float; rest value = `ringIndex`).
|
||||
- `pickPoolClip(index: number): string` — random `clip_id` from ring scale `index`'s pool (wrapped).
|
||||
- `rebuildSegment(lo: number, enteredFrom: number): void` — sets `activeSeg = { lo, clipLo, clipHi, file }`; the side equal to the just-rested altitude takes `activeClipId`, the other is a fresh `pickPoolClip` (re-roll); `file = morphByPair["<clipLo>→<clipHi>"]` (descend canonical).
|
||||
- `setPos(next: number, opts?: { commit?: boolean }): void` — clamps thrash via one seek per rAF; seeks `vid.currentTime = fracToTime(frac, dur)`; `setNeedle(next * dialStep())`; `blendAudio(lo, frac)`; processes `integerCrossings(pos, next)` to commit/lock/re-roll; updates `pos`.
|
||||
- `commitTo(index: number): void` — `ringIndex = wrapIndex(index,n)`, `activeClipId = clip at that index for the active segment`, settle base loop + `restAudio` when frac becomes 0.
|
||||
|
||||
- [ ] **Step 1: Write the failing E2E (drag partway tracks currentTime + gains)**
|
||||
|
||||
Add to the E2E spec (full version in Task 5; this drives the engine):
|
||||
|
||||
```ts
|
||||
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
|
||||
await boot(page);
|
||||
// Drag the dial ~40% of one detent (clockwise = descend).
|
||||
const box = await page.locator("#dial").boundingBox();
|
||||
const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); // partial turn
|
||||
const state = await page.evaluate(() => ({
|
||||
t: (document.querySelector("video") as HTMLVideoElement).currentTime,
|
||||
ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
|
||||
gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
|
||||
}));
|
||||
expect(state.t).toBeGreaterThan(0); // morph scrubbed off frame 0
|
||||
expect(state.gb).toBeGreaterThan(0); // next scale audio fading in
|
||||
expect(state.ga).toBeLessThan(1); // current scale fading out
|
||||
await page.mouse.up();
|
||||
});
|
||||
```
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test -g "scrubs morph currentTime"`
|
||||
Expected: FAIL (engine not built yet).
|
||||
|
||||
- [ ] **Step 2: Implement the scrub engine in `app.js`**
|
||||
|
||||
Add the position state + segment + setPos near the dial logic, and rewrite `onDialMove`/`onDialUp` to drive it live:
|
||||
|
||||
```js
|
||||
let pos = 0; // continuous knob position; rest value == ringIndex
|
||||
let activeSeg = null; // { lo, clipLo, clipHi, file }
|
||||
let seekPending = false; // throttle: one currentTime seek per rAF
|
||||
|
||||
function pickPoolClip(index) {
|
||||
const n = ring.scales.length;
|
||||
const s = ring.scales[HEFScrub.wrapIndex(index, n)];
|
||||
const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
|
||||
return pool[Math.floor(Math.random() * pool.length)].clip_id;
|
||||
}
|
||||
|
||||
// Build (or re-roll) the segment [lo, lo+1]. The side equal to the rested altitude
|
||||
// keeps the locked clip; the other end is a fresh random pick (re-roll).
|
||||
function rebuildSegment(lo, enteredFrom) {
|
||||
const n = ring.scales.length;
|
||||
const loId = (enteredFrom === HEFScrub.wrapIndex(lo, n)) ? activeClipId : pickPoolClip(lo);
|
||||
const hiId = (enteredFrom === HEFScrub.wrapIndex(lo + 1, n)) ? activeClipId : pickPoolClip(lo + 1);
|
||||
const file = morphByPair[`${loId}→${hiId}`] || null;
|
||||
activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
|
||||
if (file) {
|
||||
overlay.style.opacity = "0"; affectLayer.style.opacity = "0"; tint.style.opacity = "0";
|
||||
vid.style.filter = "none"; vid.loop = false; vid.style.opacity = "1";
|
||||
if (vid.dataset.morph !== file) { vid.dataset.morph = file; vid.src = mediaUrl(file); vid.pause(); }
|
||||
(window.__hefMorphs || (window.__hefMorphs = [])).push(file);
|
||||
}
|
||||
}
|
||||
|
||||
function setPos(next) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
const n = ring.scales.length;
|
||||
// Commit every integer crossed between the old and new position.
|
||||
for (const c of HEFScrub.integerCrossings(pos, next)) {
|
||||
ringIndex = HEFScrub.wrapIndex(c.index, n);
|
||||
// The clip at the crossed integer is whichever segment end matches it.
|
||||
if (activeSeg) activeClipId = (HEFScrub.wrapIndex(activeSeg.lo, n) === ringIndex) ? activeSeg.clipLo : activeSeg.clipHi;
|
||||
activeSeg = null; // force a fresh-approach re-roll for the next segment
|
||||
}
|
||||
pos = next;
|
||||
const { lo, frac } = HEFScrub.segmentOf(pos);
|
||||
if (frac === 0) { // settled on an altitude
|
||||
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
|
||||
update();
|
||||
restAudio(ringIndex);
|
||||
setNeedle(ringIndex * dialStep());
|
||||
return;
|
||||
}
|
||||
if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex);
|
||||
setNeedle(pos * dialStep());
|
||||
blendAudio(lo, frac);
|
||||
if (activeSeg.file && !seekPending) { // throttle seeks to one per frame
|
||||
seekPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
seekPending = false;
|
||||
if (vid.dataset.morph === activeSeg.file) vid.currentTime = HEFScrub.fracToTime(frac, vid.duration);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rewrite the drag handlers to drive `pos` live (replacing the no-live-follow `onDialMove` and the commit-on-release `onDialUp`):
|
||||
|
||||
```js
|
||||
function onDialDown(e) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
e.preventDefault();
|
||||
dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: ringIndex };
|
||||
}
|
||||
function onDialMove(e) {
|
||||
if (!dialDrag) return;
|
||||
const a = dialAngle(e);
|
||||
dialDrag.accum += angDelta(a, dialDrag.lastAng);
|
||||
dialDrag.lastAng = a;
|
||||
dialDrag.moved += Math.abs(angDelta(a, dialDrag.lastAng));
|
||||
setPos(HEFScrub.accumToPos(dialDrag.restPos, dialDrag.accum, dialStep())); // LIVE scrub
|
||||
}
|
||||
function onDialUp(e) {
|
||||
if (!dialDrag) return;
|
||||
const { moved, target } = dialDrag;
|
||||
dialDrag = null;
|
||||
if (moved < 6) { // a tap, not a turn
|
||||
if (target && target.classList && target.classList.contains("dial-label")) jumpToScale(+target.getAttribute("data-index"));
|
||||
return;
|
||||
}
|
||||
// No auto-complete: hold wherever the knob stopped (continuous-encoder model).
|
||||
}
|
||||
```
|
||||
|
||||
Keep `renderDial()` reading `pos` for the needle when mid-segment, else `ringIndex`. Retire `advance()`'s body (or leave it unused) — wheel/tap use the auto-scrub in Task 4.
|
||||
|
||||
- [ ] **Step 3: Run the focused E2E**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test -g "scrubs morph currentTime"` (dev server running)
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run the unit suite (no regressions)**
|
||||
|
||||
Run: `node --test simulator/unit/scrub.test.js`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/app.js simulator/e2e/tests/altitude-lock.spec.ts
|
||||
git commit -m "feat(sim): scrub engine — drag drives pos, seeks morph, commits+locks+re-rolls"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wheel + tap become auto-scrubs, then lock
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` (`onWheel` ~681–691; `jumpToScale` ~798–805)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `setPos`, `ringIndex`, `pos`, `HEFScrub.wrapIndex`.
|
||||
- Produces:
|
||||
- `autoScrub(targetPos: number, ms = 600): void` — rAF-animates `pos` from its current value to `targetPos` (calling `setPos` each frame), landing exactly on the integer target, then locks (frac 0 settles in `setPos`).
|
||||
|
||||
- [ ] **Step 1: Write the failing E2E (wheel auto-scrubs one altitude then locks)**
|
||||
|
||||
Add to the E2E spec:
|
||||
|
||||
```ts
|
||||
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
|
||||
await boot(page);
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 60 });
|
||||
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
|
||||
const landed = (await page.locator("#scale-name").textContent())!;
|
||||
expect(landed).toContain("orbit");
|
||||
// Mid-flight currentTime moved; after landing the morph is settled (frac 0).
|
||||
await page.waitForTimeout(1500);
|
||||
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
|
||||
});
|
||||
```
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test -g "wheel auto-scrubs"`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 2: Implement `autoScrub` and rewire wheel + tap**
|
||||
|
||||
```js
|
||||
let autoRaf = 0;
|
||||
function autoScrub(targetPos, ms = 600) {
|
||||
if (autoRaf) cancelAnimationFrame(autoRaf);
|
||||
const fromPos = pos, dist = targetPos - fromPos;
|
||||
if (!dist) { setPos(targetPos); return; }
|
||||
let startTs = null;
|
||||
const tick = (ts) => {
|
||||
if (startTs === null) startTs = ts;
|
||||
const k = Math.min(1, (ts - startTs) / ms);
|
||||
setPos(fromPos + dist * k);
|
||||
if (k < 1) autoRaf = requestAnimationFrame(tick);
|
||||
else { autoRaf = 0; setPos(targetPos); } // land exactly + lock (frac 0)
|
||||
};
|
||||
autoRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
let wheelAccum = 0, wheelTimer = null;
|
||||
function onWheel(e) {
|
||||
e.preventDefault();
|
||||
wheelAccum += e.deltaY;
|
||||
clearTimeout(wheelTimer);
|
||||
wheelTimer = setTimeout(() => {
|
||||
const detents = Math.trunc(wheelAccum / 60) || (wheelAccum > 0 ? 1 : -1);
|
||||
wheelAccum = 0;
|
||||
if (detents) autoScrub(ringIndex + detents);
|
||||
}, 90);
|
||||
}
|
||||
|
||||
function jumpToScale(idx) {
|
||||
if (!ring) return;
|
||||
const n = ring.scales.length;
|
||||
let d = (idx - ringIndex) % n;
|
||||
if (d > n / 2) d -= n;
|
||||
if (d < -n / 2) d += n;
|
||||
if (d) autoScrub(ringIndex + d);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the focused E2E**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test -g "wheel auto-scrubs"` (dev server running)
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/app.js
|
||||
git commit -m "feat(sim): wheel + label-tap auto-scrub one/path of altitudes, then lock"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Full E2E tier for scrub-driven transitions
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/e2e/tests/altitude-lock.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the running simulator, `window.__hefMorphs`, `#scale-name`, `#dial`, `video`, `#aud`/`#aud-b`.
|
||||
|
||||
- [ ] **Step 1: Replace the `.rev`-asserting reverse test with a scrub-reverse test**
|
||||
|
||||
The old "zoom back out plays a reverse morph" test asserted a `.rev.mp4` file. Under the scrub model, turn-back seeks the **same** segment morph backward. Replace it:
|
||||
|
||||
```ts
|
||||
test("turn-back scrubs the same morph in reverse and re-locks the start clip", async ({ page }) => {
|
||||
await boot(page);
|
||||
const box = await page.locator("#dial").boundingBox();
|
||||
const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
|
||||
const read = () => page.evaluate(() => ({
|
||||
t: (document.querySelector("video") as HTMLVideoElement).currentTime,
|
||||
ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
|
||||
gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
|
||||
name: document.querySelector("#scale-name")?.textContent,
|
||||
}));
|
||||
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 22, cy - 20, { steps: 10 }); // descend partway
|
||||
const fwd = await read();
|
||||
await page.mouse.move(cx + 4, cy - 30, { steps: 10 }); // turn back toward start
|
||||
const back = await read();
|
||||
await page.mouse.up();
|
||||
|
||||
expect(back.t).toBeLessThan(fwd.t); // morph seeking backward
|
||||
expect(back.gb).toBeLessThan(fwd.gb); // next-scale audio receding
|
||||
expect(back.name).toContain("cosmos"); // re-locked the altitude we left
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the full-turn commit+lock test**
|
||||
|
||||
```ts
|
||||
test("a full turn commits and locks the destination clip", async ({ page }) => {
|
||||
await boot(page);
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
const box = await page.locator("#dial").boundingBox();
|
||||
const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 30, cy, { steps: 6 });
|
||||
await page.mouse.move(cx, cy + 30, { steps: 6 }); // ~full detent clockwise
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
|
||||
const landed = (await page.locator("#scale-name").textContent())!;
|
||||
expect(landed).toContain("orbit");
|
||||
const played = await page.evaluate(() => (window as any).__hefMorphs || []);
|
||||
expect(played[0]).toMatch(/^transitions\/cosmos.*\.mp4$/);
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Keep the wheel + drag tests from Tasks 2–4; run the whole tier**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test`
|
||||
Expected: PASS — all scrub tests green (drag-scrub, turn-back, full-turn, wheel, two-audio-elements).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/e2e/tests/altitude-lock.spec.ts
|
||||
git commit -m "test(e2e): scrub tier asserts currentTime + audio gains track the dial, reverse, commit-lock, wheel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Phase-1 localhost verification
|
||||
|
||||
**Files:** none (verification task).
|
||||
|
||||
- [ ] **Step 1: Run the unit suite**
|
||||
|
||||
Run: `node --test simulator/unit/`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run the full Python test suite (no regressions)**
|
||||
|
||||
Run: `python -m pytest -q`
|
||||
Expected: PASS (existing tally; no new failures).
|
||||
|
||||
- [ ] **Step 3: Boot the simulator and run E2E against it**
|
||||
|
||||
Run (server): start the simulator dev server per `simulator/` README on its configured port with the venv python.
|
||||
Run (tests): `cd simulator/e2e && npx playwright test`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 4: Eyeball the feel**
|
||||
|
||||
Confirm by hand: slow drag eases the morph slowly; fast drag races it; stopping mid-drag holds a blended frame with mixed audio; turning back reverses; the wheel auto-scrubs one altitude and locks; a label tap travels the shortest way. Note any steppiness (expected on sparse-GOP morphs — Phase 2 fixes it).
|
||||
|
||||
- [ ] **Step 5: Checkpoint the transcript**
|
||||
|
||||
Re-publish the in-progress transcript (no commit needed beyond what tasks already pushed).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — All-intra morph re-bake for smooth seeking
|
||||
|
||||
### Task 7: Re-bake the 154 morphs all-intra (dense keyframes)
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/build_pool_manifest.py` (`_make_transition` ~402–417, `_make_reverse` ~420–429)
|
||||
- Test: `tests/test_build_pool_manifest.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: every baked morph encoded all-intra (each frame a keyframe) so arbitrary-frame seeking is smooth.
|
||||
|
||||
- [ ] **Step 1: Write the failing arg-builder test**
|
||||
|
||||
`_make_transition`/`_make_reverse` currently call `subprocess.run` directly, so factor the ffmpeg command into a pure builder to test it. Add to `simulator/build_pool_manifest.py`:
|
||||
|
||||
```python
|
||||
ALL_INTRA = ["-c:v", "libx264", "-g", "1", "-keyint_min", "1", "-sc_threshold", "0", "-pix_fmt", "yuv420p"]
|
||||
```
|
||||
|
||||
Add a pure helper and a test. In `tests/test_build_pool_manifest.py`:
|
||||
|
||||
```python
|
||||
from simulator import build_pool_manifest as B
|
||||
|
||||
def test_transition_cmd_is_all_intra():
|
||||
cmd = B.transition_cmd("ffmpeg", "/a/base.mp4", "/b/base.mp4", "/out/x__y.mp4")
|
||||
assert "-g" in cmd and cmd[cmd.index("-g") + 1] == "1"
|
||||
assert "-keyint_min" in cmd and cmd[cmd.index("-keyint_min") + 1] == "1"
|
||||
assert "-sc_threshold" in cmd and cmd[cmd.index("-sc_threshold") + 1] == "0"
|
||||
|
||||
def test_reverse_cmd_is_all_intra():
|
||||
cmd = B.reverse_cmd("ffmpeg", "/out/x__y.mp4", "/out/x__y.rev.mp4")
|
||||
assert "-g" in cmd and cmd[cmd.index("-g") + 1] == "1"
|
||||
assert "reverse" in " ".join(cmd)
|
||||
```
|
||||
|
||||
Run: `python -m pytest tests/test_build_pool_manifest.py -q`
|
||||
Expected: FAIL — `transition_cmd`/`reverse_cmd` not defined.
|
||||
|
||||
- [ ] **Step 2: Extract `transition_cmd`/`reverse_cmd` and apply all-intra flags**
|
||||
|
||||
```python
|
||||
def transition_cmd(ff, a, b, out):
|
||||
norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
|
||||
return [
|
||||
ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
|
||||
f"[0:v]{norm}[a];[1:v]{norm}[b];"
|
||||
"[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]",
|
||||
"-map", "[v]", "-an", *ALL_INTRA, str(out),
|
||||
]
|
||||
|
||||
def reverse_cmd(ff, forward, out):
|
||||
return [ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", *ALL_INTRA, str(out)]
|
||||
```
|
||||
|
||||
Then make `_make_transition`/`_make_reverse` call these builders inside `subprocess.run(..., check=True, capture_output=True)`.
|
||||
|
||||
- [ ] **Step 3: Run the arg-builder test**
|
||||
|
||||
Run: `python -m pytest tests/test_build_pool_manifest.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Regenerate the morph media**
|
||||
|
||||
Run: `python simulator/build_pool_manifest.py` (or its documented `generate_media` entrypoint) to re-bake all 154 morphs all-intra. Confirm files regrew (sparse → dense keyframes; expect ~0.5–0.9 GB total, each clip well under the 25 MB LFS/proxy ceiling noted in memory).
|
||||
|
||||
- [ ] **Step 5: Commit the re-baked media via git-LFS**
|
||||
|
||||
```bash
|
||||
git add simulator/sample_media/transitions
|
||||
git commit -m "feat(pipeline): re-bake 154 morphs all-intra for smooth scrub seeking (LFS)"
|
||||
```
|
||||
|
||||
Confirm they are LFS pointers: `git lfs ls-files | head`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Phase-2 verification — smooth scrub on dense keyframes
|
||||
|
||||
**Files:** none (verification task).
|
||||
|
||||
- [ ] **Step 1: Re-run the unit + E2E suites against re-baked media**
|
||||
|
||||
Run: `node --test simulator/unit/` → PASS.
|
||||
Run (server up): `cd simulator/e2e && npx playwright test` → PASS.
|
||||
|
||||
- [ ] **Step 2: Eyeball smoothness**
|
||||
|
||||
Confirm the scrub now seeks smoothly frame-to-frame (no stepping) on a slow drag across a full segment.
|
||||
|
||||
- [ ] **Step 3: Push the branch**
|
||||
|
||||
```bash
|
||||
git push -u origin session-0026
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ship via §9 (execution, not a code task)
|
||||
|
||||
After Phase 1 + Phase 2 are green on localhost (unit + E2E), ship through the mandatory pipeline:
|
||||
1. **localhost + E2E green** (Tasks 6 + 8).
|
||||
2. **PPE deploy + E2E green** (flotilla; provision PPE if it doesn't exist yet). Stamp the `release/<YYYY-MM-DDTHH-MM>` tag.
|
||||
3. **prod is human-gated** — the operator promotes the validated tag; the session stops at PPE-green.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Core model (continuous `pos`, frac drives currentTime + audio) → Tasks 1, 3, 2.
|
||||
- In-between hold / no auto-complete → Task 3 (`onDialUp` holds).
|
||||
- Turn-back reversible / re-lock start clip → Tasks 3, 5.
|
||||
- Destination randomness fixed-per-gesture, re-roll on fresh approach → Task 3 (`rebuildSegment`, re-roll on `activeSeg` reset at crossing).
|
||||
- Scroll wheel auto-scrub then lock; tap shortest way → Task 4.
|
||||
- Lock-per-altitude preserved → Task 3 (`commit` sets `activeClipId`; frac 0 settles base loop).
|
||||
- Audio crossfade two adjacent soundtracks by frac → Task 2.
|
||||
- All-intra re-bake (154 morphs, LFS) → Task 7.
|
||||
- Unit tests (position→segment, frac→time, commit on crossing, re-roll, reverse) → Task 1 (pure) + E2E for glue.
|
||||
- Playwright E2E (currentTime + gains track angle, reverse, full-turn commit-lock, wheel) → Task 5.
|
||||
- Phasing (interaction first, re-bake second) → Phase 1 then Phase 2.
|
||||
|
||||
**Placeholder scan:** none — every code step carries real code and exact commands.
|
||||
|
||||
**Type consistency:** `setPos`, `rebuildSegment`, `activeSeg {lo,clipLo,clipHi,file}`, `pickPoolClip`, `blendAudio(loIndex,frac)`, `restAudio(index)`, `scaleAudioUrl(index)`, `autoScrub(targetPos,ms)`, `HEFScrub.*` names are used consistently across Tasks 1–5. `transition_cmd`/`reverse_cmd`/`ALL_INTRA` consistent in Tasks 7.
|
||||
|
||||
**Open interpretation (logged as deferred decision):** the canonical-segment-file choice (always the descend/forward morph, scrubbed bidirectionally; `.rev` unused by scrub) reinterprets the spec's literal `morphByPair["<from>→<to>"]` directed lookup in favor of its "scrub the same morph in reverse" + full-reversibility requirements. Surfaced for operator awareness at finalize.
|
||||
@@ -0,0 +1,733 @@
|
||||
# Localization & Language Dropdown Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a language dropdown to the simulator that localizes all visitor-facing UI controls and the Left/Right brain annotations into English, Spanish, French, and Japanese, switchable live without reload.
|
||||
|
||||
**Architecture:** A new UMD module `simulator/static/i18n.js` (dual-export like `scrub.js`) holds the language registry, the UI-string catalog, and pure helpers (`resolveStrings`, `pickUiString`). `index.html` static text is tagged with `data-i18n` attributes; `app.js` populates a `<select>` from the registry and on change swaps UI strings + re-renders annotations. Annotation content extends the existing per-clip `strings` dict (`{lang: {key: text|list}}`) with `es/fr/ja` keys — no schema change. A Python tool (`tools/i18n/translate_manifest.py`) extracts the English catalog and merges authored translation catalogs back into the manifest.
|
||||
|
||||
**Tech Stack:** Vanilla JS (UMD modules, `node:test`), FastAPI (existing, untouched), Python (stdlib `json` + pytest), Playwright (E2E).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Languages (exact codes & order):** `en` (English, default + fallback), `es` (Español), `fr` (Français), `ja` (日本語). No Hebrew, no Chinese, **no RTL work**.
|
||||
- **Default & persistence:** `activeLang` defaults to `"en"` every load; **no localStorage** — selection is session-only.
|
||||
- **Fallback:** every annotation lookup falls back to the `en` value when a key is missing in the active language; a missing language never blanks the overlay.
|
||||
- **No schema change:** annotation translations are sibling keys inside each clip's existing `strings` object.
|
||||
- **Manifest file:** `simulator/sample_media/manifest.json` is the live manifest (`DEFAULT_MANIFEST` in `simulator/app.py:47`). 41 clips, 753 English string-variants.
|
||||
- **Tier preservation:** a string authored as a list (tiers) stays a list of identical length in every language; a plain string stays a plain string.
|
||||
- **Scope out:** dev-panel diagnostics internals (`#dev-panel`, audio diagnostics, pool picker), `/author.html`, clip titles. Only visitor-facing chrome + annotations are localized.
|
||||
- **Repo conventions:** SSH git; commit messages carry `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Run commands from repo root unless noted.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: i18n module — registry, catalog, pure helpers
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/static/i18n.js`
|
||||
- Test: `simulator/unit/i18n.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces (the `HEFi18n` global / `module.exports`):
|
||||
- `LANGUAGES` — `[{code:"en",nativeName:"English"}, {code:"es",nativeName:"Español"}, {code:"fr",nativeName:"Français"}, {code:"ja",nativeName:"日本語"}]`
|
||||
- `UI_STRINGS` — `{ key: {en, es, fr, ja} }` map (control chrome, all four languages)
|
||||
- `pickUiString(key, lang)` → string; the `lang` value, else the `en` value, else the `key` itself.
|
||||
- `resolveStrings(clipStrings, lang)` → object; `clipStrings[lang]` merged over `clipStrings.en` (so a missing key in `lang` falls back to `en`), else `clipStrings.en`, else `{}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
// simulator/unit/i18n.test.js
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const I = require("../static/i18n.js");
|
||||
|
||||
test("LANGUAGES lists the four codes in order, English first", () => {
|
||||
assert.deepEqual(I.LANGUAGES.map((l) => l.code), ["en", "es", "fr", "ja"]);
|
||||
assert.equal(I.LANGUAGES[0].nativeName, "English");
|
||||
assert.equal(I.LANGUAGES.find((l) => l.code === "ja").nativeName, "日本語");
|
||||
});
|
||||
|
||||
test("pickUiString returns the language value, falls back to en, then to key", () => {
|
||||
assert.equal(I.pickUiString("output.legend", "es"), I.UI_STRINGS["output.legend"].es);
|
||||
// a key present in en but (hypothetically) missing in fr falls back to en:
|
||||
const saved = I.UI_STRINGS["output.legend"].fr;
|
||||
delete I.UI_STRINGS["output.legend"].fr;
|
||||
assert.equal(I.pickUiString("output.legend", "fr"), I.UI_STRINGS["output.legend"].en);
|
||||
I.UI_STRINGS["output.legend"].fr = saved;
|
||||
assert.equal(I.pickUiString("totally.unknown.key", "en"), "totally.unknown.key");
|
||||
});
|
||||
|
||||
test("UI_STRINGS has every language for every key", () => {
|
||||
for (const [key, vals] of Object.entries(I.UI_STRINGS)) {
|
||||
for (const code of ["en", "es", "fr", "ja"]) {
|
||||
assert.ok(typeof vals[code] === "string" && vals[code].length, `${key} missing ${code}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveStrings merges lang over en (per-key fallback)", () => {
|
||||
const cs = { en: { a: "A", b: "B" }, es: { a: "Aes" } };
|
||||
assert.deepEqual(I.resolveStrings(cs, "es"), { a: "Aes", b: "B" });
|
||||
assert.deepEqual(I.resolveStrings(cs, "fr"), { a: "A", b: "B" }); // no fr -> all en
|
||||
assert.deepEqual(I.resolveStrings({}, "es"), {});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test simulator/unit/i18n.test.js`
|
||||
Expected: FAIL — `Cannot find module '../static/i18n.js'`.
|
||||
|
||||
- [ ] **Step 3: Write the module**
|
||||
|
||||
```js
|
||||
// simulator/static/i18n.js
|
||||
// UI/annotation localization — registry, control-string catalog, and pure
|
||||
// lookup helpers. UMD so the browser gets a `HEFi18n` global and
|
||||
// `node --test` can require() it. English is the source of truth & fallback.
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFi18n = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "en", nativeName: "English" },
|
||||
{ code: "es", nativeName: "Español" },
|
||||
{ code: "fr", nativeName: "Français" },
|
||||
{ code: "ja", nativeName: "日本語" },
|
||||
];
|
||||
|
||||
// Visitor-facing control chrome. Keys match `data-i18n` attributes in index.html.
|
||||
const UI_STRINGS = {
|
||||
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre d’Expérience Humaine — Aperçu d’altération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
|
||||
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de l’univers", ja: "宇宙を読み込み中" },
|
||||
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
|
||||
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
|
||||
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
|
||||
"altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
|
||||
"altitude.hint": { en: "Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.", es: "Gira el dial (arrástralo o desplázate) para cambiar la altitud — sin fin: tras lo más profundo vuelve a lo más alto. Haz clic en una etiqueta para saltar.", fr: "Tournez le cadran (glissez ou faites défiler) pour changer d’altitude — sans fin : après le plus profond, on revient au plus haut. Cliquez sur une étiquette pour y aller.", ja: "ダイヤルを回して(ドラッグまたはスクロール)高度を変えます — 無限ループ:最も深い先は最も高い所へ戻ります。ラベルをクリックでそこへ移動。" },
|
||||
"knobs.legend": { en: "Experience knobs (0–4)", es: "Mandos de experiencia (0–4)", fr: "Boutons d’expérience (0–4)", ja: "体験つまみ(0〜4)" },
|
||||
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
|
||||
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
|
||||
"knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
|
||||
"devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
|
||||
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
|
||||
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
|
||||
"scale.sky": { en: "sky", es: "cielo", fr: "ciel", ja: "空" },
|
||||
"scale.coast": { en: "coast", es: "costa", fr: "côte", ja: "海岸" },
|
||||
"scale.reef": { en: "reef", es: "arrecife", fr: "récif", ja: "サンゴ礁" },
|
||||
"scale.abyss": { en: "abyss", es: "abismo", fr: "abîme", ja: "深海" },
|
||||
};
|
||||
|
||||
function pickUiString(key, lang) {
|
||||
const v = UI_STRINGS[key];
|
||||
if (!v) return key;
|
||||
return (v[lang] != null ? v[lang] : v.en) || key;
|
||||
}
|
||||
|
||||
function resolveStrings(clipStrings, lang) {
|
||||
if (!clipStrings || !clipStrings.en) return (clipStrings && clipStrings[lang]) || {};
|
||||
if (lang === "en" || !clipStrings[lang]) return clipStrings.en;
|
||||
return Object.assign({}, clipStrings.en, clipStrings[lang]);
|
||||
}
|
||||
|
||||
return { LANGUAGES, UI_STRINGS, pickUiString, resolveStrings };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test simulator/unit/i18n.test.js`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/i18n.js simulator/unit/i18n.test.js
|
||||
git commit -m "feat(i18n): language registry + UI-string catalog module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Tag index.html + add the language dropdown
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html`
|
||||
- Test: `simulator/unit/index-i18n.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `data-i18n` key names from Task 1's `UI_STRINGS`.
|
||||
- Produces: a `<select id="lang-select">` and `data-i18n`-tagged nodes for Task 3 to drive.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (parses the HTML as text — no DOM lib needed)
|
||||
|
||||
```js
|
||||
// simulator/unit/index-i18n.test.js
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const I = require("../static/i18n.js");
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, "../static/index.html"), "utf8");
|
||||
|
||||
test("index.html includes the i18n script and a language select", () => {
|
||||
assert.match(html, /<script src="\/i18n\.js">/);
|
||||
assert.match(html, /id="lang-select"/);
|
||||
});
|
||||
|
||||
test("every data-i18n key in index.html exists in UI_STRINGS", () => {
|
||||
const keys = [...html.matchAll(/data-i18n="([^"]+)"/g)].map((m) => m[1]);
|
||||
assert.ok(keys.length >= 12, `expected many tagged nodes, found ${keys.length}`);
|
||||
for (const k of keys) assert.ok(I.UI_STRINGS[k], `data-i18n key not in catalog: ${k}`);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test simulator/unit/index-i18n.test.js`
|
||||
Expected: FAIL — no `i18n.js` script / no `lang-select` / no `data-i18n` attributes yet.
|
||||
|
||||
- [ ] **Step 3: Edit index.html**
|
||||
|
||||
Apply these exact edits:
|
||||
|
||||
1. Load the module before `app.js` (after the `scrub.js` script, line ~107):
|
||||
|
||||
```html
|
||||
<script src="/scrub.js"></script>
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
```
|
||||
|
||||
2. Tag the loading title (line 12):
|
||||
|
||||
```html
|
||||
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
|
||||
```
|
||||
|
||||
3. Tag the header (line 16):
|
||||
|
||||
```html
|
||||
<header><h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1></header>
|
||||
```
|
||||
|
||||
4. In the **Output** fieldset (lines 33–44), tag the legend + video label, and **add the language select** as the first control:
|
||||
|
||||
```html
|
||||
<fieldset>
|
||||
<legend data-i18n="output.legend">Output</legend>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="lang-select" aria-label="Language"></select>
|
||||
</label>
|
||||
<label class="dev-switch" for="visual">
|
||||
<input type="checkbox" id="visual" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="output.video">Video</span>
|
||||
</label>
|
||||
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
|
||||
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
|
||||
<span id="audio-level-val">0</span>/10
|
||||
</label>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
5. Tag the **Altitude** legend (line 47) and hint (line 52):
|
||||
|
||||
```html
|
||||
<legend data-i18n="altitude.legend">Altitude</legend>
|
||||
```
|
||||
```html
|
||||
<p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
|
||||
```
|
||||
|
||||
6. Tag the **Experience knobs** fieldset (lines 56–59):
|
||||
|
||||
```html
|
||||
<legend data-i18n="knobs.legend">Experience knobs (0–4)</legend>
|
||||
<label><span data-i18n="knobs.think">Think</span> <input type="range" id="left" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.feel">Feel</span> <input type="range" id="right" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.mood">Mood — dark ◀ 0 ▶ light</span> <input type="range" id="mood" min="-4" max="4" value="0" /></label>
|
||||
```
|
||||
|
||||
7. Tag the **Dev Mode** label (line 66):
|
||||
|
||||
```html
|
||||
<span class="dev-switch-label" data-i18n="devmode.label">Dev Mode</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test simulator/unit/index-i18n.test.js`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/unit/index-i18n.test.js
|
||||
git commit -m "feat(i18n): tag controls with data-i18n + add language dropdown"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire live language switching in app.js
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` (render sites at `:505` and `:556`; add init + handler)
|
||||
- Test: covered by Task 1's `resolveStrings` unit test (pure logic) + Task 6 E2E (DOM behavior). No new unit file.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HEFi18n.LANGUAGES`, `HEFi18n.pickUiString`, `HEFi18n.resolveStrings`; existing `renderOverlay`, `renderAffect`, `renderScaleReadout`, `lastOverlay`.
|
||||
- Produces: module-global `activeLang`; `lastAffect` cache for re-render.
|
||||
|
||||
- [ ] **Step 1: Add `activeLang` + `lastAffect` globals**
|
||||
|
||||
Near the other module globals (around `simulator/static/app.js:47–55`, after `let lastOverlay = null;`), add:
|
||||
|
||||
```js
|
||||
let activeLang = "en"; // session-only; no persistence (resets to en each load)
|
||||
let lastAffect = null; // {strength, intensity, right} from the last renderAffect, for re-render on language switch
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Use `activeLang` at the two render sites**
|
||||
|
||||
In `renderOverlay` (line ~505) replace:
|
||||
|
||||
```js
|
||||
const strings = (clip.strings && clip.strings.en) || {};
|
||||
```
|
||||
with:
|
||||
```js
|
||||
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
|
||||
```
|
||||
|
||||
In `renderAffect` (line ~556) replace the same line with the same call, **and** cache the args at the top of `renderAffect` so a language switch can re-render without a server round-trip. Right after `function renderAffect(strength, intensity, right) {` add:
|
||||
|
||||
```js
|
||||
lastAffect = { strength, intensity, right };
|
||||
```
|
||||
and replace its `const strings = (clip.strings && clip.strings.en) || {};` with:
|
||||
```js
|
||||
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Localize the scale-name readout**
|
||||
|
||||
In `renderScaleReadout` (line ~575) replace:
|
||||
|
||||
```js
|
||||
$("scale-name").textContent = `${s.id} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
|
||||
```
|
||||
with (localize only the scale id; `member` is a clip title and stays as-is):
|
||||
```js
|
||||
const scaleName = HEFi18n.pickUiString("scale." + s.id, activeLang);
|
||||
$("scale-name").textContent = `${scaleName} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the init + change handler**
|
||||
|
||||
Add this function and call it once during startup. Place the function near the other `apply*` helpers, and invoke `initLanguage()` from the existing startup path (the same place that first calls `renderScaleReadout()` / builds the panel — search for the boot sequence after the manifest `fetch` in the init function around `simulator/static/app.js:58–104`; add `initLanguage();` there):
|
||||
|
||||
```js
|
||||
// Populate the language dropdown from the registry and wire live switching.
|
||||
// English default, session-only (no persistence). Switching swaps UI chrome
|
||||
// and re-renders the current annotations/affect in place — no reload, no fetch.
|
||||
function initLanguage() {
|
||||
const sel = $("lang-select");
|
||||
if (!sel) return;
|
||||
for (const { code, nativeName } of HEFi18n.LANGUAGES) {
|
||||
const o = document.createElement("option");
|
||||
o.value = code;
|
||||
o.textContent = nativeName;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = activeLang;
|
||||
applyUiStrings(activeLang);
|
||||
sel.addEventListener("change", () => setLanguage(sel.value));
|
||||
}
|
||||
|
||||
// Fill every [data-i18n] node with its catalog string for `lang`.
|
||||
function applyUiStrings(lang) {
|
||||
document.documentElement.lang = lang;
|
||||
for (const el of document.querySelectorAll("[data-i18n]")) {
|
||||
el.textContent = HEFi18n.pickUiString(el.getAttribute("data-i18n"), lang);
|
||||
}
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
activeLang = lang;
|
||||
applyUiStrings(lang);
|
||||
renderScaleReadout();
|
||||
if (lastOverlay) renderOverlay(lastOverlay.level, lastOverlay.intensity);
|
||||
if (lastAffect) renderAffect(lastAffect.strength, lastAffect.intensity, lastAffect.right);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the full node unit suite**
|
||||
|
||||
Run: `node --test simulator/unit/`
|
||||
Expected: PASS — existing `scrub.test.js`, plus `i18n.test.js` and `index-i18n.test.js`.
|
||||
|
||||
- [ ] **Step 6: Manual smoke (optional but recommended)**
|
||||
|
||||
Run the app and confirm the dropdown lists English/Español/Français/日本語, switching changes the control labels and (with Think/Feel up) the annotations, and reload returns to English. (Server: the existing uvicorn launch — restart it with the venv python so it serves the new static files; a stale uvicorn is the recurring "no change" root cause.)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/app.js
|
||||
git commit -m "feat(i18n): live language switching for chrome + annotations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Manifest translation tool (extract + merge)
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/i18n/translate_manifest.py`
|
||||
- Create: `tools/i18n/__init__.py` (empty — `tools/` is a package; siblings `tools/pipeline/`, `tools/ingest/` each have one)
|
||||
- Test: `tests/test_i18n_translate.py` (repo `tests/` is flat — match it)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the manifest JSON shape — `{"clips": [ {"id", "strings": {"en": {key: str|list}}}, ... ]}`.
|
||||
- Produces:
|
||||
- `extract_en_catalog(manifest) -> {clip_id: {key: str|list}}` — the English strings of every clip.
|
||||
- `merge_catalog(manifest, lang, catalog) -> manifest` — writes `catalog[clip_id]` into each clip's `strings[lang]`; **structure-validated** (raises `ValueError` if a key's type/list-length differs from `en`); **idempotent** (re-running with the same catalog is a no-op); leaves `en` untouched.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_i18n_translate.py
|
||||
import copy
|
||||
import pytest
|
||||
from tools.i18n.translate_manifest import extract_en_catalog, merge_catalog
|
||||
|
||||
MANIFEST = {
|
||||
"clips": [
|
||||
{"id": "cosmos", "strings": {"en": {
|
||||
"detected.star": ["star", "young star"],
|
||||
"measure.distance": "≈7,500 ly",
|
||||
}}},
|
||||
]
|
||||
}
|
||||
|
||||
def test_extract_pulls_every_en_string():
|
||||
cat = extract_en_catalog(MANIFEST)
|
||||
assert cat == {"cosmos": {"detected.star": ["star", "young star"], "measure.distance": "≈7,500 ly"}}
|
||||
|
||||
def test_merge_writes_lang_and_leaves_en():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}}
|
||||
merge_catalog(m, "es", cat)
|
||||
s = m["clips"][0]["strings"]
|
||||
assert s["en"]["measure.distance"] == "≈7,500 ly" # en untouched
|
||||
assert s["es"]["detected.star"] == ["estrella", "estrella joven"]
|
||||
|
||||
def test_merge_is_idempotent():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}}
|
||||
merge_catalog(m, "es", cat)
|
||||
once = copy.deepcopy(m)
|
||||
merge_catalog(m, "es", cat)
|
||||
assert m == once
|
||||
|
||||
def test_merge_rejects_tier_length_mismatch():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
bad = {"cosmos": {"detected.star": ["estrella"], "measure.distance": "≈7.500 al"}} # 1 tier vs en's 2
|
||||
with pytest.raises(ValueError):
|
||||
merge_catalog(m, "es", bad)
|
||||
|
||||
def test_merge_rejects_type_mismatch():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
bad = {"cosmos": {"detected.star": "estrella", "measure.distance": "≈7.500 al"}} # str vs en's list
|
||||
with pytest.raises(ValueError):
|
||||
merge_catalog(m, "es", bad)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `python -m pytest tests/test_i18n_translate.py -q`
|
||||
Expected: FAIL — `ModuleNotFoundError: tools.i18n.translate_manifest`.
|
||||
|
||||
- [ ] **Step 3: Write the tool**
|
||||
|
||||
```python
|
||||
# tools/i18n/translate_manifest.py
|
||||
"""Extract a manifest's English annotation strings and merge authored
|
||||
translations back into each clip's `strings` dict (no schema change).
|
||||
|
||||
Workflow:
|
||||
1. `extract` writes tools/i18n/catalogs/en.json — the English strings.
|
||||
2. A translator (human or LLM) authors es.json / fr.json / ja.json with the
|
||||
SAME structure (lists stay lists of the same length).
|
||||
3. `merge` validates structure and writes strings[lang] into the manifest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def extract_en_catalog(manifest: dict) -> dict:
|
||||
out = {}
|
||||
for clip in manifest.get("clips", []):
|
||||
en = (clip.get("strings") or {}).get("en")
|
||||
if en:
|
||||
out[clip["id"]] = en
|
||||
return out
|
||||
|
||||
|
||||
def _check_shape(key: str, en_val, tr_val):
|
||||
if isinstance(en_val, list):
|
||||
if not isinstance(tr_val, list) or len(tr_val) != len(en_val):
|
||||
raise ValueError(f"{key}: expected list of {len(en_val)} tiers, got {tr_val!r}")
|
||||
elif not isinstance(tr_val, str):
|
||||
raise ValueError(f"{key}: expected string, got {tr_val!r}")
|
||||
|
||||
|
||||
def merge_catalog(manifest: dict, lang: str, catalog: dict) -> dict:
|
||||
if lang == "en":
|
||||
raise ValueError("refusing to overwrite the en source")
|
||||
for clip in manifest.get("clips", []):
|
||||
en = (clip.get("strings") or {}).get("en")
|
||||
tr = catalog.get(clip["id"])
|
||||
if not en or not tr:
|
||||
continue
|
||||
for key, en_val in en.items():
|
||||
if key not in tr:
|
||||
raise ValueError(f"{clip['id']}/{key}: missing in {lang} catalog")
|
||||
_check_shape(f"{clip['id']}/{key}", en_val, tr[key])
|
||||
clip["strings"][lang] = {k: tr[k] for k in en} # en key order, lang values
|
||||
return manifest
|
||||
|
||||
|
||||
def _main(argv=None):
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("command", choices=["extract", "merge"])
|
||||
ap.add_argument("--manifest", default="simulator/sample_media/manifest.json")
|
||||
ap.add_argument("--catalog-dir", default="tools/i18n/catalogs")
|
||||
ap.add_argument("--lang", help="merge: target language code")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
mpath = Path(args.manifest)
|
||||
manifest = json.loads(mpath.read_text(encoding="utf-8"))
|
||||
cdir = Path(args.catalog_dir)
|
||||
|
||||
if args.command == "extract":
|
||||
cdir.mkdir(parents=True, exist_ok=True)
|
||||
(cdir / "en.json").write_text(
|
||||
json.dumps(extract_en_catalog(manifest), ensure_ascii=False, indent=1) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"wrote {cdir / 'en.json'}")
|
||||
else:
|
||||
if not args.lang:
|
||||
ap.error("merge requires --lang")
|
||||
catalog = json.loads((cdir / f"{args.lang}.json").read_text(encoding="utf-8"))
|
||||
merge_catalog(manifest, args.lang, catalog)
|
||||
mpath.write_text(json.dumps(manifest, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
print(f"merged {args.lang} into {mpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
```
|
||||
|
||||
Create empty `tools/i18n/__init__.py` (and confirm whether `tools/` and `tools/pipeline/` have `__init__.py`; match the existing pattern — `simulator/app.py:296` imports `tools.pipeline.manifest`, so `tools` is importable as a package).
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `python -m pytest tests/test_i18n_translate.py -q`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Generate the English catalog**
|
||||
|
||||
Run: `python -m tools.i18n.translate_manifest extract`
|
||||
Expected: writes `tools/i18n/catalogs/en.json` (41 clips).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/i18n/translate_manifest.py tools/i18n/__init__.py tests/test_i18n_translate.py tools/i18n/catalogs/en.json
|
||||
git commit -m "feat(i18n): manifest translation extract/merge tool + en catalog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Author es/fr/ja translations + merge into the manifest
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/i18n/catalogs/es.json`, `tools/i18n/catalogs/fr.json`, `tools/i18n/catalogs/ja.json`
|
||||
- Modify: `simulator/sample_media/manifest.json` (via the merge tool)
|
||||
- Test: `tests/test_i18n_manifest.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `tools/i18n/catalogs/en.json` (Task 4), `merge_catalog` (Task 4).
|
||||
- Produces: a manifest where every clip's `strings` has `en/es/fr/ja`.
|
||||
|
||||
- [ ] **Step 1: Write the failing invariant test**
|
||||
|
||||
```python
|
||||
# tests/test_i18n_manifest.py
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
LANGS = ["en", "es", "fr", "ja"]
|
||||
MANIFEST = Path("simulator/sample_media/manifest.json")
|
||||
|
||||
def _tier_len(v):
|
||||
return len(v) if isinstance(v, list) else 0 # 0 = plain string
|
||||
|
||||
def test_every_clip_has_all_languages_with_matching_shape():
|
||||
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
for clip in manifest["clips"]:
|
||||
s = clip.get("strings", {})
|
||||
en = s.get("en")
|
||||
if not en:
|
||||
continue
|
||||
for lang in LANGS:
|
||||
assert lang in s, f"{clip['id']} missing language {lang}"
|
||||
assert set(s[lang]) == set(en), f"{clip['id']}/{lang} key-set differs from en"
|
||||
for key, en_val in en.items():
|
||||
assert _tier_len(s[lang][key]) == _tier_len(en_val), (
|
||||
f"{clip['id']}/{lang}/{key} tier-count differs from en"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `python -m pytest tests/test_i18n_manifest.py -q`
|
||||
Expected: FAIL — clips have only `en`.
|
||||
|
||||
- [ ] **Step 3: Author the three translation catalogs**
|
||||
|
||||
Copy `tools/i18n/catalogs/en.json` to `es.json`, `fr.json`, `ja.json` and translate **every value**, preserving each key and every list's length. Guidance:
|
||||
- Tier lists escalate (general → scientific+fact for `detected.*`; basic → compound emotion for `feel.*`) — preserve that escalation in the target language.
|
||||
- `measure.*` are plain strings (units/quantities) — localize number formatting where natural (e.g. `7,500` → `7.500` in es/fr) but keep the value.
|
||||
- These are LLM-authored; the Japanese pass gets a human spot-check before merge.
|
||||
|
||||
This is the bulk content step (~2,260 strings across the three files). It is data authoring, not code — produce the JSON, then validate with the merge tool's structure checks in the next step.
|
||||
|
||||
- [ ] **Step 4: Merge each language into the manifest**
|
||||
|
||||
```bash
|
||||
python -m tools.i18n.translate_manifest merge --lang es
|
||||
python -m tools.i18n.translate_manifest merge --lang fr
|
||||
python -m tools.i18n.translate_manifest merge --lang ja
|
||||
```
|
||||
Expected: each prints `merged <lang> into simulator/sample_media/manifest.json`. Any structure error (missing key, wrong tier length) aborts that merge — fix the catalog and re-run (merge is idempotent).
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `python -m pytest tests/test_i18n_manifest.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/i18n/catalogs/es.json tools/i18n/catalogs/fr.json tools/i18n/catalogs/ja.json simulator/sample_media/manifest.json tests/test_i18n_manifest.py
|
||||
git commit -m "feat(i18n): author es/fr/ja annotation translations + merge into manifest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: E2E — dropdown switches UI and annotations
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/e2e/tests/i18n.spec.ts` (match the existing `e2e/tests/*.spec.ts` location/pattern — confirm the exact tests dir, cf. `e2e/tests/altitude-lock.spec.ts`)
|
||||
- Test: this IS the test.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the running app with the language dropdown + localized manifest.
|
||||
|
||||
- [ ] **Step 1: Write the E2E test**
|
||||
|
||||
```ts
|
||||
// simulator/e2e/tests/i18n.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("language dropdown localizes chrome and resets to English on reload", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const sel = page.locator("#lang-select");
|
||||
await expect(sel).toBeVisible();
|
||||
|
||||
// four languages, English selected by default
|
||||
await expect(sel.locator("option")).toHaveCount(4);
|
||||
await expect(sel).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
|
||||
// switch to Spanish -> chrome changes, <html lang> updates
|
||||
await sel.selectOption("es");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Salida");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "es");
|
||||
|
||||
// Japanese
|
||||
await sel.selectOption("ja");
|
||||
await expect(page.locator('[data-i18n="knobs.think"]')).toHaveText("考える");
|
||||
|
||||
// reload returns to English (session-only, no persistence)
|
||||
await page.reload();
|
||||
await expect(page.locator("#lang-select")).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the E2E test**
|
||||
|
||||
Run: the existing Playwright command for this repo (check `simulator/e2e/` for the runner — e.g. `npx playwright test i18n` from `simulator/e2e/`, with the app server running as the other specs expect).
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/e2e/tests/i18n.spec.ts
|
||||
git commit -m "test(i18n): e2e for language dropdown chrome + reset on reload"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Full verification + finish the branch
|
||||
|
||||
- [ ] **Step 1: Run every suite**
|
||||
|
||||
```bash
|
||||
node --test simulator/unit/
|
||||
python -m pytest tests/ -q
|
||||
```
|
||||
Expected: all green (node unit incl. i18n; pytest incl. translate-tool + manifest-language invariants; existing suites unaffected).
|
||||
|
||||
- [ ] **Step 2: Run the E2E suite**
|
||||
|
||||
Run the repo's full Playwright command (as other specs run it) and confirm the i18n spec plus the existing specs pass.
|
||||
|
||||
- [ ] **Step 3: Open the PR**
|
||||
|
||||
```bash
|
||||
git push -u origin feat/localization-language-dropdown
|
||||
```
|
||||
Then open a PR to `main` via the Gitea flow, citing this plan and the design spec.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- §2 languages (en/es/fr/ja, no RTL) → Task 1 registry + Global Constraints. ✓
|
||||
- §3.1 UI chrome → Task 1 catalog + Task 2 tagging. ✓
|
||||
- §3.2 / §4.3 annotations (extend strings dict, fallback) → Task 1 `resolveStrings`, Task 3 render sites, Tasks 4–5 content. ✓
|
||||
- §4.1 registry → Task 1. ✓ §4.2 UI catalog + `applyUiStrings` + `data-i18n` → Tasks 1–3. ✓
|
||||
- §5 dropdown, English default, session-only, live switch no reload → Task 2 (select) + Task 3 (init/handler). ✓
|
||||
- §6 one-shot idempotent tool, tier-structure preservation → Task 4. ✓
|
||||
- §7 tests: node unit (registry/swap/fallback/tier) → Tasks 1, 3; pytest manifest invariants → Task 5; Playwright per-language → Task 6. ✓
|
||||
- §8 out-of-scope honored (no dev-panel internals, no author.html, no clip titles). ✓
|
||||
|
||||
**Placeholder scan:** Task 5 Step 3 is intentionally a content-authoring step (the 2,260 translations can't be inlined); its structure is fully constrained by the tool's validation and Task 5's invariant test. No code step is left as a placeholder.
|
||||
|
||||
**Type consistency:** `resolveStrings(clipStrings, lang)`, `pickUiString(key, lang)`, `LANGUAGES[].{code,nativeName}`, `extract_en_catalog`, `merge_catalog(manifest, lang, catalog)`, globals `activeLang`/`lastAffect`/`lastOverlay` — names used identically across Tasks 1, 3, 4, 5. ✓
|
||||
@@ -0,0 +1,125 @@
|
||||
# Localization & Language Dropdown — Design
|
||||
|
||||
**Date:** 2026-06-29
|
||||
**Status:** Approved design, pre-plan
|
||||
**Scope:** The `simulator/` web experience — localize all UI controls and the
|
||||
Left/Right brain annotations, selectable via a language dropdown.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let a visitor switch the experience's language from a dropdown. Switching
|
||||
re-renders **both** the UI chrome (controls, labels, hints) **and** the
|
||||
Left/Right brain annotation content (object labels, facts, emotion words) into
|
||||
the chosen language, live, without reloading the page or interrupting playback.
|
||||
|
||||
## 2. Languages (this pass)
|
||||
|
||||
Four languages, English as the source/default:
|
||||
|
||||
| code | native name | notes |
|
||||
|------|-------------|-------|
|
||||
| `en` | English | source of truth, fallback |
|
||||
| `es` | Español | |
|
||||
| `fr` | Français | |
|
||||
| `ja` | 日本語 | CJK glyphs; no layout-direction change |
|
||||
|
||||
Hebrew and Chinese were considered and **deliberately deferred** to a later
|
||||
pass. Hebrew was the only right-to-left language, so **no RTL / UI-mirror work
|
||||
is in scope here.** The language set is data-driven, so adding more later
|
||||
(including an RTL pass) is additive.
|
||||
|
||||
## 3. What needs translating — two very different costs
|
||||
|
||||
1. **UI chrome** — ~20 fixed control strings (`Loading Universe`, `Output`,
|
||||
`Video`, `Audio`, `Altitude`, `Think`, `Feel`, `Mood`, the six scale names,
|
||||
hints, dev-mode labels). Small, static.
|
||||
2. **Annotations** — the bulk. The manifest holds **41 clips / 753 authored
|
||||
English string-variants** (Left labels + facts + Right emotions, many as
|
||||
tier-lists). Authoring `es/fr/ja` = **753 × 3 ≈ 2,260 strings**. This is
|
||||
*content*, and it is the dominant effort.
|
||||
|
||||
## 4. Architecture — three isolated pieces
|
||||
|
||||
### 4.1 Language registry
|
||||
A single small module (`simulator/static/i18n.js` or a sibling) exporting the
|
||||
ordered list `[{code, nativeName}]` for `en, es, fr, ja`. It is the **one source
|
||||
of truth** consumed by the dropdown and `<html lang>`. No `dir` field needed
|
||||
this pass (no RTL).
|
||||
|
||||
### 4.2 UI string catalog
|
||||
A `{ key: {en, es, fr, ja} }` map for the ~20 control strings, living alongside
|
||||
the registry. `index.html`'s static text is tagged with `data-i18n="key"`
|
||||
attributes (removing hardcoded English from the markup); an
|
||||
`applyUiStrings(lang)` function fills every tagged node on load and on every
|
||||
language change.
|
||||
|
||||
### 4.3 Annotation strings — no schema change
|
||||
The manifest already stores `strings: {"en": {key: text}}` per clip. We add
|
||||
`es / fr / ja` sibling keys to each clip's `strings` object. The two render
|
||||
sites change from a hardcoded `.en`:
|
||||
|
||||
- `simulator/static/app.js:505` (Left labels/facts)
|
||||
- `simulator/static/app.js:556` (Right emotions)
|
||||
|
||||
…to `clip.strings[activeLang] || clip.strings.en` — an **English fallback** so a
|
||||
missing key never blanks the overlay. Tier-list structure (general→scientific,
|
||||
emotion escalation) is preserved per language.
|
||||
|
||||
## 5. The dropdown & selection behavior
|
||||
|
||||
- A `<select>` in the control panel (under "Output"), populated from the
|
||||
registry, showing **native names** (Español, 日本語, …).
|
||||
- `activeLang` is a module-level variable defaulting to `"en"`.
|
||||
- **No persistence** (no localStorage): every page load starts in English; a
|
||||
visitor's selection lasts only the session. Fits an installation that resets
|
||||
between visitors.
|
||||
- On change: set `activeLang` → `applyUiStrings(lang)` → set
|
||||
`document.documentElement.lang` → re-render the current frame's labels and
|
||||
emotions. **No reload, no video interruption.**
|
||||
|
||||
## 6. Authoring the translations (one-shot tooling)
|
||||
|
||||
`tools/i18n/translate_manifest.py`:
|
||||
- Reads each clip's `strings.en` from the manifest.
|
||||
- Produces `es / fr / ja` translations, **preserving tier-list structure** (a
|
||||
list stays a list of the same length) and the general→scientific /
|
||||
emotion-escalation intent.
|
||||
- Writes them back into the manifest in place.
|
||||
- **Idempotent / re-runnable:** skips keys already present in a target language,
|
||||
so it can be run incrementally.
|
||||
- Output is committed as content.
|
||||
|
||||
**Authorship caveat:** translations are LLM-generated and committed as the real
|
||||
content. A human spot-check of the Japanese pass precedes merge; this design
|
||||
does not claim native-quality fluency.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- **Unit (node):**
|
||||
- registry integrity (codes unique, English present);
|
||||
- `applyUiStrings` swaps every `data-i18n` node and leaves none in English
|
||||
when a non-English language is active;
|
||||
- annotation picker falls back to `en` on a missing key;
|
||||
- tier-list length is preserved across languages.
|
||||
- **Manifest test (pytest):** every clip's `strings` has all four language keys,
|
||||
with **identical key-sets** and **identical tier-counts** to `en` (catches a
|
||||
translation that dropped a key or a tier).
|
||||
- **E2E (Playwright):** select each language → control labels change to that
|
||||
language; select back to English → original restored; annotations re-render on
|
||||
switch.
|
||||
|
||||
## 8. Out of scope (this pass)
|
||||
|
||||
- Hebrew / RTL / full-UI-mirror.
|
||||
- Chinese.
|
||||
- Browser language auto-detection.
|
||||
- Persisting the chosen language across loads.
|
||||
- Localizing author-mode (`/author.html`) tooling UI.
|
||||
|
||||
## 9. Open follow-ups (noted, not built)
|
||||
|
||||
- A later RTL pass (Hebrew/Arabic) would add a `dir` field to the registry and a
|
||||
logical-property CSS migration.
|
||||
- Native-speaker review of the committed translations.
|
||||
+35
-5
@@ -6,11 +6,8 @@
|
||||
> Posture: yolo
|
||||
> Claude-Session: c6b9bebd-f94a-4fc0-982d-0a9dc00b3148
|
||||
> 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 0026 for human-experience-filter-art. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0026.0-TRANSCRIPT-2026-06-27T15-26--<end>.md form at session end.
|
||||
> End: 2026-06-27T18-48 (PST)
|
||||
> Status: **FINALIZED** — merged to main (PR #28) and published at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
@@ -128,3 +125,36 @@ finalize. Empty if none._
|
||||
eye-tweakable. Residual: a one-seek hitch at each loop wrap (~every 17–20s) and scrub-START (vs landing) is
|
||||
not made seamless (a looping clip is at arbitrary phase when a drag begins). If the wrap hitch bothers the
|
||||
operator, bake per-clip loop files (`base[3:end]`) for native seamless looping.
|
||||
|
||||
## Closeout
|
||||
|
||||
**Shipped.** Branch `session-0026` (14 commits) → **PR #28 merged to `main`** (Gitea
|
||||
merge commit `108e620`, branch deleted). Working tree clean on `main`.
|
||||
|
||||
**Arc of the session:** brainstorming was pre-done (approved design existed) → claimed
|
||||
0026 (planning-and-executing, yolo) → wrote the implementation plan (reviewed: operator
|
||||
chose "execute as-is") → executed all 8 plan tasks (Phase 1 scrub interaction + Phase 2
|
||||
all-intra re-bake) → operator held for by-eye review → **4 eyeball rounds** of fixes
|
||||
(landing frame, reef_snapper text card, video double-buffer, audio dial + fade-race +
|
||||
transition speed; one bug solved via systematic-debugging with a fails-without-the-fix
|
||||
regression test) → operator said merge → merged + finalized.
|
||||
|
||||
**§9 pipeline status:** localhost + E2E green (11 Playwright + 9 node unit + 302 pytest).
|
||||
This app is **simulator-first with no PPE/prod deploy infra** (runs locally; hardware/Pi
|
||||
deferred per the standing directive) — no deploy stage to run; the merge is the ship for
|
||||
a locally-run simulator. (Plan archive skipped: app has **no content repo** in `app.json`;
|
||||
the plan stays in-repo at `docs/superpowers/plans/2026-06-27-scrub-driven-altitude-transitions.md`.)
|
||||
|
||||
**Deferred decisions** (also in the section above): D1 canonical-forward-morph-per-segment
|
||||
(scrub same file bidirectionally); D2 reef_snapper tracked test-label misaligned by the trim;
|
||||
D3 reverse/turn-back frame asymmetry + loop-wrap seek (per-clip loop files would fully fix).
|
||||
|
||||
**Next-session prompt (operator's call — nothing outstanding blocks):**
|
||||
```
|
||||
/wgl-planning-and-executing Next HEF polish: pick one of —
|
||||
(a) bake per-clip loop files base[3:end] for native seamless looping (fixes the D3
|
||||
reverse-landing frame jump + the ~17–20s loop-wrap seek), OR
|
||||
(b) continue by-eye scrub/audio feel tweaks, OR
|
||||
(c) re-author the reef_snapper tracked label for the trimmed footage (/author.html), OR
|
||||
(d) register a content repo in app.json so plans/specs archive at finalize.
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -76,5 +76,23 @@
|
||||
},
|
||||
"0026": {
|
||||
"title": ""
|
||||
},
|
||||
"0027": {
|
||||
"title": ""
|
||||
},
|
||||
"0028": {
|
||||
"title": ""
|
||||
},
|
||||
"0029": {
|
||||
"title": ""
|
||||
},
|
||||
"0030": {
|
||||
"title": ""
|
||||
},
|
||||
"0031": {
|
||||
"title": ""
|
||||
},
|
||||
"0032": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,21 +38,32 @@ MANIFEST = MEDIA / "manifest.json"
|
||||
|
||||
# --- Pools: scale id -> ordered clip ids (primary first) (content-candidate-pool.md) ---
|
||||
POOLS: dict[str, list[str]] = {
|
||||
"cosmos": ["cosmos", "cosmos_miri", "cosmos_galaxies", "cosmos_hudf", "cosmos_xdf"],
|
||||
"orbit": ["orbit_planetearth", "orbit_crewobs", "orbit_bluemarble"],
|
||||
"coast": ["coast_birdrock", "coast_surfgrass", "coast_elkbeach", "coast_drakesbeach"],
|
||||
"reef": ["reef_lionfish", "reef_spawning", "reef_hawkfish", "reef_snapper", "reef_coralspacific"],
|
||||
"abyss": ["abyss_wow", "abyss_midwaterexp", "abyss_hiding"],
|
||||
"cosmos": ["cosmos", "cosmos_galaxies", "cosmos_orion", "cosmos_tarantula",
|
||||
"cosmos_westerlund", "cosmos_southernring", "cosmos_carina_eso"],
|
||||
"orbit": ["orbit_planetearth", "orbit_bluemarble", "orbit_aurora2025",
|
||||
"orbit_citylights", "orbit_helene", "orbit_epic"],
|
||||
"sky": ["sky_grca_templesa", "sky_greenland_landice", "sky_greenland_suture",
|
||||
"sky_jungle_amazon", "sky_jungle_waterfall", "sky_coast_cliffspain",
|
||||
"sky_mtn_castlecrags", "sky_mtn_rocky"],
|
||||
"coast": ["coast_birdrock", "coast_surfgrass", "coast_kelp", "coast_otters",
|
||||
"coast_kalaloch", "coast_seals", "coast_mist"],
|
||||
"reef": ["reef_lionfish", "reef_spawning", "reef_hawkfish", "reef_coralspacific",
|
||||
"reef_redsea", "reef_flowergarden"],
|
||||
"abyss": ["abyss_wow", "abyss_midwaterexp", "abyss_hiding", "abyss_bigfin",
|
||||
"abyss_dandelion", "abyss_octopus", "abyss_seapig"],
|
||||
}
|
||||
|
||||
# Ring order (large -> small, wraps): cosmos -> orbit -> coast -> reef -> abyss -> cosmos.
|
||||
RING_ORDER = ["cosmos", "orbit", "coast", "reef", "abyss"]
|
||||
# Ring order (large -> small, wraps): cosmos -> orbit -> sky -> coast -> reef -> abyss -> cosmos.
|
||||
# `sky` (the airplane-window / low-flight layer) sits between orbit (space) and coast (ground).
|
||||
RING_ORDER = ["cosmos", "orbit", "sky", "coast", "reef", "abyss"]
|
||||
|
||||
# Per-scale soundtrack (audio spec §5.1) — the production-pass output of
|
||||
# simulator/build_audio_media.py, served at /media/audio/<path>.
|
||||
SCALE_AUDIO: dict[str, str] = {
|
||||
"cosmos": "cosmos/pillars.loop.mp3",
|
||||
"orbit": "orbit/spaceamb.loop.mp3",
|
||||
# TODO(sky): wants its own wind/air ambience — reusing the orbit space-amb loop as a placeholder.
|
||||
"sky": "orbit/spaceamb.loop.mp3",
|
||||
"coast": "coast/waves.loop.mp3",
|
||||
"reef": "reef/soundscape.loop.mp3",
|
||||
"abyss": "abyss/whale.loop.mp3",
|
||||
@@ -113,6 +124,71 @@ META: dict[str, tuple[str, str, str]] = {
|
||||
"NOAA Ocean Exploration midwater-exploration-1920x1080-1; trim 16–40s, crossfade-loop"),
|
||||
"abyss_hiding": ("“Hiding in the Dark” (NOAA Ocean Exploration)", PD,
|
||||
"NOAA Ocean Exploration dark-1280x720-1; trim 20–44s, crossfade-loop"),
|
||||
# --- Round-4 curated additions (2026-06-28, branch feat/sky-scale-expanded-pools) ---
|
||||
# cosmos
|
||||
"cosmos_orion": ("Orion Nebula — visible/IR flythrough (NASA SVS)", PD,
|
||||
"NASA SVS 30957 orion_vis_ir_zoom_xfade-1920x1080p30; trim 40–95s, crossfade-loop. Text-free."),
|
||||
"cosmos_tarantula": ("Tarantula Nebula (30 Doradus) zoom (NASA/ESA/CSA/STScI)", CCBY_WEBB,
|
||||
"ESA/Webb weic2212b; 1080p, trim 1–59s, crossfade-loop."),
|
||||
"cosmos_westerlund": ("Flight through star cluster Westerlund 2 (ESA/Hubble)", "CC-BY 4.0 — credit ESA/Hubble",
|
||||
"ESA/Hubble heic1509f; 1080p, trim 5–59s, crossfade-loop."),
|
||||
"cosmos_southernring": ("Southern Ring Nebula zoom (NASA/ESA/CSA/STScI)", CCBY_WEBB,
|
||||
"ESA/Webb weic2207a; 1080p, trim 1–59s, crossfade-loop."),
|
||||
"cosmos_carina_eso": ("Carina Nebula zoom (ESO)", "CC-BY 4.0 — credit ESO",
|
||||
"ESO eso1828c; 1080p, trim 0–48s, crossfade-loop."),
|
||||
# orbit
|
||||
"orbit_aurora2025": ("ISS aurora — Nov 2025 geomagnetic storm (NASA JSC)", PD,
|
||||
"NASA JSC ESRS ISS_20251112_071350; 1080p, trim 5–60s, crossfade-loop."),
|
||||
"orbit_citylights": ("ISS night pass — West Coast city lights (NASA JSC)", PD,
|
||||
"NASA SVS 30180 iss028_nighttime_20110819; 720p, trim 0–54s, crossfade-loop."),
|
||||
"orbit_helene": ("Hurricane Helene from the ISS — storm + atmospheric limb (NASA JSC)", PD,
|
||||
"NASA JSC jsc2024m000168; trim 5–59s, crossfade-loop."),
|
||||
"orbit_epic": ("DSCOVR/EPIC full-disk Earth time-lapse (NASA GSFC)", PD,
|
||||
"NASA SVS 12118 EPIC_timelapse; trim 0–60s, crossfade-loop."),
|
||||
"orbit_illuminated": ("“Earth Illuminated” — aurora + Milky Way over the limb (NASA JSC)", "CC0 / public domain",
|
||||
"NASA JSC Earth_Illuminated_120808; trim 68–113s, crossfade-loop."),
|
||||
# sky (NEW scale — airplane-window / low-flight, between orbit and coast)
|
||||
"sky_grca_templesa": ("Grand Canyon — banking aerial over cliffs & temples (NPS)", PD_NPS,
|
||||
"NPS Grand Canyon b-roll hd10 0121cbe5; 1080p, trim 0–24s, crossfade-loop."),
|
||||
"sky_greenland_landice": ("IceBridge — low flight over N. Greenland snowy land ice (NASA SVS)", PD,
|
||||
"NASA SVS 13472 Panasonic 2016 Airborne LandIce; trim 20–60s, vidstab-stabilized, crossfade-loop."),
|
||||
"sky_greenland_suture": ("IceBridge — overhead N. Greenland sea-ice patterning (NASA SVS)", PD,
|
||||
"NASA SVS 13472 4K SeaIce 2017 ‘Suture’; trim 10–50s, vidstab-stabilized, crossfade-loop."),
|
||||
"sky_jungle_amazon": ("Amazon rainforest canopy — amazon→canopy splice", "Pexels License",
|
||||
"Pexels 31291128 (amazon canopy) → 29780524 (jungle canopy), 1s xfade splice; crossfade-loop."),
|
||||
"sky_jungle_waterfall": ("Waterfall plunging out of dense jungle canopy", "Pexels License",
|
||||
"Pexels 8334944; 4K source, trim 6–46s, crossfade-loop."),
|
||||
"sky_coast_cliffspain": ("Dramatic Atlantic sea cliffs, Northern Spain — aerial", "Pexels License",
|
||||
"Pexels 36668415; 4K source, trim 1–25s, crossfade-loop."),
|
||||
"sky_mtn_castlecrags": ("Castle Crags — gliding past jagged granite spires (US Forest Service)", PD,
|
||||
"USFS/DVIDS 998726 DOD_111563303; trim 120–174s, crossfade-loop."),
|
||||
"sky_mtn_rocky": ("Rocky Mountain NP — aerial over alpine summits & tundra (NPS)", PD_NPS,
|
||||
"NPS Rocky Mountain b-roll 188046ff; trim 60–110s, crossfade-loop."),
|
||||
# coast
|
||||
"coast_kelp": ("Giant kelp forest, underwater", "Pexels License",
|
||||
"Pexels 30285973; 4K source, trim 0–46s, crossfade-loop."),
|
||||
"coast_otters": ("Wild sea otters, Elkhorn Slough CA (USFWS)", PD,
|
||||
"USFWS Pacific Southwest b-roll (Wikimedia 28833083527); trim 0–48s, crossfade-loop."),
|
||||
"coast_kalaloch": ("Slow surf on rock at sunset, Kalaloch WA", "CC-BY-SA 4.0 — credit Joe Mabel",
|
||||
"Wikimedia ‘Waves crashing on rocks off Beach 4, Kalaloch’; trim 0–46s, crossfade-loop."),
|
||||
"coast_seals": ("Harbor seals hauled out on a rock", "Pexels License",
|
||||
"Pexels 6497564; 4K source, trim 8–54s, crossfade-loop."),
|
||||
"coast_mist": ("Sea mist over a rocky coastline", "Pexels License",
|
||||
"Pexels 31036891; 4K source, trim 3–57s, crossfade-loop."),
|
||||
# reef
|
||||
"reef_redsea": ("Red Sea coral reef — clear blue, colorful", "CC-BY-SA 4.0 — credit Kora27",
|
||||
"Wikimedia ‘Korallenriff im Roten Meer DSCF1654’; trim 62–86s, crossfade-loop."),
|
||||
"reef_flowergarden": ("Sunlit reef + sergeant majors + jack/scad schools (NOAA Fisheries)", PD,
|
||||
"NOAA Fisheries b-roll VIDEO_ID 1553921798001; trim 8–68s, crossfade-loop."),
|
||||
# abyss
|
||||
"abyss_bigfin": ("Bigfin squid drifting in black water (NOAA Ocean Exploration)", PD,
|
||||
"NOAA OER Windows to the Deep 2021 dive10-bigfin-squid; trim 34–76s, crossfade-loop."),
|
||||
"abyss_dandelion": ("Dandelion siphonophore — glowing orb + threads (NOAA Ocean Exploration)", PD,
|
||||
"NOAA OER 2021 North Atlantic Stepping Stones dive13-dandelion; trim 12–70s, crossfade-loop."),
|
||||
"abyss_octopus": ("Deep-sea octopus (Graneledone) crawling (NOAA Ocean Exploration)", PD,
|
||||
"NOAA OER 2023 EXPRESS West Coast; Wikimedia Graneledone_boreopacifica_close-up; trim 6–60s, crossfade-loop."),
|
||||
"abyss_seapig": ("Swimming sea cucumber (Enypniastes), CC0 (NOAA)", "CC0 / public domain",
|
||||
"NOAA Okeanos Gulf of Mexico 2014 Ex1402-dive06; trim 0–44s, crossfade-loop."),
|
||||
}
|
||||
|
||||
# --- Affect vocabulary per SCALE (shared by all the scale's pool members) -------
|
||||
@@ -131,6 +207,12 @@ AFFECT: dict[str, list[tuple]] = {
|
||||
("feel.unity", [0.66, 0.58], 3, ["one", "unity", "belonging", "a borderless belonging"]),
|
||||
("feel.distance", [0.42, 0.82], 4, ["far", "distance", "remoteness", "an exquisite remoteness"]),
|
||||
],
|
||||
"sky": [
|
||||
("feel.exhilaration", [0.50, 0.44], 1, ["whee", "exhilaration", "elation", "a soaring elation"]),
|
||||
("feel.freedom", [0.22, 0.68], 2, ["free", "freedom", "release", "a boundless release"]),
|
||||
("feel.lightness", [0.66, 0.58], 3, ["light", "lightness", "buoyancy", "a weightless buoyancy"]),
|
||||
("feel.vertigo", [0.42, 0.82], 4, ["whoa", "vertigo", "giddiness", "a giddy, falling vertigo"]),
|
||||
],
|
||||
"coast": [
|
||||
("feel.ease", [0.50, 0.44], 1, ["nice", "ease", "calm", "an unhurried calm"]),
|
||||
("feel.nostalgia", [0.22, 0.68], 2, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
|
||||
@@ -151,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)."""
|
||||
@@ -187,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),
|
||||
],
|
||||
@@ -209,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]),
|
||||
],
|
||||
@@ -254,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"],
|
||||
@@ -271,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,
|
||||
@@ -311,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"],
|
||||
@@ -319,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
|
||||
@@ -347,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,
|
||||
@@ -399,6 +926,28 @@ def _adjacent_edges() -> list[tuple[str, str]]:
|
||||
return [(RING_ORDER[i], RING_ORDER[(i + 1) % n]) for i in range(n)]
|
||||
|
||||
|
||||
# All-intra H.264: every frame a keyframe, so the scrub interaction can seek to an
|
||||
# arbitrary frame smoothly (a sparse GOP scrubs "steppy"). Files grow, but each clip
|
||||
# stays well under the LFS/proxy ceiling. (Scrub-driven-transitions design §4.)
|
||||
ALL_INTRA = ["-c:v", "libx264", "-g", "1", "-keyint_min", "1", "-sc_threshold", "0", "-pix_fmt", "yuv420p"]
|
||||
|
||||
|
||||
def transition_cmd(ff: str, a: str, b: str, out: str) -> list[str]:
|
||||
"""The ffmpeg argv for a forward (zoom-in) member-pair morph, baked all-intra."""
|
||||
norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
|
||||
return [
|
||||
ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
|
||||
f"[0:v]{norm}[a];[1:v]{norm}[b];"
|
||||
"[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]",
|
||||
"-map", "[v]", "-an", *ALL_INTRA, str(out),
|
||||
]
|
||||
|
||||
|
||||
def reverse_cmd(ff: str, forward: str, out: str) -> list[str]:
|
||||
"""The ffmpeg argv for the zoom-OUT companion (forward played backward), all-intra."""
|
||||
return [ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", *ALL_INTRA, str(out)]
|
||||
|
||||
|
||||
def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path:
|
||||
"""A zoom/warp morph between two CLIP MEMBERS' base footage (the well-liked
|
||||
orbit-coast recipe), keyed by clip id so every adjacent member pair gets its
|
||||
@@ -407,13 +956,7 @@ def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path:
|
||||
b = MEDIA / dst_clip / "base.mp4"
|
||||
out = MEDIA / "transitions" / f"{src_clip}__{dst_clip}.mp4"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
|
||||
subprocess.run([
|
||||
ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
|
||||
f"[0:v]{norm}[a];[1:v]{norm}[b];"
|
||||
"[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]",
|
||||
"-map", "[v]", "-an", str(out),
|
||||
], check=True, capture_output=True)
|
||||
subprocess.run(transition_cmd(ff, str(a), str(b), str(out)), check=True, capture_output=True)
|
||||
return out
|
||||
|
||||
|
||||
@@ -423,9 +966,7 @@ def _make_reverse(ff: str, forward: Path) -> Path:
|
||||
crosses its edge `reversed`; the renderer then plays this file, so zooming out
|
||||
recedes from the current scale back to the higher one instead of zooming in."""
|
||||
out = forward.with_suffix(".rev.mp4")
|
||||
subprocess.run([
|
||||
ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", str(out),
|
||||
], check=True, capture_output=True)
|
||||
subprocess.run(reverse_cmd(ff, str(forward), str(out)), check=True, capture_output=True)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,173 @@ async function wheelOnStage(page: Page, deltaY: number) {
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY });
|
||||
}
|
||||
|
||||
// The Audio control is a 0–10 level slider; set it to full + fire `input` directly
|
||||
// (headless Chromium relaxes the autoplay gesture requirement).
|
||||
async function enableAudio(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const c = document.querySelector("#audio") as HTMLInputElement;
|
||||
c.value = "10";
|
||||
c.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
// The Video toggle gates the base-clip loop (update() only loads media when video is
|
||||
// shown). Same hidden-checkbox pattern.
|
||||
async function enableVideo(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const c = document.querySelector("#visual") as HTMLInputElement;
|
||||
c.checked = true;
|
||||
c.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
test("two audio elements exist for crossfade", async ({ page }) => {
|
||||
await boot(page);
|
||||
const n = await page.evaluate(() => document.querySelectorAll("audio#aud, audio#aud-b").length);
|
||||
expect(n).toBe(2);
|
||||
});
|
||||
|
||||
test("a new volume fade supersedes a stale one (audio not silenced after landing)", async ({ page }) => {
|
||||
await boot(page);
|
||||
// Reproduce the race: a fade-to-0 in flight, then the same element is brought back
|
||||
// up (as happens when a faded idle element becomes the newly-landed active scale).
|
||||
// The stale fade must NOT drag it back to 0.
|
||||
const vol = await page.evaluate(async () => {
|
||||
const a = document.querySelector("#aud") as HTMLAudioElement;
|
||||
a.volume = 1;
|
||||
(window as any).__hefFade(a, 0, 600); // start a slow fade to 0
|
||||
await new Promise((r) => setTimeout(r, 120)); // let it ramp partway down
|
||||
(window as any).__hefFade(a, 0.8, 90); // new fade up — must cancel the old one
|
||||
await new Promise((r) => setTimeout(r, 350)); // well past both fades
|
||||
return a.volume;
|
||||
});
|
||||
expect(vol).toBeGreaterThan(0.7); // ended near 0.8, not dragged to 0
|
||||
});
|
||||
|
||||
test("audio is a 0-10 level dial", async ({ page }) => {
|
||||
await boot(page);
|
||||
const ctl = await page.evaluate(() => {
|
||||
const c = document.querySelector("#audio") as HTMLInputElement;
|
||||
return { type: c.type, min: c.min, max: c.max };
|
||||
});
|
||||
expect(ctl.type).toBe("range");
|
||||
expect(ctl.min).toBe("0");
|
||||
expect(ctl.max).toBe("10");
|
||||
});
|
||||
|
||||
test("boots silent (video off, audio 0); turning video on couples audio to 3/10", async ({ page }) => {
|
||||
await boot(page);
|
||||
// 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");
|
||||
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3");
|
||||
});
|
||||
|
||||
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
|
||||
await boot(page);
|
||||
await enableAudio(page); // hidden custom toggle → set + dispatch change
|
||||
const box = (await page.locator("#dial").boundingBox())!;
|
||||
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); // partial clockwise turn (~0.5 detent)
|
||||
// The currentTime seek is throttled to one rAF; wait for it to land.
|
||||
await page.waitForFunction(() => (document.querySelector("video") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
|
||||
const state = await page.evaluate(() => ({
|
||||
t: (document.querySelector("video") as HTMLVideoElement).currentTime,
|
||||
ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
|
||||
gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
|
||||
}));
|
||||
expect(state.t).toBeGreaterThan(0); // morph scrubbed off frame 0
|
||||
expect(state.gb).toBeGreaterThan(0); // next scale fading in
|
||||
expect(state.ga).toBeLessThan(1); // current scale fading out
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
|
||||
await boot(page);
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
await wheelOnStage(page, 60); // wheel down = descend one altitude
|
||||
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
|
||||
const landed = (await page.locator("#scale-name").textContent())!;
|
||||
expect(landed).toContain("orbit");
|
||||
await page.waitForTimeout(1500);
|
||||
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
|
||||
});
|
||||
|
||||
test("auto-scrub speed is constant per altitude (a 2-step jump takes ~2x a 1-step)", async ({ page }) => {
|
||||
await boot(page);
|
||||
async function timeJump(deltaY: number, target: string): Promise<number> {
|
||||
const t0 = await page.evaluate(() => performance.now());
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY });
|
||||
await page.waitForFunction((tgt) => !!document.querySelector("#scale-name")?.textContent?.includes(tgt), target, { timeout: 20000 });
|
||||
return (await page.evaluate(() => performance.now())) - t0;
|
||||
}
|
||||
const t1 = await timeJump(60, "orbit"); // cosmos -> orbit (1 altitude)
|
||||
const t2 = await timeJump(120, "coast"); // orbit -> coast (2 altitudes; ring is cosmos·orbit·sky·coast·reef·abyss)
|
||||
const ratio = t2 / t1;
|
||||
expect(ratio).toBeGreaterThan(1.5); // proportional to distance, not a fixed total
|
||||
expect(ratio).toBeLessThan(2.7); // ~2x, not more
|
||||
});
|
||||
|
||||
test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)", async ({ page }) => {
|
||||
await boot(page);
|
||||
await enableVideo(page); // base loop only loads when video is shown
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
await wheelOnStage(page, 60);
|
||||
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
|
||||
// After landing, the dedicated loop element (#vid-loop) is armed (loopTail="1") and
|
||||
// playing from the tail (~3s) — preloaded during the scrub, so the landing was a
|
||||
// source swap, not a reload that restarts at 0.
|
||||
await page.waitForFunction(
|
||||
() => { const v = document.querySelector("#vid-loop") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.currentTime >= 2.5; },
|
||||
null,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const v = await page.evaluate(() => {
|
||||
const el = document.querySelector("#vid-loop") as HTMLVideoElement;
|
||||
return { t: el.currentTime, loopTail: el.dataset.loopTail, paused: el.paused };
|
||||
});
|
||||
expect(v.loopTail).toBe("1"); // tail-loop armed
|
||||
expect(v.paused).toBe(false); // the loop is actually playing
|
||||
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())!;
|
||||
@@ -57,35 +224,57 @@ test("zoom in plays the matching morph, lands locked, and stays put while parked
|
||||
expect((await morphs(page)).length).toBe(countAfterLanding); // no extra swap
|
||||
});
|
||||
|
||||
test("zoom back out plays a reverse morph and lands locked", async ({ page }) => {
|
||||
test("turn-back scrubs the same morph in reverse and re-locks the start clip", async ({ page }) => {
|
||||
await boot(page);
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
await enableAudio(page);
|
||||
const box = (await page.locator("#dial").boundingBox())!;
|
||||
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
|
||||
const read = () => page.evaluate(() => ({
|
||||
t: (document.querySelector("video") as HTMLVideoElement).currentTime,
|
||||
ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
|
||||
gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
|
||||
name: document.querySelector("#scale-name")?.textContent,
|
||||
morphs: ((window as any).__hefMorphs || []).slice(),
|
||||
}));
|
||||
|
||||
// Zoom in one detent to get off cosmos (-> orbit)...
|
||||
await wheelOnStage(page, 60);
|
||||
await page.waitForFunction(
|
||||
(s) => document.querySelector("#scale-name")?.textContent !== s,
|
||||
start,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
const atOrbit = (await page.locator("#scale-name").textContent())!;
|
||||
expect(atOrbit).toContain("orbit");
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 22, cy - 20, { steps: 10 }); // descend partway into the segment
|
||||
await page.waitForFunction(() => (document.querySelector("video") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
|
||||
const fwd = await read();
|
||||
// The single segment morph is the descend/forward file (cosmos__orbit), NOT a `.rev`.
|
||||
expect(fwd.morphs[fwd.morphs.length - 1]).toMatch(/^transitions\/cosmos.*\.mp4$/);
|
||||
expect(fwd.morphs[fwd.morphs.length - 1]).not.toMatch(/\.rev\.mp4$/);
|
||||
|
||||
// ...then zoom back OUT to cosmos.
|
||||
await wheelOnStage(page, -60);
|
||||
await page.waitForFunction(
|
||||
(s) => document.querySelector("#scale-name")?.textContent !== s,
|
||||
atOrbit,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await page.mouse.move(cx + 4, cy - 30, { steps: 10 }); // turn back toward the start
|
||||
await page.waitForTimeout(120); // let the throttled rAF seek land
|
||||
const back = await read();
|
||||
await page.mouse.up();
|
||||
|
||||
const played = await morphs(page);
|
||||
// The most recent morph is the zoom-out: a `.rev` companion. Its file is named
|
||||
// for the descending pair (cosmos<...>__orbit<...>), so it starts "cosmos".
|
||||
expect(played[played.length - 1]).toMatch(/^transitions\/cosmos.*\.rev\.mp4$/);
|
||||
|
||||
const landed = (await page.locator("#scale-name").textContent())!;
|
||||
expect(landed).toContain("cosmos");
|
||||
await page.waitForTimeout(2500);
|
||||
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
|
||||
expect(back.t).toBeLessThan(fwd.t); // SAME morph file, seeking backward
|
||||
expect(back.gb).toBeLessThan(fwd.gb); // next-scale audio receding
|
||||
expect(back.name).toContain("cosmos"); // re-locked the altitude we left
|
||||
});
|
||||
|
||||
test("crossing a detent commits and locks the destination clip", async ({ page }) => {
|
||||
await boot(page);
|
||||
const box = (await page.locator("#dial").boundingBox())!;
|
||||
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
|
||||
// Drag clockwise across one full detent (top -> right ~= 90deg = 1.25 detents),
|
||||
// crossing integer 1 (orbit) so it commits, then hold past it (no auto-complete).
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 30, cy + 6, { steps: 14 });
|
||||
await page.waitForFunction(() => (window as any).__hefState().ringIndex === 1, null, { timeout: 5000 });
|
||||
const st = await page.evaluate(() => (window as any).__hefState());
|
||||
expect(st.ringIndex).toBe(1); // committed to orbit
|
||||
expect(String(st.activeClipId)).toMatch(/^orbit/); // locked the destination pool pick
|
||||
const played = await page.evaluate(() => (window as any).__hefMorphs || []);
|
||||
expect(played[0]).toMatch(/^transitions\/cosmos.*\.mp4$/);
|
||||
expect(played[0]).not.toMatch(/\.rev\.mp4$/);
|
||||
// Hold: the committed clip does not change while parked mid-gesture.
|
||||
await page.waitForTimeout(800);
|
||||
const st2 = await page.evaluate(() => (window as any).__hefState());
|
||||
expect(st2.activeClipId).toBe(st.activeClipId);
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// The language dropdown localizes the visitor-facing chrome live (and resets to
|
||||
// English on reload — selection is session-only, no persistence). Annotation
|
||||
// re-rendering is covered by the unit tests (resolveStrings) + the manifest
|
||||
// invariant test; here we assert the DOM behavior of the dropdown itself.
|
||||
|
||||
async function boot(page: Page) {
|
||||
await page.goto("/");
|
||||
// initLanguage() populates the select during startup (before media preload).
|
||||
await expect(page.locator("#lang-select option")).toHaveCount(4);
|
||||
}
|
||||
|
||||
test("language dropdown localizes chrome and resets to English on reload", async ({ page }) => {
|
||||
await boot(page);
|
||||
const sel = page.locator("#lang-select");
|
||||
|
||||
// English selected by default
|
||||
await expect(sel).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||
|
||||
// switch to Spanish -> chrome changes, <html lang> updates
|
||||
await sel.selectOption("es");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Salida");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "es");
|
||||
|
||||
// Japanese
|
||||
await sel.selectOption("ja");
|
||||
await expect(page.locator('[data-i18n="knobs.think"]')).toHaveText("考える");
|
||||
|
||||
// reload returns to English (session-only, no persistence)
|
||||
await page.reload();
|
||||
await expect(page.locator("#lang-select")).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Regression: the steady-state loop (#vid-loop) restarts from the morph-tail
|
||||
// offset via a narrow timeupdate window. If that window is ever missed the clip
|
||||
// fires `ended` and — without a safety net — freezes on its last frame ("videos
|
||||
// aren't looping"). This asserts the `ended` safety net recovers playback.
|
||||
test("loop recovers when a clip reaches `ended` (no freeze on last frame)", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("#loading")?.classList.contains("done"),
|
||||
null,
|
||||
{ timeout: 45_000 },
|
||||
);
|
||||
|
||||
const r = await page.evaluate(async () => {
|
||||
const lv = document.getElementById("vid-loop") as HTMLVideoElement;
|
||||
// Reproduce the stuck state: parked on the last frame, as after a missed wrap.
|
||||
lv.pause();
|
||||
lv.currentTime = Math.max(0, lv.duration - 0.001);
|
||||
// The browser emits `ended` here in real life; emit it deterministically.
|
||||
lv.dispatchEvent(new Event("ended"));
|
||||
await new Promise((res) => setTimeout(res, 800));
|
||||
return { dur: +lv.duration.toFixed(2), ct: +lv.currentTime.toFixed(2), paused: lv.paused };
|
||||
});
|
||||
|
||||
// Recovered: playing again, and seeked back near the loop tail (not stuck at end).
|
||||
expect(r.paused).toBe(false);
|
||||
expect(r.ct).toBeLessThan(5);
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13607
-3332
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user