Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c8c4b45cd | |||
| b9a52b1c41 | |||
| ee7c65a0f2 | |||
| 8277417583 | |||
| d10ffca4e8 | |||
| a68d0bc4df | |||
| 7da944af0c | |||
| 1c7f2b7785 | |||
| 3a45e338f3 | |||
| 8cc472c93c | |||
| bcae35b63e | |||
| a93c0d57c8 | |||
| 77cb04bb8c | |||
| 271586625c | |||
| be13248bdd | |||
| 7ef3e0ccc6 | |||
| 7a51d8f6d4 | |||
| 934f60cbd2 | |||
| 7995ba4069 | |||
| 7bd6c9a57b | |||
| e5fe00e948 | |||
| 020219f9a6 | |||
| 5beee55119 | |||
| ec660c4880 | |||
| a2d8179507 | |||
| 7090d3a836 | |||
| 8df7859c5a | |||
| dcdc3d62f7 | |||
| 1977e679ee | |||
| 49b77e0700 | |||
| 00534e116c | |||
| 696f8f0901 | |||
| 43370292e3 |
@@ -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/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: sim sim-local
|
||||
.PHONY: sim sim-local deploy deploy-media
|
||||
|
||||
# Prefer the project venv's interpreter (this box has no bare `python` on PATH),
|
||||
# fall back to python3, then python.
|
||||
@@ -9,3 +9,12 @@ sim:
|
||||
|
||||
sim-local:
|
||||
$(PYTHON) -m uvicorn simulator.app:app --reload --port 8000
|
||||
|
||||
# Repeatable deploy to benstull.art (Cloudflare Pages + R2). One-time setup
|
||||
# (wrangler login, bucket/CORS/domain) is in deploy/cloudflare/README.md.
|
||||
# `deploy` is frontend-only (fast); `deploy-media` also re-syncs media to R2.
|
||||
deploy:
|
||||
./deploy/cloudflare/deploy.sh
|
||||
|
||||
deploy-media:
|
||||
./deploy/cloudflare/deploy.sh --media
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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.
|
||||
|
||||
## Repeatable deploy (after the one-time setup below)
|
||||
|
||||
Once the bucket/CORS/custom-domains + `wrangler login` exist (one-time, §1–3), every
|
||||
later deploy is one command (`deploy/cloudflare/deploy.sh`, or the Make targets):
|
||||
|
||||
```bash
|
||||
make deploy # frontend only: build + Pages deploy (fast — most deploys)
|
||||
make deploy-media # also re-sync all media to R2 (~2 min, parallel) — when media changed
|
||||
./deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, skip Pages
|
||||
```
|
||||
|
||||
The script rebuilds `dist/`, (optionally) parallel-uploads `dist-media/` to R2, and
|
||||
deploys Pages — printing the live URL. It checks `wrangler` auth first and fails
|
||||
loudly. Override defaults via env: `BUCKET`, `PROJECT`, `MEDIA_BASE`, `APP_PATH`,
|
||||
`BRANCH`, `JOBS`. The sections below document the one-time setup the script assumes.
|
||||
|
||||
## 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
|
||||
|
||||
Bucket name: **`human-experience-simulator-media`**. The default `wrangler login`
|
||||
OAuth token already has R2 + Pages + zone scopes (no separate API token needed).
|
||||
|
||||
```bash
|
||||
wrangler r2 bucket create human-experience-simulator-media
|
||||
|
||||
# CORS — load-bearing (the WebGL dream shader taints without it). The wrangler CLI
|
||||
# wants its own schema, NOT the S3-style array; use the *.wrangler.json file:
|
||||
wrangler r2 bucket cors set human-experience-simulator-media --file deploy/cloudflare/r2-cors.wrangler.json
|
||||
# (deploy/cloudflare/r2-cors.json is the equivalent S3-style policy, for the
|
||||
# dashboard / S3 API; r2-cors.wrangler.json is the same rule in wrangler's
|
||||
# `{ "rules": [ { "allowed": {...} } ] }` form.)
|
||||
|
||||
# Custom domain — wrangler CAN do this (no dashboard needed); needs the zone id.
|
||||
ZONE=$(curl -s -H "Authorization: Bearer $(grep '^oauth_token' ~/.wrangler/config/default.toml | cut -d'"' -f2)" \
|
||||
"https://api.cloudflare.com/client/v4/zones?name=benstull.art" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])")
|
||||
wrangler r2 bucket domain add human-experience-simulator-media \
|
||||
--domain static.benstull.art --zone-id "$ZONE" --min-tls 1.2
|
||||
```
|
||||
|
||||
**Media upload — there is NO `wrangler r2 bucket sync`.** Upload per-file with
|
||||
`wrangler r2 object put ... --remote`, setting content-type + immutable cache. The
|
||||
URLs are content-hash-versioned (`?v=<hash>`), so `immutable` is safe — a re-baked
|
||||
clip gets a new hash → new URL → cache busts. ~604 files / ~2.3 GB, sequential:
|
||||
|
||||
```bash
|
||||
WR=$(command -v wrangler); BUCKET=human-experience-simulator-media
|
||||
find dist-media -type f | while read -r f; do
|
||||
key="${f#dist-media/}"
|
||||
case "${f##*.}" in mp4) ct=video/mp4;; mp3) ct=audio/mpeg;; json) ct=application/json;; *) ct=application/octet-stream;; esac
|
||||
"$WR" r2 object put "$BUCKET/$key" --file "$f" --remote \
|
||||
--content-type "$ct" --cache-control "public, max-age=31536000, immutable"
|
||||
done
|
||||
```
|
||||
|
||||
(If you have `rclone` + an R2 S3 API token, `rclone copy dist-media <remote>:bucket`
|
||||
is much faster than the loop.)
|
||||
|
||||
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 --production-branch main
|
||||
wrangler pages deploy dist --project-name human-experience-simulator --branch main
|
||||
```
|
||||
|
||||
Then **in the dashboard** (Workers & Pages → project → Custom domains) — there is
|
||||
no wrangler CLI for Pages custom domains:
|
||||
- Bind **`benstull.art`** to the project. (Allow a few minutes — a fresh bind
|
||||
serves `522` until the cert/routing provisions.)
|
||||
- The app serves at `benstull.art/human-experience-simulator/`; the shipped
|
||||
`dist/_redirects` sends `/` and the no-slash form to it.
|
||||
|
||||
## 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.
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repeatable deploy of the HEF simulator to benstull.art (Cloudflare Pages + R2).
|
||||
#
|
||||
# deploy/cloudflare/deploy.sh # frontend only: build + Pages deploy (fast)
|
||||
# deploy/cloudflare/deploy.sh --media # also re-sync all media to R2 (~2 min, parallel)
|
||||
# deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, no Pages deploy
|
||||
#
|
||||
# Assumes the ONE-TIME setup is already done (see README.md): wrangler logged in,
|
||||
# the R2 bucket + CORS + static.benstull.art custom domain, and the Pages project +
|
||||
# benstull.art custom domain. This script only does the repeatable part.
|
||||
#
|
||||
# Override any of these via env: BUCKET, PROJECT, MEDIA_BASE, APP_PATH, BRANCH, JOBS.
|
||||
set -euo pipefail
|
||||
|
||||
BUCKET="${BUCKET:-human-experience-simulator-media}"
|
||||
PROJECT="${PROJECT:-human-experience-simulator}"
|
||||
MEDIA_BASE="${MEDIA_BASE:-https://static.benstull.art/}"
|
||||
APP_PATH="${APP_PATH:-/human-experience-simulator}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
JOBS="${JOBS:-10}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
OUT="$REPO_ROOT/dist"
|
||||
export MEDIA_OUT="$REPO_ROOT/dist-media"
|
||||
PY="${PY:-$REPO_ROOT/.venv/bin/python}"
|
||||
export WRANGLER="${WRANGLER:-$(command -v wrangler || echo "$HOME/.npm-global/bin/wrangler")}"
|
||||
|
||||
do_media=0
|
||||
do_pages=1
|
||||
case "${1:-}" in
|
||||
--media) do_media=1 ;;
|
||||
--media-only) do_media=1; do_pages=0 ;;
|
||||
"") ;;
|
||||
*) echo "unknown flag: $1 (use --media or --media-only)"; exit 2 ;;
|
||||
esac
|
||||
|
||||
[ -x "$WRANGLER" ] || { command -v "$WRANGLER" >/dev/null || { echo "wrangler not found ($WRANGLER) — npm i -g wrangler"; exit 1; }; }
|
||||
"$WRANGLER" whoami >/dev/null 2>&1 || { echo "not authenticated — run: $WRANGLER login"; exit 1; }
|
||||
|
||||
echo "▶ build ($MEDIA_BASE)"
|
||||
cd "$REPO_ROOT"
|
||||
"$PY" -m tools.build_static --out "$OUT" --media-out "$MEDIA_OUT" \
|
||||
--media-base "$MEDIA_BASE" --app-path "$APP_PATH" | tail -1
|
||||
|
||||
if [ "$do_media" = 1 ]; then
|
||||
echo "▶ R2 media sync → $BUCKET (parallel x$JOBS, no bulk-sync exists)"
|
||||
export BUCKET
|
||||
fails=$(find "$MEDIA_OUT" -type f -print0 \
|
||||
| xargs -0 -P "$JOBS" -I {} bash "$REPO_ROOT/deploy/cloudflare/r2-put-one.sh" {} \
|
||||
| tee /dev/stderr | grep -c '^FAIL ' || true)
|
||||
[ "$fails" = 0 ] || { echo "✘ $fails media uploads failed"; exit 1; }
|
||||
echo "✔ media synced"
|
||||
fi
|
||||
|
||||
if [ "$do_pages" = 1 ]; then
|
||||
echo "▶ Pages deploy → $PROJECT"
|
||||
"$WRANGLER" pages deploy "$OUT" --project-name "$PROJECT" --branch "$BRANCH" --commit-dirty=true | tail -3
|
||||
fi
|
||||
|
||||
echo "✔ done — https://benstull.art$APP_PATH/"
|
||||
@@ -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,13 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"allowed": {
|
||||
"origins": ["https://benstull.art"],
|
||||
"methods": ["GET", "HEAD"],
|
||||
"headers": ["Range", "Content-Type"]
|
||||
},
|
||||
"exposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
|
||||
"maxAgeSeconds": 86400
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upload ONE file to R2 with the right content-type + immutable cache. Used by
|
||||
# deploy.sh's parallel xargs (wrangler has no bulk sync). Reads BUCKET / MEDIA_OUT /
|
||||
# WRANGLER from the environment; the single argument is the local file path.
|
||||
set -u
|
||||
f="$1"
|
||||
key="${f#"$MEDIA_OUT"/}"
|
||||
# Guard: if the prefix strip didn't fire (e.g. relative path vs absolute MEDIA_OUT),
|
||||
# the key would wrongly keep a leading dir → wrong R2 path. Fail loudly, don't ship it.
|
||||
if [ "$key" = "$f" ]; then
|
||||
echo "FAIL $f (key strip failed — MEDIA_OUT='$MEDIA_OUT' is not a prefix; pass absolute paths)"
|
||||
exit 1
|
||||
fi
|
||||
case "${f##*.}" in
|
||||
mp4) ct=video/mp4 ;;
|
||||
mp3) ct=audio/mpeg ;;
|
||||
json) ct=application/json ;;
|
||||
*) ct=application/octet-stream ;;
|
||||
esac
|
||||
if "$WRANGLER" r2 object put "$BUCKET/$key" --file "$f" --remote \
|
||||
--content-type "$ct" --cache-control "public, max-age=31536000, immutable" >/dev/null 2>&1; then
|
||||
echo "ok $key"
|
||||
else
|
||||
echo "FAIL $key"
|
||||
fi
|
||||
@@ -0,0 +1,733 @@
|
||||
# Localization & Language Dropdown Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a language dropdown to the simulator that localizes all visitor-facing UI controls and the Left/Right brain annotations into English, Spanish, French, and Japanese, switchable live without reload.
|
||||
|
||||
**Architecture:** A new UMD module `simulator/static/i18n.js` (dual-export like `scrub.js`) holds the language registry, the UI-string catalog, and pure helpers (`resolveStrings`, `pickUiString`). `index.html` static text is tagged with `data-i18n` attributes; `app.js` populates a `<select>` from the registry and on change swaps UI strings + re-renders annotations. Annotation content extends the existing per-clip `strings` dict (`{lang: {key: text|list}}`) with `es/fr/ja` keys — no schema change. A Python tool (`tools/i18n/translate_manifest.py`) extracts the English catalog and merges authored translation catalogs back into the manifest.
|
||||
|
||||
**Tech Stack:** Vanilla JS (UMD modules, `node:test`), FastAPI (existing, untouched), Python (stdlib `json` + pytest), Playwright (E2E).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Languages (exact codes & order):** `en` (English, default + fallback), `es` (Español), `fr` (Français), `ja` (日本語). No Hebrew, no Chinese, **no RTL work**.
|
||||
- **Default & persistence:** `activeLang` defaults to `"en"` every load; **no localStorage** — selection is session-only.
|
||||
- **Fallback:** every annotation lookup falls back to the `en` value when a key is missing in the active language; a missing language never blanks the overlay.
|
||||
- **No schema change:** annotation translations are sibling keys inside each clip's existing `strings` object.
|
||||
- **Manifest file:** `simulator/sample_media/manifest.json` is the live manifest (`DEFAULT_MANIFEST` in `simulator/app.py:47`). 41 clips, 753 English string-variants.
|
||||
- **Tier preservation:** a string authored as a list (tiers) stays a list of identical length in every language; a plain string stays a plain string.
|
||||
- **Scope out:** dev-panel diagnostics internals (`#dev-panel`, audio diagnostics, pool picker), `/author.html`, clip titles. Only visitor-facing chrome + annotations are localized.
|
||||
- **Repo conventions:** SSH git; commit messages carry `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Run commands from repo root unless noted.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: i18n module — registry, catalog, pure helpers
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/static/i18n.js`
|
||||
- Test: `simulator/unit/i18n.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces (the `HEFi18n` global / `module.exports`):
|
||||
- `LANGUAGES` — `[{code:"en",nativeName:"English"}, {code:"es",nativeName:"Español"}, {code:"fr",nativeName:"Français"}, {code:"ja",nativeName:"日本語"}]`
|
||||
- `UI_STRINGS` — `{ key: {en, es, fr, ja} }` map (control chrome, all four languages)
|
||||
- `pickUiString(key, lang)` → string; the `lang` value, else the `en` value, else the `key` itself.
|
||||
- `resolveStrings(clipStrings, lang)` → object; `clipStrings[lang]` merged over `clipStrings.en` (so a missing key in `lang` falls back to `en`), else `clipStrings.en`, else `{}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
// simulator/unit/i18n.test.js
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const I = require("../static/i18n.js");
|
||||
|
||||
test("LANGUAGES lists the four codes in order, English first", () => {
|
||||
assert.deepEqual(I.LANGUAGES.map((l) => l.code), ["en", "es", "fr", "ja"]);
|
||||
assert.equal(I.LANGUAGES[0].nativeName, "English");
|
||||
assert.equal(I.LANGUAGES.find((l) => l.code === "ja").nativeName, "日本語");
|
||||
});
|
||||
|
||||
test("pickUiString returns the language value, falls back to en, then to key", () => {
|
||||
assert.equal(I.pickUiString("output.legend", "es"), I.UI_STRINGS["output.legend"].es);
|
||||
// a key present in en but (hypothetically) missing in fr falls back to en:
|
||||
const saved = I.UI_STRINGS["output.legend"].fr;
|
||||
delete I.UI_STRINGS["output.legend"].fr;
|
||||
assert.equal(I.pickUiString("output.legend", "fr"), I.UI_STRINGS["output.legend"].en);
|
||||
I.UI_STRINGS["output.legend"].fr = saved;
|
||||
assert.equal(I.pickUiString("totally.unknown.key", "en"), "totally.unknown.key");
|
||||
});
|
||||
|
||||
test("UI_STRINGS has every language for every key", () => {
|
||||
for (const [key, vals] of Object.entries(I.UI_STRINGS)) {
|
||||
for (const code of ["en", "es", "fr", "ja"]) {
|
||||
assert.ok(typeof vals[code] === "string" && vals[code].length, `${key} missing ${code}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveStrings merges lang over en (per-key fallback)", () => {
|
||||
const cs = { en: { a: "A", b: "B" }, es: { a: "Aes" } };
|
||||
assert.deepEqual(I.resolveStrings(cs, "es"), { a: "Aes", b: "B" });
|
||||
assert.deepEqual(I.resolveStrings(cs, "fr"), { a: "A", b: "B" }); // no fr -> all en
|
||||
assert.deepEqual(I.resolveStrings({}, "es"), {});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test simulator/unit/i18n.test.js`
|
||||
Expected: FAIL — `Cannot find module '../static/i18n.js'`.
|
||||
|
||||
- [ ] **Step 3: Write the module**
|
||||
|
||||
```js
|
||||
// simulator/static/i18n.js
|
||||
// UI/annotation localization — registry, control-string catalog, and pure
|
||||
// lookup helpers. UMD so the browser gets a `HEFi18n` global and
|
||||
// `node --test` can require() it. English is the source of truth & fallback.
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFi18n = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "en", nativeName: "English" },
|
||||
{ code: "es", nativeName: "Español" },
|
||||
{ code: "fr", nativeName: "Français" },
|
||||
{ code: "ja", nativeName: "日本語" },
|
||||
];
|
||||
|
||||
// Visitor-facing control chrome. Keys match `data-i18n` attributes in index.html.
|
||||
const UI_STRINGS = {
|
||||
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre d’Expérience Humaine — Aperçu d’altération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
|
||||
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de l’univers", ja: "宇宙を読み込み中" },
|
||||
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
|
||||
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
|
||||
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
|
||||
"altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
|
||||
"altitude.hint": { en: "Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.", es: "Gira el dial (arrástralo o desplázate) para cambiar la altitud — sin fin: tras lo más profundo vuelve a lo más alto. Haz clic en una etiqueta para saltar.", fr: "Tournez le cadran (glissez ou faites défiler) pour changer d’altitude — sans fin : après le plus profond, on revient au plus haut. Cliquez sur une étiquette pour y aller.", ja: "ダイヤルを回して(ドラッグまたはスクロール)高度を変えます — 無限ループ:最も深い先は最も高い所へ戻ります。ラベルをクリックでそこへ移動。" },
|
||||
"knobs.legend": { en: "Experience knobs (0–4)", es: "Mandos de experiencia (0–4)", fr: "Boutons d’expérience (0–4)", ja: "体験つまみ(0〜4)" },
|
||||
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
|
||||
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
|
||||
"knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
|
||||
"devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
|
||||
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
|
||||
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
|
||||
"scale.sky": { en: "sky", es: "cielo", fr: "ciel", ja: "空" },
|
||||
"scale.coast": { en: "coast", es: "costa", fr: "côte", ja: "海岸" },
|
||||
"scale.reef": { en: "reef", es: "arrecife", fr: "récif", ja: "サンゴ礁" },
|
||||
"scale.abyss": { en: "abyss", es: "abismo", fr: "abîme", ja: "深海" },
|
||||
};
|
||||
|
||||
function pickUiString(key, lang) {
|
||||
const v = UI_STRINGS[key];
|
||||
if (!v) return key;
|
||||
return (v[lang] != null ? v[lang] : v.en) || key;
|
||||
}
|
||||
|
||||
function resolveStrings(clipStrings, lang) {
|
||||
if (!clipStrings || !clipStrings.en) return (clipStrings && clipStrings[lang]) || {};
|
||||
if (lang === "en" || !clipStrings[lang]) return clipStrings.en;
|
||||
return Object.assign({}, clipStrings.en, clipStrings[lang]);
|
||||
}
|
||||
|
||||
return { LANGUAGES, UI_STRINGS, pickUiString, resolveStrings };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test simulator/unit/i18n.test.js`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/i18n.js simulator/unit/i18n.test.js
|
||||
git commit -m "feat(i18n): language registry + UI-string catalog module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Tag index.html + add the language dropdown
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html`
|
||||
- Test: `simulator/unit/index-i18n.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `data-i18n` key names from Task 1's `UI_STRINGS`.
|
||||
- Produces: a `<select id="lang-select">` and `data-i18n`-tagged nodes for Task 3 to drive.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (parses the HTML as text — no DOM lib needed)
|
||||
|
||||
```js
|
||||
// simulator/unit/index-i18n.test.js
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const I = require("../static/i18n.js");
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, "../static/index.html"), "utf8");
|
||||
|
||||
test("index.html includes the i18n script and a language select", () => {
|
||||
assert.match(html, /<script src="\/i18n\.js">/);
|
||||
assert.match(html, /id="lang-select"/);
|
||||
});
|
||||
|
||||
test("every data-i18n key in index.html exists in UI_STRINGS", () => {
|
||||
const keys = [...html.matchAll(/data-i18n="([^"]+)"/g)].map((m) => m[1]);
|
||||
assert.ok(keys.length >= 12, `expected many tagged nodes, found ${keys.length}`);
|
||||
for (const k of keys) assert.ok(I.UI_STRINGS[k], `data-i18n key not in catalog: ${k}`);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test simulator/unit/index-i18n.test.js`
|
||||
Expected: FAIL — no `i18n.js` script / no `lang-select` / no `data-i18n` attributes yet.
|
||||
|
||||
- [ ] **Step 3: Edit index.html**
|
||||
|
||||
Apply these exact edits:
|
||||
|
||||
1. Load the module before `app.js` (after the `scrub.js` script, line ~107):
|
||||
|
||||
```html
|
||||
<script src="/scrub.js"></script>
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
```
|
||||
|
||||
2. Tag the loading title (line 12):
|
||||
|
||||
```html
|
||||
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
|
||||
```
|
||||
|
||||
3. Tag the header (line 16):
|
||||
|
||||
```html
|
||||
<header><h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1></header>
|
||||
```
|
||||
|
||||
4. In the **Output** fieldset (lines 33–44), tag the legend + video label, and **add the language select** as the first control:
|
||||
|
||||
```html
|
||||
<fieldset>
|
||||
<legend data-i18n="output.legend">Output</legend>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="lang-select" aria-label="Language"></select>
|
||||
</label>
|
||||
<label class="dev-switch" for="visual">
|
||||
<input type="checkbox" id="visual" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="output.video">Video</span>
|
||||
</label>
|
||||
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
|
||||
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
|
||||
<span id="audio-level-val">0</span>/10
|
||||
</label>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
5. Tag the **Altitude** legend (line 47) and hint (line 52):
|
||||
|
||||
```html
|
||||
<legend data-i18n="altitude.legend">Altitude</legend>
|
||||
```
|
||||
```html
|
||||
<p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
|
||||
```
|
||||
|
||||
6. Tag the **Experience knobs** fieldset (lines 56–59):
|
||||
|
||||
```html
|
||||
<legend data-i18n="knobs.legend">Experience knobs (0–4)</legend>
|
||||
<label><span data-i18n="knobs.think">Think</span> <input type="range" id="left" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.feel">Feel</span> <input type="range" id="right" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.mood">Mood — dark ◀ 0 ▶ light</span> <input type="range" id="mood" min="-4" max="4" value="0" /></label>
|
||||
```
|
||||
|
||||
7. Tag the **Dev Mode** label (line 66):
|
||||
|
||||
```html
|
||||
<span class="dev-switch-label" data-i18n="devmode.label">Dev Mode</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test simulator/unit/index-i18n.test.js`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/unit/index-i18n.test.js
|
||||
git commit -m "feat(i18n): tag controls with data-i18n + add language dropdown"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire live language switching in app.js
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` (render sites at `:505` and `:556`; add init + handler)
|
||||
- Test: covered by Task 1's `resolveStrings` unit test (pure logic) + Task 6 E2E (DOM behavior). No new unit file.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HEFi18n.LANGUAGES`, `HEFi18n.pickUiString`, `HEFi18n.resolveStrings`; existing `renderOverlay`, `renderAffect`, `renderScaleReadout`, `lastOverlay`.
|
||||
- Produces: module-global `activeLang`; `lastAffect` cache for re-render.
|
||||
|
||||
- [ ] **Step 1: Add `activeLang` + `lastAffect` globals**
|
||||
|
||||
Near the other module globals (around `simulator/static/app.js:47–55`, after `let lastOverlay = null;`), add:
|
||||
|
||||
```js
|
||||
let activeLang = "en"; // session-only; no persistence (resets to en each load)
|
||||
let lastAffect = null; // {strength, intensity, right} from the last renderAffect, for re-render on language switch
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Use `activeLang` at the two render sites**
|
||||
|
||||
In `renderOverlay` (line ~505) replace:
|
||||
|
||||
```js
|
||||
const strings = (clip.strings && clip.strings.en) || {};
|
||||
```
|
||||
with:
|
||||
```js
|
||||
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
|
||||
```
|
||||
|
||||
In `renderAffect` (line ~556) replace the same line with the same call, **and** cache the args at the top of `renderAffect` so a language switch can re-render without a server round-trip. Right after `function renderAffect(strength, intensity, right) {` add:
|
||||
|
||||
```js
|
||||
lastAffect = { strength, intensity, right };
|
||||
```
|
||||
and replace its `const strings = (clip.strings && clip.strings.en) || {};` with:
|
||||
```js
|
||||
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Localize the scale-name readout**
|
||||
|
||||
In `renderScaleReadout` (line ~575) replace:
|
||||
|
||||
```js
|
||||
$("scale-name").textContent = `${s.id} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
|
||||
```
|
||||
with (localize only the scale id; `member` is a clip title and stays as-is):
|
||||
```js
|
||||
const scaleName = HEFi18n.pickUiString("scale." + s.id, activeLang);
|
||||
$("scale-name").textContent = `${scaleName} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the init + change handler**
|
||||
|
||||
Add this function and call it once during startup. Place the function near the other `apply*` helpers, and invoke `initLanguage()` from the existing startup path (the same place that first calls `renderScaleReadout()` / builds the panel — search for the boot sequence after the manifest `fetch` in the init function around `simulator/static/app.js:58–104`; add `initLanguage();` there):
|
||||
|
||||
```js
|
||||
// Populate the language dropdown from the registry and wire live switching.
|
||||
// English default, session-only (no persistence). Switching swaps UI chrome
|
||||
// and re-renders the current annotations/affect in place — no reload, no fetch.
|
||||
function initLanguage() {
|
||||
const sel = $("lang-select");
|
||||
if (!sel) return;
|
||||
for (const { code, nativeName } of HEFi18n.LANGUAGES) {
|
||||
const o = document.createElement("option");
|
||||
o.value = code;
|
||||
o.textContent = nativeName;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = activeLang;
|
||||
applyUiStrings(activeLang);
|
||||
sel.addEventListener("change", () => setLanguage(sel.value));
|
||||
}
|
||||
|
||||
// Fill every [data-i18n] node with its catalog string for `lang`.
|
||||
function applyUiStrings(lang) {
|
||||
document.documentElement.lang = lang;
|
||||
for (const el of document.querySelectorAll("[data-i18n]")) {
|
||||
el.textContent = HEFi18n.pickUiString(el.getAttribute("data-i18n"), lang);
|
||||
}
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
activeLang = lang;
|
||||
applyUiStrings(lang);
|
||||
renderScaleReadout();
|
||||
if (lastOverlay) renderOverlay(lastOverlay.level, lastOverlay.intensity);
|
||||
if (lastAffect) renderAffect(lastAffect.strength, lastAffect.intensity, lastAffect.right);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the full node unit suite**
|
||||
|
||||
Run: `node --test simulator/unit/`
|
||||
Expected: PASS — existing `scrub.test.js`, plus `i18n.test.js` and `index-i18n.test.js`.
|
||||
|
||||
- [ ] **Step 6: Manual smoke (optional but recommended)**
|
||||
|
||||
Run the app and confirm the dropdown lists English/Español/Français/日本語, switching changes the control labels and (with Think/Feel up) the annotations, and reload returns to English. (Server: the existing uvicorn launch — restart it with the venv python so it serves the new static files; a stale uvicorn is the recurring "no change" root cause.)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/app.js
|
||||
git commit -m "feat(i18n): live language switching for chrome + annotations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Manifest translation tool (extract + merge)
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/i18n/translate_manifest.py`
|
||||
- Create: `tools/i18n/__init__.py` (empty — `tools/` is a package; siblings `tools/pipeline/`, `tools/ingest/` each have one)
|
||||
- Test: `tests/test_i18n_translate.py` (repo `tests/` is flat — match it)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the manifest JSON shape — `{"clips": [ {"id", "strings": {"en": {key: str|list}}}, ... ]}`.
|
||||
- Produces:
|
||||
- `extract_en_catalog(manifest) -> {clip_id: {key: str|list}}` — the English strings of every clip.
|
||||
- `merge_catalog(manifest, lang, catalog) -> manifest` — writes `catalog[clip_id]` into each clip's `strings[lang]`; **structure-validated** (raises `ValueError` if a key's type/list-length differs from `en`); **idempotent** (re-running with the same catalog is a no-op); leaves `en` untouched.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_i18n_translate.py
|
||||
import copy
|
||||
import pytest
|
||||
from tools.i18n.translate_manifest import extract_en_catalog, merge_catalog
|
||||
|
||||
MANIFEST = {
|
||||
"clips": [
|
||||
{"id": "cosmos", "strings": {"en": {
|
||||
"detected.star": ["star", "young star"],
|
||||
"measure.distance": "≈7,500 ly",
|
||||
}}},
|
||||
]
|
||||
}
|
||||
|
||||
def test_extract_pulls_every_en_string():
|
||||
cat = extract_en_catalog(MANIFEST)
|
||||
assert cat == {"cosmos": {"detected.star": ["star", "young star"], "measure.distance": "≈7,500 ly"}}
|
||||
|
||||
def test_merge_writes_lang_and_leaves_en():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}}
|
||||
merge_catalog(m, "es", cat)
|
||||
s = m["clips"][0]["strings"]
|
||||
assert s["en"]["measure.distance"] == "≈7,500 ly" # en untouched
|
||||
assert s["es"]["detected.star"] == ["estrella", "estrella joven"]
|
||||
|
||||
def test_merge_is_idempotent():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}}
|
||||
merge_catalog(m, "es", cat)
|
||||
once = copy.deepcopy(m)
|
||||
merge_catalog(m, "es", cat)
|
||||
assert m == once
|
||||
|
||||
def test_merge_rejects_tier_length_mismatch():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
bad = {"cosmos": {"detected.star": ["estrella"], "measure.distance": "≈7.500 al"}} # 1 tier vs en's 2
|
||||
with pytest.raises(ValueError):
|
||||
merge_catalog(m, "es", bad)
|
||||
|
||||
def test_merge_rejects_type_mismatch():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
bad = {"cosmos": {"detected.star": "estrella", "measure.distance": "≈7.500 al"}} # str vs en's list
|
||||
with pytest.raises(ValueError):
|
||||
merge_catalog(m, "es", bad)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `python -m pytest tests/test_i18n_translate.py -q`
|
||||
Expected: FAIL — `ModuleNotFoundError: tools.i18n.translate_manifest`.
|
||||
|
||||
- [ ] **Step 3: Write the tool**
|
||||
|
||||
```python
|
||||
# tools/i18n/translate_manifest.py
|
||||
"""Extract a manifest's English annotation strings and merge authored
|
||||
translations back into each clip's `strings` dict (no schema change).
|
||||
|
||||
Workflow:
|
||||
1. `extract` writes tools/i18n/catalogs/en.json — the English strings.
|
||||
2. A translator (human or LLM) authors es.json / fr.json / ja.json with the
|
||||
SAME structure (lists stay lists of the same length).
|
||||
3. `merge` validates structure and writes strings[lang] into the manifest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def extract_en_catalog(manifest: dict) -> dict:
|
||||
out = {}
|
||||
for clip in manifest.get("clips", []):
|
||||
en = (clip.get("strings") or {}).get("en")
|
||||
if en:
|
||||
out[clip["id"]] = en
|
||||
return out
|
||||
|
||||
|
||||
def _check_shape(key: str, en_val, tr_val):
|
||||
if isinstance(en_val, list):
|
||||
if not isinstance(tr_val, list) or len(tr_val) != len(en_val):
|
||||
raise ValueError(f"{key}: expected list of {len(en_val)} tiers, got {tr_val!r}")
|
||||
elif not isinstance(tr_val, str):
|
||||
raise ValueError(f"{key}: expected string, got {tr_val!r}")
|
||||
|
||||
|
||||
def merge_catalog(manifest: dict, lang: str, catalog: dict) -> dict:
|
||||
if lang == "en":
|
||||
raise ValueError("refusing to overwrite the en source")
|
||||
for clip in manifest.get("clips", []):
|
||||
en = (clip.get("strings") or {}).get("en")
|
||||
tr = catalog.get(clip["id"])
|
||||
if not en or not tr:
|
||||
continue
|
||||
for key, en_val in en.items():
|
||||
if key not in tr:
|
||||
raise ValueError(f"{clip['id']}/{key}: missing in {lang} catalog")
|
||||
_check_shape(f"{clip['id']}/{key}", en_val, tr[key])
|
||||
clip["strings"][lang] = {k: tr[k] for k in en} # en key order, lang values
|
||||
return manifest
|
||||
|
||||
|
||||
def _main(argv=None):
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("command", choices=["extract", "merge"])
|
||||
ap.add_argument("--manifest", default="simulator/sample_media/manifest.json")
|
||||
ap.add_argument("--catalog-dir", default="tools/i18n/catalogs")
|
||||
ap.add_argument("--lang", help="merge: target language code")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
mpath = Path(args.manifest)
|
||||
manifest = json.loads(mpath.read_text(encoding="utf-8"))
|
||||
cdir = Path(args.catalog_dir)
|
||||
|
||||
if args.command == "extract":
|
||||
cdir.mkdir(parents=True, exist_ok=True)
|
||||
(cdir / "en.json").write_text(
|
||||
json.dumps(extract_en_catalog(manifest), ensure_ascii=False, indent=1) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"wrote {cdir / 'en.json'}")
|
||||
else:
|
||||
if not args.lang:
|
||||
ap.error("merge requires --lang")
|
||||
catalog = json.loads((cdir / f"{args.lang}.json").read_text(encoding="utf-8"))
|
||||
merge_catalog(manifest, args.lang, catalog)
|
||||
mpath.write_text(json.dumps(manifest, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
print(f"merged {args.lang} into {mpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
```
|
||||
|
||||
Create empty `tools/i18n/__init__.py` (and confirm whether `tools/` and `tools/pipeline/` have `__init__.py`; match the existing pattern — `simulator/app.py:296` imports `tools.pipeline.manifest`, so `tools` is importable as a package).
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `python -m pytest tests/test_i18n_translate.py -q`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Generate the English catalog**
|
||||
|
||||
Run: `python -m tools.i18n.translate_manifest extract`
|
||||
Expected: writes `tools/i18n/catalogs/en.json` (41 clips).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/i18n/translate_manifest.py tools/i18n/__init__.py tests/test_i18n_translate.py tools/i18n/catalogs/en.json
|
||||
git commit -m "feat(i18n): manifest translation extract/merge tool + en catalog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Author es/fr/ja translations + merge into the manifest
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/i18n/catalogs/es.json`, `tools/i18n/catalogs/fr.json`, `tools/i18n/catalogs/ja.json`
|
||||
- Modify: `simulator/sample_media/manifest.json` (via the merge tool)
|
||||
- Test: `tests/test_i18n_manifest.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `tools/i18n/catalogs/en.json` (Task 4), `merge_catalog` (Task 4).
|
||||
- Produces: a manifest where every clip's `strings` has `en/es/fr/ja`.
|
||||
|
||||
- [ ] **Step 1: Write the failing invariant test**
|
||||
|
||||
```python
|
||||
# tests/test_i18n_manifest.py
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
LANGS = ["en", "es", "fr", "ja"]
|
||||
MANIFEST = Path("simulator/sample_media/manifest.json")
|
||||
|
||||
def _tier_len(v):
|
||||
return len(v) if isinstance(v, list) else 0 # 0 = plain string
|
||||
|
||||
def test_every_clip_has_all_languages_with_matching_shape():
|
||||
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
for clip in manifest["clips"]:
|
||||
s = clip.get("strings", {})
|
||||
en = s.get("en")
|
||||
if not en:
|
||||
continue
|
||||
for lang in LANGS:
|
||||
assert lang in s, f"{clip['id']} missing language {lang}"
|
||||
assert set(s[lang]) == set(en), f"{clip['id']}/{lang} key-set differs from en"
|
||||
for key, en_val in en.items():
|
||||
assert _tier_len(s[lang][key]) == _tier_len(en_val), (
|
||||
f"{clip['id']}/{lang}/{key} tier-count differs from en"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `python -m pytest tests/test_i18n_manifest.py -q`
|
||||
Expected: FAIL — clips have only `en`.
|
||||
|
||||
- [ ] **Step 3: Author the three translation catalogs**
|
||||
|
||||
Copy `tools/i18n/catalogs/en.json` to `es.json`, `fr.json`, `ja.json` and translate **every value**, preserving each key and every list's length. Guidance:
|
||||
- Tier lists escalate (general → scientific+fact for `detected.*`; basic → compound emotion for `feel.*`) — preserve that escalation in the target language.
|
||||
- `measure.*` are plain strings (units/quantities) — localize number formatting where natural (e.g. `7,500` → `7.500` in es/fr) but keep the value.
|
||||
- These are LLM-authored; the Japanese pass gets a human spot-check before merge.
|
||||
|
||||
This is the bulk content step (~2,260 strings across the three files). It is data authoring, not code — produce the JSON, then validate with the merge tool's structure checks in the next step.
|
||||
|
||||
- [ ] **Step 4: Merge each language into the manifest**
|
||||
|
||||
```bash
|
||||
python -m tools.i18n.translate_manifest merge --lang es
|
||||
python -m tools.i18n.translate_manifest merge --lang fr
|
||||
python -m tools.i18n.translate_manifest merge --lang ja
|
||||
```
|
||||
Expected: each prints `merged <lang> into simulator/sample_media/manifest.json`. Any structure error (missing key, wrong tier length) aborts that merge — fix the catalog and re-run (merge is idempotent).
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `python -m pytest tests/test_i18n_manifest.py -q`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/i18n/catalogs/es.json tools/i18n/catalogs/fr.json tools/i18n/catalogs/ja.json simulator/sample_media/manifest.json tests/test_i18n_manifest.py
|
||||
git commit -m "feat(i18n): author es/fr/ja annotation translations + merge into manifest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: E2E — dropdown switches UI and annotations
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/e2e/tests/i18n.spec.ts` (match the existing `e2e/tests/*.spec.ts` location/pattern — confirm the exact tests dir, cf. `e2e/tests/altitude-lock.spec.ts`)
|
||||
- Test: this IS the test.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the running app with the language dropdown + localized manifest.
|
||||
|
||||
- [ ] **Step 1: Write the E2E test**
|
||||
|
||||
```ts
|
||||
// simulator/e2e/tests/i18n.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("language dropdown localizes chrome and resets to English on reload", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const sel = page.locator("#lang-select");
|
||||
await expect(sel).toBeVisible();
|
||||
|
||||
// four languages, English selected by default
|
||||
await expect(sel.locator("option")).toHaveCount(4);
|
||||
await expect(sel).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
|
||||
// switch to Spanish -> chrome changes, <html lang> updates
|
||||
await sel.selectOption("es");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Salida");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "es");
|
||||
|
||||
// Japanese
|
||||
await sel.selectOption("ja");
|
||||
await expect(page.locator('[data-i18n="knobs.think"]')).toHaveText("考える");
|
||||
|
||||
// reload returns to English (session-only, no persistence)
|
||||
await page.reload();
|
||||
await expect(page.locator("#lang-select")).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the E2E test**
|
||||
|
||||
Run: the existing Playwright command for this repo (check `simulator/e2e/` for the runner — e.g. `npx playwright test i18n` from `simulator/e2e/`, with the app server running as the other specs expect).
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/e2e/tests/i18n.spec.ts
|
||||
git commit -m "test(i18n): e2e for language dropdown chrome + reset on reload"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Full verification + finish the branch
|
||||
|
||||
- [ ] **Step 1: Run every suite**
|
||||
|
||||
```bash
|
||||
node --test simulator/unit/
|
||||
python -m pytest tests/ -q
|
||||
```
|
||||
Expected: all green (node unit incl. i18n; pytest incl. translate-tool + manifest-language invariants; existing suites unaffected).
|
||||
|
||||
- [ ] **Step 2: Run the E2E suite**
|
||||
|
||||
Run the repo's full Playwright command (as other specs run it) and confirm the i18n spec plus the existing specs pass.
|
||||
|
||||
- [ ] **Step 3: Open the PR**
|
||||
|
||||
```bash
|
||||
git push -u origin feat/localization-language-dropdown
|
||||
```
|
||||
Then open a PR to `main` via the Gitea flow, citing this plan and the design spec.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- §2 languages (en/es/fr/ja, no RTL) → Task 1 registry + Global Constraints. ✓
|
||||
- §3.1 UI chrome → Task 1 catalog + Task 2 tagging. ✓
|
||||
- §3.2 / §4.3 annotations (extend strings dict, fallback) → Task 1 `resolveStrings`, Task 3 render sites, Tasks 4–5 content. ✓
|
||||
- §4.1 registry → Task 1. ✓ §4.2 UI catalog + `applyUiStrings` + `data-i18n` → Tasks 1–3. ✓
|
||||
- §5 dropdown, English default, session-only, live switch no reload → Task 2 (select) + Task 3 (init/handler). ✓
|
||||
- §6 one-shot idempotent tool, tier-structure preservation → Task 4. ✓
|
||||
- §7 tests: node unit (registry/swap/fallback/tier) → Tasks 1, 3; pytest manifest invariants → Task 5; Playwright per-language → Task 6. ✓
|
||||
- §8 out-of-scope honored (no dev-panel internals, no author.html, no clip titles). ✓
|
||||
|
||||
**Placeholder scan:** Task 5 Step 3 is intentionally a content-authoring step (the 2,260 translations can't be inlined); its structure is fully constrained by the tool's validation and Task 5's invariant test. No code step is left as a placeholder.
|
||||
|
||||
**Type consistency:** `resolveStrings(clipStrings, lang)`, `pickUiString(key, lang)`, `LANGUAGES[].{code,nativeName}`, `extract_en_catalog`, `merge_catalog(manifest, lang, catalog)`, globals `activeLang`/`lastAffect`/`lastOverlay` — names used identically across Tasks 1, 3, 4, 5. ✓
|
||||
@@ -0,0 +1,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,125 @@
|
||||
# Localization & Language Dropdown — Design
|
||||
|
||||
**Date:** 2026-06-29
|
||||
**Status:** Approved design, pre-plan
|
||||
**Scope:** The `simulator/` web experience — localize all UI controls and the
|
||||
Left/Right brain annotations, selectable via a language dropdown.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let a visitor switch the experience's language from a dropdown. Switching
|
||||
re-renders **both** the UI chrome (controls, labels, hints) **and** the
|
||||
Left/Right brain annotation content (object labels, facts, emotion words) into
|
||||
the chosen language, live, without reloading the page or interrupting playback.
|
||||
|
||||
## 2. Languages (this pass)
|
||||
|
||||
Four languages, English as the source/default:
|
||||
|
||||
| code | native name | notes |
|
||||
|------|-------------|-------|
|
||||
| `en` | English | source of truth, fallback |
|
||||
| `es` | Español | |
|
||||
| `fr` | Français | |
|
||||
| `ja` | 日本語 | CJK glyphs; no layout-direction change |
|
||||
|
||||
Hebrew and Chinese were considered and **deliberately deferred** to a later
|
||||
pass. Hebrew was the only right-to-left language, so **no RTL / UI-mirror work
|
||||
is in scope here.** The language set is data-driven, so adding more later
|
||||
(including an RTL pass) is additive.
|
||||
|
||||
## 3. What needs translating — two very different costs
|
||||
|
||||
1. **UI chrome** — ~20 fixed control strings (`Loading Universe`, `Output`,
|
||||
`Video`, `Audio`, `Altitude`, `Think`, `Feel`, `Mood`, the six scale names,
|
||||
hints, dev-mode labels). Small, static.
|
||||
2. **Annotations** — the bulk. The manifest holds **41 clips / 753 authored
|
||||
English string-variants** (Left labels + facts + Right emotions, many as
|
||||
tier-lists). Authoring `es/fr/ja` = **753 × 3 ≈ 2,260 strings**. This is
|
||||
*content*, and it is the dominant effort.
|
||||
|
||||
## 4. Architecture — three isolated pieces
|
||||
|
||||
### 4.1 Language registry
|
||||
A single small module (`simulator/static/i18n.js` or a sibling) exporting the
|
||||
ordered list `[{code, nativeName}]` for `en, es, fr, ja`. It is the **one source
|
||||
of truth** consumed by the dropdown and `<html lang>`. No `dir` field needed
|
||||
this pass (no RTL).
|
||||
|
||||
### 4.2 UI string catalog
|
||||
A `{ key: {en, es, fr, ja} }` map for the ~20 control strings, living alongside
|
||||
the registry. `index.html`'s static text is tagged with `data-i18n="key"`
|
||||
attributes (removing hardcoded English from the markup); an
|
||||
`applyUiStrings(lang)` function fills every tagged node on load and on every
|
||||
language change.
|
||||
|
||||
### 4.3 Annotation strings — no schema change
|
||||
The manifest already stores `strings: {"en": {key: text}}` per clip. We add
|
||||
`es / fr / ja` sibling keys to each clip's `strings` object. The two render
|
||||
sites change from a hardcoded `.en`:
|
||||
|
||||
- `simulator/static/app.js:505` (Left labels/facts)
|
||||
- `simulator/static/app.js:556` (Right emotions)
|
||||
|
||||
…to `clip.strings[activeLang] || clip.strings.en` — an **English fallback** so a
|
||||
missing key never blanks the overlay. Tier-list structure (general→scientific,
|
||||
emotion escalation) is preserved per language.
|
||||
|
||||
## 5. The dropdown & selection behavior
|
||||
|
||||
- A `<select>` in the control panel (under "Output"), populated from the
|
||||
registry, showing **native names** (Español, 日本語, …).
|
||||
- `activeLang` is a module-level variable defaulting to `"en"`.
|
||||
- **No persistence** (no localStorage): every page load starts in English; a
|
||||
visitor's selection lasts only the session. Fits an installation that resets
|
||||
between visitors.
|
||||
- On change: set `activeLang` → `applyUiStrings(lang)` → set
|
||||
`document.documentElement.lang` → re-render the current frame's labels and
|
||||
emotions. **No reload, no video interruption.**
|
||||
|
||||
## 6. Authoring the translations (one-shot tooling)
|
||||
|
||||
`tools/i18n/translate_manifest.py`:
|
||||
- Reads each clip's `strings.en` from the manifest.
|
||||
- Produces `es / fr / ja` translations, **preserving tier-list structure** (a
|
||||
list stays a list of the same length) and the general→scientific /
|
||||
emotion-escalation intent.
|
||||
- Writes them back into the manifest in place.
|
||||
- **Idempotent / re-runnable:** skips keys already present in a target language,
|
||||
so it can be run incrementally.
|
||||
- Output is committed as content.
|
||||
|
||||
**Authorship caveat:** translations are LLM-generated and committed as the real
|
||||
content. A human spot-check of the Japanese pass precedes merge; this design
|
||||
does not claim native-quality fluency.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- **Unit (node):**
|
||||
- registry integrity (codes unique, English present);
|
||||
- `applyUiStrings` swaps every `data-i18n` node and leaves none in English
|
||||
when a non-English language is active;
|
||||
- annotation picker falls back to `en` on a missing key;
|
||||
- tier-list length is preserved across languages.
|
||||
- **Manifest test (pytest):** every clip's `strings` has all four language keys,
|
||||
with **identical key-sets** and **identical tier-counts** to `en` (catches a
|
||||
translation that dropped a key or a tier).
|
||||
- **E2E (Playwright):** select each language → control labels change to that
|
||||
language; select back to English → original restored; annotations re-render on
|
||||
switch.
|
||||
|
||||
## 8. Out of scope (this pass)
|
||||
|
||||
- Hebrew / RTL / full-UI-mirror.
|
||||
- Chinese.
|
||||
- Browser language auto-detection.
|
||||
- Persisting the chosen language across loads.
|
||||
- Localizing author-mode (`/author.html`) tooling UI.
|
||||
|
||||
## 9. Open follow-ups (noted, not built)
|
||||
|
||||
- A later RTL pass (Hebrew/Arabic) would add a `dir` field to the registry and a
|
||||
logical-property CSS migration.
|
||||
- Native-speaker review of the committed translations.
|
||||
@@ -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 91–117 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).
|
||||
@@ -401,6 +401,139 @@ LABELS: dict[str, list[dict]] = {
|
||||
),
|
||||
measure("measure.depth", "−2,140 m", [0.06, 0.06, 0.16, 0.08], 2),
|
||||
],
|
||||
# --- Round-5 left-brain fill (2026-06-30, session 0027): the pool members that
|
||||
# shipped with affect (right-brain) but no factual LEFT labels. Static tiered
|
||||
# labels (footage not eyeballed); the abyss/reef creature clips could later be
|
||||
# upgraded to tracked labels via author mode. ---
|
||||
# ---------- cosmos ----------
|
||||
"cosmos_orion": [
|
||||
static_label("detected.nebula", 4, ["cloud", "nebula", "emission nebula", "emission nebula · M42, the Orion star-forming region"], [0.18, 0.30, 0.6, 0.5]),
|
||||
static_label("detected.star", 2, ["star", "young star", "Trapezium star", "Trapezium · hot O-stars lighting the nebula"], [0.44, 0.40, 0.14, 0.16]),
|
||||
measure("measure.distance", "≈1,344 ly", [0.06, 0.06, 0.2, 0.08], 3),
|
||||
],
|
||||
"cosmos_tarantula": [
|
||||
static_label("detected.nebula", 4, ["cloud", "nebula", "Tarantula Nebula", "30 Doradus · the Local Group’s brightest star-forming region"], [0.16, 0.28, 0.64, 0.5]),
|
||||
static_label("detected.cluster", 2, ["stars", "star cluster", "R136", "R136 · packs some of the most massive stars known"], [0.42, 0.40, 0.16, 0.18]),
|
||||
measure("measure.distance", "≈160,000 ly", [0.06, 0.06, 0.22, 0.08], 3),
|
||||
],
|
||||
"cosmos_westerlund": [
|
||||
static_label("detected.cluster", 4, ["stars", "star cluster", "Westerlund 2", "Westerlund 2 · a young cluster, ~1–2 Myr old"], [0.30, 0.30, 0.4, 0.4]),
|
||||
static_label("detected.star", 2, ["star", "massive star", "O-type star", "O-type · blue-white, tens of solar masses"], [0.46, 0.42, 0.12, 0.14]),
|
||||
measure("measure.distance", "≈20,000 ly", [0.06, 0.06, 0.22, 0.08], 3),
|
||||
],
|
||||
"cosmos_southernring": [
|
||||
static_label("detected.nebula", 4, ["ring", "nebula", "planetary nebula", "NGC 3132 · gas shed by a dying Sun-like star"], [0.24, 0.24, 0.5, 0.5]),
|
||||
static_label("detected.star", 2, ["star", "central star", "white dwarf", "white dwarf · the hot stellar core left behind"], [0.46, 0.44, 0.1, 0.12]),
|
||||
measure("measure.distance", "≈2,500 ly", [0.06, 0.06, 0.22, 0.08], 3),
|
||||
],
|
||||
"cosmos_carina_eso": [
|
||||
static_label("detected.nebula", 4, ["cloud", "nebula", "emission nebula", "emission nebula · the Carina star-forming complex"], [0.16, 0.30, 0.64, 0.5]),
|
||||
static_label("detected.star", 2, ["star", "young star", "massive star", "massive star · the kind that sculpts the Carina cliffs"], [0.44, 0.40, 0.14, 0.16]),
|
||||
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
|
||||
],
|
||||
# ---------- orbit ----------
|
||||
"orbit_aurora2025": [
|
||||
static_label("detected.aurora", 4, ["glow", "aurora", "the aurora", "aurora · solar particles exciting upper-atmosphere oxygen"], [0.10, 0.45, 0.8, 0.35]),
|
||||
static_label("detected.limb", 2, ["edge", "Earth’s limb", "atmospheric limb", "atmospheric limb · ~100 km of air, glowing on edge"], [0.05, 0.66, 0.9, 0.12]),
|
||||
measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3),
|
||||
],
|
||||
"orbit_citylights": [
|
||||
static_label("detected.citylights", 4, ["lights", "city lights", "urban grid", "urban grid · sodium/LED glow tracing the coastline"], [0.20, 0.45, 0.6, 0.4]),
|
||||
static_label("detected.airglow", 2, ["band", "airglow", "atmospheric airglow", "airglow · the faint nighttime emission of the upper air"], [0.05, 0.30, 0.9, 0.1]),
|
||||
measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3),
|
||||
],
|
||||
"orbit_helene": [
|
||||
static_label("detected.hurricane", 4, ["storm", "hurricane", "tropical cyclone", "tropical cyclone · a warm-core spiral, eye at its center"], [0.22, 0.30, 0.5, 0.5]),
|
||||
static_label("detected.limb", 2, ["edge", "Earth’s limb", "atmospheric limb", "atmospheric limb · the thin shell weather lives in"], [0.05, 0.10, 0.9, 0.12]),
|
||||
measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3),
|
||||
],
|
||||
"orbit_epic": [
|
||||
static_label("detected.globe", 4, ["Earth", "the globe", "sunlit disk", "full disk · the whole daylit Earth, seen from L1"], [0.24, 0.14, 0.52, 0.66]),
|
||||
static_label("detected.cloud_band", 2, ["clouds", "cloud band", "weather systems", "weather systems · swirling across a turning planet"], [0.30, 0.34, 0.34, 0.26]),
|
||||
measure("measure.distance", "~1.5M km", [0.06, 0.06, 0.2, 0.08], 3),
|
||||
],
|
||||
# ---------- sky ----------
|
||||
"sky_grca_templesa": [
|
||||
static_label("detected.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.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.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.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.waterfall", 4, ["falls", "waterfall", "cataract", "cataract · a river plunging off the canopy’s edge"], [0.34, 0.20, 0.3, 0.7]),
|
||||
static_label("detected.canopy", 2, ["trees", "jungle canopy", "rainforest canopy", "canopy · dense forest crowding the gorge"], [0.05, 0.30, 0.9, 0.5]),
|
||||
],
|
||||
"sky_coast_cliffspain": [
|
||||
static_label("detected.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.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.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.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.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.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.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.mist", 4, ["mist", "sea mist", "advection fog", "advection fog · warm air cooling over cold upwelling"], [0.05, 0.10, 0.9, 0.5]),
|
||||
static_label("detected.searock", 2, ["rock", "shore rock", "coastal rock", "coastal rock · the standing edge of the land"], [0.20, 0.55, 0.6, 0.35]),
|
||||
],
|
||||
# ---------- reef ----------
|
||||
"reef_redsea": [
|
||||
static_label("detected.coral", 4, ["coral", "reef coral", "scleractinian", "scleractinian · stony reef-builder in symbiosis with algae"], [0.15, 0.35, 0.7, 0.5]),
|
||||
static_label("detected.reeffish", 2, ["fish", "reef fish", "anthias", "anthias · orange clouds of plankton-pickers over the reef"], [0.30, 0.20, 0.4, 0.3]),
|
||||
measure("measure.depth", "−10 m", [0.06, 0.06, 0.16, 0.08], 3),
|
||||
],
|
||||
"reef_flowergarden": [
|
||||
static_label("detected.school", 4, ["fish", "fish school", "jack & scad school", "carangid school · jacks and scad sweeping the reef"], [0.20, 0.25, 0.55, 0.4]),
|
||||
static_label("detected.sergeant", 2, ["fish", "sergeant major", "Abudefduf saxatilis", "Abudefduf · the striped “sergeant major” damselfish"], [0.55, 0.45, 0.18, 0.2]),
|
||||
measure("measure.depth", "−20 m", [0.06, 0.06, 0.16, 0.08], 3),
|
||||
],
|
||||
# ---------- abyss ----------
|
||||
"abyss_bigfin": [
|
||||
static_label("detected.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.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.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.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),
|
||||
],
|
||||
}
|
||||
|
||||
# Human-readable strings for affect/measurement keys are produced from the tiered
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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");
|
||||
@@ -83,14 +83,27 @@ test("audio is a 0-10 level dial", async ({ page }) => {
|
||||
expect(ctl.max).toBe("10");
|
||||
});
|
||||
|
||||
test("the experience auto-starts once loaded: video on + audio at 3/10", async ({ page }) => {
|
||||
test("boots silent (video off, audio 0); turning video on couples audio to 2/10 and dismisses the Run prompt", async ({ page }) => {
|
||||
await boot(page);
|
||||
// No gesture needed — autoStart() flips Video on and lifts audio to a gentle level
|
||||
// the moment the preload finishes.
|
||||
// Boots SILENT — no un-gestured auto-start, so the browser autoplay block never bites.
|
||||
expect(await page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(false);
|
||||
expect(await page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("0");
|
||||
// "Run simulation" is revealed once preload finishes — the obvious starting point.
|
||||
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(false);
|
||||
// Turning Video on directly (a click gesture) lifts the audio dial to a gentle 2/10
|
||||
// and dismisses the Run prompt.
|
||||
await enableVideo(page);
|
||||
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2");
|
||||
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("2");
|
||||
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true);
|
||||
});
|
||||
|
||||
test("the Run simulation button starts video, snaps audio to 2/10, and dismisses itself", async ({ page }) => {
|
||||
await boot(page);
|
||||
await page.click("#run-sim");
|
||||
await expect.poll(() => page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(true);
|
||||
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3");
|
||||
// The level label reflects it.
|
||||
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3");
|
||||
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2");
|
||||
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true);
|
||||
});
|
||||
|
||||
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// The language dropdown localizes the visitor-facing chrome live (and resets to
|
||||
// English on reload — selection is session-only, no persistence). Annotation
|
||||
// re-rendering is covered by the unit tests (resolveStrings) + the manifest
|
||||
// invariant test; here we assert the DOM behavior of the dropdown itself.
|
||||
|
||||
async function boot(page: Page) {
|
||||
await page.goto("/");
|
||||
// initLanguage() populates the select during startup (before media preload).
|
||||
await expect(page.locator("#lang-select option")).toHaveCount(4);
|
||||
}
|
||||
|
||||
test("language dropdown localizes chrome and resets to English on reload", async ({ page }) => {
|
||||
await boot(page);
|
||||
const sel = page.locator("#lang-select");
|
||||
|
||||
// English selected by default
|
||||
await expect(sel).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||
|
||||
// switch to Spanish -> chrome changes, <html lang> updates
|
||||
await sel.selectOption("es");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Salida");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "es");
|
||||
|
||||
// Japanese
|
||||
await sel.selectOption("ja");
|
||||
await expect(page.locator('[data-i18n="knobs.think"]')).toHaveText("考える");
|
||||
|
||||
// reload returns to English (session-only, no persistence)
|
||||
await page.reload();
|
||||
await expect(page.locator("#lang-select")).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Regression: the steady-state loop (#vid-loop) restarts from the morph-tail
|
||||
// offset via a narrow timeupdate window. If that window is ever missed the clip
|
||||
// fires `ended` and — without a safety net — freezes on its last frame ("videos
|
||||
// aren't looping"). This asserts the `ended` safety net recovers playback.
|
||||
test("loop recovers when a clip reaches `ended` (no freeze on last frame)", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("#loading")?.classList.contains("done"),
|
||||
null,
|
||||
{ timeout: 45_000 },
|
||||
);
|
||||
|
||||
const r = await page.evaluate(async () => {
|
||||
const lv = document.getElementById("vid-loop") as HTMLVideoElement;
|
||||
// Reproduce the stuck state: parked on the last frame, as after a missed wrap.
|
||||
lv.pause();
|
||||
lv.currentTime = Math.max(0, lv.duration - 0.001);
|
||||
// The browser emits `ended` here in real life; emit it deterministically.
|
||||
lv.dispatchEvent(new Event("ended"));
|
||||
await new Promise((res) => setTimeout(res, 800));
|
||||
return { dur: +lv.duration.toFixed(2), ct: +lv.currentTime.toFixed(2), paused: lv.paused };
|
||||
});
|
||||
|
||||
// Recovered: playing again, and seeked back near the loop tail (not stuck at end).
|
||||
expect(r.paused).toBe(false);
|
||||
expect(r.ct).toBeLessThan(5);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
+12677
-7050
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
});
|
||||
+162
-47
@@ -53,16 +53,23 @@ let currentClipId = null; // base media currently loaded (reset to force a relo
|
||||
let busy = false; // true while a ring transition is playing
|
||||
let morphByPair = {}; // "fromClip→toClip" -> directed morph file (built from the ring)
|
||||
let lastOverlay = null; // {level, intensity} from the most-recent renderOverlay call; drives per-frame track animation
|
||||
let activeLang = "en"; // session-only; no persistence (resets to en each load)
|
||||
let lastAffect = null; // {strength, intensity, right} from the last renderAffect, for re-render on language switch
|
||||
|
||||
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) {
|
||||
@@ -85,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.
|
||||
@@ -116,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);
|
||||
}
|
||||
|
||||
@@ -166,7 +171,12 @@ function preloadOrder() {
|
||||
}
|
||||
}
|
||||
for (const f of reachableMorphFiles()) add(f);
|
||||
for (const f of preloadList()) add(f); // sweep up anything not on the ring
|
||||
for (const f of preloadList()) add(f); // sweep up every base not already on the ring
|
||||
// ALL morphs last: preloading every transition before interaction guarantees a
|
||||
// smooth knob turn anywhere (any random pool re-roll lands on an already-cached
|
||||
// morph). They're ordered last so the first scenes + nearest transitions cache
|
||||
// first and the loading bar fills meaningfully.
|
||||
for (const f of Object.values(morphByPair)) add(f);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
@@ -272,6 +282,17 @@ loopVid.addEventListener("timeupdate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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 tail and resume. Headless rarely
|
||||
// hits this; loaded real machines do.
|
||||
loopVid.addEventListener("ended", () => {
|
||||
if (loopVid.dataset.loopTail !== "1") return;
|
||||
try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ }
|
||||
loopVid.play().catch(() => {});
|
||||
});
|
||||
|
||||
// Compose the video look. The Right DREAM is now a painterly WebGL restyle of the
|
||||
// live frames (see the render loop below) — so here we only stash its intensity for
|
||||
// that loop, and build the Dark/Light mood grade as a CSS filter. The grade rides
|
||||
@@ -502,7 +523,7 @@ function renderOverlay(level, intensity) {
|
||||
overlay.innerHTML = "";
|
||||
if (!clip || level <= 0) { overlay.style.opacity = "0"; return; }
|
||||
overlay.style.opacity = String(intensity);
|
||||
const strings = (clip.strings && clip.strings.en) || {};
|
||||
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
|
||||
const t = loopT();
|
||||
let shown = 0;
|
||||
for (const a of clip.annotations) {
|
||||
@@ -548,12 +569,13 @@ function renderOverlay(level, intensity) {
|
||||
// scene points (no boxes — feelings are scene-level) and read softer than the
|
||||
// clinical reticles — feeling, not measurement.
|
||||
function renderAffect(strength, intensity, right) {
|
||||
lastAffect = { strength, intensity, right };
|
||||
const clip = activeClip();
|
||||
if (!affectLayer) return; // tolerate a stale page missing the affect layer
|
||||
affectLayer.innerHTML = "";
|
||||
if (!clip || !clip.affect || strength <= 0) { affectLayer.style.opacity = "0"; return; }
|
||||
affectLayer.style.opacity = String(intensity);
|
||||
const strings = (clip.strings && clip.strings.en) || {};
|
||||
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
|
||||
for (const f of clip.affect) {
|
||||
if (f.min_level > strength) continue;
|
||||
const [x, y] = f.at.map((n) => n * 100);
|
||||
@@ -572,7 +594,8 @@ function renderScaleReadout() {
|
||||
const member = (activeClip() && activeClip().title) || s.title;
|
||||
// scale id · the chosen pool member · position on the ring (pool size if >1)
|
||||
const poolTag = poolN > 1 ? ` · pool ${poolN}` : "";
|
||||
$("scale-name").textContent = `${s.id} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
|
||||
const scaleName = HEFi18n.pickUiString("scale." + s.id, activeLang);
|
||||
$("scale-name").textContent = `${scaleName} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
|
||||
renderDial();
|
||||
refreshDevClip(); // keep the Dev Mode pool picker + clip data in sync with the landing
|
||||
}
|
||||
@@ -599,12 +622,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.
|
||||
@@ -1020,6 +1052,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 {
|
||||
@@ -1128,7 +1161,10 @@ function scaleAudioUrl(index) {
|
||||
if (!ring || !ring.scales.length) return null;
|
||||
const s = ring.scales[HEFScrub.wrapIndex(index, ring.scales.length)];
|
||||
const a = s.audio || SCALE_AUDIO_FALLBACK[s.id];
|
||||
return a ? "/media/audio/" + a : null;
|
||||
// Route through mediaBase() so audio loads from R2 in the static build (and the
|
||||
// local /media mount in dev) — NOT a hardcoded /media path, which 404s on the
|
||||
// static site. mediaUrl() then passes this absolute url straight through.
|
||||
return a ? mediaBase() + "audio/" + a : null;
|
||||
}
|
||||
// The current resting altitude's soundtrack url (kept for the status readout).
|
||||
function soundtrackUrl() { return scaleAudioUrl(ringIndex); }
|
||||
@@ -1211,6 +1247,23 @@ function hideLoading() {
|
||||
|
||||
let videoEverOn = false; // has Video ever been switched on this session?
|
||||
|
||||
// The gentle audio level the experience starts at — coupled in on the first
|
||||
// Video-on (via the Run simulation button or the Video toggle directly).
|
||||
const START_AUDIO_LEVEL = "2";
|
||||
|
||||
// Begin the experience: Video on, audio snapped to the gentle start level, and
|
||||
// the "Run simulation" prompt dismissed. Audio is set + played in THIS gesture so
|
||||
// it unlocks on Safari (which blocks play() outside a user gesture). One flip = the
|
||||
// full experience at a gentle level. Idempotent across re-toggles via videoEverOn.
|
||||
function beginExperience() {
|
||||
videoEverOn = true;
|
||||
const btn = $("run-sim");
|
||||
if (btn) btn.classList.add("hidden");
|
||||
$("audio").value = START_AUDIO_LEVEL;
|
||||
updateAudioLevelLabel();
|
||||
applyAudio();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
devLiveReload();
|
||||
try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); }
|
||||
@@ -1218,6 +1271,7 @@ async function main() {
|
||||
await landScale(); // pick the initial scale's pool member before first render
|
||||
buildDial(); // draw the altitude knob from the ring's scales
|
||||
initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state)
|
||||
initLanguage(); // populate the language dropdown + wire live switching
|
||||
renderScaleReadout();
|
||||
// Sliders stream on "input"; the toggles fire on "change".
|
||||
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
|
||||
@@ -1226,14 +1280,17 @@ async function main() {
|
||||
// play() outside a user gesture).
|
||||
$("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); });
|
||||
updateAudioLevelLabel();
|
||||
// Video toggle: the FIRST time video turns on, bring audio up to 3/10 with it (one
|
||||
// flip = the full experience at a gentle level). If audio is already raised, leave it.
|
||||
// Set + played in THIS gesture so it unlocks on Safari.
|
||||
// "Run simulation" button: the obvious starting point. Turns Video on, snaps
|
||||
// audio to the start level, and dismisses itself — all in this click gesture.
|
||||
$("run-sim").addEventListener("click", () => {
|
||||
$("visual").checked = true; // setting .checked does NOT fire "change", so we drive the rest here
|
||||
beginExperience();
|
||||
debounced();
|
||||
});
|
||||
// Video toggle: the FIRST time video turns on (without the Run simulation button),
|
||||
// begin the experience too — couple audio in and dismiss the prompt.
|
||||
$("visual").addEventListener("change", () => {
|
||||
if ($("visual").checked && !videoEverOn) {
|
||||
videoEverOn = true;
|
||||
if (audioLevel() === 0) { $("audio").value = "3"; updateAudioLevelLabel(); applyAudio(); }
|
||||
}
|
||||
if ($("visual").checked && !videoEverOn) beginExperience();
|
||||
debounced();
|
||||
});
|
||||
// Altitude knob: drag to turn (commit detents on release), scroll to step, tap a label to jump.
|
||||
@@ -1243,21 +1300,79 @@ async function main() {
|
||||
dial.addEventListener("wheel", onWheel, { passive: false });
|
||||
$("stage").addEventListener("wheel", onWheel, { passive: false });
|
||||
update(); // render the initial state (both toggles off → black, silent)
|
||||
await preloadAllMedia(); // download all media, updating the loading bar
|
||||
hideLoading(); // experience is ready to run smoothly
|
||||
autoStart(); // once loaded: video on + gentle audio (3/10), no gesture needed
|
||||
// Preload BEFORE interaction: a base for every altitude + all related morphs, so
|
||||
// turning the knob is always smooth (no on-demand stall, no blank next scene). The
|
||||
// "Loading Universe…" bar tracks this; the universe stays gated until it's ready.
|
||||
await preloadAllMedia();
|
||||
hideLoading(); // experience is ready — boots SILENT (video off, audio 0)
|
||||
$("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage
|
||||
// No un-gestured auto-start: a browser autoplay policy blocks an audio play() made
|
||||
// outside a user gesture (muted video survives, sound does not), so an auto-start
|
||||
// would show video but stay silent. Instead the experience waits for the operator to
|
||||
// press "Run simulation" (or turn Video on directly) — that click IS the gesture, and
|
||||
// beginExperience() lifts audio to the start level and plays the soundtrack in-gesture,
|
||||
// so sound reliably comes up with video.
|
||||
}
|
||||
|
||||
// Auto-start the experience the moment media is ready: bring Video on and the audio
|
||||
// level to 3/10 (the same gentle default the first manual Video-on couples in). The
|
||||
// audio <audio> elements ideally unlock inside a user gesture (Safari); this fires
|
||||
// outside one, so a browser with a strict autoplay policy may BLOCK the first audio
|
||||
// play() until any interaction — the on-screen audio status readout will show it.
|
||||
function autoStart() {
|
||||
$("visual").checked = true;
|
||||
videoEverOn = true;
|
||||
if (audioLevel() === 0) { $("audio").value = "3"; updateAudioLevelLabel(); }
|
||||
applyAudio();
|
||||
update();
|
||||
// Populate the language dropdown from the registry and wire live switching.
|
||||
// English default, session-only (no persistence). Switching swaps UI chrome
|
||||
// and re-renders the current annotations/affect in place — no reload, no fetch.
|
||||
function initLanguage() {
|
||||
const sel = $("lang-select");
|
||||
if (!sel) return;
|
||||
for (const { code, nativeName } of HEFi18n.LANGUAGES) {
|
||||
const o = document.createElement("option");
|
||||
o.value = code;
|
||||
o.textContent = nativeName;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = activeLang;
|
||||
applyUiStrings(activeLang);
|
||||
sel.addEventListener("change", () => setLanguage(sel.value));
|
||||
}
|
||||
|
||||
// Fill every [data-i18n] node with its catalog string for `lang`.
|
||||
function applyUiStrings(lang) {
|
||||
document.documentElement.lang = lang;
|
||||
for (const el of document.querySelectorAll("[data-i18n]")) {
|
||||
el.textContent = HEFi18n.pickUiString(el.getAttribute("data-i18n"), lang);
|
||||
}
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
activeLang = lang;
|
||||
applyUiStrings(lang);
|
||||
renderScaleReadout();
|
||||
if (lastOverlay) renderOverlay(lastOverlay.level, lastOverlay.intensity);
|
||||
if (lastAffect) renderAffect(lastAffect.strength, lastAffect.intensity, lastAffect.right);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
// Temporary ?debug overlay: an on-screen readout of media/render state, for
|
||||
// diagnosing real-browser playback that headless can't reproduce. Add ?debug to the
|
||||
// URL to show it. Harmless/no-op without the flag; safe to remove once resolved.
|
||||
(function debugOverlay() {
|
||||
if (!/[?&]debug\b/.test(location.search)) return;
|
||||
const box = document.createElement("div");
|
||||
box.style.cssText = "position:fixed;left:6px;bottom:6px;z-index:99999;max-width:96vw;" +
|
||||
"background:rgba(0,0,0,.82);color:#0f0;font:11px/1.35 monospace;padding:8px 10px;" +
|
||||
"white-space:pre-wrap;border:1px solid #0a0;border-radius:4px;";
|
||||
document.body.appendChild(box);
|
||||
let webgl = "?";
|
||||
try { const c = document.createElement("canvas"); webgl = c.getContext("webgl2") ? "webgl2" : (c.getContext("webgl") ? "webgl1" : "NONE"); }
|
||||
catch (e) { webgl = "throw:" + e.message; }
|
||||
const errs = [];
|
||||
window.addEventListener("error", (e) => { errs.push((e.message || e.error || "err").toString().slice(0, 80)); });
|
||||
const m = (el) => el ? `rs=${el.readyState} ${el.paused ? "PAUSED" : "play"} t=${(el.currentTime || 0).toFixed(1)} err=${el.error ? el.error.code : "-"} ${(el.currentSrc || el.src || "(no src)").slice(0, 46)}` : "absent";
|
||||
setInterval(() => {
|
||||
const cfg = window.HEF_CONFIG || {};
|
||||
box.textContent =
|
||||
`cfg: static=${cfg.static} base=${cfg.mediaBase}\n` +
|
||||
`webgl=${webgl} visual=${(document.getElementById("visual") || {}).checked}\n` +
|
||||
`loop : ${m(document.getElementById("vid-loop"))}\n` +
|
||||
`morph: ${m(document.getElementById("vid"))}\n` +
|
||||
`aud : ${m(document.getElementById("aud"))}\n` +
|
||||
`errs : ${errs.slice(-3).join(" | ") || "none"}`;
|
||||
}, 400);
|
||||
})();
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Credits — Human Experience Filter</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="credits-page">
|
||||
<main class="credits-wrap">
|
||||
<header>
|
||||
<h1>Credits & licenses</h1>
|
||||
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
|
||||
</header>
|
||||
|
||||
<!-- Option D1: the altered work's own license + the "modified" statement that
|
||||
CC BY / CC BY-SA attribution requires. Heading deliberately NOT "About" — the
|
||||
separate About page covers the project's intent. -->
|
||||
<section class="declaration">
|
||||
<h2>License & reuse</h2>
|
||||
<p>
|
||||
The <em>Human Experience Filter</em> alters its source footage in real time
|
||||
(overlays, an emotion/affect channel, and a WebGL “dream” shader), so every
|
||||
frame you see is a <strong>derivative</strong> of the original clip.
|
||||
</p>
|
||||
<p>
|
||||
This altered work is offered under
|
||||
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="license">Creative
|
||||
Commons Attribution-ShareAlike 4.0 (CC BY-SA 4.0)</a>. The original sources it
|
||||
builds on are credited below. This is a non-commercial art installation.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Footage</h2>
|
||||
<p class="note">
|
||||
Original video sources and their licenses. Public-domain (US-government) and
|
||||
CC0 clips need no attribution; the names below for CC BY / CC BY-SA clips are
|
||||
required credit. All footage is altered as described above.
|
||||
</p>
|
||||
<div id="video-credits" class="credits-list">
|
||||
<p class="loading">Loading credits…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audio is all public-domain / CC0, so attribution is courtesy, not required;
|
||||
it isn't tracked per-clip in the API, so it's acknowledged here as a block. -->
|
||||
<section>
|
||||
<h2>Sound</h2>
|
||||
<p class="note">
|
||||
Soundtracks are public domain or CC0 — credit is courtesy, not required.
|
||||
</p>
|
||||
<ul class="audio-credits">
|
||||
<li><strong>NASA / Chandra X-ray Observatory (CXC/SAO)</strong> — cosmos sonification (public domain).</li>
|
||||
<li><strong>NOAA</strong> SanctSound & PMEL hydrophone recordings — reef/abyss biological layers (public domain).</li>
|
||||
<li><strong>freesound.org</strong> contributors — Sonicfreak (station room-tone) and DCSFX (underwater bed) (CC0).</li>
|
||||
<li><strong>BigSoundBank</strong> — sea waves & gull calls (CC0).</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script src="credits.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
// Credits/colophon data + render — no framework. UMD so the browser gets a
|
||||
// `HEFCredits` global and `node --test` can require() the pure functions.
|
||||
//
|
||||
// The legally load-bearing attribution is per VIDEO clip (CC BY / CC BY-SA need
|
||||
// the author + license shown to the viewer, and the footage is altered → a
|
||||
// derivative). That data already ships in clips.json (baked from /api/clips):
|
||||
// each clip carries free-text `license` + `source`. This module turns that into
|
||||
// the credits list the public colophon (credits.html) renders. Audio is all
|
||||
// PD/CC0 and credited as a static courtesy block in the page itself.
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFCredits = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// One attribution entry per clip, sorted by id (deterministic). A clip is
|
||||
// NEVER silently dropped — even one with an empty license appears, so a
|
||||
// missing credit is visible rather than hidden. License/source are the
|
||||
// free-text strings authored in the manifest.
|
||||
function creditEntries(clips) {
|
||||
return (clips || [])
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title || c.id,
|
||||
license: c.license || "",
|
||||
source: c.source || "",
|
||||
}))
|
||||
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
||||
}
|
||||
|
||||
function creditHtml(e) {
|
||||
const lic = e.license
|
||||
? `<span class="lic">${escapeHtml(e.license)}</span>`
|
||||
: `<span class="lic missing">license not recorded</span>`;
|
||||
const src = e.source ? `<div class="src">${escapeHtml(e.source)}</div>` : "";
|
||||
return (
|
||||
`<div class="credit">` +
|
||||
`<div class="title">${escapeHtml(e.title)}</div>` +
|
||||
lic + src +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function creditsListHtml(clips) {
|
||||
return creditEntries(clips).map(creditHtml).join("");
|
||||
}
|
||||
|
||||
// Browser bootstrap: fetch the same clips the experience uses (baked
|
||||
// clips.json in the static build, the live API otherwise — the config.js
|
||||
// flag, loaded before this script, decides) and fill the container.
|
||||
function init(doc, cfg) {
|
||||
const fetchUrl = cfg && cfg.static ? "clips.json" : "/api/clips";
|
||||
const box = doc.getElementById("video-credits");
|
||||
if (!box) return;
|
||||
return fetch(fetchUrl)
|
||||
.then((r) => r.json())
|
||||
.then((d) => { box.innerHTML = creditsListHtml(d.clips || []); })
|
||||
.catch((err) => { box.innerHTML = `<p class="err">Could not load credits: ${escapeHtml(err.message)}</p>`; });
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init(document, (typeof window !== "undefined" && window.HEF_CONFIG) || {});
|
||||
});
|
||||
}
|
||||
|
||||
return { escapeHtml, creditEntries, creditHtml, creditsListHtml, init };
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// UI/annotation localization — registry, control-string catalog, and pure
|
||||
// lookup helpers. UMD so the browser gets a `HEFi18n` global and
|
||||
// `node --test` can require() it. English is the source of truth & fallback.
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFi18n = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "en", nativeName: "English" },
|
||||
{ code: "es", nativeName: "Español" },
|
||||
{ code: "fr", nativeName: "Français" },
|
||||
{ code: "ja", nativeName: "日本語" },
|
||||
];
|
||||
|
||||
// Visitor-facing control chrome. Keys match `data-i18n` attributes in index.html.
|
||||
const UI_STRINGS = {
|
||||
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre d’Expérience Humaine — Aperçu d’altération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
|
||||
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de l’univers", ja: "宇宙を読み込み中" },
|
||||
"run.button": { en: "Run simulation", es: "Iniciar simulación", fr: "Lancer la simulation", ja: "シミュレーションを開始" },
|
||||
"credits.link": { en: "ⓘ Credits & licenses", es: "ⓘ Créditos y licencias", fr: "ⓘ Crédits et licences", ja: "ⓘ クレジットとライセンス" },
|
||||
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
|
||||
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
|
||||
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
|
||||
"altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
|
||||
"altitude.hint": { en: "Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.", es: "Gira el dial (arrástralo o desplázate) para cambiar la altitud — sin fin: tras lo más profundo vuelve a lo más alto. Haz clic en una etiqueta para saltar.", fr: "Tournez le cadran (glissez ou faites défiler) pour changer d’altitude — sans fin : après le plus profond, on revient au plus haut. Cliquez sur une étiquette pour y aller.", ja: "ダイヤルを回して(ドラッグまたはスクロール)高度を変えます — 無限ループ:最も深い先は最も高い所へ戻ります。ラベルをクリックでそこへ移動。" },
|
||||
"knobs.legend": { en: "Experience knobs (0–4)", es: "Mandos de experiencia (0–4)", fr: "Boutons d’expérience (0–4)", ja: "体験つまみ(0〜4)" },
|
||||
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
|
||||
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
|
||||
"knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
|
||||
"devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
|
||||
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
|
||||
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
|
||||
"scale.sky": { en: "sky", es: "cielo", fr: "ciel", ja: "空" },
|
||||
"scale.coast": { en: "coast", es: "costa", fr: "côte", ja: "海岸" },
|
||||
"scale.reef": { en: "reef", es: "arrecife", fr: "récif", ja: "サンゴ礁" },
|
||||
"scale.abyss": { en: "abyss", es: "abismo", fr: "abîme", ja: "深海" },
|
||||
};
|
||||
|
||||
function pickUiString(key, lang) {
|
||||
const v = UI_STRINGS[key];
|
||||
if (!v) return key;
|
||||
return (v[lang] != null ? v[lang] : v.en) || key;
|
||||
}
|
||||
|
||||
function resolveStrings(clipStrings, lang) {
|
||||
if (!clipStrings || !clipStrings.en) return (clipStrings && clipStrings[lang]) || {};
|
||||
if (lang === "en" || !clipStrings[lang]) return clipStrings.en;
|
||||
return Object.assign({}, clipStrings.en, clipStrings[lang]);
|
||||
}
|
||||
|
||||
return { LANGUAGES, UI_STRINGS, pickUiString, resolveStrings };
|
||||
});
|
||||
+29
-19
@@ -4,66 +4,73 @@
|
||||
<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">
|
||||
<div class="loading-inner">
|
||||
<div class="loading-title">Loading Universe<span class="loading-dots"></span></div>
|
||||
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
|
||||
<div class="loading-bar"><div id="loading-fill"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<header><h1>Human Experience Filter — Alteration Preview</h1></header>
|
||||
<header>
|
||||
<h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1>
|
||||
<a href="credits.html" class="credits-link" data-i18n="credits.link">ⓘ Credits & licenses</a>
|
||||
</header>
|
||||
<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>
|
||||
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
|
||||
<div id="black" class="black hidden"></div>
|
||||
<button type="button" id="run-sim" class="run-sim hidden" data-i18n="run.button">Run simulation</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<fieldset>
|
||||
<legend>Output</legend>
|
||||
<legend data-i18n="output.legend">Output</legend>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="lang-select" aria-label="Language"></select>
|
||||
</label>
|
||||
<label class="dev-switch" for="visual">
|
||||
<input type="checkbox" id="visual" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label">Video</span>
|
||||
<span class="dev-switch-label" data-i18n="output.video">Video</span>
|
||||
</label>
|
||||
<label class="audio-level">Audio
|
||||
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
|
||||
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
|
||||
<span id="audio-level-val">0</span>/10
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Altitude</legend>
|
||||
<legend data-i18n="altitude.legend">Altitude</legend>
|
||||
<div class="dial-wrap">
|
||||
<svg id="dial" viewBox="0 0 100 100" aria-label="Altitude knob (turn to change scale)"></svg>
|
||||
</div>
|
||||
<span id="scale-name" class="scale-name">—</span>
|
||||
<p class="hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
|
||||
<p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Experience knobs (0–4)</legend>
|
||||
<label>Think <input type="range" id="left" min="0" max="4" value="0" /></label>
|
||||
<label>Feel <input type="range" id="right" min="0" max="4" value="0" /></label>
|
||||
<label>Mood — dark ◀ 0 ▶ light <input type="range" id="mood" min="-4" max="4" value="0" /></label>
|
||||
<legend data-i18n="knobs.legend">Experience knobs (0–4)</legend>
|
||||
<label><span data-i18n="knobs.think">Think</span> <input type="range" id="left" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.feel">Feel</span> <input type="range" id="right" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.mood">Mood — dark ◀ 0 ▶ light</span> <input type="range" id="mood" min="-4" max="4" value="0" /></label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Dev Mode: a single toggle; everything dev (pool picker + analysis) lives below it. -->
|
||||
<label class="dev-switch" for="dev-mode">
|
||||
<input type="checkbox" id="dev-mode" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label">Dev Mode</span>
|
||||
<span class="dev-switch-label" data-i18n="devmode.label">Dev Mode</span>
|
||||
</label>
|
||||
|
||||
<div id="dev-panel" class="dev-panel hidden">
|
||||
@@ -104,7 +111,10 @@
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/scrub.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,7 +1,32 @@
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: #111; color: #eee; }
|
||||
header { padding: 0.6rem 1rem; background: #000; }
|
||||
header { padding: 0.6rem 1rem; background: #000; display: flex; align-items: baseline;
|
||||
justify-content: space-between; gap: 1rem; }
|
||||
h1 { font-size: 1rem; margin: 0; font-weight: 600; }
|
||||
/* Always-visible link to the credits/colophon — reachable from the work, which is
|
||||
what CC BY / BY-SA attribution "reasonable to the medium" calls for. */
|
||||
.credits-link { color: #9af; text-decoration: none; font-size: 12px; white-space: nowrap; flex: none; }
|
||||
.credits-link:hover { text-decoration: underline; }
|
||||
|
||||
/* --- Credits / colophon page (credits.html) --- */
|
||||
.credits-page { background: #0c0f14; }
|
||||
.credits-wrap { max-width: 760px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.credits-wrap h1 { font-size: 1.6rem; }
|
||||
.credits-wrap h2 { font-size: 1.05rem; color: #9af; margin: 1.8rem 0 0.5rem; }
|
||||
.credits-wrap a { color: #9af; }
|
||||
.credits-wrap .back-link { font-size: 13px; }
|
||||
.credits-wrap .note { color: #9ab; font-size: 13px; }
|
||||
.declaration { background: #131a24; border: 1px solid #233; border-radius: 6px;
|
||||
padding: 0.8rem 1rem; margin-top: 1rem; }
|
||||
.credits-list { display: grid; gap: 0.7rem; margin-top: 0.8rem; }
|
||||
.credit { background: #131820; border: 1px solid #1f2a3a; border-radius: 5px; padding: 0.6rem 0.8rem; }
|
||||
.credit .title { font-weight: 600; color: #dfe7f2; }
|
||||
.credit .lic { display: inline-block; margin-top: 0.2rem; font-size: 12px; color: #cbd; }
|
||||
.credit .lic.missing { color: #f97; }
|
||||
.credit .src { margin-top: 0.3rem; font-size: 12px; color: #8aa; line-height: 1.5; }
|
||||
.audio-credits { color: #b9c4d2; font-size: 13px; line-height: 1.6; padding-left: 1.2rem; }
|
||||
.credits-list .loading, .credits-list .err { color: #9ab; }
|
||||
.credits-list .err { color: #f97; }
|
||||
main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: flex-start; }
|
||||
/* Stage stays pinned in view while the right pane scrolls on its own. */
|
||||
.stage { flex: 1 1 640px; position: sticky; top: 1rem; }
|
||||
@@ -51,6 +76,20 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
|
||||
.black { position: absolute; inset: 0; background: #000; opacity: 1;
|
||||
z-index: 50; transform: translateZ(0); transition: opacity 200ms ease; }
|
||||
.hidden { display: none; }
|
||||
/* "Run simulation" — the obvious starting point, shown over the (black) stage once
|
||||
media is preloaded. Sits above the black cover (z-index 50). Dismissed when the
|
||||
experience begins (the button, or turning Video on directly). */
|
||||
.run-sim {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
z-index: 60; cursor: pointer; user-select: none;
|
||||
padding: 0.85rem 2rem; border: 0; border-radius: 999px;
|
||||
font: 600 18px/1 system-ui, sans-serif; letter-spacing: 0.03em;
|
||||
color: #04101f; background: linear-gradient(90deg, #4e9cff, #9af);
|
||||
box-shadow: 0 4px 24px rgba(78, 156, 255, 0.45);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.run-sim:hover { transform: translate(-50%, -50%) scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
|
||||
.run-sim:active { transform: translate(-50%, -50%) scale(0.98); }
|
||||
/* Stack the two Output toggles (Video / Audio) with a little breathing room. */
|
||||
.dev-switch + .dev-switch { margin-top: 0.45rem; }
|
||||
/* Live audio status readout (diagnostic) — turns green when actually playing. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const C = require("../static/credits.js");
|
||||
|
||||
const CLIPS = [
|
||||
{ id: "reef_x", title: "Reef X", license: "CC-BY-SA 4.0 — credit Joe Mabel", source: "Wikimedia <reef>" },
|
||||
{ id: "cosmos", title: "Cosmic Cliffs", license: "CC-BY-class — credit NASA, ESA, CSA, STScI", source: "NASA SVS 31348" },
|
||||
{ id: "pd_only", title: "PD clip", license: "public-domain (US Gov, 17 U.S.C. §105)", source: "NASA" },
|
||||
];
|
||||
|
||||
test("creditEntries returns one entry per clip, preserving license/source", () => {
|
||||
const out = C.creditEntries(CLIPS);
|
||||
assert.equal(out.length, CLIPS.length, "no clip is silently dropped from the credits");
|
||||
const reef = out.find((e) => e.id === "reef_x");
|
||||
assert.equal(reef.license, "CC-BY-SA 4.0 — credit Joe Mabel");
|
||||
assert.equal(reef.source, "Wikimedia <reef>");
|
||||
assert.equal(reef.title, "Reef X");
|
||||
});
|
||||
|
||||
test("creditEntries is sorted by id for deterministic output", () => {
|
||||
const ids = C.creditEntries(CLIPS).map((e) => e.id);
|
||||
assert.deepEqual(ids, ["cosmos", "pd_only", "reef_x"]);
|
||||
});
|
||||
|
||||
test("creditEntries falls back title->id and tolerates missing fields", () => {
|
||||
const out = C.creditEntries([{ id: "bare" }]);
|
||||
assert.deepEqual(out, [{ id: "bare", title: "bare", license: "", source: "" }]);
|
||||
});
|
||||
|
||||
test("creditEntries handles null/empty input", () => {
|
||||
assert.deepEqual(C.creditEntries(null), []);
|
||||
assert.deepEqual(C.creditEntries([]), []);
|
||||
});
|
||||
|
||||
test("creditsListHtml renders every clip's attribution and escapes HTML", () => {
|
||||
const html = C.creditsListHtml(CLIPS);
|
||||
assert.match(html, /Cosmic Cliffs/);
|
||||
assert.match(html, /Joe Mabel/);
|
||||
// free-text source "Wikimedia <reef>" must be escaped, not injected as a tag
|
||||
assert.match(html, /Wikimedia <reef>/);
|
||||
assert.doesNotMatch(html, /<reef>/);
|
||||
// one block per clip
|
||||
const blocks = html.match(/class="credit"/g) || [];
|
||||
assert.equal(blocks.length, CLIPS.length);
|
||||
});
|
||||
|
||||
test("credits.html wires config.js + credits.js, the attributions container, and a back link", () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, "../static/credits.html"), "utf8");
|
||||
assert.match(html, /<script src="config\.js">/); // static/live media-base + flag
|
||||
assert.match(html, /<script src="credits\.js">/);
|
||||
assert.match(html, /id="video-credits"/); // JS fills this
|
||||
assert.match(html, /href="index\.html"/); // back into the experience
|
||||
});
|
||||
|
||||
test("credits.html carries the CC BY-SA derivative declaration (Option D1)", () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, "../static/credits.html"), "utf8");
|
||||
assert.match(html, /CC BY-SA 4\.0/); // the altered work's own license
|
||||
assert.match(html, /altered/i); // states the footage is modified
|
||||
// The section is NOT titled "About this work" — that name belongs to the separate
|
||||
// About page; here the declaration lives under "License & reuse".
|
||||
assert.doesNotMatch(html, /About this work/i);
|
||||
assert.match(html, /License & reuse/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const I = require("../static/i18n.js");
|
||||
|
||||
test("LANGUAGES lists the four codes in order, English first", () => {
|
||||
assert.deepEqual(I.LANGUAGES.map((l) => l.code), ["en", "es", "fr", "ja"]);
|
||||
assert.equal(I.LANGUAGES[0].nativeName, "English");
|
||||
assert.equal(I.LANGUAGES.find((l) => l.code === "ja").nativeName, "日本語");
|
||||
});
|
||||
|
||||
test("pickUiString returns the language value, falls back to en, then to key", () => {
|
||||
assert.equal(I.pickUiString("output.legend", "es"), I.UI_STRINGS["output.legend"].es);
|
||||
// a key present in en but (hypothetically) missing in fr falls back to en:
|
||||
const saved = I.UI_STRINGS["output.legend"].fr;
|
||||
delete I.UI_STRINGS["output.legend"].fr;
|
||||
assert.equal(I.pickUiString("output.legend", "fr"), I.UI_STRINGS["output.legend"].en);
|
||||
I.UI_STRINGS["output.legend"].fr = saved;
|
||||
assert.equal(I.pickUiString("totally.unknown.key", "en"), "totally.unknown.key");
|
||||
});
|
||||
|
||||
test("UI_STRINGS has every language for every key", () => {
|
||||
for (const [key, vals] of Object.entries(I.UI_STRINGS)) {
|
||||
for (const code of ["en", "es", "fr", "ja"]) {
|
||||
assert.ok(typeof vals[code] === "string" && vals[code].length, `${key} missing ${code}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveStrings merges lang over en (per-key fallback)", () => {
|
||||
const cs = { en: { a: "A", b: "B" }, es: { a: "Aes" } };
|
||||
assert.deepEqual(I.resolveStrings(cs, "es"), { a: "Aes", b: "B" });
|
||||
assert.deepEqual(I.resolveStrings(cs, "fr"), { a: "A", b: "B" }); // no fr -> all en
|
||||
assert.deepEqual(I.resolveStrings({}, "es"), {});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const I = require("../static/i18n.js");
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, "../static/index.html"), "utf8");
|
||||
|
||||
test("index.html includes the i18n script and a language select", () => {
|
||||
assert.match(html, /<script src="i18n\.js">/); // relative: works at dev-root and under the deploy path prefix
|
||||
assert.match(html, /id="lang-select"/);
|
||||
});
|
||||
|
||||
test("every data-i18n key in index.html exists in UI_STRINGS", () => {
|
||||
const keys = [...html.matchAll(/data-i18n="([^"]+)"/g)].map((m) => m[1]);
|
||||
assert.ok(keys.length >= 12, `expected many tagged nodes, found ${keys.length}`);
|
||||
for (const k of keys) assert.ok(I.UI_STRINGS[k], `data-i18n key not in catalog: ${k}`);
|
||||
});
|
||||
@@ -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
|
||||
@@ -0,0 +1,53 @@
|
||||
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()
|
||||
# Credits/colophon ships so CC-BY / CC-BY-SA attribution is reachable from the public site.
|
||||
assert (app_dir / "credits.html").exists()
|
||||
assert (app_dir / "credits.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())
|
||||
+56
-13
@@ -2,12 +2,15 @@
|
||||
cleanly when Playwright or its browser binary is absent.
|
||||
|
||||
NOTE: headless engines relax BOTH autoplay and GPU compositing, so these assert
|
||||
the WIRING (auto-start on load, dial→play/volume, video-off blanking) — real
|
||||
Safari/iOS autoplay and real-GPU compositing still need a device by-ear/eye check.
|
||||
the WIRING (boot-silent, video-on couples audio, dial→play/volume, video-off
|
||||
blanking) — real Safari/iOS autoplay and real-GPU compositing still need a device
|
||||
by-ear/eye check.
|
||||
|
||||
The experience AUTO-STARTS once the preload finishes: Video turns on and the audio
|
||||
level dial rises to 3/10 with no user gesture (app.js autoStart()). Audio is a 0–10
|
||||
range dial (not a checkbox); set it by writing `value` + firing an `input` event.
|
||||
The experience BOOTS SILENT — video off, audio dial at 0 — so nothing plays until a
|
||||
real user gesture. The "Run simulation" button (or turning Video ON directly) couples
|
||||
the audio dial up to 2/10 and starts the soundtrack IN that gesture, sidestepping the
|
||||
browser autoplay block that swallowed an un-gestured auto-start. Audio is a 0–10 range
|
||||
dial (not a checkbox); set it by writing `value` + firing an `input` event.
|
||||
|
||||
Install: pip install -e '.[e2e]' && python -m playwright install chromium
|
||||
"""
|
||||
@@ -63,7 +66,7 @@ def page(app_url):
|
||||
pg = browser.new_page()
|
||||
pg.goto(app_url)
|
||||
# wait out the "Loading Universe…" splash (it overlays + blocks clicks); the
|
||||
# auto-start fires as it dismisses.
|
||||
# experience boots silent (video off, audio 0) as it dismisses.
|
||||
pg.wait_for_selector("#loading", state="detached", timeout=60000)
|
||||
yield pg
|
||||
browser.close()
|
||||
@@ -84,12 +87,49 @@ def _set_audio(page, level):
|
||||
)
|
||||
|
||||
|
||||
def test_app_auto_starts_video_and_audio(page):
|
||||
# Once the preload finishes the experience auto-starts: Video on + audio at 3/10,
|
||||
# video showing (black hidden), a scale soundtrack playing — no tap-to-start wall.
|
||||
def test_app_boots_silent_video_off(page):
|
||||
# The experience boots SILENT: Video off, audio dial at 0, screen black, both
|
||||
# crossfade elements paused — nothing plays until a real user gesture.
|
||||
assert page.is_checked("#visual") is False
|
||||
assert page.evaluate("document.getElementById('audio').value") == "0"
|
||||
assert page.evaluate("document.getElementById('audio-level-val').textContent") == "0"
|
||||
page.wait_for_selector("#black:not(.hidden)") # screen blanked
|
||||
assert page.evaluate(
|
||||
"(() => { const a = document.getElementById('aud'), b = document.getElementById('aud-b');"
|
||||
" return a.paused && b.paused; })()"
|
||||
) is True
|
||||
|
||||
|
||||
def test_run_simulation_button_shows_after_load(page):
|
||||
# Once media is preloaded the "Run simulation" prompt is revealed over the (still
|
||||
# black) stage — the obvious starting point — while the experience stays silent.
|
||||
page.wait_for_selector("#run-sim:not(.hidden)")
|
||||
assert page.is_checked("#visual") is False
|
||||
assert page.evaluate("document.getElementById('audio').value") == "0"
|
||||
|
||||
|
||||
def test_run_simulation_button_starts_video_and_audio(page):
|
||||
# Pressing "Run simulation" (a click gesture) turns Video on, snaps the audio dial
|
||||
# to 2/10, dismisses itself, and starts the soundtrack IN that gesture.
|
||||
page.click("#run-sim")
|
||||
page.wait_for_selector("#run-sim.hidden", state="attached") # prompt dismissed
|
||||
assert page.is_checked("#visual") is True
|
||||
page.wait_for_function("document.getElementById('audio').value === '3'")
|
||||
page.wait_for_function("document.getElementById('audio-level-val').textContent === '3'")
|
||||
page.wait_for_function("document.getElementById('audio').value === '2'")
|
||||
page.wait_for_function("document.getElementById('audio-level-val').textContent === '2'")
|
||||
page.wait_for_selector("#black.hidden", state="attached") # video showing
|
||||
page.wait_for_function(
|
||||
"(() => { const a = document.getElementById('aud');"
|
||||
" return /\\/media\\/audio\\/.+\\.mp3/.test(a.src) && !a.paused; })()"
|
||||
)
|
||||
|
||||
|
||||
def test_video_on_directly_couples_audio_to_two_and_dismisses_button(page):
|
||||
# Turning Video on directly (skipping the Run simulation button) ALSO begins the
|
||||
# experience: audio snaps to 2/10, the soundtrack plays, and the button goes away.
|
||||
_toggle_visual(page) # off -> on
|
||||
page.wait_for_function("document.getElementById('audio').value === '2'")
|
||||
page.wait_for_function("document.getElementById('audio-level-val').textContent === '2'")
|
||||
page.wait_for_selector("#run-sim.hidden", state="attached") # prompt dismissed
|
||||
page.wait_for_selector("#black.hidden", state="attached") # video showing
|
||||
page.wait_for_function(
|
||||
"(() => { const a = document.getElementById('aud');"
|
||||
@@ -113,8 +153,11 @@ def test_audio_dial_zero_silences(page):
|
||||
assert page.evaluate("document.getElementById('audio').value") == "0"
|
||||
|
||||
|
||||
def test_video_off_blanks_after_autostart(page):
|
||||
# Video is auto-on; turning it off blanks the screen and hides the GPU layers.
|
||||
def test_video_off_blanks(page):
|
||||
# Turn Video on (boot is off), then off again: that blanks the screen and hides
|
||||
# the GPU layers.
|
||||
_toggle_visual(page) # off -> on
|
||||
page.wait_for_selector("#black.hidden", state="attached")
|
||||
_toggle_visual(page) # on -> off
|
||||
page.wait_for_selector("#black:not(.hidden)")
|
||||
page.wait_for_function(
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""E2E (Playwright) — the public credits/colophon page actually surfaces the
|
||||
CC-BY / CC-BY-SA video attribution to a visitor.
|
||||
|
||||
Unit tests pin the credits *logic* (creditEntries/creditsListHtml) and the build
|
||||
ships the files; this confirms the live integration: credits.js fetches the clips
|
||||
and fills the page in a real browser, and the experience links to it. The suite
|
||||
skips cleanly when Playwright/Chromium is absent (headless box)."""
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with closing(socket.socket()) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url():
|
||||
import uvicorn
|
||||
|
||||
from simulator.app import app
|
||||
|
||||
port = _free_port()
|
||||
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
for _ in range(50):
|
||||
if server.started:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
if not server.started:
|
||||
pytest.skip("uvicorn did not start")
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browser_page(app_url):
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser = p.chromium.launch()
|
||||
except Exception as exc: # no browser binary installed
|
||||
pytest.skip(f"chromium not available: {exc}")
|
||||
page = browser.new_page()
|
||||
page._app_url = app_url # stash for the tests
|
||||
yield page
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_credits_page_lists_video_attributions(browser_page):
|
||||
page = browser_page
|
||||
page.goto(page._app_url + "/credits.html")
|
||||
# credits.js fetches /api/clips and fills the container with one block per clip.
|
||||
page.wait_for_selector("#video-credits .credit", timeout=30000)
|
||||
count = page.eval_on_selector_all("#video-credits .credit", "els => els.length")
|
||||
assert count >= 40, f"expected the full clip pool credited, got {count}"
|
||||
# the required CC-BY / CC-BY-SA credits are visible to the viewer
|
||||
body = page.inner_text("body")
|
||||
assert "NASA, ESA, CSA, STScI" in body
|
||||
assert "Joe Mabel" in body # a CC BY-SA clip's required author credit
|
||||
assert "CC BY-SA 4.0" in body # the Option-D1 derivative declaration ("License & reuse")
|
||||
|
||||
|
||||
def test_experience_links_to_credits(browser_page):
|
||||
page = browser_page
|
||||
page.goto(page._app_url + "/")
|
||||
page.wait_for_selector("#loading", state="detached", timeout=60000)
|
||||
href = page.get_attribute(".credits-link", "href")
|
||||
assert href == "credits.html"
|
||||
page.click(".credits-link")
|
||||
page.wait_for_selector("#video-credits .credit", timeout=30000)
|
||||
@@ -0,0 +1,25 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
LANGS = ["en", "es", "fr", "ja"]
|
||||
MANIFEST = Path("simulator/sample_media/manifest.json")
|
||||
|
||||
|
||||
def _tier_len(v):
|
||||
return len(v) if isinstance(v, list) else 0 # 0 = plain string
|
||||
|
||||
|
||||
def test_every_clip_has_all_languages_with_matching_shape():
|
||||
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
for clip in manifest["clips"]:
|
||||
s = clip.get("strings", {})
|
||||
en = s.get("en")
|
||||
if not en:
|
||||
continue
|
||||
for lang in LANGS:
|
||||
assert lang in s, f"{clip['id']} missing language {lang}"
|
||||
assert set(s[lang]) == set(en), f"{clip['id']}/{lang} key-set differs from en"
|
||||
for key, en_val in en.items():
|
||||
assert _tier_len(s[lang][key]) == _tier_len(en_val), (
|
||||
f"{clip['id']}/{lang}/{key} tier-count differs from en"
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.i18n.translate_manifest import extract_en_catalog, merge_catalog
|
||||
|
||||
MANIFEST = {
|
||||
"clips": [
|
||||
{"id": "cosmos", "strings": {"en": {
|
||||
"detected.star": ["star", "young star"],
|
||||
"measure.distance": "≈7,500 ly",
|
||||
}}},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_extract_pulls_every_en_string():
|
||||
cat = extract_en_catalog(MANIFEST)
|
||||
assert cat == {"cosmos": {"detected.star": ["star", "young star"], "measure.distance": "≈7,500 ly"}}
|
||||
|
||||
|
||||
def test_merge_writes_lang_and_leaves_en():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}}
|
||||
merge_catalog(m, "es", cat)
|
||||
s = m["clips"][0]["strings"]
|
||||
assert s["en"]["measure.distance"] == "≈7,500 ly" # en untouched
|
||||
assert s["es"]["detected.star"] == ["estrella", "estrella joven"]
|
||||
|
||||
|
||||
def test_merge_is_idempotent():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}}
|
||||
merge_catalog(m, "es", cat)
|
||||
once = copy.deepcopy(m)
|
||||
merge_catalog(m, "es", cat)
|
||||
assert m == once
|
||||
|
||||
|
||||
def test_merge_rejects_tier_length_mismatch():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
bad = {"cosmos": {"detected.star": ["estrella"], "measure.distance": "≈7.500 al"}} # 1 tier vs en's 2
|
||||
with pytest.raises(ValueError):
|
||||
merge_catalog(m, "es", bad)
|
||||
|
||||
|
||||
def test_merge_rejects_type_mismatch():
|
||||
m = copy.deepcopy(MANIFEST)
|
||||
bad = {"cosmos": {"detected.star": "estrella", "measure.distance": "≈7.500 al"}} # str vs en's list
|
||||
with pytest.raises(ValueError):
|
||||
merge_catalog(m, "es", bad)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""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 hashlib
|
||||
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",
|
||||
"credits.html", "credits.js"]
|
||||
|
||||
|
||||
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 _write_headers(out: Path, seg: str) -> None:
|
||||
# The app shell (html/js/json) must NOT be browser-cached — otherwise a deploy is
|
||||
# invisible to returning visitors for hours (Pages' default is max-age=14400).
|
||||
# `no-cache` = store but revalidate every load (cheap 304 via etag). The media is
|
||||
# on R2 (immutable + content-hash), unaffected. Cloudflare Pages `_headers` syntax.
|
||||
(out / "_headers").write_text(
|
||||
f"/{seg}/*\n"
|
||||
" Cache-Control: no-cache\n"
|
||||
)
|
||||
|
||||
|
||||
def _version_assets(app_dir: Path) -> None:
|
||||
# Content-version the app-shell asset URLs in the HTML so a new build = a new URL
|
||||
# = guaranteed-fresh fetch on a normal reload. The HTML itself is served no-cache
|
||||
# (_headers), but Cloudflare Pages caches .js/.css by type (max-age=14400) and
|
||||
# ignores _headers for them — so without this, returning visitors run stale JS.
|
||||
assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "style.css", "credits.js", "config.js"]
|
||||
tok = {}
|
||||
for a in assets:
|
||||
p = app_dir / a
|
||||
if p.exists():
|
||||
tok[a] = hashlib.sha1(p.read_bytes()).hexdigest()[:12]
|
||||
for html in ("index.html", "credits.html"):
|
||||
hp = app_dir / html
|
||||
if not hp.exists():
|
||||
continue
|
||||
s = hp.read_text()
|
||||
for a, h in tok.items():
|
||||
s = s.replace(f'src="{a}"', f'src="{a}?v={h}"').replace(f'href="{a}"', f'href="{a}?v={h}"')
|
||||
hp.write_text(s)
|
||||
|
||||
|
||||
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)
|
||||
_version_assets(app_dir) # after config.js exists, so it gets hashed too
|
||||
_write_redirects(out, seg)
|
||||
_write_headers(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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
"""Extract a manifest's English annotation strings and merge authored
|
||||
translations back into each clip's `strings` dict (no schema change).
|
||||
|
||||
Workflow:
|
||||
1. `extract` writes tools/i18n/catalogs/en.json — the English strings.
|
||||
2. A translator (human or LLM) authors es.json / fr.json / ja.json with the
|
||||
SAME structure (lists stay lists of the same length).
|
||||
3. `merge` validates structure and writes strings[lang] into the manifest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def extract_en_catalog(manifest: dict) -> dict:
|
||||
out = {}
|
||||
for clip in manifest.get("clips", []):
|
||||
en = (clip.get("strings") or {}).get("en")
|
||||
if en:
|
||||
out[clip["id"]] = en
|
||||
return out
|
||||
|
||||
|
||||
def _check_shape(key: str, en_val, tr_val):
|
||||
if isinstance(en_val, list):
|
||||
if not isinstance(tr_val, list) or len(tr_val) != len(en_val):
|
||||
raise ValueError(f"{key}: expected list of {len(en_val)} tiers, got {tr_val!r}")
|
||||
elif not isinstance(tr_val, str):
|
||||
raise ValueError(f"{key}: expected string, got {tr_val!r}")
|
||||
|
||||
|
||||
def merge_catalog(manifest: dict, lang: str, catalog: dict) -> dict:
|
||||
if lang == "en":
|
||||
raise ValueError("refusing to overwrite the en source")
|
||||
for clip in manifest.get("clips", []):
|
||||
en = (clip.get("strings") or {}).get("en")
|
||||
tr = catalog.get(clip["id"])
|
||||
if not en or not tr:
|
||||
continue
|
||||
for key, en_val in en.items():
|
||||
if key not in tr:
|
||||
raise ValueError(f"{clip['id']}/{key}: missing in {lang} catalog")
|
||||
_check_shape(f"{clip['id']}/{key}", en_val, tr[key])
|
||||
clip["strings"][lang] = {k: tr[k] for k in en} # en key order, lang values
|
||||
return manifest
|
||||
|
||||
|
||||
def _main(argv=None):
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("command", choices=["extract", "merge"])
|
||||
ap.add_argument("--manifest", default="simulator/sample_media/manifest.json")
|
||||
ap.add_argument("--catalog-dir", default="tools/i18n/catalogs")
|
||||
ap.add_argument("--lang", help="merge: target language code")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
mpath = Path(args.manifest)
|
||||
manifest = json.loads(mpath.read_text(encoding="utf-8"))
|
||||
cdir = Path(args.catalog_dir)
|
||||
|
||||
if args.command == "extract":
|
||||
cdir.mkdir(parents=True, exist_ok=True)
|
||||
(cdir / "en.json").write_text(
|
||||
json.dumps(extract_en_catalog(manifest), ensure_ascii=False, indent=1) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"wrote {cdir / 'en.json'}")
|
||||
else:
|
||||
if not args.lang:
|
||||
ap.error("merge requires --lang")
|
||||
catalog = json.loads((cdir / f"{args.lang}.json").read_text(encoding="utf-8"))
|
||||
merge_catalog(manifest, args.lang, catalog)
|
||||
mpath.write_text(json.dumps(manifest, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
print(f"merged {args.lang} into {mpath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
Reference in New Issue
Block a user