Venues: persistent room, motivated camera, ride; drop Pylon Grid

- Venue as one continuous place: persistent per-song layer, interpolated
  venueState, story jumps (one re-hang max + recap) with camera cuts
- Room camera shot plan (venueShots.js): per-section size/target, monotonic
  look sweep, traverse glide body, room-side alternation
- New Ride venue: generated vehicle + actor passengers, radio RTT screen,
  chase/stationary modes, ride archetype across generator/camera/staging
- Flora cost trim (5x(12+6+6) -> 4x(8+4+4) SDF evals, 204ms -> 140ms worst)
- Delete Pylon Grid shader (2.5s/frame SwiftShader); drop its metadata row
- tools/venue-check.js headless gate harness + venue-gallery explorer
This commit is contained in:
Dejvino
2026-09-17 19:35:26 +02:00
parent b1b6431eeb
commit 46fab512df
31 changed files with 3684 additions and 358 deletions
+373
View File
@@ -0,0 +1,373 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>flow-state · venue gallery</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; padding: 20px 24px 60px; background:#0b0d12; color:#d6dae3; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
h1 { font-size:15px; letter-spacing:.12em; text-transform:uppercase; color:#7d8698; margin:0 0 4px; }
a { color:#7dd3fc; }
.lede { color:#8b94a7; max-width:84ch; margin:0 0 14px; }
.bar { position:sticky; top:0; z-index:5; background:#0b0d12; padding:10px 0 12px; border-bottom:1px solid #1c2030; margin-bottom:16px; }
button, select { background:#171b24; color:#d6dae3; border:1px solid #2a3040; padding:5px 10px; font:inherit; border-radius:3px; cursor:pointer; }
button:hover:not(:disabled){ border-color:#4a5468; }
button:disabled{ opacity:.5; cursor:default; }
label.ctl { color:#6b7280; font-size:12px; display:inline-flex; gap:6px; align-items:center; }
#status { color:#8b94a7; margin-left:10px; }
#status.err { color:#f87171; }
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:10px; }
.card { background:#11141b; border:1px solid #1c2030; border-radius:4px; padding:8px; }
.card.error { border-color:#7f1d1d; }
.card h3 { margin:0 0 4px; font-size:12px; color:#e6eaf2; }
.meta { color:#6b7280; font-size:11px; }
.meta.err { color:#f87171; }
canvas { width:100%; display:block; background:#05070a; border-radius:2px; aspect-ratio:16/9; }
.hint { font-size:10px; color:#565f70; margin-top:4px; }
#controls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px; }
.progress { height:2px; background:#1a1e28; margin-top:8px; border-radius:2px; overflow:hidden; display:none; }
.progress i { display:block; height:100%; width:0; background:#7dd3fc; transition:width .2s; }
.building .progress{ display:block; }
</style>
</head>
<body>
<h1>flow-state · venue gallery</h1>
<p class="lede">
Venue × viz explorer. Venue is the track's place (higher-level than viz, picked first via census);
viz is the scene inside it. Grid is <strong>songs (rows) × seeds (cols)</strong> — each column is
a fresh seeded draw of the same song, so venue and viz vary by census across columns when left on
auto. <strong>Render re-renders the grid</strong>; narrow it with the dropdowns first.
A sad slow chamber of memories vs. a pumping stage field, same songs, side by side.
<a href="/debug.html">← all debug tools</a> · <a href="/?venue=1">open app with venue</a>
</p>
<div class="bar" id="bar">
<div id="controls">
<select id="sel-venue" title="pin one venue in every cell, or census"></select>
<select id="sel-viz" title="pin one viz as the subject in every cell, or census"></select>
<select id="sel-song" title="song filter — one song, or all"></select>
<label class="ctl">rows <select id="sel-rows" title="songs (rows)"></select></label>
<label class="ctl">cols <select id="sel-cols" title="seeds per song (columns)"></select></label>
<button id="btn-render">render</button>
<button id="btn-randomize">random viz</button>
<span id="status">starting…</span>
</div>
<div class="progress"><i></i></div>
</div>
<div id="out" class="grid"></div>
<script type="module">
import { Show } from '/src/Show.js';
import { SONGS, song } from '/src/audio/songbank.js';
import { Rng, hashString } from '/src/engine/rng.js';
import { generateLook } from '/src/look/LookGenerator.js';
import { sampleValues } from '/src/params/schema.js';
import { subjectIndexOf } from '/src/look/stack.js';
import { venues } from '/src/venues/registry.js';
import { scenes } from '/src/scenes/registry.js';
import { blit } from '/src/checks/blit.js';
const THUMB = { width: 320, height: 180 };
const DEFAULT_ROWS = 5;
const DEFAULT_COLS = 5;
const ROW_OPTIONS = [2, 3, 4, 5, 6, 8, 10];
const COL_OPTIONS = [1, 2, 3, 4, 5, 6, 8];
// Evenly spaced subset of the bank, same rule as songBank({count}) — a cheap
// run still spans slow/quiet through fast/dense instead of testing N drones.
function gridSongs(n) {
const count = Math.max(1, Math.min(n, SONGS.length));
if (count >= SONGS.length) return SONGS.map(s => s.name);
return Array.from(
{ length: count },
(_, i) => SONGS[Math.round(i * (SONGS.length - 1) / (count - 1))].name,
);
}
const bar = document.getElementById('bar');
const selVenue = document.getElementById('sel-venue');
const selViz = document.getElementById('sel-viz');
const selSong = document.getElementById('sel-song');
const selRows = document.getElementById('sel-rows');
const selCols = document.getElementById('sel-cols');
const btnRender = document.getElementById('btn-render');
const btnRandom = document.getElementById('btn-randomize');
const out = document.getElementById('out');
const status = document.getElementById('status');
const progress = document.querySelector('.progress i');
const bankCache = new Map(); // song name -> track (synthesised once, reused)
let building = false;
function setStatus(msg, isErr = false) {
status.textContent = msg;
status.classList.toggle('err', !!isErr);
}
function setBusy(b) {
building = b;
bar.classList.toggle('building', b);
btnRender.disabled = b;
btnRandom.disabled = b;
selVenue.disabled = b;
selViz.disabled = b;
selSong.disabled = b;
selRows.disabled = b;
selCols.disabled = b;
if (b) progress.style.width = '0%';
}
// Surface module-load and runtime failures in the page, not just the console:
// a blank grid with "ready" in the corner is the failure this file exists to
// prevent. Show construction (no WebGL2) throws before any card exists.
window.addEventListener('error', (e) => {
if (e && e.message && /WebGL2 unavailable/.test(e.message)) setStatus(e.message, true);
});
window.addEventListener('unhandledrejection', (e) => {
const r = e && e.reason;
const msg = r && r.message ? r.message : String(r);
if (/WebGL2 unavailable/.test(msg)) setStatus(msg, true);
});
function populate() {
selVenue.innerHTML = `<option value="">all venues (census)</option>` +
venues.map(v => `<option value="${v.name}">${v.name} (${v.archetype})</option>`).join('');
selViz.innerHTML = `<option value="">auto viz (census)</option>` +
scenes.filter(s => !s.venue).map(s => s.name).sort((a, b) => a.localeCompare(b))
.map(n => `<option value="${n}">${n}</option>`).join('');
selSong.innerHTML = `<option value="">all songs</option>` +
SONGS.map(s => `<option value="${s.name}">${s.name}</option>`).join('');
selRows.innerHTML = ROW_OPTIONS.map(n => `<option value="${n}">${n}</option>`).join('');
selCols.innerHTML = COL_OPTIONS.map(n => `<option value="${n}">${n}</option>`).join('');
selRows.value = String(DEFAULT_ROWS);
selCols.value = String(DEFAULT_COLS);
const q = new URLSearchParams(location.search);
const take = (el, v) => { if (v && [...el.options].some(o => o.value === v)) el.value = v; };
take(selVenue, q.get('venueSel'));
take(selViz, q.get('viz'));
take(selSong, q.get('song') ? q.get('song').split(',')[0] : null);
take(selRows, q.get('rows'));
take(selCols, q.get('cols'));
}
function pushQuery() {
const p = new URLSearchParams();
if (selVenue.value) p.set('venueSel', selVenue.value);
if (selViz.value) p.set('viz', selViz.value);
if (selSong.value) p.set('song', selSong.value);
p.set('rows', selRows.value);
p.set('cols', selCols.value);
history.replaceState(null, '', `?${p}`);
}
async function ensureSongs(names) {
const out = [];
for (let i = 0; i < names.length; i++) {
if (!bankCache.has(names[i])) {
setStatus(`synthesising song ${i + 1}/${names.length} · ${names[i]}…`);
// Yield so the status paints: synthesising a 120 s track blocks the
// main thread for seconds, otherwise the page looks dead.
await new Promise((r) => setTimeout(r, 0));
bankCache.set(names[i], song(names[i]).track);
}
out.push({ name: names[i], track: bankCache.get(names[i]) });
}
return out;
}
// Pin one viz as the subject of every section's stacks, keeping the venue
// ground (and overlays) untouched. Mutates the look before Show.useTrack, so
// the ArcDriver builds its layer cache from the pinned stacks directly.
function pinViz(look, viz, seedBase) {
const temper = look.personality && look.personality.temperament;
for (const sec of look.sections) {
const rng = new Rng(hashString(`${viz.name}:sec:${sec.index}:${seedBase >>> 0}`));
for (let v = 0; v < (sec.variants || []).length; v++) {
const stack = sec.variants[v];
if (!stack) continue;
const at = subjectIndexOf(stack);
const cur = stack[at];
if (!cur) continue;
const params = sampleValues(viz, rng.fork(`variant:${v}`), sec.bias, temper);
stack[at] = {
module: viz,
params,
seed: rng.int(0, 0x7fffffff),
// A model viz composites by alpha; a fragment viz needs the
// key/screen path over the venue ground. Reuse the old blend
// when it already fits, else fall back to the compositor-safe one.
blend: viz.kind === 'model' ? 'normal'
: (cur.blend === 'normal' ? 'lumakey' : cur.blend),
opacity: cur.opacity ?? 1,
blaze: cur.blaze ?? false,
};
}
}
}
function errorCard(title, detail) {
const card = document.createElement('div');
card.className = 'card error';
const h = document.createElement('h3');
h.textContent = title;
const meta = document.createElement('div');
meta.className = 'meta err';
meta.textContent = String(detail).slice(0, 500);
card.appendChild(h);
card.appendChild(meta);
out.appendChild(card);
}
async function renderCell(show, entry, venueName, vizMod, colIdx) {
const seedBase = hashString(`${entry.name}:${venueName || 'auto'}:${vizMod ? vizMod.name : 'auto'}:col:${colIdx}`);
let look;
try {
look = generateLook(entry.track, { seed: seedBase, venueEnabled: true });
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `generateLook failed: ${err.message || err}`);
return;
}
// Force the selected venue. The VenueSpec population stays the census one
// (counts overlap between archetypes), so this is a shell swap for the lab.
if (venueName) {
const picked = venues.find(v => v.name === venueName);
if (!picked) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `unknown venue "${venueName}"`);
return;
}
if (!look.venue) {
look.venue = {
archetype: picked.archetype, name: picked.name, seed: seedBase,
shell: { kind: picked.archetype }, projection: { kind: 'screen' },
population: { kind: 'scatter', count: 5, spread: 0.7, jitter: 0.25 },
paletteMap: [0, 1, 2, 3],
perSection: look.sections.map(() => ({ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 })),
venue: true, readsDepth: false, module: picked,
};
} else {
look.venue.module = picked;
look.venue.name = picked.name;
look.venue.archetype = picked.archetype;
}
}
if (vizMod) pinViz(look, vizMod, seedBase);
const fps = entry.track.fps || 60;
const frame = Math.min(
entry.track.frameCount - 2,
Math.floor(entry.track.duration * 0.35 * fps),
);
try {
show.useTrack(entry.track, look);
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `useTrack failed: ${err.message || err}`);
return;
}
show.warmUp(frame, 24);
let target;
try {
target = show.renderFrame(frame);
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `renderFrame failed: ${err.message || err}`);
return;
}
const pix = Uint8Array.from(show.readPixels(target));
const venueLabel = look.venue ? look.venue.name : 'no venue';
const shownViz = vizMod ? vizMod.name : (show.arc && show.arc.state ? show.arc.state.sceneName : '?');
const card = document.createElement('div');
card.className = 'card';
const h = document.createElement('h3');
h.textContent = `${entry.name} · ${venueLabel}`;
const cv = document.createElement('canvas');
cv.width = THUMB.width; cv.height = THUMB.height;
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = `${look.venue ? look.venue.archetype : ''} · viz ${shownViz} · seed ${seedBase >>> 0}`;
card.appendChild(h);
card.appendChild(cv);
card.appendChild(meta);
out.appendChild(card);
blit(cv, pix, THUMB.width, THUMB.height);
}
// Render (or re-render) the whole grid: songs (rows) × seeds (cols), one
// shared Show, cards cleared first so Render never appends.
async function renderGrid() {
if (building) return;
setBusy(true);
out.innerHTML = '';
try {
pushQuery();
const rows = Math.max(1, Number(selRows.value) || DEFAULT_ROWS);
const cols = Math.max(1, Number(selCols.value) || DEFAULT_COLS);
const songNames = selSong.value ? [selSong.value] : gridSongs(rows);
const entries = await ensureSongs(songNames);
const vizMod = selViz.value ? scenes.find(s => s.name === selViz.value) || null : null;
if (vizMod && vizMod.venue) {
setStatus(`"${vizMod.name}" is a venue, not a viz — pick a scene`, true);
return;
}
let show;
try {
show = new Show({ ...THUMB });
} catch (err) {
// Renderer throws a message that already names the cause (WebGL2)
// and the fix (enable it / use Chromium+SwiftShader).
setStatus(`cannot render: ${err.message || err}`, true);
errorCard('renderer failed to start', err.message || err);
return;
}
try {
const total = entries.length * cols;
let done = 0;
// A single bad song (bad synth params, COMPOSITION_REFUSED) must
// not silently end the grid at row one: guard per row, count the
// miss, and keep rendering. The card names the failure.
for (const entry of entries) {
for (let c = 0; c < cols; c++) {
setStatus(`rendering ${done + 1}/${total} · ${entry.name} · seed ${c + 1}/${cols}` +
`${selVenue.value ? ` · ${selVenue.value}` : ''}${vizMod ? ` · ${vizMod.name}` : ''}…`);
try {
// eslint-disable-next-line no-await-in-loop
await renderCell(show, entry, selVenue.value || null, vizMod, c);
} catch (err) {
errorCard(`${entry.name} · col ${c + 1}`,
`cell failed and would previously have stopped the grid: ${err.message || err}`);
}
done += 1;
progress.style.width = `${(done / total) * 100}%`;
// Yield so each card paints as it lands instead of the
// page freezing for the whole grid and showing it at once.
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 0));
}
}
setStatus(`done · ${done}/${total} cells · ${entries.length} song(s) × ${cols} seed(s)` +
(selVenue.value ? ` · venue ${selVenue.value}` : ' · venues from census') +
(vizMod ? ` · viz pinned to ${vizMod.name}` : ' · viz from census'));
} finally {
show.dispose();
}
} catch (err) {
setStatus(`grid failed: ${err.message || err}`, true);
} finally {
setBusy(false);
}
}
populate();
btnRender.addEventListener('click', renderGrid);
btnRandom.addEventListener('click', () => {
const vizPool = scenes.filter(s => !s.venue);
selViz.value = vizPool[Math.floor(Math.random() * vizPool.length)].name;
renderGrid();
});
document.addEventListener('keydown', (e) => {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT')) return;
if (e.key === 'Enter') renderGrid();
if (e.key === 'r' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); btnRandom.click(); }
});
// Default view is the grid itself, not an empty page waiting for a click.
renderGrid();
</script>
</body>
</html>