Compare commits

..

4 Commits

Author SHA1 Message Date
benstull 582183db2f Merge pull request 'fix(sim): kill the reverse-landing frame jump (D3)' (#30) from fix/d3-reverse-landing-jump into main 2026-06-30 14:17:56 +00:00
BenStullsBets c13ce36787 fix(sim): kill the reverse-landing frame jump (D3)
Ascending the altitude dial and landing on the higher-altitude clip jumped ~3
seconds. A baked morph `transitions/{h}__{l}.mp4` spans src@0 (frac 0) ->
dst@~LOOP_TAIL_S (frac 1) and is scrubbed bidirectionally, but the steady loop
element always seeked LOOP_TAIL_S (3.0s) on landing. Descending lands on dst at
its tail (matches the seek — the s0026 loop-from-tail fix); ascending lands on
src at the morph's HEAD (src@0), so seeking the tail was a 3s jump.

Make the loop landing frame direction-aware: pure `HEFScrub.loopLandFrame(dir,
loopTailS)` returns 0 when ascending, loopTailS otherwise. `loadLoop` takes a
`landFrame`, stores it as `dataset.loopStart`, and the timeupdate/ended wrap
handlers honor it (the base clips are already seamless crossfade-loops, so a
reverse landing wraps [0,dur] cleanly). The settle path lands explicitly on the
direction's frame so a fast ascent never jumps even if the heading clip wasn't
preloaded in time. App-side only — no 154-morph LFS re-bake.

Verify: node unit (loopLandFrame) + a new reverse-landing e2e asserting the loop
anchors at the head (loopStart "0"), and the existing forward "loops from tail"
e2e still green (12/12 altitude-lock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:16:47 -07:00
BenStullsBets 21830069ad claim human-experience-filter-art session 0028 (placeholder) + sessions.json entry 2026-06-30 06:56:42 -07:00
BenStullsBets 50366c056e add sessions/0027/SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--2026-06-30T06-47.md + replace placeholder/variant SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--INPROGRESS.md 2026-06-30 06:49:13 -07:00
36 changed files with 286 additions and 2101 deletions
-4
View File
@@ -26,7 +26,3 @@ 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 -10
View File
@@ -1,4 +1,4 @@
.PHONY: sim sim-local deploy deploy-media
.PHONY: sim sim-local
# Prefer the project venv's interpreter (this box has no bare `python` on PATH),
# fall back to python3, then python.
@@ -9,12 +9,3 @@ 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
-128
View File
@@ -1,128 +0,0 @@
# 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, §13), 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.
-60
View File
@@ -1,60 +0,0 @@
#!/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/"
-9
View File
@@ -1,9 +0,0 @@
[
{
"AllowedOrigins": ["https://benstull.art"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "Content-Type"],
"ExposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
"MaxAgeSeconds": 86400
}
]
-13
View File
@@ -1,13 +0,0 @@
{
"rules": [
{
"allowed": {
"origins": ["https://benstull.art"],
"methods": ["GET", "HEAD"],
"headers": ["Range", "Content-Type"]
},
"exposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
"maxAgeSeconds": 86400
}
]
}
-25
View File
@@ -1,25 +0,0 @@
#!/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
@@ -1,706 +0,0 @@
# 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).
@@ -1,149 +0,0 @@
# Publish to benstull.art — Cloudflare Pages + R2 (fully static) — Design
**Status:** Approved (brainstormed 2026-06-30). Ready for writing-plans.
## Goal
Publish the Human Experience **Simulator** publicly at
`benstull.art/human-experience-simulator`, hosted on Cloudflare, with the
`benstull.art` apex **redirecting** to that path for now.
The simulator today is a FastAPI server (`simulator/app.py`) serving ~92 KB of
static frontend plus video/audio from a local `/media/` mount. The public target
has **no origin server**: the frontend is **fully static** on **Cloudflare Pages**,
and the video/audio lives in a public **R2** bucket behind a custom domain. Nothing
runs, nothing to patch, nothing to keep alive. The existing GCE/flotilla deployment
stays as the dev/authoring surface — this is a *new, additional* public target, not
a migration of the dev box.
## Why this shape (the constraint that forces it)
Cloudflare Pages has a **hard 25 MiB per-file limit** (and 20,000 files free /
100,000 paid; no total-size cap). The repo carries **3.9 GB across 663 mp4 + 10
mp3**, with individual files up to ~117 MB. Pages cannot host the video. R2 has no
relevant per-file limit, **free egress**, and ~2 GB of served media sits inside its
10 GB-month free tier (≈ free). So: **Pages for the ~92 KB frontend, R2 for the
media.** (Verified against Cloudflare docs 2026-06-30.)
## Decisions (locked)
- **Path:** experience at `/human-experience-simulator`; **apex `benstull.art`
redirects** there (302/308 redirect, not a rewrite).
- **Fully static**, no Pages Functions / Worker — the dynamic logic is trivial
client-side (see Component 2).
- **Media on R2** at a custom domain `static.benstull.art` (public bucket binding).
- **No cross-session cache** (no Service Worker) for now — the existing in-memory
graduated prefetch is sufficient. Deferred, not rejected.
- **benstull.art is already a Cloudflare zone** — no nameserver migration needed.
- **Operates outside Wiggleverse deployment patterns (standing, operator-set):**
this **entire project** is exempt from the typical Wiggleverse deployment
conventions — `flotilla-only-provisioning`, the `ppe-before-prod` GCE pipeline,
and the §9 prod-promotion gate do not bind it. It is a static public art target
hosted directly on Cloudflare. (Safety/hygiene rules — `git-ssh`, the secrets
rule, branch→PR — still apply; only the *deployment* machinery is out of scope.)
## Served media — what actually ships
The on-disk 3.9 GB is mostly **pipeline source**, not served bytes. The manifest
(`simulator/sample_media/manifest.json`) references only **`base.mp4`** for clips —
the `master.mp4` (20 files, incl. the 91117 MB `coast_birdrock` set) and
`mezzanine.mp4` proxies are build inputs that **never reach the browser**. The
served set is:
- the `base.mp4` clip bases (one per pool member),
- the **154 transition morphs** (`*__*.mp4` / `*.rev.mp4`, ~1.6 GB, each already
kept < 25 MB),
- the **10 audio `.mp3`** layers (~12 MB).
**~2 GB** to R2. The build computes the exact set by walking the manifest — it
**must not** blindly sync `sample_media/` (that would push the masters too).
## Components
### 1. Static build (`tools/` — new build script → `dist/`)
Replaces the server with baked artifacts:
- **Bake the read endpoints to static JSON** the page loads at boot, replacing the
fetches to `/api/clips`, `/api/ring`, `/api/media-versions`:
- `clips.json``GET /api/clips`
- `ring.json``GET /api/ring`
- `media-versions.json``GET /api/media-versions` (file → content-hash)
Generate these by importing the existing builders (`simulator/clips.py`,
`build_pool_manifest.py`) directly — **single source of truth, no hand-copy**.
- **Copy only manifest-referenced media** into the R2 sync tree (see Served media).
- **Emit the public frontend** (`index.html`, `app.js`, `scrub.js`, `style.css`,
`i18n.js`) into `dist/`, **excluding** author/review/dev surfaces
(`author.*`, `review*.html`, `/api/author/*`).
- Output is deterministic and re-runnable; checked by the integration test below.
### 2. Client-side dynamic logic (`simulator/static/app.js`)
The only server-computed behavior is the **random clip pick on landing**
(`POST /api/ring/advance`). The scrub work (PR #28, spec 2026-06-27) **already
moved the pick client-side** for drag/scroll gestures via the pool + `morphByPair`
lookup — so the remaining task is to ensure **every** entry path (initial load, tap-
label jumps) uses the client-side pick and that **no code path depends on a live
`/api/*` call**. Boot loads the baked JSON instead of fetching the API.
`/api/alteration` is verified client-resolvable or dropped from the public build.
### 3. Media URL base + CORS (`simulator/static/app.js`)
- `mediaNetUrl(file)` changes its base from `"/media/" + file` to a configurable
**`MEDIA_BASE`** (`https://static.benstull.art/`), preserving the existing
`?v=<hash>` content-hash versioning. A single const, fed from the baked config so
localhost dev still works against the FastAPI `/media/` mount.
- **CORS is load-bearing** because of the WebGL dream shader: `app.js` preloads each
clip via `fetch()` (cross-origin → needs `Access-Control-Allow-Origin`) into a
blob, and any clip that falls back to the *direct network* `<video>` path feeding
WebGL **taints the GL texture** without CORS. So:
- R2 bucket gets a **CORS policy** allowing `https://benstull.art`.
- `<video>` elements carry **`crossOrigin="anonymous"`**.
Without this, plain playback works but the *dream effect* throws a security error.
### 4. Prefetch (unchanged)
Keep the existing graduated strategy verbatim — `preloadList()` (all bases),
`preloadOrder()` (current pool → neighbors outward → reachable morphs → sweep),
`refreshReachablePreload()` (per-landing), `preloadAllMedia(concurrency=4)` behind
the "Loading Universe…" bar. It improves on R2: Cloudflare edge-caches each object
after first fetch; egress is free, so the background fill is free. No code change
beyond the `MEDIA_BASE` swap (Component 3).
### 5. Caching headers
Server today sets `/media/` to `no-cache` (revalidate-always). On the CDN, set the
versioned media objects to long-lived **`immutable`** — the `?v=<hash>` query already
guarantees a new URL when bytes change, so a re-baked clip busts cache without a
manual purge. Pages assets keep normal Cloudflare defaults.
### 6. Deploy & redirect
- **Pages:** deploy `dist/` (Pages Git integration or `wrangler pages deploy`).
Serve the app under the `/human-experience-simulator` path.
- **R2:** sync the media tree to the bucket (`wrangler r2` or rclone), bind
`static.benstull.art` as the public custom domain, apply the CORS policy.
- **Apex redirect:** `benstull.art/``/human-experience-simulator` via a
Cloudflare redirect rule (or Pages `_redirects`).
## Testing
- **Build integration test:** run the build against the real manifest; assert
`dist/` contains the expected static files, the three baked JSONs match the live
API output, and the media sync tree contains **only** manifest-referenced files
(no masters/mezzanines, nothing > some sanity bound).
- **E2E (Playwright) against the static build:** point the existing suite at the
built `dist/` + an R2-equivalent media origin and verify: boot with **no `/api/*`
server**, the **dream/Kuwahara shader survives CORS** (texture not tainted),
scrub-driven morphs stay smooth, audio crossfade works, language dropdown works.
The E2E browser tier is required per the §9 pipeline (handbook).
- **Pre-deploy check:** a smoke run confirming CORS headers + range requests on the
R2 custom domain before flipping the apex redirect.
## Out of scope / deferred
- Service Worker / cross-session caching (deferred, not rejected).
- Migrating the dev/authoring GCE deployment (stays as-is on flotilla).
- Per-clip loop files for the reverse-landing frame jump (pre-existing residual,
tracked elsewhere).
- Cloudflare Stream (R2 is sufficient and cheaper for this payload).
@@ -0,0 +1,94 @@
# Session 0027.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-29T22-04 (PST)
> End: 2026-06-30T06-47 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 3e51cce1-9023-4a16-b607-99894dfe0597
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **FINALIZED**
## Launch prompt
```
Add left and right brain annotations (including localization) for any that are missing.
```
## Plan
> Anchor: operator direct instruction (standalone leaf content-fill task, §4.3 R2b).
**Goal:** Author the missing **left-brain** factual `LABELS` (tiered general→scientific→+fact
labels) for the rotating-pool clips that lack them, plus their **es/fr/ja** translations,
then rebuild + merge the manifest. Right-brain `affect` is auto-generated per scale for
every clip via `_affect_for_clip` — nothing missing there.
## Pre-state
- `main` at `5beee55` locally but **diverged from `origin/main`**: 11 merged-but-unpushed
commits (the entire i18n localization feature + a video-loop safety-net fix) stranded
locally; `origin/main` still at `eff43bd` (PR #29). Plus 3 files with an uncommitted
"boots-silent / no auto-start" change. Two stale `--INPROGRESS` placeholders (0015, 0017).
- Annotation reality (from a read-only survey of `build_pool_manifest.py` + catalogs):
right-brain `affect` present on all 41 clips (auto-per-scale); left-brain `LABELS`
present on only 13 of 41. 28 pool clips had `"annotations": []`.
## Arc
1. **Scoped the task** — explored the annotation/i18n system; established that the gap was
left-brain only (right-brain is auto-generated), 28 clips, + their es/fr/ja strings.
2. **Claimed session 0027** (peek showed the two stale placeholders; noted + proceeded).
3. **Surfaced the unclean baseline**; operator chose *"Push stranded, also commit dirty."*
- Stashed the 3 dirty files, merged `origin/main`'s claim commit, **pushed the 11
stranded commits** (`020219f`).
- Committed the boots-silent change on `fix/boot-silent-no-autostart`, verified
(299 pytest pass), **merged + pushed** (`7bd6c9a`).
4. **Annotation work** on `feat/left-brain-annotations`:
- Added 28 `LABELS` entries (static tiered labels) to `build_pool_manifest.py`
(Round-5 block); rebuilt manifest → all 41 clips annotated; extracted en catalog.
- Dispatched **3 parallel translator subagents** (es/fr/ja) for the 67 new keys,
matching existing catalog voice; fixed 2 ja `measure.*` values to the `約`/localized
convention; merged fragments into catalogs; merged all 3 langs into the manifest.
- Verified: 41 clips × en/es/fr/ja full key + tier-length parity; 299 pytest pass
(3 pre-existing `cv2` env failures only); 37 i18n/manifest tests green; spot-checked
translations. **Merged + pushed** (`934f60c`), deleted both branches.
## Cut state
- `main` == `origin/main` @ `934f60c`, clean tree, branches removed.
- 41/41 clips carry left+right annotations with full en/es/fr/ja parity.
- §9: local tests green. No PPE/E2E machinery exists for this app yet (pipeline is policy,
not automated — §10.6); prod promotion stays operator-gated. Visual review deferred to
operator (no Chrome on box).
## Operator plate
- **Review by eye** the 28 newly-labelled clips across all 4 languages in the running sim.
- The labels are **static**; the abyss/reef *creature* clips would benefit from a later
**tracked-label** upgrade via author mode (couldn't auto-file as a tracker issue — the
capture token returned 401 in this env; full context is in the session-0027 memory note).
- A few species IDs were inferred from clip META, not the frame (`reef_redsea`→anthias,
`reef_flowergarden`→jack/scad+sergeant-major) — worth confirming at review.
- Two stale `--INPROGRESS` sessions (0015, 0017) remain unfinalized — likely the crashed
sessions that stranded the 11 commits; left untouched for the operator.
## Next-session prompt
```
/goal Review the new left-brain annotations (all 28 newly-labelled pool clips, across en/es/fr/ja) by eye in the running simulator; correct any species/box that reads wrong, and decide whether the abyss/reef creature clips should get tracked labels via author mode.
```
## Deferred decisions
- **Static labels, not tracked.** Authored all 28 clips' left-brain labels as fixed-box
`static_label`s rather than motion-tracked labels. Alternative: tracked labels (as the
reef/abyss showcase clips use) so the box follows drifting creatures. Chose static because
the footage wasn't eyeballed — fabricating track coordinates would claim precision I don't
have. The abyss/reef creature clips are the ones that would most benefit from a later
tracked-label upgrade via author mode.
- **Some species IDs inferred from clip metadata, not the footage.** `reef_redsea`→anthias,
`reef_flowergarden`→jack/scad + sergeant-major — read from the clip META title/source.
High-confidence from provenance, but worth an eyeball check.
- **`O-type` kept verbatim in es/fr/ja** (per the translation brief) rather than the more
natural `tipo O` / `type O`. Minor; flagged by the es translator.
@@ -1,28 +0,0 @@
# Session 0027.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-29T22-04 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 3e51cce1-9023-4a16-b607-99894dfe0597
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0027 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--<end>.md form at session end.
## Launch prompt
```
Add left and right brain annotations (including localization) for any that are missing.
Resolved scope: right-brain `affect` is auto-generated per scale for every clip (nothing missing). The gap is left-brain `LABELS` (factual/scientific tiered labels) on 28 rotating-pool clips that have no entry in simulator/build_pool_manifest.py, plus their es/fr/ja translations in tools/i18n/catalogs. Author them following the existing static_label pattern, rebuild the manifest, merge translations, run tests.
```
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -0,0 +1,23 @@
# Session 0028.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T06-53 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: d9da7994-e036-4b7c-b2d4-d2675ac4e5a1
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0028 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0028.0-TRANSCRIPT-2026-06-30T06-53--<end>.md form at session end.
## Launch prompt
_(launch prompt not captured at claim time)_
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
+3
View File
@@ -79,5 +79,8 @@
},
"0027": {
"title": ""
},
"0028": {
"title": ""
}
}
-21
View File
@@ -32,24 +32,3 @@ npm test
`playwright.config.ts` starts uvicorn on port 8099 automatically (`reuseExistingServer`
locally) and points the tests at it.
## Static-build E2E (Cloudflare publish)
`tests/static-build.spec.ts` verifies the **built static site** (Cloudflare publish
— see `deploy/cloudflare/README.md`): it boots with NO `/api/*` server (baked JSON),
and the WebGL dream shader runs on **cross-origin** media without tainting the GL
texture (the CORS path R2 will use in production).
It uses `playwright.static.config.ts` (no uvicorn `webServer`) and a dual static
server, `serve-static.mjs`, that serves the built `dist/` (app, :8077) and
`dist-media/` (media with CORS + Range, :8078):
```bash
# from the repo root: build with the media base pointed at the local CORS server
.venv/bin/python -m tools.build_static --media-base http://localhost:8078/
# from this directory (simulator/e2e): start the dual server, then run the spec
DIST_DIR="$PWD/../../dist" MEDIA_DIR_E2E="$PWD/../../dist-media" node serve-static.mjs &
npx playwright test --config playwright.static.config.ts
```
-11
View File
@@ -1,11 +0,0 @@
import { defineConfig } from "@playwright/test";
// Static-build E2E: NO webServer — the built dist/ + dist-media/ are served by
// serve-static.mjs (started manually, see README.md), so there is no uvicorn/API.
// Run: npx playwright test --config playwright.static.config.ts
export default defineConfig({
testDir: "./tests",
testMatch: /static-build\.spec\.ts/,
timeout: 60_000,
use: { actionTimeout: 10_000 },
});
-48
View File
@@ -1,48 +0,0 @@
// 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");
+36 -16
View File
@@ -83,27 +83,15 @@ test("audio is a 0-10 level dial", async ({ page }) => {
expect(ctl.max).toBe("10");
});
test("boots silent (video off, audio 0); turning video on couples audio to 2/10 and dismisses the Run prompt", async ({ page }) => {
test("boots silent (video off, audio 0); turning video on couples audio to 3/10", async ({ page }) => {
await boot(page);
// 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.
// Turning Video on (a click gesture) lifts the audio dial to a gentle 3/10.
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("2");
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true);
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3");
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3");
});
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
@@ -176,6 +164,38 @@ test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)",
expect(v.t).toBeGreaterThanOrEqual(2.5); // looping from ~3s, not 0
});
test("ascending re-lands anchored at the clip head (no reverse-landing jump)", async ({ page }) => {
// D3: a morph spans src@0 (frac 0) -> dst@~3s (frac 1). Descending lands on dst at its
// tail (~3s); ASCENDING lands on src at its HEAD (~0s). The steady loop must continue
// from 0 there — seeking to the tail (~3s) was the ~3s reverse-landing jump.
await boot(page);
await enableVideo(page); // base loop only loads when video is shown
// Descend one altitude (cosmos -> orbit), then ascend back (orbit -> cosmos): the
// ascent is the reverse landing under test.
const start = (await page.locator("#scale-name").textContent())!;
await wheelOnStage(page, 60); // descend
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
const mid = (await page.locator("#scale-name").textContent())!;
expect(mid).toContain("orbit");
await wheelOnStage(page, -60); // ascend back
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, mid, { timeout: 20000 });
expect(await page.locator("#scale-name").textContent()).toContain("cosmos");
// The loop element is now anchored at the HEAD (loopStart "0"), playing — not jumped to
// the ~3s tail the descending case uses.
await page.waitForFunction(
() => { const v = document.querySelector("#vid-loop") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.dataset.loopStart === "0"; },
null,
{ timeout: 10000 },
);
const v = await page.evaluate(() => {
const el = document.querySelector("#vid-loop") as HTMLVideoElement;
return { loopStart: el.dataset.loopStart, loopTail: el.dataset.loopTail, paused: el.paused };
});
expect(v.loopTail).toBe("1"); // tail-loop machinery armed
expect(v.loopStart).toBe("0"); // anchored at the head, not the morph tail
expect(v.paused).toBe(false); // the loop is actually playing
});
test("zoom in plays the matching morph, lands locked, and stays put while parked", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
-30
View File
@@ -1,30 +0,0 @@
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([]);
});
-52
View File
@@ -1,52 +0,0 @@
// 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 };
});
+82 -127
View File
@@ -57,19 +57,14 @@ let activeLang = "en"; // session-only; no persistence (resets to en each lo
let lastAffect = null; // {strength, intensity, right} from the last renderAffect, for re-render on language switch
async function loadData() {
// 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 || [];
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.versions)).json()).versions || {}; }
try { mediaVersions = (await (await fetch("/api/media-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) {
@@ -92,12 +87,16 @@ async function loadData() {
async function pickRandomMember() {
const scale = ring && ring.scales[ringIndex];
if (!scale) return null;
// 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;
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;
}
// Land on the current scale: pick a random pool member and force its media to load.
@@ -119,11 +118,9 @@ 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 mediaBase() { return (window.HEF_CONFIG && window.HEF_CONFIG.mediaBase) || "/media/"; }
function mediaNetUrl(file) { const v = mediaVersions[file]; return mediaBase() + file + (v ? "?v=" + v : ""); }
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
function mediaUrl(file) {
// Already a resolved absolute url (audio layer, or R2 in static mode): pass through.
if (/^https?:\/\//.test(file) || file.startsWith("/media/")) return file;
if (file.startsWith("/media/")) return file; // already a resolved absolute url (audio layer)
return mediaBlobs[file] || mediaNetUrl(file);
}
@@ -171,12 +168,7 @@ function preloadOrder() {
}
}
for (const f of reachableMorphFiles()) add(f);
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);
for (const f of preloadList()) add(f); // sweep up anything not on the ring
return ordered;
}
@@ -246,20 +238,42 @@ function preloadChip() {
// to the loop's first frame, and the morph-covered intro never re-shows at rest.
const LOOP_TAIL_S = 3.0;
// Load a clip's base into the LOOP element (#vid-loop), seeked to the tail and PAUSED
// on that exact frame. Idempotent per clip. Called during a scrub to PRELOAD the clip
// we're about to land on, so settle just plays an already-decoded element at the morph's
// last frame — no reload, no seek, no stall. `playLoop()` starts it at landing.
function loadLoop(clip) {
if (!clip || loopVid.dataset.clip === clip.id) return;
// The frame the current loop both LANDS on and WRAPS back to — stored per load so the
// wrap handlers honor a direction-aware landing (forward=LOOP_TAIL_S, reverse=0).
function loopStartFrame() {
const s = parseFloat(loopVid.dataset.loopStart);
return Number.isFinite(s) ? s : LOOP_TAIL_S;
}
// Load a clip's base into the LOOP element (#vid-loop), seeked to `landFrame` and PAUSED
// on that exact frame. `landFrame` is the frame the morph last showed for THIS landing:
// LOOP_TAIL_S when descending (lands on the morph's dst-tail), 0 when ascending (lands on
// the morph's src-head — fixes the D3 reverse-landing jump). Omitted → LOOP_TAIL_S, the
// forward default for non-scrub paths (initial load, knob changes). Idempotent per clip;
// an explicit `landFrame` re-seeks the already-loaded clip if the travel direction flipped
// the landing phase, but a bare call (ensureClipMedia) never clobbers a phase a scrub set.
// Called during a scrub to PRELOAD the clip we're about to land on, so settle just plays
// an already-decoded element at the right frame — no reload, no seek, no stall.
function loadLoop(clip, landFrame) {
if (!clip) return;
const seekTo = (t) => { try { loopVid.currentTime = t; } catch (e) { /* not seekable yet */ } };
if (loopVid.dataset.clip === clip.id) {
if (landFrame != null && loopVid.dataset.loopStart !== String(landFrame)) {
loopVid.dataset.loopStart = String(landFrame);
if (loopVid.readyState >= 1) seekTo(landFrame);
else loopVid.addEventListener("loadedmetadata", () => seekTo(landFrame), { once: true });
}
return;
}
const lf = landFrame == null ? LOOP_TAIL_S : landFrame;
loopVid.dataset.clip = clip.id;
loopVid.dataset.loopTail = "1"; // arm the [LOOP_TAIL_S, duration] wrap handler
loopVid.dataset.loopTail = "1"; // arm the [loopStart, duration] wrap handler
loopVid.dataset.loopStart = String(lf);
loopVid.src = mediaUrl(clip.base_file);
loopVid.loop = false; // native loop restarts at 0; we loop from the tail instead
loopVid.loop = false; // native loop restarts at 0; we loop from loopStart
loopVid.muted = true;
const seek = () => { try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable yet */ } };
if (loopVid.readyState >= 1) seek();
else loopVid.addEventListener("loadedmetadata", seek, { once: true });
if (loopVid.readyState >= 1) seekTo(lf);
else loopVid.addEventListener("loadedmetadata", () => seekTo(lf), { once: true });
}
function playLoop() {
@@ -275,21 +289,22 @@ function ensureClipMedia() {
}
// The custom loop on the LOOP element: when a base loop reaches the end, jump back to
// the tail offset (not 0). The morph element (#vid) is driven directly by the scrub.
// its landing offset (LOOP_TAIL_S forward, 0 reverse — see loopStartFrame), not a fixed
// tail. The morph element (#vid) is driven directly by the scrub.
loopVid.addEventListener("timeupdate", () => {
if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) {
try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ }
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
}
});
// Safety net: the timeupdate wrap above is a narrow window (the last 0.08 s). Under
// real-browser decode/GPU load a `timeupdate` can skip past it, the clip fires
// `ended`, and — with native loop off — freezes on its last frame ("not looping").
// `ended` GUARANTEES recovery: seek back to the tail and resume. Headless rarely
// hits this; loaded real machines do.
// `ended` GUARANTEES recovery: seek back to the landing offset 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 */ }
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
loopVid.play().catch(() => {});
});
@@ -622,21 +637,12 @@ function controls() {
let timer = null;
async function update() {
if (busy) return;
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();
}
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();
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.
@@ -852,7 +858,12 @@ function setPos(next) {
busy = false;
delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base)
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
playLoop(); // the loop element was preloaded to this clip @ tail during the scrub → instant, no reload
// Land the loop on the frame the morph last showed for THIS travel direction:
// descending lands on the morph's dst-tail (LOOP_TAIL_S), ascending on its src-head
// (0). Explicit (not relying on the scrub's preload) so a reverse landing never
// jumps even if the heading clip wasn't warmed in time. (D3 fix.)
loadLoop(activeClip(), HEFScrub.loopLandFrame(scrubDir, LOOP_TAIL_S));
playLoop(); // the loop element was preloaded to this clip during the scrub → instant, no reload
showActiveSource();
setNeedle(ringIndex * dialStep());
renderScaleReadout();
@@ -863,10 +874,11 @@ function setPos(next) {
}
busy = true; // mid-morph: block update() from reloading the base clip under us
if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex);
// Preload the clip we're heading toward into the LOOP element (paused @ tail) so the
// landing is a swap, not a reload+seek. dir>0 lands on clipHi, dir<0 on clipLo.
// Preload the clip we're heading toward into the LOOP element (paused on its landing
// frame) so the landing is a swap, not a reload+seek. dir>0 lands on clipHi at the
// morph's dst-tail (LOOP_TAIL_S); dir<0 lands on clipLo at the morph's src-head (0).
const headingId = dir > 0 ? activeSeg.clipHi : activeSeg.clipLo;
loadLoop(clipsById[headingId]);
loadLoop(clipsById[headingId], HEFScrub.loopLandFrame(dir, LOOP_TAIL_S));
showActiveSource(); // morph element is the live source while scrubbing
setNeedle(pos * dialStep());
blendAudio(lo, frac);
@@ -1052,7 +1064,6 @@ 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 {
@@ -1161,10 +1172,7 @@ 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];
// 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;
return a ? "/media/audio/" + a : null;
}
// The current resting altitude's soundtrack url (kept for the status readout).
function soundtrackUrl() { return scaleAudioUrl(ringIndex); }
@@ -1247,23 +1255,6 @@ 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); }
@@ -1280,17 +1271,14 @@ async function main() {
// play() outside a user gesture).
$("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); });
updateAudioLevelLabel();
// "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.
// 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.
$("visual").addEventListener("change", () => {
if ($("visual").checked && !videoEverOn) beginExperience();
if ($("visual").checked && !videoEverOn) {
videoEverOn = true;
if (audioLevel() === 0) { $("audio").value = "3"; updateAudioLevelLabel(); applyAudio(); }
}
debounced();
});
// Altitude knob: drag to turn (commit detents on release), scroll to step, tap a label to jump.
@@ -1300,18 +1288,13 @@ 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)
// 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();
await preloadAllMedia(); // download all media, updating the loading bar
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.
// turn Video on — that click IS the gesture, and its handler (above) lifts audio to
// 3/10 and starts the soundtrack in-gesture, so sound reliably comes up with video.
}
// Populate the language dropdown from the registry and wire live switching.
@@ -1348,31 +1331,3 @@ function setLanguage(lang) {
}
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);
})();
-4
View File
@@ -1,4 +0,0 @@
// 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 };
-65
View File
@@ -1,65 +0,0 @@
<!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 &amp; 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 &amp; 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 &amp; 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 &amp; gull calls (CC0).</li>
</ul>
</section>
</main>
<script src="config.js"></script>
<script src="credits.js"></script>
</body>
</html>
-75
View File
@@ -1,75 +0,0 @@
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// 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 };
});
-2
View File
@@ -19,8 +19,6 @@
const UI_STRINGS = {
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre dExpérience Humaine — Aperçu daltération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de lunivers", 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: "音声" },
+9 -15
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Alteration Simulator</title>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div id="loading">
@@ -13,23 +13,19 @@
<div class="loading-bar"><div id="loading-fill"></div></div>
</div>
</div>
<header>
<h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1>
<a href="credits.html" class="credits-link" data-i18n="credits.link">ⓘ Credits &amp; licenses</a>
</header>
<header><h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1></header>
<main>
<section class="stage" id="stage">
<div class="screen">
<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>
<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>
<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>
@@ -111,10 +107,8 @@
</div>
</section>
</main>
<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>
<script src="/scrub.js"></script>
<script src="/i18n.js"></script>
<script src="/app.js"></script>
</body>
</html>
+13 -1
View File
@@ -29,6 +29,18 @@
return { from: 1 - f, to: f };
}
// The base-loop frame a landing must continue from so the steady loop picks up
// the EXACT frame the morph last showed — no jump. A morph spans src@0 (frac 0)
// -> dst@loopTailS (frac 1), scrubbed bidirectionally. Descending (dir>0) lands
// on dst at frac 1, whose frame is dst@loopTailS, so the loop resumes at loopTailS
// (and the morph-covered intro never re-shows). Ascending (dir<0) lands on src at
// frac 0, whose frame is src@0, so the loop must resume at 0 — seeking to loopTailS
// there is the ~3s reverse-landing jump (D3). dir 0 defaults to the forward/tail
// case (matches the loadLoop default used by non-scrub paths).
function loopLandFrame(dir, loopTailS) {
return dir < 0 ? 0 : loopTailS;
}
// Integers strictly crossed moving prevPos -> pos, in travel order. Landing
// exactly on an integer counts as crossing it (it commits that altitude).
function integerCrossings(prevPos, pos) {
@@ -41,5 +53,5 @@
return out;
}
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, integerCrossings };
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
});
+1 -40
View File
@@ -1,32 +1,7 @@
* { 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; display: flex; align-items: baseline;
justify-content: space-between; gap: 1rem; }
header { padding: 0.6rem 1rem; background: #000; }
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; }
@@ -76,20 +51,6 @@ 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. */
-51
View File
@@ -1,51 +0,0 @@
"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);
});
-66
View File
@@ -1,66 +0,0 @@
"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 &lt;reef&gt;/);
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 &amp; reuse/);
});
+1 -1
View File
@@ -8,7 +8,7 @@ const I = require("../static/i18n.js");
const html = fs.readFileSync(path.join(__dirname, "../static/index.html"), "utf8");
test("index.html includes the i18n script and a language select", () => {
assert.match(html, /<script src="i18n\.js">/); // relative: works at dev-root and under the deploy path prefix
assert.match(html, /<script src="\/i18n\.js">/);
assert.match(html, /id="lang-select"/);
});
+14
View File
@@ -58,3 +58,17 @@ test("integerCrossings: multiple crossings in travel order", () => {
assert.deepEqual(S.integerCrossings(2.4, 4.1), [{ index: 3, dir: 1 }, { index: 4, dir: 1 }]);
assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]);
});
test("loopLandFrame: descend lands on the morph's tail frame, ascend on its head", () => {
// A morph spans src@0 (frac 0) -> dst@loopTailS (frac 1). Descending (dir>0) lands
// on dst at frac 1 -> the base loop must continue from loopTailS. Ascending (dir<0)
// lands on src at frac 0 -> the loop must continue from 0 (NOT loopTailS — that was
// the D3 reverse-landing jump).
assert.equal(S.loopLandFrame(1, 3), 3); // descend -> tail
assert.equal(S.loopLandFrame(-1, 3), 0); // ascend -> head
// dir 0 (no travel) is treated as a forward/tail landing (the default everywhere else).
assert.equal(S.loopLandFrame(0, 3), 3);
// honors any tail value
assert.equal(S.loopLandFrame(1, 2.5), 2.5);
assert.equal(S.loopLandFrame(-1, 2.5), 0);
});
-21
View File
@@ -1,21 +0,0 @@
"""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
-53
View File
@@ -1,53 +0,0 @@
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())
+9 -33
View File
@@ -7,10 +7,10 @@ blanking) — real Safari/iOS autoplay and real-GPU compositing still need a dev
by-ear/eye check.
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 010 range
dial (not a checkbox); set it by writing `value` + firing an `input` event.
real user gesture. Turning Video ON (a click) couples the audio dial up to 3/10 and
starts the soundtrack IN that gesture, sidestepping the browser autoplay block that
swallowed an un-gestured auto-start. Audio is a 010 range dial (not a checkbox);
set it by writing `value` + firing an `input` event.
Install: pip install -e '.[e2e]' && python -m playwright install chromium
"""
@@ -100,36 +100,12 @@ def test_app_boots_silent_video_off(page):
) 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 === '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.
def test_video_on_couples_audio_to_three_and_plays(page):
# Turning Video on (a click gesture) lifts the audio dial to 3/10 and starts the
# soundtrack IN that gesture: video shows (black hidden), a scale soundtrack plays.
_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_function("document.getElementById('audio').value === '3'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '3'")
page.wait_for_selector("#black.hidden", state="attached") # video showing
page.wait_for_function(
"(() => { const a = document.getElementById('aud');"
-81
View File
@@ -1,81 +0,0 @@
"""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)
-156
View File
@@ -1,156 +0,0 @@
"""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))