Compare commits

..

9 Commits

Author SHA1 Message Date
BenStullsBets 0928988abc fix(sim): collapse Dev Mode panel when disabled
The generic .hidden { display:none } is defined earlier in the stylesheet than
.dev-panel { display:flex }; equal specificity meant source order won and the
panel stayed visible with the toggle off. A two-class .dev-panel.hidden rule
wins, so all dev controls/data truly hide when Dev Mode is disabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:26:10 -07:00
BenStullsBets a1c9c5c760 feat(sim): Dev Mode — pool clip picker + live analysis under one toggle
Adds a single "Dev Mode" switch at the bottom of the control panel (off by
default, persisted in localStorage). When on, a panel appears below it with all
dev controls and data; when off the default view is just the experience + the
three control fieldsets.

Dev panel:
- Pool picker: a <select> of the current altitude's pool members — choose any
  clip to override the random landing pick; a "Re-roll random" button re-runs
  the server-canonical pick. Rebuilds on each landing.
- Active clip metadata (id, title, source, license, base_file).
- Annotations & affect: keys with salience/window/tier counts (read-only;
  editing stays in /author.html).
- RenderPlan JSON — relocated here from the always-visible panel.
- Cache & playback: preload status (N/total), memory-vs-network for the active
  clip, live resolution/duration/loop position.

Frontend only — no API changes; everything is read from data the renderer
already has. Factored pickRandomMember() out of landScale() so the initial
landing, ring advances, and the re-roll button share one pick.

Design: docs/superpowers/specs/2026-06-25-simulator-dev-mode-design.md
269 passed, 2 skipped; node --check + server smoke clean. Frontend behavior
to be verified by-eye by the operator (no headless browser in repo yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:19:39 -07:00
benstull ab2edf77bb Merge pull request 'perf(sim): preload all clips into memory for instant altitude swaps' (#19) from feature/preload-media-cache into main 2026-06-25 14:48:44 +00:00
BenStullsBets d19fe4e8fa perf(sim): preload all clips into memory for instant altitude swaps
Each altitude change reset the single <video> element's src and re-fetched
the scale's footage over the network, compounded by the dev no-store policy
applying to /media too — so every scale change paid a full fetch+decode.

- simulator/app.py: split the cache middleware — app shell/JS/CSS stay
  no-store (by-eye dev iteration), but /media is now cacheable
  (public, max-age, immutable) since the clips are static.
- simulator/static/app.js: preload every ring clip (19 base + 5 transition,
  ~350 MB) into in-memory blob object URLs at startup — non-blocking, the
  first scale plays immediately while the rest stream in, ordered current
  scale outward. mediaUrl() serves the cached blob when ready, else falls
  back to the network. A small progress chip self-removes when done.
- tests: assert app assets stay no-store and /media is cacheable.

269 passed, 2 skipped. Frontend verified by-eye by the operator (no headless
browser in repo yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:47:32 -07:00
BenStullsBets 9a83c0132d claim human-experience-filter-art session 0017 (placeholder) + sessions.json entry 2026-06-25 07:41:36 -07:00
BenStullsBets 7fd9aabd0e add sessions/0016/SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--2026-06-24T21-46.md + replace placeholder/variant SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--2026-06-24T18-18.md 2026-06-24 21:46:35 -07:00
benstull 7a32ff1acc Merge pull request 'feat(sim): turnable Altitude knob (replaces ring buttons)' (#18) from feature/altitude-knob into main 2026-06-25 04:45:09 +00:00
BenStullsBets 7628a5321f add sessions/0016/SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--2026-06-24T18-18.md + replace placeholder/variant SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--INPROGRESS.md 2026-06-24 18:18:13 -07:00
BenStullsBets bcd4d32b19 update sessions/0016/SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--INPROGRESS.md 2026-06-24 18:17:37 -07:00
9 changed files with 512 additions and 22 deletions
@@ -0,0 +1,64 @@
# Simulator Dev Mode — design
> 2026-06-25 · session 0017 · planning-and-executing
> Anchor: leaf task (simulator dev-tooling — R2b, no upstream design needed)
## Problem
While previewing the experience, the operator can't choose *which* clip in an
altitude's rotating pool is shown — the server picks a random pool member on each
landing (content-pipeline §11.1). For by-eye review they want to step through every
clip in a pool on demand, and to see the engine/clip data behind what's on screen.
The RenderPlan JSON readout is also always visible, cluttering what should read as
an audience-facing preview.
## Goal
A single **Dev Mode** toggle at the bottom of the control panel. Off by default
(persisted in `localStorage`). When on, a panel appears directly below it with all
dev controls and data; when off, the normal view is just the experience + the three
control fieldsets.
## Scope
Frontend only — `simulator/static/{index.html,app.js,style.css}`. No API changes:
every datum Dev Mode shows is already available client-side (`/api/clips`,
`/api/ring`, the `/api/alteration` response, and the in-memory preload cache). The
separate `/author.html` remains the place to *edit* labels; Dev Mode is
inspect-and-select only.
## Dev panel contents (top → bottom)
1. **Pool picker** — a `<select>` of the current altitude's pool members
(`clip_id · title`), its value reflecting the clip currently playing. Choosing a
member sets `activeClipId` to it and reloads the base media, overriding the random
landing pick. A `🎲 Re-roll random` button re-runs the server-canonical random
pick for the current scale. The dropdown is rebuilt on every scale landing, so it
always lists the current pool; single-clip scales (cosmos/orbit) show one entry.
2. **Active clip**`id`, `title`, `source`, `license`, `base_file`.
3. **Annotations & affect** — each annotation key with salience (or legacy
`min_level`), time window (`appear``disappear`), and tier count; each affect key
with `min_level` and tier count. Read-only.
4. **RenderPlan** — the relocated `#readout` (the `/api/alteration` JSON).
5. **Cache & playback** — preload status (`N/total cached`), whether the active
clip is served **from memory** (a preloaded blob) vs the network, and live video
resolution / duration / loop position.
## Behavior
- **Pool override is per-landing.** Changing altitude re-rolls randomly as today;
the operator then picks again from the new scale's pool. The pick is not persisted
across landings (YAGNI — "select any video while on an altitude").
- **Refresh points.** Pool picker + clip metadata + annotations rebuild whenever the
scale lands (hooked into `renderScaleReadout()`) and after a manual pick/re-roll.
The RenderPlan readout updates on each `update()` (knob change) as today. The
cache/playback stats refresh on a light interval (~500 ms) while Dev Mode is on,
and the cache count also ticks up as the preloader fills.
- **`pickRandomMember()`** is factored out of `landScale()` so the initial landing,
ring advances, and the Re-roll button all share the one server-canonical pick.
## Testing
No JS unit harness in the repo and no API change, so verification is: `node --check`
on `app.js`, the Python suite stays green (unaffected), and by-eye review by the
operator (no headless browser yet — §9 E2E policy noted, machinery pending).
@@ -0,0 +1,117 @@
# Session 0016.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-24T17-48 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 0fde1d36-7c64-414f-8f49-8669b5dd2263
> Status: **FINALIZED**
## Launch prompt
`/goal` Content pipeline Increment 2 (expanded) — rotating clip pools + time-windowed
TRACKED labels + progressive left-brain detail + progressive right-brain emotions.
Short design pass first (schema changes), then build.
> Anchor: design `docs/superpowers/specs/2026-06-24-content-pipeline-design.md`
> (graduated, R2a-eligible). Repo is roadmap-anchored, no Gitea issue tracker (R1
> deviation, as all prior sessions). Increment 2 of that design.
## Pre-state
`main` at `b28f930` (PR #15 — operator-curated rotating-pool doc merged). All 19
vetted pool clips on disk (gitignored) plus the leftovers to drop. The ring was the
Increment-1 one-base-per-scale model (cosmos/orbit/forest/reef/abyss), flat
`min_level` label gating, static or simple-track boxes, scene-level affect gated by
`min(left,right)`. `tools/pipeline/` had stages 14 + 6 (no `track.py`). 246 tests.
## Arc
1. **Session gate + init.** Classified the `/goal` as planning-and-executing,
claimed session **0016** (peeked an ENDED-UNFINALIZED 0015 — its PR #15 work was
already merged, not live — and proceeded; no worktree needed, solo session).
Verified clean `main`, read the pool doc + spec + sub-project-3 memory + the live
code (ring, clips, app, app.js, alteration, pipeline).
2. **Design pass (operator asked for it — schema changes).** Appended **§11** to the
content-pipeline spec pinning the concrete shapes: rotating pool, time-windowed
tracks, progressive Left detail tiers (salience + tiered strings), progressive
Right emotion tiers, and the `track.py` + author-mode tooling. Key call: tiers
need **no engine change** — the client reads `overlay.level` (Left) and
`dream.strength` (Right) already in the `RenderPlan`.
3. **PR #16 — the experience.** `Scale.pool` + pure `pick_clip_id` (injected
randomness); server-side random pick at the API boundary (delta=0 = initial pick).
`forest``coast` rename + ring reorder + orphan-media cleanup. Time-windowed
tracked labels (`appear`/`disappear`, seam-wrapping). Salience-gated +
tier-escalating Left labels; tier-escalating Right emotions; tiered-or-static
strings (back-compat). New `build_pool_manifest.py` emits the 19-clip pool
manifest (abyss+reef authored as tracked/windowed showcase). Merged via Gitea API.
4. **PR #17 — the tooling.** `tools/pipeline/track.py`: classical LK optical-flow box
tracker (this opencv build has no CSRT) → sparse keyframed track + window; optional
lazy ML path. Simulator AUTHOR MODE (`/author.html` + `/api/author/{track,clip}`):
scrub, drag a seed box, run tracker, author tiers/affect, save to manifest
(idempotent upsert, keeps provenance). Repointed the pipeline integration test off
the retired forest base; USER_GUIDE sim section brought current. Merged.
## Cut state
`main` at `c445239` (PR #17 merge). **267 passed / 2 skipped.** Clean tree, fully
pushed, no open PRs. Both feature branches deleted. All 5 goal items delivered.
Verification was API/logic-level + JS syntax + live-server probes (5 scales with
pools, rotating picks, new edges, 19 clips served, tracker returns keyframes on real
abyss_wow footage, committed manifest untouched by the read-only probe). **The
VISUAL look was not screenshot-verified — no Chrome on this box** — so operator
by-eye review is the next step (matches the established deferred-review pattern).
## Follow-up (post-finalize, same conversation) — PR #18
Operator asked to replace the scale-ring `⊖ out / in ⊕` buttons with a **circular
"Altitude" knob** that turns all the way around (including abyss→cosmos). Built a
turnable SVG dial in `simulator/static/app.js` (`buildDial` / `renderDial` / drag +
wheel handlers) + `style.css`: cosmos (highest) at top, clockwise descends
cosmos→orbit→coast→reef→abyss and **wraps** back up. Dragging accumulates rotation
and commits whole detents on release (a big spin → one fast blended pass, reusing
the proven wheel/detent path); scroll the knob to step; tap a label to jump the
shortest way around. **Pure frontend** — the endless ring + wrap already existed
server-side, so no engine/API change. JS syntax-checked; 23 sim-API tests green;
live probe confirmed the dial replaced the buttons and the abyss→cosmos wrap is
intact. Merged as **PR #18** (`main` at `7a32ff1`). Visual look still operator-by-eye
(no Chrome on this box).
## Deployment pipeline (§9)
No stage run: the HEF simulator is a **local dev tool** (simulator-first directive),
not a deployed app — it has no PPE/prod infra. Nothing shippable through the §9
pipeline this session; the session output is the merged code on `main`.
## Plan archival
No separate `superpowers:writing-plans` artifact was produced — the design pass was a
spec §11 edit (merged in PR #16) and execution was direct TDD tracked by the
transcript `## Plan` block + the task list. Nothing to `submit-plan.sh`; this app has
no content repo anyway (roadmap-anchored).
## Deferred decisions
- **Adopting/finalizing the ENDED-UNFINALIZED session 0015 placeholder** was skipped
— its PR #15 work is already on `main` and its outcomes are fully recorded in
memory; finalizing a prior dead session was judged an unnecessary detour. Low
confidence; flagging for the operator.
- **By-eye visual review deferred** to the operator (no Chrome here) — consistent with
the prior-session pattern, but the look of the tiers/windows/pool rotation AND the
new Altitude knob is unconfirmed visually.
- **Light-vs-rich label authoring:** only abyss + reef + the scale primaries are
richly authored; the other pool members carry lighter labels. Finishing them is the
author-mode follow-up (next goal).
## Next-session prompt
`/goal` Operator by-eye review of Increment 2 + the Altitude knob in the sim
(`make sim-local`, :8000): turn the Altitude knob (drag/scroll, wraps abyss→cosmos;
pools rotate per landing), sweep Left (progressive label tiers
general→scientific+fact), sweep Right with Left up (progressive emotions
basic→compound), watch abyss/reef time-windowed tracked labels enter/leave + track,
and exercise `/author.html`. Then author real labels for the remaining pool members
via author mode. After the content is liked, the next build frontier is the deferred
i2v ring transitions (generative-video model + adjacent real bases) and/or the Pi
renderer + serial/firmware (per [[simulator-first-before-hardware]]), per ROADMAP.md.
@@ -1,15 +1,15 @@
# Session 0016.0 — Transcript
# Session 0017.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-24T17-48 (PST)
> Start: 2026-06-25T07-41 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 0fde1d36-7c64-414f-8f49-8669b5dd2263
> Claude-Session: bcb069f2-0fba-4a66-ae8e-f40b7917a48c
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0016 for human-experience-filter-art. The driver replaces this
> This file reserves session ID 0017 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0016.0-TRANSCRIPT-2026-06-24T17-48--<end>.md form at session end.
> SESSION-0017.0-TRANSCRIPT-2026-06-25T07-41--<end>.md form at session end.
## Launch prompt
+3
View File
@@ -46,5 +46,8 @@
},
"0016": {
"title": ""
},
"0017": {
"title": ""
}
}
+11 -5
View File
@@ -234,12 +234,18 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
return {"ok": True, "clip": merged}
@app.middleware("http")
async def _no_cache(request, call_next):
# Dev preview server: never let a browser serve a stale app.js/style.css.
# The whole point is by-eye iteration, so a plain refresh must always get
# the latest assets (the heuristic cache otherwise hides JS/CSS edits).
async def _cache_policy(request, call_next):
# Dev preview server: never let a browser serve a stale app.js/style.css
# by-eye iteration needs a plain refresh to always get the latest assets.
# Media is the exception: the clips are static and large, so they must be
# browser-cacheable. The renderer also preloads them into in-memory blob
# URLs (static/app.js), but a cacheable header keeps the preload fetch and
# any fallback off the network on repeat loads — instant altitude swaps.
response = await call_next(request)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
if request.url.path.startswith("/media/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
if MEDIA_DIR.exists():
+219 -8
View File
@@ -52,22 +52,28 @@ async function loadData() {
ringIndex = 0;
}
// Land on the current scale: pick a random pool member (content-pipeline §11.1).
// A real server ring picks via a delta=0 advance (Python-canonical pick); the
// synthesized fallback ring has a pool of one, so it just takes that member.
async function landScale() {
// Server-canonical random pick of a pool member for the current scale: a delta=0
// 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.
async function pickRandomMember() {
const scale = ring && ring.scales[ringIndex];
if (!scale) { activeClipId = null; return; }
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) { activeClipId = (await resp.json()).target_clip_id; currentClipId = null; return; }
if (resp.ok) return (await resp.json()).target_clip_id;
} catch (_) { /* fall through to the primary member */ }
}
activeClipId = scale.clip_id;
return scale.clip_id;
}
// Land on the current scale: pick a random pool member and force its media to load.
async function landScale() {
activeClipId = await pickRandomMember();
currentClipId = null;
}
@@ -75,7 +81,79 @@ function activeClip() {
return clipsById[activeClipId] || null;
}
function mediaUrl(file) { return "/media/" + file; }
// In-memory preload cache: media file path -> blob object URL. Once a clip's bytes
// are cached, mediaUrl() hands the <video> an in-memory blob instead of a network
// path, so swapping the altitude scale loads near-instantly (no fetch round-trip).
// Falls back to the network path for anything not yet (or never) cached.
const mediaBlobs = {};
function mediaUrl(file) { return mediaBlobs[file] || ("/media/" + file); }
// Every media file the ring can show: each scale-pool clip's base footage plus the
// per-edge transition clips. This is the full preload set (~two dozen files).
function preloadList() {
const files = new Set();
for (const c of Object.values(clipsById)) if (c && c.base_file) files.add(c.base_file);
if (ring && ring.transitions) for (const t of ring.transitions) if (t && t.file) files.add(t.file);
return [...files];
}
// Order the preload so the clips most likely to be shown next come first: the
// current scale's pool, then outward to neighboring scales, then transitions.
function preloadOrder() {
if (!ring || !ring.scales) return preloadList();
const n = ring.scales.length, seen = new Set(), ordered = [];
const add = (f) => { if (f && !seen.has(f)) { seen.add(f); ordered.push(f); } };
for (let d = 0; d < n; d++) {
for (const sign of d === 0 ? [0] : [1, -1]) {
const s = ring.scales[((ringIndex + sign * d) % n + n) % n];
if (s && s.pool) for (const m of s.pool) add((clipsById[m.clip_id] || {}).base_file);
else if (s) add((clipsById[s.clip_id] || {}).base_file);
}
}
if (ring.transitions) for (const t of ring.transitions) add(t.file);
for (const f of preloadList()) add(f); // sweep up anything not on the ring
return ordered;
}
// Fetch every clip into the blob cache in the background — non-blocking, so the
// first scale plays immediately while the rest stream in. Bounded concurrency keeps
// the ~350 MB fetch from stampeding. A tiny progress chip self-removes when done.
async function preloadAllMedia(concurrency = 4) {
const files = preloadOrder();
if (!files.length) return;
let done = 0;
const total = files.length;
const chip = preloadChip();
const tick = () => { if (chip) chip.textContent = `⬇ caching clips ${done}/${total}`; };
tick();
let i = 0;
async function worker() {
while (i < files.length) {
const file = files[i++];
if (!mediaBlobs[file]) {
try {
const blob = await (await fetch(mediaUrl(file))).blob();
mediaBlobs[file] = URL.createObjectURL(blob);
} catch (_) { /* leave it to the network path on demand */ }
}
done++; tick();
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
if (chip) chip.remove();
}
function preloadChip() {
let c = document.getElementById("preload-chip");
if (!c) {
c = document.createElement("div");
c.id = "preload-chip";
c.style.cssText = "position:fixed;bottom:8px;right:10px;z-index:9998;background:rgba(0,0,0,.55);" +
"color:#9fe;font:11px/1.4 monospace;padding:4px 8px;border-radius:4px;pointer-events:none;";
document.body.appendChild(c);
}
return c;
}
// Load the active scale's BASE footage into the video (the painterly canvas reads
// its frames live). The Right knob no longer swaps the source — it only drives the
@@ -390,6 +468,7 @@ function renderScaleReadout() {
const poolTag = poolN > 1 ? ` · pool ${poolN}` : "";
$("scale-name").textContent = `${s.id} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
renderDial();
refreshDevClip(); // keep the Dev Mode pool picker + clip data in sync with the landing
}
function controls() {
@@ -613,6 +692,136 @@ function jumpToScale(idx) {
if (d) advance(d);
}
// --- Dev Mode: pool picker + live analysis, all under one toggle ---
// Off by default, state persisted in localStorage. Everything here is read from
// data the renderer already has (clips, ring, the alteration response, the preload
// cache) — no server endpoints. Editing labels stays in /author.html.
const DEV_KEY = "hef.devMode";
let devMode = false;
let devStatsTimer = null;
const escapeHtml = (s) => String(s).replace(/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
function applyDevVisibility() {
const panel = $("dev-panel");
if (panel) panel.classList.toggle("hidden", !devMode);
if (devMode) { refreshDevClip(); renderDevStats(); }
}
function initDev() {
const toggle = $("dev-mode");
if (!toggle) return;
try { devMode = localStorage.getItem(DEV_KEY) === "1"; } catch (_) { devMode = false; }
toggle.checked = devMode;
toggle.addEventListener("change", () => {
devMode = toggle.checked;
try { localStorage.setItem(DEV_KEY, devMode ? "1" : "0"); } catch (_) {}
applyDevVisibility();
});
$("pool-select").addEventListener("change", onPoolSelect);
$("pool-reroll").addEventListener("click", reroll);
// Cache + playback stats are live-ish; cheap to repaint twice a second when shown.
devStatsTimer = setInterval(() => { if (devMode) renderDevStats(); }, 500);
applyDevVisibility();
}
// Rebuild the pool picker for the current scale and repaint the clip data. Cheap
// (pools are <= a handful), so just rebuild on every landing/pick.
function refreshDevClip() {
if (!devMode) return;
const sel = $("pool-select");
const scale = ring && ring.scales[ringIndex];
if (sel && scale) {
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id, title: scale.title }];
sel.innerHTML = "";
for (const m of pool) {
const o = document.createElement("option");
o.value = m.clip_id;
o.textContent = `${m.clip_id} · ${m.title || (clipsById[m.clip_id] || {}).title || ""}`;
sel.appendChild(o);
}
if (activeClipId) sel.value = activeClipId;
sel.disabled = pool.length < 2;
}
renderDevMeta();
renderDevAnno();
}
function renderDevMeta() {
const dl = $("clip-meta");
const c = activeClip();
if (!dl) return;
if (!c) { dl.innerHTML = "<dt>—</dt><dd>no clip</dd>"; return; }
const rows = [["id", c.id], ["title", c.title], ["source", c.source],
["license", c.license], ["base_file", c.base_file]];
dl.innerHTML = rows.map(([k, v]) =>
`<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v != null ? v : "—")}</dd>`).join("");
}
function renderDevAnno() {
const box = $("clip-anno");
const c = activeClip();
if (!box) return;
const strings = (c && c.strings && c.strings.en) || {};
const win = (a) => (typeof a.appear === "number" || typeof a.disappear === "number")
? ` win ${(a.appear ?? 0).toFixed(2)}${(a.disappear ?? 1).toFixed(2)}` : "";
const rows = [];
for (const a of (c && c.annotations) || []) {
const measure = a.key.startsWith("measure.");
const sal = typeof a.salience === "number" ? `sal ${a.salience}`
: typeof a.min_level === "number" ? `min L${a.min_level}` : "sal 1";
const tiers = tierCount(strings[a.key]);
rows.push(`<div class="anno-row"><span class="anno-key ${measure ? "measure" : ""}">${escapeHtml(a.key)}</span>` +
`<span class="anno-meta"> · ${sal} · ${tiers} tier${tiers > 1 ? "s" : ""}${win(a)}</span></div>`);
}
for (const f of (c && c.affect) || []) {
const tiers = tierCount(strings[f.key]);
rows.push(`<div class="anno-row"><span class="anno-key affect">${escapeHtml(f.key)}</span>` +
`<span class="anno-meta"> · min L${f.min_level ?? 1} · ${tiers} tier${tiers > 1 ? "s" : ""}</span></div>`);
}
box.innerHTML = rows.length ? rows.join("") : `<div class="anno-empty">no annotations or affect</div>`;
}
function renderDevStats() {
const dl = $("dev-stats");
if (!dl) return;
const c = activeClip();
const total = preloadList().length;
const cached = Object.keys(mediaBlobs).length;
const fromMem = !!(c && mediaBlobs[c.base_file]);
const res = vid.videoWidth ? `${vid.videoWidth}×${vid.videoHeight}` : "—";
const dur = vid.duration ? `${vid.duration.toFixed(1)}s` : "—";
const loop = vid.duration ? `${Math.round(loopT() * 100)}%` : "—";
dl.innerHTML =
`<dt>cache</dt><dd>${cached}/${total} cached</dd>` +
`<dt>active source</dt><dd class="${fromMem ? "mem" : "net"}">${fromMem ? "memory (blob)" : "network"}</dd>` +
`<dt>resolution</dt><dd>${res}</dd>` +
`<dt>duration</dt><dd>${dur}</dd>` +
`<dt>loop pos</dt><dd>${loop}</dd>`;
}
// Pick a specific pool member (Dev Mode override of the random landing pick).
function onPoolSelect() {
const id = $("pool-select").value;
if (!id || id === activeClipId) return;
activeClipId = id;
currentClipId = null; // force the base media to reload
renderScaleReadout(); // refresh scale name + dev clip data
update(); // reload media + plan
}
// Re-roll: a fresh server-canonical random pick for the current scale.
async function reroll() {
if (busy) return;
const id = await pickRandomMember();
if (!id) return;
activeClipId = id;
currentClipId = null;
renderScaleReadout();
update();
}
// Dev live-reload: poll the asset version and reload when it changes, so an open
// tab never keeps running a stale renderer while we iterate (the readout updates
// from the live API and masks it otherwise). Reloads on my edits AND on a server
@@ -633,7 +842,9 @@ async function main() {
try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); }
await loadData();
await landScale(); // pick the initial scale's pool member before first render
preloadAllMedia(); // background: cache every clip in memory for instant altitude swaps
buildDial(); // draw the altitude knob from the ring's scales
initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state)
renderScaleReadout();
for (const id of ["content", "left", "right", "mood"]) {
$(id).addEventListener("input", debounced);
+33 -4
View File
@@ -50,11 +50,40 @@
<label>Mood — dark ◀ 0 ▶ light <input type="range" id="mood" min="-4" max="4" value="0" /></label>
</fieldset>
<!-- Dev Mode: a single toggle; everything dev (pool picker + analysis) lives below it. -->
<label class="dev-switch" for="dev-mode">
<input type="checkbox" id="dev-mode" />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label">Dev Mode</span>
</label>
<fieldset>
<legend>RenderPlan readout</legend>
<pre id="readout"></pre>
</fieldset>
<div id="dev-panel" class="dev-panel hidden">
<fieldset>
<legend>Pool — pick a clip</legend>
<select id="pool-select" aria-label="Choose a clip from this altitude's pool"></select>
<button type="button" id="pool-reroll" class="dev-btn">🎲 Re-roll random</button>
</fieldset>
<fieldset>
<legend>Active clip</legend>
<dl id="clip-meta" class="dev-dl"></dl>
</fieldset>
<fieldset>
<legend>Annotations &amp; affect</legend>
<div id="clip-anno" class="dev-anno"></div>
</fieldset>
<fieldset>
<legend>RenderPlan</legend>
<pre id="readout"></pre>
</fieldset>
<fieldset>
<legend>Cache &amp; playback</legend>
<dl id="dev-stats" class="dev-dl"></dl>
</fieldset>
</div>
</section>
</main>
<script src="/app.js"></script>
+34
View File
@@ -66,3 +66,37 @@ input[type=range], select { width: 100%; }
.dial-hub { fill: #2c3c5c; stroke: #9cf; stroke-width: 0.6; }
.scale-name { display: block; text-align: center; font-size: 12px; color: #cde; }
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #789; }
/* --- Dev Mode --- a single switch; all dev controls/data live in #dev-panel below it. */
.dev-switch { display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
padding: 0.5rem 0.2rem 0.2rem; border-top: 1px solid #222; user-select: none; }
.dev-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.dev-switch-track { position: relative; width: 34px; height: 18px; border-radius: 9px;
background: #2a2a2a; border: 1px solid #444; transition: background 0.15s; }
.dev-switch-thumb { position: absolute; top: 1px; left: 1px; width: 14px; height: 14px;
border-radius: 50%; background: #888; transition: transform 0.15s, background 0.15s; }
.dev-switch input:checked + .dev-switch-track { background: #1d3a2a; border-color: #2e6; }
.dev-switch input:checked + .dev-switch-track .dev-switch-thumb { transform: translateX(16px); background: #4e9; }
.dev-switch input:focus-visible + .dev-switch-track { outline: 2px solid #9af; outline-offset: 1px; }
.dev-switch-label { color: #9af; font-weight: 600; letter-spacing: 0.3px; }
.dev-panel { display: flex; flex-direction: column; gap: 0.8rem; }
/* Beat the generic `.hidden` (defined earlier, equal specificity): two classes
win, so the panel truly collapses when Dev Mode is off. */
.dev-panel.hidden { display: none; }
.dev-panel legend { color: #4e9; }
.dev-btn { margin-top: 0.5rem; width: 100%; padding: 0.4rem; cursor: pointer;
background: #182028; color: #cde; border: 1px solid #2c3c5c; border-radius: 4px; }
.dev-btn:hover { background: #20303f; }
.dev-dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.15rem 0.6rem; font-size: 12px; }
.dev-dl dt { color: #789; }
.dev-dl dd { margin: 0; color: #dde; word-break: break-word; }
.dev-dl dd.mem { color: #4e9; }
.dev-dl dd.net { color: #fc6; }
.dev-anno { font-size: 11px; max-height: 220px; overflow: auto;
font-family: ui-monospace, monospace; }
.dev-anno .anno-row { padding: 0.2rem 0; border-bottom: 1px solid #1d1d1d; }
.dev-anno .anno-key { color: #aee6ff; }
.dev-anno .anno-key.affect { color: #e3cfff; }
.dev-anno .anno-key.measure { color: #ffd79a; }
.dev-anno .anno-meta { color: #678; }
.dev-anno .anno-empty { color: #567; font-style: italic; }
+26
View File
@@ -91,6 +91,32 @@ def test_index_is_served():
assert "Alteration" in resp.text
def test_app_assets_are_not_cached(client):
# Dev iteration relies on the browser never serving stale renderer code.
resp = client.get("/")
assert "no-store" in resp.headers.get("cache-control", "")
def test_media_is_cacheable(tmp_path, monkeypatch):
# Media is static: it must be browser-cacheable so the preloader's fetch (and
# any fallback) doesn't re-hit the network on every altitude change.
import simulator.app as appmod
media = tmp_path / "media"
(media / "cosmos").mkdir(parents=True)
(media / "cosmos" / "base.mp4").write_bytes(b"\x00\x00\x00\x18ftyp")
monkeypatch.setattr(appmod, "MEDIA_DIR", media)
manifest = tmp_path / "manifest.json"
manifest.write_text(json.dumps({"clips": []}))
client = TestClient(create_app(manifest_path=manifest))
resp = client.get("/media/cosmos/base.mp4")
assert resp.status_code == 200
cc = resp.headers.get("cache-control", "")
assert "no-store" not in cc
assert "max-age" in cc
@pytest.fixture
def ring_manifest_path(tmp_path):
p = tmp_path / "manifest.json"