Every rendered still links to the app frame that produced it (song + seed + frame + venue/viz pins + probe warm-up, new window), via shared src/ui/preview.js. Supporting work, each measured: app honors ?frame/?venueSel/?viz/?warm= with pre-build pins (Show.applyPins) and primes deep-link loads; shared pinViz/pinVenueShell paths for gallery, filmstrip and app; probes prime and carry their warm-up; bank probes analyse the bank PCM and file decode is fixed-rate with bit-exact WAV parsing so app and probes see identical samples; materials built mid-update compile synchronously (needsCompile). Round trip verified bit-identical gallery-versus-app.
375 lines
16 KiB
HTML
375 lines
16 KiB
HTML
<!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; }
|
||
a.preview-link canvas { cursor: zoom-in; }
|
||
a.preview-link:hover canvas { outline: 1px solid #7dd3fc; outline-offset: -1px; }
|
||
.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 { hashString } from '/src/engine/rng.js';
|
||
import { generateLook } from '/src/look/LookGenerator.js';
|
||
import { pinVenueShell } from '/src/venues/pin.js';
|
||
import { pinViz } from '/src/look/pinViz.js';
|
||
import { venues } from '/src/venues/registry.js';
|
||
import { scenes } from '/src/scenes/registry.js';
|
||
import { blit } from '/src/checks/blit.js';
|
||
import { previewLink } from '/src/ui/preview.js';
|
||
|
||
const THUMB = { width: 320, height: 180 };
|
||
// Frames of warm-up behind every probe. Carried in preview links (?warm=)
|
||
// so the app lands on identically converged state. See src/ui/preview.js.
|
||
const WARMUP = 24;
|
||
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;
|
||
}
|
||
|
||
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) {
|
||
try {
|
||
pinVenueShell(look, venueName, { seed: seedBase, buildIfMissing: true });
|
||
} catch (err) {
|
||
errorCard(`${entry.name} · col ${colIdx + 1}`, err.message || String(err));
|
||
return;
|
||
}
|
||
}
|
||
// Pin the viz through the shared path with the app's ?viz=
|
||
// (src/look/pinViz.js): the card and its deep-link pin the same subject
|
||
// with the same seeds.
|
||
if (vizMod) {
|
||
try {
|
||
pinViz(look, vizMod.name);
|
||
} catch (err) {
|
||
errorCard(`${entry.name} · col ${colIdx + 1}`, err.message || String(err));
|
||
return;
|
||
}
|
||
}
|
||
|
||
const fps = entry.track.fps || 60;
|
||
const frame = Math.min(
|
||
entry.track.frameCount - 2,
|
||
Math.floor(entry.track.duration * 0.35 * fps),
|
||
);
|
||
try {
|
||
// The file name the app shows in its title plate (OSD): without it
|
||
// the card renders a blank plate and its deep-link can't match.
|
||
// See src/ui/preview.js.
|
||
show.fileName = entry.name;
|
||
show.useTrack(entry.track, look);
|
||
} catch (err) {
|
||
errorCard(`${entry.name} · col ${colIdx + 1}`, `useTrack failed: ${err.message || err}`);
|
||
return;
|
||
}
|
||
// Prime so the probe never renders against unlinked programs — the app
|
||
// primes deep-link loads for the same reason; both sides must be linked
|
||
// for a card and its link to agree. See src/ui/preview.js.
|
||
try {
|
||
show.prime();
|
||
} catch (err) {
|
||
errorCard(`${entry.name} · col ${colIdx + 1}`, `prime failed: ${err.message || err}`);
|
||
return;
|
||
}
|
||
show.warmUp(frame, WARMUP);
|
||
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);
|
||
// Clicking opens the exact frame in the app (song + seed + frame +
|
||
// venue pin + viz pin + the probe's warm-up length — the app rebuilds
|
||
// the same look through the same shared pin paths). Every card links:
|
||
// the gallery holds nothing back.
|
||
if (look.venue) {
|
||
previewLink(cv, {
|
||
song: entry.name,
|
||
seed: seedBase,
|
||
frame,
|
||
venue: true,
|
||
venueSel: venueName || look.venue.name,
|
||
viz: vizMod ? vizMod.name : null,
|
||
warm: WARMUP,
|
||
});
|
||
}
|
||
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>
|