Compare commits

..

7 Commits

Author SHA1 Message Date
BenStullsBets bcae35b63e test(static): E2E — static boot with no API + dream shader survives cross-origin CORS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:48:22 -07:00
BenStullsBets a93c0d57c8 chore(static): Cloudflare deploy artifacts — R2 CORS policy + deploy runbook
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:45:20 -07:00
BenStullsBets 77cb04bb8c feat(static): build script — dist/<app>/ + manifest-only media tree, baked API JSON, _redirects
Run via 'python -m tools.build_static' (avoids tools/http.py shadowing stdlib http).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:43:49 -07:00
BenStullsBets 271586625c feat(static): config-driven media base, baked-JSON boot, client pick/alteration, crossOrigin
Relative asset paths (work at dev-root and under the /human-experience-simulator
deploy prefix), config.js gate, devLiveReload no-ops in static mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:43:49 -07:00
BenStullsBets be13248bdd feat(static): client-side alteration engine (port of player.alteration/audio)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:25:24 -07:00
BenStullsBets 7ef3e0ccc6 plan: Cloudflare static publish — implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:23:57 -07:00
BenStullsBets 7a51d8f6d4 design: publish to benstull.art via Cloudflare Pages + R2 (fully static)
Solution design for a no-origin public target at
benstull.art/human-experience-simulator (apex redirects): static frontend on
Pages, ~2GB served media on R2 behind static.benstull.art, dynamic logic baked
to JSON / moved client-side. Records the deliberate flotilla/PPE process
exception for a static art target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:18:00 -07:00
35 changed files with 4396 additions and 7030 deletions
+4
View File
@@ -26,3 +26,7 @@ simulator/static/review_audio.html
# Editor annotation/comment-thread metadata (not project content)
.threads/
# Static build outputs (tools/build_static.py)
/dist/
/dist-media/
+89
View File
@@ -0,0 +1,89 @@
# Deploying to benstull.art — Cloudflare Pages + R2
Publishes the simulator as a fully-static site: the frontend on **Cloudflare
Pages** (`benstull.art`, app at `/human-experience-simulator`, apex redirects to
it) and the video/audio on **R2** behind `static.benstull.art`. No origin server.
Design: `docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md`.
Plan: `docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md`.
This project is **exempt from the flotilla/PPE/§9 pipeline** (operator-set) — it
deploys directly to Cloudflare. Safety/hygiene rules still hold.
## Prerequisites (operator gestures — need Cloudflare auth)
`wrangler` is not installed in the dev box; the deploy runs from a machine with
Cloudflare credentials.
```bash
npm i -g wrangler
wrangler login
```
`benstull.art` is already a Cloudflare zone (no nameserver migration).
## 1. Build the artifacts
From the repo root (run as a MODULE — `tools/http.py` shadows stdlib `http` if run
as a script):
```bash
.venv/bin/python -m tools.build_static
```
Produces:
- `dist/` — Pages output: `dist/human-experience-simulator/` (frontend + baked
`clips.json`/`ring.json`/`media-versions.json` + generated `config.js`) and a
root `dist/_redirects` (apex → app path, and no-slash → slashed).
- `dist-media/` — R2 upload tree, ~2.3 GB / 604 files, manifest-referenced only
(clip bases + 154 transition morphs + 5 per-scale audio). No master/mezzanine
sources.
## 2. R2 — bucket, CORS, custom domain, media
```bash
wrangler r2 bucket create hef-media
wrangler r2 bucket cors put hef-media --rules deploy/cloudflare/r2-cors.json
wrangler r2 bucket sync ./dist-media r2://hef-media
```
Then in the dashboard (R2 → `hef-media` → Settings):
- **Custom domain:** add `static.benstull.art` (this is the `mediaBase` baked into
`config.js`).
- **CORS is load-bearing** — the WebGL dream shader taints without it. The policy
in `r2-cors.json` allows `https://benstull.art` + `Range` (video scrubbing).
- **Caching:** the media URLs are content-hash-versioned (`?v=<hash>`), so set the
objects `Cache-Control: public, max-age=31536000, immutable` (a re-baked clip
gets a new hash → new URL → cache busts cleanly).
If you change the media base, rebuild with `--media-base https://<host>/` so
`config.js` matches.
## 3. Pages — deploy + domain + redirect
```bash
wrangler pages project create human-experience-simulator
wrangler pages deploy dist --project-name human-experience-simulator
```
Then in the dashboard (Pages → project → Custom domains):
- Bind **`benstull.art`** to the project.
- The app serves at `benstull.art/human-experience-simulator/`; the generated
`dist/_redirects` sends `/` and the no-slash form to it. (Alternatively, a
zone-level **Redirect Rule** `benstull.art/``/human-experience-simulator`
is the authoritative equivalent if you prefer not to ship `_redirects`.)
## 4. Verify before/after going live
```bash
# CORS + Range on the media origin (after step 2)
curl -sI -H "Origin: https://benstull.art" -H "Range: bytes=0-1" \
https://static.benstull.art/<some>/base.mp4 | grep -iE "access-control-allow-origin|content-range|accept-ranges"
```
Open `https://benstull.art/` → should redirect to
`https://benstull.art/human-experience-simulator/`. Turn the **Feel** knob to max
and confirm the dream effect renders (no `SecurityError` in the console — that
would mean CORS is misconfigured and the GL texture is tainted). The local
static-build E2E (`simulator/e2e/tests/static-build.spec.ts`) exercises this same
cross-origin path before you ship.
+9
View File
@@ -0,0 +1,9 @@
[
{
"AllowedOrigins": ["https://benstull.art"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "Content-Type"],
"ExposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
"MaxAgeSeconds": 86400
}
]
@@ -0,0 +1,706 @@
# Cloudflare Static Publish — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Publish the simulator as a fully-static site (no origin server) to `benstull.art/human-experience-simulator` on Cloudflare Pages, with media on R2.
**Architecture:** Port the only live server logic (`/api/alteration`, the delta=0 random pick) to client JS; serve the read-APIs as baked JSON; point media at a configurable R2 base with CORS; keep the existing graduated prefetch. A Python build script emits `dist/` (Pages) + a media sync tree (R2). The dev FastAPI server stays for localhost/authoring.
**Tech Stack:** Vanilla JS (UMD modules, `node --test`), Python 3 build script (imports existing `simulator`/`player` builders), Playwright E2E, `wrangler` (Pages + R2), Cloudflare redirect rule.
## Global Constraints
- **Path:** app served at `/human-experience-simulator`; apex `benstull.art` **redirects** there.
- **Media base:** `https://static.benstull.art/` (R2 custom domain), public bucket.
- **CORS is load-bearing:** R2 must send `Access-Control-Allow-Origin: https://benstull.art`; `<video>`/`<audio>` carry `crossOrigin="anonymous"` — else the WebGL dream shader taints.
- **Served media = manifest-referenced only:** `base.mp4` clip bases + 154 transition morphs + 10 `.mp3`. NEVER sync `master.mp4`/`mezzanine.mp4`.
- **Versioning preserved:** keep the `?v=<hash>` query; set those objects `Cache-Control: public, max-age=31536000, immutable` on the CDN.
- **No new runtime:** no Pages Functions / Worker; no Service Worker.
- **Project is exempt from flotilla/PPE/§9** (operator-set) — deploy directly to Cloudflare. Safety/hygiene rules (git-ssh, branch→PR, secrets) still hold.
- **Module idiom:** UMD — `(function(root,factory){ if (module?.exports) module.exports=factory(); else root.HEFx=factory(); })(...)` — matching `simulator/static/scrub.js`.
- **JS tests run:** `node --test simulator/unit`. **Python tests run:** `.venv/bin/python -m pytest`.
---
### Task 1: Client-side alteration engine (`HEFAlteration`)
Port `player/alteration.py::plan_alteration` (+ `render_plan_to_dict`) and `player/audio.py::resolve_visual`/`resolve_audio` to a pure UMD JS module, producing byte-for-byte the same shape `/api/alteration` returns. Calibration is always unity (`DEFAULT_CALIBRATION`; the client never sends one — `app.js:619`).
**Files:**
- Create: `simulator/static/alteration.js`
- Test: `simulator/unit/alteration.test.js`
**Interfaces:**
- Produces (browser global `HEFAlteration`, also `module.exports`):
- `plan(controls)``{ grade:{tone}, overlay:{level,intensity}, affect:{strength,intensity}, dream:{strength,intensity}, is_identity }`
- `renderAudio(source, scaleAudio, mediaBase)``{ source, url, altitude_coupled }`
- `alteration(controls, scaleAudio, mediaBase)``{ plan, render:{ video:{shown}, audio:{source,url,altitude_coupled} } }` (the `/api/alteration` response)
- `controls` shape (from `app.js:595` `controls()`): `{ visual:"on"|"off", audio:"soundtrack"|"off", left:int, right:int, dark:int, light:int }`
- [ ] **Step 1: Write the failing test**
```js
// simulator/unit/alteration.test.js
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const A = require("../static/alteration.js");
const c = (o) => ({ visual: "on", audio: "off", left: 0, right: 0, dark: 0, light: 0, ...o });
test("identity at zero knobs", () => {
const p = A.plan(c({}));
assert.equal(p.grade.tone, 0);
assert.equal(p.overlay.level, 0);
assert.equal(p.dream.strength, 0);
assert.equal(p.is_identity, true);
});
test("mood tone is signed (light - dark)/KNOB_MAX, clamped", () => {
assert.equal(A.plan(c({ light: 4 })).grade.tone, 1); // full light
assert.equal(A.plan(c({ dark: 4 })).grade.tone, -1); // full dark
assert.equal(A.plan(c({ light: 2 })).grade.tone, 0.5);
});
test("left drives overlay level + intensity; right drives dream + affect", () => {
const p = A.plan(c({ left: 2, right: 4 }));
assert.equal(p.overlay.level, 2);
assert.equal(p.overlay.intensity, 0.5);
assert.equal(p.dream.strength, 4);
assert.equal(p.dream.intensity, 1);
assert.equal(p.affect.strength, 4);
assert.equal(p.affect.intensity, 1);
assert.equal(p.is_identity, false);
});
test("renderAudio: off → null, no coupling", () => {
assert.deepEqual(A.renderAudio("off", "cosmos.mp3", "https://x/"),
{ source: "off", url: null, altitude_coupled: false });
});
test("renderAudio: soundtrack → mediaBase audio url, coupled", () => {
assert.deepEqual(A.renderAudio("soundtrack", "cosmos.mp3", "https://x/"),
{ source: "soundtrack", url: "https://x/audio/cosmos.mp3", altitude_coupled: true });
// no scale audio → null url but still coupled
assert.deepEqual(A.renderAudio("soundtrack", "", "https://x/"),
{ source: "soundtrack", url: null, altitude_coupled: true });
});
test("alteration() composes plan + render with video.shown from visual", () => {
const r = A.alteration(c({ visual: "off", audio: "soundtrack", left: 1 }), "reef.mp3", "https://x/");
assert.equal(r.render.video.shown, false);
assert.equal(r.render.audio.url, "https://x/audio/reef.mp3");
assert.equal(r.plan.overlay.level, 1);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test simulator/unit/alteration.test.js`
Expected: FAIL — `Cannot find module '../static/alteration.js'`.
- [ ] **Step 3: Write the implementation**
```js
// simulator/static/alteration.js
// Client-side port of player/alteration.py + player/audio.py (the alteration
// engine). Replaces the live POST /api/alteration in the static build — pure math,
// unity calibration. UMD so the browser gets `HEFAlteration` and node --test can
// require() it. Keep IN SYNC with the Python (guarded by parity tests there).
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFAlteration = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
const KNOB_MAX = 4; // player/alteration.py:25
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
function plan(c) {
const tone = clamp((c.light - c.dark) / KNOB_MAX, -1, 1);
const overlayIntensity = clamp(c.left / KNOB_MAX, 0, 1);
const dreamIntensity = clamp(c.right / KNOB_MAX, 0, 1);
const affectIntensity = clamp(c.right / KNOB_MAX, 0, 1); // affect uses overlay_gain too
const is_identity = tone === 0 && c.left === 0 && c.right === 0;
return {
grade: { tone },
overlay: { level: c.left, intensity: overlayIntensity },
affect: { strength: c.right, intensity: affectIntensity },
dream: { strength: c.right, intensity: dreamIntensity },
is_identity,
};
}
function renderAudio(source, scaleAudio, mediaBase) {
if (source === "off") return { source: "off", url: null, altitude_coupled: false };
// soundtrack — couple to the current altitude's asset
const url = scaleAudio ? mediaBase + "audio/" + scaleAudio : null;
return { source: "soundtrack", url, altitude_coupled: true };
}
function alteration(controls, scaleAudio, mediaBase) {
return {
plan: plan(controls),
render: {
video: { shown: controls.visual === "on" },
audio: renderAudio(controls.audio, scaleAudio, mediaBase),
},
};
}
return { plan, renderAudio, alteration, KNOB_MAX };
});
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `node --test simulator/unit/alteration.test.js`
Expected: PASS (6 tests).
- [ ] **Step 5: Add a Python parity test (guards JS↔Python drift)**
Create `tests/test_alteration_js_parity.py`:
```python
"""The static build reimplements plan_alteration/resolve_audio in JS
(simulator/static/alteration.js). This pins the numeric contract so the Python
and JS cannot silently diverge — if you change the engine, change both."""
from hef.selection import Coordinate
from player.alteration import plan_alteration, render_plan_to_dict
from player.audio import resolve_audio, resolve_visual
def test_engine_contract_matches_js_expectations():
# mirrors the cases asserted in simulator/unit/alteration.test.js
p = render_plan_to_dict(plan_alteration(Coordinate(left=2, right=4, dark=0, light=0)))
assert p["overlay"] == {"level": 2, "intensity": 0.5}
assert p["dream"] == {"strength": 4, "intensity": 1.0}
assert p["affect"] == {"strength": 4, "intensity": 1.0}
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 4)))["grade"]["tone"] == 1.0
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 4, 0)))["grade"]["tone"] == -1.0
assert resolve_visual("off") is False
a = resolve_audio("soundtrack", scale_audio="cosmos.mp3")
assert a.url == "/media/audio/cosmos.mp3" and a.altitude_coupled is True
```
- [ ] **Step 6: Run + commit**
Run: `node --test simulator/unit/alteration.test.js && .venv/bin/python -m pytest tests/test_alteration_js_parity.py -q`
Expected: all PASS.
```bash
git add simulator/static/alteration.js simulator/unit/alteration.test.js tests/test_alteration_js_parity.py
git commit -m "feat(static): client-side alteration engine (port of player.alteration/audio)"
```
---
### Task 2: Static config + boot rewiring in `app.js`
Introduce a runtime config the build injects, then make `app.js` (a) read media from a configurable base, (b) boot from baked JSON in static mode, (c) pick pool members client-side, (d) compute alteration locally, (e) tag media elements with `crossOrigin`, (f) skip the dev-version poll in static mode. All changes degrade to today's behavior when config is absent (dev server unaffected).
**Files:**
- Create: `simulator/static/config.js` (dev default; the build overwrites it in `dist/`)
- Modify: `simulator/static/app.js` (boot `loadData` ~59, `pickRandomMember` ~87, `mediaNetUrl` ~123, alteration fetch ~617, dev poll ~1041, video/audio element creation)
- Modify: `simulator/static/index.html:111` (load `config.js` before `app.js`)
**Interfaces:**
- Consumes: `HEFAlteration` (Task 1).
- Produces: global `HEF_CONFIG = { mediaBase: string, static: boolean }`. Dev default `{ mediaBase: "/media/", static: false }`.
- [ ] **Step 1: Create the dev-default config and load it**
`simulator/static/config.js`:
```js
// Runtime config. This dev default serves media from the local FastAPI /media
// mount and uses the live API. The static build (tools/build_static.py) OVERWRITES
// this file in dist/ with { mediaBase: "https://static.benstull.art/", static: true }.
window.HEF_CONFIG = { mediaBase: "/media/", static: false };
```
In `simulator/static/index.html`, add before `app.js` (line 111 area):
```html
<script src="/config.js"></script>
```
(Place it before `<script src="/app.js"></script>`.)
- [ ] **Step 2: Media base — modify `mediaNetUrl` (`app.js:123`)**
Replace:
```js
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
```
with:
```js
function mediaNetUrl(file) {
const base = (window.HEF_CONFIG && window.HEF_CONFIG.mediaBase) || "/media/";
const v = mediaVersions[file];
return base + file + (v ? "?v=" + v : "");
}
```
Note: audio-layer absolute urls already begin with the configured base — keep the existing `file.startsWith("/media/")` guard in `mediaUrl`, and ALSO short-circuit absolute `http` urls:
```js
function mediaUrl(file) {
if (/^https?:\/\//.test(file) || file.startsWith("/media/")) return file;
return mediaBlobs[file] || mediaNetUrl(file);
}
```
And in `HEFAlteration.alteration(...)` we pass `mediaBase`, so the audio `url` it returns is already absolute (R2) in static mode and `/media/...` in dev.
- [ ] **Step 3: Boot from baked JSON — modify `loadData` (`app.js:59`)**
Replace the three `fetch("/api/...")` calls with a base that depends on mode:
```js
async function loadData() {
const api = (window.HEF_CONFIG && window.HEF_CONFIG.static)
? { clips: "./clips.json", versions: "./media-versions.json", ring: "./ring.json" }
: { clips: "/api/clips", versions: "/api/media-versions", ring: "/api/ring" };
const clips = (await (await fetch(api.clips)).json()).clips || [];
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
try { mediaVersions = (await (await fetch(api.versions)).json()).versions || {}; }
catch (_) { mediaVersions = {}; }
const r = await fetch(api.ring);
serverRing = r.ok;
ring = r.ok ? await r.json() : null;
// ...rest unchanged (fallback ring synth, morphByPair build, ringIndex = 0)
}
```
(`clips.json` mirrors `{clips:[...]}`, `ring.json` mirrors `ring_to_dict(...)`, `media-versions.json` mirrors `{versions:{...}}` — Task 3 bakes them.)
- [ ] **Step 4: Client-side pick — modify `pickRandomMember` (`app.js:87`)**
The delta=0 server pick becomes a uniform client-side pool pick (matches `hef`'s `pick_clip_id`):
```js
async function pickRandomMember() {
const scale = ring && ring.scales[ringIndex];
if (!scale) return null;
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
}
```
(Drag/scroll navigation already resolves picks + morphs client-side via `scrub.js`; this removes the last `/api/ring/advance` dependency. `serverRing` stays as the "real ring vs synthesized" flag.)
- [ ] **Step 5: Local alteration — modify the fetch at `app.js:617`**
Replace:
```js
const resp = await fetch("/api/alteration", { /* ... */ body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }) });
const data = await resp.json();
```
with a mode-aware path that reuses the engine:
```js
let data;
if (window.HEF_CONFIG && window.HEF_CONFIG.static) {
const scale = ring && ring.scales[ringIndex];
const scaleAudio = (scale && scale.audio) || "";
const base = window.HEF_CONFIG.mediaBase;
data = HEFAlteration.alteration(controls(), scaleAudio, base);
} else {
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
});
data = await resp.json();
}
```
**Prerequisite:** `ring.json` must expose each scale's `audio` field. Verify `ring_to_dict` includes it; if not, that is a Task 3 bake concern (add it). The rest of the function (`data.render`, `data.plan.*`) is unchanged.
- [ ] **Step 6: crossOrigin on media elements (CORS for WebGL)**
Find where `<video>` and `<audio>` elements are created/used in `app.js` (the double-buffered `#vid`/loop videos and the audio layer). Set `crossOrigin` BEFORE assigning `src`:
```js
videoEl.crossOrigin = "anonymous";
```
Apply to: the visible video, the preload/double-buffer video, and the `<audio>` element. (In dev, same-origin `/media/` ignores it; in static it prevents GL-texture taint and lets ranged `fetch()` succeed under CORS.) Also set `crossorigin="anonymous"` on any static `<video>`/`<audio>` in `index.html`.
- [ ] **Step 7: Skip the dev-version poll in static mode (`app.js:1041`)**
Wrap the `/dev/version` poller so it no-ops when `HEF_CONFIG.static` (there is no dev server):
```js
if (!(window.HEF_CONFIG && window.HEF_CONFIG.static)) {
// existing setInterval(... fetch("/dev/version") ...) poll
}
```
- [ ] **Step 8: Manual dev smoke (no regression)**
Run (this env has only `python3`):
```bash
.venv/bin/python -m uvicorn simulator.app:app --port 8000
```
Open `http://localhost:8000/`, confirm boot, altitude changes, dream/overlay still work (dev mode: `HEF_CONFIG.static=false`, behavior identical to before).
- [ ] **Step 9: Commit**
```bash
git add simulator/static/config.js simulator/static/app.js simulator/static/index.html
git commit -m "feat(static): config-driven media base, baked-JSON boot, client pick/alteration, crossOrigin"
```
---
### Task 3: Static build script → `dist/` + media sync tree
A Python script that produces the deployable `dist/` (frontend + baked JSON + static config) and a separate media sync tree containing ONLY manifest-referenced files. Reuses the live app's loaders so the baked JSON is identical to the API output.
**Files:**
- Create: `tools/build_static.py`
- Create: `tests/test_build_static.py`
**Interfaces:**
- Consumes: `simulator.app.create_app` (to render the same JSON the API serves) OR the underlying loaders (`simulator.clips`/`build_pool_manifest` + `player`/`hef`). Prefer driving `create_app(manifest)` via `fastapi.testclient.TestClient` so the bake is byte-identical to the API.
- Produces: `build_static(out_dir, media_out, *, media_base, app_path) -> dict` (counts), and a CLI.
- [ ] **Step 1: Write the failing test**
```python
# tests/test_build_static.py
import json
from pathlib import Path
from tools.build_static import build_static
def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
out = tmp_path / "dist"
media = tmp_path / "media"
result = build_static(out, media, media_base="https://static.benstull.art/",
app_path="/human-experience-simulator")
# frontend present, dev/author surfaces excluded
assert (out / "index.html").exists()
assert (out / "app.js").exists()
assert (out / "scrub.js").exists()
assert (out / "alteration.js").exists()
assert (out / "config.js").exists()
assert not (out / "author.html").exists()
assert not list(out.glob("review*.html"))
# baked JSON matches the API shape
clips = json.loads((out / "clips.json").read_text())
assert "clips" in clips and clips["clips"]
ring = json.loads((out / "ring.json").read_text())
assert "scales" in ring and "transitions" in ring
assert all("audio" in s for s in ring["scales"]) # needed by client alteration
versions = json.loads((out / "media-versions.json").read_text())
assert "versions" in versions
# config.js points at R2 + static mode
cfg = (out / "config.js").read_text()
assert "https://static.benstull.art/" in cfg and "static: true" in cfg
# media tree holds ONLY referenced files — no masters/mezzanines
synced = {p.name for p in media.rglob("*.mp4")}
assert not any(n in ("master.mp4", "mezzanine.mp4") for n in synced)
# every versions key exists in the media tree
for f in versions["versions"]:
assert (media / f).exists(), f"missing synced media: {f}"
assert result["media_files"] == len(list(media.rglob("*")))
```
- [ ] **Step 2: Run to verify it fails**
Run: `.venv/bin/python -m pytest tests/test_build_static.py -q`
Expected: FAIL — `ModuleNotFoundError: tools.build_static`.
- [ ] **Step 3: Implement the build script**
```python
# tools/build_static.py
"""Build the fully-static deployable for Cloudflare (Pages + R2).
Emits `out_dir/` (frontend + baked API JSON + static config.js) and a `media_out/`
sync tree containing ONLY the files the manifest references (clip bases + transition
morphs + audio) — never the master/mezzanine pipeline sources. The baked JSON is
produced through the real app (TestClient) so it is byte-identical to the API.
Cloudflare-side steps (R2 sync, Pages deploy, CORS, redirect) live in
deploy/cloudflare/ — this script only produces artifacts.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from fastapi.testclient import TestClient
from simulator.app import create_app, MEDIA_DIR
STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css"]
def _bake_api(out: Path) -> dict:
app = create_app()
client = TestClient(app)
clips = client.get("/api/clips").json()
ring = client.get("/api/ring").json()
versions = client.get("/api/media-versions").json()
(out / "clips.json").write_text(json.dumps(clips, ensure_ascii=False))
(out / "ring.json").write_text(json.dumps(ring, ensure_ascii=False))
(out / "media-versions.json").write_text(json.dumps(versions, ensure_ascii=False))
return versions["versions"]
def _write_config(out: Path, media_base: str) -> None:
(out / "config.js").write_text(
"// GENERATED by tools/build_static.py — do not edit in dist/.\n"
f'window.HEF_CONFIG = {{ mediaBase: "{media_base}", static: true }};\n'
)
def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
out = Path(out_dir)
media = Path(media_out)
for d in (out, media):
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True)
for name in PUBLIC_ASSETS:
src = STATIC / name
if src.exists():
shutil.copy2(src, out / name)
versions = _bake_api(out)
_write_config(out, media_base)
# Sync ONLY referenced media (versions covers bases + morphs); add audio explicitly.
referenced = set(versions.keys())
ring = json.loads((out / "ring.json").read_text())
for s in ring.get("scales", []):
if s.get("audio"):
referenced.add(f"audio/{s['audio']}")
n = 0
for rel in sorted(referenced):
src = MEDIA_DIR / rel
if not src.exists():
raise FileNotFoundError(f"referenced media missing on disk: {rel}")
dst = media / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
n += 1
return {"out": str(out), "media": str(media), "media_files": n, "app_path": app_path}
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="dist")
ap.add_argument("--media-out", default="dist-media")
ap.add_argument("--media-base", default="https://static.benstull.art/")
ap.add_argument("--app-path", default="/human-experience-simulator")
a = ap.parse_args()
r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path)
print(json.dumps(r, indent=2))
```
**Verify before running the test:** confirm `simulator/app.py` exports `MEDIA_DIR` and that `ring_to_dict` includes `audio` per scale. If `audio` is absent from `ring.json`, add it to `ring_to_dict` (small change in `simulator/app.py` / the ring serializer) and add a Python test asserting `"audio" in scale dict`.
- [ ] **Step 4: Run the test to verify it passes**
Run: `.venv/bin/python -m pytest tests/test_build_static.py -q`
Expected: PASS. (Test asserts media tree excludes masters and includes every versioned file + audio.)
- [ ] **Step 5: Real build smoke**
Run: `.venv/bin/python tools/build_static.py`
Expected: prints counts; `dist/` has the frontend + 3 JSON + `config.js`; `dist-media/` ≈ ~2 GB, no `master.mp4`/`mezzanine.mp4`.
- [ ] **Step 6: Commit** (do NOT commit `dist/`/`dist-media/` — add to `.gitignore`)
```bash
printf '\n/dist/\n/dist-media/\n' >> .gitignore
git add tools/build_static.py tests/test_build_static.py .gitignore simulator/app.py
git commit -m "feat(static): build script — dist/ + manifest-only media sync tree, baked API JSON"
```
---
### Task 4: Cloudflare config artifacts + deploy runbook
Produce the version-controlled Cloudflare config (CORS policy, redirect, deploy commands). The actual deploy needs operator Cloudflare auth (`wrangler login` / API token) — `wrangler` is NOT installed here, so this task delivers artifacts + exact commands and the operator runs the authenticated steps.
**Files:**
- Create: `deploy/cloudflare/README.md` (runbook)
- Create: `deploy/cloudflare/r2-cors.json` (bucket CORS policy)
- Create: `deploy/cloudflare/_redirects` (copied into `dist/` for the apex/path redirect, or documented as a dashboard Redirect Rule)
**Interfaces:** none (ops artifacts).
- [ ] **Step 1: CORS policy**
`deploy/cloudflare/r2-cors.json`:
```json
[
{
"AllowedOrigins": ["https://benstull.art"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "Content-Type"],
"ExposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
"MaxAgeSeconds": 86400
}
]
```
- [ ] **Step 2: Redirect artifact**
`deploy/cloudflare/_redirects` (Pages `_redirects` form — apex/root to the app path):
```
/ /human-experience-simulator 308
```
(If the app is deployed as a Pages project bound to `benstull.art`, also document a dashboard **Redirect Rule**: `benstull.art/``/human-experience-simulator` as the authoritative alternative.)
- [ ] **Step 3: Runbook**
`deploy/cloudflare/README.md` — exact operator commands (run after `tools/build_static.py`):
```bash
npm i -g wrangler
wrangler login
wrangler r2 bucket create hef-media
wrangler r2 bucket cors put hef-media --rules deploy/cloudflare/r2-cors.json
# bind custom domain static.benstull.art to the bucket (dashboard: R2 > bucket > Settings > Custom Domains)
# upload media (preserves keys):
wrangler r2 bucket sync ./dist-media r2://hef-media
cp deploy/cloudflare/_redirects dist/_redirects
wrangler pages project create human-experience-simulator
wrangler pages deploy dist --project-name human-experience-simulator
# bind benstull.art to the Pages project; serve at /human-experience-simulator
# add the apex Redirect Rule if not using _redirects
```
Document: set the media objects' `Cache-Control: public, max-age=31536000, immutable` (sync flag or bucket lifecycle), and verify `wrangler r2 bucket sync` excludes nothing unexpected (the tree is already filtered by the build).
- [ ] **Step 4: Commit**
```bash
git add deploy/cloudflare/
git commit -m "chore(static): Cloudflare deploy artifacts — R2 CORS, apex redirect, runbook"
```
---
### Task 5: E2E against the static build
Verify the built `dist/` boots with **no `/api/*` server**, the dream shader survives cross-origin media (CORS, no taint), and scrub/morph/audio work. Serve `dist/` and the media from **two different origins** to exercise CORS the way R2 will.
**Files:**
- Create: `simulator/e2e/tests/static-build.spec.ts`
- Create: `simulator/e2e/serve-static.mjs` (tiny dual static server: app on one port, media w/ CORS on another)
**Interfaces:** consumes the `dist/` + `dist-media/` produced by Task 3.
- [ ] **Step 1: Dual static server with CORS**
`simulator/e2e/serve-static.mjs`:
```js
// Serves dist/ (app) and dist-media/ (media w/ CORS + Range) on two ports — a
// local stand-in for Pages + R2, so the static-build E2E exercises cross-origin
// media exactly as production will.
import http from "node:http";
import { createReadStream, statSync } from "node:fs";
import { join, extname } from "node:path";
const TYPES = { ".html":"text/html", ".js":"text/javascript", ".json":"application/json",
".css":"text/css", ".mp4":"video/mp4", ".mp3":"audio/mpeg" };
function serve(rootEnv, port, cors) {
const root = process.env[rootEnv];
http.createServer((req, res) => {
if (cors) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Accept-Ranges", "bytes"); }
let p = decodeURIComponent(req.url.split("?")[0]);
if (p === "/") p = "/index.html";
const file = join(root, p);
try {
const st = statSync(file);
res.setHeader("Content-Type", TYPES[extname(file)] || "application/octet-stream");
const range = req.headers.range; // honor Range for video scrub
if (range && /^bytes=/.test(range)) {
const [s, e] = range.replace("bytes=", "").split("-");
const start = +s, end = e ? +e : st.size - 1;
res.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${st.size}`,
"Content-Length": end - start + 1 });
createReadStream(file, { start, end }).pipe(res);
} else { res.writeHead(200, { "Content-Length": st.size }); createReadStream(file).pipe(res); }
} catch { res.writeHead(404); res.end("nf"); }
}).listen(port);
}
serve("DIST_DIR", 8077, false);
serve("MEDIA_DIR_E2E", 8078, true);
console.log("static app :8077 media(cors) :8078");
```
- [ ] **Step 2: The spec**
`simulator/e2e/tests/static-build.spec.ts`:
```ts
import { test, expect } from "@playwright/test";
// Built dist/ is served on :8077; media (CORS) on :8078. The build's config.js
// must be overridden to point media at :8078 for this run (see Step 3).
test.use({ baseURL: "http://localhost:8077" });
test("boots fully static — no /api calls, ring + clips load from JSON", async ({ page }) => {
const apiCalls: string[] = [];
page.on("request", (r) => { if (r.url().includes("/api/")) apiCalls.push(r.url()); });
await page.goto("/");
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
expect(apiCalls, "no /api/* in static mode").toEqual([]);
});
test("dream shader runs on cross-origin media without tainting the GL texture", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto("/");
// crank Right to engage the dream, let a few frames render
await page.evaluate(() => { const r = document.getElementById("right") as HTMLInputElement; r.value = "4"; r.dispatchEvent(new Event("input", { bubbles: true })); });
await page.waitForTimeout(2000);
// a tainted texture throws SecurityError on the GL draw; assert none surfaced
expect(errors.filter((e) => /SecurityError|tainted|cross-origin/i.test(e))).toEqual([]);
});
```
- [ ] **Step 3: Wire the run (build → point media → serve → test)**
Document in `simulator/e2e/README.md` and use a throwaway config for the run:
```bash
.venv/bin/python tools/build_static.py --media-base http://localhost:8078/
DIST_DIR=$PWD/dist MEDIA_DIR_E2E=$PWD/dist-media node simulator/e2e/serve-static.mjs &
cd simulator/e2e && npx playwright test static-build.spec.ts
```
(Building with `--media-base http://localhost:8078/` writes that base into `dist/config.js`, so the app fetches media cross-origin from the CORS server — the production CORS path, locally.)
- [ ] **Step 4: Run the E2E**
Expected: both tests PASS — zero `/api/*` requests; no `SecurityError` from the dream shader.
- [ ] **Step 5: Commit**
```bash
git add simulator/e2e/serve-static.mjs simulator/e2e/tests/static-build.spec.ts simulator/e2e/README.md
git commit -m "test(static): E2E — static boot with no API + dream shader survives cross-origin CORS"
```
---
## Self-Review
**Spec coverage:**
- No-origin static frontend → Tasks 2, 3. ✅
- Media on R2 + CORS + crossOrigin → Tasks 2 (crossOrigin), 4 (CORS policy). ✅
- Bake read-APIs to JSON → Task 3. ✅
- Random pick client-side → Task 2 Step 4. ✅
- `/api/alteration` client-side → Tasks 1, 2 Step 5. ✅
- Served = manifest-only (no masters) → Task 3 (test asserts). ✅
- `?v=` versioning + immutable caching → Task 2 (preserved), Task 4 (cache header). ✅
- Path `/human-experience-simulator` + apex redirect → Task 4. ✅
- E2E against static build (no API, dream survives CORS) → Task 5. ✅
- Prefetch unchanged → no task needed (only `mediaNetUrl` base changes, Task 2 Step 2). ✅
**Placeholder scan:** none — every code step has full content.
**Type consistency:** `HEF_CONFIG.{mediaBase,static}`, `HEFAlteration.{plan,renderAudio,alteration}`, `controls()` shape, and `build_static(out, media_out, *, media_base, app_path)` are used identically across tasks. ✅
**Open verify-at-execution items (flagged, not placeholders):** (a) confirm `ring_to_dict` emits per-scale `audio` — add it if missing (Task 3 Step 3); (b) confirm `simulator/app.py` exports `MEDIA_DIR` (used by the build); (c) locate the exact video/audio element creation sites for `crossOrigin` (Task 2 Step 6).
@@ -0,0 +1,149 @@
# Publish to benstull.art — Cloudflare Pages + R2 (fully static) — Design
**Status:** Approved (brainstormed 2026-06-30). Ready for writing-plans.
## Goal
Publish the Human Experience **Simulator** publicly at
`benstull.art/human-experience-simulator`, hosted on Cloudflare, with the
`benstull.art` apex **redirecting** to that path for now.
The simulator today is a FastAPI server (`simulator/app.py`) serving ~92 KB of
static frontend plus video/audio from a local `/media/` mount. The public target
has **no origin server**: the frontend is **fully static** on **Cloudflare Pages**,
and the video/audio lives in a public **R2** bucket behind a custom domain. Nothing
runs, nothing to patch, nothing to keep alive. The existing GCE/flotilla deployment
stays as the dev/authoring surface — this is a *new, additional* public target, not
a migration of the dev box.
## Why this shape (the constraint that forces it)
Cloudflare Pages has a **hard 25 MiB per-file limit** (and 20,000 files free /
100,000 paid; no total-size cap). The repo carries **3.9 GB across 663 mp4 + 10
mp3**, with individual files up to ~117 MB. Pages cannot host the video. R2 has no
relevant per-file limit, **free egress**, and ~2 GB of served media sits inside its
10 GB-month free tier (≈ free). So: **Pages for the ~92 KB frontend, R2 for the
media.** (Verified against Cloudflare docs 2026-06-30.)
## Decisions (locked)
- **Path:** experience at `/human-experience-simulator`; **apex `benstull.art`
redirects** there (302/308 redirect, not a rewrite).
- **Fully static**, no Pages Functions / Worker — the dynamic logic is trivial
client-side (see Component 2).
- **Media on R2** at a custom domain `static.benstull.art` (public bucket binding).
- **No cross-session cache** (no Service Worker) for now — the existing in-memory
graduated prefetch is sufficient. Deferred, not rejected.
- **benstull.art is already a Cloudflare zone** — no nameserver migration needed.
- **Operates outside Wiggleverse deployment patterns (standing, operator-set):**
this **entire project** is exempt from the typical Wiggleverse deployment
conventions — `flotilla-only-provisioning`, the `ppe-before-prod` GCE pipeline,
and the §9 prod-promotion gate do not bind it. It is a static public art target
hosted directly on Cloudflare. (Safety/hygiene rules — `git-ssh`, the secrets
rule, branch→PR — still apply; only the *deployment* machinery is out of scope.)
## Served media — what actually ships
The on-disk 3.9 GB is mostly **pipeline source**, not served bytes. The manifest
(`simulator/sample_media/manifest.json`) references only **`base.mp4`** for clips —
the `master.mp4` (20 files, incl. the 91117 MB `coast_birdrock` set) and
`mezzanine.mp4` proxies are build inputs that **never reach the browser**. The
served set is:
- the `base.mp4` clip bases (one per pool member),
- the **154 transition morphs** (`*__*.mp4` / `*.rev.mp4`, ~1.6 GB, each already
kept < 25 MB),
- the **10 audio `.mp3`** layers (~12 MB).
**~2 GB** to R2. The build computes the exact set by walking the manifest — it
**must not** blindly sync `sample_media/` (that would push the masters too).
## Components
### 1. Static build (`tools/` — new build script → `dist/`)
Replaces the server with baked artifacts:
- **Bake the read endpoints to static JSON** the page loads at boot, replacing the
fetches to `/api/clips`, `/api/ring`, `/api/media-versions`:
- `clips.json``GET /api/clips`
- `ring.json``GET /api/ring`
- `media-versions.json``GET /api/media-versions` (file → content-hash)
Generate these by importing the existing builders (`simulator/clips.py`,
`build_pool_manifest.py`) directly — **single source of truth, no hand-copy**.
- **Copy only manifest-referenced media** into the R2 sync tree (see Served media).
- **Emit the public frontend** (`index.html`, `app.js`, `scrub.js`, `style.css`,
`i18n.js`) into `dist/`, **excluding** author/review/dev surfaces
(`author.*`, `review*.html`, `/api/author/*`).
- Output is deterministic and re-runnable; checked by the integration test below.
### 2. Client-side dynamic logic (`simulator/static/app.js`)
The only server-computed behavior is the **random clip pick on landing**
(`POST /api/ring/advance`). The scrub work (PR #28, spec 2026-06-27) **already
moved the pick client-side** for drag/scroll gestures via the pool + `morphByPair`
lookup — so the remaining task is to ensure **every** entry path (initial load, tap-
label jumps) uses the client-side pick and that **no code path depends on a live
`/api/*` call**. Boot loads the baked JSON instead of fetching the API.
`/api/alteration` is verified client-resolvable or dropped from the public build.
### 3. Media URL base + CORS (`simulator/static/app.js`)
- `mediaNetUrl(file)` changes its base from `"/media/" + file` to a configurable
**`MEDIA_BASE`** (`https://static.benstull.art/`), preserving the existing
`?v=<hash>` content-hash versioning. A single const, fed from the baked config so
localhost dev still works against the FastAPI `/media/` mount.
- **CORS is load-bearing** because of the WebGL dream shader: `app.js` preloads each
clip via `fetch()` (cross-origin → needs `Access-Control-Allow-Origin`) into a
blob, and any clip that falls back to the *direct network* `<video>` path feeding
WebGL **taints the GL texture** without CORS. So:
- R2 bucket gets a **CORS policy** allowing `https://benstull.art`.
- `<video>` elements carry **`crossOrigin="anonymous"`**.
Without this, plain playback works but the *dream effect* throws a security error.
### 4. Prefetch (unchanged)
Keep the existing graduated strategy verbatim — `preloadList()` (all bases),
`preloadOrder()` (current pool → neighbors outward → reachable morphs → sweep),
`refreshReachablePreload()` (per-landing), `preloadAllMedia(concurrency=4)` behind
the "Loading Universe…" bar. It improves on R2: Cloudflare edge-caches each object
after first fetch; egress is free, so the background fill is free. No code change
beyond the `MEDIA_BASE` swap (Component 3).
### 5. Caching headers
Server today sets `/media/` to `no-cache` (revalidate-always). On the CDN, set the
versioned media objects to long-lived **`immutable`** — the `?v=<hash>` query already
guarantees a new URL when bytes change, so a re-baked clip busts cache without a
manual purge. Pages assets keep normal Cloudflare defaults.
### 6. Deploy & redirect
- **Pages:** deploy `dist/` (Pages Git integration or `wrangler pages deploy`).
Serve the app under the `/human-experience-simulator` path.
- **R2:** sync the media tree to the bucket (`wrangler r2` or rclone), bind
`static.benstull.art` as the public custom domain, apply the CORS policy.
- **Apex redirect:** `benstull.art/``/human-experience-simulator` via a
Cloudflare redirect rule (or Pages `_redirects`).
## Testing
- **Build integration test:** run the build against the real manifest; assert
`dist/` contains the expected static files, the three baked JSONs match the live
API output, and the media sync tree contains **only** manifest-referenced files
(no masters/mezzanines, nothing > some sanity bound).
- **E2E (Playwright) against the static build:** point the existing suite at the
built `dist/` + an R2-equivalent media origin and verify: boot with **no `/api/*`
server**, the **dream/Kuwahara shader survives CORS** (texture not tainted),
scrub-driven morphs stay smooth, audio crossfade works, language dropdown works.
The E2E browser tier is required per the §9 pipeline (handbook).
- **Pre-deploy check:** a smoke run confirming CORS headers + range requests on the
R2 custom domain before flipping the apex redirect.
## Out of scope / deferred
- Service Worker / cross-session caching (deferred, not rejected).
- Migrating the dev/authoring GCE deployment (stays as-is on flotilla).
- Per-clip loop files for the reverse-landing frame jump (pre-existing residual,
tracked elsewhere).
- Cloudflare Stream (R2 is sufficient and cheaper for this payload).
@@ -1,94 +0,0 @@
# Session 0027.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-29T22-04 (PST)
> End: 2026-06-30T06-47 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 3e51cce1-9023-4a16-b607-99894dfe0597
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **FINALIZED**
## Launch prompt
```
Add left and right brain annotations (including localization) for any that are missing.
```
## Plan
> Anchor: operator direct instruction (standalone leaf content-fill task, §4.3 R2b).
**Goal:** Author the missing **left-brain** factual `LABELS` (tiered general→scientific→+fact
labels) for the rotating-pool clips that lack them, plus their **es/fr/ja** translations,
then rebuild + merge the manifest. Right-brain `affect` is auto-generated per scale for
every clip via `_affect_for_clip` — nothing missing there.
## Pre-state
- `main` at `5beee55` locally but **diverged from `origin/main`**: 11 merged-but-unpushed
commits (the entire i18n localization feature + a video-loop safety-net fix) stranded
locally; `origin/main` still at `eff43bd` (PR #29). Plus 3 files with an uncommitted
"boots-silent / no auto-start" change. Two stale `--INPROGRESS` placeholders (0015, 0017).
- Annotation reality (from a read-only survey of `build_pool_manifest.py` + catalogs):
right-brain `affect` present on all 41 clips (auto-per-scale); left-brain `LABELS`
present on only 13 of 41. 28 pool clips had `"annotations": []`.
## Arc
1. **Scoped the task** — explored the annotation/i18n system; established that the gap was
left-brain only (right-brain is auto-generated), 28 clips, + their es/fr/ja strings.
2. **Claimed session 0027** (peek showed the two stale placeholders; noted + proceeded).
3. **Surfaced the unclean baseline**; operator chose *"Push stranded, also commit dirty."*
- Stashed the 3 dirty files, merged `origin/main`'s claim commit, **pushed the 11
stranded commits** (`020219f`).
- Committed the boots-silent change on `fix/boot-silent-no-autostart`, verified
(299 pytest pass), **merged + pushed** (`7bd6c9a`).
4. **Annotation work** on `feat/left-brain-annotations`:
- Added 28 `LABELS` entries (static tiered labels) to `build_pool_manifest.py`
(Round-5 block); rebuilt manifest → all 41 clips annotated; extracted en catalog.
- Dispatched **3 parallel translator subagents** (es/fr/ja) for the 67 new keys,
matching existing catalog voice; fixed 2 ja `measure.*` values to the `約`/localized
convention; merged fragments into catalogs; merged all 3 langs into the manifest.
- Verified: 41 clips × en/es/fr/ja full key + tier-length parity; 299 pytest pass
(3 pre-existing `cv2` env failures only); 37 i18n/manifest tests green; spot-checked
translations. **Merged + pushed** (`934f60c`), deleted both branches.
## Cut state
- `main` == `origin/main` @ `934f60c`, clean tree, branches removed.
- 41/41 clips carry left+right annotations with full en/es/fr/ja parity.
- §9: local tests green. No PPE/E2E machinery exists for this app yet (pipeline is policy,
not automated — §10.6); prod promotion stays operator-gated. Visual review deferred to
operator (no Chrome on box).
## Operator plate
- **Review by eye** the 28 newly-labelled clips across all 4 languages in the running sim.
- The labels are **static**; the abyss/reef *creature* clips would benefit from a later
**tracked-label** upgrade via author mode (couldn't auto-file as a tracker issue — the
capture token returned 401 in this env; full context is in the session-0027 memory note).
- A few species IDs were inferred from clip META, not the frame (`reef_redsea`→anthias,
`reef_flowergarden`→jack/scad+sergeant-major) — worth confirming at review.
- Two stale `--INPROGRESS` sessions (0015, 0017) remain unfinalized — likely the crashed
sessions that stranded the 11 commits; left untouched for the operator.
## Next-session prompt
```
/goal Review the new left-brain annotations (all 28 newly-labelled pool clips, across en/es/fr/ja) by eye in the running simulator; correct any species/box that reads wrong, and decide whether the abyss/reef creature clips should get tracked labels via author mode.
```
## Deferred decisions
- **Static labels, not tracked.** Authored all 28 clips' left-brain labels as fixed-box
`static_label`s rather than motion-tracked labels. Alternative: tracked labels (as the
reef/abyss showcase clips use) so the box follows drifting creatures. Chose static because
the footage wasn't eyeballed — fabricating track coordinates would claim precision I don't
have. The abyss/reef creature clips are the ones that would most benefit from a later
tracked-label upgrade via author mode.
- **Some species IDs inferred from clip metadata, not the footage.** `reef_redsea`→anthias,
`reef_flowergarden`→jack/scad + sergeant-major — read from the clip META title/source.
High-confidence from provenance, but worth an eyeball check.
- **`O-type` kept verbatim in es/fr/ja** (per the translation brief) rather than the more
natural `tipo O` / `type O`. Minor; flagged by the es translator.
@@ -0,0 +1,28 @@
# Session 0027.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-29T22-04 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 3e51cce1-9023-4a16-b607-99894dfe0597
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0027 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--<end>.md form at session end.
## Launch prompt
```
Add left and right brain annotations (including localization) for any that are missing.
Resolved scope: right-brain `affect` is auto-generated per scale for every clip (nothing missing). The gap is left-brain `LABELS` (factual/scientific tiered labels) on 28 rotating-pool clips that have no entry in simulator/build_pool_manifest.py, plus their es/fr/ja translations in tools/i18n/catalogs. Author them following the existing static_label pattern, rebuild the manifest, merge translations, run tests.
```
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -1,125 +0,0 @@
# Session 0028.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T06-53 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: d9da7994-e036-4b7c-b2d4-d2675ac4e5a1
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **FINALIZED.**
> Worktree: `worktree-session-0028` (isolated; concurrent session in flight). Torn down at finalize.
> Outcome: D3 reverse-landing frame jump FIXED, app-side. PR #30 → main (`582183d`).
## Launch prompt
`/goal next` — operator then directed: "There's a concurrent session — do this one out
of a worktree in a different dir." Session 0028 runs isolated in worktree
`worktree-session-0028` while a concurrent session owns the simulator startup ("Run
simulation" button: `index.html`/`style.css`/`i18n.js`).
## Plan
> Anchor: leaf bug — D3 reverse-landing frame jump (no tracker issue; the s0027 capture
> token returned 401. Residual flagged in the altitude-morph-and-scrub memory note +
> scrub-driven-transitions design.)
**Goal:** kill the ~3-second frame jump when ASCENDING the altitude dial and landing on
the higher-altitude clip.
**Root cause (verified by reading the bake + player):** each baked morph
`transitions/{h}__{l}.mp4` spans `src(h)@0s` (frac 0) → `dst(l)@~LOOP_TAIL_S` (frac 1),
and the scrub uses this single forward file in BOTH directions. The steady loop element
always seeks to `LOOP_TAIL_S` (3.0s) on landing. So:
- DESCEND (dir>0) lands on `dst` at frac 1 → morph ends `dst@3` ≈ loop@3 → seamless ✓
(the s0026 "loop-from-tail" fix).
- ASCEND (dir<0) lands on `src` at frac 0 → morph shows `src@0`, but loop seeks `src@3`
→ ~3s jump ✗ (D3).
**Fix (app-side, no media re-bake — beats re-baking 154 LFS morphs):** make the loop
landing frame DIRECTION-AWARE. On a reverse landing seek the loop to 0 (= the morph's
frac-0 frame) and wrap `[0,dur]` (the base is already a seamless crossfade-loop); forward
keeps `[LOOP_TAIL_S,dur]`. Pure decision extracted to `scrub.js loopLandFrame(dir,
loopTailS)` + node tests; wired through `loadLoop`/settle/wrap handlers in `app.js`.
**Tasks:**
1. TDD `HEFScrub.loopLandFrame(dir, loopTailS)` in `scrub.js` (+ `scrub.test.js`).
2. Wire `app.js`: `loadLoop(clip, landFrame)`, directional settle seek, `dataset.loopStart`
in the `timeupdate`/`ended` wrap handlers.
3. Add a reverse-landing e2e (mirror of the existing forward "loops from tail" test).
4. Verify: node unit tests + pytest + e2e (best-effort) all green.
5. Branch → PR → merge (yolo). PPE/E2E machinery not built for this app (§10.6) — local
green is the bar; prod promotion stays operator-gated.
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
- **Fix shape: app-side direction-aware loop landing, NOT a 154-morph re-bake.** The
s0026 memory note guessed the D3 fix would be "per-clip loop files." Reading the bake +
player showed the root cause is simpler: the morph's frac-0 frame is `src@0` but the
loop always seeked the tail (`LOOP_TAIL_S`). Fixed entirely in `app.js`/`scrub.js` by
landing the loop on the morph's actual frac-0 frame (0) when ascending. This avoids
re-baking 154 LFS morphs (no media churn, a tiny reviewable diff, the base clips are
already seamless crossfade-loops so `[0,dur]` wraps cleanly). Alternative considered:
re-bake morphs to start at `src@LOOP_TAIL_S` — heavier, no benefit.
- **Pre-existing e2e failure left as-is:** `loop-recovery.spec.ts` fails in this
environment on the CLEAN baseline too (verified by stashing my changes). It boots
without enabling video, so the loop's `loopTail` is never armed and the `ended` guard
returns early. Not introduced here; my change preserves that guard exactly. Worth a
separate look (the test likely needs to `enableVideo` first), but out of scope for D3.
- **Could not verify by eye.** The fix is logic- and test-verified (node + the new
reverse-landing e2e asserting the loop anchors at the head, not the tail); the actual
visual seamlessness of an ascending landing still wants an operator eyeball in the
running sim.
## Session arc
1. Claimed session **0028** (race-free, via git). Two stale `--INPROGRESS` placeholders
(0015, 0017) noted as orphaned, left untouched.
2. Baseline survey surfaced uncommitted `run-sim` button edits + main behind by 2
(session commits). Operator confirmed a **concurrent session** owns that startup work
and directed isolation → created worktree `worktree-session-0028` off `origin/main`,
leaving the concurrent session's edits in the canonical clone untouched.
3. Orientation: in-repo `docs/ROADMAP.md` is stale (formal frontier = deferred hardware);
live frontier is in memory. Stored `/goal next` (0027's by-eye annotation review) is
operator-eyes work I can't complete; the run-sim button is the concurrent session's.
Asked the operator to pick 0028's disjoint item → **D3 reverse-landing fix**.
4. Read the bake (`build_pool_manifest.transition_cmd`) + player (`app.js` loop/morph) to
pin the root cause (morph spans `src@0``dst@tail`; loop always seeks the tail).
5. TDD: `HEFScrub.loopLandFrame` (red → green), then wired `app.js`
(`loadLoop(clip,landFrame)`, `dataset.loopStart`, directional settle, wrap handlers).
6. Added a reverse-landing e2e. Verified: node 16/16, **altitude-lock e2e 12/12** (forward
no-regression + new reverse test), pytest 299 pass. Confirmed `loop-recovery.spec.ts`
fails on the clean baseline too (pre-existing, not mine) by stashing.
7. Committed → PR #30 → merged to `main` (`582183d`) → deleted branch. Updated memory.
## Cut state
- **On `main` (`582183d`):** D3 fix — `simulator/static/scrub.js` (+`loopLandFrame`),
`simulator/static/app.js` (direction-aware loop landing), `simulator/unit/scrub.test.js`,
`simulator/e2e/tests/altitude-lock.spec.ts` (reverse-landing test).
- **Tests:** node 16/16; altitude-lock e2e 12/12; pytest 299 pass / 3 pre-existing
env-only failures (real-ffprobe/ffmpeg + ML detect; untouched — no Python changed) / 4
skipped. `loop-recovery.spec.ts` is a pre-existing baseline failure in this env.
- **§9 / deploy:** this whole project is **exempt** from flotilla/PPE/§9 (memory:
project-exempt-from-wiggleverse-deploy; public target is static Cloudflare). Local-green
is the bar — met. No PPE stage to run.
- **No plan artifact archived:** fused leaf fix, plan inline in this transcript; app has
**no content repo** (`CONTENT_REMOTE` empty) — nothing to archive.
## Operator plate
- **Review by eye** an ASCENDING altitude landing in the running sim — confirm the
~3s reverse-landing jump is gone (logic/test-verified only; no Chrome on box here).
- Still standing from s0027: by-eye review of the 28 left-brain annotations (4 langs);
decide on tracked labels for abyss/reef creature clips.
- A concurrent session was shipping the "Run simulation" startup button — reconcile/land
that separately (its edits are in the canonical clone, not touched here).
## Next-session prompt
```
/goal Review by eye in the running simulator: (1) an ASCENDING altitude landing — confirm the D3 reverse-landing frame jump is gone; (2) the 28 left-brain annotations across en/es/fr/ja, correcting any species/box that reads wrong; then decide whether the abyss/reef creature clips should get tracked labels via author mode.
```
@@ -1,76 +0,0 @@
# Session 0029.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T08-09 (PST)
> End: 2026-06-30T08-41 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Branch: design/cloudflare-static-publish
> Status: **FINALIZED**
## Launch prompt
"We need to make sure credits for Creative Commons licensed videos are compliant.
What are some options?" → after presenting options the operator chose **Option A**
(manifest-driven static credits/colophon page surfaced to the public) + **Option D1**
(declare the altered footage CC BY-SA 4.0). Non-commercial.
## Pre-state
- Branch `design/cloudflare-static-publish` (static-publish work, pushed). Working
tree had uncommitted, unrelated in-flight work: a "Run simulation" start button
(6 files) **plus** an `r2-cors.json` reformat (surfaced via stash, 7 total).
- Per-clip `license`/`source` already in `clips.json` (baked from `/api/clips`) but
only shown in the dev panel + internal review pages — public experience showed none.
- A concurrent session was live in worktree `fix+audio-and-tracking-labels` (off main);
the operator was also committing directly to `design/cloudflare-static-publish`.
## Arc
1. **Audited** the license surface: `tools/licensing.py`, `pipeline/provenance.py`,
manifest, frontend. Found credits exist in data + review tooling but not the public
experience → that's the gap. Presented options AD; operator chose A + D1.
2. **Claimed session 0029** (planning-and-executing, yolo). Two stale `--INPROGRESS`
placeholders (0015, 0017) noted as long-dead, left alone.
3. **Operator-requested pre-work:** the uncommitted in-flight work. Stashed it,
created an isolated worktree (`.claude/worktrees/session-0029` off the branch HEAD,
since the static work lives there not on main), applied the stash.
- Discovered the branch had advanced under me to `8cc472c` (operator's direct
commit). It **intentionally** keeps `r2-cors.json` in S3-array form alongside a
new `r2-cors.wrangler.json` (runbook documents both) → **dropped** my conflicting
`r2-cors.json` reformat commit; kept + rebased only the Run-simulation button.
- Committed the button (`3a45e33`), ff-merged + pushed.
4. **Built Option A + D1** (TDD): node test for the pure credits logic first (red) →
`credits.js` (UMD: `creditEntries`/`creditsListHtml`, escaped, sorted) → `credits.html`
colophon (D1 CC BY-SA declaration + "altered" note; JS-filled video attributions;
PD/CC0 audio courtesy block; back link) → header "ⓘ Credits" link (`credits.link`
i18n en/es/fr/ja) → `style.css``PUBLIC_ASSETS` in `build_static.py` + its test →
Playwright e2e (`tests/test_e2e_credits.py`).
5. **Verified:** node 28/28, pytest 315 passed / 2 skipped. Real build render: 41/41
clips credited, all 7 CC-BY/BY-SA clips show required attribution, 0 missing.
Committed (`1c7f2b7`), ff-merged + pushed, removed the worktree, deleted the branch.
## End state
- `design/cloudflare-static-publish` @ `1c7f2b7`, clean, pushed (origin in sync).
- Public site now attribution-compliant: credits reachable from the work; altered-work
license declared. §9 is **exempt** for this project (static Cloudflare target), so
merged+pushed to the design branch is the completion state — no PPE/prod.
## Deferred decisions
- **Dropped my `r2-cors.json` reformat** rather than merging it — concluded from the
operator's `8cc472c` + runbook that the S3-array form is intentional. (Alternative:
keep both forms / ask. Chose to honor the documented two-file split.)
- **Audio credits as a static courtesy block** in `credits.html` rather than plumbing
`audio_license` through the manifest/API. Rationale: audio is all PD/CC0 (no legal
attribution duty) and not machine-readable; data-plumbing was out of scope. (Alternative:
add audio license fields — deferred, low value for non-commercial PD/CC0.)
## Next /goal
```
/goal operator by-eye review of the credits colophon + Run-simulation button on design/cloudflare-static-publish, then the actual Cloudflare deploy (provision R2 human-experience-simulator-media + CORS, run tools.build_static, Pages deploy) per deploy/cloudflare/README.md
```
@@ -1,128 +0,0 @@
# Session 0030.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T08-10 (PST)
> End: 2026-06-30T08-50 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c4cec6c6-26d8-46f3-80ee-4ff4035a4f72
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Date: 2026-06-30
> Goal: Add an obvious "Run simulation" start button to the simulator's main
> screen (shown after the "Loading Universe…" preload); it turns Video on and
> sets Audio to level 2. Turning Video on directly should also start the
> experience, snap Audio to 2, and dismiss the button. Move the default
> first-Video-on audio coupling from 3/10 → 2/10.
> Outcome: Built + verified (303 pytest, 8 pytest-playwright incl. 3 new, 12
> Playwright TS incl. 1 updated + 1 new, node unit tests green). Work committed
> as 3a45e33 and pushed to design/cloudflare-static-publish — **the commit was
> landed by a CONCURRENT session (0029)** that swept up this session's
> working-tree changes while doing the CC-credits/Cloudflare-publish work.
## Plan
1. Map the splash/loading flow and the Video/Audio toggle wiring (Explore agent
+ direct reads of `simulator/static/{index.html,app.js,style.css,i18n.js}`).
2. Add a `#run-sim` button inside the stage `.screen`, hidden until media
preloads, styled to sit above the black cover (`z-index: 60`).
3. Add a `beginExperience()` helper: Video on + audio snapped to a
`START_AUDIO_LEVEL = "2"` + button dismissed + soundtrack played in-gesture
(Safari unlock). Wire it to both the button click and the first direct
Video-on (`!videoEverOn`).
4. Reveal the button after `hideLoading()`; change the old 3/10 coupling → 2/10.
5. Localize `run.button` in en/es/fr/ja (4-lang parity invariant).
6. Update E2E coverage (pytest-playwright + Playwright TS) from the old
"couples to 3" assertions to 2; add button-shown / button-starts /
direct-video-on tests.
7. Run the full suite + confirm a pre-existing failure is not mine.
## Pre-session state
- Branch `main` clean at `934f60c` (left-brain annotations merged). Simulator
boots SILENT (video off, audio 0) and waited for the operator to flip the
unlabelled **Video** toggle as the start gesture — not obvious to a first-time
viewer. First-Video-on coupled audio to **3/10**.
- No session-init gate was run at the top of this session — it was treated as a
small, fully-specified frontend change (see Deferred decisions).
## Turn-by-turn arc
1. **Explore + direct reads.** Dispatched an `Explore` subagent to map the
splash + AV-toggle code; in parallel read `index.html`, `app.js`
(`main()`/`hideLoading()`/`beginExperience` region, the audio dial, the
`videoEverOn` coupling), `style.css` (`#loading`, `.black z-index:50`,
`.hidden`), and `i18n.js` (UI_STRINGS shape, 4 languages). Confirmed the
stage `.screen` is `position: relative` and the black cover is `z-index: 50`,
so a start button needs `z-index > 50` inside the screen and the side panel
(Video toggle) stays reachable.
2. **Implement.** Added `#run-sim` to the stage; `.run-sim` CSS (centered pill,
`z-index: 60`); `run.button` i18n string (en/es/fr/ja); `beginExperience()`
unifying both entry paths; revealed the button after `hideLoading()`;
simplified the Video-toggle handler to call `beginExperience()` on first
on; changed coupling 3 → 2.
3. **Update tests.** Replaced the old `test_video_on_couples_audio_to_three…`
(pytest) and the "couples audio to 3/10" Playwright TS test with 2/10
variants; added button-shown-after-load, button-starts-video+audio, and
direct-Video-on-dismisses-button cases on both harnesses.
4. **Verify.** `303 passed, 2 skipped` (non-E2E pytest); `8 passed`
pytest-playwright (incl. 3 new); `12 passed` Playwright `altitude-lock.spec`
(incl. updated + new). Node unit tests (i18n / index-i18n / scrub) green; the
i18n completeness invariant confirms `run.button` has all 4 langs.
5. **Isolate a failure.** `loop-recovery.spec.ts` failed; stashed only the
source files and re-ran on a clean baseline — it **fails identically without
my changes**, so it's pre-existing, not introduced here. (During that stash
round-trip a `test-results/.last-run.json` artifact briefly blocked
`git stash pop`; recovered by `git checkout`-ing the artifact and popping —
no source lost.)
6. **Handed the working tree back uncommitted** for an operator by-eye review
(the project's usual cadence — no Chrome on the box). Offered to branch + PR.
## Cut state (end of session)
| Item | State |
| --- | --- |
| Run-simulation button + audio-2 change | Committed `3a45e33` on `design/cloudflare-static-publish`, **pushed** |
| Committed by | Concurrent **session 0029** (swept up this session's working-tree changes) |
| Files | `simulator/static/{index.html,app.js,style.css,i18n.js}`, `simulator/e2e/tests/altitude-lock.spec.ts`, `tests/test_e2e_audio.py` |
| This session's working tree | Clean; nothing of mine left to commit/push/merge |
| Branch `design/cloudflare-static-publish` | Not merged to `main` — intentional; awaits operator review + Cloudflare deploy (session 0029's frontier) |
| Deploy pipeline (§9) | **N/A** — project is exempt (static Cloudflare target; see memory `project-exempt-from-wiggleverse-deploy`) |
| Verification | 303 pytest + 8 pytest-playwright + 12 Playwright TS + node units green; `loop-recovery.spec.ts` red **pre-existing** |
## What lands on the operator's plate
- **By-eye / by-ear review** of the Run-simulation button in a real browser —
especially the **in-gesture audio unlock on real Safari/iOS** (headless tests
relax autoplay, so it's wired-but-unconfirmed on a real device).
- The button work lives on `design/cloudflare-static-publish` alongside the
CC-credits + Cloudflare-publish work; it merges to `main` / ships when that
branch does (session 0029's frontier), not separately.
- Note the concurrency: this session authored the change; session 0029 committed
it. Two transcripts (0029, 0030) touch the same single commit `3a45e33`
expected, not a duplicate of the commit.
## Prompt the operator can paste into the next session
```
/goal operator eyeball review of the static build (incl. the new Run-simulation button) + execute the real Cloudflare deploy to benstull.art/human-experience-simulator, per the cloudflare-static-publish design on branch design/cloudflare-static-publish
```
## Deferred decisions
_Autonomous-mode low-confidence calls; surfaced at finalize._
1. **No session-init gate at the top.** Treated the task as a small,
fully-specified frontend tweak and implemented directly without claiming a
transcript first. Consequence: the work landed on a shared checkout and was
committed by a concurrent session (0029) rather than carrying its own
end-to-end branch→PR. Alternative: claim a session + isolated worktree up
front. Flagged because shared-checkout work is what the worktree rule guards
against.
2. **`beginExperience()` snaps audio to 2 unconditionally** on first Video-on,
overriding a viewer who pre-set a higher level before pressing start /
flipping Video. Matched the operator's explicit "snap to audio level 2"
wording; alternative was to only raise from 0 (the prior 3/10 behavior). Easy
to soften to "only if currently 0" if undesired.
3. **Real-device audio autoplay unverified.** Headless Chromium ran with
`--autoplay-policy=no-user-gesture-required`, masking real Safari/iOS
behavior; the in-gesture unlock is wired but needs a by-ear check.
@@ -1,80 +0,0 @@
# Session 0031.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T09-06 (PST)
> End: 2026-06-30T09-11 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Branch: design/cloudflare-static-publish
> Status: **FINALIZED**
## Launch prompt
Follow-up to session 0029's credits work. Operator's first ask was to rename the
credits URL → "about" (one page holding both about + credits) and add an
Accessibility table. Mid-clarification the operator redirected: **keep About and
Credits separate** (the About page is owned by a parallel session) and simply
**"Remove the 'About this work' from the credits page. I'll merge that into the
About page on the other session."**
## What I found before acting
- The Cloudflare site is now **LIVE** (`benstull.art/human-experience-simulator`);
the branch had advanced several commits since s0029 (live deploy, progressive
boot, an audio mediaBase fix), and moved again mid-session (`a68d0bc``d10ffca`).
- A **live concurrent session** owns `feat/accessibility-pass` with an
approved-pending design spec (`docs/superpowers/specs/2026-06-30-accessibility-and-about-page-design.md`):
a full WCAG 2.1 AA pass + a **separate `about.html`**. I surfaced the overlap with
the operator's original (one-page) ask; the operator resolved it by keeping the
pages separate and narrowing my task to the section removal.
## Arc
1. Surveyed git + the concurrent worktrees; read the a11y design spec.
2. Started to raise a coordination question; operator interrupted with the narrow
instruction (remove "About this work" from credits; they move it to About).
3. Claimed **session 0031**, isolated worktree off the live branch tip (`d10ffca`)
— only `credits.html` + its tests touched, so no overlap with the a11y session's
files (`index.html`/`style.css`/`app.js`/`about.html`).
4. Removed the `<section class="declaration">` "About this work" block from
`credits.html`; rewrote the footage note to be self-contained ("All footage is
altered in real time in the experience" — keeps the per-clip modification cue,
drops the dangling "as described above").
5. Updated tests to the new intent: `credits.test.js` and `tests/test_e2e_credits.py`
now assert the page notes footage is altered but the **CC BY-SA 4.0 declaration is
NOT** on credits (it moved to About).
6. Left the `.declaration` CSS rule in `style.css` (the About page reuses these styles).
7. Verified: node 28/28, pytest 315 passed / 2 skipped. Committed `8277417`,
ff-merged + pushed, removed the worktree, deleted the branch.
## End state
- `design/cloudflare-static-publish` @ `8277417`, clean, pushed.
- Credits page = per-clip attributions + "footage altered" note + audio courtesy
block. The CC BY-SA work-license declaration (D1) is **no longer on credits**
it now belongs on the About page (operator + `feat/accessibility-pass` own it).
- Site is live; this change reaches `benstull.art` only after a **redeploy**.
## Deferred decisions
- **Kept a brief "footage is altered" note on the credits page** rather than
stripping all alteration mention. Rationale: CC BY/BY-SA attribution wants the
modification indicated alongside the credit; the full license declaration is what
moved to About. (Alternative: remove every alteration mention from credits —
judged worse for the per-clip attribution's "indicate changes" cue.)
- **Left `.declaration` CSS in `style.css`** though now unused on credits — the
About page (other session) will reuse it; removing it would break that page.
## Compliance note
D1 (the altered-work CC BY-SA declaration) now depends on the About page actually
carrying it. **Confirm it landed on about.html before considering D1 done** — it is
currently in neither place on the live site until the About page ships + a redeploy.
## Next /goal
```
/goal confirm the About page (feat/accessibility-pass) carries the CC BY-SA 4.0 work-license declaration (D1, moved off credits in s0031), then redeploy design/cloudflare-static-publish to benstull.art per deploy/cloudflare/README.md
```
@@ -1,56 +0,0 @@
# Session 0032.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T09-20 (PST)
> End: 2026-06-30T09-24 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Branch: design/cloudflare-static-publish
> Status: **FINALIZED**
## Launch prompt
After confirming that the "About this work" section removed in s0031 *was* the D1
compliance (the CC BY-SA share-alike declaration, not the per-clip attribution),
the operator: "Ok let's add that content back but can we name the section something
other than 'About This Work' so it isn't confused with the about page?"
## Arc
1. Confirmed for the operator that "About this work" carried D1 specifically — the
ShareAlike license declaration for the BY-SA source clips — while the per-clip
attribution (the original goal) was untouched and stayed on credits.
2. Claimed **session 0032**, isolated worktree off the branch tip (`8277417`).
3. Re-added the CC BY-SA 4.0 declaration to `credits.html` under the heading
**"License & reuse"** (deliberately not "About this work" — that name is reserved
for the separate About page from `feat/accessibility-pass`). Restored the footage
note's "altered as described above" now that the section is back above it. Reused
the `.declaration` CSS left in place by s0031.
4. Reverted the s0031 test changes: `credits.test.js` + `tests/test_e2e_credits.py`
now assert the declaration IS present, plus pin the rename (heading ≠ "About this
work", contains "License & reuse").
5. Verified: node 28/28, pytest 315 passed / 2 skipped (e2e confirms "CC BY-SA 4.0"
visible in-browser again). Committed `ee7c65a`, ff-merged + pushed, removed the
worktree, deleted the branch.
## End state
- `design/cloudflare-static-publish` @ `ee7c65a`, clean, pushed.
- Credits page now carries BOTH halves of CC compliance: per-clip CC-BY/BY-SA
attribution **and** the CC BY-SA 4.0 derivative declaration (under "License &
reuse"). The s0031 gap (live site missing the share-alike declaration) is closed.
- Site is live; reaches `benstull.art` only after a **redeploy**.
## Deferred decisions
- **Section heading "License & reuse"** chosen (operator delegated the name). It pairs
with the "Credits & licenses" page title and is unambiguous vs. the About page.
(Alternatives weighed: "This work's license", "License & alteration".)
## Next /goal
```
/goal redeploy design/cloudflare-static-publish to benstull.art (CC-BY/BY-SA credits + "License & reuse" D1 declaration now both on the credits page) per deploy/cloudflare/README.md; the About page lands separately via the feat/accessibility-pass session
```
-15
View File
@@ -79,20 +79,5 @@
},
"0027": {
"title": ""
},
"0028": {
"title": ""
},
"0029": {
"title": ""
},
"0030": {
"title": ""
},
"0031": {
"title": ""
},
"0032": {
"title": ""
}
}
+8 -320
View File
@@ -233,268 +233,6 @@ AFFECT: dict[str, list[tuple]] = {
],
}
# --- Per-CLIP affect override (feelings drawn from THAT specific footage) --------
# When a clip is here, these feelings replace the scale's shared register for it —
# tailored to what the actual video evokes (mood, motion, light), keeping the
# scale's emotional family as a base. Reuses the scale `feel.*` keys where they fit
# (their es/fr/ja already exist) and adds new keys (with catalog translations) for
# nuances a clip needs. Same tuple shape: (key, [x,y], min_level, [4 tiers]).
AFFECT_CLIP: dict[str, list[tuple]] = {
"orbit_planetearth": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.radiance", [0.22, 0.68], 2, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"orbit_bluemarble": [
("feel.unity", [0.5, 0.44], 1, ["one", "unity", "belonging", "a borderless belonging"]),
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.distance", [0.66, 0.58], 3, ["far", "distance", "remoteness", "an exquisite remoteness"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
"orbit_aurora2025": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.eeriness", [0.66, 0.58], 3, ["strange", "eeriness", "an otherworldly charge", "an electric, otherworldly hush"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"orbit_citylights": [
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.longing", [0.22, 0.68], 2, ["want", "longing", "yearning", "an aching yearning"]),
("feel.fragility", [0.66, 0.58], 3, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.unity", [0.42, 0.82], 4, ["one", "unity", "belonging", "a borderless belonging"]),
],
"orbit_helene": [
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.turbulence", [0.22, 0.68], 2, ["churn", "turbulence", "ferment", "a violent ferment"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"orbit_epic": [
("feel.distance", [0.5, 0.44], 1, ["far", "distance", "remoteness", "an exquisite remoteness"]),
("feel.insignificance", [0.22, 0.68], 2, ["small", "smallness", "insignificance", "a humbling insignificance"]),
("feel.unity", [0.66, 0.58], 3, ["one", "unity", "belonging", "a borderless belonging"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"sky_grca_templesa": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.exhilaration", [0.42, 0.82], 4, ["whee", "exhilaration", "elation", "a soaring elation"]),
],
"sky_greenland_landice": [
("feel.exhilaration", [0.5, 0.44], 1, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.purity", [0.66, 0.58], 3, ["pure", "purity", "stillness", "a glacial hush"]),
("feel.sublime", [0.42, 0.82], 4, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
],
"sky_greenland_suture": [
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.solitude", [0.66, 0.58], 3, ["alone", "solitude", "isolation", "a vast frozen solitude"]),
("feel.lightness", [0.42, 0.82], 4, ["light", "lightness", "buoyancy", "a weightless buoyancy"]),
],
"sky_jungle_amazon": [
("feel.freedom", [0.5, 0.44], 1, ["free", "freedom", "release", "a boundless release"]),
("feel.verdancy", [0.22, 0.68], 2, ["lush", "verdancy", "abundance", "a teeming green abundance"]),
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"sky_jungle_waterfall": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.vertigo", [0.22, 0.68], 2, ["whoa", "vertigo", "giddiness", "a giddy, falling vertigo"]),
("feel.freshness", [0.66, 0.58], 3, ["cool", "freshness", "vitality", "a cool, cascading freshness"]),
("feel.exhilaration", [0.42, 0.82], 4, ["whee", "exhilaration", "elation", "a soaring elation"]),
],
"sky_coast_cliffspain": [
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.exhilaration", [0.22, 0.68], 2, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.turbulence", [0.66, 0.58], 3, ["churn", "turbulence", "ferment", "a violent ferment"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
"sky_mtn_castlecrags": [
("feel.freedom", [0.5, 0.44], 1, ["free", "freedom", "release", "a boundless release"]),
("feel.serenity", [0.22, 0.68], 2, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.lightness", [0.42, 0.82], 4, ["light", "lightness", "buoyancy", "a weightless buoyancy"]),
],
"sky_mtn_rocky": [
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.vertigo", [0.22, 0.68], 2, ["whoa", "vertigo", "giddiness", "a giddy, falling vertigo"]),
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
"coast_birdrock": [
("feel.melancholy", [0.5, 0.44], 1, ["sad", "melancholy", "longing", "a soft seaward longing"]),
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.nostalgia", [0.66, 0.58], 3, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"coast_surfgrass": [
("feel.abundance", [0.5, 0.44], 1, ["full", "abundance", "richness", "a teeming richness"]),
("feel.immersion", [0.22, 0.68], 2, ["in", "immersion", "absorption", "a held, breathless absorption"]),
("feel.curiosity", [0.66, 0.58], 3, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.ease", [0.42, 0.82], 4, ["nice", "ease", "calm", "an unhurried calm"]),
],
"coast_kelp": [
("feel.immersion", [0.5, 0.44], 1, ["in", "immersion", "absorption", "a held, breathless absorption"]),
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.freedom", [0.42, 0.82], 4, ["free", "freedom", "release", "a boundless release"]),
],
"coast_otters": [
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.delight", [0.22, 0.68], 2, ["fun", "delight", "joy", "a darting, bright joy"]),
("feel.ease", [0.66, 0.58], 3, ["nice", "ease", "calm", "an unhurried calm"]),
("feel.belonging", [0.42, 0.82], 4, ["home", "belonging", "rootedness", "a tidal rootedness"]),
],
"coast_kalaloch": [
("feel.nostalgia", [0.5, 0.44], 1, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
("feel.melancholy", [0.22, 0.68], 2, ["sad", "melancholy", "longing", "a soft seaward longing"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.belonging", [0.42, 0.82], 4, ["home", "belonging", "rootedness", "a tidal rootedness"]),
],
"coast_seals": [
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.repose", [0.22, 0.68], 2, ["rest", "repose", "drowse", "a sun-warmed drowse"]),
("feel.belonging", [0.66, 0.58], 3, ["home", "belonging", "rootedness", "a tidal rootedness"]),
("feel.delight", [0.42, 0.82], 4, ["fun", "delight", "joy", "a darting, bright joy"]),
],
"coast_mist": [
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.hush", [0.66, 0.58], 3, ["quiet", "hush", "stillness", "a breath-held hush"]),
("feel.nostalgia", [0.42, 0.82], 4, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
],
"reef_lionfish": [
("feel.curiosity", [0.5, 0.44], 1, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.poise", [0.22, 0.68], 2, ["still", "poise", "grace", "a hovering, ornate poise"]),
("feel.fragility", [0.66, 0.58], 3, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"reef_spawning": [
("feel.abundance", [0.5, 0.44], 1, ["full", "abundance", "richness", "a teeming richness"]),
("feel.flow", [0.22, 0.68], 2, ["go", "flow", "streaming", "a sweeping, schooling flow"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"reef_hawkfish": [
("feel.radiance", [0.5, 0.44], 1, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.curiosity", [0.22, 0.68], 2, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.delight", [0.66, 0.58], 3, ["fun", "delight", "joy", "a darting, bright joy"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"reef_coralspacific": [
("feel.intricacy", [0.5, 0.44], 1, ["fine", "intricacy", "detail", "an intricate, woven density"]),
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.abundance", [0.66, 0.58], 3, ["full", "abundance", "richness", "a teeming richness"]),
("feel.curiosity", [0.42, 0.82], 4, ["look", "curiosity", "fascination", "an absorbed fascination"]),
],
"reef_redsea": [
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.abundance", [0.22, 0.68], 2, ["full", "abundance", "richness", "a teeming richness"]),
("feel.radiance", [0.66, 0.58], 3, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.delight", [0.42, 0.82], 4, ["fun", "delight", "joy", "a darting, bright joy"]),
],
"reef_flowergarden": [
("feel.delight", [0.5, 0.44], 1, ["fun", "delight", "joy", "a darting, bright joy"]),
("feel.abundance", [0.22, 0.68], 2, ["full", "abundance", "richness", "a teeming richness"]),
("feel.freedom", [0.66, 0.58], 3, ["free", "freedom", "release", "a boundless release"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"abyss_wow": [
("feel.vastness", [0.5, 0.44], 1, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.isolation", [0.22, 0.68], 2, ["alone", "isolation", "solitude", "a crushing solitude"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.unease", [0.42, 0.82], 4, ["uh", "unease", "disquiet", "a creeping disquiet"]),
],
"abyss_midwaterexp": [
("feel.fragility", [0.5, 0.44], 1, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.fascination", [0.22, 0.68], 2, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.poignancy", [0.66, 0.58], 3, ["ache", "poignancy", "bittersweetness", "a luminous farewell"]),
("feel.isolation", [0.42, 0.82], 4, ["alone", "isolation", "solitude", "a crushing solitude"]),
],
"abyss_hiding": [
("feel.spectral", [0.5, 0.44], 1, ["ghost", "the spectral", "hauntedness", "a ghost in the water"]),
("feel.unease", [0.22, 0.68], 2, ["uh", "unease", "disquiet", "a creeping disquiet"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"abyss_bigfin": [
("feel.alienness", [0.5, 0.44], 1, ["odd", "alienness", "strangeness", "an alien grace"]),
("feel.fascination", [0.22, 0.68], 2, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.unease", [0.66, 0.58], 3, ["uh", "unease", "disquiet", "a creeping disquiet"]),
("feel.isolation", [0.42, 0.82], 4, ["alone", "isolation", "solitude", "a crushing solitude"]),
],
"abyss_dandelion": [
("feel.radiance", [0.5, 0.44], 1, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"abyss_octopus": [
("feel.curiosity", [0.5, 0.44], 1, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.isolation", [0.22, 0.68], 2, ["alone", "isolation", "solitude", "a crushing solitude"]),
("feel.tenderness", [0.66, 0.58], 3, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.unease", [0.42, 0.82], 4, ["uh", "unease", "disquiet", "a creeping disquiet"]),
],
"abyss_seapig": [
("feel.strangeness", [0.5, 0.44], 1, ["weird", "strangeness", "the uncanny", "a fleshy strangeness"]),
("feel.unease", [0.22, 0.68], 2, ["uh", "unease", "disquiet", "a creeping disquiet"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.curiosity", [0.42, 0.82], 4, ["look", "curiosity", "fascination", "an absorbed fascination"]),
],
# cosmos — Webb "Cosmic Cliffs": monumental golden ridges, cathedral-scale.
"cosmos": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
],
# cosmos_galaxies — drifting through a dark field of galaxies: lonely, remote.
"cosmos_galaxies": [
("feel.vastness", [0.50, 0.44], 1, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.distance", [0.22, 0.68], 2, ["far", "distance", "remoteness", "an exquisite remoteness"]),
("feel.insignificance", [0.66, 0.58], 3, ["small", "smallness", "insignificance", "a humbling insignificance"]),
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
],
# cosmos_orion — soft rose-lit star nursery: tender, dreamy, warm.
"cosmos_orion": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.tenderness", [0.22, 0.68], 2, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
],
# cosmos_tarantula — chaotic, fierce star-forming nebula: turbulent, intense.
"cosmos_tarantula": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.turbulence", [0.22, 0.68], 2, ["churn", "turbulence", "ferment", "a violent ferment"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
],
# cosmos_westerlund — sparkling jewel-box star cluster: dazzling, radiant.
"cosmos_westerlund": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.radiance", [0.22, 0.68], 2, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
# cosmos_southernring — gas shed by a dying star: elegiac, mortal, poignant.
"cosmos_southernring": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.mortality", [0.22, 0.68], 2, ["end", "mortality", "impermanence", "a stars slow dying"]),
("feel.poignancy", [0.66, 0.58], 3, ["ache", "poignancy", "bittersweetness", "a luminous farewell"]),
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
],
# cosmos_carina_eso — wide teeming Milky Way star field: panoramic richness.
"cosmos_carina_eso": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.abundance", [0.66, 0.58], 3, ["full", "abundance", "richness", "a teeming richness"]),
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
],
}
def static_label(key, salience, tiers, box):
"""A fixed-box tiered label (always on-screen, salience-gated by Left)."""
@@ -531,7 +269,6 @@ LABELS: dict[str, list[dict]] = {
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
"cosmos_galaxies": [
measure("measure.count", "~2T galaxies", [0.06, 0.16, 0.24, 0.08], 2),
static_label("detected.galaxy", 4, ["galaxy", "spiral galaxy", "barred spiral", "barred spiral · ~10¹¹ stars"], [0.34, 0.30, 0.30, 0.34]),
measure("measure.distance", "~Mly", [0.06, 0.06, 0.18, 0.08], 3),
],
@@ -554,19 +291,13 @@ LABELS: dict[str, list[dict]] = {
],
"orbit_bluemarble": [
static_label("detected.globe", 4, ["Earth", "the globe", "terrestrial planet", "terrestrial planet · 12,742 km across"], [0.28, 0.18, 0.44, 0.6]),
# Rotating full-disk globe: continents drift through frame, so label the
# PERSISTENT features (clouds, starfield) with static boxes, not a continent.
static_label("detected.cloud_band", 3, ["clouds", "weather systems", "cloud systems", "global cloud systems · clouds cover ~67% of Earth"], [0.34, 0.42, 0.34, 0.32]),
static_label("detected.starfield", 1, ["stars", "starfield", "background stars", "background starfield · a rendered backdrop, not to scale"], [0.74, 0.05, 0.22, 0.30]),
],
# ---------- coast ----------
"coast_birdrock": [
static_label("detected.seabirds", 2, ["birds", "seabirds", "nesting seabirds", "seabird colony · the rookery that names the rock"], [0.35, 0.18, 0.25, 0.2]),
static_label("detected.surf", 4, ["waves", "surf", "breaking swell", "breaking swell · wind-driven, ~10 s period"], [0.20, 0.55, 0.6, 0.3]),
static_label("detected.searock", 2, ["rock", "sea stack", "coastal sea stack", "sea stack · wave-cut residual rock"], [0.30, 0.25, 0.22, 0.3]),
],
"coast_surfgrass": [
static_label("detected.tidepool", 2, ["pool", "tidepool", "intertidal pool", "tidepool · a pocket sea bared at low tide"], [0.15, 0.6, 0.4, 0.25]),
static_label("detected.surfgrass", 4, ["grass", "surfgrass", "Phyllospadix", "Phyllospadix · a marine seagrass, not algae"], [0.18, 0.40, 0.5, 0.4]),
static_label("detected.coralline", 2, ["pink", "coralline algae", "crustose coralline", "crustose coralline · calcified red algae"], [0.6, 0.55, 0.2, 0.2]),
],
@@ -605,7 +336,6 @@ LABELS: dict[str, list[dict]] = {
measure("measure.depth", "22 m", [0.06, 0.06, 0.16, 0.08], 3),
],
"reef_hawkfish": [
static_label("detected.coral", 2, ["coral", "reef coral", "hard coral", "hard coral · the reef this fish grazes into sand"], [0.1, 0.6, 0.55, 0.3]),
tracked_label(
"detected.parrotfish", 4,
["fish", "parrotfish", "Bolbometopon muricatum", "B. muricatum · humphead, grazes reef into sand"],
@@ -623,7 +353,6 @@ LABELS: dict[str, list[dict]] = {
),
],
"reef_coralspacific": [
static_label("detected.polyp", 1, ["polyps", "coral polyps", "living polyps", "polyps · each a tiny animal, the colonys builders"], [0.25, 0.4, 0.3, 0.3]),
static_label("detected.coral", 4, ["coral", "coral colony", "Pacific scleractinian", "Pacific scleractinian · a symbiosis with algae"], [0.2, 0.4, 0.4, 0.4]),
tracked_label(
"detected.spotfish", 2,
@@ -664,7 +393,6 @@ LABELS: dict[str, list[dict]] = {
measure("measure.depth", "1,600 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_hiding": [
static_label("detected.marinesnow", 1, ["specks", "marine snow", "falling detritus", "marine snow · organic debris drifting down from above"], [0.1, 0.1, 0.8, 0.6]),
tracked_label(
"detected.jelly", 4,
["jelly", "crimson jelly", "Scyphozoa", "Scyphozoa · red is invisible in the lightless deep"],
@@ -726,68 +454,55 @@ LABELS: dict[str, list[dict]] = {
],
# ---------- sky ----------
"sky_grca_templesa": [
static_label("detected.butte", 2, ["tower", "butte", "rock temple", "rock temple · an erosional butte left standing"], [0.4, 0.3, 0.2, 0.35]),
static_label("detected.canyon", 4, ["canyon", "gorge", "the Grand Canyon", "Grand Canyon · ~1.8 Gyr of rock, cut by the Colorado"], [0.10, 0.38, 0.8, 0.52]),
static_label("detected.strata", 2, ["layers", "rock strata", "sedimentary beds", "strata · stacked epochs of deposition"], [0.15, 0.55, 0.6, 0.25]),
],
"sky_greenland_landice": [
static_label("detected.crevasse", 2, ["cracks", "crevasses", "glacial crevasses", "crevasses · the ice fracturing as it flows"], [0.2, 0.55, 0.5, 0.25]),
static_label("detected.icesheet", 4, ["ice", "ice sheet", "the Greenland ice sheet", "ice sheet · up to ~3 km thick, flowing slowly seaward"], [0.08, 0.40, 0.84, 0.5]),
static_label("detected.snow", 2, ["snow", "snowfield", "firn", "firn · old snow compacting toward glacial ice"], [0.20, 0.20, 0.6, 0.25]),
],
"sky_greenland_suture": [
static_label("detected.floe", 2, ["plates", "ice floes", "pack-ice floes", "pack-ice floes · drifting plates of frozen sea"], [0.15, 0.2, 0.3, 0.3]),
static_label("detected.seaice", 4, ["ice", "sea ice", "drift ice", "drift ice · a frozen ocean skin, cracking and refreezing"], [0.08, 0.10, 0.84, 0.8]),
static_label("detected.lead", 2, ["crack", "lead", "open lead", "lead · a fracture of open water between floes"], [0.30, 0.40, 0.4, 0.2]),
],
"sky_jungle_amazon": [
static_label("detected.mist", 1, ["haze", "canopy mist", "transpiration haze", "transpiration haze · the forest exhaling water vapor"], [0.06, 0.1, 0.88, 0.18]),
static_label("detected.canopy", 4, ["trees", "forest canopy", "the Amazon canopy", "rainforest canopy · among the densest biodiversity on Earth"], [0.06, 0.30, 0.88, 0.6]),
static_label("detected.emergent", 2, ["tree", "tall tree", "emergent tree", "emergent · a giant breaking above the canopy roof"], [0.42, 0.40, 0.16, 0.2]),
],
"sky_jungle_waterfall": [
static_label("detected.spray", 2, ["mist", "spray", "plunge spray", "plunge spray · the river aerosolized on impact"], [0.36, 0.62, 0.28, 0.25]),
static_label("detected.waterfall", 4, ["falls", "waterfall", "cataract", "cataract · a river plunging off the canopys edge"], [0.34, 0.20, 0.3, 0.7]),
static_label("detected.canopy", 2, ["trees", "jungle canopy", "rainforest canopy", "canopy · dense forest crowding the gorge"], [0.05, 0.30, 0.9, 0.5]),
],
"sky_coast_cliffspain": [
static_label("detected.headland", 1, ["point", "headland", "rock promontory", "promontory · a cliffed arm of land into the sea"], [0.1, 0.2, 0.3, 0.4]),
static_label("detected.seacliff", 4, ["cliff", "sea cliff", "coastal headland", "sea cliff · Atlantic rock cut back by the surf"], [0.10, 0.25, 0.8, 0.55]),
static_label("detected.surf", 2, ["waves", "surf", "breaking swell", "breaking swell · ocean meeting stone"], [0.15, 0.70, 0.7, 0.25]),
],
"sky_mtn_castlecrags": [
static_label("detected.talus", 2, ["scree", "talus", "talus slope", "talus · frost-shattered rock piled below the crags"], [0.3, 0.55, 0.4, 0.25]),
static_label("detected.spire", 4, ["rock", "spire", "granite spire", "granite spire · a glacier-carved pluton, exhumed and weathered"], [0.30, 0.20, 0.4, 0.6]),
static_label("detected.forest", 2, ["trees", "conifer forest", "montane forest", "montane forest · cloaking the slopes below the crags"], [0.05, 0.60, 0.9, 0.35]),
],
"sky_mtn_rocky": [
static_label("detected.snowfield", 2, ["snow", "snowfield", "alpine snowfield", "alpine snowfield · lingering high-elevation snow"], [0.3, 0.22, 0.3, 0.22]),
static_label("detected.summit", 4, ["peak", "summit", "alpine summit", "alpine summit · above tree line, snow-streaked granite"], [0.25, 0.20, 0.5, 0.5]),
static_label("detected.tundra", 2, ["meadow", "alpine tundra", "alpine tundra", "alpine tundra · low cushion plants above the trees"], [0.10, 0.62, 0.8, 0.3]),
],
# ---------- coast ----------
"coast_kelp": [
static_label("detected.pneumatocyst", 1, ["floats", "gas bladders", "pneumatocysts", "pneumatocysts · gas floats lifting the blades to light"], [0.4, 0.18, 0.2, 0.25]),
static_label("detected.kelp", 4, ["kelp", "giant kelp", "Macrocystis pyrifera", "Macrocystis · can grow ~0.5 m a day toward the light"], [0.20, 0.10, 0.6, 0.8]),
static_label("detected.frond", 2, ["leaf", "blade", "kelp frond", "frond · gas-filled floats hold it upright"], [0.40, 0.30, 0.2, 0.4]),
],
"coast_otters": [
static_label("detected.fur", 1, ["fur", "dense fur", "the densest fur", "densest fur on Earth · ~1M hairs/in², no blubber"], [0.34, 0.38, 0.3, 0.25]),
static_label("detected.otter", 4, ["otter", "sea otter", "Enhydra lutris", "Enhydra lutris · eats ~25% of its weight a day to stay warm"], [0.30, 0.35, 0.4, 0.35]),
static_label("detected.water", 2, ["water", "estuary", "tidal slough", "tidal slough · sheltered nursery water"], [0.05, 0.05, 0.9, 0.25]),
],
"coast_kalaloch": [
static_label("detected.sunset", 2, ["glow", "sunset", "golden hour", "golden hour · low sun reddened through more air"], [0.05, 0.05, 0.9, 0.22]),
static_label("detected.surf", 4, ["waves", "surf", "breaking swell", "breaking swell · long-period Pacific swell"], [0.15, 0.50, 0.7, 0.35]),
static_label("detected.searock", 2, ["rock", "sea stack", "coastal sea stack", "sea stack · wave-cut residual rock"], [0.30, 0.25, 0.3, 0.3]),
],
"coast_seals": [
static_label("detected.whiskers", 1, ["whiskers", "vibrissae", "sensing whiskers", "vibrissae · whiskers that feel prey in murky water"], [0.3, 0.42, 0.2, 0.15]),
static_label("detected.seal", 4, ["seals", "harbor seals", "Phoca vitulina", "Phoca vitulina · hauls out on rock to rest and warm"], [0.20, 0.40, 0.6, 0.35]),
static_label("detected.searock", 2, ["rock", "haul-out rock", "intertidal rock", "haul-out · a tide-washed resting ledge"], [0.05, 0.6, 0.9, 0.3]),
],
"coast_mist": [
static_label("detected.swell", 2, ["waves", "swell", "ocean swell", "ocean swell · wind-built waves from distant storms"], [0.1, 0.58, 0.8, 0.25]),
static_label("detected.mist", 4, ["mist", "sea mist", "advection fog", "advection fog · warm air cooling over cold upwelling"], [0.05, 0.10, 0.9, 0.5]),
static_label("detected.searock", 2, ["rock", "shore rock", "coastal rock", "coastal rock · the standing edge of the land"], [0.20, 0.55, 0.6, 0.35]),
],
@@ -804,43 +519,19 @@ LABELS: dict[str, list[dict]] = {
],
# ---------- abyss ----------
"abyss_bigfin": [
static_label("detected.arms", 2, ["arms", "elbowed arms", "trailing filaments", "elbowed arms · held out, then trailing meters of thread"], [0.35, 0.5, 0.3, 0.4]),
tracked_label(
"detected.squid", 4,
["squid", "bigfin squid", "Magnapinna", "Magnapinna · elbowed arms trailing meters into the dark"],
0.0, 1.0,
[(0.0, [0.3, 0.36, 0.28, 0.3]), (0.5, [0.42, 0.22, 0.26, 0.28]), (1.0, [0.55, 0.1, 0.26, 0.3])],
),
static_label("detected.squid", 4, ["squid", "bigfin squid", "Magnapinna", "Magnapinna · elbowed arms trailing meters into the dark"], [0.30, 0.20, 0.4, 0.65]),
measure("measure.depth", "2,000 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_dandelion": [
static_label("detected.tentacle", 2, ["threads", "tentacles", "feeding tentacles", "feeding tentacles · a drifting net for prey"], [0.3, 0.45, 0.4, 0.3]),
tracked_label(
"detected.siphonophore", 4,
["orb", "siphonophore", "dandelion siphonophore", "Rhodaliidae · a colony of clones tethered to the seabed"],
0.0, 1.0,
[(0.0, [0.38, 0.28, 0.26, 0.36]), (0.5, [0.3, 0.2, 0.34, 0.46]), (1.0, [0.18, 0.1, 0.45, 0.7])],
),
static_label("detected.siphonophore", 4, ["orb", "siphonophore", "dandelion siphonophore", "Rhodaliidae · a colony of clones tethered to the seabed"], [0.32, 0.28, 0.36, 0.4]),
measure("measure.depth", "2,500 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_octopus": [
static_label("detected.arms", 2, ["arms", "eight arms", "sucker-lined arms", "eight arms · sucker-lined, tasting what they touch"], [0.25, 0.45, 0.5, 0.3]),
tracked_label(
"detected.octopus", 4,
["octopus", "deep-sea octopus", "Graneledone", "Graneledone boreopacifica · broods its eggs for ~4.5 years"],
0.0, 1.0,
[(0.0, [0.5, 0.1, 0.35, 0.55]), (0.5, [0.42, 0.16, 0.36, 0.52]), (1.0, [0.05, 0.02, 0.92, 0.95])],
),
static_label("detected.octopus", 4, ["octopus", "deep-sea octopus", "Graneledone", "Graneledone boreopacifica · broods its eggs for ~4.5 years"], [0.25, 0.30, 0.5, 0.45]),
measure("measure.depth", "2,500 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_seapig": [
static_label("detected.veil", 2, ["veil", "oral veil", "swimming veil", "oral veil · sweeps sediment, and flaps to swim"], [0.3, 0.3, 0.35, 0.25]),
tracked_label(
"detected.seacucumber", 4,
["blob", "sea cucumber", "Enypniastes eximia", "Enypniastes · a swimming sea cucumber, the “headless chicken”"],
0.0, 1.0,
[(0.0, [0.58, 0.38, 0.18, 0.3]), (0.5, [0.46, 0.32, 0.19, 0.32]), (1.0, [0.38, 0.28, 0.2, 0.36])],
),
static_label("detected.seacucumber", 4, ["blob", "sea cucumber", "Enypniastes eximia", "Enypniastes · a swimming sea cucumber, the “headless chicken”"], [0.30, 0.30, 0.4, 0.4]),
measure("measure.depth", "2,700 m", [0.06, 0.06, 0.16, 0.08], 2),
],
}
@@ -849,13 +540,10 @@ LABELS: dict[str, list[dict]] = {
# data above; this maps measurement keys to nothing extra (their value IS the string).
def _affect_for_clip(scale: str, clip_id: str) -> tuple[list, dict]:
"""Build the affect list + its strings for a clip. A per-clip override in
AFFECT_CLIP (feelings drawn from THAT footage) wins; otherwise the scale's
shared register applies."""
source = AFFECT_CLIP.get(clip_id, AFFECT[scale])
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 source:
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
@@ -874,7 +562,7 @@ def _labels_for_clip(clip_id: str) -> tuple[list, dict]:
def _clip_entry(scale: str, clip_id: str) -> dict:
title, license_, source = META[clip_id]
anns, lab_strings = _labels_for_clip(clip_id)
affect, aff_strings = _affect_for_clip(scale, clip_id)
affect, aff_strings = _affect_for_clip(scale)
return {
"id": clip_id,
"title": title,
+21
View File
@@ -32,3 +32,24 @@ npm test
`playwright.config.ts` starts uvicorn on port 8099 automatically (`reuseExistingServer`
locally) and points the tests at it.
## Static-build E2E (Cloudflare publish)
`tests/static-build.spec.ts` verifies the **built static site** (Cloudflare publish
— see `deploy/cloudflare/README.md`): it boots with NO `/api/*` server (baked JSON),
and the WebGL dream shader runs on **cross-origin** media without tainting the GL
texture (the CORS path R2 will use in production).
It uses `playwright.static.config.ts` (no uvicorn `webServer`) and a dual static
server, `serve-static.mjs`, that serves the built `dist/` (app, :8077) and
`dist-media/` (media with CORS + Range, :8078):
```bash
# from the repo root: build with the media base pointed at the local CORS server
.venv/bin/python -m tools.build_static --media-base http://localhost:8078/
# from this directory (simulator/e2e): start the dual server, then run the spec
DIST_DIR="$PWD/../../dist" MEDIA_DIR_E2E="$PWD/../../dist-media" node serve-static.mjs &
npx playwright test --config playwright.static.config.ts
```
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "@playwright/test";
// Static-build E2E: NO webServer — the built dist/ + dist-media/ are served by
// serve-static.mjs (started manually, see README.md), so there is no uvicorn/API.
// Run: npx playwright test --config playwright.static.config.ts
export default defineConfig({
testDir: "./tests",
testMatch: /static-build\.spec\.ts/,
timeout: 60_000,
use: { actionTimeout: 10_000 },
});
+48
View File
@@ -0,0 +1,48 @@
// Serves the built dist/ (app) and dist-media/ (media, with CORS + Range) on two
// ports — a local stand-in for Pages + R2, so the static-build E2E exercises the
// cross-origin media path exactly as production will (CORS for the WebGL dream
// shader, Range for video scrubbing).
//
// Env: DIST_DIR (app root), MEDIA_DIR_E2E (media root). Ports: app 8077, media 8078.
import http from "node:http";
import { createReadStream, statSync } from "node:fs";
import { join, extname, normalize } from "node:path";
const TYPES = {
".html": "text/html", ".js": "text/javascript", ".json": "application/json",
".css": "text/css", ".mp4": "video/mp4", ".mp3": "audio/mpeg",
};
function serve(root, port, cors, indexFallback) {
http.createServer((req, res) => {
if (cors) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Accept-Ranges", "bytes"); }
let p = decodeURIComponent(req.url.split("?")[0]);
if (p.endsWith("/")) p += "index.html";
// contain to root (no path traversal)
const file = normalize(join(root, p));
if (!file.startsWith(normalize(root))) { res.writeHead(403); return res.end("forbidden"); }
try {
const st = statSync(file);
res.setHeader("Content-Type", TYPES[extname(file)] || "application/octet-stream");
const range = req.headers.range; // honor Range for video scrub
if (range && /^bytes=/.test(range)) {
const [s, e] = range.replace("bytes=", "").split("-");
const start = +s, end = e ? +e : st.size - 1;
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${st.size}`,
"Content-Length": end - start + 1,
});
createReadStream(file, { start, end }).pipe(res);
} else {
res.writeHead(200, { "Content-Length": st.size });
createReadStream(file).pipe(res);
}
} catch {
res.writeHead(404); res.end("not found");
}
}).listen(port);
}
serve(process.env.DIST_DIR, 8077, false);
serve(process.env.MEDIA_DIR_E2E, 8078, true);
console.log("static app :8077 media(cors) :8078");
-32
View File
@@ -164,38 +164,6 @@ test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)",
expect(v.t).toBeGreaterThanOrEqual(2.5); // looping from ~3s, not 0
});
test("ascending re-lands anchored at the clip head (no reverse-landing jump)", async ({ page }) => {
// D3: a morph spans src@0 (frac 0) -> dst@~3s (frac 1). Descending lands on dst at its
// tail (~3s); ASCENDING lands on src at its HEAD (~0s). The steady loop must continue
// from 0 there — seeking to the tail (~3s) was the ~3s reverse-landing jump.
await boot(page);
await enableVideo(page); // base loop only loads when video is shown
// Descend one altitude (cosmos -> orbit), then ascend back (orbit -> cosmos): the
// ascent is the reverse landing under test.
const start = (await page.locator("#scale-name").textContent())!;
await wheelOnStage(page, 60); // descend
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
const mid = (await page.locator("#scale-name").textContent())!;
expect(mid).toContain("orbit");
await wheelOnStage(page, -60); // ascend back
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, mid, { timeout: 20000 });
expect(await page.locator("#scale-name").textContent()).toContain("cosmos");
// The loop element is now anchored at the HEAD (loopStart "0"), playing — not jumped to
// the ~3s tail the descending case uses.
await page.waitForFunction(
() => { const v = document.querySelector("#vid-loop") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.dataset.loopStart === "0"; },
null,
{ timeout: 10000 },
);
const v = await page.evaluate(() => {
const el = document.querySelector("#vid-loop") as HTMLVideoElement;
return { loopStart: el.dataset.loopStart, loopTail: el.dataset.loopTail, paused: el.paused };
});
expect(v.loopTail).toBe("1"); // tail-loop machinery armed
expect(v.loopStart).toBe("0"); // anchored at the head, not the morph tail
expect(v.paused).toBe(false); // the loop is actually playing
});
test("zoom in plays the matching morph, lands locked, and stays put while parked", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
+30
View File
@@ -0,0 +1,30 @@
import { test, expect } from "@playwright/test";
// Runs against the BUILT static site: dist/ served on :8077, media (CORS) on :8078
// by serve-static.mjs. Build with `--media-base http://localhost:8078/` so config.js
// points media cross-origin — the production CORS path, locally. See README.md.
const APP = "http://localhost:8077/human-experience-simulator/";
test("boots fully static — no /api calls, ring + clips load from baked JSON", async ({ page }) => {
const apiCalls: string[] = [];
page.on("request", (r) => { if (r.url().includes("/api/")) apiCalls.push(r.url()); });
await page.goto(APP);
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
expect(apiCalls, "static build must not call /api/*").toEqual([]);
});
test("dream shader runs on cross-origin media without tainting the GL texture", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(APP);
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
// crank the Feel (Right) knob to engage the dream, let a few frames render
await page.evaluate(() => {
const r = document.getElementById("right") as HTMLInputElement;
r.value = "4";
r.dispatchEvent(new Event("input", { bubbles: true }));
});
await page.waitForTimeout(2000);
// a tainted GL texture throws SecurityError on draw; assert none surfaced
expect(errors.filter((e) => /SecurityError|tainted|cross-origin/i.test(e))).toEqual([]);
});
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
// Client-side port of player/alteration.py + player/audio.py (the alteration
// engine). Replaces the live POST /api/alteration in the static build — pure math,
// unity calibration (the client never sends a calibration, so DEFAULT_CALIBRATION
// = all gains 1.0 always applies). UMD so the browser gets `HEFAlteration` and
// `node --test` can require() it. Keep IN SYNC with the Python — the numeric
// contract is guarded by tests/test_alteration_js_parity.py.
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFAlteration = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
const KNOB_MAX = 4; // player/alteration.py:25
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
// Mirror of player/alteration.py::plan_alteration with DEFAULT_CALIBRATION (unity).
function plan(c) {
const tone = clamp((c.light - c.dark) / KNOB_MAX, -1, 1);
const overlayIntensity = clamp(c.left / KNOB_MAX, 0, 1);
const dreamIntensity = clamp(c.right / KNOB_MAX, 0, 1);
const affectIntensity = clamp(c.right / KNOB_MAX, 0, 1); // affect uses overlay_gain too
const is_identity = tone === 0 && c.left === 0 && c.right === 0;
return {
grade: { tone },
overlay: { level: c.left, intensity: overlayIntensity },
affect: { strength: c.right, intensity: affectIntensity },
dream: { strength: c.right, intensity: dreamIntensity },
is_identity,
};
}
// Mirror of player/audio.py::resolve_audio. `scaleAudio` is the current scale's
// `audio` filename (relative to <mediaBase>audio/); off → silence.
function renderAudio(source, scaleAudio, mediaBase) {
if (source === "off") return { source: "off", url: null, altitude_coupled: false };
const url = scaleAudio ? mediaBase + "audio/" + scaleAudio : null;
return { source: "soundtrack", url, altitude_coupled: true };
}
// The full /api/alteration response shape, computed locally.
function alteration(controls, scaleAudio, mediaBase) {
return {
plan: plan(controls),
render: {
video: { shown: controls.visual === "on" }, // player/audio.py::resolve_visual
audio: renderAudio(controls.audio, scaleAudio, mediaBase),
},
};
}
return { plan, renderAudio, alteration, KNOB_MAX };
});
+56 -137
View File
@@ -57,14 +57,19 @@ let activeLang = "en"; // session-only; no persistence (resets to en each lo
let lastAffect = null; // {strength, intensity, right} from the last renderAffect, for re-render on language switch
async function loadData() {
const clips = (await (await fetch("/api/clips")).json()).clips || [];
// Static build: boot from baked JSON (no server). Dev: the live API. The baked
// files sit alongside index.html, so RELATIVE urls resolve under the deploy path.
const api = (window.HEF_CONFIG && window.HEF_CONFIG.static)
? { clips: "clips.json", versions: "media-versions.json", ring: "ring.json" }
: { clips: "/api/clips", versions: "/api/media-versions", ring: "/api/ring" };
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 || {}; }
try { mediaVersions = (await (await fetch(api.versions)).json()).versions || {}; }
catch (_) { mediaVersions = {}; }
const r = await fetch("/api/ring");
const r = await fetch(api.ring);
serverRing = r.ok;
ring = r.ok ? await r.json() : null;
if (!ring && clips.length) {
@@ -87,16 +92,12 @@ async function loadData() {
async function pickRandomMember() {
const scale = ring && ring.scales[ringIndex];
if (!scale) return null;
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) return (await resp.json()).target_clip_id;
} catch (_) { /* fall through to the primary member */ }
}
return scale.clip_id;
// Uniform random pool member, client-side (mirrors hef.selection.pick_clip_id).
// The drag/scroll navigation already resolves picks + morphs client-side via
// scrub.js; this removes the last /api/ring/advance dependency so the build is
// fully static. The synthesized fallback ring has a pool of one → returns it.
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
}
// Land on the current scale: pick a random pool member and force its media to load.
@@ -118,9 +119,11 @@ let mediaVersions = {}; // file -> content-hash token (from /api/media-version
// 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 mediaBase() { return (window.HEF_CONFIG && window.HEF_CONFIG.mediaBase) || "/media/"; }
function mediaNetUrl(file) { const v = mediaVersions[file]; return mediaBase() + file + (v ? "?v=" + v : ""); }
function mediaUrl(file) {
if (file.startsWith("/media/")) return file; // already a resolved absolute url (audio layer)
// Already a resolved absolute url (audio layer, or R2 in static mode): pass through.
if (/^https?:\/\//.test(file) || file.startsWith("/media/")) return file;
return mediaBlobs[file] || mediaNetUrl(file);
}
@@ -238,42 +241,20 @@ function preloadChip() {
// to the loop's first frame, and the morph-covered intro never re-shows at rest.
const LOOP_TAIL_S = 3.0;
// The frame the current loop both LANDS on and WRAPS back to — stored per load so the
// wrap handlers honor a direction-aware landing (forward=LOOP_TAIL_S, reverse=0).
function loopStartFrame() {
const s = parseFloat(loopVid.dataset.loopStart);
return Number.isFinite(s) ? s : LOOP_TAIL_S;
}
// Load a clip's base into the LOOP element (#vid-loop), seeked to `landFrame` and PAUSED
// on that exact frame. `landFrame` is the frame the morph last showed for THIS landing:
// LOOP_TAIL_S when descending (lands on the morph's dst-tail), 0 when ascending (lands on
// the morph's src-head — fixes the D3 reverse-landing jump). Omitted → LOOP_TAIL_S, the
// forward default for non-scrub paths (initial load, knob changes). Idempotent per clip;
// an explicit `landFrame` re-seeks the already-loaded clip if the travel direction flipped
// the landing phase, but a bare call (ensureClipMedia) never clobbers a phase a scrub set.
// Called during a scrub to PRELOAD the clip we're about to land on, so settle just plays
// an already-decoded element at the right frame — no reload, no seek, no stall.
function loadLoop(clip, landFrame) {
if (!clip) return;
const seekTo = (t) => { try { loopVid.currentTime = t; } catch (e) { /* not seekable yet */ } };
if (loopVid.dataset.clip === clip.id) {
if (landFrame != null && loopVid.dataset.loopStart !== String(landFrame)) {
loopVid.dataset.loopStart = String(landFrame);
if (loopVid.readyState >= 1) seekTo(landFrame);
else loopVid.addEventListener("loadedmetadata", () => seekTo(landFrame), { once: true });
}
return;
}
const lf = landFrame == null ? LOOP_TAIL_S : landFrame;
// Load a clip's base into the LOOP element (#vid-loop), seeked to the tail and PAUSED
// on that exact frame. Idempotent per clip. Called during a scrub to PRELOAD the clip
// we're about to land on, so settle just plays an already-decoded element at the morph's
// last frame — no reload, no seek, no stall. `playLoop()` starts it at landing.
function loadLoop(clip) {
if (!clip || loopVid.dataset.clip === clip.id) return;
loopVid.dataset.clip = clip.id;
loopVid.dataset.loopTail = "1"; // arm the [loopStart, duration] wrap handler
loopVid.dataset.loopStart = String(lf);
loopVid.dataset.loopTail = "1"; // arm the [LOOP_TAIL_S, duration] wrap handler
loopVid.src = mediaUrl(clip.base_file);
loopVid.loop = false; // native loop restarts at 0; we loop from loopStart
loopVid.loop = false; // native loop restarts at 0; we loop from the tail instead
loopVid.muted = true;
if (loopVid.readyState >= 1) seekTo(lf);
else loopVid.addEventListener("loadedmetadata", () => seekTo(lf), { once: true });
const seek = () => { try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable yet */ } };
if (loopVid.readyState >= 1) seek();
else loopVid.addEventListener("loadedmetadata", seek, { once: true });
}
function playLoop() {
@@ -289,22 +270,21 @@ function ensureClipMedia() {
}
// The custom loop on the LOOP element: when a base loop reaches the end, jump back to
// its landing offset (LOOP_TAIL_S forward, 0 reverse — see loopStartFrame), not a fixed
// tail. The morph element (#vid) is driven directly by the scrub.
// the tail offset (not 0). The morph element (#vid) is driven directly by the scrub.
loopVid.addEventListener("timeupdate", () => {
if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) {
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ }
}
});
// Safety net: the timeupdate wrap above is a narrow window (the last 0.08 s). Under
// real-browser decode/GPU load a `timeupdate` can skip past it, the clip fires
// `ended`, and — with native loop off — freezes on its last frame ("not looping").
// `ended` GUARANTEES recovery: seek back to the landing offset and resume. Headless
// rarely hits this; loaded real machines do.
// `ended` GUARANTEES recovery: seek back to the tail and resume. Headless rarely
// hits this; loaded real machines do.
loopVid.addEventListener("ended", () => {
if (loopVid.dataset.loopTail !== "1") return;
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ }
loopVid.play().catch(() => {});
});
@@ -583,49 +563,6 @@ function renderOverlay(level, intensity) {
// knob no longer gates them). `intensity` is the layer opacity. Words are placed at authored
// scene points (no boxes — feelings are scene-level) and read softer than the
// clinical reticles — feeling, not measurement.
// --- Keep Feel (affect) words clear of Think (label) chips -------------------
// Feelings are scene-level (soft position), so we nudge THEM off the analytical
// chip plates rather than move a chip off its subject. Obstacles are computed
// from the manifest (not the DOM) so the clearance holds for the WHOLE loop: a
// tracked chip's drift is sampled, so the per-frame track re-render never slides
// a chip under an already-placed word. Mirrors chip()'s plate geometry.
const FS_CHIP = 2.4, PAD_CHIP = 0.6;
function chipPlateRect(bx, by, textLen) {
const w = textLen * FS_CHIP * 0.6 + PAD_CHIP * 2;
const h = FS_CHIP + PAD_CHIP * 1.4;
const cy = Math.max(by - h - 0.6, 0.4); // plate sits just above the box top
return { x: bx, y: cy, w, h };
}
function rectsOverlap(a, b, pad) {
pad = pad || 0;
return a.x < b.x + b.w + pad && a.x + a.w + pad > b.x &&
a.y < b.y + b.h + pad && a.y + a.h + pad > b.y;
}
// Plate rects for every left label visible at `level`; a tracked label's path is
// sampled across the loop so its whole drift envelope is treated as occupied.
function leftLabelPlates(clip, level) {
const out = [];
if (!clip || !clip.annotations || level <= 0) return out;
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
for (const a of clip.annotations) {
if (level < firstLevel(a)) continue;
const measure = a.key.startsWith("measure.");
const raw = strings[a.key];
const tier = measure ? 1 : clamp(level - firstLevel(a) + 1, 1, tierCount(raw));
const label = String(pickTier(raw !== undefined ? raw : a.key, tier) || "");
const len = label.length + (measure ? 0 : 5); // detections append " 0.xx"
const ts = (a.track && a.track.length) ? [0, 0.2, 0.4, 0.6, 0.8, 1] : [0];
for (const tt of ts) {
const b = boxAt(a, tt);
out.push(chipPlateRect(b[0] * 100, b[1] * 100, len));
}
}
// The global "◉ ANALYSIS · L… · … OBJ" status tag (top-right) shows whenever any
// label does — reserve its corner so feelings don't collide with it either.
if (out.length) out.push({ x: 64, y: 2, w: 34, h: 6 });
return out;
}
function renderAffect(strength, intensity, right) {
lastAffect = { strength, intensity, right };
const clip = activeClip();
@@ -634,36 +571,14 @@ function renderAffect(strength, intensity, right) {
if (!clip || !clip.affect || strength <= 0) { affectLayer.style.opacity = "0"; return; }
affectLayer.style.opacity = String(intensity);
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
// Chip plates to avoid (left labels visible at the current Left level), plus the
// words placed so far so feelings don't stack on each other either.
const obstacles = leftLabelPlates(clip, lastOverlay ? lastOverlay.level : 0);
const placed = [];
for (const f of clip.affect) {
if (f.min_level > strength) continue;
const [x, y0] = f.at.map((n) => n * 100);
const t = svg("text", { x, y: y0, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
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 is gated by the Right knob via `strength` above).
const raw = strings[f.key];
t.textContent = pickTier(raw !== undefined ? raw : f.key, clamp(right || 0, 1, tierCount(raw)));
// Nudge vertically off any chip plate / already-placed word, staying on-screen;
// keep the least-overlapping spot if nothing is fully clear.
const bb0 = t.getBBox();
// Diagnostic seam (sibling of window.__hefState): disable nudging to A/B the
// overlap-avoidance in tests.
const deconflict = !(typeof window !== "undefined" && window.__hefNoDeconflict);
if (deconflict && bb0.width) {
let bestY = y0, bestHits = Infinity;
for (const dy of [0, 4, -4, 8, -8, 12, -12, 16, -16, 20, -20]) {
const yy = clamp(y0 + dy, 4, 96);
const r = { x: bb0.x, y: bb0.y + (yy - y0), w: bb0.width, h: bb0.height };
const hits = obstacles.concat(placed).filter((o) => rectsOverlap(r, o, 0.4)).length;
if (hits < bestHits) { bestHits = hits; bestY = yy; if (hits === 0) break; }
}
if (bestY !== y0) t.setAttribute("y", bestY);
}
const bb = t.getBBox();
placed.push({ x: bb.x, y: bb.y, w: bb.width, h: bb.height });
}
}
@@ -702,12 +617,21 @@ function controls() {
let timer = null;
async function update() {
if (busy) return;
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
});
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
const data = await resp.json();
let data;
if (window.HEF_CONFIG && window.HEF_CONFIG.static) {
// Static build: compute the alteration locally (HEFAlteration is the JS port of
// player.alteration/audio). Audio couples to the current altitude's asset.
const scale = ring && ring.scales[ringIndex];
const scaleAudio = (scale && scale.audio) || "";
data = HEFAlteration.alteration(controls(), scaleAudio, mediaBase());
} else {
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
});
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
data = await resp.json();
}
readout.textContent = JSON.stringify(data, null, 2);
// Tolerate a stale server that still returns {content:{video}} instead of the
// {render:{video:{shown}}} (visual/audio-split) shape, so video works either way.
@@ -923,12 +847,7 @@ function setPos(next) {
busy = false;
delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base)
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
// Land the loop on the frame the morph last showed for THIS travel direction:
// descending lands on the morph's dst-tail (LOOP_TAIL_S), ascending on its src-head
// (0). Explicit (not relying on the scrub's preload) so a reverse landing never
// jumps even if the heading clip wasn't warmed in time. (D3 fix.)
loadLoop(activeClip(), HEFScrub.loopLandFrame(scrubDir, LOOP_TAIL_S));
playLoop(); // the loop element was preloaded to this clip during the scrub → instant, no reload
playLoop(); // the loop element was preloaded to this clip @ tail during the scrub → instant, no reload
showActiveSource();
setNeedle(ringIndex * dialStep());
renderScaleReadout();
@@ -939,11 +858,10 @@ function setPos(next) {
}
busy = true; // mid-morph: block update() from reloading the base clip under us
if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex);
// Preload the clip we're heading toward into the LOOP element (paused on its landing
// frame) so the landing is a swap, not a reload+seek. dir>0 lands on clipHi at the
// morph's dst-tail (LOOP_TAIL_S); dir<0 lands on clipLo at the morph's src-head (0).
// Preload the clip we're heading toward into the LOOP element (paused @ tail) so the
// landing is a swap, not a reload+seek. dir>0 lands on clipHi, dir<0 on clipLo.
const headingId = dir > 0 ? activeSeg.clipHi : activeSeg.clipLo;
loadLoop(clipsById[headingId], HEFScrub.loopLandFrame(dir, LOOP_TAIL_S));
loadLoop(clipsById[headingId]);
showActiveSource(); // morph element is the live source while scrubbing
setNeedle(pos * dialStep());
blendAudio(lo, frac);
@@ -1129,6 +1047,7 @@ async function reroll() {
// from the live API and masks it otherwise). Reloads on my edits AND on a server
// restart. Dev-only convenience; harmless if the endpoint is absent.
function devLiveReload() {
if (window.HEF_CONFIG && window.HEF_CONFIG.static) return; // no dev server in the static build
let seen = null;
setInterval(async () => {
try {
+4
View File
@@ -0,0 +1,4 @@
// Runtime config. This dev default serves media from the local FastAPI /media
// mount and uses the live API. The static build (tools/build_static.py) OVERWRITES
// this file in dist/ with { mediaBase: "https://static.benstull.art/", static: true }.
window.HEF_CONFIG = { mediaBase: "/media/", static: false };
+10 -8
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Alteration Simulator</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="loading">
@@ -17,10 +17,10 @@
<main>
<section class="stage" id="stage">
<div class="screen">
<video id="vid" loop muted playsinline></video>
<video id="vid-loop" loop muted playsinline></video>
<audio id="aud" loop preload="auto"></audio>
<audio id="aud-b" loop preload="auto"></audio>
<video id="vid" loop muted playsinline crossorigin="anonymous"></video>
<video id="vid-loop" loop muted playsinline crossorigin="anonymous"></video>
<audio id="aud" loop preload="auto" crossorigin="anonymous"></audio>
<audio id="aud-b" loop preload="auto" crossorigin="anonymous"></audio>
<canvas id="paint"></canvas>
<div id="tint"></div>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
@@ -107,8 +107,10 @@
</div>
</section>
</main>
<script src="/scrub.js"></script>
<script src="/i18n.js"></script>
<script src="/app.js"></script>
<script src="config.js"></script>
<script src="scrub.js"></script>
<script src="alteration.js"></script>
<script src="i18n.js"></script>
<script src="app.js"></script>
</body>
</html>
+1 -13
View File
@@ -29,18 +29,6 @@
return { from: 1 - f, to: f };
}
// The base-loop frame a landing must continue from so the steady loop picks up
// the EXACT frame the morph last showed — no jump. A morph spans src@0 (frac 0)
// -> dst@loopTailS (frac 1), scrubbed bidirectionally. Descending (dir>0) lands
// on dst at frac 1, whose frame is dst@loopTailS, so the loop resumes at loopTailS
// (and the morph-covered intro never re-shows). Ascending (dir<0) lands on src at
// frac 0, whose frame is src@0, so the loop must resume at 0 — seeking to loopTailS
// there is the ~3s reverse-landing jump (D3). dir 0 defaults to the forward/tail
// case (matches the loadLoop default used by non-scrub paths).
function loopLandFrame(dir, loopTailS) {
return dir < 0 ? 0 : loopTailS;
}
// Integers strictly crossed moving prevPos -> pos, in travel order. Landing
// exactly on an integer counts as crossing it (it commits that altitude).
function integerCrossings(prevPos, pos) {
@@ -53,5 +41,5 @@
return out;
}
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, integerCrossings };
});
+51
View File
@@ -0,0 +1,51 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const A = require("../static/alteration.js");
const c = (o) => ({ visual: "on", audio: "off", left: 0, right: 0, dark: 0, light: 0, ...o });
test("identity at zero knobs", () => {
const p = A.plan(c({}));
assert.equal(p.grade.tone, 0);
assert.equal(p.overlay.level, 0);
assert.equal(p.dream.strength, 0);
assert.equal(p.is_identity, true);
});
test("mood tone is signed (light - dark)/KNOB_MAX, clamped", () => {
assert.equal(A.plan(c({ light: 4 })).grade.tone, 1); // full light
assert.equal(A.plan(c({ dark: 4 })).grade.tone, -1); // full dark
assert.equal(A.plan(c({ light: 2 })).grade.tone, 0.5);
});
test("left drives overlay level + intensity; right drives dream + affect", () => {
const p = A.plan(c({ left: 2, right: 4 }));
assert.equal(p.overlay.level, 2);
assert.equal(p.overlay.intensity, 0.5);
assert.equal(p.dream.strength, 4);
assert.equal(p.dream.intensity, 1);
assert.equal(p.affect.strength, 4);
assert.equal(p.affect.intensity, 1);
assert.equal(p.is_identity, false);
});
test("renderAudio: off → null, no coupling", () => {
assert.deepEqual(A.renderAudio("off", "cosmos.mp3", "https://x/"),
{ source: "off", url: null, altitude_coupled: false });
});
test("renderAudio: soundtrack → mediaBase audio url, coupled", () => {
assert.deepEqual(A.renderAudio("soundtrack", "cosmos.mp3", "https://x/"),
{ source: "soundtrack", url: "https://x/audio/cosmos.mp3", altitude_coupled: true });
// no scale audio → null url but still coupled
assert.deepEqual(A.renderAudio("soundtrack", "", "https://x/"),
{ source: "soundtrack", url: null, altitude_coupled: true });
});
test("alteration() composes plan + render with video.shown from visual", () => {
const r = A.alteration(c({ visual: "off", audio: "soundtrack", left: 1 }), "reef.mp3", "https://x/");
assert.equal(r.render.video.shown, false);
assert.equal(r.render.audio.url, "https://x/audio/reef.mp3");
assert.equal(r.plan.overlay.level, 1);
});
+1 -1
View File
@@ -8,7 +8,7 @@ const I = require("../static/i18n.js");
const html = fs.readFileSync(path.join(__dirname, "../static/index.html"), "utf8");
test("index.html includes the i18n script and a language select", () => {
assert.match(html, /<script src="\/i18n\.js">/);
assert.match(html, /<script src="i18n\.js">/); // relative: works at dev-root and under the deploy path prefix
assert.match(html, /id="lang-select"/);
});
-14
View File
@@ -58,17 +58,3 @@ test("integerCrossings: multiple crossings in travel order", () => {
assert.deepEqual(S.integerCrossings(2.4, 4.1), [{ index: 3, dir: 1 }, { index: 4, dir: 1 }]);
assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]);
});
test("loopLandFrame: descend lands on the morph's tail frame, ascend on its head", () => {
// A morph spans src@0 (frac 0) -> dst@loopTailS (frac 1). Descending (dir>0) lands
// on dst at frac 1 -> the base loop must continue from loopTailS. Ascending (dir<0)
// lands on src at frac 0 -> the loop must continue from 0 (NOT loopTailS — that was
// the D3 reverse-landing jump).
assert.equal(S.loopLandFrame(1, 3), 3); // descend -> tail
assert.equal(S.loopLandFrame(-1, 3), 0); // ascend -> head
// dir 0 (no travel) is treated as a forward/tail landing (the default everywhere else).
assert.equal(S.loopLandFrame(0, 3), 3);
// honors any tail value
assert.equal(S.loopLandFrame(1, 2.5), 2.5);
assert.equal(S.loopLandFrame(-1, 2.5), 0);
});
+21
View File
@@ -0,0 +1,21 @@
"""The static build reimplements plan_alteration/resolve_audio in JS
(simulator/static/alteration.js). This pins the numeric contract so the Python
and JS cannot silently diverge — if you change the engine, change both (the
mirrored JS cases live in simulator/unit/alteration.test.js)."""
from hef.selection import Coordinate
from player.alteration import plan_alteration, render_plan_to_dict
from player.audio import resolve_audio, resolve_visual
def test_engine_contract_matches_js_expectations():
# mirrors the cases asserted in simulator/unit/alteration.test.js
p = render_plan_to_dict(plan_alteration(Coordinate(left=2, right=4, dark=0, light=0)))
assert p["overlay"] == {"level": 2, "intensity": 0.5}
assert p["dream"] == {"strength": 4, "intensity": 1.0}
assert p["affect"] == {"strength": 4, "intensity": 1.0}
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 4)))["grade"]["tone"] == 1.0
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 4, 0)))["grade"]["tone"] == -1.0
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 0)))["is_identity"] is True
assert resolve_visual("off") is False
a = resolve_audio("soundtrack", scale_audio="cosmos.mp3")
assert a.url == "/media/audio/cosmos.mp3" and a.altitude_coupled is True
+50
View File
@@ -0,0 +1,50 @@
import json
from tools.build_static import build_static
def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
out = tmp_path / "dist"
media = tmp_path / "media"
result = build_static(
out, media,
media_base="https://static.benstull.art/",
app_path="/human-experience-simulator",
)
app_dir = out / "human-experience-simulator"
# frontend present under the app path; dev/author surfaces excluded
assert (app_dir / "index.html").exists()
assert (app_dir / "app.js").exists()
assert (app_dir / "scrub.js").exists()
assert (app_dir / "alteration.js").exists()
assert (app_dir / "config.js").exists()
assert not (app_dir / "author.html").exists()
assert not list(app_dir.glob("review*.html"))
# apex + no-slash redirect rules
redirects = (out / "_redirects").read_text()
assert "/ /human-experience-simulator/ 308" in redirects
assert "/human-experience-simulator /human-experience-simulator/ 308" in redirects
# baked JSON matches the API shape
clips = json.loads((app_dir / "clips.json").read_text())
assert "clips" in clips and clips["clips"]
ring = json.loads((app_dir / "ring.json").read_text())
assert "scales" in ring and "transitions" in ring
assert all("audio" in s for s in ring["scales"]) # needed by client alteration
versions = json.loads((app_dir / "media-versions.json").read_text())
assert "versions" in versions
# config.js points at R2 + static mode
cfg = (app_dir / "config.js").read_text()
assert "https://static.benstull.art/" in cfg and "static: true" in cfg
# media tree holds ONLY referenced files — no masters/mezzanines
synced = {p.name for p in media.rglob("*.mp4")}
assert synced, "expected synced media"
assert not any(n in ("master.mp4", "mezzanine.mp4") for n in synced)
# every versioned file exists in the media tree
for f in versions["versions"]:
assert (media / f).exists(), f"missing synced media: {f}"
assert result["media_files"] == sum(1 for _ in media.rglob("*") if _.is_file())
+120
View File
@@ -0,0 +1,120 @@
"""Build the fully-static deployable for Cloudflare (Pages + R2).
Emits the Pages output tree and a separate R2 media sync tree:
out_dir/
_redirects apex + no-slash → /<app>/ (308)
<app>/ the app (served at benstull.art/<app>/)
index.html app.js scrub.js i18n.js alteration.js style.css
config.js GENERATED: { mediaBase, static: true }
clips.json ring.json media-versions.json baked API responses
media_out/ R2 upload tree — ONLY manifest-referenced files
<clip base.mp4 / transition morphs / audio>
The app lives under a path segment (so apex can redirect to it) and uses RELATIVE
asset urls, which resolve correctly under the `/<app>/` prefix. The baked JSON is
produced through the real app (TestClient) so it is byte-identical to the API. The
media tree contains ONLY referenced files — never the master/mezzanine pipeline
sources. Cloudflare-side steps (R2 sync, Pages deploy, CORS, redirect) live in
deploy/cloudflare/ — this script only produces artifacts.
Run as a MODULE so the repo root (not tools/) is on sys.path — otherwise the
sibling tools/http.py shadows the stdlib `http` that fastapi/starlette import:
.venv/bin/python -m tools.build_static
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from fastapi.testclient import TestClient
from simulator.app import MEDIA_DIR, create_app
STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css"]
def _bake_api(app_dir: Path) -> dict:
app = create_app()
client = TestClient(app)
clips = client.get("/api/clips").json()
ring = client.get("/api/ring").json()
versions = client.get("/api/media-versions").json()
(app_dir / "clips.json").write_text(json.dumps(clips, ensure_ascii=False))
(app_dir / "ring.json").write_text(json.dumps(ring, ensure_ascii=False))
(app_dir / "media-versions.json").write_text(json.dumps(versions, ensure_ascii=False))
return versions["versions"]
def _write_config(app_dir: Path, media_base: str) -> None:
(app_dir / "config.js").write_text(
"// GENERATED by tools/build_static.py — do not edit in dist/.\n"
f'window.HEF_CONFIG = {{ mediaBase: "{media_base}", static: true }};\n'
)
def _write_redirects(out: Path, seg: str) -> None:
# Apex → app path, and the no-trailing-slash form → slashed (so RELATIVE asset
# urls resolve under the prefix). Cloudflare Pages `_redirects` syntax.
(out / "_redirects").write_text(
f"/ /{seg}/ 308\n"
f"/{seg} /{seg}/ 308\n"
)
def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
out = Path(out_dir)
media = Path(media_out)
seg = app_path.strip("/")
app_dir = out / seg
for d in (out, media):
if d.exists():
shutil.rmtree(d)
app_dir.mkdir(parents=True)
media.mkdir(parents=True)
for name in PUBLIC_ASSETS:
src = STATIC / name
if src.exists():
shutil.copy2(src, app_dir / name)
versions = _bake_api(app_dir)
_write_config(app_dir, media_base)
_write_redirects(out, seg)
# Sync ONLY referenced media: `versions` covers clip bases + transition morphs;
# add each scale's audio explicitly. Never the master/mezzanine pipeline sources.
referenced = set(versions.keys())
ring = json.loads((app_dir / "ring.json").read_text())
for s in ring.get("scales", []):
if s.get("audio"):
referenced.add(f"audio/{s['audio']}")
n = 0
for rel in sorted(referenced):
src = MEDIA_DIR / rel
if not src.exists():
raise FileNotFoundError(f"referenced media missing on disk: {rel}")
dst = media / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
n += 1
return {"out": str(out), "app_dir": str(app_dir), "media": str(media),
"media_files": n, "app_path": app_path}
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="dist")
ap.add_argument("--media-out", default="dist-media")
ap.add_argument("--media-base", default="https://static.benstull.art/")
ap.add_argument("--app-path", default="/human-experience-simulator")
a = ap.parse_args()
r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path)
print(json.dumps(r, indent=2))
File diff suppressed because it is too large Load Diff
+15 -658
View File
@@ -36,12 +36,6 @@
"anhelo",
"añoranza",
"una añoranza punzante"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
]
},
"cosmos_galaxies": {
@@ -75,13 +69,6 @@
"anhelo",
"añoranza",
"una añoranza punzante"
],
"measure.count": "~2 billones de galaxias",
"feel.distance": [
"lejos",
"distancia",
"lejanía",
"una lejanía exquisita"
]
},
"cosmos_orion": {
@@ -121,19 +108,7 @@
"estrella del Trapecio",
"Trapecio · cálidas estrellas O que iluminan la nebulosa"
],
"measure.distance": "≈1.344 al",
"feel.tenderness": [
"suave",
"ternura",
"calidez",
"la calidez de una cuna"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
]
"measure.distance": "≈1.344 al"
},
"cosmos_tarantula": {
"feel.wonder": [
@@ -172,13 +147,7 @@
"R136",
"R136 · reúne algunas de las estrellas más masivas conocidas"
],
"measure.distance": "≈160.000 al",
"feel.turbulence": [
"agitación",
"turbulencia",
"fermento",
"un fermento violento"
]
"measure.distance": "≈160.000 al"
},
"cosmos_westerlund": {
"feel.wonder": [
@@ -217,19 +186,7 @@
"estrella O-type",
"O-type · blanca azulada, decenas de masas solares"
],
"measure.distance": "≈20.000 al",
"feel.radiance": [
"brillo",
"resplandor",
"fulgor",
"un fulgor de joyas"
],
"feel.exhilaration": [
"yupi",
"euforia",
"júbilo",
"un júbilo que se eleva"
]
"measure.distance": "≈20.000 al"
},
"cosmos_southernring": {
"feel.wonder": [
@@ -268,19 +225,7 @@
"enana blanca",
"enana blanca · el núcleo estelar caliente que queda"
],
"measure.distance": "≈2.500 al",
"feel.mortality": [
"fin",
"mortalidad",
"fugacidad",
"la lenta muerte de una estrella"
],
"feel.poignancy": [
"punzada",
"desgarro",
"melancolía dulce",
"una despedida luminosa"
]
"measure.distance": "≈2.500 al"
},
"cosmos_carina_eso": {
"feel.wonder": [
@@ -319,13 +264,7 @@
"estrella masiva",
"estrella masiva · la que esculpe los acantilados de Carina"
],
"measure.distance": "≈7.500 al",
"feel.abundance": [
"lleno",
"abundancia",
"riqueza",
"una riqueza rebosante"
]
"measure.distance": "≈7.500 al"
},
"orbit_planetearth": {
"detected.cloud_band": [
@@ -364,18 +303,6 @@
"distancia",
"lejanía",
"una lejanía exquisita"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.radiance": [
"brillo",
"resplandor",
"fulgor",
"un fulgor de joyas"
]
},
"orbit_bluemarble": {
@@ -385,18 +312,6 @@
"planeta terrestre",
"planeta terrestre · 12.742 km de diámetro"
],
"detected.cloud_band": [
"nubes",
"sistemas meteorológicos",
"sistemas nubosos",
"sistemas nubosos globales · las nubes cubren ~67% de la Tierra"
],
"detected.starfield": [
"estrellas",
"campo estelar",
"estrellas de fondo",
"campo estelar de fondo · un telón renderizado, sin escala"
],
"feel.serenity": [
"calma",
"serenidad",
@@ -420,12 +335,6 @@
"distancia",
"lejanía",
"una lejanía exquisita"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"orbit_aurora2025": {
@@ -465,25 +374,7 @@
"limbo atmosférico",
"limbo atmosférico · ~100 km de aire, brillando de canto"
],
"measure.altitude": "~408 km",
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
],
"feel.eeriness": [
"extraño",
"inquietud",
"una carga sobrenatural",
"un susurro eléctrico y sobrenatural"
]
"measure.altitude": "~408 km"
},
"orbit_citylights": {
"feel.serenity": [
@@ -522,19 +413,7 @@
"luminiscencia atmosférica",
"luminiscencia del aire · la débil emisión nocturna de la alta atmósfera"
],
"measure.altitude": "~408 km",
"feel.tenderness": [
"suave",
"ternura",
"calidez",
"la calidez de una cuna"
],
"feel.longing": [
"querer",
"anhelo",
"añoranza",
"una añoranza punzante"
]
"measure.altitude": "~408 km"
},
"orbit_helene": {
"feel.serenity": [
@@ -573,25 +452,7 @@
"limbo atmosférico",
"limbo atmosférico · la fina capa donde vive el clima"
],
"measure.altitude": "~408 km",
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
],
"feel.turbulence": [
"agitación",
"turbulencia",
"fermento",
"un fermento violento"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
"measure.altitude": "~408 km"
},
"orbit_epic": {
"feel.serenity": [
@@ -630,13 +491,7 @@
"sistemas meteorológicos",
"sistemas meteorológicos · arremolinándose sobre un planeta que gira"
],
"measure.distance": "~1,5 M km",
"feel.insignificance": [
"pequeño",
"pequeñez",
"insignificancia",
"una insignificancia que humilla"
]
"measure.distance": "~1,5 M km"
},
"sky_grca_templesa": {
"feel.exhilaration": [
@@ -674,30 +529,6 @@
"estratos de roca",
"lechos sedimentarios",
"estratos · épocas de sedimentación apiladas"
],
"detected.butte": [
"torre",
"cerro testigo",
"templo de roca",
"templo de roca · un cerro testigo de la erosión"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"sky_greenland_landice": {
@@ -736,30 +567,6 @@
"nevero",
"neviza",
"neviza · nieve vieja que se compacta hacia hielo glaciar"
],
"detected.crevasse": [
"grietas",
"grietas",
"grietas glaciares",
"grietas · el hielo fracturándose al fluir"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
],
"feel.purity": [
"puro",
"pureza",
"quietud",
"un silencio glacial"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
]
},
"sky_greenland_suture": {
@@ -798,30 +605,6 @@
"canal",
"canal abierto",
"canal · una fractura de agua abierta entre témpanos"
],
"detected.floe": [
"placas",
"témpanos",
"témpanos de banquisa",
"témpanos · placas de mar congelado a la deriva"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
],
"feel.solitude": [
"solo",
"soledad",
"aislamiento",
"una vasta soledad helada"
]
},
"sky_jungle_amazon": {
@@ -860,24 +643,6 @@
"árbol alto",
"árbol emergente",
"emergente · un gigante que rompe sobre el techo del dosel"
],
"detected.mist": [
"bruma",
"bruma del dosel",
"bruma de transpiración",
"bruma de transpiración · el bosque exhalando vapor"
],
"feel.verdancy": [
"frondoso",
"verdor",
"exuberancia",
"una exuberancia verde y rebosante"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
]
},
"sky_jungle_waterfall": {
@@ -916,24 +681,6 @@
"dosel de la jungla",
"dosel selvático",
"dosel · bosque denso que abarrota la garganta"
],
"detected.spray": [
"rocío",
"aerosol",
"rocío de caída",
"rocío de caída · el río pulverizado al impactar"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.freshness": [
"fresco",
"frescura",
"vitalidad",
"una frescura que cae en cascada"
]
},
"sky_coast_cliffspain": {
@@ -972,30 +719,6 @@
"rompiente",
"oleaje rompiente",
"oleaje rompiente · el océano que encuentra la piedra"
],
"detected.headland": [
"punta",
"promontorio",
"promontorio rocoso",
"promontorio · un brazo de tierra acantilado en el mar"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
],
"feel.turbulence": [
"agitación",
"turbulencia",
"fermento",
"un fermento violento"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"sky_mtn_castlecrags": {
@@ -1034,24 +757,6 @@
"bosque de coníferas",
"bosque montano",
"bosque montano · cubriendo las laderas bajo los riscos"
],
"detected.talus": [
"pedrera",
"talud",
"talud de derrubios",
"talud · roca rota por el hielo amontonada bajo los riscos"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"sky_mtn_rocky": {
@@ -1090,24 +795,6 @@
"tundra alpina",
"tundra alpina",
"tundra alpina · plantas en cojín bajas sobre los árboles"
],
"detected.snowfield": [
"nieve",
"nevero",
"nevero alpino",
"nevero alpino · nieve persistente de gran altitud"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"coast_birdrock": {
@@ -1146,24 +833,6 @@
"melancolía",
"anhelo",
"un suave anhelo hacia el mar"
],
"detected.seabirds": [
"aves",
"aves marinas",
"aves marinas anidando",
"colonia de aves marinas · el criadero que da nombre a la roca"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
]
},
"coast_surfgrass": {
@@ -1202,30 +871,6 @@
"melancolía",
"anhelo",
"un suave anhelo hacia el mar"
],
"detected.tidepool": [
"charca",
"poza de marea",
"poza intermareal",
"poza de marea · un mar en miniatura al bajar la marea"
],
"feel.abundance": [
"lleno",
"abundancia",
"riqueza",
"una riqueza rebosante"
],
"feel.immersion": [
"dentro",
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"feel.curiosity": [
"mira",
"curiosidad",
"fascinación",
"una fascinación absorta"
]
},
"coast_kelp": {
@@ -1264,36 +909,6 @@
"lámina",
"fronda de alga",
"fronda · flotadores llenos de gas la mantienen erguida"
],
"detected.pneumatocyst": [
"flotadores",
"vejigas de gas",
"neumatocistos",
"neumatocistos · flotadores de gas que alzan las hojas a la luz"
],
"feel.immersion": [
"dentro",
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
],
"feel.freedom": [
"libre",
"libertad",
"liberación",
"una liberación sin límites"
]
},
"coast_otters": {
@@ -1332,24 +947,6 @@
"estuario",
"estero de marea",
"estero de marea · aguas resguardadas de crianza"
],
"detected.fur": [
"pelaje",
"pelaje denso",
"el pelaje más denso",
"el pelaje más denso de la Tierra · ~1 M de pelos/in², sin grasa"
],
"feel.tenderness": [
"suave",
"ternura",
"calidez",
"la calidez de una cuna"
],
"feel.delight": [
"divertido",
"deleite",
"alegría",
"una alegría viva y centelleante"
]
},
"coast_kalaloch": {
@@ -1388,18 +985,6 @@
"farallón",
"farallón costero",
"farallón · roca residual tallada por las olas"
],
"detected.sunset": [
"resplandor",
"atardecer",
"hora dorada",
"hora dorada · el sol bajo enrojecido por más atmósfera"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
]
},
"coast_seals": {
@@ -1438,30 +1023,6 @@
"roca de descanso",
"roca intermareal",
"descansadero · una repisa de descanso bañada por la marea"
],
"detected.whiskers": [
"bigotes",
"vibrisas",
"bigotes sensores",
"vibrisas · bigotes que detectan presas en agua turbia"
],
"feel.tenderness": [
"suave",
"ternura",
"calidez",
"la calidez de una cuna"
],
"feel.repose": [
"descanso",
"reposo",
"sopor",
"un sopor entibiado por el sol"
],
"feel.delight": [
"divertido",
"deleite",
"alegría",
"una alegría viva y centelleante"
]
},
"coast_mist": {
@@ -1500,30 +1061,6 @@
"roca de orilla",
"roca costera",
"roca costera · el borde firme de la tierra"
],
"detected.swell": [
"olas",
"mar de fondo",
"oleaje de fondo",
"mar de fondo · olas formadas por tormentas lejanas"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.hush": [
"silencio",
"quietud",
"sosiego",
"un silencio contenido"
]
},
"reef_lionfish": {
@@ -1563,18 +1100,6 @@
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"feel.poise": [
"quieto",
"aplomo",
"gracia",
"un aplomo suspendido y ornado"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
]
},
"reef_spawning": {
@@ -1614,18 +1139,6 @@
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"feel.flow": [
"fluir",
"flujo",
"corriente",
"un flujo de cardumen que barre"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"reef_hawkfish": {
@@ -1659,18 +1172,6 @@
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"detected.coral": [
"coral",
"coral de arrecife",
"coral duro",
"coral duro · el arrecife que este pez muele en arena"
],
"feel.radiance": [
"brillo",
"resplandor",
"fulgor",
"un fulgor de joyas"
]
},
"reef_coralspacific": {
@@ -1709,24 +1210,6 @@
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"detected.polyp": [
"pólipos",
"pólipos de coral",
"pólipos vivos",
"pólipos · cada uno un animal diminuto, constructores de la colonia"
],
"feel.intricacy": [
"fino",
"complejidad",
"detalle",
"una densidad intrincada y tejida"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
]
},
"reef_redsea": {
@@ -1766,19 +1249,7 @@
"antias",
"antias · nubes naranjas de comeplancton sobre el arrecife"
],
"measure.depth": "10 m",
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
],
"feel.radiance": [
"brillo",
"resplandor",
"fulgor",
"un fulgor de joyas"
]
"measure.depth": "10 m"
},
"reef_flowergarden": {
"feel.delight": [
@@ -1817,13 +1288,7 @@
"Abudefduf saxatilis",
"Abudefduf · la damisela rayada “sargento mayor”"
],
"measure.depth": "20 m",
"feel.freedom": [
"libre",
"libertad",
"liberación",
"una liberación sin límites"
]
"measure.depth": "20 m"
},
"abyss_wow": {
"detected.jelly": [
@@ -1862,12 +1327,6 @@
"pavor",
"presagio",
"un presagio lento y frío"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"abyss_midwaterexp": {
@@ -1907,18 +1366,6 @@
"pavor",
"presagio",
"un presagio lento y frío"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
],
"feel.poignancy": [
"punzada",
"desgarro",
"melancolía dulce",
"una despedida luminosa"
]
},
"abyss_hiding": {
@@ -1952,24 +1399,6 @@
"pavor",
"presagio",
"un presagio lento y frío"
],
"detected.marinesnow": [
"motas",
"nieve marina",
"detrito que cae",
"nieve marina · restos orgánicos que descienden desde arriba"
],
"feel.spectral": [
"fantasma",
"lo espectral",
"lo fantasmal",
"un fantasma en el agua"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
]
},
"abyss_bigfin": {
@@ -2003,19 +1432,7 @@
"Magnapinna",
"Magnapinna · brazos acodados que se extienden metros en la oscuridad"
],
"measure.depth": "2.000 m",
"detected.arms": [
"brazos",
"brazos acodados",
"filamentos colgantes",
"brazos acodados · extendidos y luego colgando metros de hilo"
],
"feel.alienness": [
"raro",
"lo alienígena",
"extrañeza",
"una gracia alienígena"
]
"measure.depth": "2.000 m"
},
"abyss_dandelion": {
"feel.unease": [
@@ -2048,31 +1465,7 @@
"sifonóforo diente de león",
"Rhodaliidae · una colonia de clones anclada al lecho marino"
],
"measure.depth": "2.500 m",
"detected.tentacle": [
"hilos",
"tentáculos",
"tentáculos de caza",
"tentáculos de caza · una red a la deriva para atrapar presas"
],
"feel.radiance": [
"brillo",
"resplandor",
"fulgor",
"un fulgor de joyas"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
]
"measure.depth": "2.500 m"
},
"abyss_octopus": {
"feel.unease": [
@@ -2105,25 +1498,7 @@
"Graneledone",
"Graneledone boreopacifica · incuba sus huevos durante ~4,5 años"
],
"measure.depth": "2.500 m",
"detected.arms": [
"brazos",
"ocho brazos",
"brazos con ventosas",
"ocho brazos · con ventosas que saborean lo que tocan"
],
"feel.curiosity": [
"mira",
"curiosidad",
"fascinación",
"una fascinación absorta"
],
"feel.tenderness": [
"suave",
"ternura",
"calidez",
"la calidez de una cuna"
]
"measure.depth": "2.500 m"
},
"abyss_seapig": {
"feel.unease": [
@@ -2156,24 +1531,6 @@
"Enypniastes eximia",
"Enypniastes · un pepino de mar nadador, el “pollo sin cabeza”"
],
"measure.depth": "2.700 m",
"detected.veil": [
"velo",
"velo oral",
"velo natatorio",
"velo oral · barre el sedimento y aletea para nadar"
],
"feel.strangeness": [
"raro",
"rareza",
"lo inquietante",
"una rareza carnosa"
],
"feel.curiosity": [
"mira",
"curiosidad",
"fascinación",
"una fascinación absorta"
]
"measure.depth": "2.700 m"
}
}
+15 -658
View File
@@ -36,12 +36,6 @@
"désir",
"aspiration",
"une aspiration douloureuse"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
]
},
"cosmos_galaxies": {
@@ -75,13 +69,6 @@
"désir",
"aspiration",
"une aspiration douloureuse"
],
"measure.count": "~2 000 milliards de galaxies",
"feel.distance": [
"loin",
"distance",
"éloignement",
"un éloignement exquis"
]
},
"cosmos_orion": {
@@ -121,19 +108,7 @@
"étoile du Trapèze",
"Trapèze · étoiles O chaudes illuminant la nébuleuse"
],
"measure.distance": "≈1 344 al",
"feel.tenderness": [
"doux",
"tendresse",
"chaleur",
"la chaleur dun berceau"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
]
"measure.distance": "≈1 344 al"
},
"cosmos_tarantula": {
"feel.wonder": [
@@ -172,13 +147,7 @@
"R136",
"R136 · concentre certaines des étoiles les plus massives connues"
],
"measure.distance": "≈160 000 al",
"feel.turbulence": [
"remous",
"turbulence",
"ferment",
"un ferment violent"
]
"measure.distance": "≈160 000 al"
},
"cosmos_westerlund": {
"feel.wonder": [
@@ -217,19 +186,7 @@
"étoile de type O",
"O-type · blanc-bleu, des dizaines de masses solaires"
],
"measure.distance": "≈20 000 al",
"feel.radiance": [
"éclat",
"rayonnement",
"scintillement",
"un scintillement de joyaux"
],
"feel.exhilaration": [
"youpi",
"exaltation",
"euphorie",
"une euphorie qui s'envole"
]
"measure.distance": "≈20 000 al"
},
"cosmos_southernring": {
"feel.wonder": [
@@ -268,19 +225,7 @@
"naine blanche",
"naine blanche · le cœur stellaire chaud laissé derrière"
],
"measure.distance": "≈2 500 al",
"feel.mortality": [
"fin",
"mortalité",
"impermanence",
"la lente mort dune étoile"
],
"feel.poignancy": [
"pincement",
"émotion poignante",
"douceur amère",
"un adieu lumineux"
]
"measure.distance": "≈2 500 al"
},
"cosmos_carina_eso": {
"feel.wonder": [
@@ -319,13 +264,7 @@
"étoile massive",
"étoile massive · celles qui sculptent les falaises de la Carène"
],
"measure.distance": "≈7 500 al",
"feel.abundance": [
"plein",
"abondance",
"richesse",
"une richesse grouillante"
]
"measure.distance": "≈7 500 al"
},
"orbit_planetearth": {
"detected.cloud_band": [
@@ -364,18 +303,6 @@
"distance",
"éloignement",
"un éloignement exquis"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.radiance": [
"éclat",
"rayonnement",
"scintillement",
"un scintillement de joyaux"
]
},
"orbit_bluemarble": {
@@ -385,18 +312,6 @@
"planète tellurique",
"planète tellurique · 12 742 km de diamètre"
],
"detected.cloud_band": [
"nuages",
"systèmes météo",
"systèmes nuageux",
"systèmes nuageux planétaires · les nuages couvrent ~67% de la Terre"
],
"detected.starfield": [
"étoiles",
"champ d’étoiles",
"étoiles darrière-plan",
"champ d’étoiles de fond · un décor rendu, hors échelle"
],
"feel.serenity": [
"calme",
"sérénité",
@@ -420,12 +335,6 @@
"distance",
"éloignement",
"un éloignement exquis"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"orbit_aurora2025": {
@@ -465,25 +374,7 @@
"limbe atmosphérique",
"limbe atmosphérique · ~100 km d'air, brillant sur la tranche"
],
"measure.altitude": "~408 km",
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
],
"feel.eeriness": [
"étrange",
"étrangeté",
"une charge surnaturelle",
"un frisson électrique et surnaturel"
]
"measure.altitude": "~408 km"
},
"orbit_citylights": {
"feel.serenity": [
@@ -522,19 +413,7 @@
"luminescence atmosphérique",
"luminescence atmosphérique · la faible émission nocturne de la haute atmosphère"
],
"measure.altitude": "~408 km",
"feel.tenderness": [
"doux",
"tendresse",
"chaleur",
"la chaleur dun berceau"
],
"feel.longing": [
"envie",
"désir",
"aspiration",
"une aspiration douloureuse"
]
"measure.altitude": "~408 km"
},
"orbit_helene": {
"feel.serenity": [
@@ -573,25 +452,7 @@
"limbe atmosphérique",
"limbe atmosphérique · la fine pellicule où vit la météo"
],
"measure.altitude": "~408 km",
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
],
"feel.turbulence": [
"remous",
"turbulence",
"ferment",
"un ferment violent"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
"measure.altitude": "~408 km"
},
"orbit_epic": {
"feel.serenity": [
@@ -630,13 +491,7 @@
"systèmes météorologiques",
"systèmes météorologiques · tourbillonnant sur une planète qui tourne"
],
"measure.distance": "~1,5 M km",
"feel.insignificance": [
"petit",
"petitesse",
"insignifiance",
"une insignifiance qui rend humble"
]
"measure.distance": "~1,5 M km"
},
"sky_grca_templesa": {
"feel.exhilaration": [
@@ -674,30 +529,6 @@
"strates rocheuses",
"bancs sédimentaires",
"strates · des époques de dépôt empilées"
],
"detected.butte": [
"tour",
"butte-témoin",
"temple rocheux",
"temple rocheux · une butte-témoin laissée par l’érosion"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"sky_greenland_landice": {
@@ -736,30 +567,6 @@
"champ de neige",
"névé",
"névé · vieille neige se compactant en glace glaciaire"
],
"detected.crevasse": [
"fissures",
"crevasses",
"crevasses glaciaires",
"crevasses · la glace se fracture en s’écoulant"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
],
"feel.purity": [
"pur",
"pureté",
"quiétude",
"un silence glaciaire"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
]
},
"sky_greenland_suture": {
@@ -798,30 +605,6 @@
"chenal",
"chenal libre",
"chenal · une fracture d'eau libre entre les floes"
],
"detected.floe": [
"plaques",
"floes",
"floes de banquise",
"floes · plaques de mer gelée à la dérive"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
],
"feel.solitude": [
"seul",
"solitude",
"isolement",
"une vaste solitude gelée"
]
},
"sky_jungle_amazon": {
@@ -860,24 +643,6 @@
"grand arbre",
"arbre émergent",
"émergent · un géant perçant au-dessus de la canopée"
],
"detected.mist": [
"brume",
"brume de canopée",
"brume de transpiration",
"brume de transpiration · la forêt exhalant sa vapeur"
],
"feel.verdancy": [
"luxuriant",
"verdure",
"abondance",
"une abondance verte et grouillante"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
]
},
"sky_jungle_waterfall": {
@@ -916,24 +681,6 @@
"canopée de jungle",
"canopée de forêt tropicale",
"canopée · forêt dense pressée autour de la gorge"
],
"detected.spray": [
"embruns",
"embruns",
"embruns de chute",
"embruns de chute · la rivière pulvérisée à limpact"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.freshness": [
"frais",
"fraîcheur",
"vitalité",
"une fraîcheur en cascade"
]
},
"sky_coast_cliffspain": {
@@ -972,30 +719,6 @@
"ressac",
"houle déferlante",
"houle déferlante · l'océan à la rencontre de la pierre"
],
"detected.headland": [
"pointe",
"promontoire",
"promontoire rocheux",
"promontoire · un bras de terre escarpé dans la mer"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
],
"feel.turbulence": [
"remous",
"turbulence",
"ferment",
"un ferment violent"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"sky_mtn_castlecrags": {
@@ -1034,24 +757,6 @@
"forêt de conifères",
"forêt montagnarde",
"forêt montagnarde · habillant les pentes sous les pics"
],
"detected.talus": [
"éboulis",
"talus",
"talus d’éboulis",
"éboulis · roches gélifractées entassées sous les aiguilles"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"sky_mtn_rocky": {
@@ -1090,24 +795,6 @@
"toundra alpine",
"toundra alpine",
"toundra alpine · plantes en coussin basses au-dessus des arbres"
],
"detected.snowfield": [
"neige",
"névé",
"névé alpin",
"névé alpin · neige persistante de haute altitude"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"coast_birdrock": {
@@ -1146,24 +833,6 @@
"mélancolie",
"langueur",
"une douce langueur tournée vers le large"
],
"detected.seabirds": [
"oiseaux",
"oiseaux marins",
"oiseaux marins nicheurs",
"colonie doiseaux marins · la rookerie qui nomme le rocher"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
]
},
"coast_surfgrass": {
@@ -1202,30 +871,6 @@
"mélancolie",
"langueur",
"une douce langueur tournée vers le large"
],
"detected.tidepool": [
"mare",
"cuvette de marée",
"mare intertidale",
"cuvette de marée · une mer de poche découverte à marée basse"
],
"feel.abundance": [
"plein",
"abondance",
"richesse",
"une richesse grouillante"
],
"feel.immersion": [
"dedans",
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"feel.curiosity": [
"regarde",
"curiosité",
"fascination",
"une fascination absorbée"
]
},
"coast_kelp": {
@@ -1264,36 +909,6 @@
"lame",
"fronde de varech",
"fronde · des flotteurs remplis de gaz la maintiennent droite"
],
"detected.pneumatocyst": [
"flotteurs",
"vésicules à gaz",
"pneumatocystes",
"pneumatocystes · des flotteurs à gaz qui hissent les lames vers la lumière"
],
"feel.immersion": [
"dedans",
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
],
"feel.freedom": [
"libre",
"liberté",
"délivrance",
"une délivrance sans limites"
]
},
"coast_otters": {
@@ -1332,24 +947,6 @@
"estuaire",
"chenal de marée",
"chenal de marée · eaux de nourricerie abritées"
],
"detected.fur": [
"fourrure",
"fourrure dense",
"la fourrure la plus dense",
"la fourrure la plus dense au monde · ~1 M de poils/in², sans graisse"
],
"feel.tenderness": [
"doux",
"tendresse",
"chaleur",
"la chaleur dun berceau"
],
"feel.delight": [
"amusant",
"ravissement",
"joie",
"une joie vive et frétillante"
]
},
"coast_kalaloch": {
@@ -1388,18 +985,6 @@
"aiguille marine",
"aiguille côtière",
"aiguille marine · roche résiduelle taillée par les vagues"
],
"detected.sunset": [
"lueur",
"coucher de soleil",
"heure dorée",
"heure dorée · le soleil bas rougi par plus datmosphère"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
]
},
"coast_seals": {
@@ -1438,30 +1023,6 @@
"rocher reposoir",
"rocher intertidal",
"reposoir · une corniche de repos balayée par la marée"
],
"detected.whiskers": [
"moustaches",
"vibrisses",
"moustaches sensorielles",
"vibrisses · des moustaches qui sentent les proies en eau trouble"
],
"feel.tenderness": [
"doux",
"tendresse",
"chaleur",
"la chaleur dun berceau"
],
"feel.repose": [
"repos",
"quiétude",
"somnolence",
"une somnolence réchauffée de soleil"
],
"feel.delight": [
"amusant",
"ravissement",
"joie",
"une joie vive et frétillante"
]
},
"coast_mist": {
@@ -1500,30 +1061,6 @@
"rocher de rivage",
"rocher côtier",
"rocher côtier · le bord dressé de la terre"
],
"detected.swell": [
"vagues",
"houle",
"houle océanique",
"houle · des vagues nées de tempêtes lointaines"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.hush": [
"silence",
"calme",
"quiétude",
"un silence retenu"
]
},
"reef_lionfish": {
@@ -1563,18 +1100,6 @@
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"feel.poise": [
"immobile",
"aplomb",
"grâce",
"un aplomb suspendu et ouvragé"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
]
},
"reef_spawning": {
@@ -1614,18 +1139,6 @@
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"feel.flow": [
"aller",
"flux",
"courant",
"un flux de banc qui ondule"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"reef_hawkfish": {
@@ -1659,18 +1172,6 @@
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"detected.coral": [
"corail",
"corail récifal",
"corail dur",
"corail dur · le récif que ce poisson broie en sable"
],
"feel.radiance": [
"éclat",
"rayonnement",
"scintillement",
"un scintillement de joyaux"
]
},
"reef_coralspacific": {
@@ -1709,24 +1210,6 @@
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"detected.polyp": [
"polypes",
"polypes coralliens",
"polypes vivants",
"polypes · chacun un animal minuscule, bâtisseurs de la colonie"
],
"feel.intricacy": [
"fin",
"complexité",
"détail",
"une densité tissée et complexe"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
]
},
"reef_redsea": {
@@ -1766,19 +1249,7 @@
"anthias",
"anthias · nuages orange de mangeurs de plancton au-dessus du récif"
],
"measure.depth": "10 m",
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
],
"feel.radiance": [
"éclat",
"rayonnement",
"scintillement",
"un scintillement de joyaux"
]
"measure.depth": "10 m"
},
"reef_flowergarden": {
"feel.delight": [
@@ -1817,13 +1288,7 @@
"Abudefduf saxatilis",
"Abudefduf · la demoiselle rayée “sergent-major”"
],
"measure.depth": "20 m",
"feel.freedom": [
"libre",
"liberté",
"délivrance",
"une délivrance sans limites"
]
"measure.depth": "20 m"
},
"abyss_wow": {
"detected.jelly": [
@@ -1862,12 +1327,6 @@
"effroi",
"pressentiment",
"un pressentiment lent et glacé"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"abyss_midwaterexp": {
@@ -1907,18 +1366,6 @@
"effroi",
"pressentiment",
"un pressentiment lent et glacé"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
],
"feel.poignancy": [
"pincement",
"émotion poignante",
"douceur amère",
"un adieu lumineux"
]
},
"abyss_hiding": {
@@ -1952,24 +1399,6 @@
"effroi",
"pressentiment",
"un pressentiment lent et glacé"
],
"detected.marinesnow": [
"points",
"neige marine",
"détritus tombants",
"neige marine · débris organiques descendant des couches supérieures"
],
"feel.spectral": [
"fantôme",
"le spectral",
"hantise",
"un fantôme dans leau"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
]
},
"abyss_bigfin": {
@@ -2003,19 +1432,7 @@
"Magnapinna",
"Magnapinna · des bras coudés traînant sur des mètres dans l'obscurité"
],
"measure.depth": "2 000 m",
"detected.arms": [
"bras",
"bras coudés",
"filaments traînants",
"bras coudés · tendus puis traînant des mètres de fil"
],
"feel.alienness": [
"bizarre",
"l’étrangeté",
"altérité",
"une grâce extraterrestre"
]
"measure.depth": "2 000 m"
},
"abyss_dandelion": {
"feel.unease": [
@@ -2048,31 +1465,7 @@
"siphonophore pissenlit",
"Rhodaliidae · une colonie de clones amarrée au fond marin"
],
"measure.depth": "2 500 m",
"detected.tentacle": [
"fils",
"tentacules",
"tentacules de capture",
"tentacules de capture · un filet dérivant pour les proies"
],
"feel.radiance": [
"éclat",
"rayonnement",
"scintillement",
"un scintillement de joyaux"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
]
"measure.depth": "2 500 m"
},
"abyss_octopus": {
"feel.unease": [
@@ -2105,25 +1498,7 @@
"Graneledone",
"Graneledone boreopacifica · couve ses œufs pendant ~4,5 ans"
],
"measure.depth": "2 500 m",
"detected.arms": [
"bras",
"huit bras",
"bras à ventouses",
"huit bras · garnis de ventouses qui goûtent ce quelles touchent"
],
"feel.curiosity": [
"regarde",
"curiosité",
"fascination",
"une fascination absorbée"
],
"feel.tenderness": [
"doux",
"tendresse",
"chaleur",
"la chaleur dun berceau"
]
"measure.depth": "2 500 m"
},
"abyss_seapig": {
"feel.unease": [
@@ -2156,24 +1531,6 @@
"Enypniastes eximia",
"Enypniastes · un concombre de mer nageur, le “poulet sans tête”"
],
"measure.depth": "2 700 m",
"detected.veil": [
"voile",
"voile oral",
"voile natatoire",
"voile oral · balaie le sédiment et bat pour nager"
],
"feel.strangeness": [
"étrange",
"bizarrerie",
"linquiétant",
"une étrangeté charnue"
],
"feel.curiosity": [
"regarde",
"curiosité",
"fascination",
"une fascination absorbée"
]
"measure.depth": "2 700 m"
}
}
+15 -658
View File
@@ -36,12 +36,6 @@
"憧れ",
"切望",
"胸を締めつける切望"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
]
},
"cosmos_galaxies": {
@@ -75,13 +69,6 @@
"憧れ",
"切望",
"胸を締めつける切望"
],
"measure.count": "約2兆個の銀河",
"feel.distance": [
"遠い",
"隔たり",
"遥かさ",
"この上なく美しい遥かさ"
]
},
"cosmos_orion": {
@@ -121,19 +108,7 @@
"Trapezium星",
"Trapezium · 星雲を照らす高温のO-type星"
],
"measure.distance": "≈1,344 光年",
"feel.tenderness": [
"やさしい",
"優しさ",
"ぬくもり",
"ゆりかごのようなぬくもり"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
]
"measure.distance": "≈1,344 光年"
},
"cosmos_tarantula": {
"feel.wonder": [
@@ -172,13 +147,7 @@
"R136",
"R136 · 知られている中で最も重い部類の星々を抱える"
],
"measure.distance": "≈160,000 光年",
"feel.turbulence": [
"渦巻き",
"乱流",
"胎動",
"激しい胎動"
]
"measure.distance": "≈160,000 光年"
},
"cosmos_westerlund": {
"feel.wonder": [
@@ -217,19 +186,7 @@
"O-type星",
"O-type · 青白い、太陽の数十倍の質量"
],
"measure.distance": "≈20,000 光年",
"feel.radiance": [
"輝き",
"光輝",
"煌めき",
"宝石のような煌めき"
],
"feel.exhilaration": [
"わーい",
"高揚",
"歓喜",
"舞い上がる歓喜"
]
"measure.distance": "≈20,000 光年"
},
"cosmos_southernring": {
"feel.wonder": [
@@ -268,19 +225,7 @@
"白色矮星",
"白色矮星 · 残された高温の星の中心核"
],
"measure.distance": "≈2,500 光年",
"feel.mortality": [
"終わり",
"死",
"無常",
"星のゆるやかな死"
],
"feel.poignancy": [
"切なさ",
"哀切",
"ほろ苦さ",
"光に満ちた別れ"
]
"measure.distance": "≈2,500 光年"
},
"cosmos_carina_eso": {
"feel.wonder": [
@@ -319,13 +264,7 @@
"重い星",
"重い星 · カリーナの崖を彫り出すたぐいの星"
],
"measure.distance": "≈7,500 光年",
"feel.abundance": [
"いっぱい",
"豊かさ",
"充溢",
"あふれんばかりの充溢"
]
"measure.distance": "≈7,500 光年"
},
"orbit_planetearth": {
"detected.cloud_band": [
@@ -364,18 +303,6 @@
"隔たり",
"遥かさ",
"この上なく美しい遥かさ"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.radiance": [
"輝き",
"光輝",
"煌めき",
"宝石のような煌めき"
]
},
"orbit_bluemarble": {
@@ -385,18 +312,6 @@
"岩石惑星",
"岩石惑星 · 直径12,742 km"
],
"detected.cloud_band": [
"雲",
"気象システム",
"雲システム",
"地球規模の雲システム · 雲は地表の約67%を覆う"
],
"detected.starfield": [
"星",
"星野",
"背景の星",
"背景の星野 · 描画された背景で実寸ではない"
],
"feel.serenity": [
"穏やか",
"静けさ",
@@ -420,12 +335,6 @@
"隔たり",
"遥かさ",
"この上なく美しい遥かさ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"orbit_aurora2025": {
@@ -465,25 +374,7 @@
"大気の縁",
"大気の縁 · 約100 kmの大気層が縁で光る"
],
"measure.altitude": "約408 km",
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
],
"feel.eeriness": [
"異様",
"不気味さ",
"異世界の気配",
"電気を帯びた異世界の静けさ"
]
"measure.altitude": "約408 km"
},
"orbit_citylights": {
"feel.serenity": [
@@ -522,19 +413,7 @@
"超高層の大気光",
"大気光 · 高層大気がかすかに放つ夜間の光"
],
"measure.altitude": "約408 km",
"feel.tenderness": [
"やさしい",
"優しさ",
"ぬくもり",
"ゆりかごのようなぬくもり"
],
"feel.longing": [
"欲しい",
"憧れ",
"切望",
"胸を締めつける切望"
]
"measure.altitude": "約408 km"
},
"orbit_helene": {
"feel.serenity": [
@@ -573,25 +452,7 @@
"大気の縁",
"大気の縁 · 気象が宿る薄い層"
],
"measure.altitude": "約408 km",
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
],
"feel.turbulence": [
"渦巻き",
"乱流",
"胎動",
"激しい胎動"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
"measure.altitude": "約408 km"
},
"orbit_epic": {
"feel.serenity": [
@@ -630,13 +491,7 @@
"気象系",
"気象系 · 回転する惑星の上を渦巻く"
],
"measure.distance": "約150万 km",
"feel.insignificance": [
"小さい",
"小ささ",
"ちっぽけさ",
"謙虚にさせるちっぽけさ"
]
"measure.distance": "約150万 km"
},
"sky_grca_templesa": {
"feel.exhilaration": [
@@ -674,30 +529,6 @@
"岩層",
"堆積層",
"地層 · 積み重なった堆積の時代"
],
"detected.butte": [
"塔",
"残丘",
"岩の神殿",
"岩の‘神殿’ · 浸食に取り残された残丘"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"sky_greenland_landice": {
@@ -736,30 +567,6 @@
"雪原",
"フィルン",
"フィルン · 氷河の氷へと締まっていく古い雪"
],
"detected.crevasse": [
"亀裂",
"クレバス",
"氷河の裂け目",
"クレバス · 流れる氷が割れてできた裂け目"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
],
"feel.purity": [
"清らか",
"純粋さ",
"静寂",
"氷河のような静寂"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
]
},
"sky_greenland_suture": {
@@ -798,30 +605,6 @@
"水路",
"リード",
"リード · 氷盤の間に開いた水の裂け目"
],
"detected.floe": [
"板状氷",
"氷盤",
"流氷の氷盤",
"氷盤 · 漂う凍った海の板"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
],
"feel.solitude": [
"ひとり",
"孤独",
"隔絶",
"凍てつく広大な孤独"
]
},
"sky_jungle_amazon": {
@@ -860,24 +643,6 @@
"高い木",
"突出木",
"突出木 · 樹冠の天井を突き抜ける巨木"
],
"detected.mist": [
"もや",
"林冠のもや",
"蒸散のもや",
"蒸散のもや · 森が吐き出す水蒸気"
],
"feel.verdancy": [
"緑豊か",
"青々しさ",
"繁茂",
"生い茂る緑の豊かさ"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
]
},
"sky_jungle_waterfall": {
@@ -916,24 +681,6 @@
"ジャングルの樹冠",
"熱帯雨林の樹冠",
"樹冠 · 峡谷に密集する濃い森"
],
"detected.spray": [
"水しぶき",
"しぶき",
"落下のしぶき",
"落下のしぶき · 衝突で霧化した川"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.freshness": [
"涼しい",
"清々しさ",
"生命力",
"滝のように降りそそぐ清涼"
]
},
"sky_coast_cliffspain": {
@@ -972,30 +719,6 @@
"打ち寄せる波",
"砕けるうねり",
"砕けるうねり · 海が岩に出会う"
],
"detected.headland": [
"岬",
"岬",
"岩の岬",
"岬 · 海へ突き出す断崖の地"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
],
"feel.turbulence": [
"渦巻き",
"乱流",
"胎動",
"激しい胎動"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"sky_mtn_castlecrags": {
@@ -1034,24 +757,6 @@
"針葉樹林",
"山地林",
"山地林 · 岩峰の下の斜面を覆う"
],
"detected.talus": [
"岩屑",
"タルス",
"岩屑の斜面",
"タルス · 凍結破砕した岩が岩峰の下に堆積"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"sky_mtn_rocky": {
@@ -1090,24 +795,6 @@
"ツンドラ",
"高山ツンドラ",
"高山ツンドラ · 樹林の上に育つ低いクッション状の植物"
],
"detected.snowfield": [
"雪",
"雪原",
"高山の雪原",
"高山の雪原 · 標高の高い場所に残る雪"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"coast_birdrock": {
@@ -1146,24 +833,6 @@
"憂い",
"恋しさ",
"海へと向かう柔らかな恋しさ"
],
"detected.seabirds": [
"鳥",
"海鳥",
"営巣する海鳥",
"海鳥のコロニー · 岩の名の由来となる集団繁殖地"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
]
},
"coast_surfgrass": {
@@ -1202,30 +871,6 @@
"憂い",
"恋しさ",
"海へと向かう柔らかな恋しさ"
],
"detected.tidepool": [
"潮だまり",
"タイドプール",
"潮間帯の池",
"潮だまり · 干潮で現れる小さな海"
],
"feel.abundance": [
"いっぱい",
"豊かさ",
"充溢",
"あふれんばかりの充溢"
],
"feel.immersion": [
"中へ",
"没入",
"没頭",
"息を呑む没頭"
],
"feel.curiosity": [
"見て",
"好奇心",
"魅了",
"のめり込む魅了"
]
},
"coast_kelp": {
@@ -1264,36 +909,6 @@
"葉状体",
"ケルプの葉",
"葉状部 · ガスを含んだ浮き袋が直立させる"
],
"detected.pneumatocyst": [
"浮き",
"気胞",
"気胞(ニューマトシスト)",
"気胞 · 葉を光へ持ち上げるガスの浮き"
],
"feel.immersion": [
"中へ",
"没入",
"没頭",
"息を呑む没頭"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
],
"feel.freedom": [
"自由",
"自由さ",
"解放",
"果てしない解放"
]
},
"coast_otters": {
@@ -1332,24 +947,6 @@
"河口",
"潮汐の入り江",
"潮の入り江 · 守られた育成の水域"
],
"detected.fur": [
"毛皮",
"密な毛皮",
"世界一密な毛皮",
"世界一密な毛皮 · 約100万本/in²、脂肪層なし"
],
"feel.tenderness": [
"やさしい",
"優しさ",
"ぬくもり",
"ゆりかごのようなぬくもり"
],
"feel.delight": [
"楽しい",
"喜び",
"歓び",
"ちらちらと輝く歓び"
]
},
"coast_kalaloch": {
@@ -1388,18 +985,6 @@
"海食柱",
"海岸の海食柱",
"海食柱 · 波に削り残された岩"
],
"detected.sunset": [
"輝き",
"夕焼け",
"ゴールデンアワー",
"ゴールデンアワー · 大気を長く通り赤らむ低い太陽"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
]
},
"coast_seals": {
@@ -1438,30 +1023,6 @@
"上陸岩",
"潮間帯の岩",
"上陸地 · 潮に洗われる休息の岩棚"
],
"detected.whiskers": [
"ひげ",
"触毛",
"感覚のひげ",
"触毛 · 濁った水中で獲物を感じ取るひげ"
],
"feel.tenderness": [
"やさしい",
"優しさ",
"ぬくもり",
"ゆりかごのようなぬくもり"
],
"feel.repose": [
"休息",
"安らぎ",
"まどろみ",
"陽だまりのまどろみ"
],
"feel.delight": [
"楽しい",
"喜び",
"歓び",
"ちらちらと輝く歓び"
]
},
"coast_mist": {
@@ -1500,30 +1061,6 @@
"岸の岩",
"海岸の岩",
"海岸の岩 · 陸の立つ縁"
],
"detected.swell": [
"波",
"うねり",
"海のうねり",
"うねり · 遠くの嵐が生んだ波"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.hush": [
"静か",
"静けさ",
"沈黙",
"息をひそめた静けさ"
]
},
"reef_lionfish": {
@@ -1563,18 +1100,6 @@
"没入",
"没頭",
"息を呑む没頭"
],
"feel.poise": [
"静止",
"気品",
"優雅",
"宙に浮く華麗な気品"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
]
},
"reef_spawning": {
@@ -1614,18 +1139,6 @@
"没入",
"没頭",
"息を呑む没頭"
],
"feel.flow": [
"流れ",
"フロー",
"奔流",
"群れが織りなすうねり"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"reef_hawkfish": {
@@ -1659,18 +1172,6 @@
"没入",
"没頭",
"息を呑む没頭"
],
"detected.coral": [
"サンゴ",
"礁サンゴ",
"造礁サンゴ",
"造礁サンゴ · この魚が削って砂にする礁"
],
"feel.radiance": [
"輝き",
"光輝",
"煌めき",
"宝石のような煌めき"
]
},
"reef_coralspacific": {
@@ -1709,24 +1210,6 @@
"没入",
"没頭",
"息を呑む没頭"
],
"detected.polyp": [
"ポリプ",
"サンゴのポリプ",
"生きたポリプ",
"ポリプ · 一つ一つが小さな動物、群体の建設者"
],
"feel.intricacy": [
"精緻",
"複雑さ",
"細部",
"織り込まれた緻密さ"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
]
},
"reef_redsea": {
@@ -1766,19 +1249,7 @@
"ハナダイ",
"ハナダイ · 礁の上を漂う、プランクトンを食むオレンジの群れ"
],
"measure.depth": "10 m",
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
],
"feel.radiance": [
"輝き",
"光輝",
"煌めき",
"宝石のような煌めき"
]
"measure.depth": "10 m"
},
"reef_flowergarden": {
"feel.delight": [
@@ -1817,13 +1288,7 @@
"Abudefduf saxatilis",
"Abudefduf · 縞模様の「sergeant major」スズメダイ"
],
"measure.depth": "20 m",
"feel.freedom": [
"自由",
"自由さ",
"解放",
"果てしない解放"
]
"measure.depth": "20 m"
},
"abyss_wow": {
"detected.jelly": [
@@ -1862,12 +1327,6 @@
"恐れ",
"胸騒ぎ",
"ゆっくりと冷たい胸騒ぎ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"abyss_midwaterexp": {
@@ -1907,18 +1366,6 @@
"恐れ",
"胸騒ぎ",
"ゆっくりと冷たい胸騒ぎ"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
],
"feel.poignancy": [
"切なさ",
"哀切",
"ほろ苦さ",
"光に満ちた別れ"
]
},
"abyss_hiding": {
@@ -1952,24 +1399,6 @@
"恐れ",
"胸騒ぎ",
"ゆっくりと冷たい胸騒ぎ"
],
"detected.marinesnow": [
"粒子",
"マリンスノー",
"落下する有機物",
"マリンスノー · 上層から沈む有機物の屑"
],
"feel.spectral": [
"亡霊",
"幽玄",
"亡霊めいた気配",
"水中の亡霊"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
]
},
"abyss_bigfin": {
@@ -2003,19 +1432,7 @@
"Magnapinna",
"Magnapinna · 肘のように曲がる腕を闇へ何メートルも垂らす"
],
"measure.depth": "2,000 m",
"detected.arms": [
"腕",
"肘状の腕",
"垂れ下がる糸",
"肘状の腕 · 横に張り出し、数メートルの糸を引く"
],
"feel.alienness": [
"奇妙",
"異質さ",
"異形",
"異形の優雅さ"
]
"measure.depth": "2,000 m"
},
"abyss_dandelion": {
"feel.unease": [
@@ -2048,31 +1465,7 @@
"タンポポクダクラゲ",
"Rhodaliidae · 海底につながれたクローンの群体"
],
"measure.depth": "2,500 m",
"detected.tentacle": [
"糸",
"触手",
"捕食の触手",
"捕食の触手 · 獲物を捕らえる漂う網"
],
"feel.radiance": [
"輝き",
"光輝",
"煌めき",
"宝石のような煌めき"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
]
"measure.depth": "2,500 m"
},
"abyss_octopus": {
"feel.unease": [
@@ -2105,25 +1498,7 @@
"Graneledone",
"Graneledone boreopacifica · 卵を約4.5年抱き続ける"
],
"measure.depth": "2,500 m",
"detected.arms": [
"腕",
"八本の腕",
"吸盤の並ぶ腕",
"八本の腕 · 触れたものを味わう吸盤付き"
],
"feel.curiosity": [
"見て",
"好奇心",
"魅了",
"のめり込む魅了"
],
"feel.tenderness": [
"やさしい",
"優しさ",
"ぬくもり",
"ゆりかごのようなぬくもり"
]
"measure.depth": "2,500 m"
},
"abyss_seapig": {
"feel.unease": [
@@ -2156,24 +1531,6 @@
"Enypniastes eximia",
"Enypniastes · 泳ぐナマコ、「頭のない鶏」"
],
"measure.depth": "2,700 m",
"detected.veil": [
"ヴェール",
"口前のヴェール",
"遊泳用のヴェール",
"口前のヴェール · 堆積物を掃き、羽ばたいて泳ぐ"
],
"feel.strangeness": [
"変",
"奇妙さ",
"不気味",
"肉感的な奇妙さ"
],
"feel.curiosity": [
"見て",
"好奇心",
"魅了",
"のめり込む魅了"
]
"measure.depth": "2,700 m"
}
}