Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d54023f94 | |||
| bf1013be74 | |||
| 041fcdeb7d | |||
| 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 | |||
| b710b259f2 | |||
| 0e401c9a42 | |||
| 7f99c3cdc4 | |||
| 4b1d3afe7b | |||
| 153681a402 | |||
| e862fb498b | |||
| ea530b3df6 | |||
| 80952d7b8e | |||
| 0752746f7b | |||
| 614411b5e4 | |||
| a5e44ea9c7 | |||
| 70fc9a46ab | |||
| f2f242a411 | |||
| ad0a59ac04 | |||
| 31a3fd733f | |||
| 8458ab59eb | |||
| 20f49e936a | |||
| 0548e9a82b | |||
| 3dc3e3491f | |||
| 937461e3ba | |||
| cf74d2c70a | |||
| 6781e40c94 | |||
| 66c65e78b8 | |||
| 528fb5c438 | |||
| c2cc503a9c | |||
| 972ad8832b | |||
| 9b4884b466 | |||
| d7e9fea1d3 | |||
| e8cd783258 | |||
| 5382995931 | |||
| 7855718f74 | |||
| e2b54dd0cc | |||
| cc469b5298 | |||
| 3b3d30e8e0 | |||
| 441645ae7a | |||
| a581fa41ed | |||
| afec816bb0 | |||
| df869d6978 | |||
| 8aa5ee4019 | |||
| 6427ab4a49 | |||
| 56d08d5c60 | |||
| 97d2ddd573 | |||
| 56e352fe65 | |||
| 2564168ac3 | |||
| deb7c34df9 | |||
| 625d32c0f3 | |||
| 58a6ee4644 | |||
| fd1da791bf | |||
| 4ac39c9034 | |||
| 30ec0c9c26 | |||
| 3d46b96ecb | |||
| 3b19d3eb92 | |||
| fddb6d65d4 | |||
| d446238b79 | |||
| c51fdc422a |
@@ -0,0 +1,7 @@
|
||||
# Graduated, used experience media is versioned via git-LFS (large binaries).
|
||||
# Only the media the manifest actually references is tracked here — the curated
|
||||
# clip bases, the per-scale audio, and the baked altitude morphs. Stale/unused
|
||||
# media (e.g. the parked right_variants) is NOT tracked and stays gitignored.
|
||||
simulator/sample_media/**/base.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
simulator/sample_media/transitions/*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
simulator/sample_media/**/*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||
+9
-3
@@ -6,14 +6,20 @@ __pycache__/
|
||||
media/
|
||||
.superpowers/
|
||||
*.egg-info/
|
||||
# Simulator sample media (look-tuning only; populate via setup_sample_media.py
|
||||
# / build_pool_manifest.py). All scale + transition binaries are gitignored.
|
||||
# Simulator media. Throwaway / look-tuning binaries stay out of git; the media
|
||||
# that has GRADUATED into the actual experience — the curated clip bases, the
|
||||
# per-scale audio, and the baked altitude morphs the manifest references — is
|
||||
# committed via git-LFS (see .gitattributes), negated back in below. Stale/unused
|
||||
# media (e.g. the parked right_variants) stays ignored.
|
||||
simulator/sample_media/**/*.mp4
|
||||
# Audio candidate clips (re-sourceable via docs/audio-candidate-pool.md).
|
||||
simulator/sample_media/**/*.wav
|
||||
simulator/sample_media/**/*.mp3
|
||||
simulator/sample_media/**/*.ogg
|
||||
simulator/sample_media/**/*.m4a
|
||||
# ...EXCEPT the graduated, used experience media (versioned via git-LFS):
|
||||
!simulator/sample_media/**/base.mp4
|
||||
!simulator/sample_media/transitions/*.mp4
|
||||
!simulator/sample_media/**/*.mp3
|
||||
# Local-only candidate review galleries (re-buildable; not shipped content).
|
||||
simulator/static/review.html
|
||||
simulator/static/review_audio.html
|
||||
|
||||
@@ -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,939 @@
|
||||
# Altitude Clip-Pair Morph + Lock 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:** When the viewer changes altitude, choose the destination clip *first*, play the morph footage that goes from the currently-shown clip to that chosen clip, then lock the clip in place so it never changes again until the altitude changes.
|
||||
|
||||
**Architecture:** Today the ring carries **5** per-edge morphs (each `scale_a → scale_b`, primary→primary), the server picks a random pool member only for the *landed* scale, and `advance()` plays the edge morph (which ends on the scale's *primary*) then does a *second*, jarring `fadeThroughBlack` swap to the randomly-chosen member. We replace the per-edge model with a **per-clip-pair** model: every pair of clips in adjacent altitudes gets its own morph, in both directions (**154** clips total). The server picks the destination member(s) *before* selecting the morph, threads the currently-shown clip in as the source, and chains morphs through each crossed altitude on a multi-detent jump. The renderer plays those exact morphs and locks on the final clip — no secondary swap, no re-roll while parked.
|
||||
|
||||
**Tech Stack:** Python 3.13 / FastAPI (`simulator/app.py`), pure ring logic (`player/ring.py`), pure manifest/schema (`simulator/clips.py`), media baking via ffmpeg `xfade=zoomin` (`simulator/build_pool_manifest.py`), vanilla JS renderer (`simulator/static/app.js`), pytest, and a new Playwright E2E tier.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Morph file naming:** `transitions/<src_clip_id>__<dst_clip_id>.mp4` (double-underscore separator — clip ids themselves use single `_`, e.g. `cosmos_miri`). The zoom-out companion is `<src>__<dst>.rev.mp4` and represents the **reverse** direction (`dst → src`).
|
||||
- **Direction convention:** a morph is *baked* descending (higher altitude → lower altitude = zoom IN). The `.rev.mp4` companion is the ascending (zoom OUT) playback. The manifest lists **both** directions as explicit, separately-keyed entries so the renderer needs no reverse logic at play time.
|
||||
- **Ring order (descending, wraps):** `["cosmos", "orbit", "coast", "reef", "abyss"]`; `abyss → cosmos` is the wrap edge.
|
||||
- **Pool sizes (current):** cosmos 5, orbit 3, coast 4, reef 5, abyss 3 → **77** adjacent ordered-pair morphs descending + **77** reverse = **154** total transition clips.
|
||||
- **Lock invariant:** the displayed base clip changes **only** inside `advance()` (a deliberate altitude change) and the one initial `landScale()`. Nothing else may reload base media. The Dev-Mode re-roll button stays as an explicit dev affordance.
|
||||
- **Randomness stays injected:** `player/ring.py` is a pure module; the impure `random.random()` draw happens once at the API boundary (`simulator/app.py`). Pure functions take injected `float` picks in `[0,1)`.
|
||||
- **Back-compat:** a manifest with no `pool` (pool of one) and the synthesized single-clip fallback ring must still work. A pool-of-one scale needs no morphs to itself.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Manifest emits clip-pair transition entries
|
||||
|
||||
Re-key the manifest builder from 5 per-edge entries to 154 per-clip-pair entries (both directions), each carrying `from`/`to` clip ids. Pure dict assembly — no ffmpeg.
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/build_pool_manifest.py:374-379` (`build_manifest()` transitions block)
|
||||
- Test: `tests/test_pipeline_manifest.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: module constants `RING_ORDER: list[str]`, `POOLS: dict[str, list[str]]`.
|
||||
- Produces: each transition dict shaped `{"from": <src_id>, "to": <dst_id>, "file": "transitions/<src>__<dst>.mp4", "model": "placeholder-zoom"}`. Forward entries are descending (`H→L`, `transitions/H__L.mp4`); reverse entries are ascending (`L→H`, `transitions/H__L.rev.mp4`).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_manifest.py (add)
|
||||
def test_transitions_are_per_clip_pair_both_directions():
|
||||
import simulator.build_pool_manifest as b
|
||||
m = b.build_manifest()
|
||||
trans = m["ring"]["transitions"]
|
||||
order = b.RING_ORDER
|
||||
n = len(order)
|
||||
# Every adjacent (higher H, lower L) edge, every member pair, BOTH directions.
|
||||
expected = set()
|
||||
for i in range(n):
|
||||
H, L = order[i], order[(i + 1) % n]
|
||||
for h in b.POOLS[H]:
|
||||
for l in b.POOLS[L]:
|
||||
expected.add((h, l, f"transitions/{h}__{l}.mp4")) # zoom in
|
||||
expected.add((l, h, f"transitions/{h}__{l}.rev.mp4")) # zoom out
|
||||
got = {(t["from"], t["to"], t["file"]) for t in trans}
|
||||
assert got == expected
|
||||
assert len(trans) == len(expected) == 154
|
||||
assert all(t["model"] == "placeholder-zoom" for t in trans)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py::test_transitions_are_per_clip_pair_both_directions -v`
|
||||
Expected: FAIL — current builder emits 5 `scale-scale` entries with no `from`/`to`.
|
||||
|
||||
- [ ] **Step 3: Implement the per-clip-pair transitions block**
|
||||
|
||||
```python
|
||||
# simulator/build_pool_manifest.py — replace the transitions loop in build_manifest()
|
||||
transitions = []
|
||||
n = len(RING_ORDER)
|
||||
for i in range(n):
|
||||
H, L = RING_ORDER[i], RING_ORDER[(i + 1) % n] # H higher altitude, L lower
|
||||
for h in POOLS[H]:
|
||||
for l in POOLS[L]:
|
||||
fwd = f"transitions/{h}__{l}.mp4" # zoom IN (descend H->L)
|
||||
transitions.append({"from": h, "to": l, "file": fwd,
|
||||
"model": "placeholder-zoom"})
|
||||
transitions.append({"from": l, "to": h, "file": f"transitions/{h}__{l}.rev.mp4",
|
||||
"model": "placeholder-zoom"}) # zoom OUT (ascend L->H)
|
||||
return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py::test_transitions_are_per_clip_pair_both_directions -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/build_pool_manifest.py tests/test_pipeline_manifest.py
|
||||
git commit -m "feat(pipeline): manifest emits per-clip-pair morph transitions (both directions)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Bake the member-pair morph clips
|
||||
|
||||
Generalize the ffmpeg morph recipe from scale-primary→scale-primary to any member→member, and have `generate_media()` bake every adjacent member pair forward + reverse. Tested with a stubbed ffmpeg runner so the test is fast and deterministic (the real bake runs as a media step at execution time).
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/build_pool_manifest.py:391-431` (`_make_transition`, `generate_media`)
|
||||
- Test: `tests/test_pipeline_manifest.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RING_ORDER`, `POOLS`, `MEDIA` (Path), member base footage at `MEDIA/<clip_id>/base.mp4`.
|
||||
- Produces: `_make_transition(ff, src_clip, dst_clip) -> Path` writing `transitions/<src>__<dst>.mp4` from the two members' `base.mp4`; `_make_reverse(ff, forward)` unchanged (writes `.rev.mp4`). `generate_media()` loops every adjacent ordered member pair.
|
||||
|
||||
- [ ] **Step 1: Write the failing test (stubbed ffmpeg)**
|
||||
|
||||
```python
|
||||
# tests/test_pipeline_manifest.py (add)
|
||||
def test_generate_media_bakes_every_member_pair(monkeypatch, tmp_path):
|
||||
import simulator.build_pool_manifest as b
|
||||
calls = []
|
||||
def fake_run(args, **kw):
|
||||
# record the output path (last positional arg of each ffmpeg call)
|
||||
calls.append(args[-1])
|
||||
from pathlib import Path
|
||||
Path(args[-1]).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args[-1]).write_bytes(b"x")
|
||||
class R: returncode = 0
|
||||
return R()
|
||||
monkeypatch.setattr(b, "MEDIA", tmp_path)
|
||||
monkeypatch.setattr(b.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(b, "_ffmpeg", lambda: "ffmpeg")
|
||||
b.generate_media()
|
||||
outs = {str(p).split("transitions/")[-1] for p in calls}
|
||||
# one forward + one reverse per adjacent member pair
|
||||
fwd = {f"{h}__{l}.mp4" for H, L in b._adjacent_edges()
|
||||
for h in b.POOLS[H] for l in b.POOLS[L]}
|
||||
rev = {f"{h}__{l}.rev.mp4" for H, L in b._adjacent_edges()
|
||||
for h in b.POOLS[H] for l in b.POOLS[L]}
|
||||
assert fwd <= outs and rev <= outs
|
||||
assert len(fwd) + len(rev) == 154
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py::test_generate_media_bakes_every_member_pair -v`
|
||||
Expected: FAIL — `_adjacent_edges` undefined and `generate_media` loops scales, not members.
|
||||
|
||||
- [ ] **Step 3: Implement member-pair baking**
|
||||
|
||||
```python
|
||||
# simulator/build_pool_manifest.py
|
||||
|
||||
def _adjacent_edges() -> list[tuple[str, str]]:
|
||||
"""Ordered (higher, lower) altitude edges around the ring, including the wrap."""
|
||||
n = len(RING_ORDER)
|
||||
return [(RING_ORDER[i], RING_ORDER[(i + 1) % n]) for i in range(n)]
|
||||
|
||||
|
||||
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
|
||||
own morph. Forward = zoom IN (descend src->dst)."""
|
||||
a = MEDIA / src_clip / "base.mp4"
|
||||
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)
|
||||
return out
|
||||
|
||||
|
||||
def generate_media() -> None:
|
||||
"""Bake EVERY adjacent member-pair morph from real bases — forward (zoom in,
|
||||
descend) plus its reversed companion (zoom out, ascend)."""
|
||||
ff = _ffmpeg()
|
||||
for H, L in _adjacent_edges():
|
||||
for h in POOLS[H]:
|
||||
for l in POOLS[L]:
|
||||
fwd = _make_transition(ff, h, l)
|
||||
_make_reverse(ff, fwd)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_pipeline_manifest.py::test_generate_media_bakes_every_member_pair -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/build_pool_manifest.py tests/test_pipeline_manifest.py
|
||||
git commit -m "feat(pipeline): bake member-pair zoom morphs for every adjacent video combination"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Clip-pair transition schema + morph lookup
|
||||
|
||||
Change the `Transition` dataclass and ring loader to carry `from_clip`/`to_clip`, build a `(from, to) → file` lookup on the ring, and update the structural invariant. Touches both `player/ring.py` (dataclass + ring) and `simulator/clips.py` (parse + serialize).
|
||||
|
||||
**Files:**
|
||||
- Modify: `player/ring.py:75-113` (`Transition`, `ScaleRing.__post_init__`, add `morph_for`)
|
||||
- Modify: `simulator/clips.py:88-131` (`load_ring` transition parse, `ring_to_dict` transitions block)
|
||||
- Test: `tests/test_player_ring.py`, `tests/test_clips.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Transition(from_clip: str, to_clip: str, file: str, model: str = "")`.
|
||||
- Produces: `ScaleRing.morph_for(from_clip: str, to_clip: str) -> str | None` — the morph file for that directed pair, or `None` if absent.
|
||||
- `ScaleRing.__post_init__`: drop the "N edges == N transitions" rule (no longer per-edge); keep "≥1 scale, single-scale ring has no transitions". Build the lookup dict in `__post_init__` (store on a private attr via `object.__setattr__`, since the dataclass is frozen).
|
||||
- `ring_to_dict` transitions become `[{"from","to","file","model"}, ...]`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
# tests/test_player_ring.py (add)
|
||||
from player.ring import Scale, Transition, ScaleRing
|
||||
|
||||
def _two_member_ring():
|
||||
scales = (Scale(id="cosmos", clip_id="c1", pool=("c1", "c2")),
|
||||
Scale(id="abyss", clip_id="a1", pool=("a1",)))
|
||||
# adjacency wraps: cosmos<->abyss; members c1,c2 x a1 -> 2 pairs x2 dirs = 4
|
||||
trans = (Transition("c1", "a1", "transitions/c1__a1.mp4"),
|
||||
Transition("a1", "c1", "transitions/c1__a1.rev.mp4"),
|
||||
Transition("c2", "a1", "transitions/c2__a1.mp4"),
|
||||
Transition("a1", "c2", "transitions/c2__a1.rev.mp4"))
|
||||
return ScaleRing(scales=scales, transitions=trans)
|
||||
|
||||
def test_morph_for_returns_directed_file():
|
||||
r = _two_member_ring()
|
||||
assert r.morph_for("c1", "a1") == "transitions/c1__a1.mp4"
|
||||
assert r.morph_for("a1", "c2") == "transitions/c2__a1.rev.mp4"
|
||||
assert r.morph_for("c1", "c2") is None # not an adjacent-altitude pair
|
||||
```
|
||||
|
||||
```python
|
||||
# tests/test_clips.py (add)
|
||||
def test_load_ring_parses_clip_pair_transitions(tmp_path):
|
||||
import json
|
||||
from simulator.clips import load_ring
|
||||
m = {"ring": {"scales": [{"id": "x", "clip_id": "x1", "pool": ["x1"]},
|
||||
{"id": "y", "clip_id": "y1", "pool": ["y1"]}],
|
||||
"transitions": [{"from": "x1", "to": "y1", "file": "transitions/x1__y1.mp4"},
|
||||
{"from": "y1", "to": "x1", "file": "transitions/x1__y1.rev.mp4"}]}}
|
||||
p = tmp_path / "manifest.json"; p.write_text(json.dumps(m))
|
||||
ring = load_ring(p)
|
||||
assert ring.morph_for("x1", "y1") == "transitions/x1__y1.mp4"
|
||||
assert ring.morph_for("y1", "x1") == "transitions/x1__y1.rev.mp4"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py::test_morph_for_returns_directed_file tests/test_clips.py::test_load_ring_parses_clip_pair_transitions -v`
|
||||
Expected: FAIL — `Transition` has no `from_clip`/`to_clip`; no `morph_for`.
|
||||
|
||||
- [ ] **Step 3: Implement the schema + lookup**
|
||||
|
||||
```python
|
||||
# player/ring.py — replace the Transition dataclass
|
||||
@dataclass(frozen=True)
|
||||
class Transition:
|
||||
"""A pre-baked zoom/warp morph between TWO specific clips in adjacent
|
||||
altitudes. `file` plays as-is (the manifest holds both directions as
|
||||
separate, directed entries — forward zoom-in and `.rev` zoom-out)."""
|
||||
from_clip: str
|
||||
to_clip: str
|
||||
file: str
|
||||
model: str = ""
|
||||
```
|
||||
|
||||
```python
|
||||
# player/ring.py — ScaleRing: relax the invariant, build the lookup
|
||||
@dataclass(frozen=True)
|
||||
class ScaleRing:
|
||||
scales: tuple[Scale, ...]
|
||||
transitions: tuple[Transition, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if len(self.scales) == 0:
|
||||
raise RingError("a ScaleRing needs at least one scale")
|
||||
lookup = {(t.from_clip, t.to_clip): t.file for t in self.transitions}
|
||||
object.__setattr__(self, "_morphs", lookup)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.scales)
|
||||
|
||||
def morph_for(self, from_clip: str, to_clip: str) -> str | None:
|
||||
"""The morph file for the directed clip pair, or None if none baked."""
|
||||
return self._morphs.get((from_clip, to_clip))
|
||||
```
|
||||
|
||||
```python
|
||||
# simulator/clips.py — load_ring transition parse (replace lines 101-104)
|
||||
transitions = tuple(
|
||||
Transition(
|
||||
from_clip=t.get("from", ""),
|
||||
to_clip=t.get("to", ""),
|
||||
file=t["file"],
|
||||
model=t.get("model", ""),
|
||||
)
|
||||
for t in ring.get("transitions", [])
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# simulator/clips.py — ring_to_dict transitions block (replace line 130)
|
||||
"transitions": [
|
||||
{"from": t.from_clip, "to": t.to_clip, "file": t.file, "model": t.model}
|
||||
for t in ring.transitions
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests + the existing ring/clips suites**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py tests/test_clips.py -v`
|
||||
Expected: PASS — fix any existing test that constructed `Transition(file=..., model=...)` positionally to the new 3-arg form (`Transition(from_clip, to_clip, file)`), and any test asserting the old `N == N` invariant.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add player/ring.py simulator/clips.py tests/test_player_ring.py tests/test_clips.py
|
||||
git commit -m "feat(ring): clip-pair Transition schema + morph_for lookup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Resolve chained member→member morphs (pick before transition)
|
||||
|
||||
Add a pure function that, given a structural `RingMove`, the currently-shown clip, and one injected pick per step, walks the steps — choosing a destination member for each crossed altitude and resolving the morph from the *previous* clip to it — and reports the final locked clip. This is the heart of "choose the clip first, then play the matching morph," chained through each altitude.
|
||||
|
||||
**Files:**
|
||||
- Modify: `player/ring.py` (add `MorphStep`, `ResolvedMove`, `resolve_move`)
|
||||
- Test: `tests/test_player_ring.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RingMove` (from `advance_ring`), `pick_clip_id`, `scale_at`, `ScaleRing.morph_for`.
|
||||
- Produces:
|
||||
- `MorphStep(from_clip: str, to_clip: str, file: str | None, to_index: int, blended: bool)`
|
||||
- `ResolvedMove(steps: tuple[MorphStep, ...], target_clip_id: str)`
|
||||
- `resolve_move(ring: ScaleRing, move: RingMove, from_clip_id: str, picks: tuple[float, ...]) -> ResolvedMove` — `len(picks)` must equal `len(move.steps)`; each `picks[k]` chooses the member of the scale that step `k` lands on. `from_clip` of step 0 is `from_clip_id`; each subsequent step's `from_clip` is the prior step's `to_clip`. `target_clip_id` is the last step's `to_clip` (or `from_clip_id` for an empty move).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
# tests/test_player_ring.py (add)
|
||||
from player.ring import advance_ring, resolve_move
|
||||
|
||||
def _chain_ring():
|
||||
# 3 scales, pools of 2/1/2; descending edges cosmos->coast->abyss + wrap abyss->cosmos
|
||||
scales = (Scale("cosmos", "c1", ("c1", "c2")),
|
||||
Scale("coast", "o1", ("o1",)),
|
||||
Scale("abyss", "a1", ("a1", "a2")))
|
||||
t = []
|
||||
def pair(h, l):
|
||||
t.append(Transition(h, l, f"transitions/{h}__{l}.mp4"))
|
||||
t.append(Transition(l, h, f"transitions/{h}__{l}.rev.mp4"))
|
||||
for h in ("c1", "c2"):
|
||||
pair(h, "o1") # cosmos<->coast
|
||||
for h in ("o1",):
|
||||
pair(h, "a1"); pair(h, "a2") # coast<->abyss
|
||||
for h in ("a1", "a2"):
|
||||
pair(h, "c1"); pair(h, "c2") # abyss<->cosmos (wrap)
|
||||
return ScaleRing(scales=scales, transitions=tuple(t))
|
||||
|
||||
def test_resolve_single_step_zoom_in():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=1) # cosmos -> coast
|
||||
res = resolve_move(r, move, from_clip_id="c2", picks=(0.0,))
|
||||
assert len(res.steps) == 1
|
||||
s = res.steps[0]
|
||||
assert (s.from_clip, s.to_clip) == ("c2", "o1")
|
||||
assert s.file == "transitions/c2__o1.mp4"
|
||||
assert res.target_clip_id == "o1"
|
||||
|
||||
def test_resolve_chains_through_intermediate_altitude():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=2) # cosmos -> coast -> abyss
|
||||
# pick coast member (only o1) then abyss member: 0.6*2 -> index 1 -> a2
|
||||
res = resolve_move(r, move, from_clip_id="c1", picks=(0.0, 0.6))
|
||||
files = [s.file for s in res.steps]
|
||||
assert files == ["transitions/c1__o1.mp4", "transitions/o1__a2.mp4"]
|
||||
assert res.target_clip_id == "a2"
|
||||
|
||||
def test_resolve_zoom_out_uses_reverse_file():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=2, delta=-1) # abyss -> coast (ascend)
|
||||
res = resolve_move(r, move, from_clip_id="a1", picks=(0.0,))
|
||||
assert res.steps[0].file == "transitions/o1__a1.rev.mp4"
|
||||
assert res.target_clip_id == "o1"
|
||||
|
||||
def test_resolve_empty_move_keeps_source():
|
||||
r = _chain_ring()
|
||||
move = advance_ring(r, from_index=0, delta=0)
|
||||
res = resolve_move(r, move, from_clip_id="c2", picks=())
|
||||
assert res.steps == () and res.target_clip_id == "c2"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py -k resolve -v`
|
||||
Expected: FAIL — `resolve_move` undefined.
|
||||
|
||||
- [ ] **Step 3: Implement `resolve_move`**
|
||||
|
||||
```python
|
||||
# player/ring.py (add near the bottom)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MorphStep:
|
||||
"""One morph to play while advancing: from the clip currently shown to the
|
||||
chosen clip of the next altitude, the morph file, the index it lands on, and
|
||||
whether it plays fast (a fast-spin pass)."""
|
||||
from_clip: str
|
||||
to_clip: str
|
||||
file: str | None
|
||||
to_index: int
|
||||
blended: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedMove:
|
||||
steps: tuple[MorphStep, ...]
|
||||
target_clip_id: str
|
||||
|
||||
|
||||
def resolve_move(
|
||||
ring: ScaleRing,
|
||||
move: RingMove,
|
||||
from_clip_id: str,
|
||||
picks: tuple[float, ...],
|
||||
) -> ResolvedMove:
|
||||
"""Choose the destination clip for each crossed altitude (injected `picks`,
|
||||
one per step) and resolve the morph from the previous clip to it. The morph
|
||||
is chosen AFTER the destination clip, so the footage matches what we land on.
|
||||
`file` is None if no morph was baked for a pair (renderer falls back to a
|
||||
plain cut); `target_clip_id` is the final locked clip."""
|
||||
if len(picks) != len(move.steps):
|
||||
raise ValueError(f"need one pick per step: {len(picks)} != {len(move.steps)}")
|
||||
steps: list[MorphStep] = []
|
||||
src = from_clip_id
|
||||
for st, r in zip(move.steps, picks):
|
||||
dst_scale = scale_at(ring, st.to_index)
|
||||
dst = pick_clip_id(dst_scale, r)
|
||||
steps.append(MorphStep(
|
||||
from_clip=src,
|
||||
to_clip=dst,
|
||||
file=ring.morph_for(src, dst),
|
||||
to_index=st.to_index,
|
||||
blended=st.blended,
|
||||
))
|
||||
src = dst
|
||||
return ResolvedMove(steps=tuple(steps), target_clip_id=src)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py -k resolve -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add player/ring.py tests/test_player_ring.py
|
||||
git commit -m "feat(ring): resolve_move picks destination clip then matching morph, chained"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Fast spin plays the whole chain quickly (no collapse)
|
||||
|
||||
The operator chose "chain through each altitude," with fast spins playing that chain *quickly* rather than collapsing to a single arrival pass. Change `advance_ring`'s fast-spin branch to keep every step but mark each `blended=True` (so the renderer plays each morph at `FAST_BLEND_RATE`) and still set `RingMove.fast=True`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `player/ring.py:219-241` (fast-spin branch in `advance_ring`)
|
||||
- Test: `tests/test_player_ring.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Unchanged signature. Behavior change: a fast spin returns **all** steps (each `blended=True`), not one collapsed step.
|
||||
|
||||
- [ ] **Step 1: Write the failing test (and update any existing fast-spin assertions)**
|
||||
|
||||
```python
|
||||
# tests/test_player_ring.py (add)
|
||||
def test_fast_spin_keeps_all_steps_blended():
|
||||
r = _chain_ring() # 3-scale ring from Task 4
|
||||
move = advance_ring(r, from_index=0, delta=3, fast_spin_threshold=3)
|
||||
assert move.fast is True
|
||||
assert len(move.steps) == 3 # NOT collapsed to 1
|
||||
assert all(s.blended for s in move.steps)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py::test_fast_spin_keeps_all_steps_blended -v`
|
||||
Expected: FAIL — current branch collapses to a single `(blended,)` step.
|
||||
|
||||
- [ ] **Step 3: Implement the chain-fast branch**
|
||||
|
||||
```python
|
||||
# player/ring.py — replace the fast-spin collapse block (lines ~219-237)
|
||||
if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold:
|
||||
fast_steps = tuple(
|
||||
TransitionStep(edge=s.edge, reversed=s.reversed, file=s.file,
|
||||
to_index=s.to_index, blended=True)
|
||||
for s in steps
|
||||
)
|
||||
return RingMove(from_index=start, to_index=index, steps=fast_steps,
|
||||
wrapped=wrapped, fast=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full ring suite (catch the old collapse test)**
|
||||
|
||||
Run: `pytest tests/test_player_ring.py -v`
|
||||
Expected: PASS — update/replace any prior test that asserted fast spin yields exactly one step.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add player/ring.py tests/test_player_ring.py
|
||||
git commit -m "feat(ring): fast spin plays the full morph chain quickly instead of collapsing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: API picks before transitioning and serializes the resolved chain
|
||||
|
||||
`/api/ring/advance` accepts the currently-shown clip (`from_clip_id`), draws one random pick per step, resolves the chained morphs, and returns the resolved steps + locked `target_clip_id`. Also extend `media-versions` to cover every transition file.
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/app.py:119-121` (`RingAdvanceRequest`), `:237-252` (`api_ring_advance`), `:213-229` (`api_media_versions`)
|
||||
- Modify: `simulator/clips.py:134-160` (`ring_move_to_dict` → resolved-move serializer)
|
||||
- Test: `tests/test_simulator_api.py`
|
||||
|
||||
**Interfaces:**
|
||||
- `RingAdvanceRequest`: add `from_clip_id: str = ""`.
|
||||
- New serializer `resolved_move_to_dict(move: RingMove, resolved: ResolvedMove) -> dict`:
|
||||
```json
|
||||
{"from_index": int, "to_index": int, "wrapped": bool, "fast": bool,
|
||||
"target_clip_id": str,
|
||||
"steps": [{"from_clip": str, "to_clip": str, "file": str|null,
|
||||
"to_index": int, "blended": bool}, ...]}
|
||||
```
|
||||
- `media-versions`: iterate `t.file` for every transition (no `.rev` derivation — both directions are explicit entries now).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_simulator_api.py (add)
|
||||
def test_advance_picks_then_returns_matching_morph(client):
|
||||
# client fixture serves the sample_media manifest
|
||||
r = client.post("/api/ring/advance",
|
||||
json={"from_index": 0, "delta": 1, "from_clip_id": "cosmos"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["to_index"] == 1
|
||||
target = body["target_clip_id"]
|
||||
step = body["steps"][-1]
|
||||
assert step["from_clip"] == "cosmos" # threaded current clip
|
||||
assert step["to_clip"] == target # morph lands on the locked clip
|
||||
# file matches the directed pair (zoom-in descend, forward file)
|
||||
assert step["file"] == f"transitions/cosmos__{target}.mp4"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_simulator_api.py::test_advance_picks_then_returns_matching_morph -v`
|
||||
Expected: FAIL — response has no `from_clip`/`to_clip`, file is the old per-edge name.
|
||||
|
||||
- [ ] **Step 3: Implement request field, endpoint, serializer, media-versions**
|
||||
|
||||
```python
|
||||
# simulator/app.py
|
||||
class RingAdvanceRequest(BaseModel):
|
||||
from_index: int = 0
|
||||
delta: int
|
||||
from_clip_id: str = ""
|
||||
```
|
||||
|
||||
```python
|
||||
# simulator/app.py — api_ring_advance
|
||||
@app.post("/api/ring/advance")
|
||||
def api_ring_advance(req: RingAdvanceRequest):
|
||||
if app.state.ring is None:
|
||||
raise HTTPException(status_code=404, detail="no scale ring in manifest")
|
||||
move = advance_ring(app.state.ring, req.from_index, req.delta,
|
||||
fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD)
|
||||
# Choose the destination member of each crossed altitude FIRST (one random
|
||||
# draw per step), then resolve the morph from the prior clip to it.
|
||||
src = req.from_clip_id or scale_at(app.state.ring, move.from_index).clip_id
|
||||
picks = tuple(random.random() for _ in move.steps)
|
||||
resolved = resolve_move(app.state.ring, move, src, picks)
|
||||
return resolved_move_to_dict(move, resolved)
|
||||
```
|
||||
|
||||
```python
|
||||
# simulator/clips.py — replace ring_move_to_dict with resolved_move_to_dict
|
||||
def resolved_move_to_dict(move, resolved) -> dict:
|
||||
"""JSON form of a resolved encoder move: where it lands, the locked clip, and
|
||||
the ordered chained morphs (each from the prior clip to its chosen
|
||||
destination, with the matching morph file)."""
|
||||
return {
|
||||
"from_index": move.from_index,
|
||||
"to_index": move.to_index,
|
||||
"wrapped": move.wrapped,
|
||||
"fast": move.fast,
|
||||
"target_clip_id": resolved.target_clip_id,
|
||||
"steps": [
|
||||
{"from_clip": s.from_clip, "to_clip": s.to_clip, "file": s.file,
|
||||
"to_index": s.to_index, "blended": s.blended}
|
||||
for s in resolved.steps
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# simulator/app.py — api_media_versions: cover every transition file explicitly
|
||||
files = {c.base_file for c in app.state.clips}
|
||||
if app.state.ring is not None:
|
||||
for t in app.state.ring.transitions:
|
||||
files.add(t.file)
|
||||
```
|
||||
|
||||
Update imports in `simulator/app.py` (`resolve_move`, `scale_at` already imported; add `resolve_move`) and `simulator/clips.py` (drop `ring_move_to_dict`, add `resolved_move_to_dict`; remove the now-unused `_rev_file` if nothing else uses it — grep first).
|
||||
|
||||
- [ ] **Step 4: Run the API suite**
|
||||
|
||||
Run: `pytest tests/test_simulator_api.py -v`
|
||||
Expected: PASS — fix any test referencing the removed `ring_move_to_dict` / old response shape.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/app.py simulator/clips.py tests/test_simulator_api.py
|
||||
git commit -m "feat(api): /api/ring/advance picks clip then returns matching chained morphs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Renderer plays the chosen morph and locks the clip (no secondary swap)
|
||||
|
||||
Rewire the frontend: send the currently-shown clip, play each resolved morph straight (the manifest already encodes direction, so drop the `reversed`/`reverseFile` logic), land + lock on `target_clip_id` with a plain (re)load, and **delete the secondary `fadeThroughBlack` swap**. Build a `(from,to)→file` lookup from the ring and preload only the morphs reachable from the current locked clip, refreshed on each landing (154 clips is too many to preload eagerly).
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` — `advance` (620-652), `playTransition` (568-595), `onload`/ring load (33-57), `preloadOrder`/`preloadList`/`reverseFile` (104-131, 566), remove `fadeThroughBlack` (600-618) call.
|
||||
- Verified by: Task 8 (E2E) + manual run.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes the Task 6 response: `move.steps[k] = {from_clip, to_clip, file, to_index, blended}`, `move.target_clip_id`.
|
||||
- New module state: `morphByPair = {}` — `"from→to"` → file string, built from `ring.transitions`.
|
||||
|
||||
- [ ] **Step 1: Build the morph lookup when the ring loads**
|
||||
|
||||
```javascript
|
||||
// simulator/static/app.js — in loadRing(), after `ring = ...` is set:
|
||||
morphByPair = {};
|
||||
if (ring && ring.transitions)
|
||||
for (const t of ring.transitions) morphByPair[`${t.from}→${t.to}`] = t.file;
|
||||
```
|
||||
|
||||
Add `let morphByPair = {};` near the other module state (around line 38). The synthesized single-clip fallback ring has no transitions → empty lookup → advance is a no-op (pool of one), which is correct.
|
||||
|
||||
- [ ] **Step 2: Simplify `playTransition` (no reverse logic)**
|
||||
|
||||
```javascript
|
||||
// simulator/static/app.js — replace playTransition
|
||||
function playTransition(file, blended) {
|
||||
// Play one resolved morph start-to-finish with alteration layers hidden. The
|
||||
// manifest already encodes direction (forward zoom-in vs `.rev` zoom-out), so
|
||||
// the file is played as-is. A fast-spin step runs at FAST_BLEND_RATE. If no
|
||||
// morph exists for the pair (file null), resolve immediately (plain cut).
|
||||
return new Promise((resolve) => {
|
||||
if (!file) return resolve();
|
||||
overlay.style.opacity = "0"; affectLayer.style.opacity = "0"; tint.style.opacity = "0";
|
||||
vid.style.filter = "none"; vid.loop = false; vid.style.opacity = "1";
|
||||
vid.src = mediaUrl(file);
|
||||
vid.playbackRate = blended ? FAST_BLEND_RATE : 1;
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return; done = true;
|
||||
vid.removeEventListener("ended", finish); vid.playbackRate = 1; resolve();
|
||||
};
|
||||
vid.addEventListener("ended", finish);
|
||||
vid.play().catch(finish);
|
||||
setTimeout(finish, blended ? 3000 : 6000);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rewrite `advance` — pick first (via server), play morphs, lock, no swap**
|
||||
|
||||
```javascript
|
||||
// simulator/static/app.js — replace advance
|
||||
async function advance(delta) {
|
||||
if (busy || !ring || ring.scales.length < 2) return;
|
||||
busy = true;
|
||||
try {
|
||||
const resp = await fetch("/api/ring/advance", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ from_index: ringIndex, delta, from_clip_id: activeClipId }),
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const move = await resp.json();
|
||||
for (const step of move.steps) {
|
||||
await playTransition(step.file, step.blended);
|
||||
}
|
||||
ringIndex = move.to_index;
|
||||
activeClipId = move.target_clip_id; // the locked clip — the morph ended on it
|
||||
currentClipId = null; // force ensureClipMedia to (re)load + loop it
|
||||
renderScaleReadout();
|
||||
} finally {
|
||||
busy = false;
|
||||
update(); // ensureClipMedia loads the locked clip; nothing re-rolls
|
||||
applyAudio();
|
||||
refreshReachablePreload();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then **delete** `fadeThroughBlack` (now unused) and `reverseFile` (the `.rev` companion is an explicit manifest entry, no longer derived). Grep to confirm no other callers before deleting.
|
||||
|
||||
- [ ] **Step 4: Preload only the reachable morphs, refreshed per landing**
|
||||
|
||||
```javascript
|
||||
// simulator/static/app.js — replace the eager transition preload in preloadOrder()/preloadList()
|
||||
// Reachable morphs from the CURRENT locked clip: to/from every member of the
|
||||
// neighboring altitudes (the only clips one detent can reach). 154 total is too
|
||||
// many to preload up front; we focus on what the next move can need.
|
||||
function reachableMorphFiles() {
|
||||
const files = new Set();
|
||||
if (!ring || ring.scales.length < 2 || !activeClipId) return files;
|
||||
const n = ring.scales.length;
|
||||
for (const sign of [1, -1]) {
|
||||
const nb = ring.scales[((ringIndex + sign) % n + n) % n];
|
||||
const members = (nb.pool || [{ clip_id: nb.clip_id }]).map((m) => m.clip_id);
|
||||
for (const m of members) {
|
||||
const a = morphByPair[`${activeClipId}→${m}`];
|
||||
const b = morphByPair[`${m}→${activeClipId}`];
|
||||
if (a) files.add(a);
|
||||
if (b) files.add(b);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
function refreshReachablePreload() {
|
||||
// Kick a background cache fill of the reachable morphs (bounded concurrency,
|
||||
// same cacheClip helper used for bases). Idempotent — cached files are skipped.
|
||||
for (const f of reachableMorphFiles()) cacheClip(f);
|
||||
}
|
||||
```
|
||||
|
||||
In `preloadOrder()` replace the `if (ring.transitions) for (const t ...) { add(t.file); add(reverseFile(t.file)); }` block with `for (const f of reachableMorphFiles()) add(f);`. Keep base-clip preloading (current + neighbor pools) as-is. (`cacheClip` = factor out the per-file fetch→blob step already inside the preload loop so `refreshReachablePreload` can reuse it.)
|
||||
|
||||
- [ ] **Step 5: Manual smoke + commit**
|
||||
|
||||
Run: `cd simulator && python -m uvicorn app:app --port 8011` then open `http://localhost:8011/`, turn the Altitude dial one detent each way and several detents; confirm: a zoom morph plays, it lands on a clip, and that clip **does not change** while parked (watch the Dev-Mode clip readout). No black-flash second swap.
|
||||
|
||||
```bash
|
||||
git add simulator/static/app.js
|
||||
git commit -m "feat(sim): play chosen morph then lock clip per altitude; drop secondary swap"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Playwright E2E — lock + matching morph (new tier)
|
||||
|
||||
Stand up a minimal Playwright tier (none exists) and add one spec that proves the two behaviors the operator asked for: after zooming, the displayed clip is stable (locked) until the next altitude change, and the morph that plays matches `from→to`. §9 requires an E2E browser tier for this UI change.
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/e2e/package.json`, `simulator/e2e/playwright.config.ts`, `simulator/e2e/tests/altitude-lock.spec.ts`
|
||||
- Create: `simulator/e2e/README.md` (how to run; how it boots uvicorn)
|
||||
|
||||
**Interfaces:**
|
||||
- The spec drives the real app (uvicorn on a fixed port via Playwright `webServer`), reads the Dev-Mode clip readout (enable Dev Mode via localStorage before load), and intercepts `/media/transitions/*` requests to assert the morph file played.
|
||||
|
||||
- [ ] **Step 1: Scaffold the tier (failing because no test yet)**
|
||||
|
||||
```jsonc
|
||||
// simulator/e2e/package.json
|
||||
{ "name": "hef-e2e", "private": true,
|
||||
"scripts": { "test": "playwright test" },
|
||||
"devDependencies": { "@playwright/test": "^1.45.0" } }
|
||||
```
|
||||
|
||||
```ts
|
||||
// simulator/e2e/playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
webServer: {
|
||||
command: "cd .. && python -m uvicorn app:app --port 8099",
|
||||
url: "http://localhost:8099/api/clips",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
use: { baseURL: "http://localhost:8099" },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the spec**
|
||||
|
||||
```ts
|
||||
// simulator/e2e/tests/altitude-lock.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("zoom plays a morph, lands locked, and does not change while parked", async ({ page }) => {
|
||||
const morphs: string[] = [];
|
||||
page.on("request", (r) => {
|
||||
const m = r.url().match(/\/media\/(transitions\/[^?]+)/);
|
||||
if (m) morphs.push(m[1]);
|
||||
});
|
||||
await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
|
||||
await page.goto("/");
|
||||
const readout = page.locator("#scale-name");
|
||||
await expect(readout).toContainText("cosmos");
|
||||
|
||||
// Zoom inward one altitude (wheel down = +). A morph must play.
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 120 });
|
||||
await page.waitForTimeout(2500);
|
||||
expect(morphs.some((f) => f.startsWith("transitions/cosmos__"))).toBeTruthy();
|
||||
|
||||
// The landed clip is now locked: capture it, wait through several alteration
|
||||
// ticks, and assert it never changes.
|
||||
const landed = (await readout.textContent())!;
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await readout.textContent()).toBe(landed);
|
||||
});
|
||||
|
||||
test("zooming back OUT plays a reverse morph and lands locked", async ({ page }) => {
|
||||
const morphs: string[] = [];
|
||||
page.on("request", (r) => {
|
||||
const m = r.url().match(/\/media\/(transitions\/[^?]+)/);
|
||||
if (m) morphs.push(m[1]);
|
||||
});
|
||||
await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
|
||||
await page.goto("/");
|
||||
const readout = page.locator("#scale-name");
|
||||
await expect(readout).toContainText("cosmos");
|
||||
|
||||
// Zoom inward one altitude, then back OUT (wheel up = -). The outward move must
|
||||
// play a `.rev` morph and land back on cosmos, locked.
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 120 });
|
||||
await page.waitForTimeout(2500);
|
||||
morphs.length = 0; // only watch the zoom-out move
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY: -120 });
|
||||
await page.waitForTimeout(2500);
|
||||
expect(morphs.some((f) => /transitions\/cosmos__.*\.rev\.mp4/.test(f))).toBeTruthy();
|
||||
await expect(readout).toContainText("cosmos");
|
||||
|
||||
// Locked after the outward move too.
|
||||
const landed = (await readout.textContent())!;
|
||||
await page.waitForTimeout(2000);
|
||||
expect(await readout.textContent()).toBe(landed);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Install + run**
|
||||
|
||||
Run: `cd simulator/e2e && npm install && npx playwright install chromium && npm test`
|
||||
Expected: PASS (1 test). If the wheel handler needs a different target, adjust the selector to the element bound in `onWheel` setup.
|
||||
|
||||
- [ ] **Step 4: Document the tier**
|
||||
|
||||
`simulator/e2e/README.md`: prerequisites (`npm install`, `npx playwright install chromium`), how `webServer` boots uvicorn, and `npm test`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/e2e
|
||||
git commit -m "test(e2e): Playwright tier asserts altitude lock + matching morph"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Regenerate sample media + manifest, full suite green
|
||||
|
||||
Bake the 154 morphs into `simulator/sample_media`, rebuild the manifest, and run the entire pytest suite + the E2E spec so the shipped sample media matches the new schema.
|
||||
|
||||
**Files:**
|
||||
- Modify (generated): `simulator/sample_media/manifest.json`, `simulator/sample_media/transitions/*.mp4`
|
||||
|
||||
- [ ] **Step 1: Rebuild media + manifest**
|
||||
|
||||
Run: `cd simulator && python build_pool_manifest.py --media` (or the script's generate+manifest entrypoint — confirm its CLI flags) to bake morphs and rewrite `manifest.json`.
|
||||
Expected: `sample_media/transitions/` contains 154 files; `manifest.json` ring has 154 transition entries.
|
||||
|
||||
- [ ] **Step 2: Verify every manifest transition file exists**
|
||||
|
||||
```python
|
||||
# one-off check (or add as tests/test_catalog_integrity.py case)
|
||||
import json, pathlib
|
||||
m = json.load(open("simulator/sample_media/manifest.json"))
|
||||
base = pathlib.Path("simulator/sample_media")
|
||||
missing = [t["file"] for t in m["ring"]["transitions"] if not (base / t["file"]).exists()]
|
||||
assert not missing, missing
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the full suite**
|
||||
|
||||
Run: `pytest -q`
|
||||
Expected: PASS (all existing + new tests; ~270+).
|
||||
|
||||
- [ ] **Step 4: Run the E2E spec against the regenerated media**
|
||||
|
||||
Run: `cd simulator/e2e && npm test`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/sample_media
|
||||
git commit -m "chore(media): regenerate 154 clip-pair morphs + manifest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage (operator directives):**
|
||||
- "Choose which clip plays in the next altitude *before* transitioning" → Task 4 (`resolve_move` picks dst then morph) + Task 6 (server draws picks before resolving).
|
||||
- "Play the appropriate morph footage" → Task 7 (renderer plays the resolved `step.file`).
|
||||
- "Morph footage for all transitions between all videos in adjacent altitudes" → Tasks 1–2 (manifest + bake 154, both directions) + Task 9 (regenerate).
|
||||
- "Video must not change after we've zoomed to a new altitude" / "lock per altitude until the altitude is changed" → Task 7 (delete secondary swap; lock `activeClipId`; reload only on altitude change) + Task 8 (E2E asserts stability while parked).
|
||||
- "Good transition for every video combination, in and out" → both directions baked (Task 1/2), reverse via explicit `.rev` entries (Task 3/4), E2E covers in + out (extend Task 8 spec with a zoom-out case if desired).
|
||||
- Multi-altitude jump chains through each altitude → Task 4 (`resolve_move` threads src→dst per step) + Task 5 (fast spin plays the chain quickly).
|
||||
|
||||
**Placeholder scan:** No TBD/TODO/"handle edge cases" — every code step shows real code; tests show real assertions.
|
||||
|
||||
**Type consistency:** `Transition(from_clip, to_clip, file, model)` (T3) used by `morph_for` (T3), `load_ring` (T3), `resolve_move` (T4), `resolved_move_to_dict` (T6). `MorphStep`/`ResolvedMove` fields (T4) match the serializer (T6) and the renderer's `step.{from_clip,to_clip,file,to_index,blended}` + `move.target_clip_id` (T7). `morphByPair` key `"from→to"` consistent in build (T7 S1) and lookup (T7 S4).
|
||||
|
||||
**Open items (resolved at the review gate + during execution):**
|
||||
1. **Storage/repo weight:** Corrected during execution — `sample_media` was entirely
|
||||
gitignored (regenerated from the builder), NOT already committed as the plan first
|
||||
assumed. Operator decision: the media that has **graduated** into the actual
|
||||
experience (curated clip bases, per-scale audio, the 154 morphs — ~526 MB) is now
|
||||
committed via **git-LFS**, pointing at the self-hosted Gitea LFS store
|
||||
(`git.benstull.org`). Stale/unused media (parked `right_variants`, ~1.6 GB) stays
|
||||
gitignored (not deleted from disk — that's a separate explicit cleanup).
|
||||
2. **`build_pool_manifest.py` CLI:** ✅ `python simulator/build_pool_manifest.py --media`
|
||||
rebuilds the manifest + bakes all morphs (confirmed at execution).
|
||||
3. **Zoom-out E2E:** ✅ Task 8 covers zoom-in + lock **and** zoom-out (`.rev` morph) + lock.
|
||||
Note: one wheel notch = 60 deltaY = one detent (`Math.trunc(deltaY/60)`).
|
||||
@@ -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. ✓
|
||||
@@ -13,6 +13,16 @@
|
||||
> hide), and the soundtrack now plays on Safari/iOS (audio elements unlocked
|
||||
> synchronously inside the start gesture). Sections below describe the fuller
|
||||
> off/soundtrack/white-noise design; v1 live = off/soundtrack.
|
||||
>
|
||||
> **Startup revision (session 0022):** the tap-to-start wall is **removed** — the app
|
||||
> opens behind a **"Loading Universe…"** splash (shown until all media is preloaded
|
||||
> and the experience can run smoothly), then both toggles start **off** (black +
|
||||
> silent). Autoplay is satisfied without a wall: a **single** `<audio>` element is
|
||||
> played **synchronously inside the toggle's own click** (the reliable Safari unlock),
|
||||
> replacing the A/B-crossfade-after-gesture approach that failed on real Safari. The
|
||||
> first time **Video** is turned on, **Audio** turns on with it (one flip = the full
|
||||
> experience); turning Audio on first stays audio-only. Altitude changes swap the
|
||||
> single element's source with a brief fade.
|
||||
**Extends / reframes:** [`2026-06-26-networked-control-surface-design.md`](./2026-06-26-networked-control-surface-design.md)
|
||||
§3 (control inventory) and §5 (server contract) — its bundled **Content** selector
|
||||
is split into two orthogonal controls. Everything else in that spec (server-of-truth
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Scrub-driven Altitude Transitions — Design
|
||||
|
||||
**Status:** Approved (brainstormed 2026-06-27, session 0024). Ready for writing-plans.
|
||||
|
||||
## Goal
|
||||
|
||||
Make the altitude transition **driven by the knob position** rather than auto-played.
|
||||
Turning the physical knob (or, in the simulator, dragging the Altitude dial) at any
|
||||
speed scrubs the morph video and crossfades the audio in lockstep with the knob. The
|
||||
knob position *is* the scene: turn slow → the morph eases slowly; turn fast → it
|
||||
races; stop halfway → it holds in a mid-morph blend; turn back → it reverses exactly.
|
||||
|
||||
This supersedes the timed dial-needle sweep (the dial-vs-video sync problem dissolves
|
||||
because the knob and the transition become the same thing).
|
||||
|
||||
## Core model
|
||||
|
||||
A continuous **altitude position** `pos` (float): integers are altitudes/detents,
|
||||
fractions are mid-morph blend. `pos = 2.4` ⇒ 40 % from coast (index 2) toward reef
|
||||
(index 3). The knob/needle maps directly to `pos`; the morph video's `currentTime`
|
||||
and the audio crossfade are scrubbed by `frac(pos)`.
|
||||
|
||||
**Decisions (locked):**
|
||||
- **In-between state:** hold the blend wherever the knob stops — no auto-complete
|
||||
(continuous encoder model). *(Q1 → a)*
|
||||
- **Turn-back:** scrubs the same morph in reverse; lands on exactly the previous
|
||||
altitude's clip you started from (fully reversible).
|
||||
- **Destination randomness:** the next altitude's clip is a random pick from its pool,
|
||||
**fixed during a single continuous gesture**, **re-rolled on each fresh approach**.
|
||||
*(Q → a)*
|
||||
- **Scroll wheel:** auto-scrub one altitude over ~0.6 s, then lock. *(Q2 → a)*
|
||||
- **Lock-per-altitude** still holds: a resting altitude's clip stays locked until you
|
||||
commit into a different one.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Scrub controller (frontend — `simulator/static/app.js`)
|
||||
Replaces the discrete `advance(delta)` "commit then play" for drag/knob input.
|
||||
- Drives `pos` from the dial pointer angle: `pos = restPos + accum / dialStep`. The
|
||||
needle follows continuously (the needle *is* the knob — live follow is correct here).
|
||||
- For the active segment `[floor, floor+1]` in the travel direction, sets
|
||||
`vid.currentTime = frac(pos) × vid.duration`, **throttled to one seek per
|
||||
`requestAnimationFrame`** (avoid seek thrash).
|
||||
- Crossing an integer **commits**: the crossed altitude's chosen clip becomes the
|
||||
locked `activeClipId`, `ringIndex` updates, and the next segment begins (re-roll).
|
||||
- Reversible: `pos` decreasing scrubs the morph backward.
|
||||
- Keep the pure, testable bits separable (position→segment, `frac`→currentTime,
|
||||
integer-crossing commit, re-roll-on-fresh-approach) from the DOM/event glue.
|
||||
|
||||
### 2. Clip selection / lock
|
||||
Entering a segment toward neighbor `k±1`, pick a random clip from that scale's pool
|
||||
(client-side) and resolve the directed morph via the existing
|
||||
`morphByPair["<fromClip>→<toClip>"]` lookup (built from `ring.transitions`). Fixed for
|
||||
the gesture; re-rolled on a fresh approach. *(This moves the random pick client-side
|
||||
for responsiveness vs the current server pick in `/api/ring/advance`; acceptable for
|
||||
the simulator — the Pi player can mirror the same client-side pick. `pick_clip_id` in
|
||||
`player/ring.py` stays the canonical pure helper.)*
|
||||
|
||||
### 3. Audio crossfade (frontend)
|
||||
During a segment, crossfade the two adjacent scale soundtracks by `frac(pos)` (gains
|
||||
`1-frac` / `frac`); at rest only the current scale plays. Hook into the existing audio
|
||||
layer (`applyAudio` / the per-scale `audio` field in the ring).
|
||||
|
||||
### 4. Morph media re-bake (pipeline — `simulator/build_pool_manifest.py`)
|
||||
Smooth scrubbing = seeking to arbitrary frames, which needs **dense keyframes**. The
|
||||
current morphs use a sparse GOP, so they scrub "steppy." Re-bake the 154 morphs
|
||||
**all-intra** (e.g. x264 `-g 1` / `keyint=1`, or `-intra`) so every frame is seekable.
|
||||
Files grow (~176 MB → ~0.5–0.9 GB) but each clip stays small (front-end upload limit is
|
||||
a non-issue; that was the 68 MB *base*, unrelated). Media is committed via **git-LFS**
|
||||
(see `.gitattributes`); re-push after re-baking.
|
||||
|
||||
**Phasing:** build the interaction first against the *current* morphs to validate the
|
||||
feel (will scrub somewhat steppy), then re-bake all-intra for smoothness. Two plan
|
||||
phases.
|
||||
|
||||
### 5. Wheel + tap
|
||||
- **Wheel:** auto-scrub `pos` by ±1 over ~0.6 s (drives the same scrub path), then lock.
|
||||
- **Tap a dial label:** auto-scrub to that altitude the shortest way around.
|
||||
|
||||
## Testing
|
||||
- **Unit (pytest or JS):** the pure controller logic — position→segment mapping,
|
||||
`frac`→`currentTime`, commit+lock on integer crossing, re-roll on fresh approach,
|
||||
reverse direction.
|
||||
- **E2E (Playwright, `simulator/e2e`):** drag the dial partway → assert the morph
|
||||
`currentTime` and the two audio gains track the angle; drag back → values reverse;
|
||||
a full turn → commits and locks the destination clip; the wheel → auto-scrubs one
|
||||
altitude and locks. (Extends the existing `altitude-lock.spec.ts` tier.)
|
||||
|
||||
## Out of scope
|
||||
- Physical hardware / encoder firmware (the simulator drag is the stand-in; the model
|
||||
is designed to map 1:1 to an encoder position later — see the networked control-surface
|
||||
spec).
|
||||
- Changing the clip-pair morph *set* or naming (`<src>__<dst>.mp4` + `.rev`) — reused as-is.
|
||||
|
||||
## Key existing context (for the implementer)
|
||||
- Dial + transition logic: `simulator/static/app.js` (`advance`, `playTransition`,
|
||||
`onDialDown/Move/Up`, `renderDial/setNeedle`, `morphByPair`, the preload helpers).
|
||||
- Ring/morph contract: `player/ring.py` (`Scale.pool`, `pick_clip_id`, `morph_for`,
|
||||
`resolve_move`), `simulator/clips.py` (`ring_to_dict`, `resolved_move_to_dict`),
|
||||
`/api/ring/advance` in `simulator/app.py`.
|
||||
- Morph manifest + baking: `simulator/build_pool_manifest.py` (`_make_transition`,
|
||||
`_make_reverse`, `generate_media`, `build_manifest`); morphs at
|
||||
`simulator/sample_media/transitions/<src>__<dst>.mp4` (+ `.rev`).
|
||||
- The clip-pair morph + lock feature this builds on shipped in session 0024 (plan:
|
||||
`docs/superpowers/plans/2026-06-27-altitude-clip-pair-morph-lock.md`).
|
||||
@@ -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.
|
||||
+88
-39
@@ -74,57 +74,61 @@ class Scale:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transition:
|
||||
"""A pre-baked zoom/warp morph along ONE ring edge.
|
||||
"""A pre-baked zoom/warp morph between TWO specific clips in adjacent
|
||||
altitudes.
|
||||
|
||||
`file` plays FORWARD when zooming inward (scales[i] -> scales[i+1] in ring
|
||||
order); the renderer reverses it for the outward direction, so one clip
|
||||
covers both directions of an edge.
|
||||
`file` plays as-is: the manifest holds both directions as separate, directed
|
||||
entries — a forward zoom-in (`<src>__<dst>.mp4`, descending) and its `.rev`
|
||||
zoom-out companion (ascending). So a directed `(from_clip, to_clip)` pair maps
|
||||
to exactly one file and the renderer needs no reverse logic at play time.
|
||||
"""
|
||||
|
||||
from_clip: str
|
||||
to_clip: str
|
||||
file: str
|
||||
model: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScaleRing:
|
||||
"""A closed ring of scales joined by per-edge transitions.
|
||||
"""A closed ring of scales joined by per-clip-pair morphs.
|
||||
|
||||
Invariant: a ring of N>=2 scales has exactly N transitions (one per edge,
|
||||
including the small->large wrap seam). A degenerate single-scale ring has no
|
||||
edges. An empty ring is rejected.
|
||||
Each `Transition` is a directed morph between two clips in adjacent altitudes
|
||||
(both directions stored explicitly). A single-scale (or empty-pool) ring has
|
||||
no morphs. An empty ring is rejected. The directed `(from_clip, to_clip) ->
|
||||
file` lookup is built once for `morph_for`.
|
||||
"""
|
||||
|
||||
scales: tuple[Scale, ...]
|
||||
transitions: tuple[Transition, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
n = len(self.scales)
|
||||
if n == 0:
|
||||
if len(self.scales) == 0:
|
||||
raise RingError("a ScaleRing needs at least one scale")
|
||||
expected = 0 if n == 1 else n
|
||||
if len(self.transitions) != expected:
|
||||
raise RingError(
|
||||
f"a {n}-scale ring needs {expected} transitions, "
|
||||
f"got {len(self.transitions)}"
|
||||
)
|
||||
lookup = {(t.from_clip, t.to_clip): t.file for t in self.transitions}
|
||||
object.__setattr__(self, "_morphs", lookup)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.scales)
|
||||
|
||||
def morph_for(self, from_clip: str, to_clip: str) -> str | None:
|
||||
"""The morph file for the directed clip pair, or None if none was baked."""
|
||||
return self._morphs.get((from_clip, to_clip))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransitionStep:
|
||||
"""One transition to play during a move: which edge clip, in which direction,
|
||||
and the scale index it lands on.
|
||||
"""One STRUCTURAL step of a move: which edge is crossed, in which direction,
|
||||
and the scale index it lands on. The actual morph FILE is resolved later by
|
||||
`resolve_move` (it depends on the clip picked for the destination), so this
|
||||
step carries no file.
|
||||
|
||||
`blended` marks the single collapsed step of a fast-spin pass (see
|
||||
`advance_ring`'s `fast_spin_threshold`): the renderer plays it as one quick
|
||||
accelerated morph straight to the destination instead of a full transition.
|
||||
`blended` marks a fast-spin step (see `advance_ring`'s `fast_spin_threshold`):
|
||||
the renderer plays it as one quick accelerated morph rather than at 1x.
|
||||
"""
|
||||
|
||||
edge: int
|
||||
reversed: bool
|
||||
file: str
|
||||
to_index: int
|
||||
blended: bool = False
|
||||
|
||||
@@ -207,31 +211,24 @@ def advance_ring(
|
||||
if edge == n - 1:
|
||||
wrapped = True
|
||||
steps.append(
|
||||
TransitionStep(
|
||||
edge=edge,
|
||||
reversed=reversed_,
|
||||
file=ring.transitions[edge].file,
|
||||
to_index=nxt,
|
||||
)
|
||||
TransitionStep(edge=edge, reversed=reversed_, to_index=nxt)
|
||||
)
|
||||
index = nxt
|
||||
|
||||
# Fast spin: collapse the whole chain to a single blended arrival pass. The
|
||||
# landing index, seam-crossing, and arrival edge/direction are exactly those
|
||||
# of the full chain — only the in-between transitions are dropped.
|
||||
# Fast spin: play the WHOLE chain quickly (every crossed altitude still
|
||||
# morphs, so the chained member->member morphs all show) — each step is just
|
||||
# marked `blended` so the renderer accelerates it, rather than collapsing the
|
||||
# chain to a single arrival pass.
|
||||
if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold:
|
||||
arrival = steps[-1]
|
||||
blended = TransitionStep(
|
||||
edge=arrival.edge,
|
||||
reversed=arrival.reversed,
|
||||
file=arrival.file,
|
||||
to_index=arrival.to_index,
|
||||
blended=True,
|
||||
fast_steps = tuple(
|
||||
TransitionStep(edge=s.edge, reversed=s.reversed,
|
||||
to_index=s.to_index, blended=True)
|
||||
for s in steps
|
||||
)
|
||||
return RingMove(
|
||||
from_index=start,
|
||||
to_index=index,
|
||||
steps=(blended,),
|
||||
steps=fast_steps,
|
||||
wrapped=wrapped,
|
||||
fast=True,
|
||||
)
|
||||
@@ -239,3 +236,55 @@ def advance_ring(
|
||||
return RingMove(
|
||||
from_index=start, to_index=index, steps=tuple(steps), wrapped=wrapped
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MorphStep:
|
||||
"""One resolved morph to play while advancing: from the clip currently shown
|
||||
to the chosen clip of the next altitude, the morph file, the index it lands
|
||||
on, and whether it plays fast (a fast-spin pass). `file` is None if no morph
|
||||
was baked for the pair (the renderer falls back to a plain cut)."""
|
||||
|
||||
from_clip: str
|
||||
to_clip: str
|
||||
file: str | None
|
||||
to_index: int
|
||||
blended: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedMove:
|
||||
"""A move with each step's destination clip chosen and its morph resolved.
|
||||
`target_clip_id` is the final clip the viewer locks onto."""
|
||||
|
||||
steps: tuple[MorphStep, ...]
|
||||
target_clip_id: str
|
||||
|
||||
|
||||
def resolve_move(
|
||||
ring: ScaleRing,
|
||||
move: RingMove,
|
||||
from_clip_id: str,
|
||||
picks: tuple[float, ...],
|
||||
) -> ResolvedMove:
|
||||
"""Choose the destination clip for each crossed altitude (injected `picks`,
|
||||
one per step) and resolve the morph from the previous clip to it. The
|
||||
destination clip is chosen BEFORE the morph, so the footage matches what we
|
||||
land on; the morph direction (zoom-in vs zoom-out) is encoded in the directed
|
||||
`(src, dst)` pair, which `morph_for` maps to the right baked file. The final
|
||||
`target_clip_id` is the clip the viewer locks onto."""
|
||||
if len(picks) != len(move.steps):
|
||||
raise ValueError(f"need one pick per step: {len(picks)} != {len(move.steps)}")
|
||||
steps: list[MorphStep] = []
|
||||
src = from_clip_id
|
||||
for st, r in zip(move.steps, picks):
|
||||
dst = pick_clip_id(scale_at(ring, st.to_index), r)
|
||||
steps.append(MorphStep(
|
||||
from_clip=src,
|
||||
to_clip=dst,
|
||||
file=ring.morph_for(src, dst),
|
||||
to_index=st.to_index,
|
||||
blended=st.blended,
|
||||
))
|
||||
src = dst
|
||||
return ResolvedMove(steps=tuple(steps), target_clip_id=src)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Session 0020.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T06-47 (PST)
|
||||
> End: 2026-06-26T07-58 (PST)
|
||||
> Type: writing-plans (extended into execution — operator approved "then build it" at the plan gate)
|
||||
> Posture: careful (execution proceeded under explicit operator approval)
|
||||
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
|
||||
> Anchor: design docs/superpowers/specs/2026-06-26-audio-video-separated-controls-design.md (R2a)
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> Write the implementation plan for the "Audio + Video — separated Visual & Audio
|
||||
> controls" feature, then build it. (Spec:
|
||||
> docs/superpowers/specs/2026-06-26-audio-video-separated-controls-design.md.)
|
||||
> Split the bundled 7-way Content selector into orthogonal Visual (on/off) ×
|
||||
> Audio (off / soundtrack / white noise). Audio follows the single Altitude dial;
|
||||
> white noise synthesized (ffmpeg anoisesrc). Reframe the (unbuilt) networked-
|
||||
> control-surface spec §3/§5 as a clean pre-implementation edit. Honor the testing
|
||||
> tiers; resolve the three §12 flagged decisions.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- `main` clean at 99a751c. Two stale `--INPROGRESS` placeholders (0015, 0017) from
|
||||
2026-06-24/25 — prior dead sessions, not live; left untouched.
|
||||
- The networked-control-surface architecture (SSE / `/remote` / renderer split) was
|
||||
**unbuilt** — the sim is a single fused page (`simulator/static/index.html`) talking
|
||||
to FastAPI via `POST /api/alteration` (knobs) + `POST /api/ring/advance` (altitude).
|
||||
- Audio assets (5 sourced ambiences) sat gitignored under `simulator/sample_media/audio/<scale>/`,
|
||||
unreferenced by the manifest, unserved; no `<audio>` element anywhere; video muted.
|
||||
|
||||
## Session arc (turn by turn)
|
||||
|
||||
1. **Session gate → writing-plans.** Classified the two-part "write the plan, then
|
||||
build it" prompt as the build track's entry; claimed session **0020** (`--type
|
||||
writing-plans`, careful posture). Baseline clean; created branch `session-0020`.
|
||||
2. **Scoping.** Read both specs + the audio candidate pool; dispatched an Explore
|
||||
agent to map the current simulator. Key finding: the control-surface architecture
|
||||
is unbuilt, so the audio spec's §6 server contract had no host. **Resolved by the
|
||||
standing simulator-first directive:** build the audio feature into the current
|
||||
single-page sim (ships sound now); reframe the control-surface spec on paper only;
|
||||
do NOT build the SSE/`/remote`/renderer split (it's "v1 hardware," deferred). This
|
||||
build is the "Content step" the audio spec §11 anticipates.
|
||||
3. **Plan.** Wrote `docs/superpowers/plans/2026-06-26-audio-video-separated-controls.md`
|
||||
(10 tasks, all testing tiers). Committed + pushed; presented at the careful-posture
|
||||
plan gate. Operator answered **"Approve — build it."** §12 decisions resolved:
|
||||
audio=live (~0.6 s crossfade), noise=pink, autoplay=tap-to-start overlay; asset
|
||||
params −18 LUFS / mp3 / loop mirrors the proven video crossfade recipe.
|
||||
4. **Build (TDD, commit per task):**
|
||||
- T1–2: pure ffmpeg audio builders (`tools/pipeline/audio_ops.py`) + runner
|
||||
(`audio_run.py`) + `simulator/build_audio_media.py`; produced the 5 normalized
|
||||
loops + pink-noise bed (gitignored).
|
||||
- T3: `Scale.audio` manifest field + read/emit/serialize + `SCALE_AUDIO`; manifest
|
||||
regenerated (clean diff — only 5 audio lines).
|
||||
- T4–6: pure `player/audio.py` resolver; retired `player/content.py` (+ test);
|
||||
reframed `player/controls.py` + `player/state.py`; `/api/alteration` validates
|
||||
visual+audio and returns `render.video` + server-resolved `render.audio.url` from
|
||||
`altitude_index`. Full Python suite green.
|
||||
- T7–8: client — Content `<select>` → Visual toggle + Audio selector; A/B `<audio>`
|
||||
gain-crossfade layer; tap-to-start gesture. Smoke-tested page + media (200s).
|
||||
- T9: reframed the networked-control-surface spec §3/§4/§5/§6; recorded §12
|
||||
resolutions; ROADMAP note.
|
||||
- T10: Playwright E2E (`[e2e]` extra). Installed Chromium and ran it for real —
|
||||
surfaced + fixed three client issues (see Cut state). **3 E2E pass**, full suite
|
||||
**288 passed, 2 skipped**.
|
||||
5. **Verify + ship.** Screenshotted the running app (cosmos plays, soundtrack audibly
|
||||
playing `cosmos/pillars.loop.mp3`, split controls visible, zero JS errors). Merged
|
||||
`session-0020` → `main` (`--no-ff`) and pushed. (`gh` PR create denied — Gitea host;
|
||||
merged locally with branch→merge discipline.)
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_No low-confidence autonomous calls. The scope decision (current-sim vs full control
|
||||
surface) was approved at the plan gate; the three §12 resolutions were operator-
|
||||
delegated by the launch prompt ("resolve the three flagged decisions"); asset params
|
||||
(−18 LUFS, mp3, pink) follow the spec's stated direction._
|
||||
|
||||
## Cut state
|
||||
|
||||
- `main` @ `aa76cd1` (merge of session-0020), clean, synced with origin.
|
||||
- **288 tests + 3 Playwright E2E pass; 2 skipped** (opt-in media integration).
|
||||
- Three client robustness fixes the E2E drove out (worth remembering):
|
||||
- `fadeVolume` uses `setInterval`, not `requestAnimationFrame` (rAF stalls when the
|
||||
tab is backgrounded → the gain ramp hung in headless).
|
||||
- The start gesture drops per-element "priming": a single page click grants Chrome
|
||||
autoplay document-wide; priming srcless `<audio>` only hung the handler / polluted
|
||||
the paused-state.
|
||||
- `applyAudio` does not `await` `play()` (awaiting a srcless/buffering play can hang).
|
||||
- Plan archival: this app has **no content repo** (`CONTENT_REMOTE` empty), so the plan
|
||||
was not archived to a `plans/` collection — it lives in-repo at
|
||||
`docs/superpowers/plans/` and is already on `main`.
|
||||
- §9 pipeline: localhost + E2E stage is **green**; there is no PPE/prod infra for this
|
||||
simulator-first art app (deferred with the Pi renderer), so no further ship stage ran.
|
||||
|
||||
## Operator plate (what's true now / next)
|
||||
|
||||
- The simulator has **sound**: per-altitude soundtracks that follow the Altitude dial +
|
||||
a pink white-noise bed, behind orthogonal **Visual** × **Audio** controls. The unheard
|
||||
audio wants a by-ear review/tune.
|
||||
- Deferred (already roadmap-tracked, not re-parked in memory): the **music** layer
|
||||
(reserved Audio position, no assets), the networked-control-surface SSE/`/remote`
|
||||
build, i2v ring transitions, the Pi renderer.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal review/tune the new audio by ear in the sim (per-altitude soundtracks + pink white-noise + the ~0.6s A/B crossfade; tap-to-start), then source/compose the deferred per-altitude music layer (the reserved Audio position), per docs/audio-candidate-pool.md
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
# Session 0021.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T08-12 (PST)
|
||||
> End: 2026-06-26T08-20 (PST)
|
||||
> Type: executing-plans (operator-driven hotfix/revision of session 0020's audio feature)
|
||||
> Posture: yolo
|
||||
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> Show video unchecked doesn't turn off the video. Make "show video" just "Video"
|
||||
> and have a toggle like "Dev Mode" has. I can't hear any audio when "soundtrack
|
||||
> (follows altitude)" is selected. Let's just have Audio as off/on like Video for
|
||||
> now and get rid of white noise.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Session 0020 had just merged the audio feature to `main` (Visual×Audio split,
|
||||
per-altitude soundtracks, pink white-noise, A/B crossfade). The operator tested it
|
||||
live and reported three issues.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Diagnosis (the key work).** Both reported bugs were reproduced *only* in the
|
||||
operator's real browser — neither appeared in headless Chromium **or** WebKit
|
||||
(installed `playwright install webkit` to test Safari's engine). Root causes:
|
||||
- **Video-off doesn't blank:** `#vid` (`<video>`) and `#paint` (WebGL `<canvas>`)
|
||||
are GPU-composited layers; the `#black` cover had *auto* z-index, so on a real
|
||||
GPU (Safari/Chrome) the video layer can composite *above* it despite DOM order.
|
||||
Headless software-raster hides this.
|
||||
- **No soundtrack audio:** real Safari/iOS unlocks an `<audio>` element only when
|
||||
`play()` is called *synchronously inside* the user gesture; the 0020 gesture
|
||||
played in an async continuation (after `await fetch`), which Chrome allows but
|
||||
Safari blocks. Headless WebKit relaxes autoplay, so it passed.
|
||||
This is the session's main lesson: **Playwright headless relaxes both GPU
|
||||
compositing and autoplay**, so the 0020 E2E couldn't have caught either — real
|
||||
device / by-eye review is required.
|
||||
2. **Fixes (branch `session-0021`):**
|
||||
- **Video** + **Audio** are now Dev-Mode-style on/off toggles (`.dev-switch`),
|
||||
wired on `change`. Audio on → soundtrack; **white-noise deferred** (removed from
|
||||
the live control; `AUDIO_SOURCES={off,soundtrack}`; the pure pink-noise
|
||||
machinery + tests stay for later).
|
||||
- Video-off: `.black` gets `z-index:50` + `translateZ(0)` (own layer) **and** the
|
||||
video layers are set `opacity:0` on video-off (belt-and-suspenders blanking).
|
||||
- Safari audio: the tap-to-start gesture now primes both A/B `<audio>` elements
|
||||
**synchronously in-gesture** (set src, vol 0, `play().then(pause)`), unlocking
|
||||
the later async crossfades.
|
||||
- Server: `player/audio.py` resolver + `controls.py`/`state.py` + `/api/alteration`
|
||||
drop white-noise; `build_audio_media.py` stops emitting the noise bed.
|
||||
3. **Verify + ship.** Updated unit/contract/E2E tests; rewrote the 3 E2E for the new
|
||||
toggles (click the visible `.dev-switch-track`; wait for the opacity transition).
|
||||
**288 passed, 2 skipped.** Re-verified in **both Chromium and WebKit**: Audio toggle
|
||||
plays `cosmos/pillars.loop.mp3`, video-off sets vid+paint opacity 0 with `#black`
|
||||
shown, zero JS errors; screenshotted the new toggle UI. Merged `session-0021` →
|
||||
`main` and pushed. (Also untracked a stray `docs/.threads/*.json` editor artifact
|
||||
swept in by `git add -A`, and gitignored `.threads/`.)
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_No low-confidence calls. All three changes were explicit operator requests; the
|
||||
root-cause fixes (GPU z-index/layer-hide; Safari in-gesture unlock) are the standard
|
||||
remedies for these well-known browser behaviors._
|
||||
|
||||
## Cut state
|
||||
|
||||
- `main` @ `fddb6d6` (merge of session-0021), clean, pushed.
|
||||
- **288 tests + 3 Playwright E2E pass** (2 skipped: opt-in media integration).
|
||||
- White-noise is deferred, not deleted: `tools/pipeline/audio_ops.white_noise_args` +
|
||||
`audio_run.generate_white_noise` + their tests remain; only the live control + the
|
||||
build-script emission were removed.
|
||||
- **Unvalidated by this session:** real-device audio (Safari autoplay) and real-GPU
|
||||
video-off — headless can't exercise either; needs the operator's by-ear/by-eye pass.
|
||||
|
||||
## Operator plate
|
||||
|
||||
- Video + Audio are simple on/off toggles (Dev-Mode style). Audio on = the current
|
||||
altitude's soundtrack. The two real-browser bugs should now be fixed — but please
|
||||
confirm on a real device (especially iPad/Safari).
|
||||
- Deferred: white-noise (easy to re-add as a 3rd Audio position), the per-altitude
|
||||
music layer, the networked-control-surface build, Pi renderer.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal by-ear/by-eye review the audio + Video/Audio toggles on a REAL device (esp. iPad/Safari — headless can't validate Safari autoplay or GPU compositing), then source/compose the deferred per-altitude music layer per docs/audio-candidate-pool.md (or re-enable white-noise as a 3rd Audio position if wanted)
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
# Session 0022.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T08-40 (PST)
|
||||
> End: 2026-06-26T08-50 (PST)
|
||||
> Type: executing-plans (operator-driven follow-up to the audio feature)
|
||||
> Posture: yolo
|
||||
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> (1) "It shouldn't say 'Tap to Start Sound'… it can start with both sound and video
|
||||
> off but immediately go to the app. We should have a 'Loading Universe…' loading
|
||||
> screen until all media has been downloaded and the experience is ready to run."
|
||||
> (2) "I still can't hear the sound, even in private mode."
|
||||
> (3) "The first time video is turned on, also turn on audio. If audio is turned on
|
||||
> before video after the app loads, just audio should turn on."
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Sessions 0020/0021 had shipped the audio feature + a 0021 "Safari fix" (tap-to-start
|
||||
overlay that primed A/B `<audio>` elements). The operator reported audio STILL
|
||||
silent on their real browser (even in private mode), plus the UX requests above.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Why the 0021 fix failed.** It primed *srcless* A/B elements in the tap gesture
|
||||
and played the real audio in an *async continuation* (after `await fetch`). Safari
|
||||
unlocks an element only when `play()` runs **synchronously inside** the gesture and
|
||||
on an element **with a src** — so neither condition was met. Headless WebKit relaxes
|
||||
autoplay, which is why automated tests never caught it.
|
||||
2. **Redesign (client-only, branch `session-0022`):**
|
||||
- **Single `<audio id="aud">`** element (dropped A/B). It's played **synchronously
|
||||
inside the toggle's own `change` handler** — the canonical Safari unlock. Altitude
|
||||
changes reuse the now-unlocked element with a brief fade-swap.
|
||||
- **"Loading Universe…" splash** (`#loading`, in the HTML so it shows pre-JS): `main()`
|
||||
now `await`s `preloadAllMedia()` (driving a progress bar), then `hideLoading()`.
|
||||
Replaces the tap-to-start wall.
|
||||
- **Both toggles default OFF** (black + silent at start) — no autoplay needed until a
|
||||
toggle is flipped.
|
||||
- **First Video-on couples Audio:** the Video `change` handler, on the first on,
|
||||
sets `#audio` checked and plays — in the same gesture (so it unlocks on Safari).
|
||||
Audio toggled on first stays audio-only.
|
||||
- Server contract unchanged (`{off,soundtrack}`); the client now drives playback
|
||||
directly from the ring's `scale.audio`, ignoring `render.audio`.
|
||||
3. **Verify + ship.** Rewrote the 5 E2E for the new flow (single `#aud`, no gesture,
|
||||
loading wait, the coupling, video-off blanking). Caught a test bug — `#black.hidden`
|
||||
is `display:none`, so `wait_for_selector` default (visible) timed out; switched to
|
||||
`state="attached"`. **290 passed, 2 skipped; 5 E2E pass.** Verified in headless
|
||||
**Chromium AND WebKit**: first-Video-on couples audio, soundtrack plays, video shows,
|
||||
zero JS errors; screenshotted the splash + running app. Merged to `main`.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_No low-confidence calls — all three were explicit operator requests. The
|
||||
single-element synchronous-in-gesture play is the standard Safari remedy._
|
||||
|
||||
## Cut state
|
||||
|
||||
- `main` @ `4ac39c9`, clean, pushed. 290 tests + 5 Playwright E2E pass.
|
||||
- **Honest caveat — still unverifiable headlessly:** every engine Playwright ships
|
||||
(Chromium, WebKit) relaxes autoplay, so I cannot *prove* real-Safari/iOS now plays.
|
||||
The fix is the canonical pattern (sync `play()` in the gesture, real src) and is a
|
||||
genuine change from 0021's broken approach — but it needs the operator's real-device ear.
|
||||
|
||||
## Operator plate
|
||||
|
||||
- No tap wall: "Loading Universe…" → app with both toggles off (black/silent). Flip
|
||||
**Video** → video + audio together; flip **Audio** alone → audio only.
|
||||
- If sound is STILL silent on the real device, that points to something beyond autoplay
|
||||
(codec/MIME, file, or element error) — next session should capture `aud.error` and the
|
||||
exact browser.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal confirm on a REAL device (esp. iPad/Safari) that audio is now audible when Video/Audio is toggled on and the "Loading Universe…" → app flow feels right; if still silent, capture aud.error + the exact browser. Then the deferred per-altitude music layer per docs/audio-candidate-pool.md
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
# Session 0023.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T09-05 (PST)
|
||||
> End: 2026-06-26T09-20 (PST)
|
||||
> Type: executing-plans (operator-driven debugging — "still can't hear" / "video didn't come on")
|
||||
> Posture: yolo
|
||||
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt (the thread)
|
||||
|
||||
> "I still can't hear" → (after diagnostics) operator screenshot: `audio: on, PAUSED
|
||||
> (readyState 0)` and "the audio check works" with `ring=server · url=NONE` → "the
|
||||
> video didn't come on when I turned it on" → after the fix: **"yes, both"** (video
|
||||
> and audio confirmed working).
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Sessions 0020–0022 had shipped the audio/video feature + three "Safari autoplay"
|
||||
fixes that were verified only in headless browsers and **did not work** for the
|
||||
operator. Audio was silent; this session set out to find the real cause.
|
||||
|
||||
## Session arc — the debugging
|
||||
|
||||
1. **Stop guessing; instrument.** Three blind fixes had failed because headless
|
||||
Playwright (Chromium *and* WebKit) relaxes autoplay, so the bug was never
|
||||
reproducible. Added a **live `#audio-status` readout** (surfacing the previously
|
||||
*swallowed* `play()` errors + `aud.error`) and a **native `<audio controls>` test
|
||||
player** that plays the file with zero app code involved. Confirmed the server
|
||||
already honors HTTP Range (`206`) + `audio/mpeg` — not a serving problem.
|
||||
2. **The decisive readout.** Operator reported: the **native player works** (file +
|
||||
codec + browser audio all fine), but the toggle showed
|
||||
**`ring=server · url=NONE`** with `readyState 0` and no media error — meaning my
|
||||
code never set a source because `soundtrackUrl()` returned null.
|
||||
3. **Root cause = a STALE uvicorn server.** The operator's Python process predated the
|
||||
0020 audio/video work, so it (a) returns `/api/ring` without the per-scale `audio`
|
||||
field, and (b) requires the old 7-way `content` — it **422s** the new
|
||||
`{visual,audio}` payload and returns `{content:{video}}` instead of
|
||||
`{render:{video:{shown}}}`. So audio had no url AND the Video toggle's POST failed
|
||||
(→ "video didn't come on"). Reloading never fixes it (only refreshes static assets,
|
||||
not the Python process).
|
||||
4. **Fix = make the client resilient to a stale server** (so no restart is needed):
|
||||
- `soundtrackUrl()` falls back to a scale-id→file map (`SCALE_AUDIO_FALLBACK`,
|
||||
mirrors `build_pool_manifest.SCALE_AUDIO`).
|
||||
- `controls()` also sends a back-compat `content` field (current server ignores it;
|
||||
an old one ignores visual/audio).
|
||||
- `update()` reads "video shown" from **either** response shape.
|
||||
Verified end-to-end against a **simulated fully-stale server** (routed old
|
||||
`/api/alteration` shape + stripped ring audio) in Chromium **and** WebKit: Video on
|
||||
→ video shows + soundtrack plays, zero errors. **Operator confirmed "yes, both".**
|
||||
5. **Cleanup.** Tucked the audio diagnostics (status readout + native player) under the
|
||||
**Dev Mode** panel — kept for future debugging, out of the installation UI.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_No low-confidence calls. One judgment worth noting: I added permanent client
|
||||
back-compat for a pre-release internal API to unblock the operator without a server
|
||||
restart. The clean long-term fix is restarting the dev server; the shim is harmless and
|
||||
removes the dependency. Flagging in case a future cleanup wants to drop it once servers
|
||||
are reliably current._
|
||||
|
||||
## Cut state
|
||||
|
||||
- `main` @ `6427ab4`, clean, pushed. **292 tests + 7 Playwright E2E pass** (2 skipped).
|
||||
- Audio + Video experience **works and is operator-confirmed** on the real device.
|
||||
- New E2E regressions: stale-ring soundtrack fallback, audio-then-video ordering.
|
||||
- The operator's actual server is still stale — recommended (not required) they restart
|
||||
it so the API is current; the client no longer depends on it.
|
||||
|
||||
## Operator plate / lesson
|
||||
|
||||
- **The big lesson (recorded in memory):** headless Playwright relaxes BOTH autoplay and
|
||||
GPU compositing, so it cannot reproduce real-browser/real-server issues. When a fix
|
||||
"passes headless" but the operator still sees the bug, **instrument with on-screen
|
||||
readouts + a native control** to get their real data — don't ship another blind guess.
|
||||
Three rounds were lost to this before instrumenting.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal source/compose the deferred per-altitude music layer (the reserved Audio dial position) per docs/audio-candidate-pool.md — the Audio+Video experience is working & operator-confirmed; OR re-enable white-noise as a 3rd Audio position if wanted
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
# Session 0024.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T21-12 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: e0aea412-c853-4236-9536-cfd85cbc4975
|
||||
> End: 2026-06-27T15-23 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
>
|
||||
> Started as writing-plans; transitioned to planning-and-executing (yolo) when the
|
||||
> operator approved the plan and asked to execute in-session.
|
||||
|
||||
## Next /goal
|
||||
|
||||
Build **scrub-driven altitude transitions** per the approved design at
|
||||
`docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md`:
|
||||
knob position continuously scrubs the morph `currentTime` + crossfades adjacent
|
||||
scale audio (hold-partway, reversible, re-roll on fresh approach, wheel auto-scrubs
|
||||
one altitude). Phase 1: interaction on current morphs. Phase 2: re-bake all 154
|
||||
morphs all-intra for smooth seeking + re-push LFS. Writing-plans → executing-plans
|
||||
in a fresh session. (Operator was handed a ready-to-paste prompt.)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> Fix altitude/video transition: video shouldn't switch when changing altitudes;
|
||||
> good zoom transition for every video combination; video must not change after
|
||||
> zooming to a new altitude.
|
||||
|
||||
Operator refinements during orientation:
|
||||
- **Lock per altitude until the altitude is changed** — on arrival, pick the
|
||||
clip once and keep it; no secondary swap, no re-roll while parked.
|
||||
- **Choose the destination clip BEFORE transitioning, then play the appropriate
|
||||
morph footage.** We need morph footage for ALL transitions between ALL videos
|
||||
in adjacent altitudes (both directions).
|
||||
|
||||
## Plan
|
||||
|
||||
Anchor: leaf change extending player `design.md §3` (scale navigation & zoom
|
||||
transitions) + content-pipeline. **Scope reality:** clip-pair-keyed morph system
|
||||
— **154 morph clips** (77 adjacent video pairs × 2 directions) up from 5 per-edge
|
||||
morphs; touches the bake pipeline (`build_pool_manifest.py`), the ring contract
|
||||
(`player/ring.py`), manifest/transition schema (`simulator/clips.py`), the
|
||||
`/api/ring/advance` endpoint (`simulator/app.py`), the frontend `advance()`/lock
|
||||
(`simulator/static/app.js`), and a new Playwright E2E tier. Writing-plans session:
|
||||
author ONE plan, stop at the review gate.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- The dial-needle **sweep** (synced to the morph) is verified working in Chromium +
|
||||
WebKit both directions, but the operator's real Safari still showed it "not
|
||||
working" — root cause likely stale cached `app.js` (Safari refresh confusion),
|
||||
never fully confirmed. Moot: the **scrub-driven redesign supersedes** the dial
|
||||
interaction entirely. The sweep commit merged to main as an interim improvement.
|
||||
- **git-LFS front-proxy upload limit** (HTTP 413 on the 68 MB base): resolved by
|
||||
transcoding rather than raising the server limit — the operator couldn't locate
|
||||
the front-proxy VM (`35.238.203.16`, nginx, not in any gcloud project I could
|
||||
reach) this session. Raising that limit is a deferred infra task.
|
||||
|
||||
## Arc
|
||||
|
||||
**Pre-state.** Altitude dial (PR #18, s0016) navigated a 5-scale ring with **5
|
||||
per-edge** morphs (scale-primary→primary). Landing picked a random pool member, but
|
||||
`advance()` played the edge morph (ending on the primary) then did a jarring
|
||||
fade-to-black **secondary swap** to the random member. Operator: "video switches when
|
||||
changing altitudes; must not change after zooming."
|
||||
|
||||
**What we built (writing-plans → executing, planning-and-executing).**
|
||||
1. Orientation + clarification: lock-per-altitude; **pick destination first then play
|
||||
the matching morph**; morphs for **all adjacent video pairs both directions** (154);
|
||||
multi-detent **chains** through each altitude.
|
||||
2. Wrote + reviewed the plan (`docs/superpowers/plans/2026-06-27-altitude-clip-pair-morph-lock.md`);
|
||||
operator approved + asked to execute in-session → switched to yolo.
|
||||
3. Executed TDD: per-clip-pair manifest + member-pair baking; `Transition`/`morph_for`/
|
||||
`resolve_move` in `player/ring.py`; `resolved_move_to_dict`; `/api/ring/advance`
|
||||
`from_clip_id`; frontend pick→morph→lock (no secondary swap); regenerated 154
|
||||
morphs. pytest 302 passed; new **Playwright E2E tier** 2 passed.
|
||||
4. **Media graduation:** operator flagged that "sample" media is actually the
|
||||
experience — committed bases + audio + 154 morphs (~526 MB) via **git-LFS → Gitea
|
||||
LFS store**. Hit HTTP 413 on the 68 MB `coast_birdrock` base at the front proxy;
|
||||
**transcoded it 68→22 MB** (re-baked its 16 morphs) to ship. Pushed; merged to main.
|
||||
5. Polish loop on the **dial/transition timing** (sweep → removed live drag pre-move),
|
||||
then operator **reframed**: knob position should *drive* the transition (scrub),
|
||||
not the dial follow the video. Brainstormed + wrote the **scrub-driven design spec**;
|
||||
handed off a prompt to build it in a fresh session.
|
||||
|
||||
**Recurring lesson re-confirmed:** the operator's mid-session "no transitions" was a
|
||||
**stale uvicorn** (served new `app.js` from disk but old in-memory API returning the
|
||||
deleted old-named morph files → 404). Fix = restart uvicorn with the venv python.
|
||||
|
||||
**Cut state.** `main` (528fb5c) has the shipped feature, LFS media, E2E tier, both the
|
||||
plan and the scrub design spec. No content repo → plan archived in-repo under `docs/`
|
||||
(gap flagged). Simulator is localhost-only (no PPE/prod) → no §9 deploy stage; verified
|
||||
green locally. Next increment (scrub) deferred to a fresh session.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Session 0025.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-27T06-05 (PST)
|
||||
> End: 2026-06-27T07-05 (PST)
|
||||
> Type: capture
|
||||
> Posture: careful
|
||||
> Claude-Session: d6ed1203-92b6-471f-80a4-4c773b645ca1
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
capture this as an issue. We'll implement the network feature later but want it as an Epic, then the remote simulator (e.g. ipad) as a feature with this design
|
||||
```
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Immediately followed session 0019 (brainstorming), which merged the
|
||||
**networked control surface** design SPEC to `main`
|
||||
(`docs/superpowers/specs/2026-06-26-networked-control-surface-design.md`).
|
||||
- Operator wants the work captured as backlog (deferred — "implement later"): an
|
||||
**Epic** for the whole networked feature, and a **Feature** for the iPad remote
|
||||
twin anchored to that spec.
|
||||
- Single-repo app: tracker = `benstull/human-experience-filter-art` on
|
||||
git.benstull.org; no separate content repo (`CONTENT_REMOTE` empty).
|
||||
- Checkout shared with concurrent sessions (0024 live on its own branch).
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Routing.** Classified the request as **capture**; invoked `wgl-capture`,
|
||||
claimed tracked-lite session **0025** (capture / careful).
|
||||
|
||||
2. **Target resolve.** Single-repo app → drafted into this repo's working-tree
|
||||
`issues/` (ephemeral, INV-8); tracker is this repo on git.benstull.org.
|
||||
|
||||
3. **Two asks identified** (per handbook §4.3 sizing): an **Epic** (multi-Feature
|
||||
umbrella — the networked control surface) and a **Feature** (single increment —
|
||||
the iPad web remote twin, the spec's v1 scope). Both type-fit cleanly.
|
||||
|
||||
4. **Drafted both** in the content repo's `issues/` at business altitude (Epic +
|
||||
Feature business sections free of implementation specifics; the design spec
|
||||
referenced as orientation). Presented for review; operator approved filing both
|
||||
at **P2** via `AskUserQuestion`.
|
||||
|
||||
5. **Filing.** Label-ensure first hit a **401** — the default issue-token service
|
||||
resolved to the wrong host. Fixed by pointing `WGL_CAPTURE_TOKEN_SERVICE` at the
|
||||
host-specific Keychain entry `wgl-gitea-issues-readwrite-token-git.benstull.org`.
|
||||
Then ensured capture labels (9 created) and filed:
|
||||
- **Epic #26** — "Networked control surface — drive the experience from a
|
||||
separate control device" (`type/epic`, P2).
|
||||
- **Feature #27** — "Remote controller simulator — drive the experience from an
|
||||
iPad web twin over wifi" (`type/feature`, P2; parent #26).
|
||||
(Typecheck advisory labels 401'd — fail-open, skipped; no effect on the issues.)
|
||||
|
||||
6. **Cleanup.** Deleted both filed drafts (Gitea = system of record, INV-8);
|
||||
removed the now-empty `issues/` dir; working tree carries no drafts.
|
||||
|
||||
## Cut state
|
||||
|
||||
- Filed: **Epic #26**, **Feature #27** (parent #26), both `priority/P2`, deferred.
|
||||
- No repo commits this session (capture is tracked-lite; drafts ephemeral). The
|
||||
dirty tree on branch `session-0024` is the **concurrent session's** work
|
||||
(`build_pool_manifest.py` + a test) — left untouched.
|
||||
- Memory updated: `networked-control-surface-spec.md` records the filed issues +
|
||||
a deferred "when resumed" pointer; `MEMORY.md` index refreshed.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — no autonomous low-confidence calls. Type assignment and P2 priority were
|
||||
operator-approved in-session._
|
||||
|
||||
## Type-fit tally
|
||||
|
||||
`type-fit: judged 2, mismatch 0, overridden 0` (judged against §4.3 informally;
|
||||
the typecheck-label markers could not be stamped — advisory token 401, fail-open).
|
||||
|
||||
## Operator plate
|
||||
|
||||
- The networked-control work is now backlog: **#26** (Epic) / **#27** (Feature),
|
||||
deferred per "implement later".
|
||||
- Token note for future capture on this host: use
|
||||
`WGL_CAPTURE_TOKEN_SERVICE=wgl-gitea-issues-readwrite-token-git.benstull.org`
|
||||
(the unscoped default resolves to the wrong host and 401s).
|
||||
|
||||
## Next /goal
|
||||
|
||||
No new immediate next from this capture — the work is parked on the tracker
|
||||
(#26/#27, deferred). When the operator resumes hardware:
|
||||
|
||||
```
|
||||
/goal Write the implementation plan for the remote controller simulator, per docs/superpowers/specs/2026-06-26-networked-control-surface-design.md (Feature #27)
|
||||
```
|
||||
|
||||
The active project frontier remains the per-altitude music layer (see the
|
||||
`audio-soundtrack-sourcing` memory), not this deferred thread.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Session 0026.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-27T15-26 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: c6b9bebd-f94a-4fc0-982d-0a9dc00b3148
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> End: 2026-06-27T18-48 (PST)
|
||||
> Status: **FINALIZED** — merged to main (PR #28) and published at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Build scrub-driven altitude transitions in the simulator, per the approved design at
|
||||
`docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md`. Write the
|
||||
implementation plan from that spec, review it, then execute it. Make the Altitude knob
|
||||
position continuously drive the transition: dragging the dial scrubs the morph video's
|
||||
currentTime and crossfades the two adjacent scale soundtracks by knob angle; hold-partway
|
||||
(no auto-complete), fully reversible, re-roll the destination clip on each fresh approach,
|
||||
scroll-wheel auto-scrubs one altitude then locks, lock-per-altitude preserved. Phase 1:
|
||||
build against current morphs. Phase 2: re-bake all 154 morphs all-intra for smooth seeking,
|
||||
re-push via git-LFS. Unit tests for pure scrub logic + extend Playwright E2E.
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: design docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md (R2a — ELIGIBLE)
|
||||
> Session type: planning-and-executing (fused) — write the plan, then execute it, ship via §9.
|
||||
|
||||
Scrub-driven altitude transitions. Continuous `pos` (float) drives morph `currentTime` +
|
||||
two-soundtrack audio crossfade by `frac(pos)`; integer crossing commits + locks + re-rolls.
|
||||
Phase 1 = interaction against current morphs; Phase 2 = all-intra re-bake (154 morphs, LFS).
|
||||
Pure scrub logic kept separable + unit-tested; Playwright E2E extended to assert currentTime
|
||||
+ audio gains track the dial and reverse on turn-back.
|
||||
|
||||
### Progress (checkpoint @ Phase-1 done)
|
||||
|
||||
- Plan: `docs/superpowers/plans/2026-06-27-scrub-driven-altitude-transitions.md` (approved "execute as-is").
|
||||
- **Phase 1 DONE** (branch `session-0026`, 5 commits):
|
||||
- T1 `simulator/static/scrub.js` (pure UMD math) + `simulator/unit/scrub.test.js` (9 `node --test`).
|
||||
- T2 two-element audio crossfade (`<audio id=aud-b>`, `blendAudio`/`restAudio`/`ensurePlaying`/`assignElements`).
|
||||
- T3 scrub engine: drag live-drives `pos`; `setPos` seeks morph currentTime (1 seek/rAF), crossfades audio,
|
||||
commits+locks+re-rolls on integer crossings; release holds (no auto-complete); client-side `pickPoolClip`.
|
||||
- T4 wheel + label-tap → `autoScrub` (animate `pos`, land exact, lock); removed dead `advance()`/`playTransition()`.
|
||||
- T5 E2E scrub tier (6 tests green): drag tracks currentTime+gains, turn-back reverses same file + re-locks start,
|
||||
detent-crossing commits+locks (via `window.__hefState` seam), wheel auto-scrubs+locks.
|
||||
- **Verification:** `node --test` 9 green · `pytest` 302 passed / 2 skipped · Playwright 6 green.
|
||||
- **Phase 2 DONE** (T7–T8): `transition_cmd`/`reverse_cmd` builders + `ALL_INTRA` (`-g 1 -keyint_min 1
|
||||
-sc_threshold 0`), unit-tested; re-baked all 154 morphs all-intra (verified 93/93 I-frames on a sample),
|
||||
173M→504M, largest clip ~5.8M; committed via git-LFS and pushed (154 LFS objects, 453MB).
|
||||
- **All 8 plan tasks complete. Branch `session-0026` pushed.** Final verification: `node --test` 9 green ·
|
||||
Playwright 6 green · `pytest` 302 passed/2 skipped.
|
||||
- **Remaining:** merge to main + §9. Operator by-eye "feel/smoothness liked?" review is the one acceptance
|
||||
criterion automated tests can't cover (simulator-first directive) — flagged for operator before/at merge.
|
||||
- **DECISION (operator): HOLD for eyeball first.** Branch `session-0026` stays UNMERGED; session NOT finalized.
|
||||
Operator pulls + runs the sim locally to judge the scrub feel/smoothness, then says merge (or requests tweaks).
|
||||
|
||||
### Eyeball round 1 — operator found 2 issues, both FIXED (2 more commits)
|
||||
|
||||
- **Fix 1 — landing jitter (frontend).** Landing jump-cut the loop back to frame 0. Each morph reproduces the
|
||||
dst clip's first 3s (`trim=0:3`), ending dst@~3s, so steady-state loop now starts at `LOOP_TAIL_S=3.0` and
|
||||
loops `[3.0, duration]` (`ensureClipMedia` custom loop; disarmed during morph scrub). Morph's last frame hands
|
||||
straight off to the loop. E2E asserts a landed clip loops from ~3s, not 0. No re-bake. (8th E2E test added.)
|
||||
- **Fix 2 — red-snapper text card (content).** `reef_snapper` opened with a black NOAA title card (~0.9–2.9s).
|
||||
Re-trimmed `reef_snapper/base.mp4` to drop the first 3.0s (23s→20s, footage clean from 3.0s) and re-baked the
|
||||
7 reef_snapper morph pairs (+reverses, all-intra) from the clean base; verified snapper morph frames text-free.
|
||||
- **Verification:** Playwright 7 green · all-intra re-confirmed (93/93 I-frames). Pushed to `session-0026`.
|
||||
- **D2 (deferred) — reef_snapper tracked test-label.** The trim shifts the footage, so reef_snapper's one
|
||||
loop-normalized tracked label (`appear 0.0`/`disappear 0.7` + 3-keyframe track) is now slightly misaligned to
|
||||
the subject. It's demo/test material gated behind both knobs up; left as-is — re-author via `/author.html` if
|
||||
the operator wants it pixel-accurate.
|
||||
### Eyeball round 2 — 2 more items, both FIXED (2 commits)
|
||||
|
||||
- **Fix 3 — morph→loop load jolt (double-buffer).** A pause/jolt when the morph ended and the next altitude's
|
||||
loop loaded: swapping `vid.src` to the base + seeking the tail reloaded the ON-SCREEN element (decode+seek
|
||||
stall). Added a dedicated `#vid-loop` element for the steady-state base loop; during a scrub it is PRELOADED to
|
||||
the clip we're heading toward (`loadLoop`, paused at the tail frame). The paint shader reads the active source
|
||||
(`displayVid() = busy ? vid : loopVid`), so landing is an instant source swap — no reload, no seek. E2E asserts
|
||||
the loop element is armed + playing from ~3s after landing.
|
||||
- **Fix 4 — audio on/off → 0–10 level dial.** Replaced the Audio checkbox with a 0–10 range slider = master gain
|
||||
(`audioVol = level/10`), scaling rest volume + the two-track scrub crossfade. First Video-on lifts audio to
|
||||
**3/10** (was full). E2E: range 0–10 default 0; first video-on → 3.
|
||||
- **Verification:** Playwright **9 green** · node unit 9 · screenshot-confirmed the dial layout.
|
||||
- **D3 residual still applies:** double-buffer kills the LOAD pause both directions; the reverse/turn-back FRAME
|
||||
asymmetry (morph src-start@0 vs loop tail@3s) and the ~loop-wrap seek remain (per-clip loop files would fully
|
||||
resolve if wanted).
|
||||
### Eyeball round 3 — audio-off bug (systematic-debugging), FIXED (1 commit)
|
||||
|
||||
- **Bug:** audio turned off *sometimes* after landing on a new altitude. **Root cause (code-traced, not guessed):**
|
||||
`fadeVolume` started a `setInterval` ramp that was **never cancelled**. `restAudio` fades the idle element to 0;
|
||||
if within FADE_MS (500ms) that same element becomes the newly-landed ACTIVE scale (`assignElements` reuses one
|
||||
element per soundtrack url), the stale fade-to-0 kept running and dragged the now-active element to silence —
|
||||
hence "only sometimes." **Fix:** `cancelFade(el)` supersedes any in-flight fade when a new fade starts
|
||||
(`fadeVolume`) or a direct volume is set (`ensurePlaying`). Added `window.__hefFade` seam + a deterministic E2E
|
||||
that PROVES it (fails with the fix disabled, passes with it). Playwright **10 green**.
|
||||
### Eyeball round 4 — transition speed (1 commit)
|
||||
|
||||
- **Request:** slow click/auto transitions ~2x and make them the SAME speed between any two altitudes (2 away =
|
||||
2× as long as 1). `autoScrub` was a fixed 600ms total regardless of distance. Now duration is PROPORTIONAL:
|
||||
`PER_ALTITUDE_MS=1200` (≈2× the old 600) × |distance|, so a label-click/wheel jump of N altitudes takes
|
||||
N×1200ms — constant per-step speed. Manual drag unaffected. E2E asserts a 2-step jump ≈ 2× a 1-step. 11 green.
|
||||
- **Resume:** merge + `wgl-session-finalize` on operator go, OR next eyeball round of tweaks.
|
||||
|
||||
## 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._
|
||||
|
||||
- **D1 (plan) — canonical segment morph file.** The spec's component 2 says resolve the
|
||||
directed morph via `morphByPair["<fromClip>→<toClip>"]`, but its turn-back decision says
|
||||
"scrubs the SAME morph in reverse" with full reversibility. These are only mutually
|
||||
consistent if a segment uses ONE file scrubbed bidirectionally. I locked the plan to:
|
||||
per segment `[lo, lo+1]`, always use the **descend/forward** morph
|
||||
`morphByPair["<clip@lo>→<clip@lo+1>"]` and drive `currentTime = frac × duration` in both
|
||||
travel directions; the `.rev` files become unused by the scrub interaction (kept baked
|
||||
for back-compat). This means the old E2E "zoom-out plays a `.rev`" assertion is replaced
|
||||
by a "turn-back seeks the same file backward" assertion. Low-confidence on whether the
|
||||
operator specifically wanted `.rev` retained in the drag path.
|
||||
- **D2 — reef_snapper tracked test-label misalignment after the text-card trim.** Trimming the snapper base by
|
||||
3.0s (to remove the title card) shifts the footage, so reef_snapper's one loop-normalized tracked label
|
||||
(`appear 0.0`/`disappear 0.7` + a 3-keyframe track) no longer follows the same subject. Left as-is: it's
|
||||
demo/test authoring gated behind both knobs up; re-author via `/author.html` if pixel-accuracy is wanted.
|
||||
- **D3 — landing-jitter fix approach.** Chose a frontend loop-from-tail (`LOOP_TAIL_S=3.0`, custom
|
||||
`[3.0, duration]` loop) over re-baking morphs or baking dedicated loop clips — cheap, no media churn,
|
||||
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.
|
||||
```
|
||||
+8
-7
@@ -1,15 +1,16 @@
|
||||
# Session 0020.0 — Transcript
|
||||
# Session 0029.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T06-47 (PST)
|
||||
> Type: writing-plans
|
||||
> Posture: careful
|
||||
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
|
||||
> Start: 2026-06-30T08-09 (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
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0020 for human-experience-filter-art. The driver replaces this
|
||||
> This file reserves session ID 0029 for human-experience-filter-art. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0020.0-TRANSCRIPT-2026-06-26T06-47--<end>.md form at session end.
|
||||
> SESSION-0029.0-TRANSCRIPT-2026-06-30T08-09--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
@@ -58,5 +58,32 @@
|
||||
},
|
||||
"0020": {
|
||||
"title": ""
|
||||
},
|
||||
"0021": {
|
||||
"title": ""
|
||||
},
|
||||
"0022": {
|
||||
"title": ""
|
||||
},
|
||||
"0023": {
|
||||
"title": ""
|
||||
},
|
||||
"0024": {
|
||||
"title": ""
|
||||
},
|
||||
"0025": {
|
||||
"title": ""
|
||||
},
|
||||
"0026": {
|
||||
"title": ""
|
||||
},
|
||||
"0027": {
|
||||
"title": ""
|
||||
},
|
||||
"0028": {
|
||||
"title": ""
|
||||
},
|
||||
"0029": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
+27
-17
@@ -29,11 +29,18 @@ from player.alteration import (
|
||||
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS, resolve_audio, resolve_visual
|
||||
from player.ring import (
|
||||
DEFAULT_FAST_SPIN_THRESHOLD,
|
||||
ResolvedMove,
|
||||
advance_ring,
|
||||
pick_clip_id,
|
||||
resolve_move,
|
||||
scale_at,
|
||||
)
|
||||
from simulator.clips import load_manifest, load_ring, ring_move_to_dict, ring_to_dict
|
||||
from simulator.clips import (
|
||||
load_manifest,
|
||||
load_ring,
|
||||
resolved_move_to_dict,
|
||||
ring_to_dict,
|
||||
)
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
MEDIA_DIR = Path(__file__).parent / "sample_media"
|
||||
@@ -87,12 +94,6 @@ def _media_version(rel: str) -> Optional[str]:
|
||||
return token
|
||||
|
||||
|
||||
def _rev_file(file: str) -> str:
|
||||
"""The baked zoom-out companion path for a transition file (mirrors the
|
||||
client's `reverseFile`): `<edge>.mp4` -> `<edge>.rev.mp4`."""
|
||||
return file[:-4] + ".rev.mp4" if file.endswith(".mp4") else file
|
||||
|
||||
|
||||
class ControlsModel(BaseModel):
|
||||
visual: str
|
||||
audio: str
|
||||
@@ -119,6 +120,7 @@ class AlterationRequest(BaseModel):
|
||||
class RingAdvanceRequest(BaseModel):
|
||||
from_index: int = 0
|
||||
delta: int
|
||||
from_clip_id: str = ""
|
||||
|
||||
|
||||
class AuthorTrackRequest(BaseModel):
|
||||
@@ -213,14 +215,14 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
|
||||
@app.get("/api/media-versions")
|
||||
def api_media_versions():
|
||||
"""Per-file content-hash tokens the client appends to /media URLs as
|
||||
`?v=<hash>`. Covers every served file: each clip's base footage plus each
|
||||
ring transition and its baked reverse. A re-baked clip's hash changes, so
|
||||
its URL changes and the browser refetches — a permanent cache-bust."""
|
||||
`?v=<hash>`. Covers every served file: each clip's base footage plus every
|
||||
ring transition morph (both directions are explicit entries). A re-baked
|
||||
clip's hash changes, so its URL changes and the browser refetches — a
|
||||
permanent cache-bust."""
|
||||
files = {c.base_file for c in app.state.clips}
|
||||
if app.state.ring is not None:
|
||||
for t in app.state.ring.transitions:
|
||||
files.add(t.file)
|
||||
files.add(_rev_file(t.file))
|
||||
versions = {}
|
||||
for f in sorted(files):
|
||||
v = _media_version(f)
|
||||
@@ -244,12 +246,20 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
|
||||
req.delta,
|
||||
fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD,
|
||||
)
|
||||
# Rotating pool: pick a random member of the LANDED scale (content-pipeline
|
||||
# §11.1). A delta=0 advance is the initial / re-roll pick (a no-op move that
|
||||
# still yields a fresh random clip). The pure pick takes injected randomness.
|
||||
landed = scale_at(app.state.ring, move.to_index)
|
||||
chosen = pick_clip_id(landed, random.random())
|
||||
return ring_move_to_dict(move, app.state.ring, chosen)
|
||||
if move.steps:
|
||||
# Choose the destination member of each crossed altitude FIRST (one
|
||||
# random draw per step — content-pipeline §11.1), then resolve the
|
||||
# morph from the prior clip to it, so the footage matches what we land
|
||||
# on. The currently-shown clip threads in as `from_clip_id`.
|
||||
src = req.from_clip_id or scale_at(app.state.ring, move.from_index).clip_id
|
||||
picks = tuple(random.random() for _ in move.steps)
|
||||
resolved = resolve_move(app.state.ring, move, src, picks)
|
||||
else:
|
||||
# A no-op move (delta=0) is the initial / re-roll pick: a fresh random
|
||||
# member of the current scale, no morph.
|
||||
landed = scale_at(app.state.ring, move.to_index)
|
||||
resolved = ResolvedMove(steps=(), target_clip_id=pick_clip_id(landed, random.random()))
|
||||
return resolved_move_to_dict(move, resolved)
|
||||
|
||||
@app.post("/api/author/track")
|
||||
def api_author_track(req: AuthorTrackRequest):
|
||||
|
||||
@@ -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,
|
||||
@@ -372,10 +899,15 @@ def build_manifest() -> dict:
|
||||
for s in RING_ORDER
|
||||
]
|
||||
transitions = []
|
||||
n = len(RING_ORDER)
|
||||
for i in range(n):
|
||||
a, b = RING_ORDER[i], RING_ORDER[(i + 1) % n]
|
||||
transitions.append({"file": f"transitions/{a}-{b}.mp4", "model": "placeholder-zoom"})
|
||||
for H, L in _adjacent_edges(): # H higher altitude, L lower
|
||||
for h in POOLS[H]:
|
||||
for l in POOLS[L]:
|
||||
transitions.append({"from": h, "to": l,
|
||||
"file": f"transitions/{h}__{l}.mp4", # zoom IN (descend)
|
||||
"model": "placeholder-zoom"})
|
||||
transitions.append({"from": l, "to": h,
|
||||
"file": f"transitions/{h}__{l}.rev.mp4", # zoom OUT (ascend)
|
||||
"model": "placeholder-zoom"})
|
||||
return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}}
|
||||
|
||||
|
||||
@@ -388,22 +920,43 @@ def _ffmpeg() -> str:
|
||||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||
|
||||
|
||||
def _make_transition(ff: str, scale_a: str, scale_b: str) -> Path:
|
||||
"""A zoom/warp morph between two scales, from their PRIMARY pool members'
|
||||
bases (mirrors setup_scales_media.py but keyed by scale->primary). This is the
|
||||
recipe behind the well-liked orbit-coast edge; generate_media() now applies it
|
||||
uniformly to every edge so none carry stale footage from an earlier ring."""
|
||||
a = MEDIA / POOLS[scale_a][0] / "base.mp4"
|
||||
b = MEDIA / POOLS[scale_b][0] / "base.mp4"
|
||||
out = MEDIA / "transitions" / f"{scale_a}-{scale_b}.mp4"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
def _adjacent_edges() -> list[tuple[str, str]]:
|
||||
"""Ordered (higher, lower) altitude edges around the ring, including the wrap."""
|
||||
n = len(RING_ORDER)
|
||||
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"
|
||||
subprocess.run([
|
||||
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", str(out),
|
||||
], check=True, capture_output=True)
|
||||
"-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
|
||||
own morph. Forward = zoom IN (descend src->dst)."""
|
||||
a = MEDIA / src_clip / "base.mp4"
|
||||
b = MEDIA / dst_clip / "base.mp4"
|
||||
out = MEDIA / "transitions" / f"{src_clip}__{dst_clip}.mp4"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(transition_cmd(ff, str(a), str(b), str(out)), check=True, capture_output=True)
|
||||
return out
|
||||
|
||||
|
||||
@@ -413,23 +966,21 @@ 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
|
||||
|
||||
|
||||
def generate_media() -> None:
|
||||
"""(Re)build EVERY ring-edge transition from the current real primary bases —
|
||||
the orbit-coast recipe applied uniformly, overwriting any stale clips left from
|
||||
an earlier ring — plus a reversed companion per edge for zoom-out moves."""
|
||||
"""Bake EVERY adjacent member-pair morph from real bases — forward (zoom in,
|
||||
descend) plus its reversed companion (zoom out, ascend), for every pair of
|
||||
clips in adjacent altitudes."""
|
||||
ff = _ffmpeg()
|
||||
n = len(RING_ORDER)
|
||||
for i in range(n):
|
||||
a, b = RING_ORDER[i], RING_ORDER[(i + 1) % n]
|
||||
fwd = _make_transition(ff, a, b)
|
||||
_make_reverse(ff, fwd)
|
||||
print(f"generated transitions/{a}-{b}.mp4 (+ .rev) from real bases")
|
||||
for H, L in _adjacent_edges():
|
||||
for h in POOLS[H]:
|
||||
for l in POOLS[L]:
|
||||
fwd = _make_transition(ff, h, l)
|
||||
_make_reverse(ff, fwd)
|
||||
print(f"generated {len(POOLS[H]) * len(POOLS[L])} {H}__{L} morphs (+ .rev) from real bases")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> None:
|
||||
|
||||
+25
-20
@@ -14,7 +14,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from player.ring import RingMove, Scale, ScaleRing, Transition, scale_at
|
||||
from player.ring import ResolvedMove, RingMove, Scale, ScaleRing, Transition
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -99,7 +99,12 @@ def load_ring(path: str | Path) -> ScaleRing | None:
|
||||
return None
|
||||
scales = tuple(_scale_from_dict(s) for s in ring.get("scales", []))
|
||||
transitions = tuple(
|
||||
Transition(file=t["file"], model=t.get("model", ""))
|
||||
Transition(
|
||||
from_clip=t.get("from", ""),
|
||||
to_clip=t.get("to", ""),
|
||||
file=t["file"],
|
||||
model=t.get("model", ""),
|
||||
)
|
||||
for t in ring.get("transitions", [])
|
||||
)
|
||||
return ScaleRing(scales=scales, transitions=transitions)
|
||||
@@ -127,35 +132,35 @@ def ring_to_dict(ring: ScaleRing, clips: list[Clip]) -> dict:
|
||||
}
|
||||
for s in ring.scales
|
||||
],
|
||||
"transitions": [{"file": t.file, "model": t.model} for t in ring.transitions],
|
||||
"transitions": [
|
||||
{"from": t.from_clip, "to": t.to_clip, "file": t.file, "model": t.model}
|
||||
for t in ring.transitions
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def ring_move_to_dict(
|
||||
move: RingMove, ring: ScaleRing, chosen_clip_id: str | None = None
|
||||
) -> dict:
|
||||
"""JSON form of an encoder move: the landing scale's clip and the ordered
|
||||
transition clips to play (with direction). `fast` flags a collapsed fast-spin
|
||||
pass; the single step then carries `blended` so the renderer plays it quick.
|
||||
def resolved_move_to_dict(move: RingMove, resolved: ResolvedMove) -> dict:
|
||||
"""JSON form of a RESOLVED encoder move: where it lands, the locked clip, and
|
||||
the ordered chained morphs. Each step carries the clip it morphs FROM (the
|
||||
clip currently shown), the chosen clip it morphs TO, and the matching morph
|
||||
file (None if no morph was baked — the renderer plain-cuts). `fast` marks a
|
||||
fast spin; each step then carries `blended` so the renderer accelerates it.
|
||||
|
||||
`target_clip_id` is the pool member the player should load on landing. The
|
||||
caller passes the random pick (`pick_clip_id(landed_scale, random.random())`,
|
||||
content-pipeline §11.1); when omitted it falls back to the scale's primary
|
||||
`clip_id` (deterministic — keeps pre-pool callers/tests working)."""
|
||||
`target_clip_id` is the final clip the viewer locks onto."""
|
||||
return {
|
||||
"from_index": move.from_index,
|
||||
"to_index": move.to_index,
|
||||
"wrapped": move.wrapped,
|
||||
"fast": move.fast,
|
||||
"target_clip_id": chosen_clip_id or scale_at(ring, move.to_index).clip_id,
|
||||
"target_clip_id": resolved.target_clip_id,
|
||||
"steps": [
|
||||
{
|
||||
"edge": st.edge,
|
||||
"reversed": st.reversed,
|
||||
"file": st.file,
|
||||
"to_index": st.to_index,
|
||||
"blended": st.blended,
|
||||
"from_clip": s.from_clip,
|
||||
"to_clip": s.to_clip,
|
||||
"file": s.file,
|
||||
"to_index": s.to_index,
|
||||
"blended": s.blended,
|
||||
}
|
||||
for st in move.steps
|
||||
for s in resolved.steps
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Simulator E2E (Playwright)
|
||||
|
||||
Browser end-to-end tests for the Human Experience Filter simulator. The §9
|
||||
pipeline requires an E2E browser tier for any UI surface; this is that tier.
|
||||
|
||||
## What it covers
|
||||
|
||||
`tests/altitude-lock.spec.ts` drives the real simulator and asserts the
|
||||
altitude-morph behavior:
|
||||
|
||||
- Zooming to a new altitude plays the morph that **matches the clip it lands on**
|
||||
(forward zoom-in, and `.rev` zoom-out).
|
||||
- After landing, the clip is **locked** — it does not change while parked (no
|
||||
secondary swap, no re-roll).
|
||||
|
||||
The played morph paths are read from `window.__hefMorphs` (a diagnostic array
|
||||
populated by `advance()` in `static/app.js`), which is reliable even when a morph
|
||||
is served from the in-memory blob cache. The locked clip is read from the
|
||||
Dev-Mode `#scale-name` readout.
|
||||
|
||||
## Running
|
||||
|
||||
Prerequisites: the project Python venv on PATH (the Playwright `webServer` boots
|
||||
`uvicorn simulator.app:app` from the repo root), Node, and the Chromium browser.
|
||||
|
||||
```bash
|
||||
# from this directory (simulator/e2e)
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
npm test
|
||||
```
|
||||
|
||||
`playwright.config.ts` starts uvicorn on port 8099 automatically (`reuseExistingServer`
|
||||
locally) and points the tests at it.
|
||||
+1
@@ -0,0 +1 @@
|
||||
../@playwright/test/cli.js
|
||||
+1
@@ -0,0 +1 @@
|
||||
../playwright-core/cli.js
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "hef-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Portions Copyright (c) Microsoft Corporation.
|
||||
Portions Copyright 2017 Google Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Playwright
|
||||
Copyright (c) Microsoft Corporation
|
||||
|
||||
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
|
||||
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
# 🎭 Playwright
|
||||
|
||||
[](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[](https://webkit.org/)<!-- GEN:stop --> [](https://aka.ms/playwright/discord)
|
||||
|
||||
## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
|
||||
|
||||
Playwright is a framework for web automation and testing. It drives Chromium, Firefox, and WebKit with a single API — in your tests, in your scripts, and as a tool for AI agents.
|
||||
|
||||
## Get Started
|
||||
|
||||
Choose the path that fits your workflow:
|
||||
|
||||
| | Best for | Install |
|
||||
|---|---|---|
|
||||
| **[Playwright Test](#playwright-test)** | End-to-end testing | `npm init playwright@latest` |
|
||||
| **[Playwright CLI](#playwright-cli)** | Coding agents (Claude Code, Copilot) | `npm i -g @playwright/cli@latest` |
|
||||
| **[Playwright MCP](#playwright-mcp)** | AI agents and LLM-driven automation | `npx @playwright/mcp@latest` |
|
||||
| **[Playwright Library](#playwright-library)** | Browser automation scripts | `npm i playwright` |
|
||||
| **[VS Code Extension](#vs-code-extension)** | Test authoring and debugging in VS Code | [Install from Marketplace](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) |
|
||||
|
||||
---
|
||||
|
||||
## Playwright Test
|
||||
|
||||
Playwright Test is a full-featured test runner built for end-to-end testing. It runs tests across Chromium, Firefox, and WebKit with full browser isolation, auto-waiting, and web-first assertions.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
```
|
||||
|
||||
Or add manually:
|
||||
|
||||
```bash
|
||||
npm i -D @playwright/test
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### Write a test
|
||||
|
||||
```TypeScript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('has title', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
await expect(page).toHaveTitle(/Playwright/);
|
||||
});
|
||||
|
||||
test('get started link', async ({ page }) => {
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.getByRole('link', { name: 'Get started' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
Tests run in parallel across all configured browsers, in headless mode by default. Each test gets a fresh browser context — full isolation with near-zero overhead.
|
||||
|
||||
### Key capabilities
|
||||
|
||||
**Auto-wait and web-first assertions.** No artificial timeouts. Playwright waits for elements to be actionable, and assertions automatically retry until conditions are met.
|
||||
|
||||
**Locators.** Find elements with resilient locators that mirror how users see the page:
|
||||
|
||||
```TypeScript
|
||||
page.getByRole('button', { name: 'Submit' })
|
||||
page.getByLabel('Email')
|
||||
page.getByPlaceholder('Search...')
|
||||
page.getByTestId('login-form')
|
||||
```
|
||||
|
||||
**Test isolation.** Each test runs in its own browser context — equivalent to a fresh browser profile. Save authentication state once and reuse it across tests:
|
||||
|
||||
```TypeScript
|
||||
// Save state after login
|
||||
await page.context().storageState({ path: 'auth.json' });
|
||||
|
||||
// Reuse in other tests
|
||||
test.use({ storageState: 'auth.json' });
|
||||
```
|
||||
|
||||
**Tracing.** Capture execution traces, screenshots, and videos on failure. Inspect every action, DOM snapshot, network request, and console message in the [Trace Viewer](https://playwright.dev/docs/trace-viewer):
|
||||
|
||||
```TypeScript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
npx playwright show-trace trace.zip
|
||||
```
|
||||
|
||||
<!-- TODO: screenshot of trace viewer -->
|
||||
|
||||
**Parallelism.** Tests run in parallel by default across all configured browsers.
|
||||
|
||||
[Full testing documentation](https://playwright.dev/docs/intro)
|
||||
|
||||
---
|
||||
|
||||
## Playwright CLI
|
||||
|
||||
[Playwright CLI](https://github.com/microsoft/playwright-cli) is a command-line interface for browser automation designed for coding agents. It's more token-efficient than MCP — commands avoid loading large tool schemas and accessibility trees into the model context.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install -g @playwright/cli@latest
|
||||
```
|
||||
|
||||
Optionally install skills for richer agent integration:
|
||||
|
||||
```bash
|
||||
playwright-cli install --skills
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
Point your coding agent at a task:
|
||||
|
||||
```
|
||||
Test the "add todo" flow on https://demo.playwright.dev/todomvc using playwright-cli.
|
||||
Take screenshots for all successful and failing scenarios.
|
||||
```
|
||||
|
||||
Or run commands directly:
|
||||
|
||||
```bash
|
||||
playwright-cli open https://demo.playwright.dev/todomvc/ --headed
|
||||
playwright-cli type "Buy groceries"
|
||||
playwright-cli press Enter
|
||||
playwright-cli screenshot
|
||||
```
|
||||
|
||||
### Session monitoring
|
||||
|
||||
Use `playwright-cli show` to open a visual dashboard with live screencast previews of all running browser sessions. Click any session to zoom in and take remote control.
|
||||
|
||||
```bash
|
||||
playwright-cli show
|
||||
```
|
||||
|
||||
<!-- TODO: screenshot of playwright-cli show dashboard -->
|
||||
|
||||
[Full CLI documentation](https://playwright.dev/agent-cli/introduction) | [GitHub](https://github.com/microsoft/playwright-cli)
|
||||
|
||||
---
|
||||
|
||||
## Playwright MCP
|
||||
|
||||
The [Playwright MCP server](https://github.com/microsoft/playwright-mcp) gives AI agents full browser control through the [Model Context Protocol](https://modelcontextprotocol.io). Agents interact with pages using structured accessibility snapshots — no vision models or screenshots required.
|
||||
|
||||
### Setup
|
||||
|
||||
Add to your MCP client (VS Code, Cursor, Claude Desktop, Windsurf, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**One-click install for VS Code:**
|
||||
|
||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20MCP%20Server&color=0098FF" alt="Install in VS Code" />](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||
|
||||
**For Claude Code:**
|
||||
|
||||
```bash
|
||||
claude mcp add playwright npx @playwright/mcp@latest
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
Ask your AI assistant to interact with any web page:
|
||||
|
||||
```
|
||||
Navigate to https://demo.playwright.dev/todomvc and add a few todo items.
|
||||
```
|
||||
|
||||
The agent sees the page as a structured accessibility tree:
|
||||
|
||||
```
|
||||
- heading "todos" [level=1]
|
||||
- textbox "What needs to be done?" [ref=e5]
|
||||
- listitem:
|
||||
- checkbox "Toggle Todo" [ref=e10]
|
||||
- text: "Buy groceries"
|
||||
```
|
||||
|
||||
It uses element refs like `e5` and `e10` to click, type, and interact — deterministically and without visual ambiguity. Tools cover navigation, form filling, screenshots, network mocking, storage management, and more.
|
||||
|
||||
[Full MCP documentation](https://playwright.dev/mcp/introduction) | [GitHub](https://github.com/microsoft/playwright-mcp)
|
||||
|
||||
---
|
||||
|
||||
## Playwright Library
|
||||
|
||||
Use `playwright` as a library for browser automation scripts — web scraping, PDF generation, screenshot capture, and any workflow that needs programmatic browser control without a test runner.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm i playwright
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**Take a screenshot:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.screenshot({ path: 'screenshot.png' });
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
**Generate a PDF:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.pdf({ path: 'page.pdf', format: 'A4' });
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
**Emulate a mobile device:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium, devices } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext(devices['iPhone 15']);
|
||||
const page = await context.newPage();
|
||||
await page.goto('https://playwright.dev/');
|
||||
await page.screenshot({ path: 'mobile.png' });
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
**Intercept network requests:**
|
||||
|
||||
```TypeScript
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());
|
||||
await page.goto('https://playwright.dev/');
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
[Library documentation](https://playwright.dev/docs/library) | [API reference](https://playwright.dev/docs/api/class-playwright)
|
||||
|
||||
---
|
||||
|
||||
## VS Code Extension
|
||||
|
||||
The [Playwright VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) brings test running, debugging, and code generation directly into your editor.
|
||||
|
||||
<!-- TODO: hero screenshot of VS Code with Playwright sidebar -->
|
||||
|
||||
**Run and debug tests** from the editor with a single click. Set breakpoints, inspect variables, and step through test execution with a live browser view.
|
||||
|
||||
**Generate tests with CodeGen.** Click "Record new" to open a browser — navigate and interact with your app while Playwright writes the test code for you.
|
||||
|
||||
**Pick locators.** Hover over any element in the browser to see the best available locator, then click to copy it to your clipboard.
|
||||
|
||||
**Trace Viewer integration.** Enable "Show Trace Viewer" in the sidebar to get a full execution trace after each test run — DOM snapshots, network requests, console logs, and screenshots at every step.
|
||||
|
||||
[Install the extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) | [VS Code guide](https://playwright.dev/docs/getting-started-vscode)
|
||||
|
||||
---
|
||||
|
||||
## Cross-Browser Support
|
||||
|
||||
| | Linux | macOS | Windows |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| Chromium<sup>1</sup> <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| WebKit <!-- GEN:webkit-version -->26.5<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
| Firefox <!-- GEN:firefox-version -->151.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||
|
||||
Headless and headed execution on all platforms. <sup>1</sup> Uses [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing) by default.
|
||||
|
||||
## Other Languages
|
||||
|
||||
Playwright is also available for [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), and [Java](https://playwright.dev/java/docs/intro).
|
||||
|
||||
## Resources
|
||||
|
||||
* [Documentation](https://playwright.dev)
|
||||
* [API reference](https://playwright.dev/docs/api/class-playwright)
|
||||
* [MCP server](https://github.com/microsoft/playwright-mcp)
|
||||
* [CLI for coding agents](https://github.com/microsoft/playwright-cli)
|
||||
* [VS Code extension](https://github.com/microsoft/playwright-vscode)
|
||||
* [Contribution guide](CONTRIBUTING.md)
|
||||
* [Changelog](https://github.com/microsoft/playwright/releases)
|
||||
* [Discord](https://aka.ms/playwright/discord)
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const { program } = require('playwright/lib/program');
|
||||
program.parse(process.argv);
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from 'playwright/test';
|
||||
export { default } from 'playwright/test';
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
module.exports = require('playwright/test');
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from 'playwright/test';
|
||||
export { default } from 'playwright/test';
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@playwright/test",
|
||||
"version": "1.61.1",
|
||||
"description": "A high-level API to automate web browsers",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/microsoft/playwright.git"
|
||||
},
|
||||
"homepage": "https://playwright.dev",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./index.mjs",
|
||||
"require": "./index.js",
|
||||
"default": "./index.js"
|
||||
},
|
||||
"./cli": "./cli.js",
|
||||
"./package.json": "./package.json",
|
||||
"./reporter": "./reporter.js"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from 'playwright/types/testReporter';
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// We only export types in reporter.d.ts.
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// We only export types in reporter.d.ts.
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Portions Copyright (c) Microsoft Corporation.
|
||||
Portions Copyright 2017 Google Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Playwright
|
||||
Copyright (c) Microsoft Corporation
|
||||
|
||||
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
|
||||
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# playwright-core
|
||||
|
||||
This package contains the no-browser flavor of [Playwright](http://github.com/microsoft/playwright).
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
microsoft/playwright-core
|
||||
|
||||
THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
|
||||
|
||||
This package bundles third-party software inside individual files under
|
||||
`lib/`. Each bundled output has a sidecar `<bundle>.js.LICENSE` file next
|
||||
to it listing every npm package whose source was inlined into that
|
||||
bundle, together with the full license text for each.
|
||||
|
||||
For example:
|
||||
- lib/utilsBundle.js.LICENSE
|
||||
|
||||
This project incorporates components from the projects listed below. The original copyright notices and the licenses under which Microsoft received such components are set forth below. Microsoft reserves all rights not expressly granted herein, whether by implication, estoppel or otherwise.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
$osInfo = Get-WmiObject -Class Win32_OperatingSystem
|
||||
# check if running on Windows Server
|
||||
if ($osInfo.ProductType -eq 3) {
|
||||
Install-WindowsFeature Server-Media-Foundation
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# This script sets up a WSL distribution that will be used to run WebKit.
|
||||
|
||||
$Distribution = "playwright"
|
||||
$Username = "pwuser"
|
||||
|
||||
$distributions = (wsl --list --quiet) -split "\r?\n"
|
||||
if ($distributions -contains $Distribution) {
|
||||
Write-Host "WSL distribution '$Distribution' already exists. Skipping installation."
|
||||
} else {
|
||||
Write-Host "Installing new WSL distribution '$Distribution'..."
|
||||
$VhdSize = "10GB"
|
||||
wsl --install -d Ubuntu-24.04 --name $Distribution --no-launch --vhd-size $VhdSize
|
||||
wsl -d $Distribution -u root adduser --gecos GECOS --disabled-password $Username
|
||||
}
|
||||
|
||||
$pwshDirname = (Resolve-Path -Path $PSScriptRoot).Path;
|
||||
$playwrightCoreRoot = Resolve-Path (Join-Path $pwshDirname "..")
|
||||
|
||||
$initScript = @"
|
||||
if [ ! -f "/home/$Username/node/bin/node" ]; then
|
||||
mkdir -p /home/$Username/node
|
||||
curl -fsSL https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz -o /home/$Username/node/node-v22.17.0-linux-x64.tar.xz
|
||||
tar -xJf /home/$Username/node/node-v22.17.0-linux-x64.tar.xz -C /home/$Username/node --strip-components=1
|
||||
sudo -u $Username echo 'export PATH=/home/$Username/node/bin:\`$PATH' >> /home/$Username/.profile
|
||||
fi
|
||||
/home/$Username/node/bin/node cli.js install-deps webkit
|
||||
sudo -u $Username PLAYWRIGHT_SKIP_BROWSER_GC=1 /home/$Username/node/bin/node cli.js install webkit
|
||||
"@ -replace "\r\n", "`n"
|
||||
|
||||
wsl -d $Distribution --cd $playwrightCoreRoot -u root -- bash -c "$initScript"
|
||||
Write-Host "Done!"
|
||||
Generated
Vendored
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old beta if any.
|
||||
if dpkg --get-selections | grep -q "^google-chrome-beta[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y google-chrome-beta
|
||||
fi
|
||||
|
||||
# 2. Update apt lists (needed to install curl and chrome dependencies)
|
||||
apt-get update
|
||||
|
||||
# 3. Install curl to download chrome
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# 4. download chrome beta from dl.google.com and install it.
|
||||
cd /tmp
|
||||
curl -L -O https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb
|
||||
apt-get install -y ./google-chrome-beta_current_amd64.deb
|
||||
rm -rf ./google-chrome-beta_current_amd64.deb
|
||||
cd -
|
||||
google-chrome-beta --version
|
||||
Generated
Vendored
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
rm -rf "/Applications/Google Chrome Beta.app"
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./googlechromebeta.dmg https://dl.google.com/chrome/mac/universal/beta/googlechromebeta.dmg
|
||||
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechromebeta.dmg ./googlechromebeta.dmg
|
||||
cp -pR "/Volumes/googlechromebeta.dmg/Google Chrome Beta.app" /Applications
|
||||
hdiutil detach /Volumes/googlechromebeta.dmg
|
||||
rm -rf /tmp/googlechromebeta.dmg
|
||||
|
||||
/Applications/Google\ Chrome\ Beta.app/Contents/MacOS/Google\ Chrome\ Beta --version
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$url = 'https://dl.google.com/tag/s/dl/chrome/install/beta/googlechromebetastandaloneenterprise64.msi'
|
||||
|
||||
Write-Host "Downloading Google Chrome Beta"
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\google-chrome-beta.msi"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Google Chrome Beta"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
$suffix = "\\Google\\Chrome Beta\\Application\\chrome.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Google Chrome Beta."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
Generated
Vendored
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old stable if any.
|
||||
if dpkg --get-selections | grep -q "^google-chrome[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y google-chrome
|
||||
fi
|
||||
|
||||
# 2. Update apt lists (needed to install curl and chrome dependencies)
|
||||
apt-get update
|
||||
|
||||
# 3. Install curl to download chrome
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# 4. download chrome stable from dl.google.com and install it.
|
||||
cd /tmp
|
||||
curl -L -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
apt-get install -y ./google-chrome-stable_current_amd64.deb
|
||||
rm -rf ./google-chrome-stable_current_amd64.deb
|
||||
cd -
|
||||
google-chrome --version
|
||||
Generated
Vendored
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
rm -rf "/Applications/Google Chrome.app"
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./googlechrome.dmg https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg
|
||||
hdiutil attach -nobrowse -quiet -noautofsck -noautoopen -mountpoint /Volumes/googlechrome.dmg ./googlechrome.dmg
|
||||
cp -pR "/Volumes/googlechrome.dmg/Google Chrome.app" /Applications
|
||||
hdiutil detach /Volumes/googlechrome.dmg
|
||||
rm -rf /tmp/googlechrome.dmg
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$url = 'https://dl.google.com/tag/s/dl/chrome/install/googlechromestandaloneenterprise64.msi'
|
||||
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\google-chrome.msi"
|
||||
Write-Host "Downloading Google Chrome"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Google Chrome"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
|
||||
$suffix = "\\Google\\Chrome\\Application\\chrome.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Google Chrome."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
Generated
Vendored
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old beta if any.
|
||||
if dpkg --get-selections | grep -q "^microsoft-edge-beta[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y microsoft-edge-beta
|
||||
fi
|
||||
|
||||
# 2. Install curl to download Microsoft gpg key
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# GnuPG is not preinstalled in slim images
|
||||
if ! command -v gpg >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y gpg
|
||||
fi
|
||||
|
||||
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
|
||||
rm /tmp/microsoft.gpg
|
||||
apt-get update && apt-get install -y microsoft-edge-beta
|
||||
|
||||
microsoft-edge-beta --version
|
||||
Generated
Vendored
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./msedge_beta.pkg "$1"
|
||||
# Note: there's no way to uninstall previously installed MSEdge.
|
||||
# However, running PKG again seems to update installation.
|
||||
sudo installer -pkg /tmp/msedge_beta.pkg -target /
|
||||
rm -rf /tmp/msedge_beta.pkg
|
||||
/Applications/Microsoft\ Edge\ Beta.app/Contents/MacOS/Microsoft\ Edge\ Beta --version
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$url = $args[0]
|
||||
|
||||
Write-Host "Downloading Microsoft Edge Beta"
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\microsoft-edge-beta.msi"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Microsoft Edge Beta"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
$suffix = "\\Microsoft\\Edge Beta\\Application\\msedge.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Microsoft Edge Beta."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
Generated
Vendored
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old dev if any.
|
||||
if dpkg --get-selections | grep -q "^microsoft-edge-dev[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y microsoft-edge-dev
|
||||
fi
|
||||
|
||||
# 2. Install curl to download Microsoft gpg key
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# GnuPG is not preinstalled in slim images
|
||||
if ! command -v gpg >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y gpg
|
||||
fi
|
||||
|
||||
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-dev.list'
|
||||
rm /tmp/microsoft.gpg
|
||||
apt-get update && apt-get install -y microsoft-edge-dev
|
||||
|
||||
microsoft-edge-dev --version
|
||||
Generated
Vendored
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./msedge_dev.pkg "$1"
|
||||
# Note: there's no way to uninstall previously installed MSEdge.
|
||||
# However, running PKG again seems to update installation.
|
||||
sudo installer -pkg /tmp/msedge_dev.pkg -target /
|
||||
rm -rf /tmp/msedge_dev.pkg
|
||||
/Applications/Microsoft\ Edge\ Dev.app/Contents/MacOS/Microsoft\ Edge\ Dev --version
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$url = $args[0]
|
||||
|
||||
Write-Host "Downloading Microsoft Edge Dev"
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\microsoft-edge-dev.msi"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Microsoft Edge Dev"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
$suffix = "\\Microsoft\\Edge Dev\\Application\\msedge.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Microsoft Edge Dev."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
Generated
Vendored
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
if [[ $(arch) == "aarch64" ]]; then
|
||||
echo "ERROR: not supported on Linux Arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PLAYWRIGHT_HOST_PLATFORM_OVERRIDE" ]; then
|
||||
if [[ ! -f "/etc/os-release" ]]; then
|
||||
echo "ERROR: cannot install on unknown linux distribution (/etc/os-release is missing)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ID=$(bash -c 'source /etc/os-release && echo $ID')
|
||||
if [[ "${ID}" != "ubuntu" && "${ID}" != "debian" ]]; then
|
||||
echo "ERROR: cannot install on $ID distribution - only Ubuntu and Debian are supported"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. make sure to remove old stable if any.
|
||||
if dpkg --get-selections | grep -q "^microsoft-edge-stable[[:space:]]*install$" >/dev/null; then
|
||||
apt-get remove -y microsoft-edge-stable
|
||||
fi
|
||||
|
||||
# 2. Install curl to download Microsoft gpg key
|
||||
if ! command -v curl >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y curl
|
||||
fi
|
||||
|
||||
# GnuPG is not preinstalled in slim images
|
||||
if ! command -v gpg >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y gpg
|
||||
fi
|
||||
|
||||
# 3. Add the GPG key, the apt repo, update the apt cache, and install the package
|
||||
curl -L https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /tmp/microsoft.gpg
|
||||
install -o root -g root -m 644 /tmp/microsoft.gpg /etc/apt/trusted.gpg.d/
|
||||
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/edge stable main" > /etc/apt/sources.list.d/microsoft-edge-stable.list'
|
||||
rm /tmp/microsoft.gpg
|
||||
apt-get update && apt-get install -y microsoft-edge-stable
|
||||
|
||||
microsoft-edge-stable --version
|
||||
Generated
Vendored
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
cd /tmp
|
||||
curl -L --retry 3 -o ./msedge_stable.pkg "$1"
|
||||
# Note: there's no way to uninstall previously installed MSEdge.
|
||||
# However, running PKG again seems to update installation.
|
||||
sudo installer -pkg /tmp/msedge_stable.pkg -target /
|
||||
rm -rf /tmp/msedge_stable.pkg
|
||||
/Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --version
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$url = $args[0]
|
||||
|
||||
Write-Host "Downloading Microsoft Edge"
|
||||
$wc = New-Object net.webclient
|
||||
$msiInstaller = "$env:temp\microsoft-edge-stable.msi"
|
||||
$wc.Downloadfile($url, $msiInstaller)
|
||||
|
||||
Write-Host "Installing Microsoft Edge"
|
||||
$arguments = "/i `"$msiInstaller`" /quiet"
|
||||
Start-Process msiexec.exe -ArgumentList $arguments -Wait
|
||||
Remove-Item $msiInstaller
|
||||
|
||||
$suffix = "\\Microsoft\\Edge\\Application\\msedge.exe"
|
||||
if (Test-Path "${env:ProgramFiles(x86)}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles(x86)}$suffix").VersionInfo
|
||||
} elseif (Test-Path "${env:ProgramFiles}$suffix") {
|
||||
(Get-Item "${env:ProgramFiles}$suffix").VersionInfo
|
||||
} else {
|
||||
Write-Host "ERROR: Failed to install Microsoft Edge."
|
||||
Write-Host "ERROR: This could be due to insufficient privileges, in which case re-running as Administrator may help."
|
||||
exit 1
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"comment": "Do not edit this file, use utils/roll_browser.js",
|
||||
"browsers": [
|
||||
{
|
||||
"name": "chromium",
|
||||
"revision": "1228",
|
||||
"installByDefault": true,
|
||||
"browserVersion": "149.0.7827.55",
|
||||
"title": "Chrome for Testing"
|
||||
},
|
||||
{
|
||||
"name": "chromium-headless-shell",
|
||||
"revision": "1228",
|
||||
"installByDefault": true,
|
||||
"browserVersion": "149.0.7827.55",
|
||||
"title": "Chrome Headless Shell"
|
||||
},
|
||||
{
|
||||
"name": "chromium-tip-of-tree",
|
||||
"revision": "1432",
|
||||
"installByDefault": false,
|
||||
"browserVersion": "151.0.7886.0",
|
||||
"title": "Chrome Canary for Testing"
|
||||
},
|
||||
{
|
||||
"name": "chromium-tip-of-tree-headless-shell",
|
||||
"revision": "1432",
|
||||
"installByDefault": false,
|
||||
"browserVersion": "151.0.7886.0",
|
||||
"title": "Chrome Canary Headless Shell"
|
||||
},
|
||||
{
|
||||
"name": "firefox",
|
||||
"revision": "1532",
|
||||
"installByDefault": true,
|
||||
"browserVersion": "151.0",
|
||||
"title": "Firefox"
|
||||
},
|
||||
{
|
||||
"name": "firefox-beta",
|
||||
"revision": "1526",
|
||||
"installByDefault": false,
|
||||
"browserVersion": "152.0b1",
|
||||
"title": "Firefox Beta"
|
||||
},
|
||||
{
|
||||
"name": "webkit",
|
||||
"revision": "2311",
|
||||
"installByDefault": true,
|
||||
"revisionOverrides": {
|
||||
"mac14": "2251",
|
||||
"mac14-arm64": "2251",
|
||||
"debian11-x64": "2105",
|
||||
"debian11-arm64": "2105",
|
||||
"ubuntu20.04-x64": "2092",
|
||||
"ubuntu20.04-arm64": "2092"
|
||||
},
|
||||
"browserVersion": "26.5",
|
||||
"title": "WebKit"
|
||||
},
|
||||
{
|
||||
"name": "ffmpeg",
|
||||
"revision": "1011",
|
||||
"installByDefault": true,
|
||||
"revisionOverrides": {
|
||||
"mac12": "1010",
|
||||
"mac12-arm64": "1010"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "winldd",
|
||||
"revision": "1007",
|
||||
"installByDefault": false
|
||||
},
|
||||
{
|
||||
"name": "android",
|
||||
"revision": "1001",
|
||||
"installByDefault": false
|
||||
}
|
||||
]
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
const { libCli, libCliTestStub } = require('./lib/coreBundle');
|
||||
const { program } = require('./lib/utilsBundle');
|
||||
libCli.decorateProgram(program);
|
||||
libCliTestStub.decorateProgram(program);
|
||||
program.parse(process.argv);
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './types/types';
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
require('./lib/bootstrap');
|
||||
module.exports = require('./lib/coreBundle').inprocess.playwright;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import playwright from './index.js';
|
||||
|
||||
export const chromium = playwright.chromium;
|
||||
export const firefox = playwright.firefox;
|
||||
export const webkit = playwright.webkit;
|
||||
export const selectors = playwright.selectors;
|
||||
export const devices = playwright.devices;
|
||||
export const errors = playwright.errors;
|
||||
export const request = playwright.request;
|
||||
export const _electron = playwright._electron;
|
||||
export const _android = playwright._android;
|
||||
export default playwright;
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"use strict";
|
||||
const minimumMajorNodeVersion = 18;
|
||||
const currentNodeVersion = process.versions.node;
|
||||
const major = +currentNodeVersion.split(".")[0];
|
||||
if (major < minimumMajorNodeVersion) {
|
||||
console.error(
|
||||
"You are running Node.js " + currentNodeVersion + `.
|
||||
Playwright requires Node.js ${minimumMajorNodeVersion} or higher.
|
||||
Please update your version of Node.js.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.env.PW_INSTRUMENT_MODULES) {
|
||||
const Module = require("module");
|
||||
const originalLoad = Module._load;
|
||||
const root = { name: "<root>", selfMs: 0, totalMs: 0, childrenMs: 0, children: [] };
|
||||
let current = root;
|
||||
const stack = [];
|
||||
Module._load = function(request, _parent, _isMain) {
|
||||
const node = { name: request, selfMs: 0, totalMs: 0, childrenMs: 0, children: [] };
|
||||
current.children.push(node);
|
||||
stack.push(current);
|
||||
current = node;
|
||||
const start = performance.now();
|
||||
let result;
|
||||
try {
|
||||
result = originalLoad.apply(this, arguments);
|
||||
} catch (e) {
|
||||
current = stack.pop();
|
||||
current.children.pop();
|
||||
throw e;
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
node.totalMs = duration;
|
||||
node.selfMs = Math.max(0, duration - node.childrenMs);
|
||||
current = stack.pop();
|
||||
current.childrenMs += duration;
|
||||
return result;
|
||||
};
|
||||
process.on("exit", () => {
|
||||
function printTree(node, prefix, isLast, lines2, depth) {
|
||||
if (node.totalMs < 1 && depth > 0)
|
||||
return;
|
||||
const connector = depth === 0 ? "" : isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
|
||||
const time = `${node.totalMs.toFixed(1).padStart(8)}ms`;
|
||||
const self = node.children.length ? ` (self: ${node.selfMs.toFixed(1)}ms)` : "";
|
||||
lines2.push(`${time} ${prefix}${connector}${node.name}${self}`);
|
||||
const childPrefix = prefix + (depth === 0 ? "" : isLast ? " " : "\u2502 ");
|
||||
const sorted2 = node.children.slice().sort((a, b) => b.totalMs - a.totalMs);
|
||||
for (let i = 0; i < sorted2.length; i++)
|
||||
printTree(sorted2[i], childPrefix, i === sorted2.length - 1, lines2, depth + 1);
|
||||
}
|
||||
let totalModules = 0;
|
||||
function count(n) {
|
||||
totalModules++;
|
||||
n.children.forEach(count);
|
||||
}
|
||||
root.children.forEach(count);
|
||||
const lines = [];
|
||||
const sorted = root.children.slice().sort((a, b) => b.totalMs - a.totalMs);
|
||||
for (let i = 0; i < sorted.length; i++)
|
||||
printTree(sorted[i], "", i === sorted.length - 1, lines, 0);
|
||||
const totalMs = root.children.reduce((s, c) => s + c.totalMs, 0);
|
||||
process.stderr.write(`
|
||||
--- Module load tree: ${totalModules} modules, ${totalMs.toFixed(0)}ms total ---
|
||||
` + lines.join("\n") + "\n");
|
||||
const flat = /* @__PURE__ */ new Map();
|
||||
function gather(n) {
|
||||
const existing = flat.get(n.name);
|
||||
if (existing) {
|
||||
existing.selfMs += n.selfMs;
|
||||
existing.totalMs += n.totalMs;
|
||||
existing.count++;
|
||||
} else {
|
||||
flat.set(n.name, { selfMs: n.selfMs, totalMs: n.totalMs, count: 1 });
|
||||
}
|
||||
n.children.forEach(gather);
|
||||
}
|
||||
root.children.forEach(gather);
|
||||
const top50 = [...flat.entries()].sort((a, b) => b[1].selfMs - a[1].selfMs).slice(0, 50);
|
||||
const flatLines = top50.map(
|
||||
([mod, { selfMs, totalMs: totalMs2, count: count2 }]) => `${selfMs.toFixed(1).padStart(8)}ms self ${totalMs2.toFixed(1).padStart(8)}ms total (x${String(count2).padStart(3)}) ${mod}`
|
||||
);
|
||||
process.stderr.write(`
|
||||
--- Top 50 modules by self time ---
|
||||
` + flatLines.join("\n") + "\n");
|
||||
});
|
||||
}
|
||||
+73386
File diff suppressed because one or more lines are too long
+5
@@ -0,0 +1,5 @@
|
||||
"use strict";
|
||||
var import_coreBundle = require("../coreBundle");
|
||||
const { program } = require("../utilsBundle");
|
||||
import_coreBundle.tools.decorateCliDaemonProgram(program);
|
||||
void program.parseAsync();
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
var import_coreBundle = require("../coreBundle");
|
||||
import_coreBundle.tools.openDashboardApp();
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
"use strict";
|
||||
var import_coreBundle = require("../coreBundle");
|
||||
var import_package = require("../package");
|
||||
const { program } = require("../utilsBundle");
|
||||
const p = program.version("Version " + import_package.packageJSON.version).name("Playwright MCP");
|
||||
import_coreBundle.tools.decorateMCPCommand(p);
|
||||
program.parseAsync(process.argv).catch((e) => {
|
||||
console.error(e.message);
|
||||
import_coreBundle.utils.gracefullyProcessExitDoNotHang(1);
|
||||
});
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
var import_coreBundle = require("../coreBundle");
|
||||
import_coreBundle.registry.runOopDownloadBrowserMain();
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var package_exports = {};
|
||||
__export(package_exports, {
|
||||
binPath: () => binPath,
|
||||
libPath: () => libPath,
|
||||
packageJSON: () => packageJSON,
|
||||
packageRoot: () => packageRoot
|
||||
});
|
||||
module.exports = __toCommonJS(package_exports);
|
||||
var import_path = __toESM(require("path"));
|
||||
const packageRoot = import_path.default.join(__dirname, "..");
|
||||
const packageJSON = require(import_path.default.join(packageRoot, "package.json"));
|
||||
const binPath = import_path.default.join(packageRoot, "bin");
|
||||
function libPath(...parts) {
|
||||
return import_path.default.join(packageRoot, "lib", ...parts);
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
binPath,
|
||||
libPath,
|
||||
packageJSON,
|
||||
packageRoot
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Generated
Vendored
+2739
File diff suppressed because it is too large
Load Diff
+115
@@ -0,0 +1,115 @@
|
||||
"use strict";
|
||||
|
||||
// packages/playwright-core/src/server/electron/loader.ts
|
||||
var import_electron = require("electron");
|
||||
|
||||
// packages/playwright-core/src/server/chromium/chromiumSwitches.ts
|
||||
var disabledFeatures = [
|
||||
// See https://github.com/microsoft/playwright/issues/14047
|
||||
"AvoidUnnecessaryBeforeUnloadCheckSync",
|
||||
// See https://github.com/microsoft/playwright/issues/38568
|
||||
"BoundaryEventDispatchTracksNodeRemoval",
|
||||
"DestroyProfileOnBrowserClose",
|
||||
// See https://github.com/microsoft/playwright/pull/13854
|
||||
"DialMediaRouteProvider",
|
||||
"GlobalMediaControls",
|
||||
// See https://github.com/microsoft/playwright/pull/27605
|
||||
"HttpsUpgrades",
|
||||
// Hides the Lens feature in the URL address bar. Its not working in unofficial builds.
|
||||
"LensOverlay",
|
||||
// See https://github.com/microsoft/playwright/pull/8162
|
||||
"MediaRouter",
|
||||
// See https://github.com/microsoft/playwright/issues/28023
|
||||
"PaintHolding",
|
||||
// See https://github.com/microsoft/playwright/issues/32230
|
||||
"ThirdPartyStoragePartitioning",
|
||||
// See https://github.com/microsoft/playwright/issues/16126
|
||||
"Translate",
|
||||
// See https://issues.chromium.org/u/1/issues/435410220
|
||||
"AutoDeElevate",
|
||||
// See https://github.com/microsoft/playwright/issues/37714
|
||||
"RenderDocument",
|
||||
// Prevents downloading optimization hints on startup.
|
||||
"OptimizationHints",
|
||||
// Disables forced sign-in in Edge.
|
||||
"msForceBrowserSignIn",
|
||||
// Disables updating the preferred version in LaunchServices preferences on mac.
|
||||
"msEdgeUpdateLaunchServicesPreferredVersion"
|
||||
].filter(Boolean);
|
||||
var chromiumSwitches = (options) => [
|
||||
"--disable-field-trial-config",
|
||||
// https://source.chromium.org/chromium/chromium/src/+/main:testing/variations/README.md
|
||||
"--disable-background-networking",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-back-forward-cache",
|
||||
// Avoids surprises like main request not being intercepted during page.goBack().
|
||||
"--disable-breakpad",
|
||||
"--disable-client-side-phishing-detection",
|
||||
"--disable-component-extensions-with-background-pages",
|
||||
"--disable-component-update",
|
||||
// Avoids unneeded network activity after startup.
|
||||
"--no-default-browser-check",
|
||||
"--disable-default-apps",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-edgeupdater",
|
||||
// Disables Edge-specific updater on mac.
|
||||
"--disable-extensions",
|
||||
"--disable-features=" + disabledFeatures.join(","),
|
||||
process.env.PLAYWRIGHT_LEGACY_SCREENSHOT ? "" : "--enable-features=CDPScreenshotNewSurface",
|
||||
"--allow-pre-commit-input",
|
||||
"--disable-hang-monitor",
|
||||
"--disable-ipc-flooding-protection",
|
||||
"--disable-popup-blocking",
|
||||
"--disable-prompt-on-repost",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--force-color-profile=srgb",
|
||||
"--metrics-recording-only",
|
||||
"--no-first-run",
|
||||
"--password-store=basic",
|
||||
"--use-mock-keychain",
|
||||
// See https://chromium-review.googlesource.com/c/chromium/src/+/2436773
|
||||
"--no-service-autorun",
|
||||
"--export-tagged-pdf",
|
||||
// https://chromium-review.googlesource.com/c/chromium/src/+/4853540
|
||||
"--disable-search-engine-choice-screen",
|
||||
// https://issues.chromium.org/41491762
|
||||
"--unsafely-disable-devtools-self-xss-warnings",
|
||||
// Edge can potentially restart on Windows (msRelaunchNoCompatLayer) which looses its file descriptors (stdout/stderr) and CDP (3/4). Disable until fixed upstream.
|
||||
"--edge-skip-compat-layer-relaunch",
|
||||
// This disables Chrome for Testing infobar that is visible in the persistent context.
|
||||
// The switch is ignored everywhere else, including Chromium/Chrome/Edge.
|
||||
"--disable-infobars",
|
||||
// Less annoying popups.
|
||||
"--disable-search-engine-choice-screen",
|
||||
// Prevents the "three dots" menu crash in IdentityManager::HasPrimaryAccount for ephemeral contexts.
|
||||
options?.android ? "" : "--disable-sync"
|
||||
].filter(Boolean);
|
||||
|
||||
// packages/playwright-core/src/server/electron/loader.ts
|
||||
process.argv.splice(1, process.argv.indexOf("--remote-debugging-port=0"));
|
||||
for (const arg of chromiumSwitches()) {
|
||||
const match = arg.match(/--([^=]*)=?(.*)/);
|
||||
import_electron.app.commandLine.appendSwitch(match[1], match[2]);
|
||||
}
|
||||
var originalWhenReady = import_electron.app.whenReady();
|
||||
var originalEmit = import_electron.app.emit.bind(import_electron.app);
|
||||
var readyEventArgs;
|
||||
import_electron.app.emit = (event, ...args) => {
|
||||
if (event === "ready") {
|
||||
readyEventArgs = args;
|
||||
return import_electron.app.listenerCount("ready") > 0;
|
||||
}
|
||||
return originalEmit(event, ...args);
|
||||
};
|
||||
var isReady = false;
|
||||
var whenReadyCallback;
|
||||
var whenReadyPromise = new Promise((f) => whenReadyCallback = f);
|
||||
import_electron.app.isReady = () => isReady;
|
||||
import_electron.app.whenReady = () => whenReadyPromise;
|
||||
globalThis.__playwright_run = async () => {
|
||||
const event = await originalWhenReady;
|
||||
isReady = true;
|
||||
whenReadyCallback(event);
|
||||
originalEmit("ready", ...readyEventArgs);
|
||||
};
|
||||
+7343
File diff suppressed because it is too large
Load Diff
+354
@@ -0,0 +1,354 @@
|
||||
packages/playwright-core/lib/serverRegistry.js
|
||||
|
||||
THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
|
||||
|
||||
The following npm packages are inlined into this bundle.
|
||||
|
||||
- anymatch@3.1.3 (https://github.com/micromatch/anymatch)
|
||||
- binary-extensions@2.3.0 (https://github.com/sindresorhus/binary-extensions)
|
||||
- braces@3.0.3 (https://github.com/micromatch/braces)
|
||||
- chokidar@3.6.0 (https://github.com/paulmillr/chokidar)
|
||||
- fill-range@7.1.1 (https://github.com/jonschlinkert/fill-range)
|
||||
- glob-parent@5.1.2 (https://github.com/gulpjs/glob-parent)
|
||||
- is-binary-path@2.1.0 (https://github.com/sindresorhus/is-binary-path)
|
||||
- is-extglob@2.1.1 (https://github.com/jonschlinkert/is-extglob)
|
||||
- is-glob@4.0.3 (https://github.com/micromatch/is-glob)
|
||||
- is-number@7.0.0 (https://github.com/jonschlinkert/is-number)
|
||||
- normalize-path@3.0.0 (https://github.com/jonschlinkert/normalize-path)
|
||||
- picomatch@2.3.2 (https://github.com/micromatch/picomatch)
|
||||
- readdirp@3.6.0 (https://github.com/paulmillr/readdirp)
|
||||
- to-regex-range@5.0.1 (https://github.com/micromatch/to-regex-range)
|
||||
|
||||
%% anymatch@3.1.3 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The ISC License
|
||||
|
||||
Copyright (c) 2019 Elan Shanker, Paul Miller (https://paulmillr.com)
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
=========================================
|
||||
END OF anymatch@3.1.3 NOTICES AND INFORMATION
|
||||
|
||||
%% binary-extensions@2.3.0 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
MIT License
|
||||
|
||||
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||
Copyright (c) Paul Miller (https://paulmillr.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
=========================================
|
||||
END OF binary-extensions@2.3.0 NOTICES AND INFORMATION
|
||||
|
||||
%% braces@3.0.3 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-present, Jon Schlinkert.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
=========================================
|
||||
END OF braces@3.0.3 NOTICES AND INFORMATION
|
||||
|
||||
%% chokidar@3.6.0 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2012-2019 Paul Miller (https://paulmillr.com), Elan Shanker
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
=========================================
|
||||
END OF chokidar@3.6.0 NOTICES AND INFORMATION
|
||||
|
||||
%% fill-range@7.1.1 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-present, Jon Schlinkert.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
=========================================
|
||||
END OF fill-range@7.1.1 NOTICES AND INFORMATION
|
||||
|
||||
%% glob-parent@5.1.2 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The ISC License
|
||||
|
||||
Copyright (c) 2015, 2019 Elan Shanker
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
=========================================
|
||||
END OF glob-parent@5.1.2 NOTICES AND INFORMATION
|
||||
|
||||
%% is-binary-path@2.1.0 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com), Paul Miller (https://paulmillr.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
=========================================
|
||||
END OF is-binary-path@2.1.0 NOTICES AND INFORMATION
|
||||
|
||||
%% is-extglob@2.1.1 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2016, Jon Schlinkert
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
=========================================
|
||||
END OF is-extglob@2.1.1 NOTICES AND INFORMATION
|
||||
|
||||
%% is-glob@4.0.3 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2017, Jon Schlinkert.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
=========================================
|
||||
END OF is-glob@4.0.3 NOTICES AND INFORMATION
|
||||
|
||||
%% is-number@7.0.0 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-present, Jon Schlinkert.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
=========================================
|
||||
END OF is-number@7.0.0 NOTICES AND INFORMATION
|
||||
|
||||
%% normalize-path@3.0.0 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2018, Jon Schlinkert.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
=========================================
|
||||
END OF normalize-path@3.0.0 NOTICES AND INFORMATION
|
||||
|
||||
%% picomatch@2.3.2 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017-present, Jon Schlinkert.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
=========================================
|
||||
END OF picomatch@2.3.2 NOTICES AND INFORMATION
|
||||
|
||||
%% readdirp@3.6.0 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2012-2019 Thorsten Lorenz, Paul Miller (https://paulmillr.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
=========================================
|
||||
END OF readdirp@3.6.0 NOTICES AND INFORMATION
|
||||
|
||||
%% to-regex-range@5.0.1 NOTICES AND INFORMATION BEGIN HERE
|
||||
=========================================
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present, Jon Schlinkert.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
=========================================
|
||||
END OF to-regex-range@5.0.1 NOTICES AND INFORMATION
|
||||
|
||||
SUMMARY
|
||||
=========================================
|
||||
Total Packages: 14
|
||||
=========================================
|
||||
Generated
Vendored
+141
@@ -0,0 +1,141 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var channelSessions_exports = {};
|
||||
__export(channelSessions_exports, {
|
||||
isKnownChannel: () => isKnownChannel,
|
||||
listChannelSessions: () => listChannelSessions
|
||||
});
|
||||
module.exports = __toCommonJS(channelSessions_exports);
|
||||
var import_fs = __toESM(require("fs"));
|
||||
var import_net = __toESM(require("net"));
|
||||
var import_os = __toESM(require("os"));
|
||||
var import_path = __toESM(require("path"));
|
||||
var import_extension = require("../utils/extension");
|
||||
function isKnownChannel(name) {
|
||||
return channelToUserDataDir.has(name);
|
||||
}
|
||||
async function listChannelSessions() {
|
||||
if (process.env.PWTEST_CLI_CHANNEL_SCAN_DISABLED_FOR_TEST)
|
||||
return [];
|
||||
const result = [];
|
||||
for (const [channel, dirs] of channelToUserDataDir) {
|
||||
const userDataDir = dirs[process.platform];
|
||||
if (!userDataDir)
|
||||
continue;
|
||||
if (!await pathExists(userDataDir))
|
||||
continue;
|
||||
const [endpoint, extensionInstalled] = await Promise.all([
|
||||
readEndpoint(userDataDir),
|
||||
(0, import_extension.isPlaywrightExtensionInstalled)(userDataDir)
|
||||
]);
|
||||
result.push({ channel, userDataDir, endpoint, extensionInstalled });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function pathExists(p) {
|
||||
try {
|
||||
await import_fs.default.promises.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function readEndpoint(userDataDir) {
|
||||
let contents;
|
||||
try {
|
||||
contents = await import_fs.default.promises.readFile(import_path.default.join(userDataDir, "DevToolsActivePort"), "utf-8");
|
||||
} catch {
|
||||
return void 0;
|
||||
}
|
||||
const port = parseInt(contents.trim().split("\n")[0], 10);
|
||||
if (!Number.isFinite(port))
|
||||
return void 0;
|
||||
if (!await isPortOpen(port))
|
||||
return void 0;
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
async function isPortOpen(port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = import_net.default.createConnection(port, "127.0.0.1");
|
||||
const done = (value) => {
|
||||
socket.destroy();
|
||||
resolve(value);
|
||||
};
|
||||
socket.once("connect", () => done(true));
|
||||
socket.once("error", () => done(false));
|
||||
socket.setTimeout(250, () => done(false));
|
||||
});
|
||||
}
|
||||
const channelToUserDataDir = /* @__PURE__ */ new Map([
|
||||
["chrome", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome", "User Data")
|
||||
}],
|
||||
["chrome-beta", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome-beta"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome Beta", "User Data")
|
||||
}],
|
||||
["chrome-dev", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome-unstable"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome Dev", "User Data")
|
||||
}],
|
||||
["chrome-canary", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome-canary"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome SxS", "User Data")
|
||||
}],
|
||||
["msedge", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge", "User Data")
|
||||
}],
|
||||
["msedge-beta", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge-beta"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge Beta"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge Beta", "User Data")
|
||||
}],
|
||||
["msedge-dev", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge-dev"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge Dev"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge Dev", "User Data")
|
||||
}],
|
||||
["msedge-canary", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge-canary"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge Canary"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge SxS", "User Data")
|
||||
}]
|
||||
]);
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
isKnownChannel,
|
||||
listChannelSessions
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
"use strict";
|
||||
var import_program = require("./program");
|
||||
(0, import_program.program)().catch((e) => {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+693
File diff suppressed because one or more lines are too long
+128
@@ -0,0 +1,128 @@
|
||||
"use strict";
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var minimist_exports = {};
|
||||
__export(minimist_exports, {
|
||||
minimist: () => minimist
|
||||
});
|
||||
module.exports = __toCommonJS(minimist_exports);
|
||||
function minimist(args, opts) {
|
||||
if (!opts)
|
||||
opts = {};
|
||||
const bools = {};
|
||||
const strings = {};
|
||||
for (const key of toArray(opts.boolean))
|
||||
bools[key] = true;
|
||||
for (const key of toArray(opts.string))
|
||||
strings[key] = true;
|
||||
const argv = { _: [] };
|
||||
function setArg(key, val) {
|
||||
if (argv[key] === void 0 || bools[key] || typeof argv[key] === "boolean")
|
||||
argv[key] = val;
|
||||
else if (Array.isArray(argv[key]))
|
||||
argv[key].push(val);
|
||||
else
|
||||
argv[key] = [argv[key], val];
|
||||
}
|
||||
let notFlags = [];
|
||||
const doubleDashIndex = args.indexOf("--");
|
||||
if (doubleDashIndex !== -1) {
|
||||
notFlags = args.slice(doubleDashIndex + 1);
|
||||
args = args.slice(0, doubleDashIndex);
|
||||
}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
let key;
|
||||
let next;
|
||||
if (/^--.+=/.test(arg)) {
|
||||
const m = arg.match(/^--([^=]+)=([\s\S]*)$/);
|
||||
key = m[1];
|
||||
if (bools[key])
|
||||
throw new Error(`boolean option '--${key}' should not be passed with '=value', use '--${key}' or '--no-${key}' instead`);
|
||||
setArg(key, m[2]);
|
||||
} else if (/^--no-.+/.test(arg)) {
|
||||
key = arg.match(/^--no-(.+)/)[1];
|
||||
setArg(key, false);
|
||||
} else if (/^--.+/.test(arg)) {
|
||||
key = arg.match(/^--(.+)/)[1];
|
||||
next = args[i + 1];
|
||||
if (next !== void 0 && !/^(-|--)[^-]/.test(next) && !bools[key]) {
|
||||
setArg(key, next);
|
||||
i += 1;
|
||||
} else if (/^(true|false)$/.test(next)) {
|
||||
setArg(key, next === "true");
|
||||
i += 1;
|
||||
} else {
|
||||
setArg(key, strings[key] ? "" : true);
|
||||
}
|
||||
} else if (/^-[^-]+/.test(arg)) {
|
||||
const letters = arg.slice(1, -1).split("");
|
||||
let broken = false;
|
||||
for (let j = 0; j < letters.length; j++) {
|
||||
next = arg.slice(j + 2);
|
||||
if (next === "-") {
|
||||
setArg(letters[j], next);
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z]/.test(letters[j]) && next[0] === "=") {
|
||||
setArg(letters[j], next.slice(1));
|
||||
broken = true;
|
||||
break;
|
||||
}
|
||||
if (/[A-Za-z]/.test(letters[j]) && /-?\d+(\.\d*)?(e-?\d+)?$/.test(next)) {
|
||||
setArg(letters[j], next);
|
||||
broken = true;
|
||||
break;
|
||||
}
|
||||
if (letters[j + 1] && letters[j + 1].match(/\W/)) {
|
||||
setArg(letters[j], arg.slice(j + 2));
|
||||
broken = true;
|
||||
break;
|
||||
} else {
|
||||
setArg(letters[j], strings[letters[j]] ? "" : true);
|
||||
}
|
||||
}
|
||||
key = arg.slice(-1)[0];
|
||||
if (!broken && key !== "-") {
|
||||
if (args[i + 1] && !/^(-|--)[^-]/.test(args[i + 1]) && !bools[key]) {
|
||||
setArg(key, args[i + 1]);
|
||||
i += 1;
|
||||
} else if (args[i + 1] && /^(true|false)$/.test(args[i + 1])) {
|
||||
setArg(key, args[i + 1] === "true");
|
||||
i += 1;
|
||||
} else {
|
||||
setArg(key, strings[key] ? "" : true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
argv._.push(arg);
|
||||
}
|
||||
}
|
||||
for (const k of notFlags)
|
||||
argv._.push(k);
|
||||
return argv;
|
||||
}
|
||||
function toArray(value) {
|
||||
if (!value)
|
||||
return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
minimist
|
||||
});
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var output_exports = {};
|
||||
__export(output_exports, {
|
||||
JsonOutput: () => JsonOutput,
|
||||
TextOutput: () => TextOutput
|
||||
});
|
||||
module.exports = __toCommonJS(output_exports);
|
||||
var import_path = __toESM(require("path"));
|
||||
var import_extension = require("../utils/extension");
|
||||
class TextOutput {
|
||||
constructor() {
|
||||
this.json = false;
|
||||
}
|
||||
version(v) {
|
||||
console.log(v);
|
||||
}
|
||||
help(text) {
|
||||
console.log(text);
|
||||
}
|
||||
errorUnknownCommand(name, globalHelp) {
|
||||
console.error(`Unknown command: ${name}
|
||||
`);
|
||||
console.log(globalHelp);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorUnknownOption(opts, commandHelp) {
|
||||
console.error(`Unknown option${opts.length > 1 ? "s" : ""}: ${opts.map((f) => `--${f}`).join(", ")}`);
|
||||
console.log("");
|
||||
console.log(commandHelp);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorTooManyArguments(expected, received, commandHelp) {
|
||||
console.error(`error: too many arguments: expected ${expected}, received ${received}`);
|
||||
console.log("");
|
||||
console.log(commandHelp);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorAttachConflict() {
|
||||
console.error(`Error: only one of [name], --cdp, --endpoint, or --extension can be specified`);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorDetachNotAttached(session) {
|
||||
console.error(`Error: session '${session}' was not attached; use \`playwright-cli${session !== "default" ? ` -s=${session}` : ""} close\` to stop it.`);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorBrowserNotOpenForTool(session) {
|
||||
console.log(`The browser '${session}' is not open, please run open first`);
|
||||
console.log("");
|
||||
console.log(` playwright-cli${session !== "default" ? ` -s=${session}` : ""} open [params]`);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorAttachNoTarget() {
|
||||
console.error(`Error: no target specified for attach command; use one of [name], --cdp, --endpoint, or --extension to specify the target to attach to.`);
|
||||
return process.exit(1);
|
||||
}
|
||||
list({ all, browsers, servers, channelSessions }) {
|
||||
const byWorkspace = /* @__PURE__ */ new Map();
|
||||
for (const browser of browsers) {
|
||||
let list = byWorkspace.get(browser.workspace);
|
||||
if (!list) {
|
||||
list = [];
|
||||
byWorkspace.set(browser.workspace, list);
|
||||
}
|
||||
list.push(browser);
|
||||
}
|
||||
let count = 0;
|
||||
for (const [workspaceKey, list] of byWorkspace) {
|
||||
if (count === 0)
|
||||
console.log("### Browsers");
|
||||
if (all)
|
||||
console.log(`${import_path.default.relative(process.cwd(), workspaceKey) || "/"}:`);
|
||||
for (const browser of list)
|
||||
console.log(renderBrowser(browser));
|
||||
count += list.length;
|
||||
}
|
||||
if (!all) {
|
||||
if (!count)
|
||||
console.log(" (no browsers)");
|
||||
return;
|
||||
}
|
||||
if (servers?.length) {
|
||||
if (count)
|
||||
console.log("");
|
||||
console.log("### Browser servers available for attach");
|
||||
const serversByWorkspace = /* @__PURE__ */ new Map();
|
||||
for (const server of servers) {
|
||||
let list = serversByWorkspace.get(server.workspaceDir ?? "");
|
||||
if (!list) {
|
||||
list = [];
|
||||
serversByWorkspace.set(server.workspaceDir ?? "", list);
|
||||
}
|
||||
list.push(server);
|
||||
}
|
||||
for (const [workspaceKey, list] of serversByWorkspace) {
|
||||
if (workspaceKey)
|
||||
console.log(`${import_path.default.relative(process.cwd(), workspaceKey) || "/"}:`);
|
||||
for (const server of list)
|
||||
console.log(renderServer(server));
|
||||
}
|
||||
count += servers.length;
|
||||
}
|
||||
if (!count)
|
||||
console.log(" (no browsers)");
|
||||
if (channelSessions?.length) {
|
||||
console.log("");
|
||||
console.log("### Browsers available to attach via CDP");
|
||||
for (const session of channelSessions)
|
||||
console.log(renderChannelSession(session));
|
||||
}
|
||||
}
|
||||
closeAll(_sessions) {
|
||||
}
|
||||
deleteData(session, result) {
|
||||
if (!result.existed) {
|
||||
console.log(`No user data found for browser '${session}'.`);
|
||||
return;
|
||||
}
|
||||
if (result.deletedUserDataDir)
|
||||
console.log(`Deleted user data for browser '${session}'.`);
|
||||
}
|
||||
killAll(pids) {
|
||||
for (const pid of pids)
|
||||
console.log(`Killed daemon process ${pid}`);
|
||||
if (pids.length === 0)
|
||||
console.log("No daemon processes found.");
|
||||
else
|
||||
console.log(`Killed ${pids.length} daemon process${pids.length === 1 ? "" : "es"}.`);
|
||||
}
|
||||
open(session, pid, toolResult) {
|
||||
console.log(`### Browser \`${session}\` opened with pid ${pid}.`);
|
||||
if (toolResult)
|
||||
console.log(toolResult);
|
||||
}
|
||||
attach(session, pid, endpoint, toolResult) {
|
||||
if (endpoint) {
|
||||
console.log(`### Session \`${session}\` created, attached to \`${endpoint}\`.`);
|
||||
console.log(`Run commands with: playwright-cli --s=${session} <command>`);
|
||||
console.log("");
|
||||
} else {
|
||||
console.log(`### Browser \`${session}\` opened with pid ${pid}.`);
|
||||
}
|
||||
if (toolResult)
|
||||
console.log(toolResult);
|
||||
}
|
||||
close(session, wasOpen) {
|
||||
if (!wasOpen) {
|
||||
console.log(`Browser '${session}' is not open.`);
|
||||
return;
|
||||
}
|
||||
console.log(`Browser '${session}' closed
|
||||
`);
|
||||
}
|
||||
detach(session, wasAttached) {
|
||||
if (!wasAttached) {
|
||||
console.log(`Browser '${session}' is not attached.`);
|
||||
return;
|
||||
}
|
||||
console.log(`Browser '${session}' detached
|
||||
`);
|
||||
}
|
||||
installed() {
|
||||
}
|
||||
show(_session, pid) {
|
||||
if (process.env.PWTEST_PRINT_DASHBOARD_PID_FOR_TEST)
|
||||
console.log(`### Dashboard opened with pid ${pid}.`);
|
||||
}
|
||||
toolResult(text) {
|
||||
console.log(text);
|
||||
}
|
||||
installStdio() {
|
||||
return "inherit";
|
||||
}
|
||||
}
|
||||
class JsonOutput {
|
||||
constructor() {
|
||||
this.json = true;
|
||||
}
|
||||
version(v) {
|
||||
this._emit({ version: v });
|
||||
}
|
||||
help(text) {
|
||||
this._emit({ help: text });
|
||||
}
|
||||
errorUnknownCommand(name, _globalHelp) {
|
||||
this._emit({ isError: true, error: `Unknown command: ${name}` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorUnknownOption(opts, _commandHelp) {
|
||||
this._emit({ isError: true, error: `Unknown option${opts.length > 1 ? "s" : ""}: ${opts.map((f) => `--${f}`).join(", ")}` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorTooManyArguments(expected, received, _commandHelp) {
|
||||
this._emit({ isError: true, error: `error: too many arguments: expected ${expected}, received ${received}` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorAttachConflict() {
|
||||
this._emit({ isError: true, error: `only one of [name], --cdp, --endpoint, or --extension can be specified` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorDetachNotAttached(session) {
|
||||
this._emit({ isError: true, error: `session '${session}' was not attached; use close to stop it.` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorBrowserNotOpenForTool(session) {
|
||||
this._emit({ isError: true, error: `The browser '${session}' is not open, please run open first` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorAttachNoTarget() {
|
||||
this._emit({ isError: true, error: `no target specified for attach command; use one of [name], --cdp, --endpoint, or --extension to specify the target to attach to.` });
|
||||
return process.exit(1);
|
||||
}
|
||||
list({ all, browsers, servers, channelSessions }) {
|
||||
const payload = { browsers };
|
||||
if (all) {
|
||||
payload.servers = servers ?? [];
|
||||
payload.channelSessions = channelSessions ?? [];
|
||||
}
|
||||
this._emit(payload);
|
||||
}
|
||||
closeAll(sessions) {
|
||||
this._emit({ closed: sessions });
|
||||
}
|
||||
deleteData(session, result) {
|
||||
this._emit({ session, deleted: result.existed });
|
||||
}
|
||||
killAll(pids) {
|
||||
this._emit({ killed: pids.length, pids });
|
||||
}
|
||||
open(session, pid, toolResult) {
|
||||
this._emit({ session, pid, result: parseJsonText(toolResult) });
|
||||
}
|
||||
attach(session, pid, endpoint, toolResult) {
|
||||
this._emit({
|
||||
session,
|
||||
pid,
|
||||
...endpoint ? { endpoint } : {},
|
||||
result: parseJsonText(toolResult)
|
||||
});
|
||||
}
|
||||
close(session, wasOpen) {
|
||||
this._emit({ session, status: wasOpen ? "closed" : "not-open" });
|
||||
}
|
||||
detach(session, wasAttached) {
|
||||
this._emit({ session, status: wasAttached ? "detached" : "not-attached" });
|
||||
}
|
||||
installed() {
|
||||
this._emit({ installed: true });
|
||||
}
|
||||
show(session, pid) {
|
||||
this._emit({ session, pid });
|
||||
}
|
||||
toolResult(text) {
|
||||
console.log(text);
|
||||
}
|
||||
installStdio() {
|
||||
return "ignore";
|
||||
}
|
||||
_emit(value) {
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
}
|
||||
function parseJsonText(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
function renderBrowser(browser) {
|
||||
const lines = [`- ${browser.name}:`];
|
||||
lines.push(` - status: ${browser.status}`);
|
||||
if (browser.status === "open" && !browser.compatible)
|
||||
lines.push(` - version: v${browser.version} [incompatible please re-open]`);
|
||||
if (browser.browserType)
|
||||
lines.push(` - browser-type: ${browser.browserType}${browser.attached ? " (attached)" : ""}`);
|
||||
if (!browser.attached) {
|
||||
if (browser.userDataDir === null)
|
||||
lines.push(` - user-data-dir: <in-memory>`);
|
||||
else
|
||||
lines.push(` - user-data-dir: ${browser.userDataDir}`);
|
||||
if (browser.headed !== void 0)
|
||||
lines.push(` - headed: ${browser.headed}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
function renderServer(server) {
|
||||
const lines = [`- browser "${server.title}":`];
|
||||
lines.push(` - browser: ${server.browser.browserName}`);
|
||||
lines.push(` - version: v${server.playwrightVersion}`);
|
||||
if (server.browser.userDataDir)
|
||||
lines.push(` - data-dir: ${server.browser.userDataDir}`);
|
||||
else
|
||||
lines.push(` - data-dir: <in-memory>`);
|
||||
lines.push(` - run \`playwright-cli attach "${server.title}"\` to attach`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
function renderChannelSession(session) {
|
||||
const lines = [`- ${session.channel}:`];
|
||||
lines.push(` - data-dir: ${session.userDataDir}`);
|
||||
if (session.extensionInstalled)
|
||||
lines.push(` - attach (extension): \`playwright-cli attach --extension=${session.channel}\``);
|
||||
else
|
||||
lines.push(` - attach (extension): install at ${import_extension.playwrightExtensionInstallUrl}`);
|
||||
if (session.endpoint) {
|
||||
lines.push(` - attach (remote debugging): \`playwright-cli attach --cdp=${session.channel}\``);
|
||||
} else {
|
||||
const inspectScheme = session.channel.startsWith("msedge") ? "edge" : "chrome";
|
||||
lines.push(` - attach (remote debugging): enable at ${inspectScheme}://inspect/#remote-debugging`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
JsonOutput,
|
||||
TextOutput
|
||||
});
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var program_exports = {};
|
||||
__export(program_exports, {
|
||||
calculateSha1: () => calculateSha1,
|
||||
program: () => program
|
||||
});
|
||||
module.exports = __toCommonJS(program_exports);
|
||||
var import_child_process = require("child_process");
|
||||
var import_crypto = __toESM(require("crypto"));
|
||||
var import_os = __toESM(require("os"));
|
||||
var import_path = __toESM(require("path"));
|
||||
var import_channelSessions = require("./channelSessions");
|
||||
var import_output = require("./output");
|
||||
var import_registry = require("./registry");
|
||||
var import_session = require("./session");
|
||||
var import_package = require("../../package");
|
||||
var import_serverRegistry = require("../../serverRegistry");
|
||||
var import_minimist = require("./minimist");
|
||||
const globalOptions = [
|
||||
"json",
|
||||
"raw",
|
||||
"session"
|
||||
];
|
||||
const booleanOptions = [
|
||||
"all",
|
||||
"help",
|
||||
"json",
|
||||
"raw",
|
||||
"version"
|
||||
];
|
||||
async function program(options) {
|
||||
const clientInfo = (0, import_registry.createClientInfo)();
|
||||
const help = require((0, import_package.libPath)("tools", "cli-client", "help.json"));
|
||||
const argv = process.argv.slice(2);
|
||||
const boolean = [...help.booleanOptions, ...booleanOptions];
|
||||
const args = (0, import_minimist.minimist)(argv, { boolean, string: ["_"] });
|
||||
if (args.s) {
|
||||
args.session = args.s;
|
||||
delete args.s;
|
||||
}
|
||||
const output = args.json ? new import_output.JsonOutput() : new import_output.TextOutput();
|
||||
const commandName = args._?.[0];
|
||||
if (args.version || args.v) {
|
||||
output.version(options?.embedderVersion ?? clientInfo.version);
|
||||
process.exit(0);
|
||||
}
|
||||
const command = commandName && help.commands[commandName];
|
||||
if (args.help || args.h || !commandName) {
|
||||
if (command) {
|
||||
output.help(command.help);
|
||||
} else {
|
||||
const lines = ["playwright-cli - run playwright mcp commands from terminal"];
|
||||
if (process.env.CLAUDECODE || process.env.COPILOT_CLI)
|
||||
lines.push(`Agent skill: ${import_path.default.relative(process.cwd(), (0, import_package.libPath)("tools", "cli-client", "skill", "SKILL.md"))}`);
|
||||
lines.push(help.global);
|
||||
output.help(lines.join("\n\n"));
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
if (!command)
|
||||
output.errorUnknownCommand(commandName, help.global);
|
||||
validateFlags(args, command, output);
|
||||
validateArgs(args, command, output);
|
||||
const registry = await import_registry.Registry.load();
|
||||
const sessionName = (0, import_registry.resolveSessionName)(args.session);
|
||||
switch (commandName) {
|
||||
case "list": {
|
||||
const data = await collectList(registry, clientInfo, !!args.all);
|
||||
output.list(data);
|
||||
return;
|
||||
}
|
||||
case "close-all": {
|
||||
const entries = registry.entries(clientInfo);
|
||||
const closed = [];
|
||||
for (const entry of entries) {
|
||||
await new import_session.Session(entry).stop();
|
||||
closed.push(entry.config.name);
|
||||
}
|
||||
output.closeAll(closed);
|
||||
return;
|
||||
}
|
||||
case "delete-data": {
|
||||
const entry = registry.entry(clientInfo, sessionName);
|
||||
if (!entry) {
|
||||
output.deleteData(sessionName, { existed: false, deletedUserDataDir: false });
|
||||
return;
|
||||
}
|
||||
const result = await new import_session.Session(entry).deleteData();
|
||||
output.deleteData(sessionName, result);
|
||||
return;
|
||||
}
|
||||
case "kill-all": {
|
||||
const pids = await killAllDaemons();
|
||||
output.killAll(pids);
|
||||
return;
|
||||
}
|
||||
case "open": {
|
||||
const { pid } = await startSession(sessionName, registry, clientInfo, args, "open");
|
||||
const newEntry = await registry.loadEntry(clientInfo, sessionName);
|
||||
const params = args._.slice(1);
|
||||
const toolText = await runInSessionOrStop(newEntry, clientInfo, { _: ["goto", ...params.length ? params : ["about:blank"]] }, output);
|
||||
output.open(sessionName, pid, toolText);
|
||||
return;
|
||||
}
|
||||
case "attach": {
|
||||
const attachTarget = args._[1];
|
||||
const targetCount = (attachTarget ? 1 : 0) + (args.cdp ? 1 : 0) + (args.endpoint ? 1 : 0) + (args.extension ? 1 : 0);
|
||||
if (targetCount > 1)
|
||||
output.errorAttachConflict();
|
||||
if (attachTarget)
|
||||
args.endpoint = attachTarget;
|
||||
const extensionChannel = typeof args.extension === "string" ? args.extension : void 0;
|
||||
if (extensionChannel) {
|
||||
args.browser = extensionChannel;
|
||||
args.extension = true;
|
||||
}
|
||||
const cdpChannel = typeof args.cdp === "string" && (0, import_channelSessions.isKnownChannel)(args.cdp) ? args.cdp : void 0;
|
||||
const targetName = attachTarget ?? cdpChannel ?? extensionChannel ?? args.endpoint ?? args.cdp;
|
||||
if (!targetName)
|
||||
output.errorAttachNoTarget();
|
||||
const attachSessionName = (0, import_registry.explicitSessionName)(args.session) ?? attachTarget ?? cdpChannel ?? extensionChannel ?? sessionName;
|
||||
args.session = attachSessionName;
|
||||
const { pid } = await startSession(attachSessionName, registry, clientInfo, args, "attach");
|
||||
const newEntry = await registry.loadEntry(clientInfo, attachSessionName);
|
||||
const toolText = await runInSessionOrStop(newEntry, clientInfo, { _: ["snapshot"], filename: "<auto>" }, output);
|
||||
output.attach(attachSessionName, pid, targetName, toolText);
|
||||
return;
|
||||
}
|
||||
case "close": {
|
||||
const closeEntry = registry.entry(clientInfo, sessionName);
|
||||
const { wasOpen } = closeEntry ? await new import_session.Session(closeEntry).stop() : { wasOpen: false };
|
||||
output.close(sessionName, wasOpen);
|
||||
return;
|
||||
}
|
||||
case "detach": {
|
||||
const detachEntry = registry.entry(clientInfo, sessionName);
|
||||
if (detachEntry && !detachEntry.config.attached)
|
||||
output.errorDetachNotAttached(sessionName);
|
||||
const { wasOpen } = detachEntry ? await new import_session.Session(detachEntry).stop() : { wasOpen: false };
|
||||
output.detach(sessionName, wasOpen);
|
||||
return;
|
||||
}
|
||||
case "install":
|
||||
await runInitWorkspace(args, output);
|
||||
output.installed();
|
||||
return;
|
||||
case "install-browser":
|
||||
await installBrowser();
|
||||
output.installed();
|
||||
return;
|
||||
case "show": {
|
||||
const daemonScript = (0, import_package.libPath)("entry", "dashboardApp.js");
|
||||
const daemonArgs = [
|
||||
daemonScript,
|
||||
`--workspaceDir=${clientInfo.workspaceDir ?? ""}`
|
||||
];
|
||||
const explicit = (0, import_registry.explicitSessionName)(args.session);
|
||||
if (explicit)
|
||||
daemonArgs.push(`--sessionName=${explicit}`);
|
||||
if (args.port !== void 0)
|
||||
daemonArgs.push(`--port=${args.port}`);
|
||||
if (args.host !== void 0)
|
||||
daemonArgs.push(`--host=${args.host}`);
|
||||
if (args.kill) {
|
||||
daemonArgs.push(`--kill`);
|
||||
const child2 = (0, import_child_process.spawn)(process.execPath, daemonArgs, { stdio: "ignore" });
|
||||
await new Promise((resolve) => child2.on("exit", () => resolve()));
|
||||
return;
|
||||
}
|
||||
if (args.annotate) {
|
||||
const entry = registry.entry(clientInfo, sessionName);
|
||||
if (!entry)
|
||||
output.errorBrowserNotOpenForTool(sessionName);
|
||||
args.raw = true;
|
||||
const text = await runInSession(entry, clientInfo, args, output);
|
||||
output.toolResult(text);
|
||||
return;
|
||||
}
|
||||
const foreground = args.port !== void 0;
|
||||
const child = (0, import_child_process.spawn)(process.execPath, daemonArgs, {
|
||||
detached: !foreground,
|
||||
stdio: foreground ? "inherit" : ["pipe", "pipe", "ignore"]
|
||||
});
|
||||
if (foreground) {
|
||||
await new Promise((resolve) => child.on("exit", () => resolve()));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => child.stdin.destroy(), 6e4);
|
||||
child.unref();
|
||||
let daemonPid;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
let outLog = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
outLog += data.toString();
|
||||
const match = outLog.match(/Dashboard is running pid=(\d+)/);
|
||||
if (match) {
|
||||
daemonPid = Number(match[1]);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
child.once("exit", (code, signal) => reject(new Error(`Dashboard daemon exited (code=${code}, signal=${signal}) before signaling READY${outLog ? "\n" + outLog : ""}`)));
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
child.removeAllListeners("exit");
|
||||
child.stdin.destroy();
|
||||
child.stdout.destroy();
|
||||
}
|
||||
output.show(sessionName, daemonPid);
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
const entry = registry.entry(clientInfo, sessionName);
|
||||
if (!entry)
|
||||
output.errorBrowserNotOpenForTool(sessionName);
|
||||
if (command.raw)
|
||||
args.raw = true;
|
||||
const text = await runInSession(entry, clientInfo, args, output);
|
||||
output.toolResult(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function startSession(sessionName, registry, clientInfo, args, mode) {
|
||||
const entry = registry.entry(clientInfo, sessionName);
|
||||
if (entry)
|
||||
await new import_session.Session(entry).stop();
|
||||
return await import_session.Session.startDaemon(clientInfo, args, mode);
|
||||
}
|
||||
async function runInSession(entry, clientInfo, args, output) {
|
||||
const raw = !!args.raw;
|
||||
for (const globalOption of globalOptions)
|
||||
delete args[globalOption];
|
||||
const session = new import_session.Session(entry);
|
||||
const result = await session.run(clientInfo, args, { raw, json: output.json });
|
||||
return result.text;
|
||||
}
|
||||
async function runInSessionOrStop(entry, clientInfo, args, output) {
|
||||
try {
|
||||
return await runInSession(entry, clientInfo, args, output);
|
||||
} catch (e) {
|
||||
await new import_session.Session(entry).stop().catch(() => {
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
async function runInitWorkspace(args, output) {
|
||||
const cliPath = (0, import_package.libPath)("entry", "cliDaemon.js");
|
||||
const daemonArgs = [cliPath, "--init-workspace", ...args.skills ? ["--init-skills", String(args.skills)] : []];
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = (0, import_child_process.spawn)(process.execPath, daemonArgs, {
|
||||
stdio: output.installStdio(),
|
||||
cwd: process.cwd()
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0)
|
||||
resolve();
|
||||
else
|
||||
reject(new Error(`Workspace initialization failed with exit code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
async function installBrowser() {
|
||||
const argv = process.argv.map((arg) => arg === "install-browser" ? "install" : arg);
|
||||
const { libCli } = require("../../coreBundle.js");
|
||||
const { program: program2 } = require("../../utilsBundle.js");
|
||||
if (!program2.version())
|
||||
libCli.decorateProgram(program2);
|
||||
program2.parse(argv);
|
||||
}
|
||||
const daemonProcessPatterns = ["run-mcp-server", "run-cli-server", "cli-daemon", "cliDaemon.js", "dashboardApp.js"];
|
||||
async function killAllDaemons() {
|
||||
const platform = import_os.default.platform();
|
||||
const pidFilterEnv = process.env.PWTEST_KILL_ALL_PID_FILTER_FOR_TEST;
|
||||
const pidFilter = pidFilterEnv ? new Set(pidFilterEnv.split(",").map((p) => parseInt(p, 10)).filter((n) => !isNaN(n))) : void 0;
|
||||
const killed = [];
|
||||
try {
|
||||
if (platform === "win32") {
|
||||
const clauses = [`(${daemonProcessPatterns.map((p) => `$_.CommandLine -like '*${p}*'`).join(" -or ")})`];
|
||||
if (pidFilter)
|
||||
clauses.push(`(${[...pidFilter].map((p) => `$_.ProcessId -eq ${p}`).join(" -or ")})`);
|
||||
const whereClause = clauses.join(" -and ");
|
||||
const result = (0, import_child_process.execSync)(
|
||||
`powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Where-Object { ${whereClause} } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $_.ProcessId }"`,
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
const pids = result.split("\n").map((line) => line.trim()).filter((line) => /^\d+$/.test(line));
|
||||
for (const pid of pids)
|
||||
killed.push(parseInt(pid, 10));
|
||||
} else {
|
||||
const result = (0, import_child_process.execSync)("ps auxww", { encoding: "utf-8" });
|
||||
const lines = result.split("\n");
|
||||
for (const line of lines) {
|
||||
if (daemonProcessPatterns.some((p) => line.includes(p))) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[1];
|
||||
if (pid && /^\d+$/.test(pid)) {
|
||||
const numericPid = parseInt(pid, 10);
|
||||
if (pidFilter && !pidFilter.has(numericPid))
|
||||
continue;
|
||||
try {
|
||||
process.kill(numericPid, "SIGKILL");
|
||||
killed.push(numericPid);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
async function collectList(registry, clientInfo, all) {
|
||||
const browsers = [];
|
||||
const entries = registry.entryMap();
|
||||
const serverEntries = await import_serverRegistry.serverRegistry.list();
|
||||
const key = (0, import_registry.clientKey)(clientInfo);
|
||||
for (const [workspaceKey, list] of entries) {
|
||||
if (!all && workspaceKey !== key)
|
||||
continue;
|
||||
for (const entry of list) {
|
||||
const session = new import_session.Session(entry);
|
||||
const canConnect = await session.canConnect();
|
||||
if (!canConnect) {
|
||||
await session.deleteSessionConfig();
|
||||
continue;
|
||||
}
|
||||
const config = session.config;
|
||||
const channel = config.browser?.launchOptions.channel ?? config.browser?.browserName;
|
||||
browsers.push({
|
||||
name: session.name,
|
||||
workspace: workspaceKey,
|
||||
status: canConnect ? "open" : "closed",
|
||||
browserType: channel,
|
||||
userDataDir: config.browser?.userDataDir ?? null,
|
||||
headed: config.browser ? !config.browser.launchOptions.headless : void 0,
|
||||
persistent: !!config.cli.persistent,
|
||||
attached: !!config.attached,
|
||||
compatible: session.isCompatible(clientInfo),
|
||||
version: config.version
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!all)
|
||||
return { all, browsers };
|
||||
const servers = [...serverEntries.values()].flat();
|
||||
return { all, browsers, servers, channelSessions: await (0, import_channelSessions.listChannelSessions)() };
|
||||
}
|
||||
function validateFlags(args, command, output) {
|
||||
const unknownFlags = [];
|
||||
for (const key of Object.keys(args)) {
|
||||
if (key === "_")
|
||||
continue;
|
||||
if (globalOptions.includes(key))
|
||||
continue;
|
||||
if (!(key in command.flags))
|
||||
unknownFlags.push(key);
|
||||
}
|
||||
if (unknownFlags.length)
|
||||
output.errorUnknownOption(unknownFlags, command.help);
|
||||
}
|
||||
function validateArgs(args, command, output) {
|
||||
const positional = args._.slice(1);
|
||||
if (positional.length > command.args.length)
|
||||
output.errorTooManyArguments(command.args.length, positional.length, command.help);
|
||||
}
|
||||
function calculateSha1(buffer) {
|
||||
const hash = import_crypto.default.createHash("sha1");
|
||||
hash.update(buffer);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
calculateSha1,
|
||||
program
|
||||
});
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var registry_exports = {};
|
||||
__export(registry_exports, {
|
||||
Registry: () => Registry,
|
||||
baseDaemonDir: () => baseDaemonDir,
|
||||
clientKey: () => clientKey,
|
||||
createClientInfo: () => createClientInfo,
|
||||
explicitSessionName: () => explicitSessionName,
|
||||
resolveSessionName: () => resolveSessionName
|
||||
});
|
||||
module.exports = __toCommonJS(registry_exports);
|
||||
var import_crypto = __toESM(require("crypto"));
|
||||
var import_fs = __toESM(require("fs"));
|
||||
var import_os = __toESM(require("os"));
|
||||
var import_path = __toESM(require("path"));
|
||||
var import_package = require("../../package");
|
||||
function clientKey(clientInfo) {
|
||||
return clientInfo.workspaceDir || clientInfo.workspaceDirHash;
|
||||
}
|
||||
class Registry {
|
||||
constructor(files) {
|
||||
this._files = files;
|
||||
}
|
||||
entry(clientInfo, sessionName) {
|
||||
const key = clientKey(clientInfo);
|
||||
const entries = this._files.get(key) || [];
|
||||
return entries.find((entry) => entry.config.name === sessionName);
|
||||
}
|
||||
entries(clientInfo) {
|
||||
return this._files.get(clientKey(clientInfo)) || [];
|
||||
}
|
||||
entryMap() {
|
||||
return this._files;
|
||||
}
|
||||
async loadEntry(clientInfo, sessionName) {
|
||||
const entry = await Registry._loadSessionEntry(clientInfo.daemonProfilesDir, sessionName + ".session");
|
||||
if (!entry)
|
||||
throw new Error(`Could not start the session "${sessionName}"`);
|
||||
const key = clientKey(clientInfo);
|
||||
let list = this._files.get(key);
|
||||
if (!list) {
|
||||
list = [];
|
||||
this._files.set(key, list);
|
||||
}
|
||||
const oldIndex = list.findIndex((e) => e.config.name === sessionName);
|
||||
if (oldIndex !== -1)
|
||||
list.splice(oldIndex, 1);
|
||||
list.push(entry);
|
||||
return entry;
|
||||
}
|
||||
static async _loadSessionEntry(daemonDir, file) {
|
||||
try {
|
||||
const fileName = import_path.default.join(daemonDir, file);
|
||||
const data = await import_fs.default.promises.readFile(fileName, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
if (!config.name)
|
||||
config.name = import_path.default.basename(file, ".session");
|
||||
if (!config.timestamp)
|
||||
config.timestamp = 0;
|
||||
return { file: fileName, config, daemonDir };
|
||||
} catch {
|
||||
return void 0;
|
||||
}
|
||||
}
|
||||
static async load() {
|
||||
const sessions = /* @__PURE__ */ new Map();
|
||||
const hashDirs = await import_fs.default.promises.readdir(baseDaemonDir).catch(() => []);
|
||||
for (const workspaceDirHash of hashDirs) {
|
||||
const daemonDir = import_path.default.join(baseDaemonDir, workspaceDirHash);
|
||||
const stat = await import_fs.default.promises.stat(daemonDir);
|
||||
if (!stat.isDirectory())
|
||||
continue;
|
||||
const files = await import_fs.default.promises.readdir(daemonDir).catch(() => []);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".session"))
|
||||
continue;
|
||||
const entry = await Registry._loadSessionEntry(daemonDir, file);
|
||||
if (!entry)
|
||||
continue;
|
||||
const key = entry.config.workspaceDir || workspaceDirHash;
|
||||
let list = sessions.get(key);
|
||||
if (!list) {
|
||||
list = [];
|
||||
sessions.set(key, list);
|
||||
}
|
||||
list.push(entry);
|
||||
}
|
||||
}
|
||||
return new Registry(sessions);
|
||||
}
|
||||
}
|
||||
const baseDaemonDir = (() => {
|
||||
if (process.env.PWTEST_DAEMON_SESSION_DIR)
|
||||
return process.env.PWTEST_DAEMON_SESSION_DIR;
|
||||
let localCacheDir;
|
||||
if (process.platform === "linux")
|
||||
localCacheDir = process.env.XDG_CACHE_HOME || import_path.default.join(import_os.default.homedir(), ".cache");
|
||||
if (process.platform === "darwin")
|
||||
localCacheDir = import_path.default.join(import_os.default.homedir(), "Library", "Caches");
|
||||
if (process.platform === "win32")
|
||||
localCacheDir = process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local");
|
||||
if (!localCacheDir)
|
||||
throw new Error("Unsupported platform: " + process.platform);
|
||||
return import_path.default.join(localCacheDir, "ms-playwright", "daemon");
|
||||
})();
|
||||
function createClientInfo() {
|
||||
const workspaceDir = findWorkspaceDir(process.cwd());
|
||||
const version = process.env.PLAYWRIGHT_CLI_VERSION_FOR_TEST || import_package.packageJSON.version;
|
||||
const hash = import_crypto.default.createHash("sha1");
|
||||
hash.update(workspaceDir || import_package.packageRoot);
|
||||
const workspaceDirHash = hash.digest("hex").substring(0, 16);
|
||||
return {
|
||||
version,
|
||||
workspaceDir,
|
||||
workspaceDirHash,
|
||||
daemonProfilesDir: daemonProfilesDir(workspaceDirHash),
|
||||
homeDir: import_os.default.homedir()
|
||||
};
|
||||
}
|
||||
function findWorkspaceDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (import_fs.default.existsSync(import_path.default.join(dir, ".playwright")))
|
||||
return dir;
|
||||
const parentDir = import_path.default.dirname(dir);
|
||||
if (parentDir === dir)
|
||||
break;
|
||||
dir = parentDir;
|
||||
}
|
||||
return void 0;
|
||||
}
|
||||
const daemonProfilesDir = (workspaceDirHash) => {
|
||||
return import_path.default.join(baseDaemonDir, workspaceDirHash);
|
||||
};
|
||||
function explicitSessionName(sessionName) {
|
||||
return sessionName || process.env.PLAYWRIGHT_CLI_SESSION;
|
||||
}
|
||||
function resolveSessionName(sessionName) {
|
||||
return explicitSessionName(sessionName) || "default";
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
Registry,
|
||||
baseDaemonDir,
|
||||
clientKey,
|
||||
createClientInfo,
|
||||
explicitSessionName,
|
||||
resolveSessionName
|
||||
});
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var session_exports = {};
|
||||
__export(session_exports, {
|
||||
Session: () => Session
|
||||
});
|
||||
module.exports = __toCommonJS(session_exports);
|
||||
var import_child_process = require("child_process");
|
||||
var import_fs = __toESM(require("fs"));
|
||||
var import_net = __toESM(require("net"));
|
||||
var import_os = __toESM(require("os"));
|
||||
var import_path = __toESM(require("path"));
|
||||
var import_package = require("../../package");
|
||||
var import_socketConnection = require("../utils/socketConnection");
|
||||
var import_registry = require("./registry");
|
||||
class Session {
|
||||
constructor(sessionFile) {
|
||||
this.config = sessionFile.config;
|
||||
this.name = this.config.name;
|
||||
this._sessionFile = sessionFile;
|
||||
}
|
||||
isCompatible(clientInfo) {
|
||||
return (0, import_socketConnection.compareSemver)(clientInfo.version, this.config.version) >= 0;
|
||||
}
|
||||
async run(clientInfo, args, options) {
|
||||
if (!this.isCompatible(clientInfo))
|
||||
throw new Error(`Client is v${clientInfo.version}, session '${this.name}' is v${this.config.version}. Run
|
||||
|
||||
playwright-cli${this.name !== "default" ? ` -s=${this.name}` : ""} open
|
||||
|
||||
to restart the browser session.`);
|
||||
const { socket } = await this._connect();
|
||||
if (!socket)
|
||||
throw new Error(`Browser '${this.name}' is not open. Run
|
||||
|
||||
playwright-cli${this.name !== "default" ? ` -s=${this.name}` : ""} open
|
||||
|
||||
to start the browser session.`);
|
||||
return await SocketConnectionClient.sendAndClose(socket, "run", { args, cwd: process.cwd(), raw: options?.raw, json: options?.json });
|
||||
}
|
||||
async stop() {
|
||||
if (!await this.canConnect())
|
||||
return { wasOpen: false };
|
||||
await this._stopDaemon();
|
||||
return { wasOpen: true };
|
||||
}
|
||||
async deleteData() {
|
||||
await this.stop();
|
||||
const dataDirs = await import_fs.default.promises.readdir(this._sessionFile.daemonDir).catch(() => []);
|
||||
const matchingEntries = dataDirs.filter((file) => file === `${this.name}.session` || file.startsWith(`ud-${this.name}-`));
|
||||
if (matchingEntries.length === 0)
|
||||
return { existed: false, deletedUserDataDir: false };
|
||||
let deletedUserDataDir = false;
|
||||
for (const entry of matchingEntries) {
|
||||
const userDataDir = import_path.default.resolve(this._sessionFile.daemonDir, entry);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
await import_fs.default.promises.rm(userDataDir, { recursive: true });
|
||||
if (entry.startsWith("ud-"))
|
||||
deletedUserDataDir = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT")
|
||||
break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
||||
if (i === 4)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { existed: true, deletedUserDataDir };
|
||||
}
|
||||
async _connect() {
|
||||
return await new Promise((resolve) => {
|
||||
const socket = import_net.default.createConnection(this.config.socketPath, () => {
|
||||
resolve({ socket });
|
||||
});
|
||||
socket.on("error", (error) => {
|
||||
if (import_os.default.platform() !== "win32")
|
||||
void import_fs.default.promises.unlink(this.config.socketPath).catch(() => {
|
||||
}).then(() => resolve({ error }));
|
||||
else
|
||||
resolve({ error });
|
||||
});
|
||||
});
|
||||
}
|
||||
async canConnect() {
|
||||
const { socket } = await this._connect();
|
||||
if (socket) {
|
||||
socket.destroy();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static async startDaemon(clientInfo, cliArgs, mode) {
|
||||
await import_fs.default.promises.mkdir(clientInfo.daemonProfilesDir, { recursive: true });
|
||||
const cliPath = (0, import_package.libPath)("entry", "cliDaemon.js");
|
||||
const sessionName = (0, import_registry.resolveSessionName)(cliArgs.session);
|
||||
const errLog = import_path.default.join(clientInfo.daemonProfilesDir, sessionName + ".err");
|
||||
const err = import_fs.default.openSync(errLog, "w");
|
||||
const args = [
|
||||
cliPath,
|
||||
sessionName
|
||||
];
|
||||
if (cliArgs.headed)
|
||||
args.push("--headed");
|
||||
if (cliArgs.browser)
|
||||
args.push(`--browser=${cliArgs.browser}`);
|
||||
if (cliArgs.persistent)
|
||||
args.push("--persistent");
|
||||
if (cliArgs.profile)
|
||||
args.push(`--profile=${cliArgs.profile}`);
|
||||
if (cliArgs.config)
|
||||
args.push(`--config=${cliArgs.config}`);
|
||||
if (cliArgs.extension)
|
||||
args.push("--extension");
|
||||
else if (cliArgs.cdp)
|
||||
args.push(`--cdp=${cliArgs.cdp}`);
|
||||
else if (cliArgs.endpoint)
|
||||
args.push(`--endpoint=${cliArgs.endpoint}`);
|
||||
const child = (0, import_child_process.spawn)(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", err],
|
||||
cwd: process.cwd()
|
||||
// Will be used as root.
|
||||
});
|
||||
let signalled = false;
|
||||
const sigintHandler = () => {
|
||||
signalled = true;
|
||||
child.kill("SIGINT");
|
||||
};
|
||||
const sigtermHandler = () => {
|
||||
signalled = true;
|
||||
child.kill("SIGTERM");
|
||||
};
|
||||
process.on("SIGINT", sigintHandler);
|
||||
process.on("SIGTERM", sigtermHandler);
|
||||
let outLog = "";
|
||||
const rejectWithPid = (reject, message) => reject(Object.assign(new Error(`Daemon pid=${child.pid}: ${message}`), { daemonPid: child.pid }));
|
||||
await new Promise((resolve, reject) => {
|
||||
child.stdout.on("data", (data) => {
|
||||
outLog += data.toString();
|
||||
if (outLog.includes("Daemon listening on"))
|
||||
resolve();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (!signalled) {
|
||||
const errLogContent = import_fs.default.readFileSync(errLog, "utf-8");
|
||||
rejectWithPid(reject, `Daemon process exited with code ${code}` + (outLog ? "\n" + outLog : "") + (errLogContent ? "\n" + errLogContent : ""));
|
||||
}
|
||||
});
|
||||
});
|
||||
process.off("SIGINT", sigintHandler);
|
||||
process.off("SIGTERM", sigtermHandler);
|
||||
child.stdout.destroy();
|
||||
child.unref();
|
||||
return { pid: child.pid, sessionName, endpoint: cliArgs.endpoint };
|
||||
}
|
||||
async _stopDaemon() {
|
||||
const { socket } = await this._connect();
|
||||
if (!socket)
|
||||
return;
|
||||
let error;
|
||||
await SocketConnectionClient.sendAndClose(socket, "stop", {}).catch((e) => error = e);
|
||||
if (error && !error?.message?.includes("Session closed"))
|
||||
throw error;
|
||||
}
|
||||
async deleteSessionConfig() {
|
||||
await import_fs.default.promises.rm(this._sessionFile.file).catch(() => {
|
||||
});
|
||||
}
|
||||
}
|
||||
class SocketConnectionClient {
|
||||
constructor(socket) {
|
||||
this._nextMessageId = 1;
|
||||
this._callbacks = /* @__PURE__ */ new Map();
|
||||
this._connection = new import_socketConnection.SocketConnection(socket);
|
||||
this._connection.onmessage = (message) => this._onMessage(message);
|
||||
this._connection.onclose = () => this._rejectCallbacks();
|
||||
}
|
||||
async send(method, params = {}) {
|
||||
const messageId = this._nextMessageId++;
|
||||
const message = {
|
||||
id: messageId,
|
||||
method,
|
||||
params
|
||||
};
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
this._callbacks.set(messageId, { resolve, reject, method, params });
|
||||
});
|
||||
const [result] = await Promise.all([responsePromise, this._connection.send(message)]);
|
||||
return result;
|
||||
}
|
||||
static async sendAndClose(socket, method, params = {}) {
|
||||
const connection = new SocketConnectionClient(socket);
|
||||
try {
|
||||
return await connection.send(method, params);
|
||||
} finally {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
close() {
|
||||
this._connection.close();
|
||||
}
|
||||
_onMessage(object) {
|
||||
if (object.id && this._callbacks.has(object.id)) {
|
||||
const callback = this._callbacks.get(object.id);
|
||||
this._callbacks.delete(object.id);
|
||||
if (object.error)
|
||||
callback.reject(new Error(object.error));
|
||||
else
|
||||
callback.resolve(object.result);
|
||||
} else if (object.id) {
|
||||
throw new Error(`Unexpected message id: ${object.id}`);
|
||||
} else {
|
||||
throw new Error(`Unexpected message without id: ${JSON.stringify(object)}`);
|
||||
}
|
||||
}
|
||||
_rejectCallbacks() {
|
||||
for (const callback of this._callbacks.values())
|
||||
callback.reject(new Error("Session closed"));
|
||||
this._callbacks.clear();
|
||||
}
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
Session
|
||||
});
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
---
|
||||
name: playwright-cli
|
||||
description: Automate browser interactions, test web pages and work with Playwright tests.
|
||||
allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
|
||||
---
|
||||
|
||||
# Browser Automation with playwright-cli
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# open new browser
|
||||
playwright-cli open
|
||||
# navigate to a page
|
||||
playwright-cli goto https://playwright.dev
|
||||
# interact with the page using refs from the snapshot
|
||||
playwright-cli click e15
|
||||
playwright-cli type "page.click"
|
||||
playwright-cli press Enter
|
||||
# take a screenshot (rarely used, as snapshot is more common)
|
||||
playwright-cli screenshot
|
||||
# close the browser
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Core
|
||||
|
||||
```bash
|
||||
playwright-cli open
|
||||
# open and navigate right away
|
||||
playwright-cli open https://example.com/
|
||||
playwright-cli goto https://playwright.dev
|
||||
playwright-cli type "search query"
|
||||
playwright-cli click e3
|
||||
playwright-cli dblclick e7
|
||||
# --submit presses Enter after filling the element
|
||||
playwright-cli fill e5 "user@example.com" --submit
|
||||
playwright-cli drag e2 e8
|
||||
# drop files or data onto an element (from outside the page)
|
||||
playwright-cli drop e4 --path=./image.png
|
||||
playwright-cli drop e4 --data="text/plain=hello world"
|
||||
playwright-cli hover e4
|
||||
playwright-cli select e9 "option-value"
|
||||
playwright-cli upload ./document.pdf
|
||||
playwright-cli check e12
|
||||
playwright-cli uncheck e12
|
||||
playwright-cli snapshot
|
||||
playwright-cli eval "document.title"
|
||||
playwright-cli eval "el => el.textContent" e5
|
||||
# get element id, class, or any attribute not visible in the snapshot
|
||||
playwright-cli eval "el => el.id" e5
|
||||
playwright-cli eval "el => el.getAttribute('data-testid')" e5
|
||||
playwright-cli dialog-accept
|
||||
playwright-cli dialog-accept "confirmation text"
|
||||
playwright-cli dialog-dismiss
|
||||
playwright-cli resize 1920 1080
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
```bash
|
||||
playwright-cli go-back
|
||||
playwright-cli go-forward
|
||||
playwright-cli reload
|
||||
```
|
||||
|
||||
### Keyboard
|
||||
|
||||
```bash
|
||||
playwright-cli press Enter
|
||||
playwright-cli press ArrowDown
|
||||
playwright-cli keydown Shift
|
||||
playwright-cli keyup Shift
|
||||
```
|
||||
|
||||
### Mouse
|
||||
|
||||
```bash
|
||||
playwright-cli mousemove 150 300
|
||||
playwright-cli mousedown
|
||||
playwright-cli mousedown right
|
||||
playwright-cli mouseup
|
||||
playwright-cli mouseup right
|
||||
playwright-cli mousewheel 0 100
|
||||
```
|
||||
|
||||
### Save as
|
||||
|
||||
```bash
|
||||
playwright-cli screenshot
|
||||
playwright-cli screenshot e5
|
||||
playwright-cli screenshot --filename=page.png
|
||||
playwright-cli pdf --filename=page.pdf
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
```bash
|
||||
playwright-cli tab-list
|
||||
playwright-cli tab-new
|
||||
playwright-cli tab-new https://example.com/page
|
||||
playwright-cli tab-close
|
||||
playwright-cli tab-close 2
|
||||
playwright-cli tab-select 0
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
```bash
|
||||
playwright-cli state-save
|
||||
playwright-cli state-save auth.json
|
||||
playwright-cli state-load auth.json
|
||||
|
||||
# Cookies
|
||||
playwright-cli cookie-list
|
||||
playwright-cli cookie-list --domain=example.com
|
||||
playwright-cli cookie-get session_id
|
||||
playwright-cli cookie-set session_id abc123
|
||||
playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
|
||||
playwright-cli cookie-delete session_id
|
||||
playwright-cli cookie-clear
|
||||
|
||||
# LocalStorage
|
||||
playwright-cli localstorage-list
|
||||
playwright-cli localstorage-get theme
|
||||
playwright-cli localstorage-set theme dark
|
||||
playwright-cli localstorage-delete theme
|
||||
playwright-cli localstorage-clear
|
||||
|
||||
# SessionStorage
|
||||
playwright-cli sessionstorage-list
|
||||
playwright-cli sessionstorage-get step
|
||||
playwright-cli sessionstorage-set step 3
|
||||
playwright-cli sessionstorage-delete step
|
||||
playwright-cli sessionstorage-clear
|
||||
```
|
||||
|
||||
### Network
|
||||
|
||||
```bash
|
||||
playwright-cli route "**/*.jpg" --status=404
|
||||
playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
|
||||
playwright-cli route-list
|
||||
playwright-cli unroute "**/*.jpg"
|
||||
playwright-cli unroute
|
||||
```
|
||||
|
||||
### DevTools
|
||||
|
||||
```bash
|
||||
playwright-cli console
|
||||
playwright-cli console warning
|
||||
playwright-cli requests
|
||||
playwright-cli request 5
|
||||
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
|
||||
playwright-cli run-code --filename=script.js
|
||||
playwright-cli tracing-start
|
||||
playwright-cli tracing-stop
|
||||
playwright-cli video-start video.webm
|
||||
playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000
|
||||
playwright-cli video-stop
|
||||
|
||||
# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target
|
||||
playwright-cli video-show-actions --duration=600 --position=top-right
|
||||
playwright-cli video-hide-actions
|
||||
|
||||
# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes
|
||||
playwright-cli show --annotate
|
||||
|
||||
# generate a Playwright locator for an element from its ref or selector
|
||||
playwright-cli generate-locator e5 --raw
|
||||
|
||||
# show a persistent highlight overlay for an element, optionally with a custom style
|
||||
playwright-cli highlight e5
|
||||
playwright-cli highlight e5 --style="outline: 3px dashed red"
|
||||
# hide a single element highlight, or all page highlights when no target is given
|
||||
playwright-cli highlight e5 --hide
|
||||
playwright-cli highlight --hide
|
||||
```
|
||||
|
||||
## Raw output
|
||||
|
||||
The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing.
|
||||
|
||||
```bash
|
||||
playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart'
|
||||
playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json
|
||||
playwright-cli --raw snapshot > before.yml
|
||||
playwright-cli click e5
|
||||
playwright-cli --raw snapshot > after.yml
|
||||
diff before.yml after.yml
|
||||
TOKEN=$(playwright-cli --raw cookie-get session_id)
|
||||
playwright-cli --raw localstorage-get theme
|
||||
```
|
||||
|
||||
For structured output wrapping every reply as JSON, pass --json
|
||||
```bash
|
||||
playwright-cli list --json
|
||||
```
|
||||
|
||||
## Open parameters
|
||||
```bash
|
||||
# Use specific browser when creating session
|
||||
playwright-cli open --browser=chrome
|
||||
playwright-cli open --browser=firefox
|
||||
playwright-cli open --browser=webkit
|
||||
playwright-cli open --browser=msedge
|
||||
|
||||
# Use persistent profile (by default profile is in-memory)
|
||||
playwright-cli open --persistent
|
||||
# Use persistent profile with custom directory
|
||||
playwright-cli open --profile=/path/to/profile
|
||||
|
||||
# Connect to browser via Playwright Extension
|
||||
playwright-cli attach --extension=chrome
|
||||
|
||||
# Connect to a running Chrome or Edge by channel name
|
||||
playwright-cli attach --cdp=chrome
|
||||
playwright-cli attach --cdp=msedge
|
||||
|
||||
# Connect to a running browser via CDP endpoint
|
||||
playwright-cli attach --cdp=http://localhost:9222
|
||||
|
||||
# Start with config file
|
||||
playwright-cli open --config=my-config.json
|
||||
|
||||
# Close the browser
|
||||
playwright-cli close
|
||||
# Detach from an attached browser (leaves the external browser running)
|
||||
playwright-cli -s=msedge detach
|
||||
# Delete user data for the default session
|
||||
playwright-cli delete-data
|
||||
```
|
||||
|
||||
## URLs with `&` on Windows
|
||||
|
||||
On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell:
|
||||
|
||||
```batch
|
||||
playwright-cli goto "https://example.com/?a=1^&b=2"
|
||||
```
|
||||
|
||||
```powershell
|
||||
playwright-cli --% goto "https://example.com/?a=1&b=2"
|
||||
```
|
||||
|
||||
## Snapshots
|
||||
|
||||
After each command, playwright-cli provides a snapshot of the current browser state.
|
||||
|
||||
```bash
|
||||
> playwright-cli goto https://example.com
|
||||
### Page
|
||||
- Page URL: https://example.com/
|
||||
- Page Title: Example Domain
|
||||
### Snapshot
|
||||
[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
|
||||
```
|
||||
|
||||
You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed.
|
||||
|
||||
```bash
|
||||
# default - save to a file with timestamp-based name
|
||||
playwright-cli snapshot
|
||||
|
||||
# save to file, use when snapshot is a part of the workflow result
|
||||
playwright-cli snapshot --filename=after-click.yaml
|
||||
|
||||
# snapshot an element instead of the whole page
|
||||
playwright-cli snapshot "#main"
|
||||
|
||||
# limit snapshot depth for efficiency, take a partial snapshot afterwards
|
||||
playwright-cli snapshot --depth=4
|
||||
playwright-cli snapshot e34
|
||||
|
||||
# include each element's bounding box as [box=x,y,width,height]
|
||||
playwright-cli snapshot --boxes
|
||||
```
|
||||
|
||||
## Targeting elements
|
||||
|
||||
By default, use refs from the snapshot to interact with page elements.
|
||||
|
||||
```bash
|
||||
# get snapshot with refs
|
||||
playwright-cli snapshot
|
||||
|
||||
# interact using a ref
|
||||
playwright-cli click e15
|
||||
```
|
||||
|
||||
You can also use css selectors or Playwright locators.
|
||||
|
||||
```bash
|
||||
# css selector
|
||||
playwright-cli click "#main > button.submit"
|
||||
|
||||
# role locator
|
||||
playwright-cli click "getByRole('button', { name: 'Submit' })"
|
||||
|
||||
# test id
|
||||
playwright-cli click "getByTestId('submit-button')"
|
||||
```
|
||||
|
||||
## Browser Sessions
|
||||
|
||||
```bash
|
||||
# create new browser session named "mysession" with persistent profile
|
||||
playwright-cli -s=mysession open example.com --persistent
|
||||
# same with manually specified profile directory (use when requested explicitly)
|
||||
playwright-cli -s=mysession open example.com --profile=/path/to/profile
|
||||
playwright-cli -s=mysession click e6
|
||||
playwright-cli -s=mysession close # stop a named browser
|
||||
playwright-cli -s=mysession delete-data # delete user data for persistent session
|
||||
|
||||
playwright-cli list
|
||||
# Close all browsers
|
||||
playwright-cli close-all
|
||||
# Forcefully kill all browser processes
|
||||
playwright-cli kill-all
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
If global `playwright-cli` command is not available, try a local version via `npx playwright-cli`:
|
||||
|
||||
```bash
|
||||
npx --no-install playwright-cli --version
|
||||
```
|
||||
|
||||
When local version is available, use `npx playwright-cli` in all commands. Otherwise, install `playwright-cli` as a global command:
|
||||
|
||||
```bash
|
||||
npm install -g @playwright/cli@latest
|
||||
```
|
||||
|
||||
## Example: Form submission
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com/form
|
||||
playwright-cli snapshot
|
||||
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
playwright-cli fill e2 "password123"
|
||||
playwright-cli click e3
|
||||
playwright-cli snapshot
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Multi-tab workflow
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli tab-new https://example.com/other
|
||||
playwright-cli tab-list
|
||||
playwright-cli tab-select 0
|
||||
playwright-cli snapshot
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Debugging with DevTools
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli click e4
|
||||
playwright-cli fill e7 "test"
|
||||
playwright-cli console
|
||||
playwright-cli requests
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli tracing-start
|
||||
playwright-cli click e4
|
||||
playwright-cli fill e7 "test"
|
||||
playwright-cli tracing-stop
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Interactive session
|
||||
|
||||
Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean":
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli show --annotate
|
||||
```
|
||||
|
||||
## Specific tasks
|
||||
|
||||
* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md)
|
||||
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
|
||||
* **Running Playwright code** [references/running-code.md](references/running-code.md)
|
||||
* **Browser session management** [references/session-management.md](references/session-management.md)
|
||||
* **Spec-driven testing (plan / generate / heal)** [references/spec-driven-testing.md](references/spec-driven-testing.md)
|
||||
* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
|
||||
* **Test generation** [references/test-generation.md](references/test-generation.md)
|
||||
* **Tracing** [references/tracing.md](references/tracing.md)
|
||||
* **Video recording** [references/video-recording.md](references/video-recording.md)
|
||||
* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md)
|
||||
Generated
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
# Inspecting Element Attributes
|
||||
|
||||
When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot
|
||||
# snapshot shows a button as e7 but doesn't reveal its id or data attributes
|
||||
|
||||
# get the element's id
|
||||
playwright-cli eval "el => el.id" e7
|
||||
|
||||
# get all CSS classes
|
||||
playwright-cli eval "el => el.className" e7
|
||||
|
||||
# get a specific attribute
|
||||
playwright-cli eval "el => el.getAttribute('data-testid')" e7
|
||||
playwright-cli eval "el => el.getAttribute('aria-label')" e7
|
||||
|
||||
# get a computed style property
|
||||
playwright-cli eval "el => getComputedStyle(el).display" e7
|
||||
```
|
||||
simulator/e2e/node_modules/playwright-core/lib/tools/cli-client/skill/references/playwright-tests.md
Generated
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
# Running Playwright Tests
|
||||
|
||||
To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||
|
||||
# Run all tests through a custom npm script
|
||||
PLAYWRIGHT_HTML_OPEN=never npm run special-test-command
|
||||
```
|
||||
|
||||
# Debugging Playwright Tests
|
||||
|
||||
To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.
|
||||
|
||||
**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished.
|
||||
|
||||
Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page.
|
||||
|
||||
```bash
|
||||
# Run the test
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli
|
||||
# ...
|
||||
# ... debugging instructions for "tw-abcdef" session ...
|
||||
# ...
|
||||
|
||||
# Attach to the test
|
||||
playwright-cli attach tw-abcdef
|
||||
```
|
||||
|
||||
Keep the test running in the background while you explore and look for a fix.
|
||||
The test is paused at the start, so you should step over or pause at a particular location
|
||||
where the problem is most likely to be.
|
||||
|
||||
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
|
||||
This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.
|
||||
|
||||
After fixing the test, stop the background test run. Rerun to check that test passes.
|
||||
Generated
Vendored
+87
@@ -0,0 +1,87 @@
|
||||
# Request Mocking
|
||||
|
||||
Intercept, mock, modify, and block network requests.
|
||||
|
||||
## CLI Route Commands
|
||||
|
||||
```bash
|
||||
# Mock with custom status
|
||||
playwright-cli route "**/*.jpg" --status=404
|
||||
|
||||
# Mock with JSON body
|
||||
playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
|
||||
|
||||
# Mock with custom headers
|
||||
playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
|
||||
|
||||
# Remove headers from requests
|
||||
playwright-cli route "**/*" --remove-header=cookie,authorization
|
||||
|
||||
# List active routes
|
||||
playwright-cli route-list
|
||||
|
||||
# Remove a route or all routes
|
||||
playwright-cli unroute "**/*.jpg"
|
||||
playwright-cli unroute
|
||||
```
|
||||
|
||||
## URL Patterns
|
||||
|
||||
```
|
||||
**/api/users - Exact path match
|
||||
**/api/*/details - Wildcard in path
|
||||
**/*.{png,jpg,jpeg} - Match file extensions
|
||||
**/search?q=* - Match query parameters
|
||||
```
|
||||
|
||||
## Advanced Mocking with run-code
|
||||
|
||||
For conditional responses, request body inspection, response modification, or delays:
|
||||
|
||||
### Conditional Response Based on Request
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/login', route => {
|
||||
const body = route.request().postDataJSON();
|
||||
if (body.username === 'admin') {
|
||||
route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
|
||||
} else {
|
||||
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
|
||||
}
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Modify Real Response
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/user', async route => {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
json.isPremium = true;
|
||||
await route.fulfill({ response, json });
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Simulate Network Failures
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/offline', route => route.abort('internetdisconnected'));
|
||||
}"
|
||||
# Options: connectionrefused, timedout, connectionreset, internetdisconnected
|
||||
```
|
||||
|
||||
### Delayed Response
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/slow', async route => {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
|
||||
});
|
||||
}"
|
||||
```
|
||||
Generated
Vendored
+241
@@ -0,0 +1,241 @@
|
||||
# Running Custom Playwright Code
|
||||
|
||||
Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
|
||||
|
||||
## Syntax
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
// Your Playwright code here
|
||||
// Access page.context() for browser context operations
|
||||
}"
|
||||
```
|
||||
|
||||
You can also load the function from a file:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code --filename=./my-script.js
|
||||
```
|
||||
|
||||
|
||||
The code must be a single function expression, it is wrapped in `(...)` and evaluated.
|
||||
import/export/require syntax is not supported.
|
||||
|
||||
## Geolocation
|
||||
|
||||
```bash
|
||||
# Grant geolocation permission and set location
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
|
||||
}"
|
||||
|
||||
# Set location to London
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
|
||||
}"
|
||||
|
||||
# Clear geolocation override
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().clearPermissions();
|
||||
}"
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
```bash
|
||||
# Grant multiple permissions
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions([
|
||||
'geolocation',
|
||||
'notifications',
|
||||
'camera',
|
||||
'microphone'
|
||||
]);
|
||||
}"
|
||||
|
||||
# Grant permissions for specific origin
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read'], {
|
||||
origin: 'https://example.com'
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Media Emulation
|
||||
|
||||
```bash
|
||||
# Emulate dark color scheme
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
}"
|
||||
|
||||
# Emulate light color scheme
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
}"
|
||||
|
||||
# Emulate reduced motion
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
}"
|
||||
|
||||
# Emulate print media
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
}"
|
||||
```
|
||||
|
||||
## Wait Strategies
|
||||
|
||||
```bash
|
||||
# Wait for network idle
|
||||
playwright-cli run-code "async page => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
}"
|
||||
|
||||
# Wait for specific element
|
||||
playwright-cli run-code "async page => {
|
||||
await page.locator('.loading').waitFor({ state: 'hidden' });
|
||||
}"
|
||||
|
||||
# Wait for function to return true
|
||||
playwright-cli run-code "async page => {
|
||||
await page.waitForFunction(() => window.appReady === true);
|
||||
}"
|
||||
|
||||
# Wait with timeout
|
||||
playwright-cli run-code "async page => {
|
||||
await page.locator('.result').waitFor({ timeout: 10000 });
|
||||
}"
|
||||
```
|
||||
|
||||
## Frames and Iframes
|
||||
|
||||
```bash
|
||||
# Work with iframe
|
||||
playwright-cli run-code "async page => {
|
||||
const frame = page.locator('iframe#my-iframe').contentFrame();
|
||||
await frame.locator('button').click();
|
||||
}"
|
||||
|
||||
# Get all frames
|
||||
playwright-cli run-code "async page => {
|
||||
const frames = page.frames();
|
||||
return frames.map(f => f.url());
|
||||
}"
|
||||
```
|
||||
|
||||
## File Downloads
|
||||
|
||||
```bash
|
||||
# Handle file download
|
||||
playwright-cli run-code "async page => {
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: 'Download' }).click();
|
||||
const download = await downloadPromise;
|
||||
await download.saveAs('./downloaded-file.pdf');
|
||||
return download.suggestedFilename();
|
||||
}"
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
```bash
|
||||
# Read clipboard (requires permission)
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read']);
|
||||
return await page.evaluate(() => navigator.clipboard.readText());
|
||||
}"
|
||||
|
||||
# Write to clipboard
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
|
||||
}"
|
||||
```
|
||||
|
||||
## Page Information
|
||||
|
||||
```bash
|
||||
# Get page title
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.title();
|
||||
}"
|
||||
|
||||
# Get current URL
|
||||
playwright-cli run-code "async page => {
|
||||
return page.url();
|
||||
}"
|
||||
|
||||
# Get page content
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.content();
|
||||
}"
|
||||
|
||||
# Get viewport size
|
||||
playwright-cli run-code "async page => {
|
||||
return page.viewportSize();
|
||||
}"
|
||||
```
|
||||
|
||||
## JavaScript Execution
|
||||
|
||||
```bash
|
||||
# Execute JavaScript and return result
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.evaluate(() => {
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
cookiesEnabled: navigator.cookieEnabled
|
||||
};
|
||||
});
|
||||
}"
|
||||
|
||||
# Pass arguments to evaluate
|
||||
playwright-cli run-code "async page => {
|
||||
const multiplier = 5;
|
||||
return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
|
||||
}"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```bash
|
||||
# Try-catch in run-code
|
||||
playwright-cli run-code "async page => {
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 });
|
||||
return 'clicked';
|
||||
} catch (e) {
|
||||
return 'element not found';
|
||||
}
|
||||
}"
|
||||
```
|
||||
|
||||
## Complex Workflows
|
||||
|
||||
```bash
|
||||
# Login and save state
|
||||
playwright-cli run-code "async page => {
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('secret');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await page.waitForURL('**/dashboard');
|
||||
await page.context().storageState({ path: 'auth.json' });
|
||||
return 'Login successful';
|
||||
}"
|
||||
|
||||
# Scrape data from multiple pages
|
||||
playwright-cli run-code "async page => {
|
||||
const results = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.goto(\`https://example.com/page/\${i}\`);
|
||||
const items = await page.locator('.item').allTextContents();
|
||||
results.push(...items);
|
||||
}
|
||||
return results;
|
||||
}"
|
||||
```
|
||||
Generated
Vendored
+225
@@ -0,0 +1,225 @@
|
||||
# Browser Session Management
|
||||
|
||||
Run multiple isolated browser sessions concurrently with state persistence.
|
||||
|
||||
## Named Browser Sessions
|
||||
|
||||
Use `-s` flag to isolate browser contexts:
|
||||
|
||||
```bash
|
||||
# Browser 1: Authentication flow
|
||||
playwright-cli -s=auth open https://app.example.com/login
|
||||
|
||||
# Browser 2: Public browsing (separate cookies, storage)
|
||||
playwright-cli -s=public open https://example.com
|
||||
|
||||
# Commands are isolated by browser session
|
||||
playwright-cli -s=auth fill e1 "user@example.com"
|
||||
playwright-cli -s=public snapshot
|
||||
```
|
||||
|
||||
## Browser Session Isolation Properties
|
||||
|
||||
Each browser session has independent:
|
||||
- Cookies
|
||||
- LocalStorage / SessionStorage
|
||||
- IndexedDB
|
||||
- Cache
|
||||
- Browsing history
|
||||
- Open tabs
|
||||
|
||||
## Browser Session Commands
|
||||
|
||||
```bash
|
||||
# List all browser sessions
|
||||
playwright-cli list
|
||||
|
||||
# Stop a browser session (close the browser)
|
||||
playwright-cli close # stop the default browser
|
||||
playwright-cli -s=mysession close # stop a named browser
|
||||
|
||||
# Stop all browser sessions
|
||||
playwright-cli close-all
|
||||
|
||||
# Forcefully kill all daemon processes (for stale/zombie processes)
|
||||
playwright-cli kill-all
|
||||
|
||||
# Delete browser session user data (profile directory)
|
||||
playwright-cli delete-data # delete default browser data
|
||||
playwright-cli -s=mysession delete-data # delete named browser data
|
||||
```
|
||||
|
||||
## Environment Variable
|
||||
|
||||
Set a default browser session name via environment variable:
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_CLI_SESSION="mysession"
|
||||
playwright-cli open example.com # Uses "mysession" automatically
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Concurrent Scraping
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Scrape multiple sites concurrently
|
||||
|
||||
# Start all browsers
|
||||
playwright-cli -s=site1 open https://site1.com &
|
||||
playwright-cli -s=site2 open https://site2.com &
|
||||
playwright-cli -s=site3 open https://site3.com &
|
||||
wait
|
||||
|
||||
# Take snapshots from each
|
||||
playwright-cli -s=site1 snapshot
|
||||
playwright-cli -s=site2 snapshot
|
||||
playwright-cli -s=site3 snapshot
|
||||
|
||||
# Cleanup
|
||||
playwright-cli close-all
|
||||
```
|
||||
|
||||
### A/B Testing Sessions
|
||||
|
||||
```bash
|
||||
# Test different user experiences
|
||||
playwright-cli -s=variant-a open "https://app.com?variant=a"
|
||||
playwright-cli -s=variant-b open "https://app.com?variant=b"
|
||||
|
||||
# Compare
|
||||
playwright-cli -s=variant-a screenshot
|
||||
playwright-cli -s=variant-b screenshot
|
||||
```
|
||||
|
||||
### Persistent Profile
|
||||
|
||||
By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
|
||||
|
||||
```bash
|
||||
# Use persistent profile (auto-generated location)
|
||||
playwright-cli open https://example.com --persistent
|
||||
|
||||
# Use persistent profile with custom directory
|
||||
playwright-cli open https://example.com --profile=/path/to/profile
|
||||
```
|
||||
|
||||
## Attaching to a Running Browser
|
||||
|
||||
Use `attach` to connect to a browser that is already running, instead of launching a new one.
|
||||
|
||||
### Attach by channel name
|
||||
|
||||
Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance".
|
||||
|
||||
```bash
|
||||
# Attach to Chrome
|
||||
playwright-cli attach --cdp=chrome
|
||||
|
||||
# Attach to Chrome Canary
|
||||
playwright-cli attach --cdp=chrome-canary
|
||||
|
||||
# Attach to Microsoft Edge
|
||||
playwright-cli attach --cdp=msedge
|
||||
|
||||
# Attach to Edge Dev
|
||||
playwright-cli attach --cdp=msedge-dev
|
||||
```
|
||||
|
||||
Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`.
|
||||
|
||||
When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=<name>` to override.
|
||||
|
||||
### Attach via CDP endpoint
|
||||
|
||||
Connect to a browser that exposes a Chrome DevTools Protocol endpoint:
|
||||
|
||||
```bash
|
||||
playwright-cli attach --cdp=http://localhost:9222
|
||||
```
|
||||
|
||||
### Attach via browser extension
|
||||
|
||||
Connect to a browser with the Playwright extension installed:
|
||||
|
||||
```bash
|
||||
playwright-cli attach --extension
|
||||
```
|
||||
|
||||
### Detach
|
||||
|
||||
Tear down an attached session without affecting the external browser:
|
||||
|
||||
```bash
|
||||
# Detach the default attached session
|
||||
playwright-cli detach
|
||||
|
||||
# Detach a specific attached session
|
||||
playwright-cli -s=msedge detach
|
||||
```
|
||||
|
||||
`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`.
|
||||
|
||||
## Default Browser Session
|
||||
|
||||
When `-s` is omitted, commands use the default browser session:
|
||||
|
||||
```bash
|
||||
# These use the same default browser session
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli snapshot
|
||||
playwright-cli close # Stops default browser
|
||||
```
|
||||
|
||||
## Browser Session Configuration
|
||||
|
||||
Configure a browser session with specific settings when opening:
|
||||
|
||||
```bash
|
||||
# Open with config file
|
||||
playwright-cli open https://example.com --config=.playwright/my-cli.json
|
||||
|
||||
# Open with specific browser
|
||||
playwright-cli open https://example.com --browser=firefox
|
||||
|
||||
# Open in headed mode
|
||||
playwright-cli open https://example.com --headed
|
||||
|
||||
# Open with persistent profile
|
||||
playwright-cli open https://example.com --persistent
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Name Browser Sessions Semantically
|
||||
|
||||
```bash
|
||||
# GOOD: Clear purpose
|
||||
playwright-cli -s=github-auth open https://github.com
|
||||
playwright-cli -s=docs-scrape open https://docs.example.com
|
||||
|
||||
# AVOID: Generic names
|
||||
playwright-cli -s=s1 open https://github.com
|
||||
```
|
||||
|
||||
### 2. Always Clean Up
|
||||
|
||||
```bash
|
||||
# Stop browsers when done
|
||||
playwright-cli -s=auth close
|
||||
playwright-cli -s=scrape close
|
||||
|
||||
# Or stop all at once
|
||||
playwright-cli close-all
|
||||
|
||||
# If browsers become unresponsive or zombie processes remain
|
||||
playwright-cli kill-all
|
||||
```
|
||||
|
||||
### 3. Delete Stale Browser Data
|
||||
|
||||
```bash
|
||||
# Remove old browser data to free disk space
|
||||
playwright-cli -s=oldsession delete-data
|
||||
```
|
||||
Generated
Vendored
+305
@@ -0,0 +1,305 @@
|
||||
# Spec-driven testing (plan → generate → heal)
|
||||
|
||||
End-to-end workflow for authoring and maintaining Playwright tests using `playwright-cli`. The three sections below can be used independently:
|
||||
|
||||
- **Planning** — explore the app, produce a spec file describing what to test.
|
||||
- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale.
|
||||
- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality.
|
||||
|
||||
All three lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics and [test-generation.md](test-generation.md) for how every `playwright-cli` action emits Playwright TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## 1. Planning
|
||||
|
||||
Goal: produce a spec file (e.g. `specs/<feature>.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file.
|
||||
|
||||
### 1.1 Prerequisite: workspace
|
||||
|
||||
Check the workspace has Playwright installed before anything else:
|
||||
|
||||
```bash
|
||||
# Either of these confirms a workspace:
|
||||
test -f playwright.config.ts || test -f playwright.config.js
|
||||
npx --no-install playwright --version
|
||||
```
|
||||
|
||||
If there is no Playwright install, bootstrap one and let the user pick the defaults:
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
```
|
||||
|
||||
### 1.2 Prerequisite: seed test
|
||||
|
||||
A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins.
|
||||
|
||||
Minimum viable seed:
|
||||
|
||||
```ts
|
||||
// tests/seed.spec.ts
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
await page.goto('https://example.com/');
|
||||
});
|
||||
```
|
||||
|
||||
Preferred — push navigation into a fixture so scenario tests reuse it:
|
||||
|
||||
```ts
|
||||
// tests/fixtures.ts
|
||||
import { test as baseTest } from '@playwright/test';
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
export const test = baseTest.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.goto('https://example.com/');
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// tests/seed.spec.ts
|
||||
import { test } from './fixtures';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
// Fixture already navigates. This empty body tells agents where to start.
|
||||
});
|
||||
```
|
||||
|
||||
If no seed exists, create one that at least navigates to the app.
|
||||
|
||||
### 1.3 Explore the app
|
||||
|
||||
Launch the app via the seed in the background and attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli
|
||||
# wait for "Debugging Instructions" and the session name tw-XXXX
|
||||
playwright-cli attach tw-XXXX
|
||||
```
|
||||
|
||||
Resume so the seed runs, then probe the app:
|
||||
|
||||
```bash
|
||||
playwright-cli resume # resume so that seed test runs fully
|
||||
playwright-cli snapshot # inventory of interactive elements
|
||||
playwright-cli click e5 # follow a flow
|
||||
playwright-cli eval "location.href" # read URL / state
|
||||
playwright-cli show --annotate # ask the user to point at something
|
||||
```
|
||||
|
||||
Map out:
|
||||
|
||||
- Interactive surfaces (forms, buttons, lists, filters, modals).
|
||||
- Primary user journeys end-to-end.
|
||||
- Edge cases: empty states, validation errors, very long input, boundary values.
|
||||
- Persistence: reload, local/session storage, URL fragments.
|
||||
- Navigation: which controls change the URL, back/forward behaviour.
|
||||
|
||||
**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||
**Important**: Stop the background test when done exploring.
|
||||
|
||||
### 1.4 Write the spec file
|
||||
|
||||
Save under `specs/<feature>.plan.md`. Use this structure:
|
||||
|
||||
```markdown
|
||||
# <Feature> Test Plan
|
||||
|
||||
## Application Overview
|
||||
|
||||
<One paragraph describing what the feature does and why it matters.>
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. <Group Name>
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
|
||||
#### 1.1. <kebab-case-scenario-name>
|
||||
|
||||
**File:** `tests/<group>/<kebab-case-scenario-name>.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. <Concrete user step>
|
||||
- expect: <observable outcome>
|
||||
- expect: <another observable outcome>
|
||||
2. <Next step>
|
||||
- expect: <outcome>
|
||||
|
||||
#### 1.2. <next-scenario>
|
||||
...
|
||||
|
||||
### 2. <Next Group>
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
...
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Each scenario is independent and starts from the seed's fresh state — never chain scenarios.
|
||||
- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`).
|
||||
- Cover happy path, edge cases, validation, negative flows, persistence.
|
||||
- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`").
|
||||
- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Generate
|
||||
|
||||
Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted.
|
||||
|
||||
### 2.1 Inputs
|
||||
|
||||
- **Spec file**, e.g. `specs/basic-operations.plan.md`.
|
||||
- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all.
|
||||
- **Seed file**, read from the `**Seed:**` line of the scenario's group.
|
||||
|
||||
### 2.2 Generate one scenario
|
||||
|
||||
For each target scenario, in sequence (never in parallel — scenarios share the seed session):
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test <seed-file> --debug=cli # background
|
||||
playwright-cli attach tw-XXXX
|
||||
# resume
|
||||
```
|
||||
|
||||
**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||
|
||||
Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected.
|
||||
|
||||
Every action prints the equivalent Playwright TypeScript (see [test-generation.md](test-generation.md)):
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot # find refs
|
||||
playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...)
|
||||
playwright-cli press Enter
|
||||
playwright-cli click e7
|
||||
```
|
||||
|
||||
For each `- expect:` bullet, add an explicit assertion. See [test-generation.md](test-generation.md) for details.
|
||||
|
||||
Collect the generated code and write the test file at the path given in the spec:
|
||||
|
||||
```ts
|
||||
// spec: specs/basic-operations.plan.md
|
||||
// seed: tests/seed.spec.ts
|
||||
import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file
|
||||
|
||||
test.describe('Signing in and out', () => {
|
||||
test('should sign in', async ({ page }) => {
|
||||
// 1. Navigate to the application
|
||||
// (handled by the seed fixture)
|
||||
|
||||
// 2. Type 'John Doe' into the username field
|
||||
await page.getByRole('textbox', { name: 'username' }).fill('John Doe');
|
||||
|
||||
// 3. Type password
|
||||
await page.getByRole('textbox', { name: 'password' }).fill('TestPassword');
|
||||
|
||||
// 4. Press Enter to submit
|
||||
await page.getByRole('textbox', { name: 'password' }).press('Enter');
|
||||
|
||||
await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal).
|
||||
- Prefix each numbered step with a `// N. <step text>` comment before its actions.
|
||||
- Use the describe group name verbatim from the spec (no `1.` ordinal).
|
||||
- Import from `./fixtures` if the project has one; otherwise `@playwright/test`.
|
||||
- **Important**: close the CLI session and stop the background test before moving to the next scenario.
|
||||
|
||||
### 2.3 Generate multiple scenarios
|
||||
|
||||
Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped.
|
||||
|
||||
### 2.4 Run generated tests
|
||||
|
||||
After generation, run the new tests once:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts
|
||||
```
|
||||
|
||||
Any failure goes to Section 3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Heal
|
||||
|
||||
Goal: fix failing tests, and update the spec if the app's intended behaviour changed.
|
||||
|
||||
### 3.1 Find failing tests
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||
```
|
||||
|
||||
Record the list of failing `<file>:<line>` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile.
|
||||
|
||||
### 3.2 Debug one failure
|
||||
|
||||
Run the single failing test in debug mode in the background, then attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts:<line> --debug=cli
|
||||
# wait for "Debugging Instructions" and the tw-XXXX session name
|
||||
playwright-cli attach tw-XXXX
|
||||
```
|
||||
|
||||
The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose:
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot # did the element change / move / rename?
|
||||
playwright-cli console # app-side errors?
|
||||
playwright-cli requests # failed request? wrong payload?
|
||||
playwright-cli show --annotate # ask the user to point somewhere
|
||||
```
|
||||
|
||||
Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs.
|
||||
|
||||
Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test.
|
||||
|
||||
### 3.3 Apply the fix
|
||||
|
||||
Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green.
|
||||
|
||||
Never skip hooks or add sleeps as a fix. Never use `networkidle`.
|
||||
|
||||
### 3.4 Reconcile with the spec
|
||||
|
||||
Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test.
|
||||
|
||||
- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone.
|
||||
- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change.
|
||||
- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide:
|
||||
- the scenario id (e.g. `2.3`),
|
||||
- the spec lines that no longer match,
|
||||
- the observed app behaviour (quote a snapshot excerpt or a concrete outcome).
|
||||
|
||||
Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression).
|
||||
|
||||
### 3.5 Iteration and giving up
|
||||
|
||||
- Fix failures one at a time; rerun after each.
|
||||
- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
| For... | See |
|
||||
|---|---|
|
||||
| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) |
|
||||
| How `playwright-cli` actions become TS | [test-generation.md](test-generation.md) |
|
||||
| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) |
|
||||
| Managing the CLI browser session | [session-management.md](session-management.md) |
|
||||
Generated
Vendored
+275
@@ -0,0 +1,275 @@
|
||||
# Storage Management
|
||||
|
||||
Manage cookies, localStorage, sessionStorage, and browser storage state.
|
||||
|
||||
## Storage State
|
||||
|
||||
Save and restore complete browser state including cookies and storage.
|
||||
|
||||
### Save Storage State
|
||||
|
||||
```bash
|
||||
# Save to auto-generated filename (storage-state-{timestamp}.json)
|
||||
playwright-cli state-save
|
||||
|
||||
# Save to specific filename
|
||||
playwright-cli state-save my-auth-state.json
|
||||
```
|
||||
|
||||
### Restore Storage State
|
||||
|
||||
```bash
|
||||
# Load storage state from file
|
||||
playwright-cli state-load my-auth-state.json
|
||||
|
||||
# Reload page to apply cookies
|
||||
playwright-cli open https://example.com
|
||||
```
|
||||
|
||||
### Storage State File Format
|
||||
|
||||
The saved file contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "session_id",
|
||||
"value": "abc123",
|
||||
"domain": "example.com",
|
||||
"path": "/",
|
||||
"expires": 1893456000,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://example.com",
|
||||
"localStorage": [
|
||||
{ "name": "theme", "value": "dark" },
|
||||
{ "name": "user_id", "value": "12345" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Cookies
|
||||
|
||||
### List All Cookies
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list
|
||||
```
|
||||
|
||||
### Filter Cookies by Domain
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list --domain=example.com
|
||||
```
|
||||
|
||||
### Filter Cookies by Path
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list --path=/api
|
||||
```
|
||||
|
||||
### Get Specific Cookie
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-get session_id
|
||||
```
|
||||
|
||||
### Set a Cookie
|
||||
|
||||
```bash
|
||||
# Basic cookie
|
||||
playwright-cli cookie-set session abc123
|
||||
|
||||
# Cookie with options
|
||||
playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
|
||||
|
||||
# Cookie with expiration (Unix timestamp)
|
||||
playwright-cli cookie-set remember_me token123 --expires=1893456000
|
||||
```
|
||||
|
||||
### Delete a Cookie
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-delete session_id
|
||||
```
|
||||
|
||||
### Clear All Cookies
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Cookies or Custom Options
|
||||
|
||||
For complex scenarios like adding multiple cookies at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().addCookies([
|
||||
{ name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
|
||||
{ name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
|
||||
]);
|
||||
}"
|
||||
```
|
||||
|
||||
## Local Storage
|
||||
|
||||
### List All localStorage Items
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-get token
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-set theme dark
|
||||
```
|
||||
|
||||
### Set JSON Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}'
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-delete token
|
||||
```
|
||||
|
||||
### Clear All localStorage
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Operations
|
||||
|
||||
For complex scenarios like setting multiple values at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('token', 'jwt_abc123');
|
||||
localStorage.setItem('user_id', '12345');
|
||||
localStorage.setItem('expires_at', Date.now() + 3600000);
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Session Storage
|
||||
|
||||
### List All sessionStorage Items
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-get form_data
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-set step 3
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-delete step
|
||||
```
|
||||
|
||||
### Clear sessionStorage
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-clear
|
||||
```
|
||||
|
||||
## IndexedDB
|
||||
|
||||
### List Databases
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.evaluate(async () => {
|
||||
const databases = await indexedDB.databases();
|
||||
return databases;
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Delete Database
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
indexedDB.deleteDatabase('myDatabase');
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Authentication State Reuse
|
||||
|
||||
```bash
|
||||
# Step 1: Login and save state
|
||||
playwright-cli open https://app.example.com/login
|
||||
playwright-cli snapshot
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
playwright-cli fill e2 "password123"
|
||||
playwright-cli click e3
|
||||
|
||||
# Save the authenticated state
|
||||
playwright-cli state-save auth.json
|
||||
|
||||
# Step 2: Later, restore state and skip login
|
||||
playwright-cli state-load auth.json
|
||||
playwright-cli open https://app.example.com/dashboard
|
||||
# Already logged in!
|
||||
```
|
||||
|
||||
### Save and Restore Roundtrip
|
||||
|
||||
```bash
|
||||
# Set up authentication state
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
|
||||
|
||||
# Save state to file
|
||||
playwright-cli state-save my-session.json
|
||||
|
||||
# ... later, in a new session ...
|
||||
|
||||
# Restore state
|
||||
playwright-cli state-load my-session.json
|
||||
playwright-cli open https://example.com
|
||||
# Cookies and localStorage are restored!
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit storage state files containing auth tokens
|
||||
- Add `*.auth-state.json` to `.gitignore`
|
||||
- Delete state files after automation completes
|
||||
- Use environment variables for sensitive data
|
||||
- By default, sessions run in-memory mode which is safer for sensitive operations
|
||||
Generated
Vendored
+134
@@ -0,0 +1,134 @@
|
||||
# Test Generation
|
||||
|
||||
Generate Playwright test code automatically as you interact with the browser.
|
||||
|
||||
## How It Works
|
||||
|
||||
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
|
||||
This code appears in the output and can be copied directly into your test files.
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```bash
|
||||
# Start a session
|
||||
playwright-cli open https://example.com/login
|
||||
|
||||
# Take a snapshot to see elements
|
||||
playwright-cli snapshot
|
||||
# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
|
||||
|
||||
# Fill form fields - generates code automatically
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
|
||||
playwright-cli fill e2 "password123"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
|
||||
playwright-cli click e3
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
```
|
||||
|
||||
## Building a Test File
|
||||
|
||||
Collect the generated code into a Playwright test:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('login flow', async ({ page }) => {
|
||||
// Generated code from playwright-cli session:
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Add assertions
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Semantic Locators
|
||||
|
||||
The generated code uses role-based locators when possible, which are more resilient:
|
||||
|
||||
```typescript
|
||||
// Generated (good - semantic)
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Avoid (fragile - CSS selectors)
|
||||
await page.locator('#submit-btn').click();
|
||||
```
|
||||
|
||||
### 2. Explore Before Recording
|
||||
|
||||
Take snapshots to understand the page structure before recording actions:
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli snapshot
|
||||
# Review the element structure
|
||||
playwright-cli click e5
|
||||
```
|
||||
|
||||
### 3. Add Assertions Manually
|
||||
|
||||
Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers:
|
||||
|
||||
- `toBeVisible()` — element is rendered and visible
|
||||
- `toHaveText(text)` — element text content matches
|
||||
- `toHaveValue(value) / toBeEmpty()` — input/select value matches
|
||||
- `toBeChecked() / toBeUnchecked()` — checkbox state matches
|
||||
- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot
|
||||
|
||||
Use `playwright-cli generate-locator <target>` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value.
|
||||
|
||||
When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead.
|
||||
|
||||
Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values.
|
||||
|
||||
```bash
|
||||
# Get a stable locator for an element ref to use in the assertion
|
||||
playwright-cli --raw generate-locator e5
|
||||
# getByRole('button', { name: 'Submit' })
|
||||
|
||||
# Capture expected text content for toHaveText
|
||||
playwright-cli --raw eval "el => el.textContent" e5
|
||||
|
||||
# Capture expected input value for toHaveValue/toBeEmpty
|
||||
playwright-cli --raw eval "el => el.value" e5
|
||||
|
||||
# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked
|
||||
# (whole page, or use a ref to scope to a region)
|
||||
playwright-cli --raw snapshot
|
||||
playwright-cli --raw snapshot e5
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Generated action
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Manual assertions using the outputs above:
|
||||
await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible();
|
||||
await expect(page.getByTestId('main-header')).toHaveText('Welcome, user');
|
||||
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('user@example.com');
|
||||
await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked();
|
||||
|
||||
// toMatchAriaSnapshot on the whole page, finds a matching region
|
||||
await expect(page).toMatchAriaSnapshot(`
|
||||
- heading "Welcome, user"
|
||||
- link /\\d+ new messages?/
|
||||
- button "Sign out"
|
||||
`);
|
||||
|
||||
// toMatchAriaSnapshot scoped to a region
|
||||
await expect(page.getByRole('navigation')).toMatchAriaSnapshot(`
|
||||
- link "Home"
|
||||
- link /\\d+ new messages?/
|
||||
- link "Profile"
|
||||
`);
|
||||
```
|
||||
Generated
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
# Tracing
|
||||
|
||||
Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Start trace recording
|
||||
playwright-cli tracing-start
|
||||
|
||||
# Perform actions
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli click e1
|
||||
playwright-cli fill e2 "test"
|
||||
|
||||
# Stop trace recording
|
||||
playwright-cli tracing-stop
|
||||
```
|
||||
|
||||
## Trace Output Files
|
||||
|
||||
When you start tracing, Playwright creates a `traces/` directory with several files:
|
||||
|
||||
### `trace-{timestamp}.trace`
|
||||
|
||||
**Action log** - The main trace file containing:
|
||||
- Every action performed (clicks, fills, navigations)
|
||||
- DOM snapshots before and after each action
|
||||
- Screenshots at each step
|
||||
- Timing information
|
||||
- Console messages
|
||||
- Source locations
|
||||
|
||||
### `trace-{timestamp}.network`
|
||||
|
||||
**Network log** - Complete network activity:
|
||||
- All HTTP requests and responses
|
||||
- Request headers and bodies
|
||||
- Response headers and bodies
|
||||
- Timing (DNS, connect, TLS, TTFB, download)
|
||||
- Resource sizes
|
||||
- Failed requests and errors
|
||||
|
||||
### `resources/`
|
||||
|
||||
**Resources directory** - Cached resources:
|
||||
- Images, fonts, stylesheets, scripts
|
||||
- Response bodies for replay
|
||||
- Assets needed to reconstruct page state
|
||||
|
||||
## What Traces Capture
|
||||
|
||||
| Category | Details |
|
||||
|----------|---------|
|
||||
| **Actions** | Clicks, fills, hovers, keyboard input, navigations |
|
||||
| **DOM** | Full DOM snapshot before/after each action |
|
||||
| **Screenshots** | Visual state at each step |
|
||||
| **Network** | All requests, responses, headers, bodies, timing |
|
||||
| **Console** | All console.log, warn, error messages |
|
||||
| **Timing** | Precise timing for each operation |
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Debugging Failed Actions
|
||||
|
||||
```bash
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://app.example.com
|
||||
|
||||
# This click fails - why?
|
||||
playwright-cli click e5
|
||||
|
||||
playwright-cli tracing-stop
|
||||
# Open trace to see DOM state when click was attempted
|
||||
```
|
||||
|
||||
### Analyzing Performance
|
||||
|
||||
```bash
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://slow-site.com
|
||||
playwright-cli tracing-stop
|
||||
|
||||
# View network waterfall to identify slow resources
|
||||
```
|
||||
|
||||
### Capturing Evidence
|
||||
|
||||
```bash
|
||||
# Record a complete user flow for documentation
|
||||
playwright-cli tracing-start
|
||||
|
||||
playwright-cli open https://app.example.com/checkout
|
||||
playwright-cli fill e1 "4111111111111111"
|
||||
playwright-cli fill e2 "12/25"
|
||||
playwright-cli fill e3 "123"
|
||||
playwright-cli click e4
|
||||
|
||||
playwright-cli tracing-stop
|
||||
# Trace shows exact sequence of events
|
||||
```
|
||||
|
||||
## Trace vs Video vs Screenshot
|
||||
|
||||
| Feature | Trace | Video | Screenshot |
|
||||
|---------|-------|-------|------------|
|
||||
| **Format** | .trace file | .webm video | .png/.jpeg image |
|
||||
| **DOM inspection** | Yes | No | No |
|
||||
| **Network details** | Yes | No | No |
|
||||
| **Step-by-step replay** | Yes | Continuous | Single frame |
|
||||
| **File size** | Medium | Large | Small |
|
||||
| **Best for** | Debugging | Demos | Quick capture |
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Start Tracing Before the Problem
|
||||
|
||||
```bash
|
||||
# Trace the entire flow, not just the failing step
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://example.com
|
||||
# ... all steps leading to the issue ...
|
||||
playwright-cli tracing-stop
|
||||
```
|
||||
|
||||
### 2. Clean Up Old Traces
|
||||
|
||||
Traces can consume significant disk space:
|
||||
|
||||
```bash
|
||||
# Remove traces older than 7 days
|
||||
find .playwright-cli/traces -mtime +7 -delete
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Traces add overhead to automation
|
||||
- Large traces can consume significant disk space
|
||||
- Some dynamic content may not replay perfectly
|
||||
Generated
Vendored
+143
@@ -0,0 +1,143 @@
|
||||
# Video Recording
|
||||
|
||||
Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec).
|
||||
|
||||
## Basic Recording
|
||||
|
||||
```bash
|
||||
# Open browser first
|
||||
playwright-cli open
|
||||
|
||||
# Start recording
|
||||
playwright-cli video-start demo.webm
|
||||
|
||||
# Add a chapter marker for section transitions
|
||||
playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000
|
||||
|
||||
# Navigate and perform actions
|
||||
playwright-cli goto https://example.com
|
||||
playwright-cli snapshot
|
||||
playwright-cli click e1
|
||||
|
||||
# Add another chapter
|
||||
playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000
|
||||
playwright-cli fill e2 "test input"
|
||||
|
||||
# Stop and save
|
||||
playwright-cli video-stop
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Descriptive Filenames
|
||||
|
||||
```bash
|
||||
# Include context in filename
|
||||
playwright-cli video-start recordings/login-flow-2024-01-15.webm
|
||||
playwright-cli video-start recordings/checkout-test-run-42.webm
|
||||
```
|
||||
|
||||
### 2. Record entire hero scripts.
|
||||
|
||||
When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code.
|
||||
It allows inserting appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that.
|
||||
|
||||
1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight.
|
||||
2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses.
|
||||
3) Use playwright-cli run-code --filename your-script.js
|
||||
|
||||
**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page.
|
||||
|
||||
```js
|
||||
async page => {
|
||||
await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } });
|
||||
await page.goto('https://demo.playwright.dev/todomvc');
|
||||
|
||||
// Show a chapter card — blurs the page and shows a dialog.
|
||||
// Blocks until duration expires, then auto-removes.
|
||||
// Use this for simple use cases, but always feel free to hand-craft your own beautiful
|
||||
// overlay via await page.screencast.showOverlay().
|
||||
await page.screencast.showChapter('Adding Todo Items', {
|
||||
description: 'We will add several items to the todo list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Perform action
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Show next chapter
|
||||
await page.screencast.showChapter('Verifying Results', {
|
||||
description: 'Checking the item appeared in the list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Add a sticky annotation that stays while you perform actions.
|
||||
// Overlays are pointer-events: none, so they won't block clicks.
|
||||
const annotation = await page.screencast.showOverlay(`
|
||||
<div style="position: absolute; top: 8px; right: 8px;
|
||||
padding: 6px 12px; background: rgba(0,0,0,0.7);
|
||||
border-radius: 8px; font-size: 13px; color: white;">
|
||||
✓ Item added successfully
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Perform more actions while the annotation is visible
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Remove the annotation when done
|
||||
await annotation.dispose();
|
||||
|
||||
// You can also highlight relevant locators and provide contextual annotations.
|
||||
const bounds = await page.getByText('Walk the dog').boundingBox();
|
||||
await page.screencast.showOverlay(`
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y}px;
|
||||
left: ${bounds.x}px;
|
||||
width: ${bounds.width}px;
|
||||
height: ${bounds.height}px;
|
||||
border: 1px solid red;">
|
||||
</div>
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y + bounds.height + 5}px;
|
||||
left: ${bounds.x + bounds.width / 2}px;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px;
|
||||
background: #808080;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: white;">Check it out, it is right above this text
|
||||
</div>
|
||||
`, { duration: 2000 });
|
||||
|
||||
await page.screencast.stop();
|
||||
}
|
||||
```
|
||||
|
||||
Embrace creativity, overlays are powerful.
|
||||
|
||||
### Overlay API Summary
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions |
|
||||
| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights |
|
||||
| `disposable.dispose()` | Remove a sticky overlay added without duration |
|
||||
| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays |
|
||||
|
||||
## Tracing vs Video
|
||||
|
||||
| Feature | Video | Tracing |
|
||||
|---------|-------|---------|
|
||||
| Output | WebM file | Trace file (viewable in Trace Viewer) |
|
||||
| Shows | Visual recording | DOM snapshots, network, console, actions |
|
||||
| Use case | Demos, documentation | Debugging, analysis |
|
||||
| Size | Larger | Smaller |
|
||||
|
||||
## Limitations
|
||||
|
||||
- Recording adds slight overhead to automation
|
||||
- Large recordings can consume significant disk space
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
+171
@@ -0,0 +1,171 @@
|
||||
---
|
||||
name: playwright-trace
|
||||
description: Inspect Playwright trace files from the command line — list actions, view requests, console, errors, snapshots and screenshots.
|
||||
allowed-tools: Bash(npx:*)
|
||||
---
|
||||
|
||||
# Playwright Trace CLI
|
||||
|
||||
Inspect `.zip` trace files produced by Playwright tests without opening a browser.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Start with `trace open <trace.zip>` to extract the trace and see its metadata.
|
||||
2. Use `trace actions` to see all actions with their action IDs.
|
||||
3. Use `trace action <action-id>` to drill into a specific action — see parameters, logs, source location, and available snapshots.
|
||||
4. Use `trace requests`, `trace console`, or `trace errors` for cross-cutting views.
|
||||
5. Use `trace snapshot <action-id>` to get the DOM snapshot, or run a browser command against it.
|
||||
6. Use `trace close` to remove the extracted trace data when done.
|
||||
|
||||
All commands after `open` operate on the currently opened trace — no need to pass the trace file again. Opening a new trace replaces the previous one.
|
||||
|
||||
## Commands
|
||||
|
||||
### Open a trace
|
||||
|
||||
```bash
|
||||
# Extract trace and show metadata: browser, viewport, duration, action/error counts
|
||||
npx playwright trace open <trace.zip>
|
||||
```
|
||||
|
||||
### Close a trace
|
||||
|
||||
```bash
|
||||
# Remove extracted trace data
|
||||
npx playwright trace close
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
```bash
|
||||
# List all actions as a tree with action IDs and timing
|
||||
npx playwright trace actions
|
||||
|
||||
# Filter by action title (regex, case-insensitive)
|
||||
npx playwright trace actions --grep "click"
|
||||
|
||||
# Only failed actions
|
||||
npx playwright trace actions --errors-only
|
||||
```
|
||||
|
||||
### Action details
|
||||
|
||||
```bash
|
||||
# Show full details for one action: params, result, logs, source, snapshots
|
||||
npx playwright trace action <action-id>
|
||||
```
|
||||
|
||||
The `action` command displays available snapshot phases (before, input, after) and the exact command to extract them.
|
||||
|
||||
### Requests
|
||||
|
||||
```bash
|
||||
# All network requests: method, status, URL, duration, size
|
||||
npx playwright trace requests
|
||||
|
||||
# Filter by URL pattern
|
||||
npx playwright trace requests --grep "api"
|
||||
|
||||
# Filter by HTTP method
|
||||
npx playwright trace requests --method POST
|
||||
|
||||
# Only failed requests (status >= 400)
|
||||
npx playwright trace requests --failed
|
||||
```
|
||||
|
||||
### Request details
|
||||
|
||||
```bash
|
||||
# Show full details for one request: headers, body, security
|
||||
npx playwright trace request <request-id>
|
||||
```
|
||||
|
||||
### Console
|
||||
|
||||
```bash
|
||||
# All console messages and stdout/stderr
|
||||
npx playwright trace console
|
||||
|
||||
# Only errors
|
||||
npx playwright trace console --errors-only
|
||||
|
||||
# Only browser console (no stdout/stderr)
|
||||
npx playwright trace console --browser
|
||||
|
||||
# Only stdout/stderr (no browser console)
|
||||
npx playwright trace console --stdio
|
||||
```
|
||||
|
||||
### Errors
|
||||
|
||||
```bash
|
||||
# All errors with stack traces and associated actions
|
||||
npx playwright trace errors
|
||||
```
|
||||
|
||||
### Snapshots
|
||||
|
||||
The `snapshot` command loads the DOM snapshot for an action into a headless browser and runs a single browser command against it. Without a browser command, it returns the accessibility snapshot.
|
||||
|
||||
```bash
|
||||
# Get the accessibility snapshot (default)
|
||||
npx playwright trace snapshot <action-id>
|
||||
|
||||
# Use a specific phase
|
||||
npx playwright trace snapshot <action-id> --name before
|
||||
|
||||
# Run eval to query the DOM
|
||||
npx playwright trace snapshot <action-id> -- eval "document.title"
|
||||
npx playwright trace snapshot <action-id> -- eval "document.querySelector('#error').textContent"
|
||||
|
||||
# Eval on a specific element ref (from the snapshot)
|
||||
npx playwright trace snapshot <action-id> -- eval "el => el.getAttribute('data-testid')" e5
|
||||
|
||||
# Take a screenshot of the snapshot
|
||||
npx playwright trace snapshot <action-id> -- screenshot
|
||||
|
||||
# Redirect output to a file
|
||||
npx playwright trace snapshot <action-id> -- eval "document.body.outerHTML" --filename=page.html
|
||||
npx playwright trace snapshot <action-id> -- screenshot --filename=screenshot.png
|
||||
```
|
||||
|
||||
Only three browser commands are useful on a frozen snapshot: `snapshot`, `eval`, and `screenshot`.
|
||||
|
||||
### Attachments
|
||||
|
||||
```bash
|
||||
# List all trace attachments
|
||||
npx playwright trace attachments
|
||||
|
||||
# Extract an attachment by its number
|
||||
npx playwright trace attachment 1
|
||||
npx playwright trace attachment 1 -o out.png
|
||||
```
|
||||
|
||||
## Typical investigation
|
||||
|
||||
```bash
|
||||
# 1. Open the trace and see what's inside
|
||||
npx playwright trace open test-results/my-test/trace.zip
|
||||
|
||||
# 2. What actions ran?
|
||||
npx playwright trace actions
|
||||
|
||||
# 3. Which action failed?
|
||||
npx playwright trace actions --errors-only
|
||||
|
||||
# 4. What went wrong?
|
||||
npx playwright trace action 12
|
||||
|
||||
# 5. What did the page look like at that moment?
|
||||
npx playwright trace snapshot 12
|
||||
|
||||
# 6. Query the DOM for more detail
|
||||
npx playwright trace snapshot 12 -- eval "document.querySelector('.error-message').textContent"
|
||||
|
||||
# 7. Any relevant network failures?
|
||||
npx playwright trace requests --failed
|
||||
|
||||
# 8. Any console errors?
|
||||
npx playwright trace console --errors-only
|
||||
```
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var extension_exports = {};
|
||||
__export(extension_exports, {
|
||||
isPlaywrightExtensionInstalled: () => isPlaywrightExtensionInstalled,
|
||||
playwrightExtensionId: () => playwrightExtensionId,
|
||||
playwrightExtensionInstallUrl: () => playwrightExtensionInstallUrl
|
||||
});
|
||||
module.exports = __toCommonJS(extension_exports);
|
||||
var import_fs = __toESM(require("fs"));
|
||||
var import_path = __toESM(require("path"));
|
||||
const playwrightExtensionId = "mmlmfjhmonkocbjadbfplnigmagldckm";
|
||||
const playwrightExtensionInstallUrl = `https://chromewebstore.google.com/detail/playwright-extension/${playwrightExtensionId}`;
|
||||
async function isPlaywrightExtensionInstalled(userDataDir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await import_fs.default.promises.readdir(userDataDir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry !== "Default" && !entry.startsWith("Profile "))
|
||||
continue;
|
||||
if (await isExtensionInstalledInProfile(import_path.default.join(userDataDir, entry)))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function isExtensionInstalledInProfile(profileDir) {
|
||||
if (await pathExists(import_path.default.join(profileDir, "Extensions", playwrightExtensionId)))
|
||||
return true;
|
||||
try {
|
||||
const prefs = await import_fs.default.promises.readFile(import_path.default.join(profileDir, "Preferences"), "utf-8");
|
||||
return prefs.includes(`"${playwrightExtensionId}"`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function pathExists(p) {
|
||||
try {
|
||||
await import_fs.default.promises.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
isPlaywrightExtensionInstalled,
|
||||
playwrightExtensionId,
|
||||
playwrightExtensionInstallUrl
|
||||
});
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
"use strict";
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var socketConnection_exports = {};
|
||||
__export(socketConnection_exports, {
|
||||
SocketConnection: () => SocketConnection,
|
||||
compareSemver: () => compareSemver
|
||||
});
|
||||
module.exports = __toCommonJS(socketConnection_exports);
|
||||
class SocketConnection {
|
||||
constructor(socket) {
|
||||
this._pendingBuffers = [];
|
||||
this._socket = socket;
|
||||
socket.on("data", (buffer) => this._onData(buffer));
|
||||
socket.on("close", () => {
|
||||
this.onclose?.();
|
||||
});
|
||||
socket.on("error", (e) => console.error(`error: ${e.message}`));
|
||||
}
|
||||
async send(message) {
|
||||
await new Promise((resolve, reject) => {
|
||||
this._socket.write(`${JSON.stringify(message)}
|
||||
`, (error) => {
|
||||
if (error)
|
||||
reject(error);
|
||||
else
|
||||
resolve(void 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
close() {
|
||||
this._socket.destroy();
|
||||
}
|
||||
_onData(buffer) {
|
||||
let end = buffer.indexOf("\n");
|
||||
if (end === -1) {
|
||||
this._pendingBuffers.push(buffer);
|
||||
return;
|
||||
}
|
||||
this._pendingBuffers.push(buffer.slice(0, end));
|
||||
const message = Buffer.concat(this._pendingBuffers).toString();
|
||||
this._dispatchMessage(message);
|
||||
let start = end + 1;
|
||||
end = buffer.indexOf("\n", start);
|
||||
while (end !== -1) {
|
||||
const message2 = buffer.toString(void 0, start, end);
|
||||
this._dispatchMessage(message2);
|
||||
start = end + 1;
|
||||
end = buffer.indexOf("\n", start);
|
||||
}
|
||||
this._pendingBuffers = [buffer.slice(start)];
|
||||
}
|
||||
_dispatchMessage(message) {
|
||||
try {
|
||||
this.onmessage?.(JSON.parse(message));
|
||||
} catch (e) {
|
||||
console.error("failed to dispatch message", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
function compareSemver(a, b) {
|
||||
const aBase = a.replace(/-.*$/, "");
|
||||
const bBase = b.replace(/-.*$/, "");
|
||||
const aParts = aBase.split(".").map(Number);
|
||||
const bParts = bBase.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (aParts[i] > bParts[i])
|
||||
return 1;
|
||||
if (aParts[i] < bParts[i])
|
||||
return -1;
|
||||
}
|
||||
const aTimestamp = parseSuffixTimestamp(a);
|
||||
const bTimestamp = parseSuffixTimestamp(b);
|
||||
if (aTimestamp > bTimestamp)
|
||||
return 1;
|
||||
if (aTimestamp < bTimestamp)
|
||||
return -1;
|
||||
return 0;
|
||||
}
|
||||
function parseSuffixTimestamp(version) {
|
||||
const match = version.match(/^\d+\.\d+\.\d+-(?:alpha|beta)-(.+)$/);
|
||||
if (!match)
|
||||
return Infinity;
|
||||
const suffix = match[1];
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(suffix))
|
||||
return new Date(suffix).getTime();
|
||||
return Number(suffix);
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
SocketConnection,
|
||||
compareSemver
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user