From 271586625c8b20c2c4019739852ccc7576b079bb Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 07:43:49 -0700 Subject: [PATCH] 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) --- simulator/static/app.js | 55 +++++++++++++++++++------------ simulator/static/config.js | 4 +++ simulator/static/index.html | 18 +++++----- simulator/unit/index-i18n.test.js | 2 +- 4 files changed, 49 insertions(+), 30 deletions(-) create mode 100644 simulator/static/config.js diff --git a/simulator/static/app.js b/simulator/static/app.js index 7fe28d1..54e4c2d 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -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= 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 { diff --git a/simulator/static/config.js b/simulator/static/config.js new file mode 100644 index 0000000..17fafac --- /dev/null +++ b/simulator/static/config.js @@ -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 }; diff --git a/simulator/static/index.html b/simulator/static/index.html index ce60563..838b86e 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -4,7 +4,7 @@ HEF — Alteration Simulator - +
@@ -17,10 +17,10 @@
- - - - + + + +
@@ -107,8 +107,10 @@
- - - + + + + + diff --git a/simulator/unit/index-i18n.test.js b/simulator/unit/index-i18n.test.js index 345c3c2..3d16c11 100644 --- a/simulator/unit/index-i18n.test.js +++ b/simulator/unit/index-i18n.test.js @@ -8,7 +8,7 @@ const I = require("../static/i18n.js"); const html = fs.readFileSync(path.join(__dirname, "../static/index.html"), "utf8"); test("index.html includes the i18n script and a language select", () => { - assert.match(html, /