Files
music-video-gen/flow-state/test/venue.test.js
T
Dejvino 46fab512df 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
2026-09-17 19:35:26 +02:00

198 lines
8.6 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { venues } from '../src/venues/registry.js';
import { generateVenue } from '../src/venues/VenueGenerator.js';
import { generateActorSet } from '../src/actors/ActorGenerator.js';
import { generatePersonality } from '../src/look/Personality.js';
import { generateIdentity } from '../src/look/Identity.js';
import { Rng } from '../src/engine/rng.js';
import { Compositor } from '../src/engine/Compositor.js';
const SUMMARY = {
meanCentroid: 0.5, meanFlatness: 0.15, bpm: 120,
dynamicRange: 0.6, meanLoudness: 0.4,
};
function personalityAt(seed) {
const rng = new Rng(seed);
return generatePersonality(SUMMARY, rng.fork('personality'), () => 10, 4);
}
test('every venue declares a reach rating in (0, 2]', () => {
assert.ok(venues.length > 0, 'no venues registered');
for (const v of venues) {
assert.equal(typeof v.reach, 'number', `${v.name}: missing reach rating`);
assert.ok(v.reach > 0 && v.reach <= 2, `${v.name}: reach ${v.reach} outside (0, 2]`);
}
});
test('venue spec carries a stable seed and per-section evolution', () => {
const rng = new Rng(1234);
const story = {
sections: [
{ act: 'setup' }, { act: 'development' },
{ act: 'climax' }, { act: 'resolution' },
],
};
const venue = generateVenue({
summary: SUMMARY, rng: rng.fork('venue'),
personality: personalityAt(99), director: null, story,
sections: [{}, {}, {}, {}],
});
assert.ok(venue.seed !== undefined && Number.isInteger(venue.seed));
assert.equal(venue.perSection.length, 4);
// Climax is the brightest, most populated point of the arc.
assert.ok(venue.perSection[2].light >= venue.perSection[0].light,
'climax should be at least as bright as setup');
assert.ok(venue.perSection[2].popMul >= venue.perSection[3].popMul,
'climax should be at least as populated as resolution');
// Deterministic: same seed, same spec.
const again = generateVenue({
summary: SUMMARY, rng: new Rng(1234).fork('venue'),
personality: personalityAt(99), director: null, story,
sections: [{}, {}, {}, {}],
});
assert.equal(again.seed, venue.seed);
assert.deepEqual(again.perSection, venue.perSection);
assert.deepEqual(again.rearrangeAt, venue.rearrangeAt);
});
test('rearrangeAt only jumps on act changes, and the recap returns home', () => {
const story = {
recap: true,
sections: [
{ act: 'setup' }, { act: 'development' }, { act: 'development' },
{ act: 'climax' }, { act: 'resolution' },
],
};
const mk = (seed) => generateVenue({
summary: SUMMARY, rng: new Rng(seed).fork('venue'),
personality: personalityAt(99), director: null, story,
sections: [{ kind: 'intro' }, { kind: 'build' }, { kind: 'drop' }, { kind: 'drop' }, { kind: 'outro' }],
});
const venue = mk(777);
assert.equal(venue.rearrangeAt.length, 5);
// Same-act section never rearranges.
assert.equal(venue.rearrangeAt[2], venue.rearrangeAt[1],
'same-act section must keep its arrangement');
// Recap outro returns to the intro's room.
assert.equal(venue.rearrangeAt[4], venue.rearrangeAt[0],
'recap outro must return to arrangement 0');
// Arrangement ids never decrease except at the recap.
for (let i = 1; i < 4; i++) {
assert.ok(venue.rearrangeAt[i] >= venue.rearrangeAt[i - 1],
`arrangement went backwards at section ${i}`);
}
// Stable across regenerations.
assert.deepEqual(mk(777).rearrangeAt, venue.rearrangeAt);
});
test('venue room camera walks on a bounded, deterministic path', () => {
// No WebGL needed: drive the camera method on a bare object.
const comp = Object.create(Compositor.prototype);
comp.sharedCamera = {
position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } },
rotation: {},
lookAt() {},
updateMatrixWorld() {},
};
const personality = personalityAt(7);
const venue = {
archetype: 'chamber', seed: 4242,
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 }],
};
const at = (time, journey) => {
comp._updateVenueCamera(comp.sharedCamera, {
framing: { scale: 1, shift: [0, 0] },
personality, time, venue, journey,
// Production always passes progress (Show.js); the journey-only
// fallback exists for hand-built paths. Measure the real path.
progress: time / 400,
});
return { ...comp.sharedCamera.position };
};
const a = at(10, 0.2), b = at(40, 0.6), c = at(10, 0.2);
// Deterministic in (time, journey): same inputs, same position.
assert.deepEqual(a, c);
// It moves over 30 s — this is the device under test.
const travelled = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
assert.ok(travelled > 0.15, `camera barely moves: ${travelled.toFixed(3)} units in 30 s`);
// Bounded: a walk that leaves the room fails the device.
for (const t of [0, 30, 90, 200, 400]) {
const p = at(t, 0.5);
assert.ok(Math.abs(p.x) < 2.6, `x=${p.x.toFixed(2)} outside the room at t=${t}`);
assert.ok(p.z > 0.4 && p.z < 8, `z=${p.z.toFixed(2)} outside the room at t=${t}`);
}
// Stage walks further than chamber (bigger room). The traverse is
// journey-driven, so both probes move in journey as well as time.
const stageAt = (time, journey) => {
comp._updateVenueCamera(comp.sharedCamera, {
framing: { scale: 1, shift: [0, 0] },
personality, time, venue: { ...venue, archetype: 'stage' }, journey,
progress: time / 400,
});
return { ...comp.sharedCamera.position };
};
const s0 = stageAt(0, 0.2), s1 = stageAt(37.5, 0.6);
const chamberSpan = Math.hypot(b.x - a.x, b.y - a.y);
const stageSpan = Math.hypot(s1.x - s0.x, s1.y - s0.y);
assert.ok(stageSpan >= chamberSpan * 0.9,
`stage walk (${stageSpan.toFixed(2)}) should reach at least as far as chamber (${chamberSpan.toFixed(2)})`);
});
test('each venue module builds and updates headless with a furnished actor', () => {
// THREE is ESM-only but importable from node; geometry build paths need no GL.
const THREE = awaitImportThree();
return THREE.then((T) => {
const personality = personalityAt(21);
const identity = generateIdentity(SUMMARY, new Rng(21).fork('identity'), 4);
personality.identity = identity;
const actors = generateActorSet(SUMMARY, new Rng(21).fork('actors'), personality, identity);
for (const v of venues) {
const scene = new T.Scene();
const instance = v.build({
scene, seed: 99, params: {}, actorSpec: actors.monolith,
venueSpec: {
seed: 5, population: { count: 4, spread: 0.7, jitter: 0.25 },
paletteMap: [0, 1, 2, 3],
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }],
},
THREE: T,
});
assert.ok(instance, `${v.name}: build returned nothing`);
v.update({
instance, scene, camera: new T.PerspectiveCamera(),
timeline: { time: 12.5, frame: 750, progress: 0.1 },
features: {}, params: { light: 0.7, columns: 7, spread: 1.1 },
palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.4, 0.3]],
personality, framing: { scale: 1, shift: [0, 0] }, opacity: 1,
actorSpec: actors.monolith,
venueSpec: instance.venueSpec,
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
THREE: T,
});
const furnished = countMeshes(scene);
assert.ok(furnished >= 2,
`${v.name}: only ${furnished} mesh(es) after update with an actor — furniture did not build`);
}
});
});
function countMeshes(root) {
let n = 0;
root.traverse((o) => { if (o.isMesh) n++; });
return n;
}
// NOTE: ArcDriver persistence/interpolation/jump logic is exercised in the
// browser (checks + filmstrip), not here: ArcDriver's import chain reads
// scenes/metadata.json, which Node 20 cannot import without an attribute.
// See tools/venue-check.js --filmstrip for the carryover measurement.
// Dynamic import indirection so a missing/broken three surfaces as a test
// failure with a name on it rather than a top-level load error for the file.
function awaitImportThree() {
return import('three');
}