diff --git a/.gitignore b/.gitignore index 92f1625..11bc4ce 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,7 @@ media/ .superpowers/ *.egg-info/ # Simulator sample media (look-tuning only; populate via setup_sample_media.py -# / setup_scales_media.py). All scale + transition binaries are gitignored. +# / build_pool_manifest.py). All scale + transition binaries are gitignored. simulator/sample_media/**/*.mp4 +# Local-only candidate review gallery (re-buildable; not shipped content). +simulator/static/review.html diff --git a/docs/content-candidate-pool.md b/docs/content-candidate-pool.md index bb47293..bd4b57d 100644 --- a/docs/content-candidate-pool.md +++ b/docs/content-candidate-pool.md @@ -1,5 +1,11 @@ # Content candidate pool — operator-selected, vetted (2026-06-24) +> **WIRED (session 0016).** The rotating pool below is now the live manifest: +> `simulator/build_pool_manifest.py` regenerates `simulator/sample_media/manifest.json` +> from this pool; the ring scale `forest`→`coast` rename + the cleanup list (bottom) +> are done; the player picks a random pool member on landing +> (`player.ring.pick_clip_id`). See content-pipeline design §11. + The 5 scales each get a **rotating pool** of clips. When the viewer lands on a scale by zooming the ring in/out, the player **randomly picks one clip from that scale's pool**. (This supersedes the one-clip-per-scale model — see @@ -93,11 +99,14 @@ All NOAA Ocean Exploration (PD, 17 U.S.C. §105). These have creatures drifting > Crossota has only ~17s clean (too short for a 24s window), comb jelly has a > species caption covering the whole clip. -## On disk but NOT in any pool (clean up next session) +## On disk but NOT in any pool — ✅ CLEANED UP (session 0016) -- `cosmos_traverse` — unselected (the #6/#3 mix‑up above). -- `orbit_westcoast` — rejected (ISS hardware in frame); leftover output dir. -- `forest` — the old Yosemite base, superseded by the `coast` pool. -- `reef` — the original placeholder/early reef base, superseded by the `reef_*` pool. -- The `review.html` gallery + all `cosmos/orbit/coast/reef/abyss` candidate dirs are - local‑only (gitignored). +Removed from local media (all were gitignored, so repo-invisible): + +- `cosmos_traverse` — unselected (the #6/#3 mix‑up above). **dropped.** +- `orbit_westcoast` — rejected (ISS hardware in frame). **dropped.** +- `forest` — the old Yosemite base, superseded by the `coast` pool. **dropped.** +- `reef` — the original placeholder/early reef base, superseded by `reef_*`. **dropped.** +- `orbit` / `abyss` — the Increment‑1 single real bases (NASA ISS Exp65 / ctenophore), + not in the new pools. **dropped** (the cosmos Orion base stays as the cosmos pool primary). +- `review.html` gallery — now `.gitignore`d (local‑only, re‑buildable). diff --git a/docs/superpowers/specs/2026-06-24-content-pipeline-design.md b/docs/superpowers/specs/2026-06-24-content-pipeline-design.md index d41a515..f121d65 100644 --- a/docs/superpowers/specs/2026-06-24-content-pipeline-design.md +++ b/docs/superpowers/specs/2026-06-24-content-pipeline-design.md @@ -1,7 +1,7 @@ # HEF — Content Pipeline (source → process → label → manifest) **Date:** 2026-06-24 -**Status:** Graduated — operator-approved (session 0014); **Increment 1 built & merged** (PR #13). Increment 2 (hybrid track pass + author mode) + real PD sourcing pending. +**Status:** Graduated — operator-approved (session 0014); **Increment 1 built & merged** (PR #13); real PD sourcing + rotating pool curated (PR #14/#15). **Increment 2 schema pinned + building** (session 0016 — see §11). **Repo:** `human-experience-filter-art` **Anchor:** `docs/ROADMAP.md` sub-project 3 (Player Runtime) content needs; the spiritual successor to sub-project 2 (Ingest & Tagging tools), retargeted from the @@ -239,7 +239,114 @@ master encode (waits on a measured pano spec). download + provenance record for v1. - **Crossfade overlap length** per scale (slow cosmos vs busier reef) — by eye. -## 10. Out of scope (YAGNI) +## 11. Increment 2 — schema decisions (session 0016) + +Increment 2 is the second build order from §8. It is mostly **schema work**, so +this section pins the concrete data shapes before the build (the operator asked for +a short design pass first). Each subsection is a new, **backward-compatible** +extension — Increment 1 manifests keep rendering unchanged. + +### 11.1 Rotating clip pool (supersedes one-clip-per-scale) + +Each ring scale references a **pool** of vetted clips +(`docs/content-candidate-pool.md`); the player picks **one member at random when +the viewer lands on that scale** (zooming the ring in/out). This supersedes +decision §5's one-base-per-scale. + +- **Manifest:** `ring.scales[i]` gains `"pool": ["clip_id", …]` (≥1). The legacy + `"clip_id"` stays as the **primary** member for back-compat (= `pool[0]` when a + pool is written); a scale with only `clip_id` and no `pool` behaves exactly as + Increment 1 (pool of one). +- **Canonical logic (`player/ring.py`):** `Scale` gains `pool: tuple[str, …]`; + `members` = `pool or (clip_id,)`. A **pure** `pick_clip_id(scale, r)` takes an + injected `r ∈ [0,1)` and returns `members[int(r·len)]` — randomness stays + **injected** (DI), so the function is pure/testable and the Pi reuses it. +- **Where the pick happens:** at the **API boundary** (`simulator/app.py`), which + may be impure: the advance endpoint calls `pick_clip_id(landed_scale, + random.random())` and returns it as `target_clip_id`. A `delta=0` advance is the + **initial / re-roll** pick (no-op move, fresh random member). The client loads + `move.target_clip_id` directly instead of deriving the clip from the index. One + selection implementation, Python-canonical (browser still "only renders"). +- **Rename + cleanup:** ring scale `forest` → **`coast`** (new land↔sea scale + between `orbit` and `reef`). Drop the leftovers `cosmos_traverse`, + `orbit_westcoast`, old `forest`, old `reef` (per the pool doc's cleanup list); + the cosmos pool's primary stays `cosmos` (Orion). New ring order: + `cosmos → orbit → coast → reef → abyss → (wrap) cosmos`; edges regenerated + (`orbit-coast`, `coast-reef` placeholders via `setup_scales_media.py`). + +### 11.2 Time-windowed tracked labels + +Objects drift **into and out of frame**, so a label appears only **while its +object is on screen** and its box **tracks** the object (the §4 keyframed `track`). + +- **Manifest:** an annotation gains optional `"appear"` and `"disappear"` + (loop-normalized `t ∈ [0,1]`, same clock as `track`). The label renders only + while `appear ≤ loopT ≤ disappear`. To let an object straddle the loop seam, a + **`disappear < appear` window wraps** (visible when `loopT ≥ appear` **or** + `loopT ≤ disappear`). Absent both fields → always-present (Increment-1 behavior). +- **Runtime:** client-side in `renderOverlay` — a window test gates the annotation + before the existing `boxAt(loopT)` placement. Engine unchanged (labels are not in + `RenderPlan`). The **abyss + reef** pool clips (creatures entering/leaving) are + the authored test material. + +### 11.3 Progressive LEFT-brain detail tiers (replaces flat `min_level` gating) + +The Left knob now drives **how many** labels show **and how analytical** each is — +no fading. Low Left = **fewer, more general** labels; high Left = **more** labels + +**more analytical** detail. + +- **Per-object `salience` (1–4):** how prominent the object is. It sets the Left + level at which the label first appears: `first_level = 5 − salience` (salience 4 → + appears at Left 1; salience 1 → only at Left 4). So raising Left brings in + lower-salience objects. +- **Detail tiers (escalate with Left):** the displayed string escalates + `tier 1 general ("eel") → 2 specific ("moray eel") → 3 scientific + (*Gymnothorax*) → 4 scientific + a fact (lifespan)`. The tier shown = + `clamp(left − first_level + 1, 1, n_tiers)` — an object shows tier 1 the moment + it appears and climbs one tier per further Left notch until its tiers run out. +- **Strings carry the tiers:** `strings.en[key]` may be a **list** (indexed by + `tier−1`) instead of a string; a plain string stays static (back-compat, tier + ignored). Keeps one key per object and the i18n table as the single source. +- **Back-compat:** an annotation with `min_level` and no `salience` keeps flat + gating (`first_level = min_level`, single-tier string). `measure.*` readouts stay + single-tier, gated by their level (a measurement is not a tiered object). +- **No engine change:** tier selection is client-side against the existing + `overlay.level` (the Left knob) already in the `RenderPlan`. + +### 11.4 Progressive RIGHT-brain emotion tiers + +The affect **vocabulary** gets more sophisticated as Right rises — basic words at +Right 1, nuanced/compound feelings at high Right — mirroring §11.3 for affect. + +- **Appearance rule unchanged:** affect surfaces only when **both** knobs are up; + an anchor's `min_level` is tested against `strength = min(left, right)` as today. +- **Emotion tiers:** `strings.en[feel.key]` may be a **list**; the word shown = + `clamp(right, 1, n_tiers)` (the Right knob, read from `dream.strength` already in + the `RenderPlan`). A plain string stays static (back-compat). +- **No engine change:** `AffectOverlay` already carries `strength`; the Right level + comes from `dream.strength`. Tier selection is client-side. + +### 11.5 `tools/pipeline/track.py` (hybrid) + simulator author mode + +The stage-5 tooling from §3.5 / §7, delivered as a **second PR** after the +experience PR (§8's split note). + +- **`track.py`:** a **classical** path (OpenCV CSRT / optical-flow) seeded by a + hand-drawn box on a keyframe, propagated and **sampled to a sparse keyframed + `track`** (normalized `{t, box}` over the loop, §4); plus an **optional, + lazy-imported ML** detect+track path (YOLO+ByteTrack-class) that proposes tracks + the author maps to keys. Both emit the same `track` artifact + an `appear`/ + `disappear` window. Pure helpers (normalization, keyframe sampling, + track→manifest) are unit-tested; an **opt-in** integration test runs the real + tracker on a pool clip (mirrors the Increment-1 real-footage test). +- **Author mode (simulator):** reuse the preview UI as the editor — scrub a pool + clip, draw/seed/correct boxes, run the tracker, assign `key` / `salience` / + tiered strings / `appear`-`disappear` / affect anchors, and **write the manifest + entry** (an endpoint upserts via `tools/pipeline/manifest.upsert_clip` and + persists). Semantics (keys, strings, scientific names, facts) are **always + hand-authored** — the tracker only propagates **box geometry** (§3.5). + +## 12. Out of scope (YAGNI) - Generative i2v **ring transitions** for the new edges (deferred; placeholders hold). - **Audio** sourcing/alteration (separate content-dial channel; already deferred). diff --git a/player/ring.py b/player/ring.py index 0a4b542..88cdf51 100644 --- a/player/ring.py +++ b/player/ring.py @@ -50,10 +50,25 @@ class RingError(ValueError): @dataclass(frozen=True) class Scale: - """A node on the ring: a scale of nature and the base clip it plays.""" + """A node on the ring: a scale of nature and the clip(s) it can play. + + A scale carries a rotating POOL of vetted clips (content-pipeline design + §11.1): when the viewer lands on the scale, the player picks one member at + random (`pick_clip_id`). `clip_id` is the PRIMARY member (the deterministic + default / pool[0]); `pool` is the full set. A scale written with only + `clip_id` and no `pool` is a pool of one — exactly the Increment-1 behavior, + so old manifests are unchanged. + """ id: str clip_id: str + pool: tuple[str, ...] = () + + @property + def members(self) -> tuple[str, ...]: + """The clip ids this scale can play — the pool, or just `clip_id` when + no pool was authored (a pool of one).""" + return self.pool if self.pool else (self.clip_id,) @dataclass(frozen=True) @@ -134,6 +149,20 @@ def scale_at(ring: ScaleRing, index: int) -> Scale: return ring.scales[index % len(ring)] +def pick_clip_id(scale: Scale, r: float) -> str: + """Pick one clip id from a scale's rotating pool, given an injected uniform + `r` in [0, 1) (content-pipeline design §11.1). + + Randomness is INJECTED so this stays a pure function — testable and shared + with the Pi player; the impure draw (`random.random()`) happens once at the + API/runtime boundary. `r` is clamped into [0, 1) so an off-by-epsilon caller + never indexes out of range; a pool of one always returns its single member. + """ + members = scale.members + r = min(max(r, 0.0), 0.999999) + return members[int(r * len(members))] + + def advance_ring( ring: ScaleRing, from_index: int, diff --git a/simulator/app.py b/simulator/app.py index bb22738..658bedf 100644 --- a/simulator/app.py +++ b/simulator/app.py @@ -9,6 +9,7 @@ from __future__ import annotations import hashlib import os +import random import time from pathlib import Path from typing import Optional @@ -26,7 +27,12 @@ from player.alteration import ( ) from player.content import resolve_content from player.controls import CONTENT_POSITIONS -from player.ring import DEFAULT_FAST_SPIN_THRESHOLD, advance_ring +from player.ring import ( + DEFAULT_FAST_SPIN_THRESHOLD, + advance_ring, + pick_clip_id, + scale_at, +) from simulator.clips import load_manifest, load_ring, ring_move_to_dict, ring_to_dict STATIC_DIR = Path(__file__).parent / "static" @@ -145,7 +151,12 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI: req.delta, fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD, ) - return ring_move_to_dict(move, app.state.ring) + # 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) @app.middleware("http") async def _no_cache(request, call_next): diff --git a/simulator/build_pool_manifest.py b/simulator/build_pool_manifest.py new file mode 100644 index 0000000..d234f8c --- /dev/null +++ b/simulator/build_pool_manifest.py @@ -0,0 +1,403 @@ +"""Build the rotating-pool manifest for the 5 ring scales (content-pipeline §11). + +This is the reproducible, hand-authored BASELINE for Increment 2's manifest: +each ring scale carries a POOL of vetted clips (docs/content-candidate-pool.md); +the player picks one member at random on landing (player.ring.pick_clip_id). The +authored label/affect content lives here in readable Python and is emitted to +`simulator/sample_media/manifest.json` — far less error-prone than hand-editing 19 +clips of JSON, and a clean place for the simulator author mode to regenerate from. + +Authoring model (design §11.3 / §11.4): + - LEFT detail tiers: each label carries `salience` (1-4; first shows at Left + level `5 - salience`) and a tiered string list (general -> specific -> + scientific -> +fact) that escalates with the Left knob. + - RIGHT emotion tiers: affect words are a tiered list per feeling (basic -> + compound) escalating with the Right knob; still gated on min(Left, Right). + - TIME-WINDOWED + TRACKED labels: a label may carry `appear`/`disappear` + (loop-normalized) and a keyframed `track`; it shows only while on screen and + its box follows the subject. The abyss + reef pools are the test material. + +Strings: a value may be a LIST (indexed by tier-1) or a plain string (static, +back-compat). Affect appears only when both knobs are up. + +Usage: + python simulator/build_pool_manifest.py # write manifest.json + python simulator/build_pool_manifest.py --media # + generate new transition placeholders +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +MEDIA = Path(__file__).parent / "sample_media" +MANIFEST = MEDIA / "manifest.json" + +# --- Pools: scale id -> ordered clip ids (primary first) (content-candidate-pool.md) --- +POOLS: dict[str, list[str]] = { + "cosmos": ["cosmos", "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"], +} + +# Ring order (large -> small, wraps): cosmos -> orbit -> coast -> reef -> abyss -> cosmos. +RING_ORDER = ["cosmos", "orbit", "coast", "reef", "abyss"] + +PD = "public-domain (US Gov, 17 U.S.C. §105)" +PD_NPS = "public-domain (NPS, no © — 17 U.S.C. §105)" +CCBY_STSCI = "CC-BY-class — credit NASA, ESA, STScI" + +# --- Per-clip provenance: id -> (title, license, source) --- +META: dict[str, tuple[str, str, str]] = { + # cosmos + "cosmos": ("Orion Nebula flythrough (NASA/JPL-Caltech)", PD, + "NASA/JPL-Caltech — images.nasa.gov JPL-20221122-SOLSYSf-0001 (Orion); trim ~30–54s, crossfade-loop"), + "cosmos_galaxies": ("Flying Through Galaxies (NASA SVS)", PD, + "NASA SVS a014950 14950_Galaxies_FlyThrough_4k; trim 8–32s, crossfade-loop"), + "cosmos_hudf": ("Hubble Ultra Deep Field zoom (NASA/ESA/STScI)", CCBY_STSCI, + "NASA SVS a030687 hudf-b-1920x1080p30; trim 20–44s, crossfade-loop"), + "cosmos_xdf": ("eXtreme Deep Field flythrough (NASA/ESA/STScI)", CCBY_STSCI, + "NASA SVS a030681 hxdf_fly-b-1920x1080p30; trim 2–26s, crossfade-loop"), + # orbit + "orbit_planetearth": ("ISS — View of Planet Earth (NASA SVS)", PD, + "NASA SVS a030771 ISS_View_of_Planet_Earth_2160p; trim 10–34s, crossfade-loop"), + "orbit_crewobs": ("ISS — Crew Earth Observations (NASA SVS)", PD, + "NASA SVS a030771 ISS_Crew_Earth_Observations_2160p; trim 9–33s, crossfade-loop"), + "orbit_bluemarble": ("NPP “Blue Marble” rotating globe (NASA SVS)", PD, + "NASA SVS a004550 BlueMarble_starfield_4k_2160p30_v2; trim 10–34s, crossfade-loop"), + # coast + "coast_birdrock": ("Bird rock + ocean, aerial (NPS Channel Islands)", PD_NPS, + "NPS Channel Islands AV 663a2f13; trim 3–27s, crossfade-loop"), + "coast_surfgrass": ("Surfgrass / coralline tidepool (NPS Cabrillo)", PD_NPS, + "NPS Cabrillo AV de7d1cf2; trim 2–26s, crossfade-loop"), + "coast_elkbeach": ("Elk resting in coastal fog (NPS Redwood)", PD_NPS, + "NPS Redwood redw-elkbeach_1280x720; trim 20–38s, crossfade-loop (720p source)"), + "coast_drakesbeach": ("Elephant seals on dark sand (NPS Point Reyes)", PD_NPS, + "NPS Point Reyes AV 1cfd8165; trim 20–35s, crossfade-loop"), + # reef + "reef_lionfish": ("Lionfish hovering over reef (NOAA Fisheries)", PD, + "NOAA Fisheries b-roll VIDEO_ID 4088881464001; trim 12–36s, crossfade-loop"), + "reef_spawning": ("Snapper school over sunlit reef (NOAA Fisheries)", PD, + "NOAA Fisheries b-roll VIDEO_ID 1553921798001; trim 50–78s, crossfade-loop"), + "reef_hawkfish": ("Humphead parrotfish over reef (NOAA Fisheries)", PD, + "NOAA Fisheries b-roll VIDEO_ID 6039896460001; trim 250–274s, crossfade-loop"), + "reef_snapper": ("Red snapper schooling (NOAA Fisheries)", PD, + "NOAA Fisheries b-roll VIDEO_ID 5305427942001; trim 7–31s, crossfade-loop"), + "reef_coralspacific": ("Pacific coral macro (NOAA Fisheries)", PD, + "NOAA Fisheries b-roll VIDEO_ID 5231464663001; trim 30–54s, crossfade-loop"), + # abyss + "abyss_wow": ("“World of Water” (NOAA Ocean Exploration)", PD, + "NOAA Ocean Exploration wow-1280x720-1; trim 10–34s, crossfade-loop"), + "abyss_midwaterexp": ("“Midwater Exploration” (NOAA Ocean Exploration)", PD, + "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"), +} + +# --- Affect vocabulary per SCALE (shared by all the scale's pool members) ------- +# Each: (key, [x, y], min_level, [tier1 basic .. tier4 compound]). The word shown +# escalates with the RIGHT knob; appears only when both knobs are up (§11.4). +AFFECT: dict[str, list[tuple]] = { + "cosmos": [ + ("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]), + ("feel.vastness", [0.20, 0.70], 2, ["big", "vastness", "immensity", "a dizzying immensity"]), + ("feel.insignificance", [0.66, 0.60], 3, ["small", "smallness", "insignificance", "a humbling insignificance"]), + ("feel.longing", [0.42, 0.84], 4, ["want", "longing", "yearning", "an aching yearning"]), + ], + "orbit": [ + ("feel.serenity", [0.50, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]), + ("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]), + ("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"]), + ], + "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"]), + ("feel.belonging", [0.66, 0.58], 3, ["home", "belonging", "rootedness", "a tidal rootedness"]), + ("feel.melancholy", [0.42, 0.82], 4, ["sad", "melancholy", "longing", "a soft seaward longing"]), + ], + "reef": [ + ("feel.delight", [0.50, 0.44], 1, ["fun", "delight", "joy", "a darting, bright joy"]), + ("feel.abundance", [0.22, 0.68], 2, ["full", "abundance", "richness", "a teeming richness"]), + ("feel.curiosity", [0.66, 0.58], 3, ["look", "curiosity", "fascination", "an absorbed fascination"]), + ("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]), + ], + "abyss": [ + ("feel.unease", [0.50, 0.44], 1, ["uh", "unease", "disquiet", "a creeping disquiet"]), + ("feel.fascination", [0.22, 0.68], 2, ["ooh", "fascination", "wonder", "a forbidden wonder"]), + ("feel.isolation", [0.66, 0.58], 3, ["alone", "isolation", "solitude", "a crushing solitude"]), + ("feel.dread", [0.42, 0.82], 4, ["fear", "dread", "foreboding", "a slow, cold foreboding"]), + ], +} + + +def static_label(key, salience, tiers, box): + """A fixed-box tiered label (always on-screen, salience-gated by Left).""" + return {"key": key, "salience": salience, "box": box, "_tiers": tiers} + + +def tracked_label(key, salience, tiers, appear, disappear, track): + """A time-windowed tracked label: shows only in [appear, disappear] and its + box follows the subject (§11.2). `track` = [(t, [x,y,w,h]), ...].""" + return { + "key": key, "salience": salience, "appear": appear, "disappear": disappear, + "track": [{"t": t, "box": b} for t, b in track], "_tiers": tiers, + } + + +def measure(key, value, box, min_level): + """A single-tier measurement readout (not a tiered object), gated by Left.""" + return {"key": key, "box": box, "min_level": min_level, "_tiers": value} + + +# --- Labels per clip. `_tiers` is lifted into the strings table by build(). ------ +# Showcase tracked/windowed labels live on the abyss + reef pools (the creatures +# drift through frame). Other clips get a few salience-gated static tiered labels. +LABELS: dict[str, list[dict]] = { + # ---------- cosmos ---------- + "cosmos": [ + static_label("detected.nebula", 4, ["cloud", "nebula", "emission nebula", "emission nebula · ionized H II region"], [0.32, 0.28, 0.34, 0.36]), + static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · <1 Myr old"], [0.60, 0.30, 0.10, 0.12]), + measure("measure.redshift", "z ≈ 0.0", [0.36, 0.70, 0.18, 0.08], 4), + ], + "cosmos_galaxies": [ + 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), + ], + "cosmos_hudf": [ + static_label("detected.galaxy", 4, ["smudge", "galaxy", "early galaxy", "early galaxy · z ≈ 1–6, light Gyr-old"], [0.40, 0.36, 0.20, 0.22]), + measure("measure.field", "deep field", [0.06, 0.06, 0.2, 0.08], 3), + ], + "cosmos_xdf": [ + static_label("detected.galaxy", 4, ["dot", "galaxy", "faint galaxy", "faint galaxy · among the earliest seen"], [0.42, 0.38, 0.18, 0.2]), + ], + # ---------- orbit ---------- + "orbit_planetearth": [ + static_label("detected.cloud_band", 4, ["clouds", "cloud band", "cumulus field", "cumulus field · tropical convection"], [0.30, 0.30, 0.30, 0.20]), + static_label("detected.limb", 2, ["edge", "Earth’s limb", "atmospheric limb", "atmospheric limb · ~100 km of air"], [0.05, 0.70, 0.9, 0.12]), + measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3), + ], + "orbit_crewobs": [ + static_label("detected.coastline", 4, ["land", "coastline", "continental margin", "continental margin · land meets sea"], [0.30, 0.34, 0.34, 0.26]), + measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3), + ], + "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]), + ], + # ---------- coast ---------- + "coast_birdrock": [ + 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.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]), + ], + "coast_elkbeach": [ + static_label("detected.elk", 4, ["elk", "Roosevelt elk", "Cervus canadensis roosevelti", "C. c. roosevelti · largest elk subspecies"], [0.34, 0.42, 0.3, 0.3]), + static_label("detected.fog", 2, ["fog", "coastal fog", "advection fog", "advection fog · warm air over cold upwelling"], [0.05, 0.05, 0.9, 0.25]), + ], + "coast_drakesbeach": [ + static_label("detected.seal", 4, ["seals", "elephant seals", "Mirounga angustirostris", "M. angustirostris · males to 2,000 kg"], [0.25, 0.55, 0.5, 0.3]), + static_label("detected.sand", 1, ["sand", "dark sand", "mineral-dark beach", "dark beach · eroded coastal sediment"], [0.05, 0.8, 0.9, 0.15]), + ], + # ---------- reef (showcase: tracked + windowed) ---------- + "reef_lionfish": [ + tracked_label( + "detected.lionfish", 4, + ["fish", "lionfish", "Pterois volitans", "Pterois volitans · venomous spines, invasive in the Atlantic"], + 0.05, 0.95, + [(0.05, [0.15, 0.30, 0.16, 0.18]), (0.5, [0.50, 0.38, 0.16, 0.18]), (0.95, [0.72, 0.34, 0.16, 0.18])], + ), + tracked_label( + "detected.snapper", 2, + ["fish", "blue snapper", "Lutjanus", "Lutjanus · schools over reef structure"], + 0.0, 0.6, + [(0.0, [0.05, 0.55, 0.12, 0.1]), (0.3, [0.22, 0.5, 0.12, 0.1]), (0.6, [0.40, 0.56, 0.12, 0.1])], + ), + measure("measure.depth", "−18 m", [0.06, 0.06, 0.16, 0.08], 3), + ], + "reef_spawning": [ + tracked_label( + "detected.school", 4, + ["fish", "fish school", "snapper aggregation", "snapper aggregation · synchronized spawning run"], + 0.0, 1.0, + [(0.0, [0.20, 0.30, 0.2, 0.18]), (0.5, [0.55, 0.34, 0.2, 0.18]), (1.0, [0.20, 0.30, 0.2, 0.18])], + ), + static_label("detected.coral", 2, ["coral", "reef coral", "scleractinian", "scleractinian · reef-building stony coral"], [0.15, 0.6, 0.35, 0.3]), + measure("measure.depth", "−22 m", [0.06, 0.06, 0.16, 0.08], 3), + ], + "reef_hawkfish": [ + tracked_label( + "detected.parrotfish", 4, + ["fish", "parrotfish", "Bolbometopon muricatum", "B. muricatum · humphead, grazes reef into sand"], + 0.1, 0.9, + [(0.1, [0.55, 0.25, 0.2, 0.22]), (0.5, [0.35, 0.34, 0.2, 0.22]), (0.9, [0.12, 0.40, 0.2, 0.22])], + ), + measure("measure.depth", "−12 m", [0.06, 0.06, 0.16, 0.08], 3), + ], + "reef_snapper": [ + tracked_label( + "detected.snapper", 4, + ["fish", "red snapper", "Lutjanus campechanus", "L. campechanus · long-lived, can reach 50+ yr"], + 0.0, 0.7, + [(0.0, [0.10, 0.3, 0.16, 0.14]), (0.35, [0.40, 0.36, 0.16, 0.14]), (0.7, [0.68, 0.30, 0.16, 0.14])], + ), + ], + "reef_coralspacific": [ + 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, + ["fish", "spotted fish", "reef damselfish", "reef damselfish · territorial over its coral"], + 0.2, 0.8, + [(0.2, [0.6, 0.25, 0.1, 0.09]), (0.5, [0.45, 0.3, 0.1, 0.09]), (0.8, [0.3, 0.26, 0.1, 0.09])], + ), + ], + # ---------- abyss (showcase: tracked + windowed; creatures drift through) ---------- + "abyss_wow": [ + tracked_label( + "detected.jelly", 4, + ["jelly", "comb jelly", "Ctenophora", "Ctenophora · swims by beating rows of cilia"], + 0.0, 0.55, + [(0.0, [0.05, 0.25, 0.14, 0.18]), (0.3, [0.30, 0.32, 0.14, 0.18]), (0.55, [0.52, 0.28, 0.14, 0.18])], + ), + tracked_label( + "detected.siphonophore", 3, + ["chain", "siphonophore", "Siphonophorae", "Siphonophorae · a colony of clones, can exceed 40 m"], + 0.45, 1.0, + [(0.45, [0.80, 0.6, 0.1, 0.3]), (0.7, [0.6, 0.45, 0.1, 0.34]), (1.0, [0.45, 0.3, 0.1, 0.38])], + ), + measure("measure.depth", "−1,200 m", [0.06, 0.06, 0.16, 0.08], 2), + ], + "abyss_midwaterexp": [ + tracked_label( + "detected.medusa", 4, + ["jelly", "white medusa", "Scyphozoa", "Scyphozoa · the swimming medusa stage of a jellyfish"], + 0.0, 0.6, + [(0.0, [0.55, 0.20, 0.16, 0.2]), (0.3, [0.40, 0.34, 0.16, 0.2]), (0.6, [0.22, 0.30, 0.16, 0.2])], + ), + tracked_label( + "detected.worm", 2, + ["worm", "red worm", "polychaete", "polychaete · a free-swimming bristle worm"], + 0.5, 1.0, + [(0.5, [0.10, 0.7, 0.12, 0.1]), (0.75, [0.3, 0.6, 0.12, 0.1]), (1.0, [0.5, 0.66, 0.12, 0.1])], + ), + measure("measure.depth", "−1,600 m", [0.06, 0.06, 0.16, 0.08], 2), + ], + "abyss_hiding": [ + tracked_label( + "detected.jelly", 4, + ["jelly", "crimson jelly", "Scyphozoa", "Scyphozoa · red is invisible in the lightless deep"], + 0.0, 0.7, + [(0.0, [0.15, 0.28, 0.16, 0.2]), (0.35, [0.42, 0.36, 0.16, 0.2]), (0.7, [0.66, 0.30, 0.16, 0.2])], + ), + measure("measure.depth", "−2,140 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.""" + entries, strings = [], {} + for key, at, min_level, tiers in AFFECT[scale]: + entries.append({"key": key, "at": at, "min_level": min_level}) + strings[key] = tiers + return entries, strings + + +def _labels_for_clip(clip_id: str) -> tuple[list, dict]: + """Build the annotation list + its strings, lifting `_tiers` into the table.""" + anns, strings = [], {} + for raw in LABELS.get(clip_id, []): + a = {k: v for k, v in raw.items() if k != "_tiers"} + anns.append(a) + strings[a["key"]] = raw["_tiers"] + return anns, strings + + +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) + return { + "id": clip_id, + "title": title, + "base_file": f"{clip_id}/base.mp4", + "license": license_, + "source": source, + "right_variants": {}, + "annotations": anns, + "affect": affect, + "strings": {"en": {**lab_strings, **aff_strings}}, + } + + +def build_manifest() -> dict: + """Assemble the full rotating-pool manifest dict.""" + clips = [] + for scale in RING_ORDER: + for clip_id in POOLS[scale]: + clips.append(_clip_entry(scale, clip_id)) + scales = [{"id": s, "clip_id": POOLS[s][0], "pool": POOLS[s]} 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"}) + return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}} + + +# --- Optional: generate the NEW transition placeholders (orbit-coast, coast-reef) --- + +def _ffmpeg() -> str: + if shutil.which("ffmpeg"): + return "ffmpeg" + import imageio_ffmpeg + return imageio_ffmpeg.get_ffmpeg_exe() + + +def _make_transition(ff: str, scale_a: str, scale_b: str) -> Path: + """A placeholder zoom/warp morph between two scales, from their PRIMARY pool + members' bases (mirrors setup_scales_media.py but keyed by scale->primary).""" + a = 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) + 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: + """Generate the new ring edges introduced by the coast scale; the cosmos-orbit, + reef-abyss and abyss-cosmos edges already exist from the prior 5-scale ring.""" + ff = _ffmpeg() + for a, b in [("orbit", "coast"), ("coast", "reef")]: + _make_transition(ff, a, b) + print(f"generated transitions/{a}-{b}.mp4 (placeholder zoom)") + + +def main(argv: list[str]) -> None: + manifest = build_manifest() + MANIFEST.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n") + print(f"wrote {MANIFEST} — {len(manifest['clips'])} clips, " + f"{len(manifest['ring']['scales'])} ring scales") + if "--media" in argv: + generate_media() + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/simulator/clips.py b/simulator/clips.py index cc5e449..00d2b49 100644 --- a/simulator/clips.py +++ b/simulator/clips.py @@ -73,6 +73,18 @@ def load_manifest(path: str | Path) -> list[Clip]: return [_clip_from_dict(c) for c in data["clips"]] +def _scale_from_dict(s: dict[str, Any]) -> Scale: + """A ring scale node, with its rotating clip pool (content-pipeline §11.1). + + Accepts a `pool` list (the rotating model) and/or a legacy single `clip_id`. + The primary `clip_id` is the explicit one if given, else the first pool + member; `pool` defaults to `(clip_id,)` for a pre-pool manifest. + """ + pool = tuple(s.get("pool", [])) + clip_id = s.get("clip_id") or (pool[0] if pool else "") + return Scale(id=s["id"], clip_id=clip_id, pool=pool) + + def load_ring(path: str | Path) -> ScaleRing | None: """Load the optional `ring` section as a `ScaleRing`, or None if absent. @@ -85,9 +97,7 @@ def load_ring(path: str | Path) -> ScaleRing | None: ring = data.get("ring") if not ring: return None - scales = tuple( - Scale(id=s["id"], clip_id=s["clip_id"]) for s in ring.get("scales", []) - ) + scales = tuple(_scale_from_dict(s) for s in ring.get("scales", [])) transitions = tuple( Transition(file=t["file"], model=t.get("model", "")) for t in ring.get("transitions", []) @@ -97,27 +107,46 @@ def load_ring(path: str | Path) -> ScaleRing | None: def ring_to_dict(ring: ScaleRing, clips: list[Clip]) -> dict: """JSON form of the ring for the API: ordered scales (with their clip title) - and the per-edge transitions.""" + and the per-edge transitions. + + Each scale carries its full rotating `pool` (clip id + title per member) plus + the primary `clip_id`/`title` for back-compat. The client lands on a member + chosen at the API boundary (`/api/ring/advance` → `target_clip_id`).""" titles = {c.id: c.title for c in clips} return { "scales": [ - {"id": s.id, "clip_id": s.clip_id, "title": titles.get(s.clip_id, s.id)} + { + "id": s.id, + "clip_id": s.clip_id, + "title": titles.get(s.clip_id, s.id), + "pool": [ + {"clip_id": cid, "title": titles.get(cid, cid)} + for cid in s.members + ], + } for s in ring.scales ], "transitions": [{"file": t.file, "model": t.model} for t in ring.transitions], } -def ring_move_to_dict(move: RingMove, ring: ScaleRing) -> dict: +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.""" + pass; the single step then carries `blended` so the renderer plays it quick. + + `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).""" return { "from_index": move.from_index, "to_index": move.to_index, "wrapped": move.wrapped, "fast": move.fast, - "target_clip_id": scale_at(ring, move.to_index).clip_id, + "target_clip_id": chosen_clip_id or scale_at(ring, move.to_index).clip_id, "steps": [ { "edge": st.edge, diff --git a/simulator/sample_media/manifest.json b/simulator/sample_media/manifest.json index d5e4ea0..f660d0a 100644 --- a/simulator/sample_media/manifest.json +++ b/simulator/sample_media/manifest.json @@ -2,27 +2,37 @@ "clips": [ { "id": "cosmos", - "title": "Orion Nebula flythrough (NASA/JPL-Caltech, neutral base)", + "title": "Orion Nebula flythrough (NASA/JPL-Caltech)", "base_file": "cosmos/base.mp4", "license": "public-domain (US Gov, 17 U.S.C. §105)", - "source": "NASA/JPL-Caltech — https://images.nasa.gov/details/JPL-20221122-SOLSYSf-0001-Orion Dust and Death (true PD; trimmed ~30–54s, crossfade-loop)", + "source": "NASA/JPL-Caltech — images.nasa.gov JPL-20221122-SOLSYSf-0001 (Orion); trim ~30–54s, crossfade-loop", "right_variants": {}, "annotations": [ { - "key": "detected.galaxy", + "key": "detected.nebula", + "salience": 4, "box": [ + 0.32, + 0.28, 0.34, + 0.36 + ] + }, + { + "key": "detected.star", + "salience": 2, + "box": [ + 0.6, 0.3, - 0.3, - 0.34 - ], - "min_level": 1 + 0.1, + 0.12 + ] }, { "key": "measure.redshift", "box": [ 0.36, - 0.66, + 0.7, 0.18, 0.08 ], @@ -34,7 +44,7 @@ "key": "feel.wonder", "at": [ 0.5, - 0.46 + 0.44 ], "min_level": 1 }, @@ -49,7 +59,7 @@ { "key": "feel.insignificance", "at": [ - 0.64, + 0.66, 0.6 ], "min_level": 3 @@ -65,32 +75,359 @@ ], "strings": { "en": { - "detected.galaxy": "spiral galaxy", - "measure.redshift": "z ≈ 0.04", - "feel.wonder": "wonder", - "feel.vastness": "vastness", - "feel.insignificance": "insignificance", - "feel.longing": "longing" + "detected.nebula": [ + "cloud", + "nebula", + "emission nebula", + "emission nebula · ionized H II region" + ], + "detected.star": [ + "star", + "young star", + "protostar", + "protostar · <1 Myr old" + ], + "measure.redshift": "z ≈ 0.0", + "feel.wonder": [ + "wow", + "wonder", + "awe", + "transcendent awe" + ], + "feel.vastness": [ + "big", + "vastness", + "immensity", + "a dizzying immensity" + ], + "feel.insignificance": [ + "small", + "smallness", + "insignificance", + "a humbling insignificance" + ], + "feel.longing": [ + "want", + "longing", + "yearning", + "an aching yearning" + ] } } }, { - "id": "orbit", - "title": "Earth from ISS — Expedition 65 (NASA/JSC, neutral base)", - "base_file": "orbit/base.mp4", + "id": "cosmos_galaxies", + "title": "Flying Through Galaxies (NASA SVS)", + "base_file": "cosmos_galaxies/base.mp4", "license": "public-domain (US Gov, 17 U.S.C. §105)", - "source": "NASA/JSC — https://images.nasa.gov/details/jsc2022m000172_Earth_in_4K_Expedition_65_Edition (true PD; trimmed ~10:00, crossfade-loop)", + "source": "NASA SVS a014950 14950_Galaxies_FlyThrough_4k; trim 8–32s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.galaxy", + "salience": 4, + "box": [ + 0.34, + 0.3, + 0.3, + 0.34 + ] + }, + { + "key": "measure.distance", + "box": [ + 0.06, + 0.06, + 0.18, + 0.08 + ], + "min_level": 3 + } + ], + "affect": [ + { + "key": "feel.wonder", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.vastness", + "at": [ + 0.2, + 0.7 + ], + "min_level": 2 + }, + { + "key": "feel.insignificance", + "at": [ + 0.66, + 0.6 + ], + "min_level": 3 + }, + { + "key": "feel.longing", + "at": [ + 0.42, + 0.84 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.galaxy": [ + "galaxy", + "spiral galaxy", + "barred spiral", + "barred spiral · ~10¹¹ stars" + ], + "measure.distance": "~Mly", + "feel.wonder": [ + "wow", + "wonder", + "awe", + "transcendent awe" + ], + "feel.vastness": [ + "big", + "vastness", + "immensity", + "a dizzying immensity" + ], + "feel.insignificance": [ + "small", + "smallness", + "insignificance", + "a humbling insignificance" + ], + "feel.longing": [ + "want", + "longing", + "yearning", + "an aching yearning" + ] + } + } + }, + { + "id": "cosmos_hudf", + "title": "Hubble Ultra Deep Field zoom (NASA/ESA/STScI)", + "base_file": "cosmos_hudf/base.mp4", + "license": "CC-BY-class — credit NASA, ESA, STScI", + "source": "NASA SVS a030687 hudf-b-1920x1080p30; trim 20–44s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.galaxy", + "salience": 4, + "box": [ + 0.4, + 0.36, + 0.2, + 0.22 + ] + }, + { + "key": "measure.field", + "box": [ + 0.06, + 0.06, + 0.2, + 0.08 + ], + "min_level": 3 + } + ], + "affect": [ + { + "key": "feel.wonder", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.vastness", + "at": [ + 0.2, + 0.7 + ], + "min_level": 2 + }, + { + "key": "feel.insignificance", + "at": [ + 0.66, + 0.6 + ], + "min_level": 3 + }, + { + "key": "feel.longing", + "at": [ + 0.42, + 0.84 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.galaxy": [ + "smudge", + "galaxy", + "early galaxy", + "early galaxy · z ≈ 1–6, light Gyr-old" + ], + "measure.field": "deep field", + "feel.wonder": [ + "wow", + "wonder", + "awe", + "transcendent awe" + ], + "feel.vastness": [ + "big", + "vastness", + "immensity", + "a dizzying immensity" + ], + "feel.insignificance": [ + "small", + "smallness", + "insignificance", + "a humbling insignificance" + ], + "feel.longing": [ + "want", + "longing", + "yearning", + "an aching yearning" + ] + } + } + }, + { + "id": "cosmos_xdf", + "title": "eXtreme Deep Field flythrough (NASA/ESA/STScI)", + "base_file": "cosmos_xdf/base.mp4", + "license": "CC-BY-class — credit NASA, ESA, STScI", + "source": "NASA SVS a030681 hxdf_fly-b-1920x1080p30; trim 2–26s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.galaxy", + "salience": 4, + "box": [ + 0.42, + 0.38, + 0.18, + 0.2 + ] + } + ], + "affect": [ + { + "key": "feel.wonder", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.vastness", + "at": [ + 0.2, + 0.7 + ], + "min_level": 2 + }, + { + "key": "feel.insignificance", + "at": [ + 0.66, + 0.6 + ], + "min_level": 3 + }, + { + "key": "feel.longing", + "at": [ + 0.42, + 0.84 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.galaxy": [ + "dot", + "galaxy", + "faint galaxy", + "faint galaxy · among the earliest seen" + ], + "feel.wonder": [ + "wow", + "wonder", + "awe", + "transcendent awe" + ], + "feel.vastness": [ + "big", + "vastness", + "immensity", + "a dizzying immensity" + ], + "feel.insignificance": [ + "small", + "smallness", + "insignificance", + "a humbling insignificance" + ], + "feel.longing": [ + "want", + "longing", + "yearning", + "an aching yearning" + ] + } + } + }, + { + "id": "orbit_planetearth", + "title": "ISS — View of Planet Earth (NASA SVS)", + "base_file": "orbit_planetearth/base.mp4", + "license": "public-domain (US Gov, 17 U.S.C. §105)", + "source": "NASA SVS a030771 ISS_View_of_Planet_Earth_2160p; trim 10–34s, crossfade-loop", "right_variants": {}, "annotations": [ { "key": "detected.cloud_band", + "salience": 4, "box": [ 0.3, 0.3, 0.3, 0.2 - ], - "min_level": 1 + ] + }, + { + "key": "detected.limb", + "salience": 2, + "box": [ + 0.05, + 0.7, + 0.9, + 0.12 + ] }, { "key": "measure.altitude", @@ -100,7 +437,7 @@ 0.18, 0.08 ], - "min_level": 2 + "min_level": 3 } ], "affect": [ @@ -108,7 +445,7 @@ "key": "feel.serenity", "at": [ 0.5, - 0.46 + 0.44 ], "min_level": 1 }, @@ -139,131 +476,102 @@ ], "strings": { "en": { - "detected.cloud_band": "cloud band", + "detected.cloud_band": [ + "clouds", + "cloud band", + "cumulus field", + "cumulus field · tropical convection" + ], + "detected.limb": [ + "edge", + "Earth’s limb", + "atmospheric limb", + "atmospheric limb · ~100 km of air" + ], "measure.altitude": "~408 km", - "feel.serenity": "serenity", - "feel.fragility": "fragility", - "feel.unity": "unity", - "feel.distance": "distance" + "feel.serenity": [ + "calm", + "serenity", + "peace", + "a quiet, weightless peace" + ], + "feel.fragility": [ + "thin", + "fragility", + "vulnerability", + "a tender fragility" + ], + "feel.unity": [ + "one", + "unity", + "belonging", + "a borderless belonging" + ], + "feel.distance": [ + "far", + "distance", + "remoteness", + "an exquisite remoteness" + ] } } }, { - "id": "forest", - "title": "Yosemite (NPS stock b-roll, neutral base)", - "base_file": "forest/base.mp4", - "license": "public-domain (NPS, no © — 17 U.S.C. §105)", - "source": "NPS Yosemite stock footage — https://www.nps.gov/yose/learn/photosmultimedia/b-roll.htm (true PD; trimmed ~75–99s, crossfade-loop)", - "right_variants": { - "1": { - "file": "forest/right1.mp4", - "model": "sd-turbo+farneback-flow" - }, - "2": { - "file": "forest/right2.mp4", - "model": "sd-turbo+farneback-flow" - }, - "3": { - "file": "forest/right3.mp4", - "model": "sd-turbo+farneback-flow" - }, - "4": { - "file": "forest/right4.mp4", - "model": "sd-turbo+farneback-flow" - } - }, + "id": "orbit_crewobs", + "title": "ISS — Crew Earth Observations (NASA SVS)", + "base_file": "orbit_crewobs/base.mp4", + "license": "public-domain (US Gov, 17 U.S.C. §105)", + "source": "NASA SVS a030771 ISS_Crew_Earth_Observations_2160p; trim 9–33s, crossfade-loop", + "right_variants": {}, "annotations": [ { - "key": "detected.water", - "min_level": 1, - "track": [ - { - "t": 0.0, - "box": [ - 0.3, - 0.1, - 0.18, - 0.7 - ] - }, - { - "t": 0.5, - "box": [ - 0.33, - 0.1, - 0.18, - 0.7 - ] - }, - { - "t": 1.0, - "box": [ - 0.3, - 0.1, - 0.18, - 0.7 - ] - } + "key": "detected.coastline", + "salience": 4, + "box": [ + 0.3, + 0.34, + 0.34, + 0.26 ] }, { - "key": "detected.rock_face", + "key": "measure.altitude", "box": [ - 0.05, - 0.3, - 0.2, - 0.55 - ], - "min_level": 2 - }, - { - "key": "detected.conifer", - "box": [ - 0.7, - 0.2, - 0.22, - 0.45 - ], - "min_level": 3 - }, - { - "key": "measure.flow_rate", - "box": [ - 0.34, - 0.55, - 0.14, + 0.06, + 0.06, + 0.18, 0.08 ], - "min_level": 4 + "min_level": 3 } ], "affect": [ { - "key": "feel.awe", + "key": "feel.serenity", "at": [ - 0.48, + 0.5, 0.44 ], "min_level": 1 }, { - "key": "feel.serenity", + "key": "feel.fragility", "at": [ - 0.18, - 0.66 + 0.22, + 0.68 ], "min_level": 2 }, { - "key": "feel.reverence", + "key": "feel.unity", "at": [ 0.66, - 0.56 + 0.58 ], "min_level": 3 }, { - "key": "feel.stillness", + "key": "feel.distance", "at": [ 0.42, 0.82 @@ -273,64 +581,618 @@ ], "strings": { "en": { - "detected.water": "flowing water", - "detected.rock_face": "granite face", - "detected.conifer": "conifer stand", - "measure.flow_rate": "~2.1 m³/s", - "feel.awe": "awe", - "feel.serenity": "serenity", - "feel.reverence": "reverence", - "feel.stillness": "stillness" + "detected.coastline": [ + "land", + "coastline", + "continental margin", + "continental margin · land meets sea" + ], + "measure.altitude": "~408 km", + "feel.serenity": [ + "calm", + "serenity", + "peace", + "a quiet, weightless peace" + ], + "feel.fragility": [ + "thin", + "fragility", + "vulnerability", + "a tender fragility" + ], + "feel.unity": [ + "one", + "unity", + "belonging", + "a borderless belonging" + ], + "feel.distance": [ + "far", + "distance", + "remoteness", + "an exquisite remoteness" + ] } } }, { - "id": "reef", - "title": "Coral reef b-roll (NOAA Fisheries, neutral base)", - "base_file": "reef/base.mp4", + "id": "orbit_bluemarble", + "title": "NPP “Blue Marble” rotating globe (NASA SVS)", + "base_file": "orbit_bluemarble/base.mp4", "license": "public-domain (US Gov, 17 U.S.C. §105)", - "source": "NOAA Fisheries — B-Roll: Corals of the Atlantic/Gulf/Keys/Caribbean — https://videos.fisheries.noaa.gov/detail/videos/b-roll:-corals/video/5231418011001 (true PD; trimmed 127–155s, crossfade-loop)", + "source": "NASA SVS a004550 BlueMarble_starfield_4k_2160p30_v2; trim 10–34s, crossfade-loop", "right_variants": {}, "annotations": [ { - "key": "detected.coral", + "key": "detected.globe", + "salience": 4, "box": [ - 0.2, - 0.45, - 0.3, - 0.3 + 0.28, + 0.18, + 0.44, + 0.6 + ] + } + ], + "affect": [ + { + "key": "feel.serenity", + "at": [ + 0.5, + 0.44 ], "min_level": 1 }, { - "key": "detected.fish", - "min_level": 2, + "key": "feel.fragility", + "at": [ + 0.22, + 0.68 + ], + "min_level": 2 + }, + { + "key": "feel.unity", + "at": [ + 0.66, + 0.58 + ], + "min_level": 3 + }, + { + "key": "feel.distance", + "at": [ + 0.42, + 0.82 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.globe": [ + "Earth", + "the globe", + "terrestrial planet", + "terrestrial planet · 12,742 km across" + ], + "feel.serenity": [ + "calm", + "serenity", + "peace", + "a quiet, weightless peace" + ], + "feel.fragility": [ + "thin", + "fragility", + "vulnerability", + "a tender fragility" + ], + "feel.unity": [ + "one", + "unity", + "belonging", + "a borderless belonging" + ], + "feel.distance": [ + "far", + "distance", + "remoteness", + "an exquisite remoteness" + ] + } + } + }, + { + "id": "coast_birdrock", + "title": "Bird rock + ocean, aerial (NPS Channel Islands)", + "base_file": "coast_birdrock/base.mp4", + "license": "public-domain (NPS, no © — 17 U.S.C. §105)", + "source": "NPS Channel Islands AV 663a2f13; trim 3–27s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.surf", + "salience": 4, + "box": [ + 0.2, + 0.55, + 0.6, + 0.3 + ] + }, + { + "key": "detected.searock", + "salience": 2, + "box": [ + 0.3, + 0.25, + 0.22, + 0.3 + ] + } + ], + "affect": [ + { + "key": "feel.ease", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.nostalgia", + "at": [ + 0.22, + 0.68 + ], + "min_level": 2 + }, + { + "key": "feel.belonging", + "at": [ + 0.66, + 0.58 + ], + "min_level": 3 + }, + { + "key": "feel.melancholy", + "at": [ + 0.42, + 0.82 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.surf": [ + "waves", + "surf", + "breaking swell", + "breaking swell · wind-driven, ~10 s period" + ], + "detected.searock": [ + "rock", + "sea stack", + "coastal sea stack", + "sea stack · wave-cut residual rock" + ], + "feel.ease": [ + "nice", + "ease", + "calm", + "an unhurried calm" + ], + "feel.nostalgia": [ + "miss", + "nostalgia", + "wistfulness", + "a salt-air wistfulness" + ], + "feel.belonging": [ + "home", + "belonging", + "rootedness", + "a tidal rootedness" + ], + "feel.melancholy": [ + "sad", + "melancholy", + "longing", + "a soft seaward longing" + ] + } + } + }, + { + "id": "coast_surfgrass", + "title": "Surfgrass / coralline tidepool (NPS Cabrillo)", + "base_file": "coast_surfgrass/base.mp4", + "license": "public-domain (NPS, no © — 17 U.S.C. §105)", + "source": "NPS Cabrillo AV de7d1cf2; trim 2–26s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.surfgrass", + "salience": 4, + "box": [ + 0.18, + 0.4, + 0.5, + 0.4 + ] + }, + { + "key": "detected.coralline", + "salience": 2, + "box": [ + 0.6, + 0.55, + 0.2, + 0.2 + ] + } + ], + "affect": [ + { + "key": "feel.ease", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.nostalgia", + "at": [ + 0.22, + 0.68 + ], + "min_level": 2 + }, + { + "key": "feel.belonging", + "at": [ + 0.66, + 0.58 + ], + "min_level": 3 + }, + { + "key": "feel.melancholy", + "at": [ + 0.42, + 0.82 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.surfgrass": [ + "grass", + "surfgrass", + "Phyllospadix", + "Phyllospadix · a marine seagrass, not algae" + ], + "detected.coralline": [ + "pink", + "coralline algae", + "crustose coralline", + "crustose coralline · calcified red algae" + ], + "feel.ease": [ + "nice", + "ease", + "calm", + "an unhurried calm" + ], + "feel.nostalgia": [ + "miss", + "nostalgia", + "wistfulness", + "a salt-air wistfulness" + ], + "feel.belonging": [ + "home", + "belonging", + "rootedness", + "a tidal rootedness" + ], + "feel.melancholy": [ + "sad", + "melancholy", + "longing", + "a soft seaward longing" + ] + } + } + }, + { + "id": "coast_elkbeach", + "title": "Elk resting in coastal fog (NPS Redwood)", + "base_file": "coast_elkbeach/base.mp4", + "license": "public-domain (NPS, no © — 17 U.S.C. §105)", + "source": "NPS Redwood redw-elkbeach_1280x720; trim 20–38s, crossfade-loop (720p source)", + "right_variants": {}, + "annotations": [ + { + "key": "detected.elk", + "salience": 4, + "box": [ + 0.34, + 0.42, + 0.3, + 0.3 + ] + }, + { + "key": "detected.fog", + "salience": 2, + "box": [ + 0.05, + 0.05, + 0.9, + 0.25 + ] + } + ], + "affect": [ + { + "key": "feel.ease", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.nostalgia", + "at": [ + 0.22, + 0.68 + ], + "min_level": 2 + }, + { + "key": "feel.belonging", + "at": [ + 0.66, + 0.58 + ], + "min_level": 3 + }, + { + "key": "feel.melancholy", + "at": [ + 0.42, + 0.82 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.elk": [ + "elk", + "Roosevelt elk", + "Cervus canadensis roosevelti", + "C. c. roosevelti · largest elk subspecies" + ], + "detected.fog": [ + "fog", + "coastal fog", + "advection fog", + "advection fog · warm air over cold upwelling" + ], + "feel.ease": [ + "nice", + "ease", + "calm", + "an unhurried calm" + ], + "feel.nostalgia": [ + "miss", + "nostalgia", + "wistfulness", + "a salt-air wistfulness" + ], + "feel.belonging": [ + "home", + "belonging", + "rootedness", + "a tidal rootedness" + ], + "feel.melancholy": [ + "sad", + "melancholy", + "longing", + "a soft seaward longing" + ] + } + } + }, + { + "id": "coast_drakesbeach", + "title": "Elephant seals on dark sand (NPS Point Reyes)", + "base_file": "coast_drakesbeach/base.mp4", + "license": "public-domain (NPS, no © — 17 U.S.C. §105)", + "source": "NPS Point Reyes AV 1cfd8165; trim 20–35s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.seal", + "salience": 4, + "box": [ + 0.25, + 0.55, + 0.5, + 0.3 + ] + }, + { + "key": "detected.sand", + "salience": 1, + "box": [ + 0.05, + 0.8, + 0.9, + 0.15 + ] + } + ], + "affect": [ + { + "key": "feel.ease", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.nostalgia", + "at": [ + 0.22, + 0.68 + ], + "min_level": 2 + }, + { + "key": "feel.belonging", + "at": [ + 0.66, + 0.58 + ], + "min_level": 3 + }, + { + "key": "feel.melancholy", + "at": [ + 0.42, + 0.82 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.seal": [ + "seals", + "elephant seals", + "Mirounga angustirostris", + "M. angustirostris · males to 2,000 kg" + ], + "detected.sand": [ + "sand", + "dark sand", + "mineral-dark beach", + "dark beach · eroded coastal sediment" + ], + "feel.ease": [ + "nice", + "ease", + "calm", + "an unhurried calm" + ], + "feel.nostalgia": [ + "miss", + "nostalgia", + "wistfulness", + "a salt-air wistfulness" + ], + "feel.belonging": [ + "home", + "belonging", + "rootedness", + "a tidal rootedness" + ], + "feel.melancholy": [ + "sad", + "melancholy", + "longing", + "a soft seaward longing" + ] + } + } + }, + { + "id": "reef_lionfish", + "title": "Lionfish hovering over reef (NOAA Fisheries)", + "base_file": "reef_lionfish/base.mp4", + "license": "public-domain (US Gov, 17 U.S.C. §105)", + "source": "NOAA Fisheries b-roll VIDEO_ID 4088881464001; trim 12–36s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.lionfish", + "salience": 4, + "appear": 0.05, + "disappear": 0.95, "track": [ { - "t": 0.0, + "t": 0.05, "box": [ - 0.2, + 0.15, 0.3, - 0.1, - 0.08 + 0.16, + 0.18 ] }, { "t": 0.5, "box": [ - 0.62, - 0.34, - 0.1, - 0.08 + 0.5, + 0.38, + 0.16, + 0.18 ] }, { - "t": 1.0, + "t": 0.95, "box": [ - 0.2, - 0.3, - 0.1, - 0.08 + 0.72, + 0.34, + 0.16, + 0.18 + ] + } + ] + }, + { + "key": "detected.snapper", + "salience": 2, + "appear": 0.0, + "disappear": 0.6, + "track": [ + { + "t": 0.0, + "box": [ + 0.05, + 0.55, + 0.12, + 0.1 + ] + }, + { + "t": 0.3, + "box": [ + 0.22, + 0.5, + 0.12, + 0.1 + ] + }, + { + "t": 0.6, + "box": [ + 0.4, + 0.56, + 0.12, + 0.1 ] } ] @@ -351,7 +1213,7 @@ "key": "feel.delight", "at": [ 0.5, - 0.46 + 0.44 ], "min_level": 1 }, @@ -382,34 +1244,630 @@ ], "strings": { "en": { - "detected.coral": "coral colony", - "detected.fish": "reef fish", + "detected.lionfish": [ + "fish", + "lionfish", + "Pterois volitans", + "Pterois volitans · venomous spines, invasive in the Atlantic" + ], + "detected.snapper": [ + "fish", + "blue snapper", + "Lutjanus", + "Lutjanus · schools over reef structure" + ], "measure.depth": "−18 m", - "feel.delight": "delight", - "feel.abundance": "abundance", - "feel.curiosity": "curiosity", - "feel.immersion": "immersion" + "feel.delight": [ + "fun", + "delight", + "joy", + "a darting, bright joy" + ], + "feel.abundance": [ + "full", + "abundance", + "richness", + "a teeming richness" + ], + "feel.curiosity": [ + "look", + "curiosity", + "fascination", + "an absorbed fascination" + ], + "feel.immersion": [ + "in", + "immersion", + "absorption", + "a held, breathless absorption" + ] } } }, { - "id": "abyss", - "title": "Comb jelly / ctenophore (NOAA Ocean Exploration, neutral base)", - "base_file": "abyss/base.mp4", + "id": "reef_spawning", + "title": "Snapper school over sunlit reef (NOAA Fisheries)", + "base_file": "reef_spawning/base.mp4", "license": "public-domain (US Gov, 17 U.S.C. §105)", - "source": "NOAA Ocean Exploration — https://oceanexplorer.noaa.gov/multimedia/video-playlist-ex2103-ctenophore/ (true PD; trimmed 3–27s, crossfade-loop)", + "source": "NOAA Fisheries b-roll VIDEO_ID 1553921798001; trim 50–78s, crossfade-loop", "right_variants": {}, "annotations": [ { - "key": "detected.organism", + "key": "detected.school", + "salience": 4, + "appear": 0.0, + "disappear": 1.0, + "track": [ + { + "t": 0.0, + "box": [ + 0.2, + 0.3, + 0.2, + 0.18 + ] + }, + { + "t": 0.5, + "box": [ + 0.55, + 0.34, + 0.2, + 0.18 + ] + }, + { + "t": 1.0, + "box": [ + 0.2, + 0.3, + 0.2, + 0.18 + ] + } + ] + }, + { + "key": "detected.coral", + "salience": 2, "box": [ - 0.4, - 0.38, - 0.22, - 0.26 + 0.15, + 0.6, + 0.35, + 0.3 + ] + }, + { + "key": "measure.depth", + "box": [ + 0.06, + 0.06, + 0.16, + 0.08 + ], + "min_level": 3 + } + ], + "affect": [ + { + "key": "feel.delight", + "at": [ + 0.5, + 0.44 ], "min_level": 1 }, + { + "key": "feel.abundance", + "at": [ + 0.22, + 0.68 + ], + "min_level": 2 + }, + { + "key": "feel.curiosity", + "at": [ + 0.66, + 0.58 + ], + "min_level": 3 + }, + { + "key": "feel.immersion", + "at": [ + 0.42, + 0.82 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.school": [ + "fish", + "fish school", + "snapper aggregation", + "snapper aggregation · synchronized spawning run" + ], + "detected.coral": [ + "coral", + "reef coral", + "scleractinian", + "scleractinian · reef-building stony coral" + ], + "measure.depth": "−22 m", + "feel.delight": [ + "fun", + "delight", + "joy", + "a darting, bright joy" + ], + "feel.abundance": [ + "full", + "abundance", + "richness", + "a teeming richness" + ], + "feel.curiosity": [ + "look", + "curiosity", + "fascination", + "an absorbed fascination" + ], + "feel.immersion": [ + "in", + "immersion", + "absorption", + "a held, breathless absorption" + ] + } + } + }, + { + "id": "reef_hawkfish", + "title": "Humphead parrotfish over reef (NOAA Fisheries)", + "base_file": "reef_hawkfish/base.mp4", + "license": "public-domain (US Gov, 17 U.S.C. §105)", + "source": "NOAA Fisheries b-roll VIDEO_ID 6039896460001; trim 250–274s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.parrotfish", + "salience": 4, + "appear": 0.1, + "disappear": 0.9, + "track": [ + { + "t": 0.1, + "box": [ + 0.55, + 0.25, + 0.2, + 0.22 + ] + }, + { + "t": 0.5, + "box": [ + 0.35, + 0.34, + 0.2, + 0.22 + ] + }, + { + "t": 0.9, + "box": [ + 0.12, + 0.4, + 0.2, + 0.22 + ] + } + ] + }, + { + "key": "measure.depth", + "box": [ + 0.06, + 0.06, + 0.16, + 0.08 + ], + "min_level": 3 + } + ], + "affect": [ + { + "key": "feel.delight", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.abundance", + "at": [ + 0.22, + 0.68 + ], + "min_level": 2 + }, + { + "key": "feel.curiosity", + "at": [ + 0.66, + 0.58 + ], + "min_level": 3 + }, + { + "key": "feel.immersion", + "at": [ + 0.42, + 0.82 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.parrotfish": [ + "fish", + "parrotfish", + "Bolbometopon muricatum", + "B. muricatum · humphead, grazes reef into sand" + ], + "measure.depth": "−12 m", + "feel.delight": [ + "fun", + "delight", + "joy", + "a darting, bright joy" + ], + "feel.abundance": [ + "full", + "abundance", + "richness", + "a teeming richness" + ], + "feel.curiosity": [ + "look", + "curiosity", + "fascination", + "an absorbed fascination" + ], + "feel.immersion": [ + "in", + "immersion", + "absorption", + "a held, breathless absorption" + ] + } + } + }, + { + "id": "reef_snapper", + "title": "Red snapper schooling (NOAA Fisheries)", + "base_file": "reef_snapper/base.mp4", + "license": "public-domain (US Gov, 17 U.S.C. §105)", + "source": "NOAA Fisheries b-roll VIDEO_ID 5305427942001; trim 7–31s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.snapper", + "salience": 4, + "appear": 0.0, + "disappear": 0.7, + "track": [ + { + "t": 0.0, + "box": [ + 0.1, + 0.3, + 0.16, + 0.14 + ] + }, + { + "t": 0.35, + "box": [ + 0.4, + 0.36, + 0.16, + 0.14 + ] + }, + { + "t": 0.7, + "box": [ + 0.68, + 0.3, + 0.16, + 0.14 + ] + } + ] + } + ], + "affect": [ + { + "key": "feel.delight", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.abundance", + "at": [ + 0.22, + 0.68 + ], + "min_level": 2 + }, + { + "key": "feel.curiosity", + "at": [ + 0.66, + 0.58 + ], + "min_level": 3 + }, + { + "key": "feel.immersion", + "at": [ + 0.42, + 0.82 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.snapper": [ + "fish", + "red snapper", + "Lutjanus campechanus", + "L. campechanus · long-lived, can reach 50+ yr" + ], + "feel.delight": [ + "fun", + "delight", + "joy", + "a darting, bright joy" + ], + "feel.abundance": [ + "full", + "abundance", + "richness", + "a teeming richness" + ], + "feel.curiosity": [ + "look", + "curiosity", + "fascination", + "an absorbed fascination" + ], + "feel.immersion": [ + "in", + "immersion", + "absorption", + "a held, breathless absorption" + ] + } + } + }, + { + "id": "reef_coralspacific", + "title": "Pacific coral macro (NOAA Fisheries)", + "base_file": "reef_coralspacific/base.mp4", + "license": "public-domain (US Gov, 17 U.S.C. §105)", + "source": "NOAA Fisheries b-roll VIDEO_ID 5231464663001; trim 30–54s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.coral", + "salience": 4, + "box": [ + 0.2, + 0.4, + 0.4, + 0.4 + ] + }, + { + "key": "detected.spotfish", + "salience": 2, + "appear": 0.2, + "disappear": 0.8, + "track": [ + { + "t": 0.2, + "box": [ + 0.6, + 0.25, + 0.1, + 0.09 + ] + }, + { + "t": 0.5, + "box": [ + 0.45, + 0.3, + 0.1, + 0.09 + ] + }, + { + "t": 0.8, + "box": [ + 0.3, + 0.26, + 0.1, + 0.09 + ] + } + ] + } + ], + "affect": [ + { + "key": "feel.delight", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.abundance", + "at": [ + 0.22, + 0.68 + ], + "min_level": 2 + }, + { + "key": "feel.curiosity", + "at": [ + 0.66, + 0.58 + ], + "min_level": 3 + }, + { + "key": "feel.immersion", + "at": [ + 0.42, + 0.82 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.coral": [ + "coral", + "coral colony", + "Pacific scleractinian", + "Pacific scleractinian · a symbiosis with algae" + ], + "detected.spotfish": [ + "fish", + "spotted fish", + "reef damselfish", + "reef damselfish · territorial over its coral" + ], + "feel.delight": [ + "fun", + "delight", + "joy", + "a darting, bright joy" + ], + "feel.abundance": [ + "full", + "abundance", + "richness", + "a teeming richness" + ], + "feel.curiosity": [ + "look", + "curiosity", + "fascination", + "an absorbed fascination" + ], + "feel.immersion": [ + "in", + "immersion", + "absorption", + "a held, breathless absorption" + ] + } + } + }, + { + "id": "abyss_wow", + "title": "“World of Water” (NOAA Ocean Exploration)", + "base_file": "abyss_wow/base.mp4", + "license": "public-domain (US Gov, 17 U.S.C. §105)", + "source": "NOAA Ocean Exploration wow-1280x720-1; trim 10–34s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.jelly", + "salience": 4, + "appear": 0.0, + "disappear": 0.55, + "track": [ + { + "t": 0.0, + "box": [ + 0.05, + 0.25, + 0.14, + 0.18 + ] + }, + { + "t": 0.3, + "box": [ + 0.3, + 0.32, + 0.14, + 0.18 + ] + }, + { + "t": 0.55, + "box": [ + 0.52, + 0.28, + 0.14, + 0.18 + ] + } + ] + }, + { + "key": "detected.siphonophore", + "salience": 3, + "appear": 0.45, + "disappear": 1.0, + "track": [ + { + "t": 0.45, + "box": [ + 0.8, + 0.6, + 0.1, + 0.3 + ] + }, + { + "t": 0.7, + "box": [ + 0.6, + 0.45, + 0.1, + 0.34 + ] + }, + { + "t": 1.0, + "box": [ + 0.45, + 0.3, + 0.1, + 0.38 + ] + } + ] + }, { "key": "measure.depth", "box": [ @@ -426,7 +1884,7 @@ "key": "feel.unease", "at": [ 0.5, - 0.46 + 0.44 ], "min_level": 1 }, @@ -457,12 +1915,332 @@ ], "strings": { "en": { - "detected.organism": "siphonophore", + "detected.jelly": [ + "jelly", + "comb jelly", + "Ctenophora", + "Ctenophora · swims by beating rows of cilia" + ], + "detected.siphonophore": [ + "chain", + "siphonophore", + "Siphonophorae", + "Siphonophorae · a colony of clones, can exceed 40 m" + ], + "measure.depth": "−1,200 m", + "feel.unease": [ + "uh", + "unease", + "disquiet", + "a creeping disquiet" + ], + "feel.fascination": [ + "ooh", + "fascination", + "wonder", + "a forbidden wonder" + ], + "feel.isolation": [ + "alone", + "isolation", + "solitude", + "a crushing solitude" + ], + "feel.dread": [ + "fear", + "dread", + "foreboding", + "a slow, cold foreboding" + ] + } + } + }, + { + "id": "abyss_midwaterexp", + "title": "“Midwater Exploration” (NOAA Ocean Exploration)", + "base_file": "abyss_midwaterexp/base.mp4", + "license": "public-domain (US Gov, 17 U.S.C. §105)", + "source": "NOAA Ocean Exploration midwater-exploration-1920x1080-1; trim 16–40s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.medusa", + "salience": 4, + "appear": 0.0, + "disappear": 0.6, + "track": [ + { + "t": 0.0, + "box": [ + 0.55, + 0.2, + 0.16, + 0.2 + ] + }, + { + "t": 0.3, + "box": [ + 0.4, + 0.34, + 0.16, + 0.2 + ] + }, + { + "t": 0.6, + "box": [ + 0.22, + 0.3, + 0.16, + 0.2 + ] + } + ] + }, + { + "key": "detected.worm", + "salience": 2, + "appear": 0.5, + "disappear": 1.0, + "track": [ + { + "t": 0.5, + "box": [ + 0.1, + 0.7, + 0.12, + 0.1 + ] + }, + { + "t": 0.75, + "box": [ + 0.3, + 0.6, + 0.12, + 0.1 + ] + }, + { + "t": 1.0, + "box": [ + 0.5, + 0.66, + 0.12, + 0.1 + ] + } + ] + }, + { + "key": "measure.depth", + "box": [ + 0.06, + 0.06, + 0.16, + 0.08 + ], + "min_level": 2 + } + ], + "affect": [ + { + "key": "feel.unease", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.fascination", + "at": [ + 0.22, + 0.68 + ], + "min_level": 2 + }, + { + "key": "feel.isolation", + "at": [ + 0.66, + 0.58 + ], + "min_level": 3 + }, + { + "key": "feel.dread", + "at": [ + 0.42, + 0.82 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.medusa": [ + "jelly", + "white medusa", + "Scyphozoa", + "Scyphozoa · the swimming medusa stage of a jellyfish" + ], + "detected.worm": [ + "worm", + "red worm", + "polychaete", + "polychaete · a free-swimming bristle worm" + ], + "measure.depth": "−1,600 m", + "feel.unease": [ + "uh", + "unease", + "disquiet", + "a creeping disquiet" + ], + "feel.fascination": [ + "ooh", + "fascination", + "wonder", + "a forbidden wonder" + ], + "feel.isolation": [ + "alone", + "isolation", + "solitude", + "a crushing solitude" + ], + "feel.dread": [ + "fear", + "dread", + "foreboding", + "a slow, cold foreboding" + ] + } + } + }, + { + "id": "abyss_hiding", + "title": "“Hiding in the Dark” (NOAA Ocean Exploration)", + "base_file": "abyss_hiding/base.mp4", + "license": "public-domain (US Gov, 17 U.S.C. §105)", + "source": "NOAA Ocean Exploration dark-1280x720-1; trim 20–44s, crossfade-loop", + "right_variants": {}, + "annotations": [ + { + "key": "detected.jelly", + "salience": 4, + "appear": 0.0, + "disappear": 0.7, + "track": [ + { + "t": 0.0, + "box": [ + 0.15, + 0.28, + 0.16, + 0.2 + ] + }, + { + "t": 0.35, + "box": [ + 0.42, + 0.36, + 0.16, + 0.2 + ] + }, + { + "t": 0.7, + "box": [ + 0.66, + 0.3, + 0.16, + 0.2 + ] + } + ] + }, + { + "key": "measure.depth", + "box": [ + 0.06, + 0.06, + 0.16, + 0.08 + ], + "min_level": 2 + } + ], + "affect": [ + { + "key": "feel.unease", + "at": [ + 0.5, + 0.44 + ], + "min_level": 1 + }, + { + "key": "feel.fascination", + "at": [ + 0.22, + 0.68 + ], + "min_level": 2 + }, + { + "key": "feel.isolation", + "at": [ + 0.66, + 0.58 + ], + "min_level": 3 + }, + { + "key": "feel.dread", + "at": [ + 0.42, + 0.82 + ], + "min_level": 4 + } + ], + "strings": { + "en": { + "detected.jelly": [ + "jelly", + "crimson jelly", + "Scyphozoa", + "Scyphozoa · red is invisible in the lightless deep" + ], "measure.depth": "−2,140 m", - "feel.unease": "unease", - "feel.fascination": "fascination", - "feel.isolation": "isolation", - "feel.dread": "dread" + "feel.unease": [ + "uh", + "unease", + "disquiet", + "a creeping disquiet" + ], + "feel.fascination": [ + "ooh", + "fascination", + "wonder", + "a forbidden wonder" + ], + "feel.isolation": [ + "alone", + "isolation", + "solitude", + "a crushing solitude" + ], + "feel.dread": [ + "fear", + "dread", + "foreboding", + "a slow, cold foreboding" + ] } } } @@ -471,23 +2249,52 @@ "scales": [ { "id": "cosmos", - "clip_id": "cosmos" + "clip_id": "cosmos", + "pool": [ + "cosmos", + "cosmos_galaxies", + "cosmos_hudf", + "cosmos_xdf" + ] }, { "id": "orbit", - "clip_id": "orbit" + "clip_id": "orbit_planetearth", + "pool": [ + "orbit_planetearth", + "orbit_crewobs", + "orbit_bluemarble" + ] }, { - "id": "forest", - "clip_id": "forest" + "id": "coast", + "clip_id": "coast_birdrock", + "pool": [ + "coast_birdrock", + "coast_surfgrass", + "coast_elkbeach", + "coast_drakesbeach" + ] }, { "id": "reef", - "clip_id": "reef" + "clip_id": "reef_lionfish", + "pool": [ + "reef_lionfish", + "reef_spawning", + "reef_hawkfish", + "reef_snapper", + "reef_coralspacific" + ] }, { "id": "abyss", - "clip_id": "abyss" + "clip_id": "abyss_wow", + "pool": [ + "abyss_wow", + "abyss_midwaterexp", + "abyss_hiding" + ] } ], "transitions": [ @@ -496,11 +2303,11 @@ "model": "placeholder-zoom" }, { - "file": "transitions/orbit-forest.mp4", + "file": "transitions/orbit-coast.mp4", "model": "placeholder-zoom" }, { - "file": "transitions/forest-reef.mp4", + "file": "transitions/coast-reef.mp4", "model": "placeholder-zoom" }, { diff --git a/simulator/setup_scales_media.py b/simulator/setup_scales_media.py index 2c3a767..c79a364 100644 --- a/simulator/setup_scales_media.py +++ b/simulator/setup_scales_media.py @@ -1,5 +1,13 @@ """Generate cheap placeholder media for the full 5-scale RING (scales design §3). +SUPERSEDED (session 0016): the ring now uses real strict-PD footage in a ROTATING +POOL per scale, regenerated by `simulator/build_pool_manifest.py` (which also makes +the new coast-edge transition placeholders). This generator built the older +one-base-per-scale forest ring (cosmos/orbit/forest/reef/abyss) and is kept only +for reference / a clean-room placeholder rebuild; it does NOT match the current +pool manifest or ring order (cosmos → orbit → coast → reef → abyss). + + Produces offline synthetic placeholders for all five ring scales — **cosmos** (NASA/Hubble), **orbit** (NASA/ISS), **forest** (NPS/USGS), **reef** (NOAA Ocean Exploration), and **abyss** (NOAA Ocean Exploration) — plus the short zoom/warp diff --git a/simulator/static/app.js b/simulator/static/app.js index bdf6bad..e8606bc 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -31,8 +31,10 @@ window.addEventListener("error", (e) => showError(`${e.message} @ ${(e.filename || "").split("/").pop()}:${e.lineno}`)); let clipsById = {}; // id -> clip manifest entry -let ring = null; // {scales:[{id,clip_id,title}], transitions:[...]} or null +let ring = null; // {scales:[{id,clip_id,title,pool:[...]}], transitions:[...]} or null +let serverRing = false; // true when /api/ring served a real ring (vs the synthesized fallback) let ringIndex = 0; // current scale position on the ring +let activeClipId = null; // the pool member chosen for the current scale landing let currentClipId = null; // base media currently loaded (reset to force a reload) let busy = false; // true while a ring transition is playing let lastOverlay = null; // {level, intensity} from the most-recent renderOverlay call; drives per-frame track animation @@ -41,17 +43,36 @@ async function loadData() { const clips = (await (await fetch("/api/clips")).json()).clips || []; clipsById = Object.fromEntries(clips.map((c) => [c.id, c])); const r = await fetch("/api/ring"); + serverRing = r.ok; ring = r.ok ? await r.json() : null; if (!ring && clips.length) { - // No ring: single-clip mode — synthesize a 1-scale ring over clip 0. - ring = { scales: [{ id: clips[0].id, clip_id: clips[0].id, title: clips[0].title }], transitions: [] }; + // No ring: single-clip mode — synthesize a 1-scale ring (pool of one) over clip 0. + ring = { scales: [{ id: clips[0].id, clip_id: clips[0].id, title: clips[0].title, pool: [{ clip_id: clips[0].id, title: clips[0].title }] }], transitions: [] }; } ringIndex = 0; } +// Land on the current scale: pick a random pool member (content-pipeline §11.1). +// A real server ring picks via a delta=0 advance (Python-canonical pick); the +// synthesized fallback ring has a pool of one, so it just takes that member. +async function landScale() { + const scale = ring && ring.scales[ringIndex]; + if (!scale) { activeClipId = null; return; } + if (serverRing) { + try { + const resp = await fetch("/api/ring/advance", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ from_index: ringIndex, delta: 0 }), + }); + if (resp.ok) { activeClipId = (await resp.json()).target_clip_id; currentClipId = null; return; } + } catch (_) { /* fall through to the primary member */ } + } + activeClipId = scale.clip_id; + currentClipId = null; +} + function activeClip() { - if (!ring) return null; - return clipsById[ring.scales[ringIndex].clip_id] || null; + return clipsById[activeClipId] || null; } function mediaUrl(file) { return "/media/" + file; } @@ -183,10 +204,12 @@ function paintLoop() { gl.drawArrays(gl.TRIANGLES, 0, 3); paint.style.filter = busy ? "none" : gradeFilter; } - // Animate keyframed annotation tracks: re-place boxes against playback time. + // Animate per-frame: re-place keyframed tracks AND re-evaluate time-windowed + // labels (a label enters/leaves frame as the loop plays) against playback time. const c = activeClip(); if (lastOverlay && lastOverlay.level > 0 && c && - c.annotations.some((a) => a.track && a.track.length)) { + c.annotations.some((a) => (a.track && a.track.length) || + typeof a.appear === "number" || typeof a.disappear === "number")) { renderOverlay(lastOverlay.level, lastOverlay.intensity); } requestAnimationFrame(paintLoop); @@ -261,17 +284,50 @@ function loopT() { return vid && vid.duration ? (vid.currentTime % vid.duration) / vid.duration : 0; } +// --- Progressive labels & emotions (content-pipeline §11.3 / §11.4) --- +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + +// The Left level at which a label first appears. From `salience` (1..4): high +// salience appears early (first_level = 5 - salience), so raising Left brings in +// lower-salience objects. Falls back to a legacy flat `min_level`, else 1. +function firstLevel(a) { + if (typeof a.salience === "number") return clamp(5 - a.salience, 1, 4); + if (typeof a.min_level === "number") return a.min_level; + return 1; +} + +// A tiered string escalates with a knob: a LIST is indexed by tier-1 (clamped to +// the available tiers); a plain string is static (tier ignored — back-compat). +function pickTier(value, tier) { + if (Array.isArray(value)) return value[clamp(tier - 1, 0, value.length - 1)]; + return value; +} +function tierCount(value) { return Array.isArray(value) ? value.length : 1; } + +// Time-windowed presence (§11.2): a label with `appear`/`disappear` (loop-norm t) +// shows only while on screen. A `disappear < appear` window wraps across the loop +// seam (visible when t >= appear OR t <= disappear). No window => always present. +function inWindow(a, t) { + const hasA = typeof a.appear === "number", hasD = typeof a.disappear === "number"; + if (!hasA && !hasD) return true; + const ap = hasA ? a.appear : 0, dis = hasD ? a.disappear : 1; + return ap <= dis ? (t >= ap && t <= dis) : (t >= ap || t <= dis); +} + function renderOverlay(level, intensity) { const clip = activeClip(); overlay.innerHTML = ""; if (!clip || level <= 0) { overlay.style.opacity = "0"; return; } overlay.style.opacity = String(intensity); const strings = (clip.strings && clip.strings.en) || {}; + const t = loopT(); let shown = 0; for (const a of clip.annotations) { - if (a.min_level > level) continue; + const first = firstLevel(a); + if (level < first) continue; // salience gating: low Left = fewer/general labels + if (!inWindow(a, t)) continue; // time-windowed: only while the object is on screen shown++; - const [x, y, w, h] = boxAt(a, loopT()).map((n) => n * 100); + const [x, y, w, h] = boxAt(a, t).map((n) => n * 100); const measure = a.key.startsWith("measure."); const kind = measure ? "measure" : "detect"; reticle(x, y, w, h, "hud-reticle " + kind, overlay); @@ -281,7 +337,12 @@ function renderOverlay(level, intensity) { svg("line", { x1: cx - 1, y1: cyc, x2: cx + 1, y2: cyc, class: "hud-reticle measure" }, overlay); svg("line", { x1: cx, y1: cyc - 1, x2: cx, y2: cyc + 1, class: "hud-reticle measure" }, overlay); } - chip(x, y, strings[a.key] || a.key, measure ? null : confidence(a.key), kind, overlay); + // LEFT detail tier: a detection escalates general -> scientific+fact as Left + // rises above where it first appeared; measurements stay single-tier. + const raw = strings[a.key]; + const tier = measure ? 1 : clamp(level - first + 1, 1, tierCount(raw)); + const label = pickTier(raw !== undefined ? raw : a.key, tier); + chip(x, y, label, measure ? null : confidence(a.key), kind, overlay); } // Global status tag — the machine announcing it is analyzing, escalating with Left. // Right-anchored at the frame edge; the plate is sized from the text's real bbox @@ -302,7 +363,7 @@ function renderOverlay(level, intensity) { // up. `strength` = min(left, right); `intensity` is the layer opacity. Words are // placed at authored scene points (no boxes — feelings are scene-level) and read // softer than the clinical reticles — the dream leaking into the machine's read. -function renderAffect(strength, intensity) { +function renderAffect(strength, intensity, right) { const clip = activeClip(); if (!affectLayer) return; // tolerate a stale page missing the affect layer affectLayer.innerHTML = ""; @@ -313,14 +374,21 @@ function renderAffect(strength, intensity) { if (f.min_level > strength) continue; const [x, y] = f.at.map((n) => n * 100); const t = svg("text", { x, y, "text-anchor": "middle", class: "hud-affect" }, affectLayer); - t.textContent = strings[f.key] || f.key; + // RIGHT emotion tier: the vocabulary escalates basic -> compound as the Right + // knob rises (appearance still gated by min(left,right) above). + const raw = strings[f.key]; + t.textContent = pickTier(raw !== undefined ? raw : f.key, clamp(right || 0, 1, tierCount(raw))); } } function renderScaleReadout() { if (!ring) return; const s = ring.scales[ringIndex]; - $("scale-name").textContent = `${s.title} (${ringIndex + 1}/${ring.scales.length})`; + const poolN = (s.pool && s.pool.length) || 1; + const member = (activeClip() && activeClip().title) || s.title; + // scale id · the chosen pool member · position on the ring (pool size if >1) + const poolTag = poolN > 1 ? ` · pool ${poolN}` : ""; + $("scale-name").textContent = `${s.id} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`; } function controls() { @@ -352,7 +420,7 @@ async function update() { ensureClipMedia(); applyVideoLook(data.plan.grade.tone, data.plan.dream.intensity); renderOverlay(data.plan.overlay.level, data.plan.overlay.intensity); - renderAffect(data.plan.affect.strength, data.plan.affect.intensity); + renderAffect(data.plan.affect.strength, data.plan.affect.intensity, data.plan.dream.strength); const b = document.getElementById("err-banner"); if (b) b.remove(); // render succeeded — clear any prior error } catch (err) { @@ -411,7 +479,8 @@ async function advance(delta) { await playTransition(step.file, step.blended); } ringIndex = move.to_index; - currentClipId = null; // force the target scale's base media to (re)load + activeClipId = move.target_clip_id; // the randomly-picked pool member to load + currentClipId = null; // force the target scale's base media to (re)load renderScaleReadout(); } finally { busy = false; @@ -450,6 +519,7 @@ async function main() { devLiveReload(); try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); } await loadData(); + await landScale(); // pick the initial scale's pool member before first render renderScaleReadout(); for (const id of ["content", "left", "right", "mood"]) { $(id).addEventListener("input", debounced); diff --git a/tests/test_player_ring.py b/tests/test_player_ring.py index 1964ca8..ddb1a87 100644 --- a/tests/test_player_ring.py +++ b/tests/test_player_ring.py @@ -8,6 +8,7 @@ from player.ring import ( Transition, TransitionStep, advance_ring, + pick_clip_id, scale_at, ) @@ -206,3 +207,36 @@ def test_fast_spin_noop_on_degenerate_ring(): move = advance_ring(r, from_index=0, delta=9, fast_spin_threshold=3) assert move.fast is False assert move.steps == () + + +# --- rotating clip pool (content-pipeline design §11.1) --- + + +def test_scale_without_pool_is_a_pool_of_one(): + s = Scale(id="cosmos", clip_id="cosmos") + assert s.members == ("cosmos",) + # any r maps to the single member + assert pick_clip_id(s, 0.0) == "cosmos" + assert pick_clip_id(s, 0.99) == "cosmos" + + +def test_scale_with_pool_exposes_all_members(): + s = Scale(id="coast", clip_id="coast_a", pool=("coast_a", "coast_b", "coast_c")) + assert s.members == ("coast_a", "coast_b", "coast_c") + + +def test_pick_clip_id_selects_member_by_injected_r(): + s = Scale(id="coast", clip_id="a", pool=("a", "b", "c", "d")) + # r in [0,1) partitions the pool into len equal buckets + assert pick_clip_id(s, 0.0) == "a" + assert pick_clip_id(s, 0.24) == "a" + assert pick_clip_id(s, 0.25) == "b" + assert pick_clip_id(s, 0.5) == "c" + assert pick_clip_id(s, 0.75) == "d" + + +def test_pick_clip_id_clamps_out_of_range_r(): + s = Scale(id="coast", clip_id="a", pool=("a", "b", "c")) + assert pick_clip_id(s, -5.0) == "a" # below 0 clamps to first + assert pick_clip_id(s, 1.0) == "c" # 1.0 must not index past the end + assert pick_clip_id(s, 999.0) == "c" diff --git a/tests/test_simulator_api.py b/tests/test_simulator_api.py index 2fddc19..fcd227a 100644 --- a/tests/test_simulator_api.py +++ b/tests/test_simulator_api.py @@ -189,3 +189,64 @@ def test_ring_404_when_no_ring_in_manifest(client): def test_ring_advance_rejects_bad_delta(ring_client): resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": "x"}) assert resp.status_code == 422 + + +# --- rotating clip pool (content-pipeline §11.1) --- + + +@pytest.fixture +def pool_client(tmp_path): + p = tmp_path / "manifest.json" + members = ["coast_a", "coast_b", "coast_c", "coast_d"] + clips = [{"id": "cosmos", "title": "Cosmos", "base_file": "cosmos/base.mp4", + "license": "PD", "source": "NASA", "right_variants": {}, + "annotations": [], "affect": [], "strings": {}}] + clips += [{"id": m, "title": m, "base_file": f"{m}/base.mp4", "license": "PD", + "source": "NPS", "right_variants": {}, "annotations": [], "affect": [], + "strings": {}} for m in members] + p.write_text(json.dumps({ + "clips": clips, + "ring": { + "scales": [ + {"id": "cosmos", "clip_id": "cosmos"}, + {"id": "coast", "clip_id": members[0], "pool": members}, + ], + "transitions": [ + {"file": "transitions/cosmos-coast.mp4"}, + {"file": "transitions/coast-cosmos.mp4"}, + ], + }, + })) + return TestClient(create_app(manifest_path=p)), members + + +def test_ring_exposes_each_scales_pool(pool_client): + client, members = pool_client + data = client.get("/api/ring").json() + coast = next(s for s in data["scales"] if s["id"] == "coast") + assert [m["clip_id"] for m in coast["pool"]] == members + # a single-clip scale still reports a pool of one + cosmos = next(s for s in data["scales"] if s["id"] == "cosmos") + assert [m["clip_id"] for m in cosmos["pool"]] == ["cosmos"] + + +def test_advance_into_a_pool_lands_on_a_member(pool_client): + client, members = pool_client + # landing on the coast scale (index 1) must pick one of its pool members + seen = set() + for _ in range(40): + data = client.post("/api/ring/advance", json={"from_index": 0, "delta": 1}).json() + assert data["to_index"] == 1 + assert data["target_clip_id"] in members + seen.add(data["target_clip_id"]) + # over many landings the random pick should cover more than one member + assert len(seen) > 1 + + +def test_delta_zero_is_an_initial_pool_pick(pool_client): + client, members = pool_client + # a no-op (delta 0) advance on the pool scale is the initial / re-roll pick + data = client.post("/api/ring/advance", json={"from_index": 1, "delta": 0}).json() + assert data["to_index"] == 1 + assert data["steps"] == [] + assert data["target_clip_id"] in members