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:
@@ -39,7 +39,7 @@ const FORBIDDEN = [
|
||||
];
|
||||
|
||||
// Directories whose output must be a pure function of (seed, params, frame).
|
||||
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params', 'actors'];
|
||||
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params', 'actors', 'venues'];
|
||||
// Files legitimately allowed a wall clock: perf measurement, not image content.
|
||||
const ALLOWED = new Set(['engine/perf.js']);
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
// Scaffold a new venue — mirrors tools/new-scene.js --model.
|
||||
// Venue IS the ground (role:'ground'), kind:'model' with build/update stubs
|
||||
// that map venueSpec.perSection + stageNode pattern.
|
||||
// Usage: node tools/new-venue.js "Harbour" --archetype=chamber
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const VENUES = join(root, 'src/venues');
|
||||
const REGISTRY = join(root, 'src/venues/registry.js');
|
||||
const ARCHETYPES = ['chamber','stage','ride'];
|
||||
const args = process.argv.slice(2);
|
||||
const flags = new Map(args.filter(a=>a.startsWith('--')).map(a=>{ const [k,v]=a.replace(/^--/,'').split('='); return [k, v===undefined?true:v]; }));
|
||||
const name = args.find(a=>!a.startsWith('--'));
|
||||
if (!name) { console.error(`usage: node tools/new-venue.js "Name" --archetype=<${ARCHETYPES.join('|')}>`); process.exit(1); }
|
||||
const archetype = String(flags.get('archetype')||'chamber');
|
||||
if (!ARCHETYPES.includes(archetype)) { console.error(`unknown archetype '${archetype}'`); process.exit(1); }
|
||||
const kebab = name.trim().toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
|
||||
const camel = kebab.replace(/-([a-z0-9])/g, (_,c)=>c.toUpperCase());
|
||||
const file = join(VENUES, `${kebab}.js`);
|
||||
if (existsSync(file)) { console.error(`${kebab}.js already exists`); process.exit(1); }
|
||||
const salt = (text, lo, hi)=>{ let h=2166136261; for(let i=0;i<text.length;i++){ h^=text.charCodeAt(i); h=Math.imul(h,16777619)>>>0; } return +(lo+(h%1000)/1000*(hi-lo)).toFixed(2); };
|
||||
const spread = salt(kebab, 0.5, 1.1);
|
||||
const light = salt(kebab+'light', 0.5, 0.9);
|
||||
const source = `// VENUE: ${name} — ${archetype} archetype. One venue = one place.
|
||||
// Primitives only in V1; per-section evolution via venueState {light,fog,popMul,reachMul} from VenueSpec.perSection.
|
||||
import { actorToGeometry } from '../actors/meshes.js';
|
||||
export const ${camel} = {
|
||||
name: '${name.trim()}',
|
||||
archetype: '${archetype}',
|
||||
family: '${archetype==='chamber'?'minimal':'structural'}',
|
||||
kind: 'model',
|
||||
venue: true,
|
||||
consumes: ['form','ink','staging'],
|
||||
traits: ['shape','space','style'],
|
||||
texture: 0.35,
|
||||
// How much viz bloom this room can take before its highlights clip.
|
||||
// Dim rooms (plaster, floor light) ~1.1; rooms with a big emissive screen ~0.55.
|
||||
reach: 0.9,
|
||||
params: {
|
||||
density: { type: 'float', range: [0.2, 0.9], default: ${spread}, uniform: 'u_density', bias: 'density' },
|
||||
light: { type: 'float', range: [0.3, 1.2], default: ${light}, uniform: 'u_light', bias: 'energy' },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
reactive: {},
|
||||
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
|
||||
const T = THREE;
|
||||
const group = new T.Group(); group.name='${kebab}';
|
||||
const mat = new T.MeshStandardMaterial({ color: new T.Color(0.14,0.13,0.12), roughness: 0.85 });
|
||||
const floor = new T.Mesh(new T.PlaneGeometry(22,22), mat);
|
||||
floor.rotation.x = -Math.PI/2; floor.position.y = -1.4; group.add(floor);
|
||||
scene.add(group);
|
||||
const amb = new T.AmbientLight(0xffffff, 0.42); scene.add(amb);
|
||||
scene.background = null; scene.fog = new T.Fog(0x0e0e12, 12, 28); scene.userData.transparentBackground = true;
|
||||
const pop = new T.Group(); pop.name='pop'; scene.add(pop);
|
||||
return { group, mat, amb, pop, seed, venueSpec: venueSpec||null, venueState: null, builtKey: null, popCount: 0 };
|
||||
},
|
||||
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, THREE }) {
|
||||
const T = THREE; const t = timeline.time;
|
||||
const pal = palette && palette.length ? palette : [[0.9,0.9,0.92],[0.7,0.6,0.8]];
|
||||
const vs = venueSpec || instance.venueSpec;
|
||||
const vState = venueState || instance.venueState || null;
|
||||
if (vState) instance.venueState = vState;
|
||||
const c0 = pal[0];
|
||||
instance.mat.color.setRGB(c0[0]*0.26, c0[1]*0.24, c0[2]*0.28);
|
||||
if (scene.fog && vState) { const fogMul = vState.fog; const space = personality?.space; const base = space ? 12 + space.depth*6 : 15; scene.fog.near = base * (0.85 + fogMul*0.4); }
|
||||
if (vState) {
|
||||
const target = 0.55 * (params.light||0.7) * (0.75 + vState.light*0.5);
|
||||
// stub emissive if needed: venue surface placeholder
|
||||
void target;
|
||||
}
|
||||
const identity = personality?.identity || null;
|
||||
if (vs && identity && actorSpec && actorSpec.form && (!instance.popCount || instance.builtKey !== \`\${vs.seed}:\${vs.population.count}:\${identity.cast.protagonist.sides}:\${actorSpec.seed}\`)) {
|
||||
// rebuild pop on spec change — stub: real venue would populate via stageNode/actorToGeometry
|
||||
instance.builtKey = \`\${vs.seed}:\${vs.population.count}:\${identity.cast.protagonist.sides}:\${actorSpec.seed}\`;
|
||||
instance.popCount = vs.population.count;
|
||||
}
|
||||
// Gate visibility by popMul roughly
|
||||
if (vState) {
|
||||
const mul = vState.popMul; const floorCount = Math.max(1, Math.floor((instance.popCount||0) * (0.3 + mul*0.8)));
|
||||
for (let i=0;i<instance.pop.children.length;i++) instance.pop.children[i].visible = i < floorCount && opacity>0.01;
|
||||
}
|
||||
void T; void t; void params; void opacity;
|
||||
},
|
||||
};
|
||||
export default ${camel};
|
||||
`;
|
||||
writeFileSync(file, source);
|
||||
let registry = readFileSync(REGISTRY,'utf8');
|
||||
const importLine = `import { ${camel} } from './${kebab}.js';`;
|
||||
if (!registry.includes(importLine)) {
|
||||
const lastImport = registry.lastIndexOf("} from './");
|
||||
const eol = registry.indexOf('\n', lastImport);
|
||||
registry = registry.slice(0,eol+1)+importLine+'\n'+registry.slice(eol+1);
|
||||
}
|
||||
registry = registry.replace(/const MODULES = \[/, `const MODULES = [\n ${camel},`);
|
||||
writeFileSync(REGISTRY, registry);
|
||||
console.log(`created src/venues/${kebab}.js`);
|
||||
console.log(`registered ${camel} (${archetype})`);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user