Files
music-video-gen/flow-state/venue-gallery.html
T
Dejvino 4d05250294 Clickable previews: exact app deep-links everywhere
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.
2026-09-20 18:42:53 +02:00

375 lines
16 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; }
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>