Compare commits

..

10 Commits

Author SHA1 Message Date
BenStullsBets 7c8c4b45cd fix(load): preload all bases + morphs before interaction; remove buggy hold-gate
The hold-last-frame settle gated playLoop() behind a canplay event that doesn't
re-fire on an already-loaded element → the next altitude's video never started.
Reverted to the simple swap, and gate the universe on a full preload (every base +
every morph) so navigation is smooth with no on-demand stall. Adds a ?debug on-screen
media-state overlay for diagnosing real-browser playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:31:10 -07:00
BenStullsBets b9a52b1c41 fix(deploy): bust the app-shell cache — no-cache _headers + content-versioned asset urls
Pages caches .js/.css by type (max-age=14400) and ignores _headers for them, so
returning visitors ran stale JS (no-video/no-audio until a private window). index.html
is no-cache and now references app.js?v=<hash> etc., so a normal reload always gets
fresh code; media on R2 stays immutable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:31:10 -07:00
BenStullsBets ee7c65a0f2 feat(credits): restore the CC BY-SA declaration as "License & reuse" (Option D1)
s0031 removed "About this work" to avoid duplicating it on the separate About
page, but that section WAS the D1 compliance — it declares the altered work is a
derivative offered under CC BY-SA 4.0, which is what satisfies the ShareAlike
obligation for the BY-SA source clips. Without it the live site credited the
BY-SA authors but never declared the derivative share-alike.

Restore the declaration on the credits page, under the heading "License & reuse"
instead of "About this work" so it isn't confused with the separate About page
(which covers the project's intent). Tests assert the declaration is present and
that the heading is NOT "About this work".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:23:57 -07:00
BenStullsBets 8277417583 chore(credits): drop the "About this work" section — it moves to the About page
The CC BY-SA work-license declaration ("About this work") was on the credits
page, but the About page (built on the parallel feat/accessibility-pass) will
own that content; keeping it in both places would duplicate it. Remove the
section from credits.html and make the footage note self-contained (it still
states the footage is altered — the per-clip "indicate modifications" cue stays
with the attributions). Tests updated to assert the declaration is intentionally
no longer on the credits page. The .declaration CSS rule is left in place for the
About page to reuse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:10:59 -07:00
BenStullsBets d10ffca4e8 fix(audio): route scaleAudioUrl through mediaBase — was hardcoded /media (404 on static)
The audio layer built the soundtrack url as a literal /media/audio/<f>, which 404s
on the static R2 build (audio err 4, silent). Route it through mediaBase() like the
video path: dev → /media/ (unchanged), static → https://static.benstull.art/. Verified
live: aud loads 200 from R2 and plays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:06:41 -07:00
BenStullsBets a68d0bc4df feat(deploy): repeatable Cloudflare deploy script + make targets
deploy/cloudflare/deploy.sh (build → optional parallel R2 media sync → Pages
deploy); 'make deploy' (frontend-only) / 'make deploy-media'. One-time setup
stays documented in README. Guards against unauth + mis-keyed R2 uploads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:56:30 -07:00
BenStullsBets 7da944af0c feat(load): progressive boot + hold-last-frame until media loaded
Boot becomes ready on the first clip and streams the rest in the background
(was: blocked on the full ~2.3 GB before 'Run simulation' appeared). During nav,
the visible morph and landing base only swap to cached blobs — otherwise the
current frame holds (settleGen guards a stale settle) — so rapid altitude-clicking
never blanks the stage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:53:40 -07:00
BenStullsBets 1c7f2b7785 feat(credits): public CC-BY/BY-SA attribution colophon + CC BY-SA work license
CC BY / CC BY-SA footage needs its author + license shown to the viewer, and the
installation alters every frame (a derivative). Per-clip license/source already
shipped in clips.json but only surfaced in the dev panel and internal review
pages — the public experience showed nothing, so the static benstull.art site
was not attribution-compliant.

Option A — add a manifest-driven credits/colophon (credits.html + credits.js):
the page fetches the same clips the experience uses (baked clips.json in static,
the live API otherwise) and renders one attribution block per clip (title,
license, source), HTML-escaped, sorted, never silently dropping a clip. An
"ⓘ Credits & licenses" link in the header (localised en/es/fr/ja) makes it
reachable from the work — what CC's "reasonable to the medium" calls for. Audio
is all PD/CC0, credited as a courtesy block.

Option D1 — the colophon declares the altered work itself is offered under
CC BY-SA 4.0 and states the footage is modified, satisfying the ShareAlike +
"indicate changes" obligations for the BY-SA sources. Non-commercial.

Ships in the static build (added to PUBLIC_ASSETS). Verified: 41/41 clips
credited incl. all 7 CC-BY/BY-SA clips, 0 missing a license. Tests: node units
for creditEntries/creditsListHtml + escaping + page structure/declaration;
pytest build-output assertion; Playwright e2e (page lists attributions, header
links through).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:39:26 -07:00
BenStullsBets 3a45e338f3 feat(static): "Run simulation" start button over the black stage
The experience boots silent and waits for a gesture (browser autoplay blocks
an un-gestured audio play()). Until now that gesture was an unlabelled Video
toggle — not obvious to a first-time viewer. Add a "Run simulation" button,
revealed over the black stage once media has preloaded; clicking it turns
Video on, snaps the audio dial to a gentle 2/10, dismisses the prompt, and
starts the soundtrack in that one gesture. Turning Video on directly still
begins the experience too (couples audio in, dismisses the button) via a
shared beginExperience(). Localised en/es/fr/ja.

E2E coverage updated (Playwright + pytest-playwright): button shown after
load, button starts video+audio+dismisses, and direct-Video-on path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:28:49 -07:00
BenStullsBets 8cc472c93c docs(static): correct Cloudflare runbook to match actual deploy
Real bucket name, wrangler 'cors set --file' (+ wrangler-format CORS file),
per-file r2 object put upload (no bulk sync exists), CLI custom-domain bind via
zone-id, Pages domain is dashboard-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:25:08 -07:00
17 changed files with 662 additions and 48 deletions
+10 -1
View File
@@ -1,4 +1,4 @@
.PHONY: sim sim-local
.PHONY: sim sim-local deploy deploy-media
# Prefer the project venv's interpreter (this box has no bare `python` on PATH),
# fall back to python3, then python.
@@ -9,3 +9,12 @@ sim:
sim-local:
$(PYTHON) -m uvicorn simulator.app:app --reload --port 8000
# Repeatable deploy to benstull.art (Cloudflare Pages + R2). One-time setup
# (wrangler login, bucket/CORS/domain) is in deploy/cloudflare/README.md.
# `deploy` is frontend-only (fast); `deploy-media` also re-syncs media to R2.
deploy:
./deploy/cloudflare/deploy.sh
deploy-media:
./deploy/cloudflare/deploy.sh --media
+58 -19
View File
@@ -10,6 +10,22 @@ 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
@@ -41,20 +57,43 @@ Produces:
## 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 hef-media
wrangler r2 bucket cors put hef-media --rules deploy/cloudflare/r2-cors.json
wrangler r2 bucket sync ./dist-media r2://hef-media
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
```
Then in the dashboard (R2 → `hef-media` → Settings):
- **Custom domain:** add `static.benstull.art` (this is the `mediaBase` baked into
`config.js`).
- **CORS is load-bearing** — the WebGL dream shader taints without it. The policy
in `r2-cors.json` allows `https://benstull.art` + `Range` (video scrubbing).
- **Caching:** the media URLs are content-hash-versioned (`?v=<hash>`), so set the
objects `Cache-Control: public, max-age=31536000, immutable` (a re-baked clip
gets a new hash → new URL → cache busts cleanly).
**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.
@@ -62,16 +101,16 @@ If you change the media base, rebuild with `--media-base https://<host>/` so
## 3. Pages — deploy + domain + redirect
```bash
wrangler pages project create human-experience-simulator
wrangler pages deploy dist --project-name human-experience-simulator
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 (Pages → project → Custom domains):
- Bind **`benstull.art`** to the project.
- The app serves at `benstull.art/human-experience-simulator/`; the generated
`dist/_redirects` sends `/` and the no-slash form to it. (Alternatively, a
zone-level **Redirect Rule** `benstull.art/``/human-experience-simulator`
is the authoritative equivalent if you prefer not to ship `_redirects`.)
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
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Repeatable deploy of the HEF simulator to benstull.art (Cloudflare Pages + R2).
#
# deploy/cloudflare/deploy.sh # frontend only: build + Pages deploy (fast)
# deploy/cloudflare/deploy.sh --media # also re-sync all media to R2 (~2 min, parallel)
# deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, no Pages deploy
#
# Assumes the ONE-TIME setup is already done (see README.md): wrangler logged in,
# the R2 bucket + CORS + static.benstull.art custom domain, and the Pages project +
# benstull.art custom domain. This script only does the repeatable part.
#
# Override any of these via env: BUCKET, PROJECT, MEDIA_BASE, APP_PATH, BRANCH, JOBS.
set -euo pipefail
BUCKET="${BUCKET:-human-experience-simulator-media}"
PROJECT="${PROJECT:-human-experience-simulator}"
MEDIA_BASE="${MEDIA_BASE:-https://static.benstull.art/}"
APP_PATH="${APP_PATH:-/human-experience-simulator}"
BRANCH="${BRANCH:-main}"
JOBS="${JOBS:-10}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT="$REPO_ROOT/dist"
export MEDIA_OUT="$REPO_ROOT/dist-media"
PY="${PY:-$REPO_ROOT/.venv/bin/python}"
export WRANGLER="${WRANGLER:-$(command -v wrangler || echo "$HOME/.npm-global/bin/wrangler")}"
do_media=0
do_pages=1
case "${1:-}" in
--media) do_media=1 ;;
--media-only) do_media=1; do_pages=0 ;;
"") ;;
*) echo "unknown flag: $1 (use --media or --media-only)"; exit 2 ;;
esac
[ -x "$WRANGLER" ] || { command -v "$WRANGLER" >/dev/null || { echo "wrangler not found ($WRANGLER) — npm i -g wrangler"; exit 1; }; }
"$WRANGLER" whoami >/dev/null 2>&1 || { echo "not authenticated — run: $WRANGLER login"; exit 1; }
echo "▶ build ($MEDIA_BASE)"
cd "$REPO_ROOT"
"$PY" -m tools.build_static --out "$OUT" --media-out "$MEDIA_OUT" \
--media-base "$MEDIA_BASE" --app-path "$APP_PATH" | tail -1
if [ "$do_media" = 1 ]; then
echo "▶ R2 media sync → $BUCKET (parallel x$JOBS, no bulk-sync exists)"
export BUCKET
fails=$(find "$MEDIA_OUT" -type f -print0 \
| xargs -0 -P "$JOBS" -I {} bash "$REPO_ROOT/deploy/cloudflare/r2-put-one.sh" {} \
| tee /dev/stderr | grep -c '^FAIL ' || true)
[ "$fails" = 0 ] || { echo "$fails media uploads failed"; exit 1; }
echo "✔ media synced"
fi
if [ "$do_pages" = 1 ]; then
echo "▶ Pages deploy → $PROJECT"
"$WRANGLER" pages deploy "$OUT" --project-name "$PROJECT" --branch "$BRANCH" --commit-dirty=true | tail -3
fi
echo "✔ done — https://benstull.art$APP_PATH/"
+13
View File
@@ -0,0 +1,13 @@
{
"rules": [
{
"allowed": {
"origins": ["https://benstull.art"],
"methods": ["GET", "HEAD"],
"headers": ["Range", "Content-Type"]
},
"exposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
"maxAgeSeconds": 86400
}
]
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Upload ONE file to R2 with the right content-type + immutable cache. Used by
# deploy.sh's parallel xargs (wrangler has no bulk sync). Reads BUCKET / MEDIA_OUT /
# WRANGLER from the environment; the single argument is the local file path.
set -u
f="$1"
key="${f#"$MEDIA_OUT"/}"
# Guard: if the prefix strip didn't fire (e.g. relative path vs absolute MEDIA_OUT),
# the key would wrongly keep a leading dir → wrong R2 path. Fail loudly, don't ship it.
if [ "$key" = "$f" ]; then
echo "FAIL $f (key strip failed — MEDIA_OUT='$MEDIA_OUT' is not a prefix; pass absolute paths)"
exit 1
fi
case "${f##*.}" in
mp4) ct=video/mp4 ;;
mp3) ct=audio/mpeg ;;
json) ct=application/json ;;
*) ct=application/octet-stream ;;
esac
if "$WRANGLER" r2 object put "$BUCKET/$key" --file "$f" --remote \
--content-type "$ct" --cache-control "public, max-age=31536000, immutable" >/dev/null 2>&1; then
echo "ok $key"
else
echo "FAIL $key"
fi
+16 -4
View File
@@ -83,15 +83,27 @@ 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 3/10", async ({ page }) => {
test("boots silent (video off, audio 0); turning video on couples audio to 2/10 and dismisses the Run prompt", async ({ page }) => {
await boot(page);
// 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");
// Turning Video on (a click gesture) lifts the audio dial to a gentle 3/10.
// "Run simulation" is revealed once preload finishes — the obvious starting point.
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(false);
// Turning Video on directly (a click gesture) lifts the audio dial to a gentle 2/10
// and dismisses the Run prompt.
await enableVideo(page);
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3");
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3");
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2");
expect(await page.evaluate(() => document.querySelector("#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);
});
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
+73 -12
View File
@@ -171,7 +171,12 @@ function preloadOrder() {
}
}
for (const f of reachableMorphFiles()) add(f);
for (const f of preloadList()) add(f); // sweep up anything not on the ring
for (const f of preloadList()) add(f); // sweep up every base not already on the ring
// ALL morphs last: preloading every transition before interaction guarantees a
// smooth knob turn anywhere (any random pool re-roll lands on an already-cached
// morph). They're ordered last so the first scenes + nearest transitions cache
// first and the loading bar fills meaningfully.
for (const f of Object.values(morphByPair)) add(f);
return ordered;
}
@@ -1156,7 +1161,10 @@ function scaleAudioUrl(index) {
if (!ring || !ring.scales.length) return null;
const s = ring.scales[HEFScrub.wrapIndex(index, ring.scales.length)];
const a = s.audio || SCALE_AUDIO_FALLBACK[s.id];
return a ? "/media/audio/" + a : null;
// Route through mediaBase() so audio loads from R2 in the static build (and the
// local /media mount in dev) — NOT a hardcoded /media path, which 404s on the
// static site. mediaUrl() then passes this absolute url straight through.
return a ? mediaBase() + "audio/" + a : null;
}
// The current resting altitude's soundtrack url (kept for the status readout).
function soundtrackUrl() { return scaleAudioUrl(ringIndex); }
@@ -1239,6 +1247,23 @@ function hideLoading() {
let videoEverOn = false; // has Video ever been switched on this session?
// The gentle audio level the experience starts at — coupled in on the first
// Video-on (via the Run simulation button or the Video toggle directly).
const START_AUDIO_LEVEL = "2";
// Begin the experience: Video on, audio snapped to the gentle start level, and
// the "Run simulation" prompt dismissed. Audio is set + played in THIS gesture so
// it unlocks on Safari (which blocks play() outside a user gesture). One flip = the
// full experience at a gentle level. Idempotent across re-toggles via videoEverOn.
function beginExperience() {
videoEverOn = true;
const btn = $("run-sim");
if (btn) btn.classList.add("hidden");
$("audio").value = START_AUDIO_LEVEL;
updateAudioLevelLabel();
applyAudio();
}
async function main() {
devLiveReload();
try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); }
@@ -1255,14 +1280,17 @@ async function main() {
// play() outside a user gesture).
$("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); });
updateAudioLevelLabel();
// Video toggle: the FIRST time video turns on, bring audio up to 3/10 with it (one
// flip = the full experience at a gentle level). If audio is already raised, leave it.
// Set + played in THIS gesture so it unlocks on Safari.
// "Run simulation" button: the obvious starting point. Turns Video on, snaps
// audio to the start level, and dismisses itself — all in this click gesture.
$("run-sim").addEventListener("click", () => {
$("visual").checked = true; // setting .checked does NOT fire "change", so we drive the rest here
beginExperience();
debounced();
});
// Video toggle: the FIRST time video turns on (without the Run simulation button),
// begin the experience too — couple audio in and dismiss the prompt.
$("visual").addEventListener("change", () => {
if ($("visual").checked && !videoEverOn) {
videoEverOn = true;
if (audioLevel() === 0) { $("audio").value = "3"; updateAudioLevelLabel(); applyAudio(); }
}
if ($("visual").checked && !videoEverOn) beginExperience();
debounced();
});
// Altitude knob: drag to turn (commit detents on release), scroll to step, tap a label to jump.
@@ -1272,13 +1300,18 @@ async function main() {
dial.addEventListener("wheel", onWheel, { passive: false });
$("stage").addEventListener("wheel", onWheel, { passive: false });
update(); // render the initial state (both toggles off → black, silent)
await preloadAllMedia(); // download all media, updating the loading bar
// Preload BEFORE interaction: a base for every altitude + all related morphs, so
// turning the knob is always smooth (no on-demand stall, no blank next scene). The
// "Loading Universe…" bar tracks this; the universe stays gated until it's ready.
await preloadAllMedia();
hideLoading(); // experience is ready — boots SILENT (video off, audio 0)
$("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage
// No un-gestured auto-start: a browser autoplay policy blocks an audio play() made
// outside a user gesture (muted video survives, sound does not), so an auto-start
// would show video but stay silent. Instead the experience waits for the operator to
// 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.
// 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.
}
// Populate the language dropdown from the registry and wire live switching.
@@ -1315,3 +1348,31 @@ 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);
})();
+65
View File
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Credits — Human Experience Filter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
<main class="credits-wrap">
<header>
<h1>Credits &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
@@ -0,0 +1,75 @@
// Credits/colophon data + render — no framework. UMD so the browser gets a
// `HEFCredits` global and `node --test` can require() the pure functions.
//
// The legally load-bearing attribution is per VIDEO clip (CC BY / CC BY-SA need
// the author + license shown to the viewer, and the footage is altered → a
// derivative). That data already ships in clips.json (baked from /api/clips):
// each clip carries free-text `license` + `source`. This module turns that into
// the credits list the public colophon (credits.html) renders. Audio is all
// PD/CC0 and credited as a static courtesy block in the page itself.
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFCredits = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&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,6 +19,8 @@
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: "音声" },
+5 -1
View File
@@ -13,7 +13,10 @@
<div class="loading-bar"><div id="loading-fill"></div></div>
</div>
</div>
<header><h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1></header>
<header>
<h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1>
<a href="credits.html" class="credits-link" data-i18n="credits.link">ⓘ Credits &amp; licenses</a>
</header>
<main>
<section class="stage" id="stage">
<div class="screen">
@@ -26,6 +29,7 @@
<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>
+40 -1
View File
@@ -1,7 +1,32 @@
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: #111; color: #eee; }
header { padding: 0.6rem 1rem; background: #000; }
header { padding: 0.6rem 1rem; background: #000; display: flex; align-items: baseline;
justify-content: space-between; gap: 1rem; }
h1 { font-size: 1rem; margin: 0; font-weight: 600; }
/* Always-visible link to the credits/colophon — reachable from the work, which is
what CC BY / BY-SA attribution "reasonable to the medium" calls for. */
.credits-link { color: #9af; text-decoration: none; font-size: 12px; white-space: nowrap; flex: none; }
.credits-link:hover { text-decoration: underline; }
/* --- Credits / colophon page (credits.html) --- */
.credits-page { background: #0c0f14; }
.credits-wrap { max-width: 760px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.credits-wrap h1 { font-size: 1.6rem; }
.credits-wrap h2 { font-size: 1.05rem; color: #9af; margin: 1.8rem 0 0.5rem; }
.credits-wrap a { color: #9af; }
.credits-wrap .back-link { font-size: 13px; }
.credits-wrap .note { color: #9ab; font-size: 13px; }
.declaration { background: #131a24; border: 1px solid #233; border-radius: 6px;
padding: 0.8rem 1rem; margin-top: 1rem; }
.credits-list { display: grid; gap: 0.7rem; margin-top: 0.8rem; }
.credit { background: #131820; border: 1px solid #1f2a3a; border-radius: 5px; padding: 0.6rem 0.8rem; }
.credit .title { font-weight: 600; color: #dfe7f2; }
.credit .lic { display: inline-block; margin-top: 0.2rem; font-size: 12px; color: #cbd; }
.credit .lic.missing { color: #f97; }
.credit .src { margin-top: 0.3rem; font-size: 12px; color: #8aa; line-height: 1.5; }
.audio-credits { color: #b9c4d2; font-size: 13px; line-height: 1.6; padding-left: 1.2rem; }
.credits-list .loading, .credits-list .err { color: #9ab; }
.credits-list .err { color: #f97; }
main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: flex-start; }
/* Stage stays pinned in view while the right pane scrolls on its own. */
.stage { flex: 1 1 640px; position: sticky; top: 1rem; }
@@ -51,6 +76,20 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
.black { position: absolute; inset: 0; background: #000; opacity: 1;
z-index: 50; transform: translateZ(0); transition: opacity 200ms ease; }
.hidden { display: none; }
/* "Run simulation" — the obvious starting point, shown over the (black) stage once
media is preloaded. Sits above the black cover (z-index 50). Dismissed when the
experience begins (the button, or turning Video on directly). */
.run-sim {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
z-index: 60; cursor: pointer; user-select: none;
padding: 0.85rem 2rem; border: 0; border-radius: 999px;
font: 600 18px/1 system-ui, sans-serif; letter-spacing: 0.03em;
color: #04101f; background: linear-gradient(90deg, #4e9cff, #9af);
box-shadow: 0 4px 24px rgba(78, 156, 255, 0.45);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.run-sim:hover { transform: translate(-50%, -50%) scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
.run-sim:active { transform: translate(-50%, -50%) scale(0.98); }
/* Stack the two Output toggles (Video / Audio) with a little breathing room. */
.dev-switch + .dev-switch { margin-top: 0.45rem; }
/* Live audio status readout (diagnostic) — turns green when actually playing. */
+66
View File
@@ -0,0 +1,66 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const C = require("../static/credits.js");
const CLIPS = [
{ id: "reef_x", title: "Reef X", license: "CC-BY-SA 4.0 — credit Joe Mabel", source: "Wikimedia <reef>" },
{ id: "cosmos", title: "Cosmic Cliffs", license: "CC-BY-class — credit NASA, ESA, CSA, STScI", source: "NASA SVS 31348" },
{ id: "pd_only", title: "PD clip", license: "public-domain (US Gov, 17 U.S.C. §105)", source: "NASA" },
];
test("creditEntries returns one entry per clip, preserving license/source", () => {
const out = C.creditEntries(CLIPS);
assert.equal(out.length, CLIPS.length, "no clip is silently dropped from the credits");
const reef = out.find((e) => e.id === "reef_x");
assert.equal(reef.license, "CC-BY-SA 4.0 — credit Joe Mabel");
assert.equal(reef.source, "Wikimedia <reef>");
assert.equal(reef.title, "Reef X");
});
test("creditEntries is sorted by id for deterministic output", () => {
const ids = C.creditEntries(CLIPS).map((e) => e.id);
assert.deepEqual(ids, ["cosmos", "pd_only", "reef_x"]);
});
test("creditEntries falls back title->id and tolerates missing fields", () => {
const out = C.creditEntries([{ id: "bare" }]);
assert.deepEqual(out, [{ id: "bare", title: "bare", license: "", source: "" }]);
});
test("creditEntries handles null/empty input", () => {
assert.deepEqual(C.creditEntries(null), []);
assert.deepEqual(C.creditEntries([]), []);
});
test("creditsListHtml renders every clip's attribution and escapes HTML", () => {
const html = C.creditsListHtml(CLIPS);
assert.match(html, /Cosmic Cliffs/);
assert.match(html, /Joe Mabel/);
// free-text source "Wikimedia <reef>" must be escaped, not injected as a tag
assert.match(html, /Wikimedia &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/);
});
+3
View File
@@ -19,6 +19,9 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
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"))
+33 -9
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. 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.
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.
Install: pip install -e '.[e2e]' && python -m playwright install chromium
"""
@@ -100,12 +100,36 @@ def test_app_boots_silent_video_off(page):
) is True
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.
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.
_toggle_visual(page) # off -> on
page.wait_for_function("document.getElementById('audio').value === '3'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '3'")
page.wait_for_function("document.getElementById('audio').value === '2'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '2'")
page.wait_for_selector("#run-sim.hidden", state="attached") # prompt dismissed
page.wait_for_selector("#black.hidden", state="attached") # video showing
page.wait_for_function(
"(() => { const a = document.getElementById('aud');"
+81
View File
@@ -0,0 +1,81 @@
"""E2E (Playwright) — the public credits/colophon page actually surfaces the
CC-BY / CC-BY-SA video attribution to a visitor.
Unit tests pin the credits *logic* (creditEntries/creditsListHtml) and the build
ships the files; this confirms the live integration: credits.js fetches the clips
and fills the page in a real browser, and the experience links to it. The suite
skips cleanly when Playwright/Chromium is absent (headless box)."""
import socket
import threading
import time
from contextlib import closing
import pytest
pytest.importorskip("playwright.sync_api")
def _free_port() -> int:
with closing(socket.socket()) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture(scope="module")
def app_url():
import uvicorn
from simulator.app import app
port = _free_port()
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
for _ in range(50):
if server.started:
break
time.sleep(0.1)
if not server.started:
pytest.skip("uvicorn did not start")
yield f"http://127.0.0.1:{port}"
server.should_exit = True
thread.join(timeout=5)
@pytest.fixture
def browser_page(app_url):
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
try:
browser = p.chromium.launch()
except Exception as exc: # no browser binary installed
pytest.skip(f"chromium not available: {exc}")
page = browser.new_page()
page._app_url = app_url # stash for the tests
yield page
browser.close()
def test_credits_page_lists_video_attributions(browser_page):
page = browser_page
page.goto(page._app_url + "/credits.html")
# credits.js fetches /api/clips and fills the container with one block per clip.
page.wait_for_selector("#video-credits .credit", timeout=30000)
count = page.eval_on_selector_all("#video-credits .credit", "els => els.length")
assert count >= 40, f"expected the full clip pool credited, got {count}"
# the required CC-BY / CC-BY-SA credits are visible to the viewer
body = page.inner_text("body")
assert "NASA, ESA, CSA, STScI" in body
assert "Joe Mabel" in body # a CC BY-SA clip's required author credit
assert "CC BY-SA 4.0" in body # the Option-D1 derivative declaration ("License & reuse")
def test_experience_links_to_credits(browser_page):
page = browser_page
page.goto(page._app_url + "/")
page.wait_for_selector("#loading", state="detached", timeout=60000)
href = page.get_attribute(".credits-link", "href")
assert href == "credits.html"
page.click(".credits-link")
page.wait_for_selector("#video-credits .credit", timeout=30000)
+37 -1
View File
@@ -25,6 +25,7 @@ sibling tools/http.py shadows the stdlib `http` that fastapi/starlette import:
"""
from __future__ import annotations
import hashlib
import json
import shutil
from pathlib import Path
@@ -35,7 +36,8 @@ 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"]
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:
@@ -66,6 +68,38 @@ def _write_redirects(out: Path, seg: str) -> None:
)
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)
@@ -84,7 +118,9 @@ def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
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.