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 gallery

+

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
+

the 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)}
duration${formatTime(show.duration)}
sections${show.track.sections.length}
+
venue${show.look.venue ? `${show.look.venue.name} (${show.look.venue.archetype})${show.look.venueEnabled ? '' : ' · off'}` : 'off'}${show.arc && show.arc.state && show.arc.state.venueState ? ` · light ${show.arc.state.venueState.light.toFixed(2)} fog ${show.arc.state.venueState.fog.toFixed(2)} pop ${show.arc.state.venueState.popMul.toFixed(2)}` : ''} ${show.look.venueEnabled ? 'disable' : 'enable'} · gallery
director${show.look.director}${plan ? ` · ${plan.progression}` : ''}
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 +

+
+
+ + + + + + + + starting… +
+
+
+
+ + + diff --git a/flow-state/vite.config.js b/flow-state/vite.config.js index b73bd24..9f0580f 100644 --- a/flow-state/vite.config.js +++ b/flow-state/vite.config.js @@ -303,6 +303,7 @@ export default defineConfig({ debug: resolve(process.cwd(), 'debug.html'), gallery: resolve(process.cwd(), 'gallery.html'), filmstrip: resolve(process.cwd(), 'filmstrip.html'), + 'venue-gallery': resolve(process.cwd(), 'venue-gallery.html'), }, }, },