feat(static): config-driven media base, baked-JSON boot, client pick/alteration, crossOrigin

Relative asset paths (work at dev-root and under the /human-experience-simulator
deploy prefix), config.js gate, devLiveReload no-ops in static mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-30 07:43:49 -07:00
parent be13248bdd
commit 271586625c
4 changed files with 49 additions and 30 deletions
+34 -21
View File
@@ -57,14 +57,19 @@ let activeLang = "en"; // session-only; no persistence (resets to en each lo
let lastAffect = null; // {strength, intensity, right} from the last renderAffect, for re-render on language switch
async function loadData() {
const clips = (await (await fetch("/api/clips")).json()).clips || [];
// Static build: boot from baked JSON (no server). Dev: the live API. The baked
// files sit alongside index.html, so RELATIVE urls resolve under the deploy path.
const api = (window.HEF_CONFIG && window.HEF_CONFIG.static)
? { clips: "clips.json", versions: "media-versions.json", ring: "ring.json" }
: { clips: "/api/clips", versions: "/api/media-versions", ring: "/api/ring" };
const clips = (await (await fetch(api.clips)).json()).clips || [];
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
// Per-file content-hash tokens → appended to /media URLs as ?v=<hash> so a
// re-baked clip (new bytes, same path) gets a fresh URL the browser can't serve
// stale. Best-effort: an empty map just yields un-versioned URLs.
try { mediaVersions = (await (await fetch("/api/media-versions")).json()).versions || {}; }
try { mediaVersions = (await (await fetch(api.versions)).json()).versions || {}; }
catch (_) { mediaVersions = {}; }
const r = await fetch("/api/ring");
const r = await fetch(api.ring);
serverRing = r.ok;
ring = r.ok ? await r.json() : null;
if (!ring && clips.length) {
@@ -87,16 +92,12 @@ async function loadData() {
async function pickRandomMember() {
const scale = ring && ring.scales[ringIndex];
if (!scale) return null;
if (serverRing) {
try {
const resp = await fetch("/api/ring/advance", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ from_index: ringIndex, delta: 0 }),
});
if (resp.ok) return (await resp.json()).target_clip_id;
} catch (_) { /* fall through to the primary member */ }
}
return scale.clip_id;
// Uniform random pool member, client-side (mirrors hef.selection.pick_clip_id).
// The drag/scroll navigation already resolves picks + morphs client-side via
// scrub.js; this removes the last /api/ring/advance dependency so the build is
// fully static. The synthesized fallback ring has a pool of one → returns it.
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
}
// Land on the current scale: pick a random pool member and force its media to load.
@@ -118,9 +119,11 @@ let mediaVersions = {}; // file -> content-hash token (from /api/media-version
// Network path for a media file, content-hash-versioned so a re-baked clip's URL
// changes with its bytes (permanent cache-bust). The blob cache, when present,
// wins — those bytes are already the current ones for this session.
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
function mediaBase() { return (window.HEF_CONFIG && window.HEF_CONFIG.mediaBase) || "/media/"; }
function mediaNetUrl(file) { const v = mediaVersions[file]; return mediaBase() + file + (v ? "?v=" + v : ""); }
function mediaUrl(file) {
if (file.startsWith("/media/")) return file; // already a resolved absolute url (audio layer)
// Already a resolved absolute url (audio layer, or R2 in static mode): pass through.
if (/^https?:\/\//.test(file) || file.startsWith("/media/")) return file;
return mediaBlobs[file] || mediaNetUrl(file);
}
@@ -614,12 +617,21 @@ function controls() {
let timer = null;
async function update() {
if (busy) return;
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
});
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
const data = await resp.json();
let data;
if (window.HEF_CONFIG && window.HEF_CONFIG.static) {
// Static build: compute the alteration locally (HEFAlteration is the JS port of
// player.alteration/audio). Audio couples to the current altitude's asset.
const scale = ring && ring.scales[ringIndex];
const scaleAudio = (scale && scale.audio) || "";
data = HEFAlteration.alteration(controls(), scaleAudio, mediaBase());
} else {
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
});
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
data = await resp.json();
}
readout.textContent = JSON.stringify(data, null, 2);
// Tolerate a stale server that still returns {content:{video}} instead of the
// {render:{video:{shown}}} (visual/audio-split) shape, so video works either way.
@@ -1035,6 +1047,7 @@ async function reroll() {
// from the live API and masks it otherwise). Reloads on my edits AND on a server
// restart. Dev-only convenience; harmless if the endpoint is absent.
function devLiveReload() {
if (window.HEF_CONFIG && window.HEF_CONFIG.static) return; // no dev server in the static build
let seen = null;
setInterval(async () => {
try {
+4
View File
@@ -0,0 +1,4 @@
// Runtime config. This dev default serves media from the local FastAPI /media
// mount and uses the live API. The static build (tools/build_static.py) OVERWRITES
// this file in dist/ with { mediaBase: "https://static.benstull.art/", static: true }.
window.HEF_CONFIG = { mediaBase: "/media/", static: false };
+10 -8
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Alteration Simulator</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="loading">
@@ -17,10 +17,10 @@
<main>
<section class="stage" id="stage">
<div class="screen">
<video id="vid" loop muted playsinline></video>
<video id="vid-loop" loop muted playsinline></video>
<audio id="aud" loop preload="auto"></audio>
<audio id="aud-b" loop preload="auto"></audio>
<video id="vid" loop muted playsinline crossorigin="anonymous"></video>
<video id="vid-loop" loop muted playsinline crossorigin="anonymous"></video>
<audio id="aud" loop preload="auto" crossorigin="anonymous"></audio>
<audio id="aud-b" loop preload="auto" crossorigin="anonymous"></audio>
<canvas id="paint"></canvas>
<div id="tint"></div>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
@@ -107,8 +107,10 @@
</div>
</section>
</main>
<script src="/scrub.js"></script>
<script src="/i18n.js"></script>
<script src="/app.js"></script>
<script src="config.js"></script>
<script src="scrub.js"></script>
<script src="alteration.js"></script>
<script src="i18n.js"></script>
<script src="app.js"></script>
</body>
</html>