Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d711db6a2c | |||
| 17856cac32 | |||
| 1a6ffc74a1 |
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Worktree isolation guard. This repo is worked by MULTIPLE concurrent sessions; if
|
||||
# they share the main checkout they clobber each other (branch switches, lost edits,
|
||||
# commits on the wrong branch). Every session must work in its OWN git worktree
|
||||
# OUTSIDE the main directory.
|
||||
#
|
||||
# worktree-guard.sh block PreToolUse(Edit|Write|NotebookEdit) — DENY edits to
|
||||
# files INSIDE the main checkout while in it. Files
|
||||
# outside the repo (memory, /tmp, scratchpad) are allowed.
|
||||
# worktree-guard.sh notice SessionStart — tell the agent to enter a worktree.
|
||||
#
|
||||
# Isolated = the session sits in a git worktree, where `--git-dir` (…/.git/worktrees/
|
||||
# <name>) differs from `--git-common-dir` (…/.git). Equal = the shared main checkout.
|
||||
set -u
|
||||
mode="${1:-notice}"
|
||||
|
||||
gd=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 # not a repo → nothing to guard
|
||||
gc=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) || exit 0
|
||||
[ "$gd" = "$gc" ] || exit 0 # already in a worktree → allow
|
||||
|
||||
root=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
repo=$(basename "${root:-repo}")
|
||||
guidance="You are in the SHARED main checkout ($root). Multiple sessions use it \
|
||||
concurrently and WILL clobber each other. Work in an isolated worktree OUTSIDE the \
|
||||
main directory: use the EnterWorktree tool (preferred — it relocates this session to \
|
||||
a fresh worktree branched from origin/main), or run \
|
||||
\`git worktree add -b <branch> ../${repo}-worktrees/<name> origin/main\` and cd there. \
|
||||
Do NOT edit files in the main checkout."
|
||||
|
||||
esc() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
|
||||
|
||||
if [ "$mode" = "block" ]; then
|
||||
# Only guard files INSIDE the main checkout's working tree; allow edits to files
|
||||
# outside the repo (auto-memory, /tmp, scratchpad, other repos).
|
||||
fp=$(python3 -c 'import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin); print(d.get("tool_input",{}).get("file_path","") or "")
|
||||
except Exception: print("")' 2>/dev/null)
|
||||
case "$fp" in
|
||||
/*) abs="$fp" ;;
|
||||
"") abs="$root" ;;
|
||||
*) abs="$root/$fp" ;;
|
||||
esac
|
||||
case "$abs" in
|
||||
"$root"/*|"$root") : ;; # inside the repo → fall through to deny
|
||||
*) exit 0 ;; # outside the repo → allow
|
||||
esac
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$(esc "$guidance")"
|
||||
else
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' "$(esc "⚠ WORKTREE ISOLATION REQUIRED. $guidance")"
|
||||
fi
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"worktree": {
|
||||
"bgIsolation": "worktree",
|
||||
"baseRef": "fresh"
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" notice"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" block"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
# Session 0034.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T07-24 (PST)
|
||||
> End: 2026-06-30T10-01 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 08b95eb0-3ec5-400a-9236-da78f01139a0
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art/.claude/worktrees/fix+audio-and-tracking-labels
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> The audio isn't playing at all. Let's fix that and then let's make sure there are
|
||||
> left and right brain tracking labels for everything. Make sure you're working on a
|
||||
> worktree. There are other concurrent sessions that may have real merge conflicts to
|
||||
> handle.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- The simulator (`simulator/`) is a FastAPI + static-JS experience: an altitude dial
|
||||
over 6 scales (cosmos/orbit/sky/coast/reef/abyss), 41 rotating-pool clips, a
|
||||
real-time Kuwahara "dream" shader, two HUD label layers — **Think** (left-brain
|
||||
factual chips, `#overlay`) and **Feel** (right-brain affect words, `#affect`) —
|
||||
plus a per-altitude soundtrack with a 0–10 audio dial and a built-in
|
||||
`#audio-status` diagnostic.
|
||||
- s0027 had brought all 41 clips to left+right annotation with 4-lang parity, but the
|
||||
left labels were thin (many clips 1–2) and STATIC; affect was authored PER-SCALE
|
||||
(every clip in an altitude shared 4 feelings).
|
||||
- Operator was running a stale `:8000` server on the `design/cloudflare-static-publish`
|
||||
branch (a concurrent session's WIP — "Run simulation" begin-experience flow, +
|
||||
later a credits page). Project is EXEMPT from the Wiggleverse deploy pipeline
|
||||
(flotilla/PPE/§9); public target is static Cloudflare. cv2/imageio NOT installed in
|
||||
this env.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Worktree.** Created native worktree `fix+audio-and-tracking-labels` off
|
||||
`origin/main` (582183d). All work done here.
|
||||
2. **Audio (systematic-debugging) — FALSE ALARM.** Could not reproduce "no audio" in
|
||||
ANY automated condition: Chromium + WebKit, strict autoplay
|
||||
(`--autoplay-policy=user-gesture-required`), clean-main server AND the operator's
|
||||
`:8000` WIP server, via Video-toggle / Audio-dial / "Run simulation" button; all 5
|
||||
soundtracks decode to real non-silent audio (RMS 0.08–0.24). The raw
|
||||
native-controls `#aud-test` player was ALSO silent on the operator's Safari while
|
||||
the element reported playing → isolated it BELOW the app. Operator confirmed:
|
||||
**their Safari sound was muted.** No code change.
|
||||
3. **"Labels not showing" on cosmos_tarantula.** Root cause: operator was on `:8000`
|
||||
(design branch), whose manifest had that clip's `annotations: []`. My branch
|
||||
renders all labels correctly; labels are knob-gated (none at Think/Feel 0).
|
||||
4. **Left (Think) enrichment.** Every clip → ≥3 factual labels (added one to each of
|
||||
23 thin clips; orbit_bluemarble 1→3), full es/fr/ja parity. Pipeline:
|
||||
`LABELS` in `build_pool_manifest.py` → build → catalogs → `translate_manifest.py
|
||||
merge`. (commit `8cfceff`)
|
||||
5. **Motion-tracking.** Converted the 4 still-static abyss creatures (octopus, bigfin
|
||||
squid, dandelion siphonophore, sea pig) static→tracked, 3-keyframe paths sampled
|
||||
by eye from frames (cv2 unavailable). Tracked clips 7→11. (`8cfceff`)
|
||||
6. **Think/Feel overlap avoidance.** `renderAffect` now nudges Feel words off Think
|
||||
chip plates (obstacles computed from manifest, tracked paths sampled → holds whole
|
||||
loop, no per-frame jitter; new `window.__hefNoDeconflict` seam). Verified 0
|
||||
overlaps across all 6 scales incl. a tracked clip over time. (commit `041fcde`)
|
||||
7. **Per-clip right-brain feelings.** Operator: feelings should match each ACTUAL
|
||||
video. Added `AFFECT_CLIP` (per-clip override, scale fallback) for all 41 clips →
|
||||
41 distinct feeling-sets. cosmos done by me as a reviewed sample; the other 5
|
||||
scales drafted by **5 parallel subagents** (one per scale) that each VIEWED footage
|
||||
frames and followed the cosmos recipe; I normalized + authored all es/fr/ja for the
|
||||
19 new nuance keys. 42 distinct feeling keys now. (commits `bf1013b` + `2d54023`)
|
||||
8. **Merge.** Merged branch → `origin/main` (clean; main had only advanced by
|
||||
`sessions/` transcript commits). Confirmed all 4 work commits are ancestors of
|
||||
`origin/main`; another session (`feat/accessibility-pass`) has since merged main +
|
||||
a11y on top (`380c2d1`).
|
||||
|
||||
## Cut state (final)
|
||||
|
||||
- All work committed, merged to `origin/main`, pushed. Working tree clean.
|
||||
- Verification: 34/34 catalog+i18n+build pytest, 16/16 node unit, live render shows
|
||||
≥3 Think chips + distinct Feel words per clip (incl. new keys) with overlap 0 and no
|
||||
JS errors. (3 unrelated pytest failures = cv2/ffmpeg absent in this env.)
|
||||
- Worktree RETAINED (not torn down) because it is serving the operator's by-eye review
|
||||
at `http://127.0.0.1:8011/author.html`; tear down + stop `:8011` when review done.
|
||||
- §9 deploy pipeline: N/A — project is exempt (static Cloudflare target).
|
||||
|
||||
## Deferred decisions (low-confidence calls)
|
||||
|
||||
- **Audio = no code change.** Chose to treat it as environmental after exhaustive
|
||||
repro rather than guess-patch a working engine; operator confirmed muted Safari.
|
||||
- **Left-label box positions authored WITHOUT viewing each frame** (23 enriched
|
||||
clips, except bluemarble) — factual text is grounded, positions are estimates that
|
||||
want by-eye review.
|
||||
- **Per-clip feelings drafted from a single MID-FRAME** (motion not seen) via parallel
|
||||
subagents — first-pass reads; emotions may want tuning once a clip is in motion.
|
||||
- **Left reef/coast moving-subject clips left STATIC** (reef_redsea/flowergarden
|
||||
schools, coast_otters/seals) — tracking deferred (diffuse subjects + cv2 absent).
|
||||
- **Merged directly to `main` via `git push HEAD:main`** (no Gitea PR) at operator's
|
||||
explicit request; gh is GitHub-only here and no `tea` CLI.
|
||||
|
||||
## Operator plate
|
||||
|
||||
- Review the first-pass label/track/feeling positions in `/author.html` on `:8011`.
|
||||
- For Think: check each chip sits on its subject + the 4 abyss tracks follow the
|
||||
creature. For Feel: check emotions ring true once clips are in motion.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal refine the first-pass left-label box positions and the 4 abyss creature tracks per operator by-eye review (grab a frame per clip, nudge each chip onto its subject), and motion-track the remaining moving-subject clips (reef schools, coast animals) — data-only via build_pool_manifest + i18n pipeline
|
||||
```
|
||||
@@ -1,102 +0,0 @@
|
||||
# Session 0035.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T08-45 (PST)
|
||||
> End: 2026-06-30T10-08 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: ee3eaaac-d0a4-41a9-aa53-1919052fd8d3
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Not a `/goal` launch. Opened conversationally: "We need to make the entire
|
||||
simulator accessible, assuming it's not being used at a kiosk. What are the
|
||||
different types of accessibility and your suggestions (and difficulty)?" Then:
|
||||
build the first pass on a worktree, and add an `about.html` about the project's
|
||||
intent (accessible to all humans; vantage points from underwater→drones→space
|
||||
exploration; built with LLMs; honest that it's imperfect but more than the author
|
||||
alone could make).
|
||||
|
||||
Drove through the **superpowers** pipeline (brainstorming → writing-plans →
|
||||
executing-plans → finishing-a-development-branch), not a wgl init — so the wgl
|
||||
transcript ID was claimed at finalize (this file).
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Simulator built for an attended **kiosk**; the Cloudflare static publish
|
||||
(`design/cloudflare-static-publish`, already merged to `main` mid-session as
|
||||
`87d605d`) puts it on a public URL where no AT/attendant assumptions hold.
|
||||
- No `prefers-reduced-motion`, no keyboard on the SVG Altitude dial, several
|
||||
failing-contrast colors, no screen-reader path, no seizure safeguard — on a
|
||||
piece that is continuous motion + a "trippy at max" WebGL shader.
|
||||
|
||||
## Arc (turn-by-turn)
|
||||
|
||||
1. **Analysis** — read `index.html` / `style.css`, grepped `app.js` (zero aria /
|
||||
keyboard / reduced-motion). Enumerated 7 accessibility types grounded in the
|
||||
actual UI, with difficulty + a priority table. Recommended WCAG 2.1 AA + a
|
||||
motion/seizure layer.
|
||||
2. **Brainstorming** — operator chose: reduced-motion = **freeze-to-stills**;
|
||||
photosensitivity = **warning gate + flash audit**; about page = **English-first**.
|
||||
Added the about-page "imperfect but more than alone" beat. Wrote + committed the
|
||||
design spec.
|
||||
3. **Worktree** — first tried `EnterWorktree` (branches off `origin/main`); on
|
||||
inspecting the diff, the work belonged on `design/cloudflare-static-publish`
|
||||
(correct `app.js`, existing `credits.html` pattern). Operator: "new worktree off
|
||||
that, keep separate until ready for main." Created
|
||||
`feat/accessibility-pass` off the design branch under `.claude/worktrees/`.
|
||||
4. **Plan** — 10 bite-sized TDD tasks, committed.
|
||||
5. **Execution (TDD, test-first each)** — (1) layout: lang picker below Audio,
|
||||
globe inline-left; (2) AA contrast + focus-visible + visually-hidden; (3)
|
||||
`flash.js` pure WCAG-2.3.1 helper + node tests; (4) one-time photosensitivity
|
||||
gate; (5) reduced-motion freeze (single `playLoop()` guard + instant `autoScrub`);
|
||||
(6) keyboard/ARIA dial (`role=slider`, Arrow/Home/End, label buttons); (7)
|
||||
aria-live narration + `aria-hidden` decoratives; (8) wire flash-clamp + document
|
||||
audit (no breach found); (9) `about.html` + header link + `PUBLIC_ASSETS`.
|
||||
9 a11y e2e + 2 node tests, all green; fixed a gate-vs-run-sim regression in the
|
||||
shared `boot()` helper.
|
||||
6. **Finish** — operator: merge everything to main + push origin. `origin/main`
|
||||
had advanced 29 commits (static-publish already landed there) → divergent merge;
|
||||
one `app.js` conflict (main's D3 reverse-landing fix vs my reduced-motion guard)
|
||||
resolved keeping **both**. Re-verified on merged code (a11y 9/9, altitude-lock
|
||||
13/13, i18n 1/1, static-build 2/2, flash 2/2). Pushed `ff06783..380c2d1 main`.
|
||||
Removed the worktree; stopped bg servers.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **a11y pass + About page MERGED + PUSHED to `origin/main` (`380c2d1`).**
|
||||
- On `main` but **NOT deployed** — live benstull.art needs a Cloudflare redeploy.
|
||||
- App has **no content repo** → plan stays in-repo at
|
||||
`docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md`. No
|
||||
`submit-plan.sh`. Project is **exempt** from §9/flotilla/PPE (Cloudflare static).
|
||||
- A **concurrent** commit `1a6ffc7` ("phase-1 preload gate") sits on local `main`
|
||||
ahead of origin by 1 — **not this session's; left untouched/unpushed** per
|
||||
operator ("leave anything that isn't yours alone"). Other worktree
|
||||
`fix+audio-and-tracking-labels` also left alone.
|
||||
- Checkout now on `main` (was `design/cloudflare-static-publish` at start).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous low-confidence calls (no live placeholder existed, so logged here):_
|
||||
|
||||
- **SR narration = scale + clip title**, not the tiered factual (Think) HUD label —
|
||||
simpler/robust; the plan explicitly permitted the degrade. Could enrich later.
|
||||
- **Flash audit concluded no >3/sec breach** (1200ms per-altitude floor; fades are
|
||||
single, ≥1.2s apart); `flash.js` wired only as a guard floor (no-op today).
|
||||
- **Branched off the design branch, not main** — corrected after operator input.
|
||||
- **Merged the full divergent history to main** (static-publish was already on
|
||||
origin/main, so net-new was just the a11y work) — on explicit operator instruction.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal redeploy benstull.art/human-experience-simulator to Cloudflare so the accessibility pass + About page go live, per cloudflare-deploy-live (build with `python3 -m tools.build_static`, then the R2/Pages deploy gestures)
|
||||
```
|
||||
|
||||
Operator by-eye review of the a11y changes precedes the redeploy. Env gotchas for
|
||||
the next session: run `build_static` as a **module** (`python3 -m tools.build_static`)
|
||||
or `tools/http.py` shadows stdlib `http`; uvicorn dies mid-session and the e2e
|
||||
`webServer` fallback calls `python` (only `python3` here) → start it by hand on :8099;
|
||||
Playwright `test.use()` inside `test.describe()` breaks collection in PW 1.45.
|
||||
@@ -97,11 +97,5 @@
|
||||
},
|
||||
"0033": {
|
||||
"title": ""
|
||||
},
|
||||
"0034": {
|
||||
"title": ""
|
||||
},
|
||||
"0035": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
+53
-15
@@ -89,15 +89,30 @@ async function loadData() {
|
||||
// advance (content-pipeline §11.1, Python owns the randomness). The synthesized
|
||||
// fallback ring has a pool of one, so it just returns that member. Returns a
|
||||
// clip_id or null. Shared by the initial landing AND the Dev Mode re-roll button.
|
||||
// Lookups for the pure preload planner (HEFPreload): map clip/morph → media file and
|
||||
// test the in-memory blob cache. A clip is "loaded" only when its file is a cached blob.
|
||||
function _preloadDeps() {
|
||||
return {
|
||||
baseOf: (id) => (clipsById[id] || {}).base_file,
|
||||
morphFile: (a, b) => morphByPair[`${a}→${b}`] || null,
|
||||
isCached: (f) => !!f && !!mediaBlobs[f],
|
||||
};
|
||||
}
|
||||
|
||||
function poolIds(scale) {
|
||||
return ((scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }]).map((m) => m.clip_id);
|
||||
}
|
||||
|
||||
async function pickRandomMember() {
|
||||
const scale = ring && ring.scales[ringIndex];
|
||||
if (!scale) return null;
|
||||
// Uniform random pool member, client-side (mirrors hef.selection.pick_clip_id).
|
||||
// The drag/scroll navigation already resolves picks + morphs client-side via
|
||||
// scrub.js; this removes the last /api/ring/advance dependency so the build is
|
||||
// fully static. The synthesized fallback ring has a pool of one → returns it.
|
||||
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }];
|
||||
return pool[Math.floor(Math.random() * pool.length)].clip_id;
|
||||
// Show only a clip whose base is already loaded (eligible). Initially that's the
|
||||
// phase-1 first member; the pool grows as the background preload finishes. Fallback
|
||||
// to the first member (guaranteed loaded by the phase-1 gate).
|
||||
const ids = poolIds(scale);
|
||||
let elig = HEFPreload.eligibleMembers(ids, _preloadDeps());
|
||||
if (!elig.length) elig = [ids[0]];
|
||||
return HEFPreload.pick(elig, Math.random);
|
||||
}
|
||||
|
||||
// Land on the current scale: pick a random pool member and force its media to load.
|
||||
@@ -180,6 +195,18 @@ function preloadOrder() {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// Cache a fixed list of files (bounded concurrency), driving the "Loading Universe…"
|
||||
// bar. Used for the phase-1 gate (one clip/altitude + connecting morphs) before unlock.
|
||||
async function cacheMany(files, concurrency = 6) {
|
||||
const total = files.length || 1;
|
||||
let done = 0, i = 0;
|
||||
setLoadingProgress(0, total);
|
||||
async function worker() {
|
||||
while (i < files.length) { await cacheClip(files[i++]); done++; setLoadingProgress(done, total); }
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
|
||||
}
|
||||
|
||||
// Fetch one media file into the blob cache (idempotent — cached files are skipped).
|
||||
async function cacheClip(file) {
|
||||
if (!file || mediaBlobs[file]) return;
|
||||
@@ -928,11 +955,18 @@ function angDelta(a, b) {
|
||||
// destination pick (was the server's job in /api/ring/advance; moved here so the
|
||||
// scrub responds without a round-trip. `pick_clip_id` in player/ring.py stays the
|
||||
// canonical pure helper for the Pi player).
|
||||
function pickPoolClip(index) {
|
||||
function pickPoolClip(index, fromId) {
|
||||
const n = ring.scales.length;
|
||||
const s = ring.scales[HEFScrub.wrapIndex(index, n)];
|
||||
const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
|
||||
return pool[Math.floor(Math.random() * pool.length)].clip_id;
|
||||
const ids = poolIds(s);
|
||||
// Pick a DESTINATION only among clips fully loaded relative to fromId (base +
|
||||
// morphs both ways) — so the transition never stalls on un-downloaded media. The
|
||||
// eligible set grows as the background preload completes. Fallback to the first
|
||||
// pool member (the phase-1 gate guarantees it + its connecting morphs are loaded).
|
||||
let elig = fromId ? HEFPreload.eligibleDestinations(ids, fromId, _preloadDeps())
|
||||
: HEFPreload.eligibleMembers(ids, _preloadDeps());
|
||||
if (!elig.length) elig = [ids[0]];
|
||||
return HEFPreload.pick(elig, Math.random);
|
||||
}
|
||||
|
||||
// Build (or re-roll) the segment [lo, lo+1]. The end equal to the rested altitude
|
||||
@@ -942,8 +976,8 @@ function pickPoolClip(index) {
|
||||
function rebuildSegment(lo, enteredFrom) {
|
||||
const n = ring.scales.length;
|
||||
const atLo = HEFScrub.wrapIndex(lo, n), atHi = HEFScrub.wrapIndex(lo + 1, n);
|
||||
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo);
|
||||
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1);
|
||||
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo, activeClipId);
|
||||
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1, activeClipId);
|
||||
const file = morphByPair[`${loId}→${hiId}`] || null;
|
||||
activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
|
||||
if (file) {
|
||||
@@ -1515,12 +1549,16 @@ async function main() {
|
||||
dial.addEventListener("keydown", onDialKey); // arrows/Home/End + Enter/Space on labels
|
||||
$("stage").addEventListener("wheel", onWheel, { passive: false });
|
||||
update(); // render the initial state (both toggles off → black, silent)
|
||||
// Preload BEFORE interaction: a base for every altitude + all related morphs, so
|
||||
// turning the knob is always smooth (no on-demand stall, no blank next scene). The
|
||||
// "Loading Universe…" bar tracks this; the universe stays gated until it's ready.
|
||||
await preloadAllMedia();
|
||||
// Phase 1 — gate the universe on a minimal, fully-loaded set: one clip per altitude
|
||||
// + the morphs connecting them (~126 MB). Fast to start; the knob is smooth from the
|
||||
// first turn because the random pick only ever lands on loaded clips (eligibility).
|
||||
await cacheMany(HEFPreload.phase1Files(ring.scales, _preloadDeps()));
|
||||
hideLoading(); // experience is ready — boots SILENT (video off, audio 0)
|
||||
$("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage
|
||||
// Phase 2 — grow the pool in the background: more clips per altitude + their morphs.
|
||||
// Each becomes eligible for the random pick only once fully loaded, so navigation
|
||||
// keeps using the already-loaded clips until the new ones are ready (no stall).
|
||||
preloadAllMedia(); // NO await — streams the rest behind the unlocked universe
|
||||
maybeShowMotionWarning(); // first-visit photosensitivity notice, above the button
|
||||
// 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
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
<script src="config.js"></script>
|
||||
<script src="scrub.js"></script>
|
||||
<script src="alteration.js"></script>
|
||||
<script src="preload.js"></script>
|
||||
<script src="flash.js"></script>
|
||||
<script src="i18n.js"></script>
|
||||
<script src="app.js"></script>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Pure preload planning — no DOM. The boot caches phase1Files() before unlocking the
|
||||
// universe; the random clip pick is then restricted to eligible clips (fully loaded
|
||||
// base + connecting morphs), so a transition never stalls on un-downloaded media and
|
||||
// the pool grows safely as the background preload finishes. UMD so the browser gets
|
||||
// `HEFPreload` and `node --test` can require() it.
|
||||
//
|
||||
// deps shape: { baseOf(clipId)->file, morphFile(fromId,toId)->file|null, isCached(file)->bool }
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFPreload = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
|
||||
// The minimal set to load before the simulator can run: the FIRST pool member of
|
||||
// every altitude + the morphs connecting adjacent first-members (both directions,
|
||||
// cyclic ring). One clip per altitude + corresponding morphs.
|
||||
function phase1Files(scales, deps) {
|
||||
const n = scales.length;
|
||||
const chosen = scales.map((s) => (s.pool && s.pool.length ? s.pool[0].clip_id : s.clip_id));
|
||||
const files = new Set();
|
||||
for (const c of chosen) {
|
||||
const bf = deps.baseOf(c);
|
||||
if (bf) files.add(bf);
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = chosen[i], b = chosen[(i + 1) % n];
|
||||
for (const f of [deps.morphFile(a, b), deps.morphFile(b, a)]) if (f) files.add(f);
|
||||
}
|
||||
return [...files];
|
||||
}
|
||||
|
||||
// Pool members eligible as a DESTINATION from `fromId`: base cached AND the morph
|
||||
// cached BOTH ways (so the forward transition and a turn-back are ready).
|
||||
function eligibleDestinations(poolIds, fromId, deps) {
|
||||
return poolIds.filter((c) =>
|
||||
deps.isCached(deps.baseOf(c)) &&
|
||||
deps.isCached(deps.morphFile(fromId, c)) &&
|
||||
deps.isCached(deps.morphFile(c, fromId)));
|
||||
}
|
||||
|
||||
// Pool members eligible to SHOW at their own altitude (no transition): base cached.
|
||||
function eligibleMembers(poolIds, deps) {
|
||||
return poolIds.filter((c) => deps.isCached(deps.baseOf(c)));
|
||||
}
|
||||
|
||||
function pick(list, rnd) {
|
||||
return list.length ? list[Math.floor(rnd() * list.length)] : null;
|
||||
}
|
||||
|
||||
return { phase1Files, eligibleDestinations, eligibleMembers, pick };
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const P = require("../static/preload.js");
|
||||
|
||||
// Tiny fixture: 3-altitude cyclic ring, 2 pool members each. base_file = "<id>.b";
|
||||
// morph file = "<from>>-<to>" when it exists.
|
||||
const scales = [
|
||||
{ id: "a", pool: [{ clip_id: "a1" }, { clip_id: "a2" }] },
|
||||
{ id: "b", pool: [{ clip_id: "b1" }, { clip_id: "b2" }] },
|
||||
{ id: "c", pool: [{ clip_id: "c1" }, { clip_id: "c2" }] },
|
||||
];
|
||||
const baseOf = (id) => id + ".b";
|
||||
const morphFile = (a, b) => `${a}>-${b}`; // pretend every pair has a morph
|
||||
const cached = new Set();
|
||||
const deps = () => ({ baseOf, morphFile, isCached: (f) => cached.has(f) });
|
||||
|
||||
test("phase1Files = first member of each altitude + connecting morphs (both dirs, cyclic)", () => {
|
||||
const files = P.phase1Files(scales, deps());
|
||||
// bases: a1.b b1.b c1.b
|
||||
for (const b of ["a1.b", "b1.b", "c1.b"]) assert.ok(files.includes(b), `missing base ${b}`);
|
||||
assert.ok(!files.includes("a2.b"), "second members must NOT be in phase 1");
|
||||
// connecting morphs between adjacent first-members, both directions, cyclic (a-b, b-c, c-a)
|
||||
for (const m of ["a1>-b1", "b1>-a1", "b1>-c1", "c1>-b1", "c1>-a1", "a1>-c1"])
|
||||
assert.ok(files.includes(m), `missing morph ${m}`);
|
||||
assert.equal(files.length, 3 + 6);
|
||||
});
|
||||
|
||||
test("eligibleDestinations requires base + BOTH morphs cached", () => {
|
||||
cached.clear();
|
||||
// from a1, candidate b1: nothing cached → not eligible
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), []);
|
||||
cached.add("b1.b"); cached.add("a1>-b1"); // base + forward only
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), [], "reverse morph still missing");
|
||||
cached.add("b1>-a1"); // now reverse too
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), ["b1"]);
|
||||
// b2 fully cached too → both eligible
|
||||
cached.add("b2.b"); cached.add("a1>-b2"); cached.add("b2>-a1");
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()).sort(), ["b1", "b2"]);
|
||||
});
|
||||
|
||||
test("eligibleMembers requires only the base cached", () => {
|
||||
cached.clear();
|
||||
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), []);
|
||||
cached.add("a1.b");
|
||||
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), ["a1"]);
|
||||
});
|
||||
|
||||
test("pick returns null on empty, a member otherwise", () => {
|
||||
assert.equal(P.pick([], Math.random), null);
|
||||
assert.equal(P.pick(["x"], () => 0), "x");
|
||||
assert.equal(P.pick(["x", "y", "z"], () => 0.99), "z");
|
||||
});
|
||||
@@ -36,8 +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",
|
||||
"credits.html", "credits.js", "about.html", "flash.js"]
|
||||
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js",
|
||||
"style.css", "credits.html", "credits.js", "about.html", "flash.js"]
|
||||
|
||||
|
||||
def _bake_api(app_dir: Path) -> dict:
|
||||
@@ -84,7 +84,8 @@ def _version_assets(app_dir: Path) -> None:
|
||||
# = 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"]
|
||||
assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js", "flash.js",
|
||||
"style.css", "credits.js", "config.js"]
|
||||
tok = {}
|
||||
for a in assets:
|
||||
p = app_dir / a
|
||||
|
||||
Reference in New Issue
Block a user