Files
music-video-gen/flow-state/venue-gallery.html
T
Dejvino 46fab512df 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
2026-09-17 19:35:26 +02:00

374 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>