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
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env node
// Venue gate loop driver — headless Chromium (SwiftShader WebGL2) against vite dev.
// Renders as little as possible per step; every wait has a heartbeat log so a
// hang is visible in seconds, not after a 15-minute timeout.
//
// Usage:
// node tools/venue-check.js --quick fast gates, phases 9+12 only
// node tools/venue-check.js --quick --phase=9 fast gates, one phase
// node tools/venue-check.js --full + slow phase 12 + variety/songs/decompose (small N)
// node tools/venue-check.js --filmstrip venue=1 filmstrip, 2 songs default
// node tools/venue-check.js --filmstrip --song=centre one song
// node tools/venue-check.js --filmstrip --venue=0 no-venue baseline for comparison
// node tools/venue-check.js --gallery venue gallery, 2x2 default
// node tools/venue-check.js --gallery --venue-sel=Chamber --viz=Kaleido\ Tunnel
// node tools/venue-check.js --all everything, small defaults
//
// Flags:
// --phase=9,12 phase filter for the checks steps (default quick: 9,12; full: 12)
// --venue=1 filmstrip venue flag (default 1; use 0 for the no-venue baseline)
// --venue-sel=NAME pin one venue in every gallery cell (default: census)
// --viz=NAME pin one viz in every gallery cell (default: census)
// --song=a,b filmstrip song subset (default: centre,drone)
// --rows=N --cols=N gallery grid (default: 2x2)
// --seeds=N variety/songs/decompose counts (default: 4)
// --timeout=N seconds per wait (default: 150)
// Exits non-zero if any gate fails. Screenshots to logs/venue/.
import { chromium } from 'playwright-core';
import { mkdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..');
const BASE = process.env.VENUE_BASE || 'http://127.0.0.1:5180';
const OUT = resolve(root, 'logs/venue');
mkdirSync(OUT, { recursive: true });
const argv = process.argv.slice(2);
const has = (n) => argv.includes(n);
const arg = (n, fb) => {
// Supports both `--flag value` and `--flag=value`.
const eq = argv.find((a) => a.startsWith(`${n}=`));
if (eq) return eq.slice(n.length + 1);
const i = argv.indexOf(n);
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fb;
};
const TIMEOUT_MS = Number(arg('--timeout', 150)) * 1000;
const PHASE_QUICK = arg('--phase', '9,12');
const PHASE_FULL = arg('--phase', '12');
const SONGS = arg('--song', 'centre,drone');
const VENUE_FLAG = arg('--venue', '1');
const VENUE_SEL = arg('--venue-sel', '');
const VIZ_SEL = arg('--viz', '');
const ROWS = Number(arg('--rows', 2));
const COLS = Number(arg('--cols', 2));
const SEEDS = Number(arg('--seeds', 4));
const want = {
quick: has('--quick') || has('--all') || (!has('--full') && !has('--filmstrip') && !has('--gallery')),
full: has('--full') || has('--all'),
filmstrip: has('--filmstrip') || has('--all'),
gallery: has('--gallery') || has('--all'),
};
const t0 = Date.now();
const ts = () => `${((Date.now() - t0) / 1000).toFixed(0).padStart(4)}s`;
const log = (...a) => console.log(`[${ts()}]`, ...a);
const results = { ok: true, steps: [] };
function step(name, ok, detail) {
results.steps.push({ name, ok, detail });
if (!ok) results.ok = false;
log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${String(detail).slice(0, 300)}` : ''}`);
}
// Fail fast when the dev server is unreachable instead of hanging in goto.
async function preflight() {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), 10000);
try {
const r = await fetch(`${BASE}/checks.html`, { signal: ctl.signal });
log(`preflight ${BASE} -> ${r.status}`);
return r.ok;
} catch (e) {
log(`preflight ${BASE} UNREACHABLE: ${String(e && e.message || e).slice(0, 120)}`);
return false;
} finally {
clearTimeout(timer);
}
}
// Poll `probe` (runs in page) until it reports {done:true}; heartbeat every 10s.
async function waitDone(page, probe, label, timeoutMs = TIMEOUT_MS) {
const start = Date.now();
let lastBeat = 0;
for (;;) {
const st = await page.evaluate(`(${probe})()`).catch((e) => ({ evalFail: String(e).slice(0, 80) }));
if (st && st.done) return st;
const elapsed = Date.now() - start;
if (elapsed > timeoutMs) {
await page.screenshot({ path: resolve(OUT, `fail-${label}-${Date.now()}.png`) }).catch(() => {});
throw new Error(`${label}: timeout after ${Math.round(elapsed / 1000)}s (last: ${JSON.stringify(st).slice(0, 200)})`);
}
if (elapsed - lastBeat > 10000) {
lastBeat = elapsed;
log(`${label}: +${Math.round(elapsed / 1000)}s`, JSON.stringify(st).slice(0, 160));
}
await new Promise((r) => setTimeout(r, 2000));
}
}
const checksProbe = `() => ({
done: window.__CHECKS_DONE__ === true,
info: (document.getElementById('summary')?.textContent || '').slice(0, 100)
+ ' | rows=' + document.querySelectorAll('#results .row').length,
})`;
const filmProbe = `() => ({
done: window.__FILMSTRIP_DONE__ === true,
info: 'rows=' + document.querySelectorAll('#out .row').length
+ ' ' + (document.getElementById('status')?.textContent || '').slice(0, 80),
})`;
const galleryProbe = `() => {
const s = document.getElementById('status');
const t = s ? s.textContent : '';
return { done: /^done/.test(t), info: t.slice(0, 120)
+ ' | cards=' + document.querySelectorAll('#out .card').length };
}`;
let browser = null;
try {
log(`args phases=${PHASE_QUICK}/${PHASE_FULL} songs=${SONGS} venue=${VENUE_FLAG} grid=${ROWS}x${COLS} seeds=${SEEDS} timeout=${TIMEOUT_MS / 1000}s base=${BASE}${VENUE_SEL ? ` venueSel=${VENUE_SEL}` : ''}${VIZ_SEL ? ` viz=${VIZ_SEL}` : ''}`);
if (!await preflight()) throw new Error('dev server unreachable — start `npm run dev` first');
log('launching chromium (headless shell, swiftshader, --no-sandbox)…');
browser = await chromium.launch({ args: ['--no-sandbox', '--disable-gpu', '--enable-unsafe-swiftshader'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
page.setDefaultTimeout(TIMEOUT_MS);
page.setDefaultNavigationTimeout(30000);
page.on('pageerror', (e) => log('[pageerror]', String(e && e.message || e).slice(0, 200)));
page.on('console', (m) => {
if (m.type() === 'error') log('[console.error]', m.text().slice(0, 200));
else if (m.type() === 'warning' && /WebGL/i.test(m.text())) log('[console.webgl]', m.text().slice(0, 160));
});
page.on('requestfailed', (r) => log('[reqfail]', r.url().slice(0, 100), r.failure()?.errorText || ''));
async function gotoChecks(query, label) {
const url = `${BASE}/checks.html?${query}`;
log(`${label}: goto ${query}`);
await page.goto(url, { waitUntil: 'commit' }); // 'load' never fires while GL contexts are held
const st = await waitDone(page, checksProbe, label);
log(`${label}: finished — ${st.info}`);
return page.evaluate(() => window.__CHECKS__);
}
if (want.quick) {
const label = 'quick';
const c = await gotoChecks(`slow=0&phase=${PHASE_QUICK}`, label);
const failed = (c.results || []).filter((r) => !r.pass && !r.manual);
step(`fast gates (phases ${PHASE_QUICK})`, failed.length === 0,
`${c.summary.passed}/${c.summary.total} passed` +
(failed.length ? ` · FAIL: ${failed.map((r) => `P${r.phase}/${r.name} — ${(r.detail || '').slice(0, 120)}`).join(' · ')}` : ''));
}
if (want.full) {
const c = await gotoChecks(`phase=${PHASE_FULL}&slow=1`, 'full-p12');
const failed = (c.results || []).filter((r) => !r.pass && !r.manual);
step(`slow phase ${PHASE_FULL}`, failed.length === 0,
failed.length ? `FAIL: ${failed.map((r) => `${r.name}: ${(r.detail || '').slice(0, 150)}`).join(' · ')}`
: `${c.summary.passed}/${c.summary.total} passed`);
for (const q of [`variety=1&seeds=${SEEDS}&library=0`, `songs=1&count=${SEEDS}`, `decompose=1&count=${SEEDS}`]) {
const r = await gotoChecks(q, `full-${q.split('&')[0].split('=')[0]}`);
step(`checks ${q.split('&')[0]}`, !!r.ok, (r.headline || r.lines?.slice(-3).join(' | ') || '').slice(0, 200));
}
}
if (want.filmstrip) {
const label = `filmstrip-v${VENUE_FLAG}`;
const EVERY = arg('--every', '30');
const DUR = arg('--duration', '180');
const url = `${BASE}/filmstrip.html?venue=${VENUE_FLAG}&song=${encodeURIComponent(SONGS)}&every=${EVERY}&duration=${DUR}`;
log(`${label}: goto venue=${VENUE_FLAG} song=${SONGS} every=${EVERY} duration=${DUR}`);
await page.goto(url, { waitUntil: 'commit' });
await waitDone(page, filmProbe, label);
const rows = await page.evaluate(() => window.__FILMSTRIP__.rows.map((r) => ({
name: r.name, direction: +r.direction?.toFixed(3), drift: +r.drift?.toFixed(3),
agitation: +r.agitation?.toFixed(3), error: r.error || null,
venue: r.look?.venue ? `${r.look.venue.name}(${r.look.venue.archetype})` : 'none',
director: r.director || '?',
// Per-probe story: what was on screen when each frame was taken.
probes: (r.shots || r.frames || []).map((f) => {
const t = Math.round(f.time);
const mm = `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
return `${mm} ${f.kind}/${f.scene}${f.moment ? `:${f.moment}` : ''} j${(+f.journey || 0).toFixed(2)}`;
}),
})));
const flat = rows.filter((r) => !r.error && r.direction < 0.15);
const agitated = rows.filter((r) => (r.agitation ?? 0) >= 0.70);
const broken = rows.filter((r) => r.error);
step(`filmstrip venue=${VENUE_FLAG} (${SONGS})`, flat.length === 0 && broken.length === 0,
`${rows.length} songs · flat: ${flat.map((r) => r.name).join(',') || 'none'}` +
` · agitated: ${agitated.map((r) => r.name).join(',') || 'none'}` +
(broken.length ? ` · BROKEN: ${broken.map((r) => r.name).join(',')}` : ''));
for (const r of rows) {
log(` ${r.name.padEnd(8)} dir ${r.direction} drift ${r.drift} agit ${r.agitation} [${r.venue} · ${r.director}]${r.error ? ' ERR ' + r.error : ''}`);
for (const p of r.probes || []) log(` ${r.name.padEnd(8)} ${p}`);
}
}
if (want.gallery) {
const label = 'gallery';
const qp = new URLSearchParams({ rows: String(ROWS), cols: String(COLS) });
if (VENUE_SEL) qp.set('venueSel', VENUE_SEL);
if (VIZ_SEL) qp.set('viz', VIZ_SEL);
const url = `${BASE}/venue-gallery.html?${qp}`;
log(`${label}: goto rows=${ROWS} cols=${COLS}`);
await page.goto(url, { waitUntil: 'commit' });
const st = await waitDone(page, galleryProbe, label);
log(`${label}: finished — ${st.info}`);
const shot = resolve(OUT, `venue-gallery-${Date.now()}.png`);
await page.screenshot({ path: shot, fullPage: true });
const meta = await page.evaluate(() => [...document.querySelectorAll('#out .card .meta')].map((e) => e.textContent));
const venuesSeen = [...new Set(meta.map((m) => m.split('·')[0]?.trim()))];
step(`venue gallery ${ROWS}x${COLS} census`, venuesSeen.length >= 2,
`${meta.length} cells · venues: ${venuesSeen.join(', ')} · shot ${shot}`);
}
} catch (err) {
step('harness', false, String(err && err.stack || err).slice(0, 300));
} finally {
await browser?.close().catch(() => {});
log('browser closed');
}
log(JSON.stringify({ ok: results.ok, steps: results.steps.map((s) => ({ name: s.name, ok: s.ok })) }));
process.exit(results.ok ? 0 : 1);