diff --git a/flow-state/debug.html b/flow-state/debug.html
index b0835c1..f38f6a0 100644
--- a/flow-state/debug.html
+++ b/flow-state/debug.html
@@ -78,6 +78,13 @@
?song= just these ·
EPIC-4.md for the arc it is checking
+
Venue × 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.
+
rows = songs · try ?venue=1 in gallery/filmstrip/app
Drop a track and watch it. test/songs/*.wav holds the
diff --git a/flow-state/filmstrip.html b/flow-state/filmstrip.html
index 8718e24..2c0fe9f 100644
--- a/flow-state/filmstrip.html
+++ b/flow-state/filmstrip.html
@@ -114,6 +114,7 @@ const params = new URLSearchParams(location.search);
const every = Number(params.get('every')) || DEFAULT_EVERY;
const duration = Number(params.get('duration')) || DEFAULT_DURATION;
const only = params.get('song') ? params.get('song').split(',') : null;
+const venueEnabled = params.get('venue') === '1';
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
// 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() {
bar.classList.add('building');
@@ -241,7 +242,7 @@ async function build() {
const total = (only || songNames()).length;
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.
// 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
diff --git a/flow-state/src/Show.js b/flow-state/src/Show.js
index 6013365..4b6d605 100644
--- a/flow-state/src/Show.js
+++ b/flow-state/src/Show.js
@@ -69,9 +69,10 @@ export class Show {
report('look', 0.97);
const samples = monoSamples(audioBuffer);
+ const wantsVenue = this._wantsVenue();
this.setLook(seed === null
- ? generateLook(this.track, { samples })
- : generateLook(this.track, { seed }));
+ ? generateLook(this.track, { samples, venueEnabled: wantsVenue })
+ : generateLook(this.track, { seed, venueEnabled: wantsVenue }));
this.engine.timeline.setDuration(this.track.duration);
this.engine.setFeatureProvider(featureProviderFor(this.track));
@@ -97,7 +98,7 @@ export class Show {
this.track = track;
this.engine.timeline.setDuration(track.duration);
this.engine.setFeatureProvider(featureProviderFor(track));
- this.setLook(look || generateLook(track, { seed: 1 }));
+ this.setLook(look || generateLook(track, { seed: 1, venueEnabled: this._wantsVenue() }));
this.prewarm();
return this;
}
@@ -144,6 +145,37 @@ export class Show {
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. */
_fadeAt(frame) {
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.
if (sceneLayers.some((l) => l && l.module && l.module.kind === 'model')) {
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({
framing,
personality: pers,
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
// through one perspective and one depth buffer.
diff --git a/flow-state/src/checks/filmstrip.js b/flow-state/src/checks/filmstrip.js
index 809e722..254d0d9 100644
--- a/flow-state/src/checks/filmstrip.js
+++ b/flow-state/src/checks/filmstrip.js
@@ -69,11 +69,12 @@ function blockMean(byBlock) {
*
* @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 });
// Seeded off the song's name, so a strip is stable between rebuilds and two
// 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);
const frames = [];
@@ -162,6 +163,7 @@ export function renderSong(show, name, { duration, every }) {
return {
name,
frames,
+ look,
drift: mean(dists),
direction: directionOf(gaps, dists),
// Agitation over the strip: worst 1 s floating window, max(flash/3,
@@ -205,7 +207,7 @@ function momentAt(story, index) {
* times for nothing.
*/
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,
} = {}) {
const list = names && names.length ? names : songNames();
@@ -223,9 +225,9 @@ export async function buildFilmstrip({
}
let row;
try {
- row = renderSong(show, list[i], { duration, every });
+ row = renderSong(show, list[i], { duration, every, venueEnabled });
} 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);
if (onSong) onSong(i + 1, list.length, row);
diff --git a/flow-state/src/engine/Compositor.js b/flow-state/src/engine/Compositor.js
index 029703a..141ad13 100644
--- a/flow-state/src/engine/Compositor.js
+++ b/flow-state/src/engine/Compositor.js
@@ -64,6 +64,12 @@ export class Compositor {
const bh = Math.max(1, Math.floor(h / 2));
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.accumB = r.createTarget(w, h);
this.historyA = r.createTarget(w, h, { float: true });
@@ -89,10 +95,25 @@ export class Compositor {
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 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 pCam = personality ? personality.camera : null;
if (pCam) {
@@ -114,6 +135,154 @@ export class Compositor {
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() {
this.blendMaterial = makePassMaterial(BLEND_FRAG, {
u_base: { value: null },
@@ -271,7 +440,7 @@ export class Compositor {
reset() {
const r = this.renderer;
[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));
}
@@ -291,7 +460,90 @@ export class Compositor {
? this.layers.slice(this.soloIndex, this.soloIndex + 1)
: 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;
r.clear(this.layerTarget);
@@ -391,7 +643,7 @@ export class Compositor {
}
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.sharedDepthTarget].forEach((t) => t && t.dispose());
this.sharedDepthTarget = null;
diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js
index 493e371..f94c1b4 100644
--- a/flow-state/src/engine/Layer.js
+++ b/flow-state/src/engine/Layer.js
@@ -145,6 +145,7 @@ export class Layer {
this.blend = blend;
this.palette = [];
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.position.set(0, 0, 5);
this.actorSpec = options.actorSpec || null;
+ this.venueSpec = options.venueSpec || null;
// Shared rig injected by Compositor at render time when available.
this.sharedCamera = null;
this.instance = this.module.build({
@@ -382,10 +384,48 @@ export class ModelLayer extends Layer {
seed: this.seed,
params: this.baseParams,
actorSpec: this.actorSpec,
+ venueSpec: this.venueSpec,
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. */
setActor(actorSpec) {
this.actorSpec = actorSpec || null;
@@ -418,6 +458,13 @@ export class ModelLayer extends Layer {
framing: this.framing,
opacity: this.opacity,
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,
});
renderer.renderScene(this.scene, cam, target, true);
diff --git a/flow-state/src/engine/Renderer.js b/flow-state/src/engine/Renderer.js
index 6186d41..4c84cfa 100644
--- a/flow-state/src/engine/Renderer.js
+++ b/flow-state/src/engine/Renderer.js
@@ -9,12 +9,37 @@ import { VERTEX_SHADER } from './shader-contract.js';
*/
export class Renderer {
constructor({ width = 1280, height = 720, canvas = null } = {}) {
- this.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',
- });
+ // three@0.181 requires WebGL2. When the browser blocks it (disabled
+ // pref/policy, headless shell without SwiftShader), the constructor
+ // throws — historically a blank page with only a console error, since
+ // Show is built at module scope in main.js. Catch it here and rethrow
+ // 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.setPixelRatio(1); // never device-dependent: output size is explicit
this.gl.setSize(width, height, false);
diff --git a/flow-state/src/look/ArcDriver.js b/flow-state/src/look/ArcDriver.js
index 2a154fa..efd8ead 100644
--- a/flow-state/src/look/ArcDriver.js
+++ b/flow-state/src/look/ArcDriver.js
@@ -5,6 +5,7 @@ import { paletteShiftAt } from './paletteArc.js';
import { shiftPalette, lerpPalettes } from './palette.js';
import { frameShot, neutralFraming } from './framing.js';
import { planGaze, gazeAt } from './Camera.js';
+import { planVenueShots, venueShotAt, describeVenueShot } from './venueShots.js';
import { storyStateAt, NEUTRAL_STATE } from './Story.js';
import { subjectIndexOf, isGround } from './stack.js';
import { groundPersonalityFrom } from '../scenes/surface.js';
@@ -49,6 +50,7 @@ export class ArcDriver {
? look.framing : null;
this._planFraming();
this._planGaze();
+ this._planVenueShots();
this._planPalette();
this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
}
@@ -95,7 +97,48 @@ export class ArcDriver {
_planGaze() {
if (!this.look.camera) return;
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;
}
+ /** 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. */
_actorFor(module) {
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;
}
/** One Layer per (section, variant, layer slot), built lazily and kept. */
_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}`;
let layer = this.layerCache.get(key);
if (!layer) {
- const spec = this._specFor(sectionIndex, variant, slot);
const actorSpec = this._actorFor(spec.module);
+ const venueSpec = spec.module && spec.module.venue ? this._venueFor() : null;
layer = createLayer(spec.module, {
params: spec.params,
seed: spec.seed,
opacity: spec.opacity,
blend: spec.blend,
actorSpec,
+ venueSpec,
});
+ // For venue grounds, also bind venueSpec as actorSpec-like for geometry
layer.setPalette(this.look.palette);
layer.setPersonality(this.look.personality);
// ModelLayers can have their actor swapped without being rebuilt — the
// mesh is imposter-free so the geometry can be re-bound live.
if (actorSpec && layer.setActor) layer.setActor(actorSpec);
+ if (venueSpec && layer.setVenue) layer.setVenue(venueSpec);
this.layerCache.set(key, 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.
* Slow enough to read as evolution rather than wobble — 20 to 70 seconds.
@@ -447,6 +563,92 @@ export class ArcDriver {
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
* rather than remembered, so a seek and playback agree.
@@ -650,6 +852,28 @@ export class ArcDriver {
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) {
// buildSlope is discontinuous at a section boundary by construction:
// 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++) {
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);
layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures, story));
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++) {
const spec = this._specFor(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.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
layer.blend = slot === 0 ? 'normal' : spec.blend;
@@ -707,12 +943,95 @@ export class ArcDriver {
tension: story.tension,
reveal: story.reveal,
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;
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. */
_subjectSlot(cue) {
return subjectIndexOf(this._stackFor(cue));
@@ -736,6 +1055,9 @@ export class ArcDriver {
/** Invalidate caches for one section after an edit or reroll. */
invalidateSection(sectionIndex) {
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}:`)) {
this.layerCache.get(key).dispose();
this.layerCache.delete(key);
@@ -746,6 +1068,7 @@ export class ArcDriver {
this.cues = this._buildCues();
this._planFraming();
this._planGaze();
+ this._planVenueShots();
this._planPalette();
this._paletteKey = null;
this._slopeCache = null;
@@ -757,6 +1080,7 @@ export class ArcDriver {
this.cues = this._buildCues();
this._planFraming();
this._planGaze();
+ this._planVenueShots();
this._planPalette();
this._paletteKey = null;
this._slopeCache = null;
@@ -777,6 +1101,12 @@ export class ArcDriver {
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;
}
@@ -821,6 +1151,11 @@ export class ArcDriver {
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) {
const s = story ?? (this.look.story ? storyStateAt(this.look.story, frame) : null);
return this._personalityAt(s);
diff --git a/flow-state/src/look/Camera.js b/flow-state/src/look/Camera.js
index 07cdb59..985d0fb 100644
--- a/flow-state/src/look/Camera.js
+++ b/flow-state/src/look/Camera.js
@@ -303,9 +303,13 @@ function relocatesAt(cue, story, rng) {
* @param {Array} sections look.sections, for bias and story state
* @param {object} camera from deriveCamera
* @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
*/
-export function planGaze(cues, sections, camera, rng) {
+export function planGaze(cues, sections, camera, rng, forceRelocateAt = null) {
const moves = [];
let at = [0, 0];
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
// least able to see it.
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 jumped = targetFor(from, hop, camera, story, rng, previousDir);
from = clampToReach(jumped.point, reach);
diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js
index e599924..78663be 100644
--- a/flow-state/src/look/LookGenerator.js
+++ b/flow-state/src/look/LookGenerator.js
@@ -31,6 +31,7 @@ import { deriveFramingStyle, describeFraming } from './framing.js';
import { deriveCamera, describeCamera } from './Camera.js';
import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.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
// (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;
}
+let _activeVenueFamily = null; // set per-track for venue-family lean
function candidatesForKind(kind, used, signature = [], director, pool = null) {
const families = director.families[kind] || Object.keys(FAMILIES);
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.
weight: weight * signatureWeight(scene, signature)
* (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,
{ ground = null, director = null, kind = null } = {}) {
const sectionKind = kind;
- // A shot that fills the frame by itself is its own ground; anything else
- // gets one under it.
- const standsAlone = canGround(module) || !ground;
+ // Venue is always the ground — even a canvas shot stands on its venue.
+ // (canGround(venue) is false by design, so the old rule would think a
+ // canvas viz needs no ground and drop the venue.)
+ const isVenueGround = !!(ground && ground.venue);
+ const standsAlone = !isVenueGround && (canGround(module) || !ground);
// --- the blaze ------------------------------------------------------
// 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
// story. Quiet kinds never blaze; a breakdown that goes white is not a
// 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
&& !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));
// 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, {
seed = null, samples = null, overrides = null,
pool: poolOverride = null, poolSize = POOL_SIZE,
+ venueEnabled = false,
} = {}) {
const resolvedSeed = seed !== null
? seed >>> 0
@@ -858,8 +868,38 @@ export function generateLook(track, {
// section, and whether the outro answers the intro. See look/Story.js.
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(
track.sections, rng.fork('scenes'), personality.signature, director, pool);
+ _activeVenueFamily = null;
if (story.recap) applyRecap(rosterByKind);
// What each kind's sections stand on. After the recap, so the outro is
// 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.
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(
module, overlayRoster, bias,
@@ -940,6 +987,8 @@ export function generateLook(track, {
const look = {
seed: resolvedSeed,
+ venue,
+ venueEnabled,
palette,
// The full set the director cuts between — ArcDriver reads this per cue.
palettes,
@@ -1104,8 +1153,11 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
const state = section.story || NEUTRAL_STATE;
// 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.
- const ground = castGround(
- section.kind, roster, rng.fork('ground'), signature, director, new Set());
+ // When venue is enabled the venue IS the ground and must not be re-cast per section.
+ 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(
module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
temperamentFor(look.personality && look.personality.temperament, state), state,
@@ -1123,7 +1175,7 @@ export function rerollLook(look, track, newSeed) {
const locked = new Map();
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) => {
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;
}
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.feedback) look.feedback = { ...look.feedback, ...overrides.feedback };
if (overrides.sections) {
@@ -1167,10 +1220,11 @@ export function describeLook(look) {
? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}`
: '';
const actorTag = look.actors ? ` · ${describeActorSet(look.actors)}` : '';
+ const venueTag = look.venue ? ` · ${describeVenue(look.venue)}` : '';
return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` +
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
- `${describeCamera(look.camera)}${planTag}${actorTag} · ` +
+ `${describeCamera(look.camera)}${planTag}${actorTag}${venueTag} · ` +
`${[...new Set(kinds)].join(', ')}`;
}
diff --git a/flow-state/src/look/venueShots.js b/flow-state/src/look/venueShots.js
new file mode 100644
index 0000000..8b3e924
--- /dev/null
+++ b/flow-state/src/look/venueShots.js
@@ -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}`;
+}
diff --git a/flow-state/src/main.js b/flow-state/src/main.js
index a6aecfb..70bc782 100644
--- a/flow-state/src/main.js
+++ b/flow-state/src/main.js
@@ -52,8 +52,37 @@ const dom = {
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 = {
- show: new Show({ canvas: dom.canvas, width: 1280, height: 720 }),
+ show: bootShow(),
playing: false,
quality: 'full',
loopSection: -1,
@@ -376,6 +405,7 @@ function renderPanel() {
tempo conf.${show.track.tempo.confidence.toFixed(2)}
built on${personality.signature.join(' + ') || 'nothing'}
form${personality.shape.sides || 'round'}${
@@ -413,6 +443,7 @@ function renderPanel() {
Mixes clicks onto the detected beat grid. If they don't sit on
the beat, tempo detection is wrong and everything downstream inherits it.
`;
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;
}
diff --git a/flow-state/src/params/schema.js b/flow-state/src/params/schema.js
index c178478..b1a4e49 100644
--- a/flow-state/src/params/schema.js
+++ b/flow-state/src/params/schema.js
@@ -321,6 +321,12 @@ export function validateModule(module) {
if (module.readsDepth && typeof module.readsDepth !== '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 || {};
diff --git a/flow-state/src/scenes/metadata.json b/flow-state/src/scenes/metadata.json
index 237d9cf..6b8c1a4 100644
--- a/flow-state/src/scenes/metadata.json
+++ b/flow-state/src/scenes/metadata.json
@@ -1475,7 +1475,7 @@
]
}
},
- "Moiré Grid": {
+ "Moir\u00e9 Grid": {
"coverage": 0.327,
"bedCoverage": 0.02,
"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": {
"coverage": 0.085,
"bedCoverage": 0.013,
@@ -5257,7 +5135,7 @@
]
}
},
- "Kármán Street": {
+ "K\u00e1rm\u00e1n Street": {
"coverage": 0.258,
"bedCoverage": 0.047,
"bedCoverageMean": 0.269,
@@ -8550,7 +8428,7 @@
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."
}
}
-}
+}
\ No newline at end of file
diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js
index ce117f0..fb35ad4 100644
--- a/flow-state/src/scenes/registry.js
+++ b/flow-state/src/scenes/registry.js
@@ -29,7 +29,7 @@ import { neonCity } from './shader/neon-city.js';
import { flora } from './shader/flora.js';
import { fireflyDrift } from './shader/firefly-drift.js';
import { silkRibbon } from './shader/silk-ribbon.js';
-import { pylonGrid } from './shader/pylon-grid.js';
+
import { prismBloom } from './shader/prism-bloom.js';
import { pitchShatter } from './shader/pitch-shatter.js';
import { auroraVeil } from './shader/aurora-veil.js';
@@ -114,7 +114,6 @@ const MODULES = [
flora,
fireflyDrift,
silkRibbon,
- pylonGrid,
prismBloom,
pitchShatter,
diff --git a/flow-state/src/scenes/shader/flora.js b/flow-state/src/scenes/shader/flora.js
index b614b09..b28191a 100644
--- a/flow-state/src/scenes/shader/flora.js
+++ b/flow-state/src/scenes/shader/flora.js
@@ -16,7 +16,11 @@ export const flora = {
traits: ['shape', 'camera', 'style'],
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' },
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' },
@@ -62,7 +66,7 @@ vec4 scene(vec2 uv, vec2 p) {
// Soft vertical wash behind the plants.
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;
float fi = float(i);
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.
float stemMask = 0.0;
- for (int j = 0; j < 12; j++) {
- float hj = (float(j) + 0.5) / 12.0;
+ for (int j = 0; j < 8; j++) {
+ float hj = (float(j) + 0.5) / 8.0;
float cx = baseX + swayAt(hj, s, t) * u_sway;
float cy = baseY + hj * height;
float d = length(p - vec2(cx, cy));
@@ -87,9 +91,9 @@ vec4 scene(vec2 uv, vec2 p) {
float tipY = baseY + height;
// 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 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 nx = baseX + swayAt(node, s, t) * u_sway;
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.
float bloom = 0.0;
- for (int k = 0; k < 6; k++) {
- float a = 6.2831853 * (float(k) + 0.5) / 6.0 + s;
+ for (int k = 0; k < 4; k++) {
+ 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);
bloom += castForm(p - vec2(tipX, tipY), off, u_leaf * 0.5);
}
diff --git a/flow-state/src/scenes/shader/pylon-grid.js b/flow-state/src/scenes/shader/pylon-grid.js
deleted file mode 100644
index 520b2cd..0000000
--- a/flow-state/src/scenes/shader/pylon-grid.js
+++ /dev/null
@@ -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);
-}
-`,
-};
\ No newline at end of file
diff --git a/flow-state/src/scenes/stage/pylon-field-3d.js b/flow-state/src/scenes/stage/pylon-field-3d.js
index 8d93d84..002777e 100644
--- a/flow-state/src/scenes/stage/pylon-field-3d.js
+++ b/flow-state/src/scenes/stage/pylon-field-3d.js
@@ -1,12 +1,9 @@
// STAGE: Pylon Field 3D — the song's chorus as structure, with depth.
//
-// The flat pylon-grid stamps castSolid/castChorusSolid impostors on a screen-space
-// perspective grid (pylon-grid.js:83-168). This is the mesh twin: the same
-// rows×columns grid, the same chorus-stacked legs (segs from stageScale) and the
-// same beat-walk pulse — now as BufferGeometry with real occlusion, parallax and
-// 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.
+// A rows×columns grid of chorus-stacked legs (segs from stageScale) with a
+// beat-walk pulse — as BufferGeometry with real occlusion, parallax and scale
+// foreshortening. Crowns are the protagonist assembly via actorToGeometry,
+// legs are chorus rungs, ground is a plane at sigHorizonY level.
//
// Analytic motion only: f(t,seed,params,actor.motion). No integration, so seek ===
// playback. Camera is the shared rig (Compositor.sharedCamera) driven from
diff --git a/flow-state/src/scenes/surface.js b/flow-state/src/scenes/surface.js
index 8377a56..bd1b520 100644
--- a/flow-state/src/scenes/surface.js
+++ b/flow-state/src/scenes/surface.js
@@ -132,7 +132,19 @@ const ROW = (module) => (metadata.scenes && metadata.scenes[module.name]) || nul
*/
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) {
+ if (isVenue(module)) return 0.72;
const row = ROW(module);
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.
*/
export function groundCoverageOf(module) {
+ if (isVenue(module)) return 0.72;
const row = ROW(module);
if (!row) return ASSUMED_COVERAGE;
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.
*/
export function groundFloorCoverageOf(module) {
+ if (isVenue(module)) return 0.62;
const row = ROW(module);
if (!row) return ASSUMED_COVERAGE;
return row.bedCoverage === undefined ? groundCoverageOf(module) : row.bedCoverage;
@@ -191,6 +205,8 @@ export function surfaceOf(module) {
* tolerance of 4.
*/
export function canGround(module) {
+ if (isVenue(module)) return false;
+ if (module && module.readsDepth) return false;
return isMeasured(module)
&& surfaceOf(module) === 'canvas'
&& groundCoverageOf(module) >= GROUND_MIN
diff --git a/flow-state/src/venues/VenueGenerator.js b/flow-state/src/venues/VenueGenerator.js
new file mode 100644
index 0000000..4395389
--- /dev/null
+++ b/flow-state/src/venues/VenueGenerator.js
@@ -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}`;
+}
diff --git a/flow-state/src/venues/chamber.js b/flow-state/src/venues/chamber.js
new file mode 100644
index 0000000..2efc792
--- /dev/null
+++ b/flow-state/src/venues/chamber.js
@@ -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;
diff --git a/flow-state/src/venues/projection.js b/flow-state/src/venues/projection.js
new file mode 100644
index 0000000..e2479ca
--- /dev/null
+++ b/flow-state/src/venues/projection.js
@@ -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;
+ }
+}
diff --git a/flow-state/src/venues/registry.js b/flow-state/src/venues/registry.js
new file mode 100644
index 0000000..d88c8d5
--- /dev/null
+++ b/flow-state/src/venues/registry.js
@@ -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;
diff --git a/flow-state/src/venues/ride.js b/flow-state/src/venues/ride.js
new file mode 100644
index 0000000..15ad79e
--- /dev/null
+++ b/flow-state/src/venues/ride.js
@@ -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;
diff --git a/flow-state/src/venues/stage.js b/flow-state/src/venues/stage.js
new file mode 100644
index 0000000..5645457
--- /dev/null
+++ b/flow-state/src/venues/stage.js
@@ -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; r1 ? 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; c0.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;
diff --git a/flow-state/test/venue.test.js b/flow-state/test/venue.test.js
new file mode 100644
index 0000000..82a915c
--- /dev/null
+++ b/flow-state/test/venue.test.js
@@ -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');
+}
diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js
index b139dfc..be22fe1 100644
--- a/flow-state/tools/lint-scenes.js
+++ b/flow-state/tools/lint-scenes.js
@@ -39,7 +39,7 @@ const FORBIDDEN = [
];
// Directories whose output must be a pure function of (seed, params, frame).
-const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params', 'actors'];
+const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params', 'actors', 'venues'];
// Files legitimately allowed a wall clock: perf measurement, not image content.
const ALLOWED = new Set(['engine/perf.js']);
diff --git a/flow-state/tools/new-venue.js b/flow-state/tools/new-venue.js
new file mode 100644
index 0000000..8f1900e
--- /dev/null
+++ b/flow-state/tools/new-venue.js
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+// Scaffold a new venue — mirrors tools/new-scene.js --model.
+// Venue IS the ground (role:'ground'), kind:'model' with build/update stubs
+// that map venueSpec.perSection + stageNode pattern.
+// Usage: node tools/new-venue.js "Harbour" --archetype=chamber
+import { readFileSync, writeFileSync, existsSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const VENUES = join(root, 'src/venues');
+const REGISTRY = join(root, 'src/venues/registry.js');
+const ARCHETYPES = ['chamber','stage','ride'];
+const args = process.argv.slice(2);
+const flags = new Map(args.filter(a=>a.startsWith('--')).map(a=>{ const [k,v]=a.replace(/^--/,'').split('='); return [k, v===undefined?true:v]; }));
+const name = args.find(a=>!a.startsWith('--'));
+if (!name) { console.error(`usage: node tools/new-venue.js "Name" --archetype=<${ARCHETYPES.join('|')}>`); process.exit(1); }
+const archetype = String(flags.get('archetype')||'chamber');
+if (!ARCHETYPES.includes(archetype)) { console.error(`unknown archetype '${archetype}'`); process.exit(1); }
+const kebab = name.trim().toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
+const camel = kebab.replace(/-([a-z0-9])/g, (_,c)=>c.toUpperCase());
+const file = join(VENUES, `${kebab}.js`);
+if (existsSync(file)) { console.error(`${kebab}.js already exists`); process.exit(1); }
+const salt = (text, lo, hi)=>{ let h=2166136261; for(let i=0;i>>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;i0.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})`);
diff --git a/flow-state/tools/venue-check.js b/flow-state/tools/venue-check.js
new file mode 100644
index 0000000..718dca2
--- /dev/null
+++ b/flow-state/tools/venue-check.js
@@ -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);
diff --git a/flow-state/venue-gallery.html b/flow-state/venue-gallery.html
new file mode 100644
index 0000000..55bc90a
--- /dev/null
+++ b/flow-state/venue-gallery.html
@@ -0,0 +1,373 @@
+
+
+
+
+flow-state · venue gallery
+
+
+
+
flow-state · venue gallery
+
+ 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 songs (rows) × seeds (cols) — each column is
+ a fresh seeded draw of the same song, so venue and viz vary by census across columns when left on
+ auto. Render re-renders the grid; narrow it with the dropdowns first.
+ A sad slow chamber of memories vs. a pumping stage field, same songs, side by side.
+ ← all debug tools · open app with venue
+