Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c8c4b45cd | |||
| b9a52b1c41 | |||
| ee7c65a0f2 | |||
| 8277417583 | |||
| d10ffca4e8 | |||
| a68d0bc4df | |||
| 7da944af0c | |||
| 1c7f2b7785 | |||
| 3a45e338f3 | |||
| 8cc472c93c |
@@ -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
@@ -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, §1–3), every
|
||||
later deploy is one command (`deploy/cloudflare/deploy.sh`, or the Make targets):
|
||||
|
||||
```bash
|
||||
make deploy # frontend only: build + Pages deploy (fast — most deploys)
|
||||
make deploy-media # also re-sync all media to R2 (~2 min, parallel) — when media changed
|
||||
./deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, skip Pages
|
||||
```
|
||||
|
||||
The script rebuilds `dist/`, (optionally) parallel-uploads `dist-media/` to R2, and
|
||||
deploys Pages — printing the live URL. It checks `wrangler` auth first and fails
|
||||
loudly. Override defaults via env: `BUCKET`, `PROJECT`, `MEDIA_BASE`, `APP_PATH`,
|
||||
`BRANCH`, `JOBS`. The sections below document the one-time setup the script assumes.
|
||||
|
||||
## Prerequisites (operator gestures — need Cloudflare auth)
|
||||
|
||||
`wrangler` is not installed in the dev box; the deploy runs from a machine with
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repeatable deploy of the HEF simulator to benstull.art (Cloudflare Pages + R2).
|
||||
#
|
||||
# deploy/cloudflare/deploy.sh # frontend only: build + Pages deploy (fast)
|
||||
# deploy/cloudflare/deploy.sh --media # also re-sync all media to R2 (~2 min, parallel)
|
||||
# deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, no Pages deploy
|
||||
#
|
||||
# Assumes the ONE-TIME setup is already done (see README.md): wrangler logged in,
|
||||
# the R2 bucket + CORS + static.benstull.art custom domain, and the Pages project +
|
||||
# benstull.art custom domain. This script only does the repeatable part.
|
||||
#
|
||||
# Override any of these via env: BUCKET, PROJECT, MEDIA_BASE, APP_PATH, BRANCH, JOBS.
|
||||
set -euo pipefail
|
||||
|
||||
BUCKET="${BUCKET:-human-experience-simulator-media}"
|
||||
PROJECT="${PROJECT:-human-experience-simulator}"
|
||||
MEDIA_BASE="${MEDIA_BASE:-https://static.benstull.art/}"
|
||||
APP_PATH="${APP_PATH:-/human-experience-simulator}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
JOBS="${JOBS:-10}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
OUT="$REPO_ROOT/dist"
|
||||
export MEDIA_OUT="$REPO_ROOT/dist-media"
|
||||
PY="${PY:-$REPO_ROOT/.venv/bin/python}"
|
||||
export WRANGLER="${WRANGLER:-$(command -v wrangler || echo "$HOME/.npm-global/bin/wrangler")}"
|
||||
|
||||
do_media=0
|
||||
do_pages=1
|
||||
case "${1:-}" in
|
||||
--media) do_media=1 ;;
|
||||
--media-only) do_media=1; do_pages=0 ;;
|
||||
"") ;;
|
||||
*) echo "unknown flag: $1 (use --media or --media-only)"; exit 2 ;;
|
||||
esac
|
||||
|
||||
[ -x "$WRANGLER" ] || { command -v "$WRANGLER" >/dev/null || { echo "wrangler not found ($WRANGLER) — npm i -g wrangler"; exit 1; }; }
|
||||
"$WRANGLER" whoami >/dev/null 2>&1 || { echo "not authenticated — run: $WRANGLER login"; exit 1; }
|
||||
|
||||
echo "▶ build ($MEDIA_BASE)"
|
||||
cd "$REPO_ROOT"
|
||||
"$PY" -m tools.build_static --out "$OUT" --media-out "$MEDIA_OUT" \
|
||||
--media-base "$MEDIA_BASE" --app-path "$APP_PATH" | tail -1
|
||||
|
||||
if [ "$do_media" = 1 ]; then
|
||||
echo "▶ R2 media sync → $BUCKET (parallel x$JOBS, no bulk-sync exists)"
|
||||
export BUCKET
|
||||
fails=$(find "$MEDIA_OUT" -type f -print0 \
|
||||
| xargs -0 -P "$JOBS" -I {} bash "$REPO_ROOT/deploy/cloudflare/r2-put-one.sh" {} \
|
||||
| tee /dev/stderr | grep -c '^FAIL ' || true)
|
||||
[ "$fails" = 0 ] || { echo "✘ $fails media uploads failed"; exit 1; }
|
||||
echo "✔ media synced"
|
||||
fi
|
||||
|
||||
if [ "$do_pages" = 1 ]; then
|
||||
echo "▶ Pages deploy → $PROJECT"
|
||||
"$WRANGLER" pages deploy "$OUT" --project-name "$PROJECT" --branch "$BRANCH" --commit-dirty=true | tail -3
|
||||
fi
|
||||
|
||||
echo "✔ done — https://benstull.art$APP_PATH/"
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"allowed": {
|
||||
"origins": ["https://benstull.art"],
|
||||
"methods": ["GET", "HEAD"],
|
||||
"headers": ["Range", "Content-Type"]
|
||||
},
|
||||
"exposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
|
||||
"maxAgeSeconds": 86400
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upload ONE file to R2 with the right content-type + immutable cache. Used by
|
||||
# deploy.sh's parallel xargs (wrangler has no bulk sync). Reads BUCKET / MEDIA_OUT /
|
||||
# WRANGLER from the environment; the single argument is the local file path.
|
||||
set -u
|
||||
f="$1"
|
||||
key="${f#"$MEDIA_OUT"/}"
|
||||
# Guard: if the prefix strip didn't fire (e.g. relative path vs absolute MEDIA_OUT),
|
||||
# the key would wrongly keep a leading dir → wrong R2 path. Fail loudly, don't ship it.
|
||||
if [ "$key" = "$f" ]; then
|
||||
echo "FAIL $f (key strip failed — MEDIA_OUT='$MEDIA_OUT' is not a prefix; pass absolute paths)"
|
||||
exit 1
|
||||
fi
|
||||
case "${f##*.}" in
|
||||
mp4) ct=video/mp4 ;;
|
||||
mp3) ct=audio/mpeg ;;
|
||||
json) ct=application/json ;;
|
||||
*) ct=application/octet-stream ;;
|
||||
esac
|
||||
if "$WRANGLER" r2 object put "$BUCKET/$key" --file "$f" --remote \
|
||||
--content-type "$ct" --cache-control "public, max-age=31536000, immutable" >/dev/null 2>&1; then
|
||||
echo "ok $key"
|
||||
else
|
||||
echo "FAIL $key"
|
||||
fi
|
||||
@@ -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
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Credits — Human Experience Filter</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="credits-page">
|
||||
<main class="credits-wrap">
|
||||
<header>
|
||||
<h1>Credits & licenses</h1>
|
||||
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
|
||||
</header>
|
||||
|
||||
<!-- Option D1: the altered work's own license + the "modified" statement that
|
||||
CC BY / CC BY-SA attribution requires. Heading deliberately NOT "About" — the
|
||||
separate About page covers the project's intent. -->
|
||||
<section class="declaration">
|
||||
<h2>License & reuse</h2>
|
||||
<p>
|
||||
The <em>Human Experience Filter</em> alters its source footage in real time
|
||||
(overlays, an emotion/affect channel, and a WebGL “dream” shader), so every
|
||||
frame you see is a <strong>derivative</strong> of the original clip.
|
||||
</p>
|
||||
<p>
|
||||
This altered work is offered under
|
||||
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="license">Creative
|
||||
Commons Attribution-ShareAlike 4.0 (CC BY-SA 4.0)</a>. The original sources it
|
||||
builds on are credited below. This is a non-commercial art installation.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Footage</h2>
|
||||
<p class="note">
|
||||
Original video sources and their licenses. Public-domain (US-government) and
|
||||
CC0 clips need no attribution; the names below for CC BY / CC BY-SA clips are
|
||||
required credit. All footage is altered as described above.
|
||||
</p>
|
||||
<div id="video-credits" class="credits-list">
|
||||
<p class="loading">Loading credits…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audio is all public-domain / CC0, so attribution is courtesy, not required;
|
||||
it isn't tracked per-clip in the API, so it's acknowledged here as a block. -->
|
||||
<section>
|
||||
<h2>Sound</h2>
|
||||
<p class="note">
|
||||
Soundtracks are public domain or CC0 — credit is courtesy, not required.
|
||||
</p>
|
||||
<ul class="audio-credits">
|
||||
<li><strong>NASA / Chandra X-ray Observatory (CXC/SAO)</strong> — cosmos sonification (public domain).</li>
|
||||
<li><strong>NOAA</strong> SanctSound & PMEL hydrophone recordings — reef/abyss biological layers (public domain).</li>
|
||||
<li><strong>freesound.org</strong> contributors — Sonicfreak (station room-tone) and DCSFX (underwater bed) (CC0).</li>
|
||||
<li><strong>BigSoundBank</strong> — sea waves & gull calls (CC0).</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script src="credits.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
// Credits/colophon data + render — no framework. UMD so the browser gets a
|
||||
// `HEFCredits` global and `node --test` can require() the pure functions.
|
||||
//
|
||||
// The legally load-bearing attribution is per VIDEO clip (CC BY / CC BY-SA need
|
||||
// the author + license shown to the viewer, and the footage is altered → a
|
||||
// derivative). That data already ships in clips.json (baked from /api/clips):
|
||||
// each clip carries free-text `license` + `source`. This module turns that into
|
||||
// the credits list the public colophon (credits.html) renders. Audio is all
|
||||
// PD/CC0 and credited as a static courtesy block in the page itself.
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFCredits = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// One attribution entry per clip, sorted by id (deterministic). A clip is
|
||||
// NEVER silently dropped — even one with an empty license appears, so a
|
||||
// missing credit is visible rather than hidden. License/source are the
|
||||
// free-text strings authored in the manifest.
|
||||
function creditEntries(clips) {
|
||||
return (clips || [])
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title || c.id,
|
||||
license: c.license || "",
|
||||
source: c.source || "",
|
||||
}))
|
||||
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
||||
}
|
||||
|
||||
function creditHtml(e) {
|
||||
const lic = e.license
|
||||
? `<span class="lic">${escapeHtml(e.license)}</span>`
|
||||
: `<span class="lic missing">license not recorded</span>`;
|
||||
const src = e.source ? `<div class="src">${escapeHtml(e.source)}</div>` : "";
|
||||
return (
|
||||
`<div class="credit">` +
|
||||
`<div class="title">${escapeHtml(e.title)}</div>` +
|
||||
lic + src +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function creditsListHtml(clips) {
|
||||
return creditEntries(clips).map(creditHtml).join("");
|
||||
}
|
||||
|
||||
// Browser bootstrap: fetch the same clips the experience uses (baked
|
||||
// clips.json in the static build, the live API otherwise — the config.js
|
||||
// flag, loaded before this script, decides) and fill the container.
|
||||
function init(doc, cfg) {
|
||||
const fetchUrl = cfg && cfg.static ? "clips.json" : "/api/clips";
|
||||
const box = doc.getElementById("video-credits");
|
||||
if (!box) return;
|
||||
return fetch(fetchUrl)
|
||||
.then((r) => r.json())
|
||||
.then((d) => { box.innerHTML = creditsListHtml(d.clips || []); })
|
||||
.catch((err) => { box.innerHTML = `<p class="err">Could not load credits: ${escapeHtml(err.message)}</p>`; });
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init(document, (typeof window !== "undefined" && window.HEF_CONFIG) || {});
|
||||
});
|
||||
}
|
||||
|
||||
return { escapeHtml, creditEntries, creditHtml, creditsListHtml, init };
|
||||
});
|
||||
@@ -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 d’Expérience Humaine — Aperçu d’altération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
|
||||
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de l’univers", ja: "宇宙を読み込み中" },
|
||||
"run.button": { en: "Run simulation", es: "Iniciar simulación", fr: "Lancer la simulation", ja: "シミュレーションを開始" },
|
||||
"credits.link": { en: "ⓘ Credits & licenses", es: "ⓘ Créditos y licencias", fr: "ⓘ Crédits et licences", ja: "ⓘ クレジットとライセンス" },
|
||||
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
|
||||
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
|
||||
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: #111; color: #eee; }
|
||||
header { padding: 0.6rem 1rem; background: #000; }
|
||||
header { padding: 0.6rem 1rem; background: #000; display: flex; align-items: baseline;
|
||||
justify-content: space-between; gap: 1rem; }
|
||||
h1 { font-size: 1rem; margin: 0; font-weight: 600; }
|
||||
/* Always-visible link to the credits/colophon — reachable from the work, which is
|
||||
what CC BY / BY-SA attribution "reasonable to the medium" calls for. */
|
||||
.credits-link { color: #9af; text-decoration: none; font-size: 12px; white-space: nowrap; flex: none; }
|
||||
.credits-link:hover { text-decoration: underline; }
|
||||
|
||||
/* --- Credits / colophon page (credits.html) --- */
|
||||
.credits-page { background: #0c0f14; }
|
||||
.credits-wrap { max-width: 760px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.credits-wrap h1 { font-size: 1.6rem; }
|
||||
.credits-wrap h2 { font-size: 1.05rem; color: #9af; margin: 1.8rem 0 0.5rem; }
|
||||
.credits-wrap a { color: #9af; }
|
||||
.credits-wrap .back-link { font-size: 13px; }
|
||||
.credits-wrap .note { color: #9ab; font-size: 13px; }
|
||||
.declaration { background: #131a24; border: 1px solid #233; border-radius: 6px;
|
||||
padding: 0.8rem 1rem; margin-top: 1rem; }
|
||||
.credits-list { display: grid; gap: 0.7rem; margin-top: 0.8rem; }
|
||||
.credit { background: #131820; border: 1px solid #1f2a3a; border-radius: 5px; padding: 0.6rem 0.8rem; }
|
||||
.credit .title { font-weight: 600; color: #dfe7f2; }
|
||||
.credit .lic { display: inline-block; margin-top: 0.2rem; font-size: 12px; color: #cbd; }
|
||||
.credit .lic.missing { color: #f97; }
|
||||
.credit .src { margin-top: 0.3rem; font-size: 12px; color: #8aa; line-height: 1.5; }
|
||||
.audio-credits { color: #b9c4d2; font-size: 13px; line-height: 1.6; padding-left: 1.2rem; }
|
||||
.credits-list .loading, .credits-list .err { color: #9ab; }
|
||||
.credits-list .err { color: #f97; }
|
||||
main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: flex-start; }
|
||||
/* Stage stays pinned in view while the right pane scrolls on its own. */
|
||||
.stage { flex: 1 1 640px; position: sticky; top: 1rem; }
|
||||
@@ -51,6 +76,20 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
|
||||
.black { position: absolute; inset: 0; background: #000; opacity: 1;
|
||||
z-index: 50; transform: translateZ(0); transition: opacity 200ms ease; }
|
||||
.hidden { display: none; }
|
||||
/* "Run simulation" — the obvious starting point, shown over the (black) stage once
|
||||
media is preloaded. Sits above the black cover (z-index 50). Dismissed when the
|
||||
experience begins (the button, or turning Video on directly). */
|
||||
.run-sim {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
z-index: 60; cursor: pointer; user-select: none;
|
||||
padding: 0.85rem 2rem; border: 0; border-radius: 999px;
|
||||
font: 600 18px/1 system-ui, sans-serif; letter-spacing: 0.03em;
|
||||
color: #04101f; background: linear-gradient(90deg, #4e9cff, #9af);
|
||||
box-shadow: 0 4px 24px rgba(78, 156, 255, 0.45);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.run-sim:hover { transform: translate(-50%, -50%) scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
|
||||
.run-sim:active { transform: translate(-50%, -50%) scale(0.98); }
|
||||
/* Stack the two Output toggles (Video / Audio) with a little breathing room. */
|
||||
.dev-switch + .dev-switch { margin-top: 0.45rem; }
|
||||
/* Live audio status readout (diagnostic) — turns green when actually playing. */
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const C = require("../static/credits.js");
|
||||
|
||||
const CLIPS = [
|
||||
{ id: "reef_x", title: "Reef X", license: "CC-BY-SA 4.0 — credit Joe Mabel", source: "Wikimedia <reef>" },
|
||||
{ id: "cosmos", title: "Cosmic Cliffs", license: "CC-BY-class — credit NASA, ESA, CSA, STScI", source: "NASA SVS 31348" },
|
||||
{ id: "pd_only", title: "PD clip", license: "public-domain (US Gov, 17 U.S.C. §105)", source: "NASA" },
|
||||
];
|
||||
|
||||
test("creditEntries returns one entry per clip, preserving license/source", () => {
|
||||
const out = C.creditEntries(CLIPS);
|
||||
assert.equal(out.length, CLIPS.length, "no clip is silently dropped from the credits");
|
||||
const reef = out.find((e) => e.id === "reef_x");
|
||||
assert.equal(reef.license, "CC-BY-SA 4.0 — credit Joe Mabel");
|
||||
assert.equal(reef.source, "Wikimedia <reef>");
|
||||
assert.equal(reef.title, "Reef X");
|
||||
});
|
||||
|
||||
test("creditEntries is sorted by id for deterministic output", () => {
|
||||
const ids = C.creditEntries(CLIPS).map((e) => e.id);
|
||||
assert.deepEqual(ids, ["cosmos", "pd_only", "reef_x"]);
|
||||
});
|
||||
|
||||
test("creditEntries falls back title->id and tolerates missing fields", () => {
|
||||
const out = C.creditEntries([{ id: "bare" }]);
|
||||
assert.deepEqual(out, [{ id: "bare", title: "bare", license: "", source: "" }]);
|
||||
});
|
||||
|
||||
test("creditEntries handles null/empty input", () => {
|
||||
assert.deepEqual(C.creditEntries(null), []);
|
||||
assert.deepEqual(C.creditEntries([]), []);
|
||||
});
|
||||
|
||||
test("creditsListHtml renders every clip's attribution and escapes HTML", () => {
|
||||
const html = C.creditsListHtml(CLIPS);
|
||||
assert.match(html, /Cosmic Cliffs/);
|
||||
assert.match(html, /Joe Mabel/);
|
||||
// free-text source "Wikimedia <reef>" must be escaped, not injected as a tag
|
||||
assert.match(html, /Wikimedia <reef>/);
|
||||
assert.doesNotMatch(html, /<reef>/);
|
||||
// one block per clip
|
||||
const blocks = html.match(/class="credit"/g) || [];
|
||||
assert.equal(blocks.length, CLIPS.length);
|
||||
});
|
||||
|
||||
test("credits.html wires config.js + credits.js, the attributions container, and a back link", () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, "../static/credits.html"), "utf8");
|
||||
assert.match(html, /<script src="config\.js">/); // static/live media-base + flag
|
||||
assert.match(html, /<script src="credits\.js">/);
|
||||
assert.match(html, /id="video-credits"/); // JS fills this
|
||||
assert.match(html, /href="index\.html"/); // back into the experience
|
||||
});
|
||||
|
||||
test("credits.html carries the CC BY-SA derivative declaration (Option D1)", () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, "../static/credits.html"), "utf8");
|
||||
assert.match(html, /CC BY-SA 4\.0/); // the altered work's own license
|
||||
assert.match(html, /altered/i); // states the footage is modified
|
||||
// The section is NOT titled "About this work" — that name belongs to the separate
|
||||
// About page; here the declaration lives under "License & reuse".
|
||||
assert.doesNotMatch(html, /About this work/i);
|
||||
assert.match(html, /License & reuse/);
|
||||
});
|
||||
@@ -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
@@ -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 0–10 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 0–10 range
|
||||
dial (not a checkbox); set it by writing `value` + firing an `input` event.
|
||||
|
||||
Install: pip install -e '.[e2e]' && python -m playwright install chromium
|
||||
"""
|
||||
@@ -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');"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user