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
+7
View File
@@ -78,6 +78,13 @@
<a href="/filmstrip.html?song=centre,ember">?song=</a> just these · <a href="/filmstrip.html?song=centre,ember">?song=</a> just these ·
<code>EPIC-4.md</code> for the arc it is checking</div> <code>EPIC-4.md</code> for the arc it is checking</div>
</div> </div>
<div class="card fast">
<h3><a href="/venue-gallery.html">venue gallery</a></h3>
<p>Venue &times; viz explorer — venue (higher-level than viz, picked first via census; hybrid
projection per archetype) against viz behind it. Try a sad-slow chamber of memories vs. a
pumping stage field from different angles and with different viz.</p>
<div class="meta">rows = songs · try <code>?venue=1</code> in gallery/filmstrip/app</div>
</div>
<div class="card fast"> <div class="card fast">
<h3><a href="/">the app</a></h3> <h3><a href="/">the app</a></h3>
<p>Drop a track and watch it. <code>test/songs/*.wav</code> holds the <p>Drop a track and watch it. <code>test/songs/*.wav</code> holds the
+3 -2
View File
@@ -114,6 +114,7 @@ const params = new URLSearchParams(location.search);
const every = Number(params.get('every')) || DEFAULT_EVERY; const every = Number(params.get('every')) || DEFAULT_EVERY;
const duration = Number(params.get('duration')) || DEFAULT_DURATION; const duration = Number(params.get('duration')) || DEFAULT_DURATION;
const only = params.get('song') ? params.get('song').split(',') : null; const only = params.get('song') ? params.get('song').split(',') : null;
const venueEnabled = params.get('venue') === '1';
document.getElementById('everyLabel').textContent = every; document.getElementById('everyLabel').textContent = every;
@@ -231,7 +232,7 @@ function summarise(built) {
// The cache key carries the sampling parameters as well as the source, so // The cache key carries the sampling parameters as well as the source, so
// changing `?every=` does not silently show you the previous spacing's pixels. // changing `?every=` does not silently show you the previous spacing's pixels.
const cacheKey = () => `filmstrip:${every}:${duration}:${only ? only.join(',') : 'all'}:${sourceFingerprint()}`; const cacheKey = () => `filmstrip:${every}:${duration}:${only ? only.join(',') : 'all'}:v${venueEnabled ? 1 : 0}:${sourceFingerprint()}`;
async function build() { async function build() {
bar.classList.add('building'); bar.classList.add('building');
@@ -241,7 +242,7 @@ async function build() {
const total = (only || songNames()).length; const total = (only || songNames()).length;
const built = await buildFilmstrip({ const built = await buildFilmstrip({
duration, every, names: only, duration, every, names: only, venueEnabled,
// Painted as they land, in bank order, rather than after the whole run. // Painted as they land, in bank order, rather than after the whole run.
// A full build is a couple of minutes of GPU work and the interesting // A full build is a couple of minutes of GPU work and the interesting
// failure — a strip whose frames could be shuffled — is visible in the // failure — a strip whose frames could be shuffled — is visible in the
+51 -4
View File
@@ -69,9 +69,10 @@ export class Show {
report('look', 0.97); report('look', 0.97);
const samples = monoSamples(audioBuffer); const samples = monoSamples(audioBuffer);
const wantsVenue = this._wantsVenue();
this.setLook(seed === null this.setLook(seed === null
? generateLook(this.track, { samples }) ? generateLook(this.track, { samples, venueEnabled: wantsVenue })
: generateLook(this.track, { seed })); : generateLook(this.track, { seed, venueEnabled: wantsVenue }));
this.engine.timeline.setDuration(this.track.duration); this.engine.timeline.setDuration(this.track.duration);
this.engine.setFeatureProvider(featureProviderFor(this.track)); this.engine.setFeatureProvider(featureProviderFor(this.track));
@@ -97,7 +98,7 @@ export class Show {
this.track = track; this.track = track;
this.engine.timeline.setDuration(track.duration); this.engine.timeline.setDuration(track.duration);
this.engine.setFeatureProvider(featureProviderFor(track)); this.engine.setFeatureProvider(featureProviderFor(track));
this.setLook(look || generateLook(track, { seed: 1 })); this.setLook(look || generateLook(track, { seed: 1, venueEnabled: this._wantsVenue() }));
this.prewarm(); this.prewarm();
return this; return this;
} }
@@ -144,6 +145,37 @@ export class Show {
this.engine.setSize(width, height); this.engine.setSize(width, height);
} }
_wantsVenue() {
try {
const qp = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null;
if (qp) {
const v = qp.get('venue');
if (v === '1' || v === 'true' || v === 'on') return true;
if (v && v !== '0' && v !== 'false' && v !== 'off') return true;
}
} catch { /* non-browser */ }
if (this._venueOverride !== undefined) return !!this._venueOverride;
return false;
}
setVenueEnabled(enabled) {
enabled = !!enabled;
this._venueOverride = enabled;
if (!this.track || !this.look) return;
const fromSamples = this.audioBuffer ? monoSamples(this.audioBuffer) : null;
const seed = this.look ? this.look.seed : 1;
try {
const next = fromSamples
? generateLook(this.track, { samples: fromSamples, venueEnabled: enabled, seed: null })
: generateLook(this.track, { seed, venueEnabled: enabled });
const locked = new Map();
for (const sec of this.look.sections) if (sec.locked) locked.set(sec.index, sec);
for (let i = 0; i < next.sections.length; i++) if (locked.has(i)) next.sections[i] = locked.get(i);
this.setLook(next);
this.prewarm();
} catch { /* leave look as-is on failure */ }
}
/** Fade in at the head and out at the tail; nothing starts or ends abruptly. */ /** Fade in at the head and out at the tail; nothing starts or ends abruptly. */
_fadeAt(frame) { _fadeAt(frame) {
const fadeFrames = FADE_SECONDS * this.fps; const fadeFrames = FADE_SECONDS * this.fps;
@@ -208,11 +240,26 @@ export class Show {
// nothing reads it. Pure f(frame,look) so seek === playback still holds. // nothing reads it. Pure f(frame,look) so seek === playback still holds.
if (sceneLayers.some((l) => l && l.module && l.module.kind === 'model')) { if (sceneLayers.some((l) => l && l.module && l.module.kind === 'model')) {
const pers = this.look.personality; const pers = this.look.personality;
const framing = this.arc.framingForFrame(timeline.frame); const hasVenue = sceneLayers.some((l) => l && l.module && l.module.venue);
// The room gets its own framing, not the viz's: the venue camera
// relocates only on story jumps and otherwise holds, while the viz
// framing cuts at every shot. Feeding the viz framing to the room
// is what threw it around at every section. See _venueFramingAt.
const framing = hasVenue && this.look.venue
? this.arc.venueFramingForFrame(timeline.frame)
: this.arc.framingForFrame(timeline.frame);
this.engine.compositor.updateSharedCamera({ this.engine.compositor.updateSharedCamera({
framing, framing,
personality: pers, personality: pers,
time: timeline.time, time: timeline.time,
venue: hasVenue && this.look.venue ? this.look.venue : null,
journey: this.arc.state ? this.arc.state.journey : 0,
// Continuous 0→1 across the video. The story journey travels in
// held stages (right for narrative, wrong for a camera dolly —
// staged values jump at section starts); progress is monotonic
// in the frame, so a traverse driven by it cannot jump.
progress: timeline.frameCount > 1
? timeline.frame / (timeline.frameCount - 1) : 0,
}); });
// Inject the shared camera into each active model layer so they render // Inject the shared camera into each active model layer so they render
// through one perspective and one depth buffer. // through one perspective and one depth buffer.
+7 -5
View File
@@ -69,11 +69,12 @@ function blockMean(byBlock) {
* *
* @returns {{name, frames: Array, drift: number, direction: number, look}} * @returns {{name, frames: Array, drift: number, direction: number, look}}
*/ */
export function renderSong(show, name, { duration, every }) { export function renderSong(show, name, { duration, every, venueEnabled = null }) {
const { track } = song(name, { duration }); const { track } = song(name, { duration });
// Seeded off the song's name, so a strip is stable between rebuilds and two // Seeded off the song's name, so a strip is stable between rebuilds and two
// songs are not accidentally handed the same look. // songs are not accidentally handed the same look.
const look = generateLook(track, { seed: hashString(name) }); const venueFlag = venueEnabled !== null ? !!venueEnabled : (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venue') === '1');
const look = generateLook(track, { seed: hashString(name), venueEnabled: venueFlag });
show.useTrack(track, look); show.useTrack(track, look);
const frames = []; const frames = [];
@@ -162,6 +163,7 @@ export function renderSong(show, name, { duration, every }) {
return { return {
name, name,
frames, frames,
look,
drift: mean(dists), drift: mean(dists),
direction: directionOf(gaps, dists), direction: directionOf(gaps, dists),
// Agitation over the strip: worst 1 s floating window, max(flash/3, // Agitation over the strip: worst 1 s floating window, max(flash/3,
@@ -205,7 +207,7 @@ function momentAt(story, index) {
* times for nothing. * times for nothing.
*/ */
export async function buildFilmstrip({ export async function buildFilmstrip({
duration = DEFAULT_DURATION, every = DEFAULT_EVERY, names = null, duration = DEFAULT_DURATION, every = DEFAULT_EVERY, names = null, venueEnabled = null,
onSong = null, onSongStart = null, onSong = null, onSongStart = null,
} = {}) { } = {}) {
const list = names && names.length ? names : songNames(); const list = names && names.length ? names : songNames();
@@ -223,9 +225,9 @@ export async function buildFilmstrip({
} }
let row; let row;
try { try {
row = renderSong(show, list[i], { duration, every }); row = renderSong(show, list[i], { duration, every, venueEnabled });
} catch (err) { } catch (err) {
row = { name: list[i], frames: [], drift: 0, direction: 0, error: err.message }; row = { name: list[i], frames: [], shots: [], drift: 0, direction: 0, error: err.message };
} }
rows.push(row); rows.push(row);
if (onSong) onSong(i + 1, list.length, row); if (onSong) onSong(i + 1, list.length, row);
+257 -5
View File
@@ -64,6 +64,12 @@ export class Compositor {
const bh = Math.max(1, Math.floor(h / 2)); const bh = Math.max(1, Math.floor(h / 2));
this.layerTarget = r.createTarget(w, h, { depth: true }); this.layerTarget = r.createTarget(w, h, { depth: true });
// Viz render target for the venue RTT path: non-venue layers composite
// here first, then the texture is bound onto the venue's screen and the
// venue itself renders into layerTarget. Same size as the layer target
// (aspect-preserving screens sample it; oversized is harmless, undersized
// would smear text). Cleared with the rest in reset().
this.vizTarget = r.createTarget(w, h, { depth: true });
this.accumA = r.createTarget(w, h); this.accumA = r.createTarget(w, h);
this.accumB = r.createTarget(w, h); this.accumB = r.createTarget(w, h);
this.historyA = r.createTarget(w, h, { float: true }); this.historyA = r.createTarget(w, h, { float: true });
@@ -89,10 +95,25 @@ export class Compositor {
this.sharedDepthTarget = this.renderer.createDepthTarget(this.width, this.height); this.sharedDepthTarget = this.renderer.createDepthTarget(this.width, this.height);
} }
/** Drive the shared perspective rig from the current framing + gaze + personality. */ /**
updateSharedCamera({ framing, personality, time }) { * Drive the shared perspective rig from the current framing + gaze + personality.
*
* `venue` selects the grammar. Without one the camera is the old subject
* rig: near-still at dolly ~4, drift measured in hundredths, because that
* camera films an object. A venue is a room the camera is INSIDE, and the
* same hundredths read as a locked tripod in the middle of it — which is
* why every venue render so far has been one static wide. The room grammar
* walks: a slow lateral travel (±1.5) with a counter-pan so the screen stays
* framed, a per-section push-in from the story journey, and a handheld
* layer from churn. All analytic f(time, seed) — seek === playback.
*/
updateSharedCamera({ framing, personality, time, venue = null, journey = 0 }) {
const cam = this.sharedCamera; const cam = this.sharedCamera;
const frame = framing || { scale: 1, shift: [0, 0] }; const frame = framing || { scale: 1, shift: [0, 0] };
if (venue) {
this._updateVenueCamera(cam, { framing: frame, personality, time, venue, journey });
return;
}
const dolly = 4 / Math.max(frame.scale, 0.05); const dolly = 4 / Math.max(frame.scale, 0.05);
const pCam = personality ? personality.camera : null; const pCam = personality ? personality.camera : null;
if (pCam) { if (pCam) {
@@ -114,6 +135,154 @@ export class Compositor {
cam.updateMatrixWorld(); cam.updateMatrixWorld();
} }
_updateVenueCamera(cam, { framing, personality, time, venue, journey, progress = null }) {
const pCam = personality ? personality.camera : null;
const motion = personality ? personality.motion : null;
const seed = (venue.seed >>> 0) || 1;
const s1 = Math.sin(seed * 0.00013), s2 = Math.cos(seed * 0.00029);
// Smaller rooms get a smaller walk: chamber is 22 wide, stage 44.
const room = venue.archetype === 'chamber' ? 0.62 : venue.archetype === 'ride' ? 0.85 : 1.0;
const still = motion ? motion.stillness : 0.5;
const churn = motion ? motion.churn : 0.25;
// Planned room shots (see look/venueShots.js): absolute per-cue marks
// with a motive — screen, furniture, or the whole room. When the arc
// serves one, it replaces the traverse/push/gaze stack below outright;
// only the handheld texture survives. No plan (hand-built looks, old
// tests driving this method directly) → the legacy traverse path.
const planned = framing && framing.venueShot ? framing.venueShot : null;
// The walk: a TRAVERSE, not a sway. Two oscillators were tried here and
// both failed the same way — a 75 s period put consecutive 30 s probes
// half a cycle apart, and even a 300 s period swings back within a long
// video, so near-in-time frames differed more than far-apart ones and
// filmstrip direction went negative. The staged story journey was tried
// next and jumped at section starts for the same reason. The camera
// instead crosses the room once, driven by continuous video progress
// (monotonic in the frame — it cannot jump), while the push-in below
// stays journey-staged so arrivals still land per section. Direction
// from the seed so two tracks walk different lines; counter-pan below
// keeps the screen framed. Stillness LENGTHENS the crossing: a pumping
// track changes its viz constantly, a still one does not, so the camera
// does the travelling the music doesn't — otherwise slow songs go
// static in both layers at once (two flat filmstrips on the slowest
// bank songs). A small two-sine handheld keeps it alive in a hold.
const walkDir = s1 >= 0 ? 1 : -1;
const walkSpan = (1.1 + still * 0.9) * walkDir;
const trav = progress !== null && progress !== undefined ? progress : journey;
// Glide, not drift: the lateral crossing is only one axis of travel.
// A second, slower cosine on a different phase swings the camera across
// the room's middle over ~2 minutes, so the path arcs instead of
// drawing a straight line — straight lines read as static at 30 s
// probe spacing once the endpoints are familiar.
const walk = (trav - 0.5) * 2 * walkSpan
+ Math.cos(time * 0.043 + s2 * 4.2) * walkSpan * 0.55
+ Math.sin(time * 0.23 + s1 * 3.1) * 0.08
+ Math.sin(time * 0.11 + s2 * 5.0) * 0.06;
// The push: story journey 0→1 walks the camera closer over the video,
// staged through per-section steps by the journey curve itself. Sized
// so consecutive 30 s probes see the room at measurably different
// scales — a static camera in a static room is churn, not an arc.
// The dolly ALSO marches on continuous progress underneath the staged
// push, so even a journey that holds for a minute keeps gliding closer.
const push = (journey - 0.5) * 2.2 * room
+ (trav - 0.5) * 1.6 * room;
// Handheld: churn-scaled, two incommensurate sines so it never loops.
const hx = Math.sin(time * 0.9 + s1 * 7.0) * 0.035 * (0.4 + churn)
+ Math.sin(time * 0.23 + s2 * 5.0) * 0.05 * (0.4 + churn);
const hy = Math.cos(time * 0.77 + s2 * 9.0) * 0.03 * (0.4 + churn)
+ Math.sin(time * 0.31 + s1 * 4.0) * 0.04 * (0.4 + churn);
const sway = pCam ? pCam.sway : 0.01;
const swayRate = pCam ? pCam.swayRate : 0.09;
if (planned) {
// A planned mark: the BODY walks the traverse (continuous,
// monotonic — the thing that arcs), while the HEAD takes the
// plan's look target (screen / room). Position marks were tried and
// reverted: discrete positions made held compositions that all
// looked alike, and blending positions with the walk broke the
// walk's time correlation. The look is where the motive lives — a
// pan cut over a gliding body reads as following the action. Jump
// sections cut the body to the arrangement side (the furniture
// swapped; gliding through the old layout is the glitch).
const lim = venue.archetype === 'chamber'
? { x: 2.4, yLo: -0.2, yHi: 1.4, zLo: 0.8, zHi: 7.0 }
: venue.archetype === 'ride'
? { x: 4.0, yLo: -0.4, yHi: 2.2, zLo: -6.0, zHi: 6.0 }
: { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 };
const isJump = framing.venueJump !== undefined && framing.venueJump !== null;
if (isJump) {
cam.position.set(
Math.max(-lim.x, Math.min(lim.x, planned.pos[0] + hx)),
Math.max(lim.yLo, Math.min(lim.yHi, planned.pos[1] + hy)),
Math.max(lim.zLo, Math.min(lim.zHi, planned.pos[2])),
);
} else {
// Same traverse body as the legacy path below (walk + rise +
// sway + gaze share): one body, two heads. The plan's per-shot
// size scales the dolly (a close stands nearer).
const baseZ = venue.archetype === 'chamber' ? 4.0 : venue.archetype === 'ride' ? 3.4 : 4.6;
const sizeDolly = (planned.dolly && planned.dolly > 0) ? planned.dolly : 1.0;
const dolly = (baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push)) * sizeDolly;
const rise = (trav - 0.5) * 0.9 * room;
const shiftX = framing.shift[0] * 0.6;
const shiftY = framing.shift[1] * 0.35;
cam.position.set(
walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx,
Math.max(lim.yLo, Math.min(lim.yHi,
0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy)),
Math.max(lim.zLo, Math.min(lim.zHi, dolly)),
);
}
cam.rotation.z = pCam ? pCam.spin * time * 0.5 : 0;
cam.lookAt(planned.look[0], planned.look[1], planned.look[2]);
cam.updateMatrixWorld();
return;
}
// Close enough that the screen fills the frame: the chamber window is
// 12 wide at z=-10.9, so z≈4 makes it ~40% of a 60° frame.
const baseZ = venue.archetype === 'chamber' ? 4.0 : venue.archetype === 'ride' ? 3.4 : 4.6;
const dolly = baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push);
// A story jump relocates the room camera for the section: the furniture
// swapped at its start (see ArcDriver.venueJumpAt), so the camera
// MUST be somewhere new too — a new room filmed from the old mark
// reads as the glitch, while a new room from a new angle reads as a
// cut. The framing carries the jump side; the traverse continues
// underneath, so the walk resumes from the new mark without snapping
// back. Pure f(framing, time) — the jump id is in the framing.
const jumpShift = (framing && framing.venueJump !== undefined && framing.venueJump !== null)
? framing.shift : null;
// Gaze shifts relocate at section starts; the venue camera takes a
// third of them — the room should sit stiller than the viz, and the
// counter-pan already keeps the screen framed through the traverse.
// (Kept small deliberately: full gaze jumps threw the whole room
// around at every section, ~4x the no-venue section distance.)
// On a jump frame the jump shift replaces the gaze shift outright.
// The mix is continuous in time EXCEPT the jump cut: gaze moves ease
// with the section (they are per-cue plans evaluated live), so the
// camera reframes at every section boundary instead of teleporting.
const shiftX = jumpShift ? jumpShift[0] : framing.shift[0] * 0.6;
const shiftY = jumpShift ? jumpShift[1] : framing.shift[1] * 0.35;
// Vertical rise: the camera climbs as it pushes in — low wide early,
// higher and closer late — so the video gains a second travel axis the
// walk cannot provide. Clamped to the room box: the test asserts
// |x| < 2.6 and z in 0.4..8, and the bigger swing must not break it.
const rise = (trav - 0.5) * 0.9 * room;
const limX = venue.archetype === 'chamber' ? 2.4 : venue.archetype === 'ride' ? 4.5 : 4.0;
cam.position.set(
Math.max(-limX, Math.min(limX,
walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx)),
0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy,
Math.max(0.6, Math.min(7.5, dolly)),
);
cam.rotation.z = pCam ? pCam.spin * time * 0.5 : 0;
// Counter-pan: look back toward the screen by a fraction of the walk, so
// the camera travels without losing the room's subject. Was 0.35 — but
// that cancelled the traverse visually (the camera walked while its
// view stayed pinned, so layout never travelled and direction stayed
// flat). 0.15 lets the walk reframe while keeping the screen in view.
const lookX = cam.position.x * 0.15;
cam.lookAt(lookX, cam.position.y * 0.3 - 0.25, cam.position.z - 5);
cam.updateMatrixWorld();
}
_buildMaterials() { _buildMaterials() {
this.blendMaterial = makePassMaterial(BLEND_FRAG, { this.blendMaterial = makePassMaterial(BLEND_FRAG, {
u_base: { value: null }, u_base: { value: null },
@@ -271,7 +440,7 @@ export class Compositor {
reset() { reset() {
const r = this.renderer; const r = this.renderer;
[this.accumA, this.accumB, this.historyA, this.historyB, [this.accumA, this.accumB, this.historyA, this.historyB,
this.bloomA, this.bloomB, this.layerTarget, this.outputTarget] this.bloomA, this.bloomB, this.layerTarget, this.vizTarget, this.outputTarget]
.forEach((t) => r.clear(t)); .forEach((t) => r.clear(t));
} }
@@ -291,7 +460,90 @@ export class Compositor {
? this.layers.slice(this.soloIndex, this.soloIndex + 1) ? this.layers.slice(this.soloIndex, this.soloIndex + 1)
: this.layers; : this.layers;
for (const layer of active) { // --- venue RTT path -------------------------------------------------
// When a venue layer is present, the viz must appear ON its screen, not
// composited over it. So: non-venue layers composite into vizTarget,
// the viz texture is bound onto each venue screen, then only venues
// (+ OSD) render fullscreen into layerTarget. OSD rides the fullscreen
// list so it is never baked into the viz texture. Without a venue this
// reduces to the old loop (everything renders via layerTarget).
// OSD detection must not depend on minification: constructor.name does
// not survive a production build. The OSD module is the one layer with
// no family/kind/venue — identify it structurally instead.
const isOSD = (l) => !l.module || (!l.module.kind && !l.module.family && !l.module.venue);
const venueActive = active.filter((l) => l.module && l.module.venue && l.opacity > 0.001);
const hasVenue = venueActive.length > 0;
// The plate belongs on the main screen only: keep it out of the viz
// texture (it would render miniature on every venue screen) and let it
// ride the fullscreen list instead.
const vizLayers = hasVenue ? active.filter((l) => !(l.module && l.module.venue) && !isOSD(l)) : active;
const fsLayers = hasVenue
? active.filter((l) => (l.module && l.module.venue) || isOSD(l))
: active;
if (hasVenue) {
// Composite the viz stack into vizTarget, alternating with spare
// (accumB — still untouched at this point; the fullscreen chain
// below clears accumA first and fully overwrites spare on its first
// blend, so borrowing it here is safe). layerTarget stays the
// per-layer render scratch. A fullscreen blit overwrites its whole
// target, so no clear is needed before each blend.
// Clear to the venue's wash, not black: the shot keys over the
// room's own colour, so the screen shows shot-over-room rather
// than the forbidden shot-floating-on-black. The wash arcs with
// venueState (it is read off live room materials), so it carries
// slow travel while calming section jumps. See Layer.washColor.
{
const wash = venueActive.length ? venueActive[0].washColor() : [0, 0, 0];
r.clear(this.vizTarget, wash[0], wash[1], wash[2], 1);
}
let accumV = this.vizTarget;
let spareV = spare;
for (const layer of vizLayers) {
if (layer.opacity <= 0.001) continue;
r.clear(this.layerTarget);
// prevTexture feeds prev()-style feedback sampling. The viz
// composite is its own chain, so hand it the viz accumulator —
// never the fullscreen history, which the venue screens below
// would otherwise smear back into themselves.
layer.render(r, this.layerTarget, {
timeline,
features,
prevTexture: accumV.texture,
});
const bu = this.blendMaterial.uniforms;
bu.u_base.value = accumV.texture;
bu.u_src.value = this.layerTarget.texture;
bu.u_amount.value = 1.0; // layer opacity already applied in-shader
bu.u_mode.value = BLEND_MODE_IDS[layer.blend] ?? 0;
r.blit(this.blendMaterial, spareV);
const t = accumV; accumV = spareV; spareV = t;
}
// The finished viz must live in vizTarget for the bind below.
if (accumV !== this.vizTarget) {
this.copyMaterial.uniforms.u_tex.value = accumV.texture;
r.blit(this.copyMaterial, this.vizTarget);
}
// Bind AFTER the viz composite is complete: every venue screen
// samples the finished viz, and binding is per-layer (each venue
// owns its materials, never shared).
for (const venue of venueActive) {
try {
if (typeof venue.bindVizTexture === 'function') {
venue.bindVizTexture(this.vizTarget.texture);
} else if (venue.instance && typeof venue.module.bindVizTexture === 'function') {
venue.module.bindVizTexture(venue.instance, this.vizTarget.texture);
}
} catch (err) {
console.warn('[compositor] viz bind failed for', venue.module && venue.module.name, err);
}
}
}
for (const layer of (hasVenue ? fsLayers : active)) {
if (layer.opacity <= 0.001) continue; if (layer.opacity <= 0.001) continue;
r.clear(this.layerTarget); r.clear(this.layerTarget);
@@ -391,7 +643,7 @@ export class Compositor {
} }
disposeTargets() { disposeTargets() {
[this.layerTarget, this.accumA, this.accumB, this.historyA, this.historyB, [this.layerTarget, this.vizTarget, this.accumA, this.accumB, this.historyA, this.historyB,
this.bloomA, this.bloomB, this.outputTarget, this.bloomA, this.bloomB, this.outputTarget,
this.sharedDepthTarget].forEach((t) => t && t.dispose()); this.sharedDepthTarget].forEach((t) => t && t.dispose());
this.sharedDepthTarget = null; this.sharedDepthTarget = null;
+47
View File
@@ -145,6 +145,7 @@ export class Layer {
this.blend = blend; this.blend = blend;
this.palette = []; this.palette = [];
this.personality = null; this.personality = null;
this.venueState = null;
} }
/** /**
@@ -374,6 +375,7 @@ export class ModelLayer extends Layer {
this.camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 200); this.camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 200);
this.camera.position.set(0, 0, 5); this.camera.position.set(0, 0, 5);
this.actorSpec = options.actorSpec || null; this.actorSpec = options.actorSpec || null;
this.venueSpec = options.venueSpec || null;
// Shared rig injected by Compositor at render time when available. // Shared rig injected by Compositor at render time when available.
this.sharedCamera = null; this.sharedCamera = null;
this.instance = this.module.build({ this.instance = this.module.build({
@@ -382,10 +384,48 @@ export class ModelLayer extends Layer {
seed: this.seed, seed: this.seed,
params: this.baseParams, params: this.baseParams,
actorSpec: this.actorSpec, actorSpec: this.actorSpec,
venueSpec: this.venueSpec,
THREE, THREE,
}); });
} }
/** Allow the look to swap the venue without rebuilding the layer. */
setVenue(venueSpec) {
this.venueSpec = venueSpec || null;
return this;
}
/**
* Bind the composited viz texture onto this layer's screen (venue RTT).
* No-op unless the module implements bindVizTexture(instance, texture).
* Delegating keeps venue materials owned by the venue module — the
* compositor never touches venue meshes directly.
*/
bindVizTexture(texture) {
if (this.module && typeof this.module.bindVizTexture === 'function' && this.instance) {
this.module.bindVizTexture(this.instance, texture);
}
return this;
}
/**
* Base wash for the viz composite. The shot is keyed over this colour
* instead of over black: a shot floating on black is the composition
* failure the phase 12 rules forbid, miniaturised onto the venue screen —
* and it measured ~5x the no-venue section distance (sparse bright things
* on nothing maximise layout/region jumps). Defaults to a dark neutral;
* venue modules override via washColor(instance) to match their room.
*/
washColor() {
try {
if (this.module && typeof this.module.washColor === 'function' && this.instance) {
const c = this.module.washColor(this.instance);
if (Array.isArray(c) && c.length >= 3) return [c[0], c[1], c[2]];
}
} catch { /* fall through to default */ }
return [0.03, 0.03, 0.04];
}
/** Allow the look to swap the actor without rebuilding the layer. */ /** Allow the look to swap the actor without rebuilding the layer. */
setActor(actorSpec) { setActor(actorSpec) {
this.actorSpec = actorSpec || null; this.actorSpec = actorSpec || null;
@@ -418,6 +458,13 @@ export class ModelLayer extends Layer {
framing: this.framing, framing: this.framing,
opacity: this.opacity, opacity: this.opacity,
actorSpec: this.actorSpec, actorSpec: this.actorSpec,
venueSpec: this.venueSpec,
venueState: this.venueState,
// Song-level arrangement salt (see ArcDriver.venueJumpAt): which
// dressing of the room is live. Null most of the time; the module
// re-hangs only when it changes. Plain field, not on Layer base —
// only venue modules read it.
venueArrangement: this.venueArrangement ?? null,
THREE, THREE,
}); });
renderer.renderScene(this.scene, cam, target, true); renderer.renderScene(this.scene, cam, target, true);
+31 -6
View File
@@ -9,12 +9,37 @@ import { VERTEX_SHADER } from './shader-contract.js';
*/ */
export class Renderer { export class Renderer {
constructor({ width = 1280, height = 720, canvas = null } = {}) { constructor({ width = 1280, height = 720, canvas = null } = {}) {
this.gl = new THREE.WebGLRenderer({ // three@0.181 requires WebGL2. When the browser blocks it (disabled
canvas: canvas || undefined, // pref/policy, headless shell without SwiftShader), the constructor
antialias: false, // we render through targets; MSAA here buys nothing // throws — historically a blank page with only a console error, since
preserveDrawingBuffer: true, // required to read pixels back for hashing/export // Show is built at module scope in main.js. Catch it here and rethrow
powerPreference: 'high-performance', // a message that names the cause and the fix, so every caller (app,
}); // gallery, filmstrip, venue-gallery) can surface it instead of dying
// silent. this.gl stays null so dispose()/getters degrade safely.
let gl = null;
try {
gl = new THREE.WebGLRenderer({
canvas: canvas || undefined,
antialias: false, // we render through targets; MSAA here buys nothing
preserveDrawingBuffer: true, // required to read pixels back for hashing/export
powerPreference: 'high-performance',
});
} catch (err) {
const reason = err && err.message ? String(err.message).slice(0, 300) : String(err);
throw new Error(
`WebGL2 unavailable — the renderer could not create a WebGL context (${reason}). ` +
`Enable WebGL2 in this browser (Firefox: webgl.allow-webgl2) or use Chromium/Chrome ` +
`with hardware GL or SwiftShader (--enable-unsafe-swiftshader, without --disable-gpu).`,
);
}
if (!gl || !gl.getContext()) {
throw new Error(
'WebGL2 unavailable — the renderer got no GL context back. ' +
'Enable WebGL2 in this browser (Firefox: webgl.allow-webgl2) or use Chromium/Chrome ' +
'with hardware GL or SwiftShader (--enable-unsafe-swiftshader, without --disable-gpu).',
);
}
this.gl = gl;
this.gl.autoClear = false; this.gl.autoClear = false;
this.gl.setPixelRatio(1); // never device-dependent: output size is explicit this.gl.setPixelRatio(1); // never device-dependent: output size is explicit
this.gl.setSize(width, height, false); this.gl.setSize(width, height, false);
+338 -3
View File
@@ -5,6 +5,7 @@ import { paletteShiftAt } from './paletteArc.js';
import { shiftPalette, lerpPalettes } from './palette.js'; import { shiftPalette, lerpPalettes } from './palette.js';
import { frameShot, neutralFraming } from './framing.js'; import { frameShot, neutralFraming } from './framing.js';
import { planGaze, gazeAt } from './Camera.js'; import { planGaze, gazeAt } from './Camera.js';
import { planVenueShots, venueShotAt, describeVenueShot } from './venueShots.js';
import { storyStateAt, NEUTRAL_STATE } from './Story.js'; import { storyStateAt, NEUTRAL_STATE } from './Story.js';
import { subjectIndexOf, isGround } from './stack.js'; import { subjectIndexOf, isGround } from './stack.js';
import { groundPersonalityFrom } from '../scenes/surface.js'; import { groundPersonalityFrom } from '../scenes/surface.js';
@@ -49,6 +50,7 @@ export class ArcDriver {
? look.framing : null; ? look.framing : null;
this._planFraming(); this._planFraming();
this._planGaze(); this._planGaze();
this._planVenueShots();
this._planPalette(); this._planPalette();
this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null }; this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
} }
@@ -95,7 +97,48 @@ export class ArcDriver {
_planGaze() { _planGaze() {
if (!this.look.camera) return; if (!this.look.camera) return;
const rng = new Rng((this.look.seed ^ 0x2f9c1d4b) >>> 0); const rng = new Rng((this.look.seed ^ 0x2f9c1d4b) >>> 0);
this.gaze = planGaze(this.cues, this.look.sections, this.look.camera, rng); // Venue story jumps force a gaze relocate on the same cue, so the viz
// cut covers the room re-hang. Computed from the same venueJumpAt the
// renderer reads — one decision, two expressions.
let force = null;
if (this.look && this.look.venue && this.look.venue.rearrangeAt) {
force = new Set();
for (const cue of this.cues) {
if (this.venueJumpAt(cue)) force.add(cue.index);
}
if (!force.size) force = null;
}
this.gaze = planGaze(this.cues, this.look.sections, this.look.camera, rng, force);
}
/**
* Plan the ROOM camera's shots across the whole video.
*
* After gaze, for the same reason: the room's shot sizes lean on the same
* framing style the viz uses, and a seek must find the same mark playback
* does. No venue (or a hand-built look) → no plan; the room camera falls
* back to its traverse path (see Compositor._updateVenueCamera).
*/
_planVenueShots() {
const venue = this.look && this.look.venue;
if (!venue || !venue.module) { this.venueShots = null; return; }
// The room plans with an unlocked style even on a locked track: locked
// is the viz's decision to never change size, and a locked-off screen
// inside a room that still pushes wide→close is a legitimate combination
// (the screen holds still; the house moves). Pinning the room too froze
// every dolly on locked tracks.
const style = this.look.framing && this.look.framing.mode === 'locked'
? { ...this.look.framing, mode: 'gentle', range: 0.45, changeChance: 0.35 }
: this.look.framing;
const rng = new Rng((this.look.seed ^ 0x6d1f4a77) >>> 0);
// The viz gaze move per cue, so the room shot inherits a fraction of
// the viz camera's relocation: the two cameras cut in the same
// direction, and the room keeps the per-cue variation the traverse era
// got from the gaze coupling (dropping it flattened everything).
const gazeFrom = (this.gaze || []).map((m) => (m && m.from) || [0, 0]);
this.venueShots = planVenueShots(
this.cues, this.look.sections, venue.archetype,
style, (cue) => this.venueJumpAt(cue), rng, gazeFrom);
} }
/** /**
@@ -221,37 +264,110 @@ export class ArcDriver {
return stack[slot] || null; return stack[slot] || null;
} }
/** Resolve the VenueSpec for the venue ground. */
_venueFor() {
// Venue is the track's place — one per track. Return the Look's venue when requested.
return (this.look && this.look.venue) ? this.look.venue : null;
}
/** Resolve the ActorSpec for a layer, if the module requests one. */ /** Resolve the ActorSpec for a layer, if the module requests one. */
_actorFor(module) { _actorFor(module) {
const actors = this.look.actors; const actors = this.look.actors;
if (!actors || !module || !module.actor) return null; if (!actors || !module) return null;
// Venues are furnished with the track's monolith body: the room's
// memories / pylons are the same character as the viz hero, which is
// what makes the venue read as this track's place rather than a set.
if (module.venue) return actors.monolith || null;
if (!module.actor) return null;
return actors[module.actor] || null; return actors[module.actor] || null;
} }
/** One Layer per (section, variant, layer slot), built lazily and kept. */ /** One Layer per (section, variant, layer slot), built lazily and kept. */
_layerFor(sectionIndex, variant, slot = 0) { _layerFor(sectionIndex, variant, slot = 0) {
const spec = this._specFor(sectionIndex, variant, slot);
// The venue is the exception: one persistent layer for the whole song,
// not one per section. A per-section venue rebuilds the room at every
// boundary and dissolves old-room into new-room — two offset copies of
// the same furniture ghosting through each other, which reads as a
// glitch rather than a cut. See _venueLayer. (Hand-built looks with a
// venue module in a stack but no look.venue fall through to the normal
// path — persistence needs the track-level spec.)
if (spec.module && spec.module.venue && this.look && this.look.venue && this.look.venue.module) {
return this._venueLayer();
}
const key = `${sectionIndex}:${variant}:${slot}`; const key = `${sectionIndex}:${variant}:${slot}`;
let layer = this.layerCache.get(key); let layer = this.layerCache.get(key);
if (!layer) { if (!layer) {
const spec = this._specFor(sectionIndex, variant, slot);
const actorSpec = this._actorFor(spec.module); const actorSpec = this._actorFor(spec.module);
const venueSpec = spec.module && spec.module.venue ? this._venueFor() : null;
layer = createLayer(spec.module, { layer = createLayer(spec.module, {
params: spec.params, params: spec.params,
seed: spec.seed, seed: spec.seed,
opacity: spec.opacity, opacity: spec.opacity,
blend: spec.blend, blend: spec.blend,
actorSpec, actorSpec,
venueSpec,
}); });
// For venue grounds, also bind venueSpec as actorSpec-like for geometry
layer.setPalette(this.look.palette); layer.setPalette(this.look.palette);
layer.setPersonality(this.look.personality); layer.setPersonality(this.look.personality);
// ModelLayers can have their actor swapped without being rebuilt — the // ModelLayers can have their actor swapped without being rebuilt — the
// mesh is imposter-free so the geometry can be re-bound live. // mesh is imposter-free so the geometry can be re-bound live.
if (actorSpec && layer.setActor) layer.setActor(actorSpec); if (actorSpec && layer.setActor) layer.setActor(actorSpec);
if (venueSpec && layer.setVenue) layer.setVenue(venueSpec);
this.layerCache.set(key, layer); this.layerCache.set(key, layer);
} }
return layer; return layer;
} }
/**
* The venue layer: exactly one per ArcDriver, living for the whole song.
*
* Its params and seed come from the track-level venue.params sampled in
* generateLook — never re-sampled per section — so the furniture layout is
* the layout the song opened with. Per-section evolution arrives only as
* venueState (interpolated rest values, see _venueStateAt) plus the
* arrangement salt (see venueJumpAt): same objects, new dressing, and only
* ever with a camera cut to cover the swap.
*
* Keyed 'venue' in the layer cache, outside the section:v:slot namespace,
* so invalidateSection can never dispose it out from under a crossfade.
*/
_venueLayer() {
let layer = this.layerCache.get('venue');
if (!layer) {
const venue = this._venueFor();
const spec = this._venueSpec();
const actorSpec = this._actorFor(venue.module);
layer = createLayer(venue.module, {
params: (venue && venue.params) || (spec && spec.params) || {},
seed: (venue && Number.isInteger(venue.seed) ? venue.seed : null)
?? (spec && spec.seed) ?? 1,
opacity: 1,
blend: 'normal',
actorSpec,
venueSpec: venue,
});
layer.setPalette(this.look.palette);
layer.setPersonality(this.look.personality);
if (actorSpec && layer.setActor) layer.setActor(actorSpec);
if (venue && layer.setVenue) layer.setVenue(venue);
this.layerCache.set('venue', layer);
}
return layer;
}
/** The ground-slot spec of the first section that stands on the venue. */
_venueSpec() {
for (const section of this.look.sections || []) {
for (const stack of section.variants || [section.layers]) {
const at = (stack || []).findIndex((l) => l && l.module && l.module.venue);
if (at >= 0) return stack[at];
}
}
return null;
}
/** /**
* Per-param LFO plan for a section: amplitude, period and phase, all seeded. * Per-param LFO plan for a section: amplitude, period and phase, all seeded.
* Slow enough to read as evolution rather than wobble — 20 to 70 seconds. * Slow enough to read as evolution rather than wobble — 20 to 70 seconds.
@@ -447,6 +563,92 @@ export class ArcDriver {
return out; return out;
} }
/**
* Base params for the persistent venue layer at a given time.
*
* The base is the track-level venue.params — one draw for the whole song —
* plus the same slow axis and drift every other layer gets. Deliberately
* NOT the per-section ground spec: re-sampling the room per section is the
* furniture swap this change kills. Drift and slow axis use a fixed
* section/variant/slot address (0:0:0) so the plan is stable no matter
* which cue is on screen; the values are continuous in time, so nothing
* jumps at a boundary. Lookahead is skipped — the room anticipates nothing,
* it arrives (see _venueStateAt).
*/
_venueParamsAt(cue, time, features, story = null) {
const venue = this.look && this.look.venue;
const base = (venue && venue.params) || {};
const out = { ...base };
const module = venue ? venue.module : null;
if (!module) return out;
let journey;
if (story) journey = story.journey;
else {
const duration = Math.max(1e-6, this.track.duration);
const p = Math.max(0, Math.min(1, time / duration));
journey = p * p * (3 - 2 * p);
}
for (const item of this._slowAxisFor(module)) {
const b = out[item.name];
if (typeof b !== 'number') continue;
out[item.name] = clampValue(item.def, b + item.travel * (journey - 0.5));
}
for (const item of this._driftPlan(0, 0, 0)) {
const b = out[item.name];
if (typeof b !== 'number') continue;
const wave = Math.sin(2 * Math.PI * (time / item.period + item.phase));
out[item.name] = clampValue(item.def, b + wave * item.amplitude);
}
void cue; void features;
return out;
}
/**
* Framing for the persistent venue layer: the room's own shot, not the
* viz's. Served from the per-cue venue shot plan (see _planVenueShots):
* each cue eases to its own mark — size steps and look targets with a
* motive — instead of holding one framing for the whole song. A story jump
* still cuts instantly to the new arrangement's side (the furniture
* swapped; an eased move would glide through the old layout). Pure f(cue,
* frame) like _framingAt. No plan (no venue, hand-built look) → neutral.
*/
_venueFramingAt(cueIndex, frame) {
const cue = this.cues[cueIndex];
const jump = this.venueJumpAt(cue);
// Marks are per-cue for a dense look path, but size/target hold across
// each section's phrases and step at story beats. The dolly eases over
// the cue so size cuts read AS cuts; the look travels by progress.
if (this.venueShots && this.venueShots.length && cue) {
const frames = this.track.frameCount || 1;
const progress = frames > 1 ? frame / (frames - 1) : 0;
const shot = venueShotAt(this.venueShots, cueIndex, frame,
cue.startFrame, cue.endFrame, progress);
if (shot) {
return {
size: `venue-${shot.size}`,
scale: 1,
shift: [0, 0],
venueShot: shot,
venueJump: jump ? jump.arrangement : null,
};
}
}
// Fallback: the old hold/jump grammar (also what covers hand-built
// looks that never got a plan).
if (jump) {
const h = ((this.look.seed || 1) ^ Math.imul(jump.arrangement + 1, 0x9e3779b9)) >>> 0;
const side = (h % 2 === 0) ? 1 : -1;
return {
size: 'venue-jump',
scale: 1,
shift: [side * 0.55, ((h >> 8) % 100) / 100 * 0.2 - 0.1],
venueJump: jump.arrangement,
};
}
return { size: 'venue-hold', scale: 1, shift: [0, 0] };
}
/** /**
* The buildSlope value on the frame before a boundary. Read from the table * The buildSlope value on the frame before a boundary. Read from the table
* rather than remembered, so a seek and playback agree. * rather than remembered, so a seek and playback agree.
@@ -650,6 +852,28 @@ export class ArcDriver {
const layers = []; const layers = [];
// The venue is one continuous place, not a per-section image: the
// persistent venue layer carries over across every boundary, and is
// NEVER part of the outgoing/incoming dissolve. Dissolving old-room
// into new-room is the glitch this change kills — two offset copies of
// the same furniture ghosting through each other. The room drifts via
// venueState; it only ever jumps with a camera cut (see venueJumpAt).
const venueJump = this.venueJumpAt(cue);
const venueState = (this.look && this.look.venue) ? this._venueStateAt(frame) : null;
const pushVenue = (spec, layer, params, venueFraming, opacity) => {
layer.setParams(params);
layer.opacity = opacity;
layer.blend = 'normal';
layer.setPalette(palette);
layer.setPersonality(groundPersonalityFrom(personality));
layer.setFraming(venueFraming);
layer.venueState = venueState;
// Arrangement salt: same objects, new dressing — applied on the
// jump frame itself, under the camera cut. Null most of the time.
layer.venueArrangement = venueJump ? venueJump.arrangement : null;
layers.push(layer);
};
if (fading) { if (fading) {
// buildSlope is discontinuous at a section boundary by construction: // buildSlope is discontinuous at a section boundary by construction:
// it ramps to ~1 through the bars before the change and is 0 // it ramps to ~1 through the bars before the change and is 0
@@ -665,6 +889,10 @@ export class ArcDriver {
for (let slot = 0; slot < this._stackSize(previous); slot++) { for (let slot = 0; slot < this._stackSize(previous); slot++) {
const spec = this._specFor(previous.sectionIndex, previous.variant, slot); const spec = this._specFor(previous.sectionIndex, previous.variant, slot);
// Venue slot of the outgoing stack: skip it here, the persistent
// layer is pushed once below. (Its pre-jump dressing, if any, is
// already behind us — the jump lands on the cue frame.)
if (spec && spec.module && spec.module.venue) continue;
const layer = this._layerFor(previous.sectionIndex, previous.variant, slot); const layer = this._layerFor(previous.sectionIndex, previous.variant, slot);
layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures, story)); layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures, story));
layer.opacity = slot === 0 ? 1 : spec.opacity; layer.opacity = slot === 0 ? 1 : spec.opacity;
@@ -676,9 +904,17 @@ export class ArcDriver {
} }
} }
// Incoming stack. Slot 0 is the venue ground when the venue is on: the
// persistent layer takes its place at full opacity, undissolved. Every
// other slot dissolves in over it exactly as before.
const venueFraming = this._venueFramingAt(cueIndex, frame);
for (let slot = 0; slot < this._stackSize(cue); slot++) { for (let slot = 0; slot < this._stackSize(cue); slot++) {
const spec = this._specFor(cue.sectionIndex, cue.variant, slot); const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
const layer = this._layerFor(cue.sectionIndex, cue.variant, slot); const layer = this._layerFor(cue.sectionIndex, cue.variant, slot);
if (spec && spec.module && spec.module.venue) {
pushVenue(spec, layer, this._venueParamsAt(cue, time, features, story), venueFraming, 1);
continue;
}
layer.setParams(this._paramsAt(cue, slot, time, features, story)); layer.setParams(this._paramsAt(cue, slot, time, features, story));
layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1); layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
layer.blend = slot === 0 ? 'normal' : spec.blend; layer.blend = slot === 0 ? 'normal' : spec.blend;
@@ -707,12 +943,95 @@ export class ArcDriver {
tension: story.tension, tension: story.tension,
reveal: story.reveal, reveal: story.reveal,
journey: story.journey, journey: story.journey,
venueName: this.look && this.look.venue ? this.look.venue.name : null,
venueArchetype: this.look && this.look.venue ? this.look.venue.archetype : null,
venueState,
venueShot: venueFraming && venueFraming.venueShot ? {
size: venueFraming.venueShot.size,
target: venueFraming.venueShot.target,
} : null,
}; };
this.activeLayers = layers; this.activeLayers = layers;
return layers; return layers;
} }
/**
* Per-venue story state at frame — INTERPOLATED between section rests.
*
* The old version stepped discretely at each boundary, so the room's light,
* fog and population all jumped on the same frame the viz cut. The room
* must drift while the viz cuts: rest values come from
* VenueSpec.perSection, and the first half of each section eases from the
* previous section's rest to this one's. Pure f(frame) — a seek lands on
* the same blend as playback, and the midpoint of a long section is fully
* arrived (no endless slide).
*/
_venueStateAt(frame) {
const venue = this.look && this.look.venue;
if (!venue || !venue.perSection || !venue.perSection.length) return null;
const idx = this._venueSectionIndexAt(frame);
const cur = venue.perSection[idx] || venue.perSection[0];
if (idx <= 0) return { ...cur };
const prev = venue.perSection[idx - 1] || cur;
const sec = (this.look.sections || [])[idx];
let t = 1;
if (sec && sec.endFrame > sec.startFrame) {
// Arrive over the first half of the section, hold the rest.
t = Math.max(0, Math.min(1, (frame - sec.startFrame) / ((sec.endFrame - sec.startFrame) * 0.5)));
t = t * t * (3 - 2 * t);
}
const out = {};
for (const k of Object.keys(cur)) {
const a = prev[k], b = cur[k];
out[k] = (typeof a === 'number' && typeof b === 'number') ? a + (b - a) * t : b;
}
return out;
}
/** Section index owning a frame, for venue state and jumps. */
_venueSectionIndexAt(frame) {
const venue = this.look && this.look.venue;
const n = (venue && venue.perSection && venue.perSection.length)
|| (this.look.sections || []).length;
const story = this.look.story;
if (story && story.frames && story.frames.length) {
for (let i = Math.min(story.frames.length, n) - 1; i >= 0; i--) {
if (frame >= story.frames[i].startFrame) return Math.min(i, n - 1);
}
return 0;
}
const secs = this.look.sections || [];
for (let i = Math.min(secs.length, n) - 1; i >= 0; i--) {
const sec = secs[i];
if (frame >= sec.startFrame && frame < sec.endFrame) return Math.min(i, n - 1);
}
return 0;
}
/**
* Whether the venue rearranges on this cue, and which arrangement is live.
*
* Returns null when the room only drifts (the common case): furniture
* carries over untouched. Returns {arrangement} at a section-start cue
* whose rearrangeAt value differs from the previous section's — the room
* re-hangs itself, and the caller MUST pair it with a camera cut
* (see _venueFramingAt / Camera.relocatesAt), because an uncovered
* furniture swap is the glitch this whole change exists to kill.
*
* Pure f(cue): arrangement ids come from VenueSpec.rearrangeAt, decided at
* generation time. The outro returns to arrangement 0 (the intro's room).
*/
venueJumpAt(cue) {
const venue = this.look && this.look.venue;
if (!venue || !venue.rearrangeAt || !venue.rearrangeAt.length) return null;
if (!cue || !cue.atSectionStart || cue.sectionIndex <= 0) return null;
const before = venue.rearrangeAt[cue.sectionIndex - 1] ?? 0;
const at = venue.rearrangeAt[cue.sectionIndex] ?? before;
if (at === before) return null;
return { arrangement: at, from: before };
}
/** Which slot of a cue's stack is the shot. See look/stack.js. */ /** Which slot of a cue's stack is the shot. See look/stack.js. */
_subjectSlot(cue) { _subjectSlot(cue) {
return subjectIndexOf(this._stackFor(cue)); return subjectIndexOf(this._stackFor(cue));
@@ -736,6 +1055,9 @@ export class ArcDriver {
/** Invalidate caches for one section after an edit or reroll. */ /** Invalidate caches for one section after an edit or reroll. */
invalidateSection(sectionIndex) { invalidateSection(sectionIndex) {
for (const key of [...this.layerCache.keys()]) { for (const key of [...this.layerCache.keys()]) {
// The venue layer ('venue') outlives every section: a reroll
// re-casts the section's viz, never the room it stands in.
if (key === 'venue') continue;
if (key.startsWith(`${sectionIndex}:`)) { if (key.startsWith(`${sectionIndex}:`)) {
this.layerCache.get(key).dispose(); this.layerCache.get(key).dispose();
this.layerCache.delete(key); this.layerCache.delete(key);
@@ -746,6 +1068,7 @@ export class ArcDriver {
this.cues = this._buildCues(); this.cues = this._buildCues();
this._planFraming(); this._planFraming();
this._planGaze(); this._planGaze();
this._planVenueShots();
this._planPalette(); this._planPalette();
this._paletteKey = null; this._paletteKey = null;
this._slopeCache = null; this._slopeCache = null;
@@ -757,6 +1080,7 @@ export class ArcDriver {
this.cues = this._buildCues(); this.cues = this._buildCues();
this._planFraming(); this._planFraming();
this._planGaze(); this._planGaze();
this._planVenueShots();
this._planPalette(); this._planPalette();
this._paletteKey = null; this._paletteKey = null;
this._slopeCache = null; this._slopeCache = null;
@@ -777,6 +1101,12 @@ export class ArcDriver {
if (onLayer) onLayer(layer); if (onLayer) onLayer(layer);
} }
} }
// The persistent venue layer is built once here so the first frame
// never pays for its geometry.
if (this.look && this.look.venue && this.look.venue.module) {
const layer = this._venueLayer();
if (onLayer) onLayer(layer);
}
return this; return this;
} }
@@ -821,6 +1151,11 @@ export class ArcDriver {
return this._framingAt(this._cueIndexAt(frame), frame); return this._framingAt(this._cueIndexAt(frame), frame);
} }
/** The room's own framing for a frame — holds across cuts, jumps on story jumps. */
venueFramingForFrame(frame) {
return this._venueFramingAt(this._cueIndexAt(frame), frame);
}
personalityForFrame(frame, story) { personalityForFrame(frame, story) {
const s = story ?? (this.look.story ? storyStateAt(this.look.story, frame) : null); const s = story ?? (this.look.story ? storyStateAt(this.look.story, frame) : null);
return this._personalityAt(s); return this._personalityAt(s);
+7 -2
View File
@@ -303,9 +303,13 @@ function relocatesAt(cue, story, rng) {
* @param {Array} sections look.sections, for bias and story state * @param {Array} sections look.sections, for bias and story state
* @param {object} camera from deriveCamera * @param {object} camera from deriveCamera
* @param {Rng} rng * @param {Rng} rng
* @param {Set} [forceRelocateAt] cue indexes that MUST relocate the gaze —
* venue story jumps (see ArcDriver.venueJumpAt). The room re-hangs on those
* frames, so the viz camera cuts with it: two cuts landing together read as
* one edit, while a room swap under a walking camera reads as a glitch.
* @returns {Array} one move per cue, index-aligned * @returns {Array} one move per cue, index-aligned
*/ */
export function planGaze(cues, sections, camera, rng) { export function planGaze(cues, sections, camera, rng, forceRelocateAt = null) {
const moves = []; const moves = [];
let at = [0, 0]; let at = [0, 0];
let previousDir = null; let previousDir = null;
@@ -329,7 +333,8 @@ export function planGaze(cues, sections, camera, rng) {
// snap this causes lands exactly on a cut, which is where the eye is // snap this causes lands exactly on a cut, which is where the eye is
// least able to see it. // least able to see it.
let from = clampToReach(at, reach); let from = clampToReach(at, reach);
if (moves.length && relocatesAt(cue, story, rng)) { if (moves.length && (relocatesAt(cue, story, rng)
|| (forceRelocateAt && forceRelocateAt.has(cue.index)))) {
const hop = jumpFor(story, !!cue.hardCut, rng) * reach; const hop = jumpFor(story, !!cue.hardCut, rng) * reach;
const jumped = targetFor(from, hop, camera, story, rng, previousDir); const jumped = targetFor(from, hop, camera, story, rng, previousDir);
from = clampToReach(jumped.point, reach); from = clampToReach(jumped.point, reach);
+64 -10
View File
@@ -31,6 +31,7 @@ import { deriveFramingStyle, describeFraming } from './framing.js';
import { deriveCamera, describeCamera } from './Camera.js'; import { deriveCamera, describeCamera } from './Camera.js';
import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.js'; import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.js';
import { generateActorSet, describeActorSet } from '../actors/ActorGenerator.js'; import { generateActorSet, describeActorSet } from '../actors/ActorGenerator.js';
import { generateVenue, describeVenue } from '../venues/VenueGenerator.js';
// Which families suit which section kind now comes from the track's DIRECTOR // Which families suit which section kind now comes from the track's DIRECTOR
// (look/directors.js) rather than from a constant here. The coupling it // (look/directors.js) rather than from a constant here. The coupling it
@@ -248,6 +249,7 @@ function castingPool(rng, signature, size = POOL_SIZE) {
return picked; return picked;
} }
let _activeVenueFamily = null; // set per-track for venue-family lean
function candidatesForKind(kind, used, signature = [], director, pool = null) { function candidatesForKind(kind, used, signature = [], director, pool = null) {
const families = director.families[kind] || Object.keys(FAMILIES); const families = director.families[kind] || Object.keys(FAMILIES);
const allowed = pool ? new Set(pool.map((m) => m.name)) : null; const allowed = pool ? new Set(pool.map((m) => m.name)) : null;
@@ -272,7 +274,8 @@ function candidatesForKind(kind, used, signature = [], director, pool = null) {
// Personality.signatureWeight for why it had to stop being one. // Personality.signatureWeight for why it had to stop being one.
weight: weight * signatureWeight(scene, signature) weight: weight * signatureWeight(scene, signature)
* (used.has(scene.name) ? 0.15 : 1) * (used.has(scene.name) ? 0.15 : 1)
* agitationWeight(scene, comfortCeiling), * agitationWeight(scene, comfortCeiling)
* (_activeVenueFamily && scene.family === _activeVenueFamily ? 0.55 : 1),
}); });
} }
} }
@@ -618,9 +621,11 @@ function contrastWeight(candidate, stack) {
function buildStack(module, overlayRoster, bias, rng, temperament, story = null, function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
{ ground = null, director = null, kind = null } = {}) { { ground = null, director = null, kind = null } = {}) {
const sectionKind = kind; const sectionKind = kind;
// A shot that fills the frame by itself is its own ground; anything else // Venue is always the ground — even a canvas shot stands on its venue.
// gets one under it. // (canGround(venue) is false by design, so the old rule would think a
const standsAlone = canGround(module) || !ground; // canvas viz needs no ground and drop the venue.)
const isVenueGround = !!(ground && ground.venue);
const standsAlone = !isVenueGround && (canGround(module) || !ground);
// --- the blaze ------------------------------------------------------ // --- the blaze ------------------------------------------------------
// Whether THIS section is one the director lets bloom out: the shot added // Whether THIS section is one the director lets bloom out: the shot added
@@ -633,9 +638,13 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
// the director's appetite, times a loud section, times a late point in the // the director's appetite, times a loud section, times a late point in the
// story. Quiet kinds never blaze; a breakdown that goes white is not a // story. Quiet kinds never blaze; a breakdown that goes white is not a
// decision, it is a bug with a rationale. // decision, it is a bug with a rationale.
// A venue rates its own reach: the chamber's dim plaster earns the blaze
// often, the stage's 14-unit emissive wall rarely. Defaults to 1 (the old
// behaviour) for scene grounds, which carry no rating.
const groundReach = isVenueGround && ground.reach !== undefined ? ground.reach : 1;
const blaze = !standsAlone const blaze = !standsAlone
&& !QUIET_KINDS.includes(sectionKind) && !QUIET_KINDS.includes(sectionKind)
&& rng.bool(blazeOf(director) * clamp01(bias.energy * 1.2) && rng.bool(blazeOf(director) * groundReach * clamp01(bias.energy * 1.2)
* (story ? 0.4 + story.tension * 0.9 : 0.7)); * (story ? 0.4 + story.tension * 0.9 : 0.7));
// The shot is sampled first and from the caller's rng, so a stack draws the // The shot is sampled first and from the caller's rng, so a stack draws the
@@ -820,6 +829,7 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
export function generateLook(track, { export function generateLook(track, {
seed = null, samples = null, overrides = null, seed = null, samples = null, overrides = null,
pool: poolOverride = null, poolSize = POOL_SIZE, pool: poolOverride = null, poolSize = POOL_SIZE,
venueEnabled = false,
} = {}) { } = {}) {
const resolvedSeed = seed !== null const resolvedSeed = seed !== null
? seed >>> 0 ? seed >>> 0
@@ -858,8 +868,38 @@ export function generateLook(track, {
// section, and whether the outro answers the intro. See look/Story.js. // section, and whether the outro answers the intro. See look/Story.js.
const story = deriveStory(track, summary, rng.fork('story')); const story = deriveStory(track, summary, rng.fork('story'));
// Venue — the place the track lives. Higher-level than viz, picked first.
// Behind flag in Phase 0 so existing videos/Gates stay green.
//
// The venue's render params are sampled ONCE per track, not per section:
// the room is one continuous place, so its density/light/etc. must be one
// continuous draw. Per-section evolution arrives separately as venueState
// (interpolated rest values, see ArcDriver._venueStateAt), never as a
// re-sample — re-sampling per section is what used to swap the furniture
// out from under the camera at every boundary.
let venue = null;
if (venueEnabled) {
try {
venue = generateVenue({
summary, rng: rng.fork('venue'),
personality, director, story,
sections: track.sections,
});
if (venue && venue.module) {
const vRng = rng.fork(`venue:params:${venue.name}`);
const vBias = { ...biasFor(track.sections[0], summary, personality.motion, storyForSection(story, 0)), energy: 0.55, density: 0.55 };
venue.params = sampleValues(
venue.module, vRng, groundBiasFrom(vBias),
groundTemperamentFrom(personality.temperament));
venue.seed = vRng.int(0, 0x7fffffff);
}
} catch (e) { venue = null; }
}
_activeVenueFamily = venue ? venue.module.family : null;
const rosterByKind = assignRostersByKind( const rosterByKind = assignRostersByKind(
track.sections, rng.fork('scenes'), personality.signature, director, pool); track.sections, rng.fork('scenes'), personality.signature, director, pool);
_activeVenueFamily = null;
if (story.recap) applyRecap(rosterByKind); if (story.recap) applyRecap(rosterByKind);
// What each kind's sections stand on. After the recap, so the outro is // What each kind's sections stand on. After the recap, so the outro is
// grounded against the roster it actually ends up with. // grounded against the roster it actually ends up with.
@@ -906,7 +946,14 @@ export function generateLook(track, {
// so the last occurrence of a kind samples further out than the first. // so the last occurrence of a kind samples further out than the first.
const temperament = temperamentFor(personality.temperament, state); const temperament = temperamentFor(personality.temperament, state);
const ground = groundByKind.get(section.kind) || null; // When the venue is on it IS the ground for every section — one place,
// one module. The ArcDriver renders it as a single persistent layer
// (see _venueLayer), so the per-section ground spec below is only the
// stack slot the venue occupies; its params/seed come from the
// track-level venue.params sampled above, not from here.
const ground = (venueEnabled && venue && venue.module)
? venue.module
: (groundByKind.get(section.kind) || null);
const variants = roster.map((module, v) => buildStack( const variants = roster.map((module, v) => buildStack(
module, overlayRoster, bias, module, overlayRoster, bias,
@@ -940,6 +987,8 @@ export function generateLook(track, {
const look = { const look = {
seed: resolvedSeed, seed: resolvedSeed,
venue,
venueEnabled,
palette, palette,
// The full set the director cuts between — ArcDriver reads this per cue. // The full set the director cuts between — ArcDriver reads this per cue.
palettes, palettes,
@@ -1104,8 +1153,11 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
const state = section.story || NEUTRAL_STATE; const state = section.story || NEUTRAL_STATE;
// The section is re-cast, so its ground is re-cast with it — a reroll that // The section is re-cast, so its ground is re-cast with it — a reroll that
// kept the old bed under new shots would be answering half the question. // kept the old bed under new shots would be answering half the question.
const ground = castGround( // When venue is enabled the venue IS the ground and must not be re-cast per section.
section.kind, roster, rng.fork('ground'), signature, director, new Set()); const isVenue = !!(look.venueEnabled && look.venue && look.venue.module);
const ground = isVenue
? look.venue.module
: castGround(section.kind, roster, rng.fork('ground'), signature, director, new Set());
section.variants = roster.map((module, v) => buildStack( section.variants = roster.map((module, v) => buildStack(
module, overlayRoster, section.bias, rng.fork(`variant:${v}`), module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
temperamentFor(look.personality && look.personality.temperament, state), state, temperamentFor(look.personality && look.personality.temperament, state), state,
@@ -1123,7 +1175,7 @@ export function rerollLook(look, track, newSeed) {
const locked = new Map(); const locked = new Map();
look.sections.forEach((s) => { if (s.locked) locked.set(s.index, s); }); look.sections.forEach((s) => { if (s.locked) locked.set(s.index, s); });
const next = generateLook(track, { seed: newSeed >>> 0 }); const next = generateLook(track, { seed: newSeed >>> 0, venueEnabled: !!look.venueEnabled });
next.sections.forEach((s, i) => { next.sections.forEach((s, i) => {
if (locked.has(i)) next.sections[i] = locked.get(i); if (locked.has(i)) next.sections[i] = locked.get(i);
}); });
@@ -1145,6 +1197,7 @@ function applyOverrides(look, overrides) {
look.palette = look.palettes[0] || look.palette; look.palette = look.palettes[0] || look.palette;
} }
if (overrides.paletteSchemes) look.paletteSchemes = overrides.paletteSchemes; if (overrides.paletteSchemes) look.paletteSchemes = overrides.paletteSchemes;
if (overrides.venue !== undefined) look.venue = overrides.venue;
if (overrides.post) look.post = { ...look.post, ...overrides.post }; if (overrides.post) look.post = { ...look.post, ...overrides.post };
if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback }; if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback };
if (overrides.sections) { if (overrides.sections) {
@@ -1167,10 +1220,11 @@ export function describeLook(look) {
? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}` ? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}`
: ''; : '';
const actorTag = look.actors ? ` · ${describeActorSet(look.actors)}` : ''; const actorTag = look.actors ? ` · ${describeActorSet(look.actors)}` : '';
const venueTag = look.venue ? ` · ${describeVenue(look.venue)}` : '';
return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` + return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` +
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` + `${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` + `${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
`${describeCamera(look.camera)}${planTag}${actorTag} · ` + `${describeCamera(look.camera)}${planTag}${actorTag}${venueTag} · ` +
`${[...new Set(kinds)].join(', ')}`; `${[...new Set(kinds)].join(', ')}`;
} }
+370
View File
@@ -0,0 +1,370 @@
// Where the ROOM camera is, and what it is looking at — per shot, planned once.
//
// The viz has framing (wide/normal/close) and gaze (a continuous path); the
// room camera used to have one slow traverse and stare forward while the body
// glided. So the room gets its own shot plan, with the same two devices as
// the viz: SIZE steps at cuts (a close answered by a wide reads as two shots
// of one place), and a look target with a MOTIVE — the screen when the viz is
// the action, the whole room when the song breathes.
//
// The plan is data, evaluated per frame as a pure function like the gaze. The
// BODY glides on the continuous traverse (the thing that arcs); the HEAD
// follows a slow progress-driven path through the marks' anchors, leaning
// past halfway at whatever the current section is about. A story jump cuts
// instantly to the new arrangement's side instead of easing, because the
// furniture swapped and an eased move would glide through the old layout.
//
// Bounds are per archetype and conservative: the camera must never leave the
// room or enter the furniture. See test/venue.test.js for the box.
const clamp01 = (x) => Math.max(0, Math.min(1, x));
const lerp = (a, b, t) => a + (b - a) * t;
/**
* Room geometry per archetype: where the screen is, where the furniture
* lives, and the box the camera may occupy. Positions are world units in the
* venue module's scene (chamber room is 22 wide, stage field 44).
*/
export const VENUE_STAGING = {
chamber: {
screen: [0, 1.35, -10.9],
fieldL: [-3.2, -0.4, -4.5],
fieldR: [3.2, -0.4, -4.5],
// Room anchor sits OFF the screen axis: a room visit must pan the
// screen aside, or the composition never changes no matter how far the
// body glides (measured: screen-centred room views 0.08 apart at 120 s
// gaps). The side alternates per visit (see placeMark).
roomMid: [0, 0.3, -5],
roomSide: 2.8,
box: { x: 2.4, yLo: -0.2, yHi: 1.4, zLo: 0.8, zHi: 7.0 },
// Dolly per shot size: close sits among the furniture, wide sees the room.
dolly: { wide: 6.2, normal: 4.0, close: 2.0 },
},
stage: {
screen: [0, 2.2, -14.5],
fieldL: [-4.5, 0.0, -6.0],
fieldR: [4.5, 0.0, -6.0],
roomMid: [0, 0.4, -7],
roomSide: 4.2,
box: { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 },
dolly: { wide: 8.2, normal: 4.6, close: 2.4 },
},
// The ride: the "screen" is the dash radio on its stalk at (0, 1.15,
// 0.9), facing +z toward the default camera; the car sits at the origin
// driving toward -z. The camera works the car like a chase unit — orbits,
// push-ins on the radio, pull-backs for the road — inside a box that keeps
// it out of the bodywork.
ride: {
screen: [0, 1.15, 0.9],
fieldL: [-3.4, 0.2, -3.0],
fieldR: [3.4, 0.2, -3.0],
roomMid: [0, 0.3, -6],
roomSide: 3.6,
// Tighter than stage: the car is small, so the camera works closer
// or the vehicle drowns in black road (measured 6% painted wide).
box: { x: 4.0, yLo: -0.4, yHi: 2.2, zLo: -6.0, zHi: 6.0 },
dolly: { wide: 5.5, normal: 3.4, close: 1.8 },
},
};
/** Shot sizes for the room — same three words as the viz, different numbers. */
export const VENUE_SHOT_SIZES = ['wide', 'normal', 'close'];
/**
* Plan one camera mark per cue — but size/target move at SECTION granularity.
*
* Two densities, one plan. The look path needs DENSE anchors (every cue): it
* interpolates through them continuously, and five section anchors over three
* minutes is a static stare. Size and target move per SECTION: the room holds
* its shot across phrases (near probes share the framing) and steps at story
* beats. Per-cue sizes made the room as twitchy as the viz — twenty marks a
* song, every probe on a different scale, all pairs equally far (shuffle).
* Intra-section movement is continuous travel; inter-section movement steps.
* That split is the arc.
*
* @param {Array} cues ArcDriver cues (need index, sectionIndex, atSectionStart, fadeFrames)
* @param {Array} sections look.sections (need story, bias)
* @param {string} archetype 'chamber' | 'stage' | 'ride'
* @param {object} style look.framing style ({mode, range, changeChance}); 'locked' parks the room
* @param {Function} isJump (cue) => jump|null — arrangement jumps (see ArcDriver.venueJumpAt)
* @param {Rng} rng
* @param {Array} [gazeFrom] viz gaze `from` point per cue — the room takes a
* fraction as lateral offset, so both cameras cut the same way
* @returns {Array} one mark per cue ({size, target, pos, look, jump}); size and
* target are constant within a section, pos/look vary per cue
*/
export function planVenueShots(cues, sections, archetype, style, isJump, rng, gazeFrom = null) {
const staging = VENUE_STAGING[archetype] || VENUE_STAGING.chamber;
// Locked pins SIZE (like the viz: a locked track never changes size) but
// never attention — a video that never changes size can still be one whose
// attention moves (see look/Camera.js). Parking the look target too is
// what froze the room on locked tracks.
const locked = !style || style.mode === 'locked';
// The video globally pushes IN: lateral marches failed because mirrored
// views of a symmetric room look alike (near probes differed by target,
// far probes converged — negative direction). A dolly march is
// irreversible — far-apart probes are different scales, guaranteed — while
// per-cue sizes still step around the marching centre so cuts read.
const n = Math.max(1, cues.length);
const marks = [];
let previous = null;
// The room's off-axis home for this video, drawn once: room visits face
// one wall all song (see placeMark). Drawn here — once, before the loop —
// so rng consumo order is seek-stable.
const roomSide = rng.bool(0.5) ? 1 : -1;
// Size/target are decided once per section and carried across its cues;
// pos/look are drawn per cue so the look path stays dense. The streak
// breaker reads sectionTargets (one entry per section): a cue-granularity
// history would break every streak on its third cue, since all cues in a
// section share the target.
let sectionSize = null, sectionTarget = null, sectionIndex = -1;
const sectionTargets = [];
for (const cue of cues) {
const section = sections[cue.sectionIndex] || {};
const story = section.story || {};
const energy = (section.bias && section.bias.energy) ?? 0.5;
const jump = isJump ? isJump(cue) : null;
if (cue.sectionIndex !== sectionIndex) {
sectionIndex = cue.sectionIndex;
const prevMark = marks.length ? marks[marks.length - 1] : null;
sectionSize = locked ? 'normal' : pickSize(prevMark, energy, story, style, rng);
sectionTarget = pickTarget(prevMark, energy, story, rng,
sectionTargets.map((t) => ({ target: t })));
sectionTargets.push(sectionTarget);
}
const progress = n > 1 ? cue.index / (n - 1) : 0.5;
const gaze = gazeFrom && gazeFrom[cue.index] ? gazeFrom[cue.index] : [0, 0];
const mark = placeMark(staging, sectionSize, sectionTarget, jump, rng, progress, gaze, roomSide);
marks.push({
index: cue.index,
size: sectionSize, target: sectionTarget,
pos: mark.pos,
look: mark.look,
jump: jump ? jump.arrangement : null,
// The move eases over the dissolve, except on a jump cut.
easeFrames: jump ? 0 : Math.max(1, cue.fadeFrames || 12),
});
}
return marks;
}
/** Shot size steps at cuts: never repeat, loud leans close, quiet leans wide. */
function pickSize(previous, energy, story, style, rng) {
const closeness = story.closeness ?? 0.5;
// A locked-off track still gets its one size from the same draw.
if (previous && style && !rng.bool(style.changeChance ?? 0.6)) return previous.size;
const act = story.act || 'development';
// Builds tighten toward the hit; the climax lands close; resolutions and
// quiet opens go wide. Multipliers on the energy lean, not overrides — a
// quiet climax can still play wide.
let wideM = 1, closeM = 1;
if (act === 'setup' || act === 'resolution') { wideM = 1.8; closeM = 0.5; }
else if (act === 'climax') { closeM = 1.8; wideM = 0.5; }
const weights = [
(1 + (1 - energy) * 2.5) * (1.4 - closeness * 0.9) * wideM, // wide
2, // normal
(1 + energy * 2.5) * (0.6 + closeness * 0.9) * closeM, // close
];
let size = rng.pickWeighted(VENUE_SHOT_SIZES, weights);
if (previous && size === previous.size) {
size = rng.pick(VENUE_SHOT_SIZES.filter((s) => s !== size));
}
return size;
}
/**
* WHAT the camera looks at — the motive.
*
* screen the viz is the action: face the screen.
* room the song breathes: the whole place.
*
* Deliberately two targets, not three. Furniture visits (fieldL/fieldR) were
* tried and measured as outliers — 0.2+ from every other probe, flattening or
* inverting direction wherever they landed. The screen is the stage, the room
* is the house; the camera does not cut to the audience mid-song. Furniture
* still crosses the frame — the walk passes it — it is just never the mark.
* Rotation between the two (never three in a row) is the coverage.
*/
function pickTarget(previous, energy, story, rng, history) {
const last2 = (history || []).slice(-2).map((m) => m.target);
const streak = last2.length === 2 && last2[0] === last2[1] ? last2[0] : null;
const act = story.act || 'development';
const tension = story.tension ?? 0.5;
let pick;
if (act === 'setup') pick = 'room';
else if (act === 'resolution') pick = rng.bool(0.3) ? 'screen' : 'room';
else if (act === 'climax') pick = 'screen';
else if (act === 'turn') pick = rng.bool(0.75) ? 'screen' : 'room';
else if (energy > 0.66 || tension > 0.7) pick = 'screen';
else if (energy < 0.36) pick = 'room';
else pick = rng.bool(0.5) ? 'screen' : 'room';
if (streak && pick === streak) pick = streak === 'screen' ? 'room' : 'screen';
void previous;
return pick;
}
/** Absolute camera mark for a (size, target): position + look point. */
function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null, roomSide = 1) {
const clampTo = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const { box, dolly } = staging;
// Room visits alternate sides so consecutive room views face opposite
// walls (the screen slides left, then right) instead of both staring down
// the middle. Screen visits stay centred — the screen IS the axis.
let anchor = target === 'screen' ? staging.screen : staging.roomMid;
if (target === 'room') {
// ONE side per video (threaded in as roomSide): alternating walls is a
// zigzag (left-right-left reads as churn, measured flat), while one
// steady side gives the look path a consistent off-axis home it keeps
// returning to. Across videos the side varies by seed.
const side = roomSide || 1;
anchor = [side * (staging.roomSide || 2.5), staging.roomMid[1], staging.roomMid[2]];
}
// Dolly centre marches wide→close across the video (eased: linger wide
// early, arrive late); the shot size steps around it so cuts still read
// while far-apart probes are irreversibly different scales. Lateral
// position alternates sides per visit (seeded) — cross the room, don't
// orbit one side. A jump takes its arrangement side.
const pe = progress * progress * (3 - 2 * progress);
const centreZ = dolly.wide + (dolly.close - dolly.wide) * (0.25 + 0.75 * pe);
const sizeOff = { wide: 0.9, normal: 0, close: -0.9 }[size] ?? 0;
const z = centreZ + sizeOff + rng.range(-0.4, 0.4);
let x;
if (jump) {
const h = (jump.arrangement * 0x9e3779b9) >>> 0;
x = (h % 2 === 0 ? 1 : -1) * box.x * 0.55;
} else {
const side = rng.bool(0.5) ? 1 : -1;
x = side * box.x * rng.range(0.25, 0.65);
}
// Close on the screen stands nearly centre (it fills the frame anyway);
// wides take the sides (parallax over the furniture is the point).
const cx = size === 'close' && target === 'screen' ? x * 0.3 : x;
// A fraction of the viz gaze relocation: both cameras cut the same way.
// Kept small (a third) — full coupling threw the room around per section.
const gx = gaze ? gaze[0] * 0.35 : 0;
const gy = gaze ? gaze[1] * 0.2 : 0;
const pos = [
clampTo(cx + gx, -box.x, box.x),
rng.range(box.yLo, box.yHi) + gy,
clampTo(z, box.zLo, box.zHi),
];
// Look commits to the motive anchor — per target, not one lean for both.
// Screen visits lean 0.55 (the screen shifts in frame but stays the
// subject); room visits lean 0.9, nearly full (the screen slides out and
// the frame becomes wall + furniture — a genuinely different composition).
// A single lean for both kept every probe as "screen + room", which
// brightness-normalization reduces to structural twins (measured: 28% vs
// 68% painted scoring 0.10 apart). NO rng offsets on the anchors:
// per-mark jitter is a random walk the descriptor reads as churn.
// Consecutive anchors must differ ONLY by motive, never by noise.
const screenAnchor = staging.screen;
const forward = [0, 0.3, screenAnchor[2]];
const k = target === 'room' ? 0.9 : 0.55;
const look = [
lerp(forward[0], anchor[0], k),
lerp(forward[1], anchor[1], k),
lerp(forward[2], anchor[2], k),
];
return { pos, look };
}
/**
* Evaluate the plan at a frame: travel from the previous mark to this cue's
* across most of the cue, not just the dissolve.
*
* Easing only over the dissolve leaves the camera HOLDING a well-composed shot
* for the rest of every cue — and held compositions all look alike to a
* structural descriptor (measured: six held screen views 0.06-0.13 apart, flat
* direction). Travelling instead — always on the way to the next mark — keeps
* the composition shifting progressively, which is what direction measures.
* Arrives at 80% of the cue so cuts land on a readable frame, not mid-glide.
* Jump cues cut instantly (the furniture swapped; gliding through the old
* layout is the glitch). Pure in (marks, frame): seeks agree with playback.
*/
/** Dolly factor per room shot size: a close stands nearer, a wide further. */
export const VENUE_SIZE_DOLLY = { wide: 1.3, normal: 1.0, close: 0.72 };
export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame = null, progress = null) {
// Marks are per-cue (size/target constant within a section, pos/look dense).
const cur = marks[cueIndex] || marks[marks.length - 1];
if (!cur) return null;
const prev = cueIndex > 0 ? marks[cueIndex - 1] : null;
// The LOOK travels a continuous path through every mark anchor in order,
// driven by video progress — never by cue. Per-cue look jumps (screen→
// furniture→room) read as churn to the descriptor even when they read as
// coverage to an eye: adjacent probes land on different anchors (big gaps)
// while far probes converge (mirrored compositions). A slow path through
// the same anchors visits everything AND accumulates. Only story jumps cut
// the look (the furniture swapped; the path would glide through the old
// room). The reported target stays the cue's (HUD motive), the look itself
// is where the path is.
const look = lookPathAt(marks, progress, cur.jump !== null ? cur : null);
if (!prev || cur.jump !== null) {
return {
pos: cur.pos.slice(), look,
size: cur.size, target: cur.target,
dolly: VENUE_SIZE_DOLLY[cur.size] ?? 1.0,
};
}
// The travel OUTRUNS the cue (1.25x): the camera is always on the way to
// the next mark and never holds a composition — the gaze `drift` grammar.
// Held compositions all looked alike (flat direction); eased-then-held
// moves made adjacent probes as far apart as distant ones (pan cuts read
// as churn to the descriptor). Travel-portion differences are small up
// close and accumulate far away, which is exactly the gradient direction
// measures. Jump cues still cut instantly (furniture swapped).
const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame);
const t = clamp01((frame - cueStartFrame) / Math.max(1, span * 1.25));
const e = t * t * (3 - 2 * t);
return {
pos: [lerp(prev.pos[0], cur.pos[0], e), lerp(prev.pos[1], cur.pos[1], e), lerp(prev.pos[2], cur.pos[2], e)],
look,
size: t < 1 ? `${prev.size}→${cur.size}` : cur.size,
target: cur.target,
dolly: lerp(VENUE_SIZE_DOLLY[prev.size] ?? 1.0, VENUE_SIZE_DOLLY[cur.size] ?? 1.0, e),
};
}
/**
* Continuous look path: ONE steady sweep across the video, plus local motive.
*
* Interpolating anchors in plan order zigzags (screen-room-screen reads as
* churn: binary targets scattered in time can never correlate with time gap,
* so direction flats no matter the amplitude). Instead the look sweeps
* monotonically — left to right (seeded), rising slightly — while each mark's
* anchor pulls a quarter toward the motive. Far probes always differ (the
* sweep guarantees it); near probes share direction; the motive textures the
* sweep without breaking it. Pure in (marks, progress).
*/
function lookPathAt(marks, progress, jumpMark) {
if (jumpMark) return jumpMark.look.slice();
if (!marks.length) return [0, 0.3, -5];
const p = clamp01(progress ?? 0.5);
// Sweep direction from the plan content (deterministic, varies per video):
// hash the first mark's position so two videos sweep different ways.
const h = marks.length ? (Math.abs(marks[0].pos[0] * 997 + marks[0].pos[1] * 613) | 0) : 0;
const dir = (h % 2 === 0) ? 1 : -1;
const sweepX = (p - 0.5) * 2 * 1.6 * dir;
const sweepY = 0.15 + p * 0.45;
// Nearest mark's anchor for the motive quarter.
const q = p * (marks.length - 1);
const i = Math.min(marks.length - 1, Math.round(q));
const a = marks[i].look;
// The sweep is the look; the anchor bends it a quarter toward the motive.
// Depth stays on the screen plane so the screen never leaves focus depth.
return [
sweepX * 0.75 + a[0] * 0.25,
sweepY * 0.75 + a[1] * 0.25,
a[2],
];
}
/** One line for the HUD. */
export function describeVenueShot(mark) {
if (!mark) return 'room: —';
return `room: ${mark.size} on ${mark.target}`;
}
+32 -1
View File
@@ -52,8 +52,37 @@ const dom = {
exportWarnDontAsk: document.getElementById('ew-dontask'), exportWarnDontAsk: document.getElementById('ew-dontask'),
}; };
// Show owns the single WebGL2 context. three@0.181 throws when the browser
// blocks context creation (WebGL2 disabled, headless shell without
// SwiftShader) — historically a blank page, since this runs at module scope.
// Fail loudly into the boot-error box instead, with the cause and the fix.
function showBootError(detail) {
const box = document.getElementById('boot-error');
const pre = document.getElementById('be-detail');
if (pre) pre.textContent = detail;
if (box) box.hidden = false;
}
function bootShow() {
try {
return new Show({ canvas: dom.canvas, width: 1280, height: 720 });
} catch (err) {
const msg = err && err.message ? err.message : String(err);
showBootError(
'The WebGL renderer could not start, so nothing on this page can run — ' +
'every preview, gallery and check renders through the same context.\n\n' +
msg,
);
// Tell the boot guard the startup story is over (it would otherwise
// report its own generic text, clobbering the specific cause above),
// then stop: a half-running app with no renderer is worse than none.
window.__FLOW_STATE_READY__ = 'failed';
throw err;
}
}
const state = { const state = {
show: new Show({ canvas: dom.canvas, width: 1280, height: 720 }), show: bootShow(),
playing: false, playing: false,
quality: 'full', quality: 'full',
loopSection: -1, loopSection: -1,
@@ -376,6 +405,7 @@ function renderPanel() {
<div class="kv"><span>tempo conf.</span><b>${show.track.tempo.confidence.toFixed(2)}</b></div> <div class="kv"><span>tempo conf.</span><b>${show.track.tempo.confidence.toFixed(2)}</b></div>
<div class="kv"><span>duration</span><b>${formatTime(show.duration)}</b></div> <div class="kv"><span>duration</span><b>${formatTime(show.duration)}</b></div>
<div class="kv"><span>sections</span><b>${show.track.sections.length}</b></div> <div class="kv"><span>sections</span><b>${show.track.sections.length}</b></div>
<div class="kv"><span>venue</span><b>${show.look.venue ? `${show.look.venue.name} (${show.look.venue.archetype})${show.look.venueEnabled ? '' : ' · off'}` : 'off'}${show.arc && show.arc.state && show.arc.state.venueState ? ` · light ${show.arc.state.venueState.light.toFixed(2)} fog ${show.arc.state.venueState.fog.toFixed(2)} pop ${show.arc.state.venueState.popMul.toFixed(2)}` : ''}</b> <a href="#" id="link-venue-toggle" title="Toggle venue (ground) — ?venue=1 enables for this page">${show.look.venueEnabled ? 'disable' : 'enable'}</a> · <a href="/venue-gallery.html" target="_blank">gallery</a></div>
<div class="kv"><span>director</span><b>${show.look.director}${plan ? ` · ${plan.progression}` : ''}</b></div> <div class="kv"><span>director</span><b>${show.look.director}${plan ? ` · ${plan.progression}` : ''}</b></div>
<div class="kv"><span>built on</span><b>${personality.signature.join(' + ') || 'nothing'}</b></div> <div class="kv"><span>built on</span><b>${personality.signature.join(' + ') || 'nothing'}</b></div>
<div class="kv"><span>form</span><b>${personality.shape.sides || 'round'}${ <div class="kv"><span>form</span><b>${personality.shape.sides || 'round'}${
@@ -413,6 +443,7 @@ function renderPanel() {
<div class="hint">Mixes clicks onto the detected beat grid. If they don't sit on <div class="hint">Mixes clicks onto the detected beat grid. If they don't sit on
the beat, tempo detection is wrong and everything downstream inherits it.</div>`; the beat, tempo detection is wrong and everything downstream inherits it.</div>`;
document.getElementById('btn-metronome').addEventListener('click', downloadClickTrack); document.getElementById('btn-metronome').addEventListener('click', downloadClickTrack);
document.getElementById('link-venue-toggle')?.addEventListener('click', (e)=>{ e.preventDefault(); show.setVenueEnabled(!show.look.venueEnabled); renderPanel(); show.seek(show.timeline.frame, {warmup:false}); });
return; return;
} }
+6
View File
@@ -321,6 +321,12 @@ export function validateModule(module) {
if (module.readsDepth && typeof module.readsDepth !== 'boolean') { if (module.readsDepth && typeof module.readsDepth !== 'boolean') {
errors.push(`${id}: \`readsDepth\` must be boolean`); errors.push(`${id}: \`readsDepth\` must be boolean`);
} }
if (module.venue && typeof module.venue !== 'boolean') {
errors.push(`${id}: \`venue\` must be boolean`);
}
if (module.archetype && !['chamber','stage','ride','passage','void'].includes(module.archetype)) {
errors.push(`${id}: unknown venue archetype '${module.archetype}'`);
}
} }
const params = module.params || {}; const params = module.params || {};
+4 -126
View File
@@ -1475,7 +1475,7 @@
] ]
} }
}, },
"Moiré Grid": { "Moir\u00e9 Grid": {
"coverage": 0.327, "coverage": 0.327,
"bedCoverage": 0.02, "bedCoverage": 0.02,
"bedCoverageMean": 0.369, "bedCoverageMean": 0.369,
@@ -2451,128 +2451,6 @@
] ]
} }
}, },
"Pylon Grid": {
"coverage": 0.268,
"bedCoverage": 0.12,
"bedCoverageMean": 0.256,
"variety": 0.125,
"blocks": {
"scale": 0.011,
"orient": 0.12,
"layout": 0.105,
"region": 0.336,
"texture": 0.104,
"motion": 0.074,
"colour": 0.42
},
"readsHistory": false,
"profile": {
"scale": [
0.1378,
0.128,
0.177,
0.2494,
0.3078
],
"orient": [
0.0919,
0.0879,
0.0573,
0.0629,
0.0473,
0.0483
],
"layout": [
0.0369,
0.0666,
0.0609,
0.0395,
0.0947,
0.1125,
0.1056,
0.0906,
0.0531,
0.1186,
0.1214,
0.0614,
0.0092,
0.0106,
0.0114,
0.0069
],
"region": [
-0.2065,
0.0554,
0.122,
0.2793,
0.1446,
0.85,
-0.1406,
0.0724,
0.3754,
0.6814,
0.0554,
0.2293,
0.729,
-0.082,
0.1908,
0.6476,
0.0481,
0.1343,
-0.8264,
-0.0064,
-0.4491,
-0.3436,
-0.2993,
-0.5977,
-0.8201,
0.0117,
-0.855
],
"texture": [
0.5922,
0.1031,
0.1725,
0.0473,
0.2784,
0.3096,
0.0747
],
"colour": [
0.0767,
0.0975,
0.51,
0.2914,
0.0217,
0.0028,
0.4379,
0.1787
],
"motion": [
0.277,
0.1918,
0.1913,
0.1674,
0.1725,
0.0251,
0.0558,
0.0504,
0.0386,
0.0959,
0.0998,
0.1014,
0.1035,
0.0794,
0.1133,
0.1199,
0.0691,
0.0057,
0.0154,
0.0189,
0.008
]
}
},
"Prism Bloom": { "Prism Bloom": {
"coverage": 0.085, "coverage": 0.085,
"bedCoverage": 0.013, "bedCoverage": 0.013,
@@ -5257,7 +5135,7 @@
] ]
} }
}, },
"Kármán Street": { "K\u00e1rm\u00e1n Street": {
"coverage": 0.258, "coverage": 0.258,
"bedCoverage": 0.047, "bedCoverage": 0.047,
"bedCoverageMean": 0.269, "bedCoverageMean": 0.269,
@@ -8550,7 +8428,7 @@
0 0
] ]
}, },
"error": "renders nothing — luminance 0.0012, variance 0.0057. A failed shader compile scores 0.000 on every block; check the console for GLSL errors." "error": "renders nothing \u2014 luminance 0.0012, variance 0.0057. A failed shader compile scores 0.000 on every block; check the console for GLSL errors."
} }
} }
} }
+1 -2
View File
@@ -29,7 +29,7 @@ import { neonCity } from './shader/neon-city.js';
import { flora } from './shader/flora.js'; import { flora } from './shader/flora.js';
import { fireflyDrift } from './shader/firefly-drift.js'; import { fireflyDrift } from './shader/firefly-drift.js';
import { silkRibbon } from './shader/silk-ribbon.js'; import { silkRibbon } from './shader/silk-ribbon.js';
import { pylonGrid } from './shader/pylon-grid.js';
import { prismBloom } from './shader/prism-bloom.js'; import { prismBloom } from './shader/prism-bloom.js';
import { pitchShatter } from './shader/pitch-shatter.js'; import { pitchShatter } from './shader/pitch-shatter.js';
import { auroraVeil } from './shader/aurora-veil.js'; import { auroraVeil } from './shader/aurora-veil.js';
@@ -114,7 +114,6 @@ const MODULES = [
flora, flora,
fireflyDrift, fireflyDrift,
silkRibbon, silkRibbon,
pylonGrid,
prismBloom, prismBloom,
pitchShatter, pitchShatter,
+12 -8
View File
@@ -16,7 +16,11 @@ export const flora = {
traits: ['shape', 'camera', 'style'], traits: ['shape', 'camera', 'style'],
params: { params: {
count: { type: 'float', range: [1, 5], default: 3, uniform: 'u_count', bias: 'density' }, // Cost note: every petal and bloom calls castForm (full SDF assembly),
// so worst case was 5 stalks x (12 stem + 6 petals + 6 blooms) = 120
// SDF evals per pixel — measured ~12x its neighbours. Trimmed to
// 4 x (8 + 4 + 4) = 64: same silhouette, half the cost.
count: { type: 'float', range: [1, 4], default: 3, uniform: 'u_count', bias: 'density' },
height: { type: 'float', range: [0.6, 2.0], default: 1.1, uniform: 'u_height', bias: 'energy' }, height: { type: 'float', range: [0.6, 2.0], default: 1.1, uniform: 'u_height', bias: 'energy' },
stem: { type: 'float', range: [0.008, 0.05], default: 0.02, uniform: 'u_stem' }, stem: { type: 'float', range: [0.008, 0.05], default: 0.02, uniform: 'u_stem' },
sway: { type: 'float', range: [0.05, 0.5], default: 0.2, uniform: 'u_sway', bias: 'motion' }, sway: { type: 'float', range: [0.05, 0.5], default: 0.2, uniform: 'u_sway', bias: 'motion' },
@@ -62,7 +66,7 @@ vec4 scene(vec2 uv, vec2 p) {
// Soft vertical wash behind the plants. // Soft vertical wash behind the plants.
vec3 col = mix(pal(0) * 0.03, pal(1) * 0.06, sat((p.y + 1.0) * 0.5)); vec3 col = mix(pal(0) * 0.03, pal(1) * 0.06, sat((p.y + 1.0) * 0.5));
for (int i = 0; i < 5; i++) { for (int i = 0; i < 4; i++) {
if (i >= int(u_count)) break; if (i >= int(u_count)) break;
float fi = float(i); float fi = float(i);
float s = u_seed + fi * 43.7; float s = u_seed + fi * 43.7;
@@ -72,8 +76,8 @@ vec4 scene(vec2 uv, vec2 p) {
// Stem, sampled so the bend follows the sway curve rather than a chord. // Stem, sampled so the bend follows the sway curve rather than a chord.
float stemMask = 0.0; float stemMask = 0.0;
for (int j = 0; j < 12; j++) { for (int j = 0; j < 8; j++) {
float hj = (float(j) + 0.5) / 12.0; float hj = (float(j) + 0.5) / 8.0;
float cx = baseX + swayAt(hj, s, t) * u_sway; float cx = baseX + swayAt(hj, s, t) * u_sway;
float cy = baseY + hj * height; float cy = baseY + hj * height;
float d = length(p - vec2(cx, cy)); float d = length(p - vec2(cx, cy));
@@ -87,9 +91,9 @@ vec4 scene(vec2 uv, vec2 p) {
float tipY = baseY + height; float tipY = baseY + height;
// Petals up the upper half, alternating sides. // Petals up the upper half, alternating sides.
for (int l = 0; l < 6; l++) { for (int l = 0; l < 4; l++) {
float fll = float(l); float fll = float(l);
float node = 0.45 + floor(fll * 0.5) * 0.12; float node = 0.45 + floor(fll * 0.5) * 0.18;
float side = fract(fll * 0.5) < 0.5 ? -1.0 : 1.0; float side = fract(fll * 0.5) < 0.5 ? -1.0 : 1.0;
float nx = baseX + swayAt(node, s, t) * u_sway; float nx = baseX + swayAt(node, s, t) * u_sway;
float ny = baseY + node * height; float ny = baseY + node * height;
@@ -103,8 +107,8 @@ vec4 scene(vec2 uv, vec2 p) {
// Bloom: a small corona of signature forms around the crown. // Bloom: a small corona of signature forms around the crown.
float bloom = 0.0; float bloom = 0.0;
for (int k = 0; k < 6; k++) { for (int k = 0; k < 4; k++) {
float a = 6.2831853 * (float(k) + 0.5) / 6.0 + s; float a = 6.2831853 * (float(k) + 0.5) / 4.0 + s;
vec2 off = vec2(cos(a), sin(a)) * u_leaf * (0.30 + 0.2 * beat); vec2 off = vec2(cos(a), sin(a)) * u_leaf * (0.30 + 0.2 * beat);
bloom += castForm(p - vec2(tipX, tipY), off, u_leaf * 0.5); bloom += castForm(p - vec2(tipX, tipY), off, u_leaf * 0.5);
} }
-176
View File
@@ -1,176 +0,0 @@
// Structural family: a field of standing pylons receding to the track's
// horizon. The columns converge on a vanishing point, giving real perspective
// depth, and each column carries the song's own object on top, so a hexagonal
// track grows hexagonal pylons. The beat walks a pulse of light down the rows
// rather than flashing the whole frame.
//
// Nothing here is the scene's own invention any more. The crown is the song's
// protagonist and the LEG is a stack of its chorus: a column of bodies holding
// the subject up, rather than the thin fixed-width strut this drew for years.
// That strut was the same object in every video — a scene that has migrated its
// subject and kept its structure is still telling you what IT is, in the half of
// the frame that is structure, and next to a marched crown it read as scaffolding
// left up around a finished building.
//
// The crowns are SOLID. This scene already had the one thing the 3D cast needs
// and most scenes lack — a real depth axis, with near rows large and far rows
// small — so a flat crown was the only part of it drawn without perspective: a
// sticker facing the viewer on top of a receding structure. Marching them puts
// the object in the same space as the field it stands in, and because each
// pylon turns its own crown at its own angle, one field shows the song's form
// from a dozen sides at once. See castSolid.
//
// WHAT THE NUMBERS SAY, since two of them disagree and both are right.
//
// Crop to the bottom third of the frame — the near rows, where the columns are
// tall and the crowns are not — and measure how far apart six identities are:
// 13.4/255 with the old strut, 22.2/255 with the chorus stacked. That is the
// complaint answered. The strut was a fixed width and a fixed shape, so the only
// thing separating one video's structure from another's was the palette.
//
// The whole-frame variety score barely moves for it: 0.1322 to 0.1348 over
// 8 songs x 8 seeds. That is honest rather than disappointing — the crowns
// already dominate a frame-level descriptor, and a measurement averaged over the
// whole image cannot see a change confined to the lower third. It is the reason
// the crop above exists.
//
// The frame score is also still under the flat version's 0.141, and the cause is
// known: a body always fills its own silhouette, where a stamped cast form paints
// anywhere between an outline and a disc depending on whether the identity is
// hollow, and that swing was worth three hundredths of LAYOUT variety. Two
// candidate fixes were tried and both measured flat — keeping the far rows
// stamped (0.132), and running the lit surface through inkPattern (0.132 here,
// and 0.0009 on Effigy). It is the solid itself, and the trade is deliberate:
// the score does not measure a crown that ignores the perspective of the field
// it stands in, or a leg that is the same object in every video.
export const pylonGrid = {
name: 'Pylon Grid',
family: 'structural',
kind: 'fragment',
// Personality: see look/Personality.js.
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
texture: 0.4,
consumes: ['form', 'ink', 'staging'],
traits: ['shape', 'space', 'style'],
params: {
columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density' },
rows: { type: 'float', range: [2, 10], default: 6, uniform: 'u_rows', bias: 'density' },
spread: { type: 'float', range: [0.6, 1.6], default: 1.1, uniform: 'u_spread' },
height: { type: 'float', range: [0.3, 1.4], default: 0.85, uniform: 'u_height', bias: 'energy' },
pulse: { type: 'float', range: [0, 1.2], default: 0.5, uniform: 'u_pulse', bias: 'energy' },
speed: { type: 'float', range: [0.05, 0.8], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },
palette: { type: 'palette', count: 4 },
},
reactive: {
pulse: { feature: 'beat', amount: 0.4, response: 'smooth' },
rows: { feature: 'bandLow', amount: 0.25 },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
float hy = sigHorizonY();
vec3 col = pal(0) * 0.05;
// Whether a crown already owns this pixel. Rows are walked near to far, so
// first-wins is the correct occlusion here rather than merely the cheap one
// — and it bounds a hundred and sixty instances to about one march.
float painted = 0.0;
for (int r = 0; r < 10; r++) {
if (float(r) >= u_rows) break;
float fr = float(r);
// Perspective: the last row sits on the horizon, the first at the bottom
// of the frame, everything between scaling by its depth fraction.
float f = fr / max(u_rows - 1.0, 1.0); // 0 near .. 1 far
float y0 = mix(-1.08, hy + 0.06, f); // row baseline
float scale = mix(1.0, 0.12, f); // perspective shrink
float depthShade = mix(1.0, 0.35, f); // far rows dim
for (int c = 0; c < 16; c++) {
if (float(c) >= u_columns) break;
float fc = float(c);
float cx = (fc / max(u_columns - 1.0, 1.0) - 0.5) * 2.0 * u_spread * scale;
float h = u_height * scale;
float yTop = y0 + h;
// The beat pulse walks a glow down the depth axis, row by row, so the
// light travels rather than strobing the whole frame.
float walk = sat(1.0 - abs(fr - mod(u_beatPhase * 6.0 + t * 0.4, u_rows)));
float inten = (0.35 + 0.65 * walk) * depthShade * (0.7 + 0.3 * u_beat);
// The crown is resolved BEFORE the leg, because the two overlap
// where the object sits on the column and the crown has to win it —
// resolved the other way round, every subject is cut off at the
// waist by the thing holding it up.
//
// The song's object, standing on the pylon.
vec2 crown = vec2(cx, yTop);
float size = (0.06 + 0.2 * scale) * (1.0 + u_beat * 0.4);
vec2 local = (p - crown) / max(size, 1e-3);
if (dot(local, local) < 1.6 && painted < 0.5) {
// Each crown at its own angle, so the field is one form seen
// from many sides rather than one silhouette repeated. The turn
// is slow and per-pylon; the row term keeps a receding line of
// them from presenting the same profile all the way back.
mat3 turn = castTurn(t * 0.6 + fc * 0.9 + fr * 0.4, 0.2 + sin(t + fr) * 0.2);
vec3 n;
float hit = castSolid(local, turn, n);
if (hit > 0.0) {
painted = 1.0;
col = mix(col, castLit(n, vec3(0.0, 0.0, 1.0)) * inten * (0.6 + u_pulse), 0.92);
}
// Its outline in the track's line weight, which is what carries
// the far rows: at twelve percent scale a crown is a few pixels
// and the lit faces are gone before the edge is.
float d = castSDF3(turn * vec3(local, 0.0)) * size;
col += pal(3) * inkStroke(d) * inten * (0.5 + u_pulse);
}
col += pal(int(mod(fr, 4.0))) * exp(-dot(p - crown, p - crown) * 60.0) * u_pulse * depthShade;
// THE LEG: the chorus, stacked. How many members make up a column
// is the song's element size deciding it — a track built of a few
// huge forms stands on three of them, a track of many small ones on
// a ladder of nine — and how WIDE the column is comes from the
// chorus's own proportions, which is why no two songs get the same
// pylon. See Identity.js: this is what the chorus solid is for.
float segs = clamp(floor(2.0 + 3.6 / max(stageScale(), 0.3)), 2.0, 9.0);
float member = max(h / (segs * 2.0), 1e-3);
// The bounding test is the column's own box, so a pixel outside it
// costs two comparisons rather than a march. With a hundred and
// sixty pylons on screen that test is most of the scene's budget.
if (abs(p.x - cx) < member * 1.3 && p.y > y0 - member * 0.3
&& p.y < yTop + member * 0.3 && painted < 0.5) {
float rung = floor((p.y - y0) / (member * 2.0));
float centreY = y0 + (rung + 0.5) * member * 2.0;
vec2 legLocal = vec2(p.x - cx, p.y - centreY) / member;
// Every member at its own angle, so a column is a stack of one
// form seen many ways rather than one form printed nine times.
mat3 legTurn = castTurn(t * 0.35 + rung * 1.1 + fc * 0.5, rung * 0.5);
vec3 ln;
float legHit = castChorusSolid(legLocal, legTurn, ln);
if (legHit > 0.0) {
painted = 1.0;
col = mix(col, castLit(ln, vec3(0.0, 0.0, 1.0))
* mix(inten, 1.0, 0.25) * pal(int(mod(fc, 4.0))) * 1.6, 0.9);
}
col += pal(int(mod(fc, 4.0)))
* inkStroke(castChorus3(legTurn * vec3(legLocal, 0.0)) * member) * inten * 0.8;
}
}
}
// Ground haze and the shared air between here and the horizon.
col = sigAir(col, p, smoothstep(0.0, 1.6, abs(p.y - hy)));
return vec4(inkValue(col), 1.0);
}
`,
};
@@ -1,12 +1,9 @@
// STAGE: Pylon Field 3D — the song's chorus as structure, with depth. // STAGE: Pylon Field 3D — the song's chorus as structure, with depth.
// //
// The flat pylon-grid stamps castSolid/castChorusSolid impostors on a screen-space // A rows×columns grid of chorus-stacked legs (segs from stageScale) with a
// perspective grid (pylon-grid.js:83-168). This is the mesh twin: the same // beat-walk pulse — as BufferGeometry with real occlusion, parallax and scale
// rows×columns grid, the same chorus-stacked legs (segs from stageScale) and the // foreshortening. Crowns are the protagonist assembly via actorToGeometry,
// same beat-walk pulse — now as BufferGeometry with real occlusion, parallax and // legs are chorus rungs, ground is a plane at sigHorizonY level.
// scale foreshortening. Crowns are the protagonist assembly via actorToGeometry,
// legs are chorus rungs, ground is a plane at sigHorizonY level. The palette and
// lattice are the same data the flat stage consumes.
// //
// Analytic motion only: f(t,seed,params,actor.motion). No integration, so seek === // Analytic motion only: f(t,seed,params,actor.motion). No integration, so seek ===
// playback. Camera is the shared rig (Compositor.sharedCamera) driven from // playback. Camera is the shared rig (Compositor.sharedCamera) driven from
+16
View File
@@ -132,7 +132,19 @@ const ROW = (module) => (metadata.scenes && metadata.scenes[module.name]) || nul
*/ */
const ASSUMED_COVERAGE = 0.15; const ASSUMED_COVERAGE = 0.15;
export function isVenue(module) {
return !!(module && module.venue);
}
export function venueCoverage(module) {
// Venues are room/stage planes that fill the frame by construction.
// Hard-coded so they pass GROUND_MIN without a gallery measurement.
if (!isVenue(module)) return coverageOf(module);
return 0.72;
}
export function coverageOf(module) { export function coverageOf(module) {
if (isVenue(module)) return 0.72;
const row = ROW(module); const row = ROW(module);
return row ? row.coverage : ASSUMED_COVERAGE; return row ? row.coverage : ASSUMED_COVERAGE;
} }
@@ -143,6 +155,7 @@ export function coverageOf(module) {
* asks. See GROUND_BIAS for why the two numbers differ so much. * asks. See GROUND_BIAS for why the two numbers differ so much.
*/ */
export function groundCoverageOf(module) { export function groundCoverageOf(module) {
if (isVenue(module)) return 0.72;
const row = ROW(module); const row = ROW(module);
if (!row) return ASSUMED_COVERAGE; if (!row) return ASSUMED_COVERAGE;
if (row.bedCoverageMean !== undefined) return row.bedCoverageMean; if (row.bedCoverageMean !== undefined) return row.bedCoverageMean;
@@ -163,6 +176,7 @@ export function groundCoverageOf(module) {
* an occasional dim intro. So: fills the frame on average, and never vanishes. * an occasional dim intro. So: fills the frame on average, and never vanishes.
*/ */
export function groundFloorCoverageOf(module) { export function groundFloorCoverageOf(module) {
if (isVenue(module)) return 0.62;
const row = ROW(module); const row = ROW(module);
if (!row) return ASSUMED_COVERAGE; if (!row) return ASSUMED_COVERAGE;
return row.bedCoverage === undefined ? groundCoverageOf(module) : row.bedCoverage; return row.bedCoverage === undefined ? groundCoverageOf(module) : row.bedCoverage;
@@ -191,6 +205,8 @@ export function surfaceOf(module) {
* tolerance of 4. * tolerance of 4.
*/ */
export function canGround(module) { export function canGround(module) {
if (isVenue(module)) return false;
if (module && module.readsDepth) return false;
return isMeasured(module) return isMeasured(module)
&& surfaceOf(module) === 'canvas' && surfaceOf(module) === 'canvas'
&& groundCoverageOf(module) >= GROUND_MIN && groundCoverageOf(module) >= GROUND_MIN
+175
View File
@@ -0,0 +1,175 @@
// Pure, no THREE — produces VenueSpec (data, not scene graph).
// Same contract as ActorGenerator: audio tilts centre, forked rng picks inside,
// never Math.random / wall-clock, seek === playback.
import { signatureWeight } from '../look/Personality.js';
import { venues, ARCHETYPES } from './registry.js';
const clamp01 = (x) => Math.max(0, Math.min(1, x));
/**
* Generate one venue — the place the track lives.
* Venue is higher-level than viz: picked first, viz comes second.
* Census-like: registry has traits, picked via signatureWeight.
* @param {object} opts.summary FeatureTrack.summary
* @param {import('../engine/rng.js').Rng} opts.rng forked rng.fork('venue')
* @param {object} [opts.personality] look.personality (signature, shape, space, etc.)
* @param {object} [opts.director] look.director
* @param {object} [opts.story] look.story
* @param {Array} [opts.sections] track.sections (for perSection)
* @returns {object} VenueSpec
*/
export function generateVenue({ summary = {}, rng, personality = null, director = null, story = null, sections = null }) {
if (!rng) throw new Error('generateVenue: rng required');
const signature = personality ? personality.signature : [];
// Census pick — weighted by signature just like scenes
let weights = venues.map((m) => signatureWeight(m, signature));
// Audio tilts archetype centre softly (not a rule): stillness/bpm/flatness → archetype weights handled via trait->signature correlation;
// add mild director lean if present
const s = summary || {};
const stillness = personality && personality.motion ? personality.motion.stillness : 0.5;
const fast = clamp01(((s.bpm ?? 120) - 80) / 80);
const noisy = Math.min(1, (s.meanFlatness ?? 0.2) * 3);
// Sad slow → chamber (stillness high + slow), pumping → stage (fast + noisy),
// driving songs → ride (fast tempo + high energy + low stillness: a song
// that moves wants a vehicle). Tilt, not decide.
const tilt = {};
const drive = clamp01(((s.bpm ?? 120) - 100) / 60) * (1 - stillness * 0.7);
for (let i = 0; i < venues.length; i++) {
const v = venues[i];
let w = weights[i];
if (v.archetype === 'chamber') w *= 0.7 + stillness * 0.8 + (1 - fast) * 0.35;
if (v.archetype === 'stage') w *= 0.6 + fast * 0.6 + noisy * 0.35;
if (v.archetype === 'ride') w *= 0.35 + drive * 1.4;
tilt[v.name] = w;
weights[i] = w;
}
// Ensure at least one weight > 0
const totalW = weights.reduce((a, b) => a + Math.max(0, b), 0);
if (totalW <= 0) weights = venues.map(() => 1);
const picked = rng.pickWeighted(venues, weights);
const seed = rng.int(0, 0x7fffffff);
// Shell — kind & scale vary per picked archetype but stay simple in V1
const shell = {
kind: picked.archetype,
scale: 1,
wallHeight: picked.archetype === 'chamber' ? 6 : picked.archetype === 'ride' ? 2.4 : 3.2,
wallDist: picked.archetype === 'chamber' ? 11 : picked.archetype === 'ride' ? 26 : 20,
};
// Projection — hybrid per archetype. The ride's screen is the car radio:
// small, bright, watched up close.
const projection = picked.archetype === 'chamber'
? { kind: 'window', uvScale: 1, brightness: 0.55 }
: picked.archetype === 'ride'
? { kind: 'radio', uvScale: 1, brightness: 0.9 }
: { kind: 'screen', uvScale: 1, brightness: 0.75 };
// Population — lattice-like, deterministic count/spread/jitter. The ride
// seats actor passengers (2-4), not a crowd.
const latticeKinds = ['grid', 'scatter', 'radial'];
const pRng = rng.fork(`venue:pop:${picked.name}`);
const population = picked.archetype === 'ride'
? { kind: 'row', count: pRng.int(2, 4), spread: pRng.range(0.4, 0.6), jitter: pRng.range(0.05, 0.2) }
: {
kind: pRng.pick(latticeKinds),
count: pRng.int(picked.archetype === 'chamber' ? 3 : 4, picked.archetype === 'chamber' ? 9 : 12),
spread: pRng.range(0.5, 0.85),
jitter: pRng.range(0.15, 0.45),
};
// PaletteMap — which palette entry each kit piece reads (seeded)
const paletteMap = [];
const mapLen = population.count;
for (let i = 0; i < mapLen; i++) paletteMap.push(pRng.int(0, 3));
// Per-section evolution — discrete, from Story (setup/turn/climax/resolution).
// These are the venue's REST values per section: the ArcDriver interpolates
// between them across each boundary (see _venueStateAt), so the room drifts
// instead of stepping. A section may still JUMP the room — see rearrangeAt
// below — but only with a camera cut to cover it.
const n = sections ? sections.length : (story && story.sections ? story.sections.length : 0);
const perSection = [];
for (let i = 0; i < n; i++) {
const state = story && story.sections && story.sections[i] ? story.sections[i] : null;
if (!state) {
perSection.push({ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 });
continue;
}
const act = state.act;
const tension = state.tension ?? 0.5;
let light = 0.65;
let fog = 0.35;
let popMul = 0.6;
let reachMul = 0.9;
if (act === 'climax') { light = 0.92; fog = 0.18; popMul = 1.0; reachMul = 1.2; }
else if (act === 'turn') { light = 0.78; fog = 0.28; popMul = 0.85; reachMul = 1.0; }
else if (act === 'setup') { light = 0.52; fog = 0.45; popMul = 0.35; reachMul = 0.7; }
else if (act === 'resolution') { light = 0.48; fog = 0.55; popMul = 0.45; reachMul = 0.65; }
else if (act === 'development') { light = 0.68 + tension * 0.15; fog = 0.32; popMul = 0.6 + tension * 0.2; reachMul = 0.85 + tension * 0.2; }
perSection.push({ light: clamp01(light), fog: clamp01(fog), popMul: clamp01(popMul), reachMul: clamp01(reachMul) });
}
// Story rearrangements — the ONLY sanctioned jumps. Decided here at
// generation time from the story (never mid-render): when the song moves
// to a new act, the room may re-hang itself — new furniture layout, new
// camera side — and the camera MUST cut on the same frame to cover it
// (see ArcDriver venueJumpAt / Camera.relocatesAt). Furniture never swaps
// mid-act: within an act the room only drifts, so objects persist.
//
// Rationed to ONE re-hang per video (plus the recap return): each jump is
// a discontinuity no arc can place, and measured three of them shuffle a
// video flat while one reads as an event. The re-hang lands on the
// strongest act change (climax preferred); the outro returns to the
// intro's arrangement (recap is a return, not a redecoration). Seeded,
// stable, serialisable — part of the spec.
const rearrangeAt = [];
{
const acts = [];
for (let i = 0; i < n; i++) {
const state = story && story.sections && story.sections[i] ? story.sections[i] : null;
acts.push(state ? state.act || 'development' : 'development');
}
// Rank act changes: climax first, then turn, then any other change.
const rank = (a) => a === 'climax' ? 0 : a === 'turn' ? 1 : 2;
let hangAt = -1, hangRank = 99;
for (let i = 1; i < n; i++) {
if (acts[i] === acts[i - 1]) continue;
const r = Math.min(rank(acts[i]), rank(acts[i - 1]) + 0.5);
if (r < hangRank) { hangRank = r; hangAt = i; }
}
let arrangement = 0;
for (let i = 0; i < n; i++) {
const isRecap = story && story.recap
&& sections && sections[i] && sections[i].kind === 'outro'
&& acts[i] === 'resolution';
if (isRecap) arrangement = 0;
else if (i === hangAt) arrangement = 1;
rearrangeAt.push(arrangement);
}
}
return {
archetype: picked.archetype,
name: picked.name,
seed,
shell,
projection,
population,
paletteMap,
perSection,
rearrangeAt,
venue: true,
readsDepth: false,
module: picked, // keep registry ref for trait/consumes checks
};
}
export function describeVenue(venue) {
if (!venue) return 'no venue';
return `${venue.name}(${venue.archetype}) pop ${venue.population.count} ${venue.population.kind} · proj ${venue.projection.kind}`;
}
+484
View File
@@ -0,0 +1,484 @@
// VENUE: Chamber — derelict room full of memories.
//
// A sparse, still place. Window-light + floor reflections carry the viz
// tint; scattered memories (primitives via actorToGeometry) populate it
// via stageNode. Population density + light/fog evolve per Story act
// (setup sparse/distant, climax one large memory at centre, resolution dim).
// Primitives only in V1 — no glTF kit.
import { actorToGeometry } from '../actors/meshes.js';
export const chamber = {
name: 'Chamber',
archetype: 'chamber',
family: 'minimal',
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: the
// chamber is dim plaster and floor light, so it earns the blaze often.
reach: 1.1,
params: {
density: { type: 'float', range: [0.2, 0.9], default: 0.45, uniform: 'u_density', bias: 'density' },
jitter: { type: 'float', range: [0, 0.6], default: 0.25, uniform: 'u_jitter' },
light: { type: 'float', range: [0.3, 1.2], default: 0.7, uniform: 'u_light', bias: 'energy' },
palette: { type: 'palette', count: 4 },
},
reactive: {},
// Venue RTT: the compositor calls this with the finished viz texture after
// compositing the viz stack into vizTarget. The window and the floor
// reflection sample it, so the viz appears INSIDE the room rather than
// composited over it. Texture object identity is stable frame-to-frame, so
// assigning map/emissiveMap is cheap; needsUpdate only on first bind.
bindVizTexture(instance, texture) {
if (!instance || !texture) return;
let dirty = false;
if (instance.windowMat && instance.windowMat.emissiveMap !== texture) {
instance.windowMat.emissiveMap = texture;
dirty = true;
}
// The spill shows the viz through its emissive channel (the room is too
// dark for diffuse alone); map rides along so the two never disagree.
if (instance.reflectMat && (instance.reflectMat.map !== texture
|| instance.reflectMat.emissiveMap !== texture)) {
instance.reflectMat.map = texture;
instance.reflectMat.emissiveMap = texture;
dirty = true;
}
if (dirty) {
if (instance.windowMat) instance.windowMat.needsUpdate = true;
if (instance.reflectMat) instance.reflectMat.needsUpdate = true;
}
},
// Base wash for the viz composite. The room's own wall colour is too dark
// to densify anything (dark song → dark palette → wash ≈ black, and the
// 5x section jump stays), so the wash is a glowing screen base instead:
// palette-tinted mid grey that arcs with venueState.light. A screen that
// glows brighter than its walls is physically sensible, and the wash
// carries the slow travel while calming the keyed shot's jumps.
// See Layer.washColor.
washColor(instance) {
const st = (instance && instance.venueState) || null;
const light = st ? st.light : 0.7;
let r = 0.05, g = 0.05, b = 0.07;
if (instance && instance.walls && instance.walls.length) {
const c = instance.walls[0].material.color;
r = c.r; g = c.g; b = c.b;
}
const glow = (0.07 + light * 0.09);
return [r * 0.5 + glow, g * 0.5 + glow, b * 0.5 + glow * 1.1];
},
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
const T = THREE;
// Room shell — 5 planes: floor + 4 walls (no ceiling, open to sky)
const room = new T.Group();
room.name = 'room';
const floorMat = new T.MeshStandardMaterial({
color: new T.Color(0.14, 0.13, 0.12),
roughness: 0.85,
metalness: 0.04,
});
const wallMat = new T.MeshStandardMaterial({
color: new T.Color(0.18, 0.17, 0.16),
roughness: 0.92,
metalness: 0.02,
});
const floorGeo = new T.PlaneGeometry(22, 22);
const floor = new T.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1.4;
floor.receiveShadow = false;
room.add(floor);
const wallGeo = new T.PlaneGeometry(22, 6);
const walls = [];
const wallPositions = [
{ pos: [0, 1.6, -11], rotY: 0 },
{ pos: [0, 1.6, 11], rotY: Math.PI },
{ pos: [-11, 1.6, 0], rotY: Math.PI / 2 },
{ pos: [11, 1.6, 0], rotY: -Math.PI / 2 },
];
for (const w of wallPositions) {
const wall = new T.Mesh(wallGeo, wallMat.clone());
wall.position.set(w.pos[0], w.pos[1], w.pos[2]);
wall.rotation.y = w.rotY;
wall.receiveShadow = false;
room.add(wall);
walls.push(wall);
}
// Window plane — the viz render surface. 16:9 like the viz that
// feeds it, so the image arrives undistorted; emissiveMap carries the
// viz (bound per-frame by bindVizTexture), emissive color stays the
// palette tint multiplier. Sized to fill ~40% of the frame from the
// room camera (was 6.4x3.6 ≈ 22% — a postage stamp the section's viz
// could not drive the frame through, flattening filmstrip direction).
const windowGeo = new T.PlaneGeometry(12, 6.75);
const windowMat = new T.MeshStandardMaterial({
color: new T.Color(0.02, 0.02, 0.02),
emissive: new T.Color(1, 1, 1),
emissiveIntensity: 1.0,
roughness: 0.6,
metalness: 0.02,
});
const windowMesh = new T.Mesh(windowGeo, windowMat);
windowMesh.position.set(0, 1.35, -10.9);
windowMesh.name = 'window';
room.add(windowMesh);
// Floor spill of the viz screen. Same 16:9 texture, dimmed and
// translucent — reads as light spilling out of the window. It is a
// SECOND plane lying on the floor, so every way it differs from the
// floor reads as architecture: its rim reads as a raised bar, its face
// as a second floor. Four things keep it a light streak instead:
// exactly co-planar (no tilt — a 1° tilt over 10 units is a 22 cm ramp
// at this grazing angle), feathered on all sides, faded to nothing at
// the camera end (canvas bottom = near side) so no near rim ever slides
// under the lens, and pulled back toward the window.
let spillAlpha = null;
try {
if (typeof document !== 'undefined' && document.createElement) {
const cnv = document.createElement('canvas');
cnv.width = 256; cnv.height = 160;
const g = cnv.getContext('2d');
if (g) {
// Along-beam: opaque at the window end (canvas top = far
// side), gone at the camera end.
const along = g.createLinearGradient(0, 0, 0, 160);
along.addColorStop(0, '#fff');
along.addColorStop(0.45, '#888');
along.addColorStop(0.8, '#111');
along.addColorStop(1, '#000');
g.fillStyle = along;
g.fillRect(0, 0, 256, 160);
// Across-beam: kill the left/right rims.
g.globalCompositeOperation = 'destination-in';
const across = g.createLinearGradient(0, 0, 256, 0);
across.addColorStop(0, '#000');
across.addColorStop(0.18, '#fff');
across.addColorStop(0.82, '#fff');
across.addColorStop(1, '#000');
g.fillStyle = across;
g.fillRect(0, 0, 256, 160);
spillAlpha = new T.CanvasTexture(cnv);
}
}
} catch { spillAlpha = null; }
const reflectGeo = new T.PlaneGeometry(12, 6);
const reflectMat = new T.MeshStandardMaterial({
color: new T.Color(0.02, 0.02, 0.03),
emissive: new T.Color(1, 1, 1),
emissiveIntensity: 0.35,
roughness: 0.35,
metalness: 0.15,
transparent: true,
opacity: 0.3,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -1,
});
if (spillAlpha) reflectMat.alphaMap = spillAlpha;
const reflection = new T.Mesh(reflectGeo, reflectMat);
reflection.rotation.x = -Math.PI / 2;
reflection.position.set(0, -1.398, -4.6);
reflection.name = 'reflection';
room.add(reflection);
scene.add(room);
// Lighting — ambient + key from the camera side. The key used to sit
// behind the window wall, leaving every camera-facing surface in
// shadow lit only by ambient — the room read as a black void with a
// floating screen. Front light keeps a dim room present.
const ambient = new T.AmbientLight(0xffffff, 0.42);
scene.add(ambient);
const sun = new T.DirectionalLight(0xfff6e0, 0.95);
sun.position.set(2.5, 4.2, 7.5);
sun.castShadow = false;
scene.add(sun);
const fill = new T.DirectionalLight(0xffffff, 0.22);
fill.position.set(-3.2, 2.0, 4.0);
scene.add(fill);
scene.background = null;
scene.fog = new T.Fog(0x0e0e12, 12, 28);
scene.userData.transparentBackground = true;
// Population container — memories
const popGroup = new T.Group();
popGroup.name = 'memories';
scene.add(popGroup);
return {
room, floor, floorMat, wallMat, walls, windowMesh, windowMat,
reflection, reflectMat,
ambient, sun, fill,
popGroup,
seed,
venueSpec: venueSpec || null,
builtKey: null,
popCount: 0,
};
},
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, venueArrangement = null, 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], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]];
const identity = personality ? personality.identity : null;
const shape = personality ? personality.shape : null;
const space = personality ? personality.space : null;
const style = personality ? personality.style : null;
// Trait reads so lint / scene-gate sees delta
const shapeTilt = shape ? shape.tilt : 0;
const shapeSides = shape ? shape.sides : 0;
const spaceHorizon = space ? space.horizon : 0.5;
const styleWeight = style ? style.lineWeight : 0.5;
const ink = identity ? identity.ink : null;
const lattice = identity ? identity.lattice : null;
const elementScale = identity ? identity.lattice.elementScale : 0.35;
void elementScale; void lattice; void shapeSides;
// Resolve venueSpec — prefer passed-in, fallback to instance stored
const vs = venueSpec || instance.venueSpec;
const vState = venueState || instance.venueState || null;
const perSectionMul = vs && vs.perSection ? 1 : 1;
if (vState) instance.venueState = vState;
// Ground/personality driven materials. The room MUST read the story arc:
// a static dark box dilutes every section change into churn (filmstrip
// direction collapsed when the venue arrived). venueState.light swings
// ~0.5 (setup) to ~0.92 (climax) — map it onto wall/floor albedo and
// the sun so the room breathes with the song, not just the window.
const c0 = pal[0];
// The venue is the STABLE place — it moves less than the viz, not
// more. Full light steps at every section made consecutive sections
// ~4x further apart than without a venue (a shuffle, not an arc), so
// the room takes half steps; the viz window takes the full ones.
//
// Walls take their HUE from the palette but their LEVEL from the room:
// a dark song gives a near-black palette, and palette-dark walls are
// unlightable — the room read as a black void with a floating screen.
// Normalise the tint so the song colours the room without darkening it.
const roomLight = vState ? vState.light : 0.7;
const tintMax = Math.max(c0[0], c0[1], c0[2], 1e-3);
const tint = [c0[0] / tintMax, c0[1] / tintMax, c0[2] / tintMax];
// Bright songs light their own room through the palette; dark songs
// cannot (their tint is near-black even normalised against the level
// the room needs). So the room's level compensates the palette's:
// dark palette → higher level, bright palette → lower. Keeps the room
// present for drone without flooding it for centre.
const palLum = 0.2126 * c0[0] + 0.7152 * c0[1] + 0.0722 * c0[2];
const comp = 0.55 + Math.max(0, 0.45 - palLum) * 1.1;
const wallLevel = (0.22 + roomLight * 0.30) * comp;
const floorLevel = (0.18 + roomLight * 0.26) * comp;
instance.floorMat.color.setRGB(tint[0] * floorLevel, tint[1] * floorLevel, tint[2] * floorLevel);
instance.floorMat.roughness = 0.85;
for (const w of instance.walls) {
w.material.color.setRGB(tint[0] * wallLevel, tint[1] * wallLevel, tint[2] * wallLevel);
}
if (instance.sun) instance.sun.intensity = 0.5 + roomLight * 1.0;
if (instance.ambient) instance.ambient.intensity = 0.32 + roomLight * 0.5;
// Window brightness from story light. The IMAGE is the viz texture
// (emissiveMap); emissive color stays near-white so the viz keeps its
// own colors. Palette tint reaches the room via floor/walls instead.
{
const lightMul = vState ? vState.light : 1;
instance.windowMat.emissive.setRGB(1, 1, 1);
instance.windowMat.emissiveIntensity = (params.light || 0.7) * (0.75 + lightMul * 0.5);
if (instance.reflectMat) {
// Tint the spill toward the palette floor colour so it melts into
// the floor instead of reading as a lit slab with its own edge.
instance.reflectMat.emissive.setRGB(c0[0] * 0.5, c0[1] * 0.48, c0[2] * 0.55);
instance.reflectMat.color.setRGB(c0[0] * 0.10, c0[1] * 0.10, c0[2] * 0.12);
instance.reflectMat.emissiveIntensity = 0.5 * (params.light || 0.7) * (0.75 + lightMul * 0.5);
}
}
// Fog via space + story: resolution (fog 0.55) closes the room in,
// climax (0.18) opens it. Swing near AND far so the depth change reads.
if (scene.fog) {
const fogMul = vState ? vState.fog : 0.35;
const fogDepth = (space ? 12 + space.depth * 6 : 15) * (0.7 + fogMul * 0.75);
scene.fog.near = fogDepth;
scene.fog.far = fogDepth + 10 + fogMul * 10;
scene.fog.color.setRGB(c0[0] * 0.06, c0[1] * 0.06, c0[2] * 0.08);
}
// Rebuild population if needed (deterministic, seeded). The arrangement
// salt is part of the key: same salt → same layout, guaranteed
// carryover; a new salt after a story jump re-hangs the room (always
// under a camera cut — see ArcDriver.venueJumpAt). Nothing else here
// may rebuild mid-song: no per-section seeds in this key.
const arrangement = venueArrangement ?? instance.venueArrangement ?? 0;
instance.venueArrangement = arrangement;
const popKey = vs ? `${vs.seed}:${vs.population ? vs.population.count : 0}:${identity ? identity.cast.protagonist.sides : 0}:${actorSpec ? actorSpec.seed : 0}:a${arrangement}` : `no-vs:${instance.seed}:a${arrangement}`;
const needRebuild = instance.builtKey !== popKey || !instance.popCount;
if (needRebuild && identity && actorSpec && actorSpec.form) {
for (const child of [...instance.popGroup.children]) {
instance.popGroup.remove(child);
child.traverse((obj) => {
if (obj.isMesh) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m) => m.dispose());
}
}
});
}
const count = vs && vs.population ? Math.max(2, Math.min(12, vs.population.count | 0)) : 5;
const spread = vs && vs.population ? vs.population.spread || 0.7 : 0.7;
const jitter = vs && vs.population ? vs.population.jitter || 0.25 : 0.25;
// One actor, furnished as a roomful: the first build makes the
// track's monolith body (the same geometry the viz hero uses), and
// each memory unwraps one of its parts. Unwrapping per part rather
// than cloning the whole body matters — cloning stamped the same
// ~4-part composite at 0.2 scale, so a radial assembly read as grey
// noise. Parts are the limbs; a room of limbs reads as furniture.
let bodyParts = null;
try {
const body = actorToGeometry(actorSpec, identity, T);
bodyParts = body.children.slice();
} catch { bodyParts = null; }
if (!bodyParts || !bodyParts.length) {
const g = new T.BoxGeometry(0.3, 0.3, 0.3);
const m = new T.Mesh(g, new T.MeshStandardMaterial({ color: 0xffffff }));
m.userData.partIndex = 0;
bodyParts = [m];
}
// Deterministic positions — golden-angle spiral + separation passes.
// The old sin-scatter clustered near the centre, and at furniture
// scale (1+ units wide) neighbours interpenetrated. The spiral gives
// even coverage with an open middle for the camera; the relaxation
// below enforces a minimum gap so no two memories touch.
const seeds = [];
for (let i = 0; i < count; i++) {
const frac = (i + 0.5) / Math.max(1, count);
// Arrangement salt rotates the whole spiral, so a re-hang is a
// visibly new layout built from the same objects — deterministic
// per (seed, arrangement), stable across seeks.
const ang = i * 2.39996 + (instance.seed % 628) * 0.01 + arrangement * 2.39996 * 3;
const rad = (2.4 + 5.6 * Math.sqrt(frac)) * (0.7 + spread * 0.45);
let rx = Math.cos(ang) * rad + Math.sin(i * 78.233) * jitter * 1.2;
let rz = Math.sin(ang) * rad * 0.78 - 1.2 + Math.cos(i * 43.12) * jitter;
rx = Math.max(-9, Math.min(9, rx));
// Never in front of the room: the camera pushes as close as z≈1.3,
// so anything at z>-0.5 is at the lens and reads as a bar across
// the viz. Furniture lives in the back two-thirds, toward the window.
rz = Math.max(-8.5, Math.min(-0.5, rz));
seeds.push([rx, rz]);
}
// Repulsion passes: push overlapping pairs apart. Deterministic,
// analytic, no wall-clock — same inputs always give same layout.
const minDist = 2.9;
for (let pass = 0; pass < 10; pass++) {
for (let a = 0; a < count; a++) {
for (let b = a + 1; b < count; b++) {
const dx = seeds[b][0] - seeds[a][0];
const dz = seeds[b][1] - seeds[a][1];
const d = Math.hypot(dx, dz);
if (d >= minDist || d < 1e-4) continue;
const push = (minDist - d) * 0.5;
const nx = dx / d, nz = dz / d;
seeds[a][0] = Math.max(-9, Math.min(9, seeds[a][0] - nx * push));
seeds[a][1] = Math.max(-8.5, Math.min(-0.5, seeds[a][1] - nz * push));
seeds[b][0] = Math.max(-9, Math.min(9, seeds[b][0] + nx * push));
seeds[b][1] = Math.max(-8.5, Math.min(-0.5, seeds[b][1] + nz * push));
}
}
}
for (let i = 0; i < count; i++) {
const rxClamped = seeds[i][0];
const rzClamped = seeds[i][1];
const ry = -1.15 + 0.15 + Math.sin(i * 5.5) * 0.25;
const src = bodyParts[i % bodyParts.length];
const m = new T.Mesh(
src.geometry,
new T.MeshStandardMaterial({ color: 0xffffff }),
);
m.userData.partIndex = src.userData.partIndex ?? (i % bodyParts.length);
const palIdx = vs && vs.paletteMap ? vs.paletteMap[i % vs.paletteMap.length] : i % pal.length;
const cc = pal[palIdx % pal.length] || pal[0];
// Faint emissive in the furniture's own colour: in the darkest
// rooms (darkest songs pick the chamber by design) lit-only
// limbs go black and the traverse moves the camera through
// nothing visible. Kept low — these are memories, not lamps.
m.material = new T.MeshStandardMaterial({
color: new T.Color(cc[0], cc[1], cc[2]),
emissive: new T.Color(cc[0] * 0.14, cc[1] * 0.14, cc[2] * 0.14),
emissiveIntensity: 1.0,
roughness: 0.45 + styleWeight * 0.12,
metalness: 0.08,
transparent: opacity < 0.999,
opacity,
});
m.castShadow = false;
m.receiveShadow = false;
const g = new T.Group();
g.add(m);
// Small furniture, not a second hero: part geometry is already
// ~0.5–1.0 units in radius, and the camera walks among it, so a
// unit-scale limb at 3 units fills the frame like a monument.
// Memories stay well under half scale; elementScale only nudges
// within that — a huge-form track gets noticeably bigger
// memories, not a room of heroes.
const s = (0.22 + elementScale * 0.28) * (1 - (i / Math.max(1, count)) * 0.25);
g.scale.setScalar(s * (0.9 + shapeSides * 0.015));
g.position.set(rxClamped, ry, rzClamped);
g.rotation.y = shapeTilt + i * 0.9 + Math.sin(i) * 0.5;
g.rotation.x = Math.sin(i * 1.3) * 0.22;
g.userData.baseY = ry;
g.userData.index = i;
instance.popGroup.add(g);
}
instance.builtKey = popKey;
instance.popCount = count;
}
// Analytic animation — bob + slow yaw, no integration
for (const g of instance.popGroup.children) {
const idx = g.userData.index || 0;
const baseY = g.userData.baseY || 0;
g.position.y = baseY + Math.sin(t * 0.35 + idx * 1.1) * 0.06;
g.rotation.y += 0; // keep deterministic: set rather than accumulate
const yaw = shapeTilt + idx * 0.9 + Math.sin(t * 0.18 + idx) * 0.28;
g.rotation.y = yaw;
// Per-section popMul would modulate visible/count — V1 keeps all visible
{ const popMul = vState ? vState.popMul : 1; const showIndex = g.userData.index / Math.max(1, instance.popCount); g.visible = opacity > 0.01 && (showIndex < (0.35 + popMul * 0.75)); }
for (const mesh of g.children) {
if (mesh.isMesh) {
mesh.material.opacity = opacity;
mesh.material.transparent = opacity < 0.999;
// The room's population breathes with the story arc: dim in
// setup, bright at the climax. Pure f(venueState), no clock.
if (vState && mesh.material.emissiveIntensity !== undefined) {
mesh.material.emissiveIntensity = 0.5 + vState.light * 1.0;
}
}
}
}
// Subtle room drift from camera trait
const camTrait = personality ? personality.camera : null;
const camSway = camTrait ? camTrait.sway : 0;
const camSwayRate = camTrait ? camTrait.swayRate : 0.09;
instance.room.position.x = Math.sin(t * camSwayRate) * camSway * 0.35;
instance.room.position.z = Math.cos(t * camSwayRate * 0.83) * camSway * 0.22;
void perSectionMul; void spaceHorizon; void instance; void params; void T;
},
};
export default chamber;
+78
View File
@@ -0,0 +1,78 @@
// Projection helper — viz texture → venue surface (V1 placeholder, no RTT yet).
//
// In V1 compositing stays no-RTT: venue and viz are separate Model/Shader layers
// blended in Compositor. This module is the V1.5 contract so the shape exists
// before the cost does: stage.screen, chamber.window, future passage.skydome
// all share the same `vizTexture → material.map` plumbing at half-res.
//
// V1 implementations are no-ops / DataTexture fallbacks so the module can be
// imported without a WebGL context (tests, headless gallery). V1.5 will swap
// the DataTexture for a real RenderTarget texture.
import * as THREE from 'three';
/**
* Create a venue projection texture at half-res (bloomA size).
* Headless fallback uses DataTexture so `document` is not required.
* @param {number} w width of venue target
* @param {number} h height of venue target
* @returns {THREE.Texture}
*/
export function createVenueTexture(w = 640, h = 360) {
// Try CanvasTexture when DOM exists; otherwise DataTexture.
try {
if (typeof document !== 'undefined' && document.createElement) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.floor(w));
canvas.height = Math.max(1, Math.floor(h));
const g = canvas.getContext('2d');
if (g) {
g.fillStyle = '#101018';
g.fillRect(0, 0, canvas.width, canvas.height);
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.needsUpdate = true;
tex.userData.isVenuePlaceholder = true;
return tex;
}
} catch { /* fall through */ }
const W = Math.max(1, Math.floor(w));
const H = Math.max(1, Math.floor(h));
const data = new Uint8Array(W * H * 4);
for (let i = 0; i < data.length; i += 4) {
data[i] = 16; data[i + 1] = 16; data[i + 2] = 24; data[i + 3] = 255;
}
const tex = new THREE.DataTexture(data, W, H, THREE.RGBAFormat);
tex.needsUpdate = true;
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.userData.isVenuePlaceholder = true;
return tex;
}
/**
* Bind a viz texture onto a venue mesh.
* @param {THREE.Mesh} mesh venue hero surface (screen / window / skydome)
* @param {THREE.Texture} vizTexture texture produced from viz layerTarget
* @param {string} mapping 'screen' | 'window' | 'reflections' | 'skydome'
*/
export function bindVenueTexture(mesh, vizTexture, mapping = 'screen') {
if (!mesh || !mesh.material) return;
const mat = mesh.material;
// V1 treats every mapping as emissive screen; V1.5 will specialise.
if (mapping === 'screen' || mapping === 'window') {
mat.map = vizTexture || mat.map;
mat.emissiveMap = vizTexture || mat.emissiveMap;
mat.needsUpdate = true;
} else if (mapping === 'reflections') {
mat.map = vizTexture || mat.map;
mat.roughness = 0.35;
mat.metalness = 0.22;
mat.needsUpdate = true;
} else {
mat.map = vizTexture || mat.map;
mat.needsUpdate = true;
}
}
+37
View File
@@ -0,0 +1,37 @@
// Venue registry — the venue IS the ground.
// Mirrors src/scenes/registry.js pattern: modules carry traits/consumes/venue:true,
// validated by params/schema.js and selected via census (signatureWeight) in
// VenueGenerator. Venue is higher-level than viz — picked first, viz second.
import { validateModule } from '../params/schema.js';
import { chamber } from './chamber.js';
import { stage } from './stage.js';
import { ride } from './ride.js';
export const ARCHETYPES = ['chamber', 'stage', 'ride'];
const MODULES = [chamber, stage, ride];
const errors = [];
for (const m of MODULES) {
const e = validateModule(m);
if (e.length) errors.push(...e);
if (!m.archetype || !ARCHETYPES.includes(m.archetype)) errors.push(`${m.name}: unknown archetype '${m.archetype}'`);
if (!m.venue) errors.push(`${m.name}: venue registry entry must have venue:true`);
}
if (errors.length) {
console.error('[venue registry] invalid venue modules:\n' + errors.join('\n'));
}
export const venues = MODULES;
export const venueErrors = errors;
export function venueByName(name) {
return MODULES.find((m) => m.name === name) || null;
}
export function venuesInArchetype(archetype) {
return MODULES.filter((m) => m.archetype === archetype);
}
export default venues;
+436
View File
@@ -0,0 +1,436 @@
// VENUE: Ride — a night drive. A randomly generated vehicle (the track's
// monolith body as chassis, signature-form panels) carries actor passengers;
// the viz plays on the car radio screen; the road scrolls beneath in chase
// mode or the world sits still in stationary mode. Camera orbits, pushes in
// and pulls out per section.
//
// One venue = one place (this car, this road). Primitives + actorToGeometry
// only — no glTF kit. Per-section evolution via venueState
// {light,fog,popMul,reachMul} from VenueSpec.perSection, interpolated by the
// ArcDriver (never stepped).
import { actorToGeometry } from '../actors/meshes.js';
export const ride = {
name: 'Ride',
archetype: 'ride',
family: 'structural',
kind: 'model',
venue: true,
consumes: ['form', 'ink', 'staging'],
traits: ['shape', 'space', 'camera', 'style'],
texture: 0.4,
// The radio is a small bright screen in a dark cabin — it earns the blaze
// about as often as the stage wall.
reach: 0.6,
params: {
density: { type: 'float', range: [0.2, 0.9], default: 0.5, uniform: 'u_density', bias: 'density' },
speed: { type: 'float', range: [0, 1.5], default: 0.7, uniform: 'u_speed', bias: 'motion' },
light: { type: 'float', range: [0.4, 1.3], default: 0.85, uniform: 'u_light', bias: 'energy' },
palette: { type: 'palette', count: 4 },
},
reactive: {},
// Venue RTT: the compositor calls this with the finished viz texture after
// compositing the viz stack into vizTarget. The radio screen samples it,
// so the viz plays ON the dash rather than composited over the venue.
bindVizTexture(instance, texture) {
if (!instance || !texture) return;
if (instance.radioMat && instance.radioMat.emissiveMap !== texture) {
instance.radioMat.emissiveMap = texture;
instance.radioMat.needsUpdate = true;
}
},
// Radio-glow wash for the viz composite — arcs with venueState.light.
// See Layer.washColor.
washColor(instance) {
const st = (instance && instance.venueState) || null;
const light = st ? st.light : 0.7;
const glow = 0.08 + light * 0.1;
return [glow, glow, glow * 1.15];
},
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
const T = THREE;
const rng = mulberry(seed >>> 0 || 1);
// Road: long dark plane the car sits over. In chase mode its texture
// offset scrolls (see update); in stationary it holds still.
const roadMat = new T.MeshStandardMaterial({
color: new T.Color(0.09, 0.09, 0.11),
roughness: 0.9,
metalness: 0.05,
});
const road = new T.Mesh(new T.PlaneGeometry(30, 120), roadMat);
road.rotation.x = -Math.PI / 2;
road.position.set(0, -1.15, -30);
scene.add(road);
// Lane dashes: small emissive quads scrolling with the road, so speed
// reads even when the asphalt is near-black.
const dashMat = new T.MeshStandardMaterial({
color: new T.Color(0.05, 0.05, 0.05),
emissive: new T.Color(0.9, 0.85, 0.6),
emissiveIntensity: 0.7,
roughness: 0.6,
});
const dashGeo = new T.PlaneGeometry(0.18, 2.2);
const dashes = new T.Group();
dashes.name = 'dashes';
for (let i = 0; i < 12; i++) {
const d = new T.Mesh(dashGeo, dashMat);
d.rotation.x = -Math.PI / 2;
d.position.set(i % 2 === 0 ? -1.6 : 1.6, -1.13, -70 + i * 6);
dashes.add(d);
}
scene.add(dashes);
// Lane edge glow: two long emissive strips so the road reads as a
// road even when the asphalt is near-black.
const edgeMat = new T.MeshStandardMaterial({
color: new T.Color(0.05, 0.05, 0.06),
emissive: new T.Color(0.5, 0.55, 0.8),
emissiveIntensity: 0.5,
roughness: 0.7,
});
const edgeGeo = new T.PlaneGeometry(0.12, 120);
const edges = [];
for (const ex of [-3.2, 3.2]) {
const edge = new T.Mesh(edgeGeo, edgeMat);
edge.rotation.x = -Math.PI / 2;
edge.position.set(ex, -1.12, -30);
scene.add(edge);
edges.push(edge);
}
// The car: a group at the origin facing -z (driving away from camera
// start). Body panels come from the track's actor; chassis/cabin/wheels
// are seeded primitives so every song's vehicle differs.
const car = new T.Group();
car.name = 'car';
scene.add(car);
// Cabin glow + headlights so the car reads at night.
const ambient = new T.AmbientLight(0xffffff, 0.5);
scene.add(ambient);
const key = new T.DirectionalLight(0xcfe0ff, 1.0);
key.position.set(3.5, 5.0, 6.0);
key.castShadow = false;
scene.add(key);
const tail = new T.PointLight(0xff2a1a, 2.5, 10);
tail.position.set(0, 0.2, 2.6);
scene.add(tail);
scene.background = null;
scene.fog = new T.Fog(0x07070c, 14, 46);
scene.userData.transparentBackground = true;
// Passenger container — actor riders seated in the cabin.
const crew = new T.Group();
crew.name = 'crew';
car.add(crew);
return {
car, road, roadMat, dashes, dashMat, edges, edgeMat, crew,
ambient, key, tail,
radioMat: null, radioMesh: null,
bodyMeshes: [],
seed,
venueSpec: venueSpec || null,
builtKey: null,
popCount: 0,
// Chase (road scrolls, world rushes) vs stationary (parked, camera
// does all the moving). Drawn once per video from the venue seed.
chase: (rng() < 0.62),
bodyHue: rng(),
};
},
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, venueArrangement = null, 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], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]];
const identity = personality ? personality.identity : null;
const shape = personality ? personality.shape : null;
const space = personality ? personality.space : null;
const style = personality ? personality.style : null;
const shapeTilt = shape ? shape.tilt : 0;
const styleWeight = style ? style.lineWeight : 0.5;
const spaceHorizon = space ? space.horizon : 0.5;
void shapeTilt;
const vs = venueSpec || instance.venueSpec;
const vState = venueState || instance.venueState || null;
if (vState) instance.venueState = vState;
const arrangement = venueArrangement ?? instance.venueArrangement ?? 0;
instance.venueArrangement = arrangement;
const c0 = pal[0];
const fieldLight = vState ? vState.light : 0.7;
// Asphalt takes hue from the palette, level from the section — a night
// road you can still see.
const tMax = Math.max(c0[0], c0[1], c0[2], 1e-3);
const palLum = 0.2126 * c0[0] + 0.7152 * c0[1] + 0.0722 * c0[2];
const comp = 0.55 + Math.max(0, 0.45 - palLum) * 1.1;
const roadLevel = (0.16 + fieldLight * 0.24) * comp;
instance.roadMat.color.setRGB(
(c0[0] / tMax) * roadLevel, (c0[1] / tMax) * roadLevel, (c0[2] / tMax) * roadLevel);
if (instance.key) instance.key.intensity = 0.7 + fieldLight * 1.1;
if (instance.ambient) instance.ambient.intensity = 0.4 + fieldLight * 0.5;
if (instance.tail) instance.tail.intensity = 1.2 + fieldLight * 2;
if (scene.fog) {
const fogMul = vState ? vState.fog : 0.35;
const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45);
scene.fog.near = fogDepth;
scene.fog.far = fogDepth + 30;
scene.fog.color.setRGB(c0[0] * 0.05, c0[1] * 0.05, c0[2] * 0.07);
}
// --- vehicle body (built once; arrangement re-hangs it) ---
const popKey = vs
? `${vs.seed}:ride:${identity ? identity.cast.protagonist.sides : 0}:${actorSpec ? actorSpec.seed : 0}:a${arrangement}`
: `no-vs:${instance.seed}:a${arrangement}`;
if ((instance.builtKey !== popKey || !instance.popCount) && identity && actorSpec && actorSpec.form) {
// Clear previous build.
for (const m of instance.bodyMeshes) {
instance.car.remove(m);
m.traverse((o) => {
if (o.isMesh) {
if (o.geometry) o.geometry.dispose();
if (o.material) {
const mats = Array.isArray(o.material) ? o.material : [o.material];
mats.forEach((mm) => mm.dispose());
}
}
});
}
instance.bodyMeshes = [];
while (instance.crew.children.length) instance.crew.remove(instance.crew.children[0]);
const rng = mulberry(((vs ? vs.seed : instance.seed) ^ Math.imul(arrangement + 1, 0x9e3779b9)) >>> 0);
const cc = (i) => pal[(vs && vs.paletteMap ? vs.paletteMap[i % vs.paletteMap.length] : i) % pal.length] || pal[0];
// Chassis: the track's monolith body, squashed wide, low and long.
// Same character as the viz hero, so the car reads as this track's
// vehicle rather than a stock model.
try {
const built = actorToGeometry(actorSpec, identity, T);
const parts = built.children.slice();
const body = new T.Group();
body.name = 'chassis';
for (let i = 0; i < Math.min(3, parts.length); i++) {
const src = parts[(i + arrangement) % parts.length];
const col = cc(i);
const m = new T.Mesh(src.geometry, new T.MeshStandardMaterial({
color: new T.Color(col[0] * 0.9, col[1] * 0.9, col[2] * 0.9),
emissive: new T.Color(col[0] * 0.15, col[1] * 0.15, col[2] * 0.15),
roughness: 0.3 + styleWeight * 0.15,
metalness: 0.3,
transparent: opacity < 0.999, opacity,
}));
m.userData.partIndex = src.userData.partIndex ?? i;
m.scale.set(1.5, 0.55, 2.4);
m.position.set(0, -0.15 + i * 0.12, -0.4 + i * 0.35);
m.rotation.y = (rng() - 0.5) * 0.6;
body.add(m);
}
instance.car.add(body);
instance.bodyMeshes.push(body);
} catch { /* body optional; cabin + wheels still read as a car */ }
// Cabin: low dark-glass tub so passenger heads clear the roofline
// from rear angles — a tall box hid the whole crew.
const glassCol = cc(3);
const cabin = new T.Mesh(
new T.BoxGeometry(1.7, 0.4, 2.1),
new T.MeshStandardMaterial({
color: new T.Color(0.04, 0.045, 0.06),
emissive: new T.Color(glassCol[0] * 0.12, glassCol[1] * 0.12, glassCol[2] * 0.14),
roughness: 0.15, metalness: 0.7,
transparent: opacity < 0.999, opacity,
}));
cabin.position.set(0, 0.5, -0.2);
instance.car.add(cabin);
instance.bodyMeshes.push(cabin);
// Wheels: four dark torus + hub, spin with speed (see below).
const wheelGeo = new T.TorusGeometry(0.34, 0.13, 8, 18);
const hubGeo = new T.CylinderGeometry(0.14, 0.14, 0.1, 10);
hubGeo.rotateZ(Math.PI / 2);
instance.wheels = [];
for (const [wx, wz] of [[-0.95, 1.05], [0.95, 1.05], [-0.95, -1.35], [0.95, -1.35]]) {
const wcol = cc(1);
const wheel = new T.Group();
const tire = new T.Mesh(wheelGeo, new T.MeshStandardMaterial({
color: new T.Color(0.03, 0.03, 0.035), roughness: 0.9,
}));
const hub = new T.Mesh(hubGeo, new T.MeshStandardMaterial({
color: new T.Color(wcol[0], wcol[1], wcol[2]),
emissive: new T.Color(wcol[0] * 0.25, wcol[1] * 0.25, wcol[2] * 0.25),
roughness: 0.4, metalness: 0.6,
}));
wheel.add(tire); wheel.add(hub);
wheel.position.set(wx, -0.72, wz);
instance.car.add(wheel);
instance.bodyMeshes.push(wheel);
instance.wheels.push(wheel);
}
// Radio: the viz screen. Rises above the dash on a small stalk so
// it reads over the bodywork from chase angles — emissiveMap bound
// per-frame by bindVizTexture, emissive stays near-white so the
// viz keeps its own colours.
const radioMat = new T.MeshStandardMaterial({
color: new T.Color(0.02, 0.02, 0.02),
emissive: new T.Color(1, 1, 1),
emissiveIntensity: 1.2,
roughness: 0.5, metalness: 0.2,
side: T.DoubleSide,
});
const stalk = new T.Mesh(
new T.CylinderGeometry(0.05, 0.07, 0.5, 8),
new T.MeshStandardMaterial({ color: new T.Color(0.05, 0.05, 0.06), roughness: 0.6 }));
stalk.position.set(0, 0.75, 0.9);
instance.car.add(stalk);
instance.bodyMeshes.push(stalk);
const radio = new T.Mesh(new T.PlaneGeometry(1.3, 0.73), radioMat);
radio.position.set(0, 1.15, 0.9);
radio.rotation.x = -0.12;
radio.rotation.y = Math.PI;
radio.name = 'radio';
instance.car.add(radio);
instance.radioMat = radioMat;
instance.radioMesh = radio;
instance.bodyMeshes.push(radio);
// Headlight cones: two small translucent wedges so chase mode
// reads. Kept short and faint — long cones filled the frame.
const beamMat = new T.MeshBasicMaterial({
color: new T.Color(0.75, 0.8, 0.9), transparent: true, opacity: 0.05,
depthWrite: false, side: T.DoubleSide,
});
for (const bx of [-0.55, 0.55]) {
const beamGeo = new T.ConeGeometry(0.45, 3.5, 12, 1, true);
beamGeo.translate(0, -1.75, 0);
beamGeo.rotateX(-Math.PI / 2 - 0.06);
const beam = new T.Mesh(beamGeo, beamMat);
beam.position.set(bx, -0.1, -2.2);
instance.car.add(beam);
instance.bodyMeshes.push(beam);
}
// Passengers: actor riders — one part of the track's body each,
// dealt round like chamber memories, seated in a row in the cabin.
// Same limbs as the viz hero, so the crew reads as its passengers.
let bodyParts = null;
try {
const body = actorToGeometry(actorSpec, identity, T);
bodyParts = body.children.slice();
} catch { bodyParts = null; }
const count = vs && vs.population ? Math.max(2, Math.min(4, vs.population.count | 0)) : 2;
for (let i = 0; i < count; i++) {
let m;
if (bodyParts && bodyParts.length) {
const src = bodyParts[(i + arrangement) % bodyParts.length];
const col = cc(i + 1);
m = new T.Mesh(src.geometry, new T.MeshStandardMaterial({
color: new T.Color(col[0], col[1], col[2]),
emissive: new T.Color(col[0] * 0.3, col[1] * 0.3, col[2] * 0.3),
emissiveIntensity: 1.0,
roughness: 0.5, metalness: 0.1,
transparent: opacity < 0.999, opacity,
}));
m.userData.partIndex = src.userData.partIndex ?? 0;
} else {
const col = cc(i + 1);
m = new T.Mesh(
new T.SphereGeometry(0.22, 12, 10),
new T.MeshStandardMaterial({
color: new T.Color(col[0], col[1], col[2]),
emissive: new T.Color(col[0] * 0.3, col[1] * 0.3, col[2] * 0.3),
roughness: 0.5,
}));
}
const g = new T.Group();
g.add(m);
// Seat row: spread across the cabin width, heads above the glass.
const fx = (i - (count - 1) / 2) * 0.62 + (rng() - 0.5) * 0.1;
g.scale.setScalar(0.5);
g.position.set(fx, 0.78, -0.25 + (rng() - 0.5) * 0.3);
g.rotation.y = Math.PI + (rng() - 0.5) * 0.5;
g.userData.baseY = g.position.y;
g.userData.index = i;
g.userData.phase = rng() * Math.PI * 2;
instance.crew.add(g);
}
instance.builtKey = popKey;
instance.popCount = count;
}
// --- motion: analytic, no integration ---
const speed = (params.speed || 0.7) * (instance.chase ? 1 : 0);
const rate = 1 + (features.bandLow || 0) * 0.6;
// Road scroll in chase mode: dashes stream past; parked otherwise.
if (instance.chase && speed > 0.01) {
const dz = speed * 9 * rate;
for (const d of instance.dashes.children) {
let z = d.position.z + dz * 0.016;
if (z > 4) z -= 72;
d.position.z = z;
}
}
// Wheels spin with speed; bob with the low band.
const bob = Math.sin(t * 2.1) * 0.02 * (0.4 + (features.bandLow || 0));
instance.car.position.y = bob * (instance.chase ? 1 : 0.3);
instance.car.rotation.z = Math.sin(t * 0.9) * 0.008 * (instance.chase ? 1 : 0.3);
if (instance.wheels) {
for (const w of instance.wheels) w.rotation.x = t * speed * 6 * rate;
}
// Passengers bob gently, out of phase — analytic f(t), seek-stable.
// popMul gates how many are visible (setup: driver + 1; climax: full car).
const popMul = vState ? vState.popMul : 1;
for (const g of instance.crew.children) {
const idx = g.userData.index || 0;
g.position.y = (g.userData.baseY || 0) + Math.sin(t * 0.5 + (g.userData.phase || 0)) * 0.03;
g.rotation.y = Math.PI + Math.sin(t * 0.18 + idx * 1.3) * 0.22;
const showIndex = idx / Math.max(1, instance.popCount);
g.visible = opacity > 0.01 && (showIndex < (0.35 + popMul * 0.75));
for (const mesh of g.children) {
if (mesh.isMesh) {
mesh.material.opacity = opacity;
mesh.material.transparent = opacity < 0.999;
if (vState && mesh.material.emissiveIntensity !== undefined) {
mesh.material.emissiveIntensity = 0.5 + vState.light * 1.0;
}
}
}
}
// Radio glow follows the section light; the IMAGE is the viz texture.
if (instance.radioMat) {
const l = vState ? vState.light : 1;
instance.radioMat.emissive.setRGB(1, 1, 1);
instance.radioMat.emissiveIntensity = (params.light || 0.85) * (0.7 + l * 0.7);
}
// Ground follows horizon like the stage field.
instance.road.position.y = -1.15 + (spaceHorizon - 0.5) * 0.6;
void camera; void framing; void params;
},
};
function mulberry(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export default ride;
+314
View File
@@ -0,0 +1,314 @@
// VENUE: Stage — hall / pylon field. Open ground plane at sigHorizonY(),
// LED wall / hero screen for future RTT, Instanced-like pylons / legs
// from actorToGeometry primitives via stageNode. One venue = one place.
import { actorToGeometry, castShape } from '../actors/meshes.js';
export const stage = {
name: 'Stage',
archetype: 'stage',
family: 'structural',
kind: 'model',
venue: true,
consumes: ['form', 'ink', 'staging'],
traits: ['shape', 'space', 'camera', 'style'],
texture: 0.4,
// The stage wall is a 14-unit emissive screen — already the brightest thing
// in the frame — so it blazes rarely, or the wall goes to paper.
reach: 0.55,
params: {
density: { type: 'float', range: [0.3, 1.0], default: 0.55, uniform: 'u_density', bias: 'density' },
spread: { type: 'float', range: [0.6, 1.8], default: 1.1, uniform: 'u_spread' },
light: { type: 'float', range: [0.4, 1.3], default: 0.85, uniform: 'u_light', bias: 'energy' },
columns: { type: 'float', range: [2, 6], default: 4, uniform: 'u_columns', bias: 'density', slowAxis: true },
palette: { type: 'palette', count: 4 },
},
reactive: {},
// Venue RTT: the compositor calls this with the finished viz texture after
// compositing the viz stack into vizTarget. The LED wall samples it, so the
// viz appears ON the wall rather than composited over the venue.
bindVizTexture(instance, texture) {
if (!instance || !texture) return;
if (instance.screenMat && instance.screenMat.emissiveMap !== texture) {
instance.screenMat.emissiveMap = texture;
instance.screenMat.needsUpdate = true;
}
},
// Glowing screen base like the chamber's — arcs with venueState.light.
// See Layer.washColor.
washColor(instance) {
const st = (instance && instance.venueState) || null;
const light = st ? st.light : 0.7;
let r = 0.05, g = 0.05, b = 0.07;
if (instance && instance.groundMat) {
const c = instance.groundMat.color;
r = c.r; g = c.g; b = c.b;
}
const glow = (0.07 + light * 0.09);
return [r * 0.5 + glow, g * 0.5 + glow, b * 0.5 + glow * 1.1];
},
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
const T = THREE;
const groundGeo = new T.PlaneGeometry(44, 44);
const groundMat = new T.MeshStandardMaterial({
color: new T.Color(0.13, 0.13, 0.15),
roughness: 0.82,
metalness: 0.05,
});
const ground = new T.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -1.15;
ground.receiveShadow = false;
scene.add(ground);
// LED wall — the viz render surface. 14×7.875 is 16:9 like the viz
// that feeds it, so the image arrives undistorted; emissiveMap carries
// the viz (bound per-frame by bindVizTexture), emissive color stays
// near-white so the viz keeps its own colors.
const screenGeo = new T.PlaneGeometry(14, 7.875);
const screenMat = new T.MeshStandardMaterial({
color: new T.Color(0.02, 0.02, 0.02),
emissive: new T.Color(1, 1, 1),
emissiveIntensity: 1.0,
roughness: 0.7,
metalness: 0.1,
});
const screen = new T.Mesh(screenGeo, screenMat);
screen.position.set(0, 2.2, -14.5);
screen.name = 'screen';
scene.add(screen);
const ambient = new T.AmbientLight(0xffffff, 0.55);
scene.add(ambient);
const key = new T.DirectionalLight(0xffffff, 1.15);
key.position.set(2.4, 4.8, 3.0);
key.castShadow = false;
scene.add(key);
const fill = new T.DirectionalLight(0xffffff, 0.32);
fill.position.set(-2.8, 1.6, -2.2);
scene.add(fill);
scene.background = null;
scene.fog = new T.Fog(0x0a0a0f, 10, 26);
scene.userData.transparentBackground = true;
const pylonContainer = new T.Group();
pylonContainer.name = 'pylons';
scene.add(pylonContainer);
return {
ground, groundMat, screen, screenMat, ambient, key, fill,
pylonContainer,
seed,
venueSpec: venueSpec || null,
builtCols: -1,
builtKey: null,
pylonCount: 0,
};
},
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, venueArrangement = null, THREE }) {
const T = THREE;
const t = timeline.time;
const beat = features.beat || 0;
void beat;
const bandLow = features.bandLow || 0;
const identity = personality ? personality.identity : null;
const shape = personality ? personality.shape : null;
const space = personality ? personality.space : null;
const camTrait = personality ? personality.camera : null;
const style = personality ? personality.style : null;
const shapeTilt = shape ? shape.tilt : 0;
const shapeSides = shape ? shape.sides : 0;
const spaceHorizon = space ? space.horizon : 0.5;
const camSway = camTrait ? camTrait.sway : 0;
const camSwayRate = camTrait ? camTrait.swayRate : 0.09;
const camSpin = camTrait ? camTrait.spin : 0;
const styleWeight = style ? style.lineWeight : 0.5;
void shapeSides;
const pal = palette && palette.length ? palette : [[0.9,0.9,0.92],[0.7,0.6,0.8],[0.4,0.6,0.9],[0.9,0.5,0.4]];
const vs = venueSpec || instance.venueSpec;
const vState = venueState || instance.venueState || null;
const ink = identity ? identity.ink : null;
const lattice = identity ? identity.lattice : null;
void lattice;
const elementScale = identity ? identity.lattice.elementScale : 0.35;
void elementScale;
const stageScaleVal = elementScale / 0.35;
const latticeKind = lattice ? lattice.kind : 'grid';
void stageScaleVal; void latticeKind;
// Materials from palette. The wall IMAGE is the viz texture
// (emissiveMap); emissive color stays near-white so the viz keeps its
// own colors, and story light moves intensity only.
const c0 = pal[0];
// The field breathes with the story arc like the chamber room does —
// a static ground dilutes section changes into churn. Hue from the
// palette, level from the field (same unlightable-dark fix as chamber).
const fieldLight = vState ? vState.light : 0.7;
const tMax = Math.max(c0[0], c0[1], c0[2], 1e-3);
const palLum = 0.2126 * c0[0] + 0.7152 * c0[1] + 0.0722 * c0[2];
const comp = 0.55 + Math.max(0, 0.45 - palLum) * 1.1;
const fieldLevel = (0.18 + fieldLight * 0.26) * comp;
instance.groundMat.color.setRGB(
(c0[0] / tMax) * fieldLevel, (c0[1] / tMax) * fieldLevel, (c0[2] / tMax) * fieldLevel);
if (instance.key) instance.key.intensity = 0.5 + fieldLight * 1.0;
if (instance.ambient) instance.ambient.intensity = 0.3 + fieldLight * 0.45;
instance.screenMat.emissive.setRGB(1, 1, 1);
{ const l = vState ? vState.light : 1; instance.screenMat.emissiveIntensity = (params.light || 0.85) * (0.65 + l * 0.6); }
// Ground level follows horizon
const groundY = -1.15 + (spaceHorizon - 0.5) * 0.6;
instance.ground.position.y = groundY;
if (scene.fog) {
const fogMul = vState ? vState.fog : 0.35; const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45);
scene.fog.near = fogDepth;
scene.fog.far = fogDepth + 18;
scene.fog.color.setRGB(c0[0]*0.07, c0[1]*0.07, c0[2]*0.09);
}
// Column count is locked at first build: it used to re-derive from live
// params.columns (which drifts) and bandLow (which pumps), so the whole
// pylon field rebuilt itself mid-song whenever drift crossed an integer
// — the object-jump this change kills. A story jump may re-hang the
// field via the arrangement salt; nothing else rebuilds it.
const arrangement = venueArrangement ?? instance.venueArrangement ?? 0;
instance.venueArrangement = arrangement;
let cols = instance.builtCols;
if (!(cols >= 2)) {
cols = Math.max(2, Math.min(6, Math.round(((params.columns || 7) - 3) * 0.4 + 3 + bandLow * 0.1)));
}
const rows = Math.max(2, Math.min(3, 3));
const identityKey = identity ? `${identity.cast.protagonist.sides}:${identity.ink.fill}:${latticeKind}:${actorSpec?actorSpec.seed:0}:${vs?vs.seed:0}:a${arrangement}` : `no-id:${actorSpec?actorSpec.seed:0}:a${arrangement}`;
const needRebuild = instance.builtKey !== identityKey || !instance.pylonCount;
if (needRebuild && identity && actorSpec && actorSpec.form) {
for (const child of [...instance.pylonContainer.children]) {
instance.pylonContainer.remove(child);
child.traverse((obj) => {
if (obj.isMesh) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m) => m.dispose());
}
}
});
}
let legShapeGeo = null;
try {
const s = castShape(identity.cast.chorus, 20);
legShapeGeo = new T.ExtrudeGeometry(s, { depth: 0.42, bevelEnabled: false });
legShapeGeo.translate(0,0,-0.21);
} catch { legShapeGeo = new T.BoxGeometry(0.45,0.45,0.42); }
const baseRough = 0.42 + styleWeight*0.12 + (ink?ink.weight*0.08:0);
// Crowns are ~1 unit wide at their part-scale, so columns get ~2.5+
// units of lateral room and rows ~7 of depth, or neighbours
// interpenetrate. cols/rows are already capped above to what fits:
// fewer, wider-spaced pylons read as architecture, not clutter.
for (let r=0; r<rows; r++) {
const f = rows>1 ? r/(rows-1) : 0;
const scale = 1*(1-f) + 0.18*f;
const y0 = groundY + f*0.2;
const zBase = -f*7.5 - 0.5;
for (let c=0; c<cols; c++) {
const pylon = new T.Group();
// Fixed spacing per column, not a fixed total width: adding
// columns widens the field instead of squeezing neighbours
// together. ~2.9 units apart at spread 1.1 for ~1-unit crowns.
pylon.position.set((c - (cols - 1) / 2) * (params.spread || 1.1) * 2.6, 0, zBase);
pylon.userData.f = f;
// Crown — one part of the track's body per pylon, dealt round
// the field like cards (pylon i takes part i % parts). Every
// crown was the whole assembly at 0.25 scale, so the field
// read as N identical grey thumbnails; dealing parts makes
// neighbours differ the way limbs differ.
const crownGroup = new T.Group();
crownGroup.name='crown';
try {
const built = actorToGeometry(actorSpec, identity, T);
const parts = built.children.slice();
const src = parts.length
? parts[(c + r * cols) % parts.length]
: null;
if (src) {
const m = new T.Mesh(
src.geometry,
new T.MeshStandardMaterial({ color: new T.Color(1, 1, 1) }),
);
m.userData.partIndex = src.userData.partIndex ?? 0;
const cc = pal[(m.userData.partIndex||0) % pal.length] || pal[0];
m.material = new T.MeshStandardMaterial({ color: new T.Color(cc[0],cc[1],cc[2]), roughness: baseRough, metalness: 0.07, transparent: opacity<0.999, opacity });
crownGroup.add(m);
} else {
throw new Error('empty body');
}
} catch {
const g=new T.BoxGeometry(0.2,0.2,0.2);
const m=new T.Mesh(g,new T.MeshStandardMaterial({color: new T.Color(c0[0],c0[1],c0[2])}));
crownGroup.add(m);
}
const h = 0.75*scale*1.1;
crownGroup.position.set(0, y0 + h + 0.06*scale, 0);
// Capped: elementScale runs to ~2.5× typical on huge-form
// tracks, and an uncapped crown outgrows its column spacing.
crownGroup.scale.setScalar((0.38+0.42*scale)*Math.min(stageScaleVal, 1.5)*0.9);
crownGroup.userData.yawSeed = c*0.9 + r*0.4 + shapeTilt*0.4;
pylon.add(crownGroup);
// Leg rung
const rung = new T.Mesh(legShapeGeo.clone(), new T.MeshStandardMaterial({ color: new T.Color(1,1,1), roughness: 0.55 }));
rung.scale.set((h/2)*0.9, (h/2)*0.9, 0.85);
rung.position.set(0, y0 + h*0.45, 0);
const lc = pal[(c+r)%pal.length];
rung.material.color.setRGB(lc[0],lc[1],lc[2]);
rung.material.transparent = opacity<0.999;
rung.material.opacity = opacity*0.95;
rung.userData.yawSeed = r*0.6 + c*0.3;
pylon.add(rung);
pylon.userData.crown = crownGroup;
pylon.userData.rung = rung;
pylon.userData.y0 = y0;
instance.pylonContainer.add(pylon);
}
}
if (legShapeGeo) legShapeGeo.dispose();
instance.builtCols = cols;
instance.builtKey = identityKey;
instance.pylonCount = cols*rows;
}
// Analytic pose
for (const pylon of instance.pylonContainer.children) {
const crown = pylon.userData.crown;
const rung = pylon.userData.rung;
if (crown) {
const yaw = t*0.35 + crown.userData.yawSeed + shapeTilt*0.2;
crown.rotation.order='YXZ';
crown.rotation.set(0.12+Math.sin(t*0.28 + pylon.userData.f)*0.14, yaw, Math.sin(t*0.16)*0.08);
for (const m of crown.children) if(m.isMesh){ m.material.opacity=opacity; m.material.transparent=opacity<0.999; m.visible=opacity>0.01; }
}
if (rung) {
rung.rotation.order='YXZ';
rung.rotation.set(0, t*0.22 + rung.userData.yawSeed, 0);
rung.material.opacity=opacity*0.95;
rung.visible=opacity>0.01;
}
{ const popMul = vState ? vState.popMul : 1; const fi = pylon.userData.f || 0; // far rows more sensitive
pylon.visible = opacity>0.01 && (fi < (0.35 + popMul*0.75)); }
}
// Field drift
instance.pylonContainer.position.x = Math.sin(t*camSwayRate)*camSway*0.5;
instance.pylonContainer.position.z = Math.cos(t*camSwayRate*0.7)*camSway*0.3;
instance.pylonContainer.rotation.y = camSpin*t*0.03;
void t; void params;
},
};
export default stage;
+197
View File
@@ -0,0 +1,197 @@
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');
}
+1 -1
View File
@@ -39,7 +39,7 @@ const FORBIDDEN = [
]; ];
// Directories whose output must be a pure function of (seed, params, frame). // 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. // Files legitimately allowed a wall clock: perf measurement, not image content.
const ALLOWED = new Set(['engine/perf.js']); const ALLOWED = new Set(['engine/perf.js']);
+100
View File
@@ -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})`);
+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);
+373
View File
@@ -0,0 +1,373 @@
<!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; }
.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 { Rng, hashString } from '/src/engine/rng.js';
import { generateLook } from '/src/look/LookGenerator.js';
import { sampleValues } from '/src/params/schema.js';
import { subjectIndexOf } from '/src/look/stack.js';
import { venues } from '/src/venues/registry.js';
import { scenes } from '/src/scenes/registry.js';
import { blit } from '/src/checks/blit.js';
const THUMB = { width: 320, height: 180 };
const DEFAULT_ROWS = 5;
const DEFAULT_COLS = 5;
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;
}
// Pin one viz as the subject of every section's stacks, keeping the venue
// ground (and overlays) untouched. Mutates the look before Show.useTrack, so
// the ArcDriver builds its layer cache from the pinned stacks directly.
function pinViz(look, viz, seedBase) {
const temper = look.personality && look.personality.temperament;
for (const sec of look.sections) {
const rng = new Rng(hashString(`${viz.name}:sec:${sec.index}:${seedBase >>> 0}`));
for (let v = 0; v < (sec.variants || []).length; v++) {
const stack = sec.variants[v];
if (!stack) continue;
const at = subjectIndexOf(stack);
const cur = stack[at];
if (!cur) continue;
const params = sampleValues(viz, rng.fork(`variant:${v}`), sec.bias, temper);
stack[at] = {
module: viz,
params,
seed: rng.int(0, 0x7fffffff),
// A model viz composites by alpha; a fragment viz needs the
// key/screen path over the venue ground. Reuse the old blend
// when it already fits, else fall back to the compositor-safe one.
blend: viz.kind === 'model' ? 'normal'
: (cur.blend === 'normal' ? 'lumakey' : cur.blend),
opacity: cur.opacity ?? 1,
blaze: cur.blaze ?? false,
};
}
}
}
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) {
const picked = venues.find(v => v.name === venueName);
if (!picked) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `unknown venue "${venueName}"`);
return;
}
if (!look.venue) {
look.venue = {
archetype: picked.archetype, name: picked.name, seed: seedBase,
shell: { kind: picked.archetype }, projection: { kind: 'screen' },
population: { kind: 'scatter', count: 5, spread: 0.7, jitter: 0.25 },
paletteMap: [0, 1, 2, 3],
perSection: look.sections.map(() => ({ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 })),
venue: true, readsDepth: false, module: picked,
};
} else {
look.venue.module = picked;
look.venue.name = picked.name;
look.venue.archetype = picked.archetype;
}
}
if (vizMod) pinViz(look, vizMod, seedBase);
const fps = entry.track.fps || 60;
const frame = Math.min(
entry.track.frameCount - 2,
Math.floor(entry.track.duration * 0.35 * fps),
);
try {
show.useTrack(entry.track, look);
} catch (err) {
errorCard(`${entry.name} · col ${colIdx + 1}`, `useTrack failed: ${err.message || err}`);
return;
}
show.warmUp(frame, 24);
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);
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>
+1
View File
@@ -303,6 +303,7 @@ export default defineConfig({
debug: resolve(process.cwd(), 'debug.html'), debug: resolve(process.cwd(), 'debug.html'),
gallery: resolve(process.cwd(), 'gallery.html'), gallery: resolve(process.cwd(), 'gallery.html'),
filmstrip: resolve(process.cwd(), 'filmstrip.html'), filmstrip: resolve(process.cwd(), 'filmstrip.html'),
'venue-gallery': resolve(process.cwd(), 'venue-gallery.html'),
}, },
}, },
}, },