Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 229af60f1f | |||
| 32e74ca446 | |||
| e3a9b7cff6 | |||
| 7a2f37af7e | |||
| d0ad9a9ac9 | |||
| c3aa419c53 | |||
| 5bb762ae53 | |||
| 2eb752b5bc | |||
| aa56dfe145 | |||
| ed267c554c | |||
| 7da7446740 | |||
| d24f829276 | |||
| de33cbe86d | |||
| 129bb23cb9 | |||
| a2ef792f0f | |||
| 53cb89b4c6 | |||
| 0201690ce0 |
@@ -1,7 +1,11 @@
|
||||
.PHONY: sim sim-local
|
||||
|
||||
# Prefer the project venv's interpreter (this box has no bare `python` on PATH),
|
||||
# fall back to python3, then python.
|
||||
PYTHON ?= $(firstword $(wildcard .venv/bin/python) python3 python)
|
||||
|
||||
sim:
|
||||
docker compose -f simulator/docker-compose.yml up --build
|
||||
|
||||
sim-local:
|
||||
python -m uvicorn simulator.app:app --reload --port 8000
|
||||
$(PYTHON) -m uvicorn simulator.app:app --reload --port 8000
|
||||
|
||||
@@ -23,20 +23,43 @@ a credit line** (our overlays + dream are derivatives, which these allow);
|
||||
**CC‑BY‑ND is NOT usable** (no derivatives); **CC‑BY‑NC only if the installation
|
||||
stays non‑commercial**.
|
||||
|
||||
## 🌌 cosmos (pool of 4)
|
||||
## 🌌 cosmos (pool of 5)
|
||||
|
||||
| id | clip | source | window (s) | license / credit |
|
||||
|----|------|--------|-----------|------------------|
|
||||
| `cosmos` | Orion Nebula flythrough | NASA/JPL‑Caltech — https://images.nasa.gov/details/JPL-20221122-SOLSYSf-0001-Orion%20Dust%20and%20Death | ~30–54 | PD (17 U.S.C. §105) |
|
||||
| `cosmos` | Cosmic Cliffs — Carina Nebula flythrough | NASA SVS 31348 — https://svs.gsfc.nasa.gov/31348/ (`Clifs-3d-STScI.mp4`, *Exploring the Cosmic Cliffs in 3D*, Webb NIRCam, **4K**) | 10–34 | CC‑BY‑class — credit **NASA, ESA, CSA, STScI** |
|
||||
| `cosmos_miri` | Cosmic Cliffs — Carina Nebula, **mid-infrared** (same towers, muted gray/teal/gold dust palette) | ESA/Webb weic2205c — https://esawebb.org/videos/weic2205c/ (*Pan of the Carina Nebula, combined NIRCam + MIRI*, **4K** `weic2205c.mp4`) | 4–26 | CC‑BY‑class — credit **NASA, ESA, CSA, STScI** |
|
||||
|
||||
> ℹ️ **Cosmos primary swapped to the Webb "Cosmic Cliffs" flythrough (2026-06-25, session 0018).**
|
||||
> History: the original primary was NASA/JPL's *"Orion: Dust and Death"* (captions
|
||||
> burned in → cropping lost framing); it was then swapped to STScI's text-free
|
||||
> *Flight Through the Orion Nebula* (SVS 30957, visible light). The operator found
|
||||
> the Orion clip merely good and asked for a more dramatic nebula, so the primary is
|
||||
> now STScI's **Exploring the Cosmic Cliffs in 3D** (Carina Nebula, Webb NIRCam,
|
||||
> **SVS 31348**) — a full-frame, text-free 3D flythrough sourced from its **4K**
|
||||
> master (`Clifs-3d-STScI.mp4`, 3840×2160) → supersampled 1080p base + 4K master.
|
||||
> Trim 10–34s clears the opening title card (gone by ~8s) and the end-credit card
|
||||
> (~102s). The earlier `decaption_cosmos` crop helper remains removed.
|
||||
| `cosmos_galaxies` | Flying Through Galaxies | NASA SVS — https://svs.gsfc.nasa.gov/vis/a010000/a014900/a014950/14950_Galaxies_FlyThrough_4k.mp4 | 8–32 | PD |
|
||||
| `cosmos_hudf` | Hubble Ultra Deep Field zoom | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030687/hudf-b-1920x1080p30.mov | 20–44 | CC‑BY‑class — credit **NASA, ESA, STScI** |
|
||||
| `cosmos_xdf` | eXtreme Deep Field flythrough | NASA SVS — https://svs.gsfc.nasa.gov/vis/a030000/a030600/a030681/hxdf_fly-b-1920x1080p30.mov | 2–26 | CC‑BY‑class — credit **NASA, ESA, STScI** |
|
||||
|
||||
> ➕ **`cosmos_miri` added (2026-06-26).** Operator wanted more *Cosmic Cliffs*
|
||||
> footage and a different-color rendering. The keep-the-flythrough-primary +
|
||||
> add-the-mid-infrared-pan pair: ESA/Webb's **weic2205c** is the *same* Carina
|
||||
> towers shot with NIRCam **+ MIRI**, so it renders in a muted gray/teal/gold dust
|
||||
> palette instead of the iconic orange/blue — a distinct look from the same scene.
|
||||
> Sourced text-free/human-free from the **4K** master, trim 4–26s (clears the
|
||||
> opening fade-from-black, ends before any tail fade) → 1080p base + 4K master,
|
||||
> 1 s crossfade-loop (~21 s). ESA/Webb's narration-free *"Diving Into the Cosmic
|
||||
> Cliffs"* pan (`carina_revisit_pan`, NIRCam palette) was the runner-up — left
|
||||
> out for now since it repeats the existing palette; easy to add if wanted.
|
||||
|
||||
> ⚠️ **DISCREPANCY to fix next session.** Operator selected cosmos **#1, #3
|
||||
> (Orion), #4, #5**. During processing, **#6 Galaxy Traverse was run by mistake
|
||||
> instead of #3 Orion**. The Orion clip (#3) is text‑free and already lives at
|
||||
> `simulator/sample_media/cosmos/base.mp4` (the prior real base), so the pool above
|
||||
> is correct. **`cosmos_traverse` is an UNSELECTED extra on disk — drop it** (or
|
||||
> instead of #3 Orion**. The cosmos primary now lives at
|
||||
> `simulator/sample_media/cosmos/base.mp4` as the Webb Cosmic Cliffs flythrough
|
||||
> (SVS 31348 — see the cosmos-primary note above). **`cosmos_traverse` is an UNSELECTED extra on disk — drop it** (or
|
||||
> confirm with operator). The current ring scale id is `cosmos`; when the pool is
|
||||
> wired, `cosmos` becomes one pool member.
|
||||
|
||||
@@ -108,5 +131,5 @@ Removed from local media (all were gitignored, so repo-invisible):
|
||||
- `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).
|
||||
not in the new pools. **dropped** (the cosmos base — now the Webb Cosmic Cliffs flythrough, SVS 31348 — stays as the cosmos pool primary).
|
||||
- `review.html` gallery — now `.gitignore`d (local‑only, re‑buildable).
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
# Affect channel — emotions in the Left-brain HUD
|
||||
|
||||
**Status:** approved (session 0013, 2026-06-22)
|
||||
**Status:** approved (session 0013, 2026-06-22); **revised session 0018, 2026-06-26**
|
||||
**Surface:** simulator preview (`simulator/`, `player/alteration.py`)
|
||||
**Builds on:** the machine-altered-perception design SPEC; the Left HUD look-and-feel
|
||||
pass (same session).
|
||||
|
||||
> **Revision (session 0018) — affect is a RIGHT-brain output.** The original design
|
||||
> gated emotions on *both* knobs (`strength = min(left, right)`). Per operator
|
||||
> direction, **emotions are now controlled by the Right (dreamlike) knob alone** —
|
||||
> the analytical Left knob no longer gates them. This sharpens the left/right split:
|
||||
> Left = measurement (detections + measurements), Right = the dream **and** the
|
||||
> feeling it evokes. Every `min(left, right)` below now reads `right`. The data
|
||||
> shape, rendering, and per-word `min_level` mechanic are unchanged — only the knob
|
||||
> the strength is keyed off.
|
||||
|
||||
## Idea
|
||||
|
||||
A third HUD channel beside the cyan *detections* and amber *measurements*: soft,
|
||||
glowing **emotion-words** that surface the feelings the experience may invoke.
|
||||
They appear only when *both* the analytical (Left) and dreamlike (Right) knobs are
|
||||
up — the dream leaking into the machine's reading. This is the uncanny edge of the
|
||||
thesis: the machine trying to quantify not just what is there, but how you are
|
||||
meant to feel.
|
||||
They are a **right-brain output: driven by the dreamlike Right knob alone** (the
|
||||
analytical Left knob does not gate them) — the dream surfacing as the machine's
|
||||
felt reading. This is the uncanny edge of the thesis: the machine trying to
|
||||
quantify not just what is there, but how you are meant to feel.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- **Trigger / intensity.** Affect strength = `min(left, right)` (0–4). Either knob
|
||||
at 0 → no emotions at all. Opacity scales with that strength × `overlay_gain`.
|
||||
- **Escalation.** A **stable emotional palette per scene** — higher combined
|
||||
strength reveals *more* words at higher opacity. Each word carries a `min_level`
|
||||
(the same mechanic the detection channel uses), but compared against the
|
||||
*combined* `min(left, right)` strength rather than Left alone.
|
||||
- **Trigger / intensity.** Affect strength = `right` (0–4). Right at 0 → no
|
||||
emotions at all, regardless of Left. Opacity scales with that strength ×
|
||||
`overlay_gain`.
|
||||
- **Escalation.** A **stable emotional palette per scene** — higher Right reveals
|
||||
*more* words at higher opacity. Each word carries a `min_level` (the same
|
||||
mechanic the detection channel uses), compared against the `right` strength.
|
||||
- **Rendering.** Floating words at authored scene positions — **no boxes or
|
||||
reticles**, lowercase, semi-transparent with a soft glow, in a distinct **violet**
|
||||
register so they read as affective and clearly *softer* than the hard clinical
|
||||
@@ -33,15 +42,15 @@ Consistent with the existing split (alteration math in Python, label-selection i
|
||||
the client):
|
||||
|
||||
- `player/alteration.py` gains an `AffectOverlay(strength, intensity)` on the
|
||||
`RenderPlan`. `strength = min(left, right)`; `intensity = clamp(overlay_gain *
|
||||
min(left,right) / KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under
|
||||
`"affect"`. Pure, unit-tested.
|
||||
`RenderPlan`. `strength = right`; `intensity = clamp(overlay_gain * right /
|
||||
KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under `"affect"`. Pure,
|
||||
unit-tested.
|
||||
- The simulator client picks *which* words to show by comparing each affect
|
||||
entry's `min_level` to `plan.affect.strength`, and renders them at
|
||||
`plan.affect.intensity` opacity.
|
||||
|
||||
`is_identity` is unaffected: `min(left,right) > 0` implies `left > 0`, which already
|
||||
makes `overlay.level > 0`.
|
||||
`is_identity` is unaffected: `affect.strength == right`, and `right > 0` already
|
||||
makes `dream.strength > 0`.
|
||||
|
||||
## Data
|
||||
|
||||
|
||||
+17
-14
@@ -88,11 +88,12 @@ class AnalyticalOverlay:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AffectOverlay:
|
||||
"""Affect channel (Left x Right): emotion-words surfaced only when BOTH the
|
||||
analytical (Left) and dreamlike (Right) knobs are up. `strength` is
|
||||
`min(left, right)` (0..4) — a runtime affect track picks which feeling words
|
||||
appear by it; `intensity` 0..1 is their opacity. The dream leaking into the
|
||||
machine's reading (affect-channel design, session 0013)."""
|
||||
"""Affect channel (RIGHT brain): emotion-words surfaced by the Right (dreamlike)
|
||||
knob alone — the analytical Left knob does NOT gate them. `strength` is the Right
|
||||
knob (0..4) — a runtime affect track picks which feeling words appear by it;
|
||||
`intensity` 0..1 is their opacity. Feeling is the right brain's output, surfaced
|
||||
as the machine's felt reading (affect-channel design, session 0013; Right-only
|
||||
since session 0018)."""
|
||||
|
||||
strength: int
|
||||
intensity: float
|
||||
@@ -122,8 +123,9 @@ class RenderPlan:
|
||||
@property
|
||||
def is_identity(self) -> bool:
|
||||
"""True when the plan leaves the neutral base un-altered. Affect needs no
|
||||
clause: `min(left,right) > 0` implies `left > 0`, so `overlay.level > 0`
|
||||
already makes a plan with active affect non-identity."""
|
||||
clause: `affect.strength == right`, and `right > 0` already makes
|
||||
`dream.strength > 0`, so an active-affect plan is non-identity via the
|
||||
dream check below."""
|
||||
return (
|
||||
self.grade.is_identity
|
||||
and self.overlay.level == 0
|
||||
@@ -135,13 +137,14 @@ def _overlay_intensity(left: int, cal: Calibration) -> float:
|
||||
return _clamp(cal.overlay_gain * left / KNOB_MAX, 0.0, 1.0)
|
||||
|
||||
|
||||
def _affect_strength(left: int, right: int) -> int:
|
||||
"""Affect surfaces only when BOTH knobs are up — gated by the smaller one."""
|
||||
return min(left, right)
|
||||
def _affect_strength(right: int) -> int:
|
||||
"""Affect is a RIGHT-brain output: the emotion channel is driven by the Right
|
||||
(dreamlike) knob alone — the analytical Left knob no longer gates it."""
|
||||
return right
|
||||
|
||||
|
||||
def _affect_intensity(left: int, right: int, cal: Calibration) -> float:
|
||||
return _clamp(cal.overlay_gain * _affect_strength(left, right) / KNOB_MAX, 0.0, 1.0)
|
||||
def _affect_intensity(right: int, cal: Calibration) -> float:
|
||||
return _clamp(cal.overlay_gain * _affect_strength(right) / KNOB_MAX, 0.0, 1.0)
|
||||
|
||||
|
||||
def _dream_intensity(right: int, cal: Calibration) -> float:
|
||||
@@ -163,8 +166,8 @@ def plan_alteration(
|
||||
intensity=_overlay_intensity(coord.left, calibration),
|
||||
),
|
||||
affect=AffectOverlay(
|
||||
strength=_affect_strength(coord.left, coord.right),
|
||||
intensity=_affect_intensity(coord.left, coord.right, calibration),
|
||||
strength=_affect_strength(coord.right),
|
||||
intensity=_affect_intensity(coord.right, calibration),
|
||||
),
|
||||
dream=Dream(
|
||||
strength=coord.right,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Session 0018.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-25T09-42 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: b96041e5-c682-49f5-88ee-380ea7abb6af
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0018 for human-experience-filter-art. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0018.0-TRANSCRIPT-2026-06-25T09-42--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Find a better public-domain nebula flythrough video for the cosmos scale (the current text-free SVS Orion clip lost quality when cropped to remove its text overlay), or upscale the existing one if no better source exists.
|
||||
|
||||
```
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Appended as the session runs; surfaced at
|
||||
finalize. Empty if none._
|
||||
@@ -0,0 +1,25 @@
|
||||
# Session 0019.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-26T04-47 (PST)
|
||||
> Type: brainstorming
|
||||
> Posture: careful
|
||||
> Claude-Session: d6ed1203-92b6-471f-80a4-4c773b645ca1
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0019 for human-experience-filter-art. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0019.0-TRANSCRIPT-2026-06-26T04-47--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
Let's start talking about hardware. Let's assume for the first version I'm ok having my computer or laptop do the rendering. We'd still want a controller but it could be connected to the computer via wifi, USB, or bluetooth and would send commands to the software we've already written that would adjust left brain, right brain, etc. We'd prototype this by having a controller simulator run on another device, such as an ipad (just riffing here) but eventually move to a physical remote running on an Arduino or similar
|
||||
|
||||
```
|
||||
|
||||
## 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._
|
||||
@@ -49,5 +49,11 @@
|
||||
},
|
||||
"0017": {
|
||||
"title": ""
|
||||
},
|
||||
"0018": {
|
||||
"title": ""
|
||||
},
|
||||
"0019": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
+60
-5
@@ -59,6 +59,41 @@ def _asset_version() -> str:
|
||||
return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
# Content-hash tokens for served media, so the client can append `?v=<hash>` to a
|
||||
# /media URL. A clip re-baked under the SAME path (e.g. a re-sourced cosmos base)
|
||||
# changes its hash → its URL → a fresh fetch, busting any cached prior bytes
|
||||
# permanently (even an immutable-pinned entry a plain reload can't revalidate).
|
||||
# Cached by (mtime_ns, size) so the full-file hash is recomputed only when the
|
||||
# file actually changes — a re-bake is picked up without a server restart.
|
||||
_media_hash_cache: dict[str, tuple[int, int, str]] = {}
|
||||
|
||||
|
||||
def _media_version(rel: str) -> Optional[str]:
|
||||
"""Short content hash of the media file at `rel` under MEDIA_DIR, or None if
|
||||
it's absent. Cheap on repeat calls: re-hashes only when (mtime, size) change."""
|
||||
path = MEDIA_DIR / rel
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
cached = _media_hash_cache.get(rel)
|
||||
if cached and cached[0] == st.st_mtime_ns and cached[1] == st.st_size:
|
||||
return cached[2]
|
||||
h = hashlib.sha1()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
token = h.hexdigest()[:12]
|
||||
_media_hash_cache[rel] = (st.st_mtime_ns, st.st_size, token)
|
||||
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):
|
||||
content: str
|
||||
left: int = Field(ge=0, le=4)
|
||||
@@ -160,6 +195,24 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
|
||||
def api_clips():
|
||||
return {"clips": [c.to_dict() for c in app.state.clips]}
|
||||
|
||||
@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."""
|
||||
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)
|
||||
if v:
|
||||
versions[f] = v
|
||||
return {"versions": versions}
|
||||
|
||||
@app.get("/api/ring")
|
||||
def api_ring():
|
||||
if app.state.ring is None:
|
||||
@@ -237,13 +290,15 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
|
||||
async def _cache_policy(request, call_next):
|
||||
# Dev preview server: never let a browser serve a stale app.js/style.css —
|
||||
# by-eye iteration needs a plain refresh to always get the latest assets.
|
||||
# Media is the exception: the clips are static and large, so they must be
|
||||
# browser-cacheable. The renderer also preloads them into in-memory blob
|
||||
# URLs (static/app.js), but a cacheable header keeps the preload fetch and
|
||||
# any fallback off the network on repeat loads — instant altitude swaps.
|
||||
# Media uses `no-cache` (store, but REVALIDATE every load): instant altitude
|
||||
# swaps already come from the in-memory blob preload (static/app.js), so the
|
||||
# HTTP cache only matters on (re)load — where a conditional request returns a
|
||||
# cheap 304 when unchanged, yet picks up a re-sourced/re-cropped clip
|
||||
# immediately. `immutable` was wrong here: it pinned stale footage for a year
|
||||
# so edited clips never showed without a manual cache clear.
|
||||
response = await call_next(request)
|
||||
if request.url.path.startswith("/media/"):
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
return response
|
||||
|
||||
@@ -38,7 +38,7 @@ 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"],
|
||||
"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"],
|
||||
@@ -51,12 +51,18 @@ 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"
|
||||
CCBY_WEBB = "CC-BY-class — credit NASA, ESA, CSA, 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": ("Cosmic Cliffs — Carina Nebula flythrough (NASA/ESA/CSA/STScI)", CCBY_WEBB,
|
||||
"NASA SVS 31348 Clifs-3d-STScI (Exploring the Cosmic Cliffs in 3D, Webb NIRCam); "
|
||||
"4K source, trim 10–34s, crossfade-loop. Text-free — replaces the Orion SVS 30957 flythrough."),
|
||||
"cosmos_miri": ("Cosmic Cliffs — Carina Nebula, mid-infrared (NASA/ESA/CSA/STScI)", CCBY_WEBB,
|
||||
"ESA/Webb weic2205c (Pan of the Carina Nebula, combined NIRCam + MIRI); 4K source, "
|
||||
"trim 4–26s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold "
|
||||
"dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free."),
|
||||
"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,
|
||||
@@ -161,9 +167,14 @@ def measure(key, value, box, min_level):
|
||||
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),
|
||||
static_label("detected.nebula", 4, ["cloud", "nebula", "emission nebula", "emission nebula · the Carina star-forming region"], [0.20, 0.42, 0.5, 0.42]),
|
||||
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · a newborn star, <1 Myr old"], [0.54, 0.12, 0.12, 0.14]),
|
||||
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
|
||||
],
|
||||
"cosmos_miri": [
|
||||
static_label("detected.nebula", 4, ["dust", "nebula", "dust ridge", "dust ridge · mid-infrared glow of the Carina cliffs"], [0.10, 0.45, 0.7, 0.45]),
|
||||
static_label("detected.star", 2, ["star", "young star", "protostar", "protostar · its dust laid bare in mid-infrared"], [0.48, 0.18, 0.14, 0.16]),
|
||||
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
|
||||
],
|
||||
"cosmos_galaxies": [
|
||||
static_label("detected.galaxy", 4, ["galaxy", "spiral galaxy", "barred spiral", "barred spiral · ~10¹¹ stars"], [0.34, 0.30, 0.30, 0.34]),
|
||||
@@ -355,7 +366,7 @@ def build_manifest() -> dict:
|
||||
return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}}
|
||||
|
||||
|
||||
# --- Optional: generate the NEW transition placeholders (orbit-coast, coast-reef) ---
|
||||
# --- Generate the per-edge transition clips (zoom morph + reversed companion) ---
|
||||
|
||||
def _ffmpeg() -> str:
|
||||
if shutil.which("ffmpeg"):
|
||||
@@ -365,8 +376,10 @@ def _ffmpeg() -> str:
|
||||
|
||||
|
||||
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 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"
|
||||
@@ -381,13 +394,29 @@ def _make_transition(ff: str, scale_a: str, scale_b: str) -> Path:
|
||||
return out
|
||||
|
||||
|
||||
def _make_reverse(ff: str, forward: Path) -> Path:
|
||||
"""The zoom-OUT companion of a forward (zoom-in) edge: the same clip played
|
||||
backward, written alongside it as `<edge>.rev.mp4`. An ascending ring move
|
||||
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)
|
||||
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."""
|
||||
"""(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."""
|
||||
ff = _ffmpeg()
|
||||
for a, b in [("orbit", "coast"), ("coast", "reef")]:
|
||||
_make_transition(ff, a, b)
|
||||
print(f"generated transitions/{a}-{b}.mp4 (placeholder zoom)")
|
||||
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")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> None:
|
||||
|
||||
@@ -2,41 +2,41 @@
|
||||
"clips": [
|
||||
{
|
||||
"id": "cosmos",
|
||||
"title": "Orion Nebula flythrough (NASA/JPL-Caltech)",
|
||||
"title": "Cosmic Cliffs — Carina Nebula flythrough (NASA/ESA/CSA/STScI)",
|
||||
"base_file": "cosmos/base.mp4",
|
||||
"license": "public-domain (US Gov, 17 U.S.C. §105)",
|
||||
"source": "NASA/JPL-Caltech — images.nasa.gov JPL-20221122-SOLSYSf-0001 (Orion); trim ~30–54s, crossfade-loop",
|
||||
"license": "CC-BY-class — credit NASA, ESA, CSA, STScI",
|
||||
"source": "NASA SVS 31348 Clifs-3d-STScI (Exploring the Cosmic Cliffs in 3D, Webb NIRCam); 4K source, trim 10–34s, crossfade-loop. Text-free — replaces the Orion SVS 30957 flythrough.",
|
||||
"right_variants": {},
|
||||
"annotations": [
|
||||
{
|
||||
"key": "detected.nebula",
|
||||
"salience": 4,
|
||||
"box": [
|
||||
0.32,
|
||||
0.28,
|
||||
0.34,
|
||||
0.36
|
||||
0.2,
|
||||
0.42,
|
||||
0.5,
|
||||
0.42
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "detected.star",
|
||||
"salience": 2,
|
||||
"box": [
|
||||
0.6,
|
||||
0.3,
|
||||
0.1,
|
||||
0.12
|
||||
0.54,
|
||||
0.12,
|
||||
0.12,
|
||||
0.14
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "measure.redshift",
|
||||
"key": "measure.distance",
|
||||
"box": [
|
||||
0.36,
|
||||
0.7,
|
||||
0.18,
|
||||
0.06,
|
||||
0.06,
|
||||
0.2,
|
||||
0.08
|
||||
],
|
||||
"min_level": 4
|
||||
"min_level": 3
|
||||
}
|
||||
],
|
||||
"affect": [
|
||||
@@ -79,15 +79,130 @@
|
||||
"cloud",
|
||||
"nebula",
|
||||
"emission nebula",
|
||||
"emission nebula · ionized H II region"
|
||||
"emission nebula · the Carina star-forming region"
|
||||
],
|
||||
"detected.star": [
|
||||
"star",
|
||||
"young star",
|
||||
"protostar",
|
||||
"protostar · <1 Myr old"
|
||||
"protostar · a newborn star, <1 Myr old"
|
||||
],
|
||||
"measure.redshift": "z ≈ 0.0",
|
||||
"measure.distance": "≈7,500 ly",
|
||||
"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_miri",
|
||||
"title": "Cosmic Cliffs — Carina Nebula, mid-infrared (NASA/ESA/CSA/STScI)",
|
||||
"base_file": "cosmos_miri/base.mp4",
|
||||
"license": "CC-BY-class — credit NASA, ESA, CSA, STScI",
|
||||
"source": "ESA/Webb weic2205c (Pan of the Carina Nebula, combined NIRCam + MIRI); 4K source, trim 4–26s, crossfade-loop. The MIRI mid-infrared composite — a muted gray/teal/gold dust palette distinct from the NIRCam flythrough, same Cosmic Cliffs towers. Text-free.",
|
||||
"right_variants": {},
|
||||
"annotations": [
|
||||
{
|
||||
"key": "detected.nebula",
|
||||
"salience": 4,
|
||||
"box": [
|
||||
0.1,
|
||||
0.45,
|
||||
0.7,
|
||||
0.45
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "detected.star",
|
||||
"salience": 2,
|
||||
"box": [
|
||||
0.48,
|
||||
0.18,
|
||||
0.14,
|
||||
0.16
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "measure.distance",
|
||||
"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.nebula": [
|
||||
"dust",
|
||||
"nebula",
|
||||
"dust ridge",
|
||||
"dust ridge · mid-infrared glow of the Carina cliffs"
|
||||
],
|
||||
"detected.star": [
|
||||
"star",
|
||||
"young star",
|
||||
"protostar",
|
||||
"protostar · its dust laid bare in mid-infrared"
|
||||
],
|
||||
"measure.distance": "≈7,500 ly",
|
||||
"feel.wonder": [
|
||||
"wow",
|
||||
"wonder",
|
||||
@@ -2252,6 +2367,7 @@
|
||||
"clip_id": "cosmos",
|
||||
"pool": [
|
||||
"cosmos",
|
||||
"cosmos_miri",
|
||||
"cosmos_galaxies",
|
||||
"cosmos_hudf",
|
||||
"cosmos_xdf"
|
||||
|
||||
+72
-15
@@ -42,6 +42,11 @@ let lastOverlay = null; // {level, intensity} from the most-recent renderOver
|
||||
async function loadData() {
|
||||
const clips = (await (await fetch("/api/clips")).json()).clips || [];
|
||||
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
|
||||
// Per-file content-hash tokens → appended to /media URLs as ?v=<hash> so a
|
||||
// re-baked clip (new bytes, same path) gets a fresh URL the browser can't serve
|
||||
// stale. Best-effort: an empty map just yields un-versioned URLs.
|
||||
try { mediaVersions = (await (await fetch("/api/media-versions")).json()).versions || {}; }
|
||||
catch (_) { mediaVersions = {}; }
|
||||
const r = await fetch("/api/ring");
|
||||
serverRing = r.ok;
|
||||
ring = r.ok ? await r.json() : null;
|
||||
@@ -86,14 +91,23 @@ function activeClip() {
|
||||
// path, so swapping the altitude scale loads near-instantly (no fetch round-trip).
|
||||
// Falls back to the network path for anything not yet (or never) cached.
|
||||
const mediaBlobs = {};
|
||||
function mediaUrl(file) { return mediaBlobs[file] || ("/media/" + file); }
|
||||
let mediaVersions = {}; // file -> content-hash token (from /api/media-versions)
|
||||
// Network path for a media file, content-hash-versioned so a re-baked clip's URL
|
||||
// changes with its bytes (permanent cache-bust). The blob cache, when present,
|
||||
// wins — those bytes are already the current ones for this session.
|
||||
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
|
||||
function mediaUrl(file) { return mediaBlobs[file] || mediaNetUrl(file); }
|
||||
|
||||
// Every media file the ring can show: each scale-pool clip's base footage plus the
|
||||
// per-edge transition clips. This is the full preload set (~two dozen files).
|
||||
function preloadList() {
|
||||
const files = new Set();
|
||||
for (const c of Object.values(clipsById)) if (c && c.base_file) files.add(c.base_file);
|
||||
if (ring && ring.transitions) for (const t of ring.transitions) if (t && t.file) files.add(t.file);
|
||||
// Each ring edge ships both directions: the forward (zoom-in) clip and its baked
|
||||
// reverse (zoom-out) companion — cache both so up and down moves are instant.
|
||||
if (ring && ring.transitions) for (const t of ring.transitions) {
|
||||
if (t && t.file) { files.add(t.file); files.add(reverseFile(t.file)); }
|
||||
}
|
||||
return [...files];
|
||||
}
|
||||
|
||||
@@ -110,7 +124,7 @@ function preloadOrder() {
|
||||
else if (s) add((clipsById[s.clip_id] || {}).base_file);
|
||||
}
|
||||
}
|
||||
if (ring.transitions) for (const t of ring.transitions) add(t.file);
|
||||
if (ring.transitions) for (const t of ring.transitions) { add(t.file); add(reverseFile(t.file)); }
|
||||
for (const f of preloadList()) add(f); // sweep up anything not on the ring
|
||||
return ordered;
|
||||
}
|
||||
@@ -132,7 +146,12 @@ async function preloadAllMedia(concurrency = 4) {
|
||||
const file = files[i++];
|
||||
if (!mediaBlobs[file]) {
|
||||
try {
|
||||
const blob = await (await fetch(mediaUrl(file))).blob();
|
||||
// The content-hash `?v=` already makes a re-baked clip's URL unique, but
|
||||
// `cache: "reload"` is belt-and-suspenders: it bypasses the HTTP cache for
|
||||
// this fetch (so even an un-versioned or immutable-pinned prior entry can't
|
||||
// serve stale) and refreshes the entry. The blob cache above still gives
|
||||
// instant in-session swaps; this only affects the one fetch per (re)load.
|
||||
const blob = await (await fetch(mediaNetUrl(file), { cache: "reload" })).blob();
|
||||
mediaBlobs[file] = URL.createObjectURL(blob);
|
||||
} catch (_) { /* leave it to the network path on demand */ }
|
||||
}
|
||||
@@ -437,10 +456,11 @@ function renderOverlay(level, intensity) {
|
||||
lastOverlay = { level, intensity };
|
||||
}
|
||||
|
||||
// Affect channel: soft, glowing emotion-words surfaced only when BOTH knobs are
|
||||
// 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.
|
||||
// Affect channel: soft, glowing emotion-words — a RIGHT-brain output, surfaced by
|
||||
// the Right (dreamlike) knob alone (`strength` = right; the Left analytical knob no
|
||||
// longer gates them). `intensity` is the layer opacity. Words are placed at authored
|
||||
// scene points (no boxes — feelings are scene-level) and read softer than the
|
||||
// clinical reticles — feeling, not measurement.
|
||||
function renderAffect(strength, intensity, right) {
|
||||
const clip = activeClip();
|
||||
if (!affectLayer) return; // tolerate a stale page missing the affect layer
|
||||
@@ -453,7 +473,7 @@ function renderAffect(strength, intensity, right) {
|
||||
const [x, y] = f.at.map((n) => n * 100);
|
||||
const t = svg("text", { x, y, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
|
||||
// RIGHT emotion tier: the vocabulary escalates basic -> compound as the Right
|
||||
// knob rises (appearance still gated by min(left,right) above).
|
||||
// knob rises (appearance is gated by the Right knob via `strength` above).
|
||||
const raw = strings[f.key];
|
||||
t.textContent = pickTier(raw !== undefined ? raw : f.key, clamp(right || 0, 1, tierCount(raw)));
|
||||
}
|
||||
@@ -494,7 +514,7 @@ async function update() {
|
||||
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
|
||||
const data = await resp.json();
|
||||
readout.textContent = JSON.stringify(data, null, 2);
|
||||
if (!data.content.video) { black.classList.remove("hidden"); return; }
|
||||
if (!data.content.video) { black.style.opacity = "1"; black.classList.remove("hidden"); return; }
|
||||
black.classList.add("hidden");
|
||||
try {
|
||||
ensureClipMedia();
|
||||
@@ -515,11 +535,17 @@ function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }
|
||||
|
||||
const FAST_BLEND_RATE = 2.5; // playback speed for a collapsed fast-spin pass
|
||||
|
||||
function playTransition(file, blended) {
|
||||
// The zoom-OUT companion of an edge clip: the baked `<edge>.rev.mp4` reverse. A
|
||||
// `reversed` step (an ascending / zoom-out ring move) plays this instead of the
|
||||
// forward zoom-in clip, so going up recedes from the current scale.
|
||||
function reverseFile(file) { return file.replace(/\.mp4$/, ".rev.mp4"); }
|
||||
|
||||
function playTransition(file, blended, reversed) {
|
||||
// Play one zoom/warp transition clip start-to-finish, with the alteration
|
||||
// layers hidden. A `blended` (fast-spin) pass runs at FAST_BLEND_RATE so a
|
||||
// quick encoder spin lands fast instead of grinding through every morph.
|
||||
// Resolves on 'ended' (or a safety timeout that scales with playback rate).
|
||||
// Zoom-out moves (`reversed`) play the baked reverse clip. Resolves on 'ended'
|
||||
// (or a safety timeout that scales with playback rate).
|
||||
return new Promise((resolve) => {
|
||||
overlay.style.opacity = "0";
|
||||
affectLayer.style.opacity = "0";
|
||||
@@ -527,7 +553,7 @@ function playTransition(file, blended) {
|
||||
vid.style.filter = "none";
|
||||
vid.loop = false;
|
||||
vid.style.opacity = "1";
|
||||
vid.src = mediaUrl(file);
|
||||
vid.src = mediaUrl(reversed ? reverseFile(file) : file);
|
||||
vid.playbackRate = blended ? FAST_BLEND_RATE : 1;
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
@@ -543,6 +569,29 @@ function playTransition(file, blended) {
|
||||
});
|
||||
}
|
||||
|
||||
// Briefly fade through the #black overlay while `swap` changes the displayed clip,
|
||||
// so a content swap reads as a deliberate cut, not a jarring jump. Restores #black
|
||||
// to its hidden/opaque resting state (the video-off use) afterward.
|
||||
function fadeThroughBlack(swap) {
|
||||
return new Promise((resolve) => {
|
||||
black.style.opacity = "0";
|
||||
black.classList.remove("hidden");
|
||||
void black.offsetWidth; // reflow so the 0 -> 1 transition runs
|
||||
black.style.opacity = "1";
|
||||
setTimeout(() => {
|
||||
try { swap(); } catch (_) {}
|
||||
setTimeout(() => {
|
||||
black.style.opacity = "0";
|
||||
setTimeout(() => {
|
||||
black.classList.add("hidden");
|
||||
black.style.opacity = ""; // back to CSS default for the video-off use
|
||||
resolve();
|
||||
}, 210);
|
||||
}, 160); // let the chosen clip decode under black
|
||||
}, 210);
|
||||
});
|
||||
}
|
||||
|
||||
async function advance(delta) {
|
||||
if (busy || !ring || ring.scales.length < 2) return;
|
||||
busy = true;
|
||||
@@ -556,12 +605,20 @@ async function advance(delta) {
|
||||
// A fast spin comes back collapsed to a single blended step (move.fast);
|
||||
// a slow spin chains one full transition per scale crossed.
|
||||
for (const step of move.steps) {
|
||||
await playTransition(step.file, step.blended);
|
||||
await playTransition(step.file, step.blended, step.reversed);
|
||||
}
|
||||
ringIndex = move.to_index;
|
||||
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();
|
||||
// The baked transition lands on the scale PRIMARY; if the rotating pool picked a
|
||||
// DIFFERENT member, mask the swap behind a fade to black so it doesn't hard-cut
|
||||
// from the primary to the chosen clip (e.g. coast birdrock -> surfgrass).
|
||||
const landedPrimary = (ring.scales[move.to_index] || {}).clip_id;
|
||||
if (move.target_clip_id && move.target_clip_id !== landedPrimary) {
|
||||
await fadeThroughBlack(() => { currentClipId = null; ensureClipMedia(); });
|
||||
} else {
|
||||
currentClipId = null; // same clip the transition ended on — plain (re)load
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
update();
|
||||
|
||||
@@ -39,7 +39,7 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; }
|
||||
.hud-chip-text.measure { fill: #ffd79a; }
|
||||
.hud-conf { fill: #6cf; opacity: 0.8; }
|
||||
.hud-status { fill: #8fdcff; font: 2.4px monospace; opacity: 0.9; letter-spacing: 0.15px; }
|
||||
.black { position: absolute; inset: 0; background: #000; }
|
||||
.black { position: absolute; inset: 0; background: #000; opacity: 1; transition: opacity 200ms ease; }
|
||||
.hidden { display: none; }
|
||||
.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
fieldset { border: 1px solid #333; border-radius: 6px; }
|
||||
|
||||
@@ -55,42 +55,44 @@ def test_left_and_right_stack_not_cancel():
|
||||
assert plan.dream.intensity == 1.0
|
||||
|
||||
|
||||
def test_affect_needs_both_knobs_up():
|
||||
# affect-channel design (session 0013): emotion-words surface only when BOTH
|
||||
# the Left and Right knobs are up — either at 0 means no affect at all.
|
||||
def test_affect_is_driven_by_the_right_knob_alone():
|
||||
# Right-brain affect (session 0018): emotion-words are controlled by the Right
|
||||
# knob alone — Right at 0 means no affect regardless of Left, and Left no longer
|
||||
# gates the channel (full Right with Left at 0 still surfaces affect).
|
||||
assert plan_alteration(_coord(left=4, right=0)).affect.strength == 0
|
||||
assert plan_alteration(_coord(left=0, right=4)).affect.strength == 0
|
||||
assert plan_alteration(_coord(left=4, right=0)).affect.intensity == 0.0
|
||||
assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 0.0
|
||||
assert plan_alteration(_coord(left=0, right=4)).affect.strength == 4
|
||||
assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 1.0
|
||||
|
||||
|
||||
def test_affect_strength_is_the_smaller_knob():
|
||||
# strength = min(left, right) — the smaller knob gates the channel.
|
||||
assert plan_alteration(_coord(left=2, right=4)).affect.strength == 2
|
||||
def test_affect_strength_is_the_right_knob():
|
||||
# strength = right — Left is irrelevant to the affect channel.
|
||||
assert plan_alteration(_coord(left=2, right=4)).affect.strength == 4
|
||||
assert plan_alteration(_coord(left=4, right=2)).affect.strength == 2
|
||||
assert plan_alteration(_coord(left=4, right=4)).affect.strength == 4
|
||||
assert plan_alteration(_coord(left=3, right=1)).affect.strength == 1
|
||||
assert plan_alteration(_coord(left=0, right=3)).affect.strength == 3
|
||||
assert plan_alteration(_coord(left=4, right=1)).affect.strength == 1
|
||||
|
||||
|
||||
def test_affect_intensity_scales_with_strength_over_full_scale():
|
||||
for lo in range(5):
|
||||
plan = plan_alteration(_coord(left=lo, right=4))
|
||||
assert plan.affect.intensity == pytest.approx(lo / 4)
|
||||
assert plan_alteration(_coord(left=4, right=4)).affect.intensity == 1.0
|
||||
def test_affect_intensity_scales_with_right_over_full_scale():
|
||||
for r in range(5):
|
||||
plan = plan_alteration(_coord(left=0, right=r))
|
||||
assert plan.affect.intensity == pytest.approx(r / 4)
|
||||
assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 1.0
|
||||
|
||||
|
||||
def test_affect_intensity_honors_overlay_gain():
|
||||
# affect rides the same overlay_gain as the analytical HUD it belongs to.
|
||||
# affect text rides the overlay-opacity gain, but is keyed off the Right knob.
|
||||
cal = Calibration(overlay_gain=0.5)
|
||||
assert plan_alteration(_coord(left=4, right=4), cal).affect.intensity == pytest.approx(0.5)
|
||||
assert plan_alteration(_coord(left=0, right=4), cal).affect.intensity == pytest.approx(0.5)
|
||||
clamp = Calibration(overlay_gain=10.0)
|
||||
assert plan_alteration(_coord(left=4, right=4), clamp).affect.intensity == 1.0
|
||||
assert plan_alteration(_coord(left=0, right=4), clamp).affect.intensity == 1.0
|
||||
|
||||
|
||||
def test_affect_active_implies_non_identity():
|
||||
# min(left,right) > 0 implies left > 0, so an active affect plan is never identity.
|
||||
plan = plan_alteration(_coord(left=2, right=3))
|
||||
assert plan.affect.strength == 2
|
||||
# affect.strength == right, and right > 0 makes dream.strength > 0, so an active
|
||||
# affect plan (even with Left at 0) is never identity.
|
||||
plan = plan_alteration(_coord(left=0, right=3))
|
||||
assert plan.affect.strength == 3
|
||||
assert not plan.is_identity
|
||||
|
||||
|
||||
|
||||
@@ -76,6 +76,30 @@ def test_clips_returns_the_manifest(client):
|
||||
assert data["clips"][0]["annotations"][0]["key"] == "detected.water"
|
||||
|
||||
|
||||
def test_media_versions_endpoint_shape(client):
|
||||
resp = client.get("/api/media-versions")
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json()["versions"], dict)
|
||||
|
||||
|
||||
def test_media_version_is_a_content_hash(tmp_path, monkeypatch):
|
||||
import hashlib
|
||||
|
||||
import simulator.app as appmod
|
||||
|
||||
# Point the media root at a temp dir (the served dir is otherwise fixed).
|
||||
monkeypatch.setattr(appmod, "MEDIA_DIR", tmp_path)
|
||||
(tmp_path / "clip").mkdir()
|
||||
f = tmp_path / "clip" / "base.mp4"
|
||||
f.write_bytes(b"hello-bytes")
|
||||
assert appmod._media_version("clip/base.mp4") == hashlib.sha1(b"hello-bytes").hexdigest()[:12]
|
||||
# Absent file -> None (it's simply omitted from the versions map).
|
||||
assert appmod._media_version("clip/missing.mp4") is None
|
||||
# Re-baked under the same path (new bytes) -> a new token, not the cached one.
|
||||
f.write_bytes(b"different-bytes-entirely")
|
||||
assert appmod._media_version("clip/base.mp4") == hashlib.sha1(b"different-bytes-entirely").hexdigest()[:12]
|
||||
|
||||
|
||||
def test_retired_selection_endpoints_are_gone(client):
|
||||
# The route no longer exists; the static catch-all yields 404 on GET and
|
||||
# 405 on the (now-unrouted) POST. Either proves the endpoint is gone.
|
||||
@@ -113,8 +137,11 @@ def test_media_is_cacheable(tmp_path, monkeypatch):
|
||||
resp = client.get("/media/cosmos/base.mp4")
|
||||
assert resp.status_code == 200
|
||||
cc = resp.headers.get("cache-control", "")
|
||||
# Stored but revalidated: not no-store (so the blob preload can cache it), and
|
||||
# not pinned immutable (so a re-sourced/re-cropped clip shows on a plain reload).
|
||||
assert "no-store" not in cc
|
||||
assert "max-age" in cc
|
||||
assert "immutable" not in cc
|
||||
assert "no-cache" in cc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
Reference in New Issue
Block a user