Files
music-video-gen/flow-state/venue-gallery.html
T
Dejvino 9c768f13d1 Venues: song sky, ride roadside, room camera, filmstrip venue pin
- Shared sky dome (src/venues/sky.js): gradient backdrop from palette /
  section light / style texture, wired into chamber/stage/ride with fog
  matched to the dome mid colour — replaces clear-to-black
- Ride roadside: houses, signs, trees, seeded viz billboard, all analytic
  scroll; popMul fade for crew, arrangement-gated roadside (no mid-section
  popping); parked idle creep + engine shake; analytic dash clock
- Room camera: traverse glide + progress look sweep + per-section dolly,
  single-side room visits, one re-hang max; venue framing per archetype
- Filmstrip venue selector (?venueSel=) with cache key; seed persisted to
  URL on reroll; gallery query plumbing
2026-09-18 18:57:04 +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 = 3;
const DEFAULT_COLS = 3;
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>