Venues: persistent room, motivated camera, ride; drop Pylon Grid

- Venue as one continuous place: persistent per-song layer, interpolated
  venueState, story jumps (one re-hang max + recap) with camera cuts
- Room camera shot plan (venueShots.js): per-section size/target, monotonic
  look sweep, traverse glide body, room-side alternation
- New Ride venue: generated vehicle + actor passengers, radio RTT screen,
  chase/stationary modes, ride archetype across generator/camera/staging
- Flora cost trim (5x(12+6+6) -> 4x(8+4+4) SDF evals, 204ms -> 140ms worst)
- Delete Pylon Grid shader (2.5s/frame SwiftShader); drop its metadata row
- tools/venue-check.js headless gate harness + venue-gallery explorer
This commit is contained in:
Dejvino
2026-09-17 19:35:26 +02:00
parent b1b6431eeb
commit 46fab512df
31 changed files with 3684 additions and 358 deletions
+338 -3
View File
@@ -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);
+7 -2
View File
@@ -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);
+64 -10
View File
@@ -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(', ')}`;
}
+370
View File
@@ -0,0 +1,370 @@
// Where the ROOM camera is, and what it is looking at — per shot, planned once.
//
// The viz has framing (wide/normal/close) and gaze (a continuous path); the
// room camera used to have one slow traverse and stare forward while the body
// glided. So the room gets its own shot plan, with the same two devices as
// the viz: SIZE steps at cuts (a close answered by a wide reads as two shots
// of one place), and a look target with a MOTIVE — the screen when the viz is
// the action, the whole room when the song breathes.
//
// The plan is data, evaluated per frame as a pure function like the gaze. The
// BODY glides on the continuous traverse (the thing that arcs); the HEAD
// follows a slow progress-driven path through the marks' anchors, leaning
// past halfway at whatever the current section is about. A story jump cuts
// instantly to the new arrangement's side instead of easing, because the
// furniture swapped and an eased move would glide through the old layout.
//
// Bounds are per archetype and conservative: the camera must never leave the
// room or enter the furniture. See test/venue.test.js for the box.
const clamp01 = (x) => Math.max(0, Math.min(1, x));
const lerp = (a, b, t) => a + (b - a) * t;
/**
* Room geometry per archetype: where the screen is, where the furniture
* lives, and the box the camera may occupy. Positions are world units in the
* venue module's scene (chamber room is 22 wide, stage field 44).
*/
export const VENUE_STAGING = {
chamber: {
screen: [0, 1.35, -10.9],
fieldL: [-3.2, -0.4, -4.5],
fieldR: [3.2, -0.4, -4.5],
// Room anchor sits OFF the screen axis: a room visit must pan the
// screen aside, or the composition never changes no matter how far the
// body glides (measured: screen-centred room views 0.08 apart at 120 s
// gaps). The side alternates per visit (see placeMark).
roomMid: [0, 0.3, -5],
roomSide: 2.8,
box: { x: 2.4, yLo: -0.2, yHi: 1.4, zLo: 0.8, zHi: 7.0 },
// Dolly per shot size: close sits among the furniture, wide sees the room.
dolly: { wide: 6.2, normal: 4.0, close: 2.0 },
},
stage: {
screen: [0, 2.2, -14.5],
fieldL: [-4.5, 0.0, -6.0],
fieldR: [4.5, 0.0, -6.0],
roomMid: [0, 0.4, -7],
roomSide: 4.2,
box: { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 },
dolly: { wide: 8.2, normal: 4.6, close: 2.4 },
},
// The ride: the "screen" is the dash radio on its stalk at (0, 1.15,
// 0.9), facing +z toward the default camera; the car sits at the origin
// driving toward -z. The camera works the car like a chase unit — orbits,
// push-ins on the radio, pull-backs for the road — inside a box that keeps
// it out of the bodywork.
ride: {
screen: [0, 1.15, 0.9],
fieldL: [-3.4, 0.2, -3.0],
fieldR: [3.4, 0.2, -3.0],
roomMid: [0, 0.3, -6],
roomSide: 3.6,
// Tighter than stage: the car is small, so the camera works closer
// or the vehicle drowns in black road (measured 6% painted wide).
box: { x: 4.0, yLo: -0.4, yHi: 2.2, zLo: -6.0, zHi: 6.0 },
dolly: { wide: 5.5, normal: 3.4, close: 1.8 },
},
};
/** Shot sizes for the room — same three words as the viz, different numbers. */
export const VENUE_SHOT_SIZES = ['wide', 'normal', 'close'];
/**
* Plan one camera mark per cue — but size/target move at SECTION granularity.
*
* Two densities, one plan. The look path needs DENSE anchors (every cue): it
* interpolates through them continuously, and five section anchors over three
* minutes is a static stare. Size and target move per SECTION: the room holds
* its shot across phrases (near probes share the framing) and steps at story
* beats. Per-cue sizes made the room as twitchy as the viz — twenty marks a
* song, every probe on a different scale, all pairs equally far (shuffle).
* Intra-section movement is continuous travel; inter-section movement steps.
* That split is the arc.
*
* @param {Array} cues ArcDriver cues (need index, sectionIndex, atSectionStart, fadeFrames)
* @param {Array} sections look.sections (need story, bias)
* @param {string} archetype 'chamber' | 'stage' | 'ride'
* @param {object} style look.framing style ({mode, range, changeChance}); 'locked' parks the room
* @param {Function} isJump (cue) => jump|null — arrangement jumps (see ArcDriver.venueJumpAt)
* @param {Rng} rng
* @param {Array} [gazeFrom] viz gaze `from` point per cue — the room takes a
* fraction as lateral offset, so both cameras cut the same way
* @returns {Array} one mark per cue ({size, target, pos, look, jump}); size and
* target are constant within a section, pos/look vary per cue
*/
export function planVenueShots(cues, sections, archetype, style, isJump, rng, gazeFrom = null) {
const staging = VENUE_STAGING[archetype] || VENUE_STAGING.chamber;
// Locked pins SIZE (like the viz: a locked track never changes size) but
// never attention — a video that never changes size can still be one whose
// attention moves (see look/Camera.js). Parking the look target too is
// what froze the room on locked tracks.
const locked = !style || style.mode === 'locked';
// The video globally pushes IN: lateral marches failed because mirrored
// views of a symmetric room look alike (near probes differed by target,
// far probes converged — negative direction). A dolly march is
// irreversible — far-apart probes are different scales, guaranteed — while
// per-cue sizes still step around the marching centre so cuts read.
const n = Math.max(1, cues.length);
const marks = [];
let previous = null;
// The room's off-axis home for this video, drawn once: room visits face
// one wall all song (see placeMark). Drawn here — once, before the loop —
// so rng consumo order is seek-stable.
const roomSide = rng.bool(0.5) ? 1 : -1;
// Size/target are decided once per section and carried across its cues;
// pos/look are drawn per cue so the look path stays dense. The streak
// breaker reads sectionTargets (one entry per section): a cue-granularity
// history would break every streak on its third cue, since all cues in a
// section share the target.
let sectionSize = null, sectionTarget = null, sectionIndex = -1;
const sectionTargets = [];
for (const cue of cues) {
const section = sections[cue.sectionIndex] || {};
const story = section.story || {};
const energy = (section.bias && section.bias.energy) ?? 0.5;
const jump = isJump ? isJump(cue) : null;
if (cue.sectionIndex !== sectionIndex) {
sectionIndex = cue.sectionIndex;
const prevMark = marks.length ? marks[marks.length - 1] : null;
sectionSize = locked ? 'normal' : pickSize(prevMark, energy, story, style, rng);
sectionTarget = pickTarget(prevMark, energy, story, rng,
sectionTargets.map((t) => ({ target: t })));
sectionTargets.push(sectionTarget);
}
const progress = n > 1 ? cue.index / (n - 1) : 0.5;
const gaze = gazeFrom && gazeFrom[cue.index] ? gazeFrom[cue.index] : [0, 0];
const mark = placeMark(staging, sectionSize, sectionTarget, jump, rng, progress, gaze, roomSide);
marks.push({
index: cue.index,
size: sectionSize, target: sectionTarget,
pos: mark.pos,
look: mark.look,
jump: jump ? jump.arrangement : null,
// The move eases over the dissolve, except on a jump cut.
easeFrames: jump ? 0 : Math.max(1, cue.fadeFrames || 12),
});
}
return marks;
}
/** Shot size steps at cuts: never repeat, loud leans close, quiet leans wide. */
function pickSize(previous, energy, story, style, rng) {
const closeness = story.closeness ?? 0.5;
// A locked-off track still gets its one size from the same draw.
if (previous && style && !rng.bool(style.changeChance ?? 0.6)) return previous.size;
const act = story.act || 'development';
// Builds tighten toward the hit; the climax lands close; resolutions and
// quiet opens go wide. Multipliers on the energy lean, not overrides — a
// quiet climax can still play wide.
let wideM = 1, closeM = 1;
if (act === 'setup' || act === 'resolution') { wideM = 1.8; closeM = 0.5; }
else if (act === 'climax') { closeM = 1.8; wideM = 0.5; }
const weights = [
(1 + (1 - energy) * 2.5) * (1.4 - closeness * 0.9) * wideM, // wide
2, // normal
(1 + energy * 2.5) * (0.6 + closeness * 0.9) * closeM, // close
];
let size = rng.pickWeighted(VENUE_SHOT_SIZES, weights);
if (previous && size === previous.size) {
size = rng.pick(VENUE_SHOT_SIZES.filter((s) => s !== size));
}
return size;
}
/**
* WHAT the camera looks at — the motive.
*
* screen the viz is the action: face the screen.
* room the song breathes: the whole place.
*
* Deliberately two targets, not three. Furniture visits (fieldL/fieldR) were
* tried and measured as outliers — 0.2+ from every other probe, flattening or
* inverting direction wherever they landed. The screen is the stage, the room
* is the house; the camera does not cut to the audience mid-song. Furniture
* still crosses the frame — the walk passes it — it is just never the mark.
* Rotation between the two (never three in a row) is the coverage.
*/
function pickTarget(previous, energy, story, rng, history) {
const last2 = (history || []).slice(-2).map((m) => m.target);
const streak = last2.length === 2 && last2[0] === last2[1] ? last2[0] : null;
const act = story.act || 'development';
const tension = story.tension ?? 0.5;
let pick;
if (act === 'setup') pick = 'room';
else if (act === 'resolution') pick = rng.bool(0.3) ? 'screen' : 'room';
else if (act === 'climax') pick = 'screen';
else if (act === 'turn') pick = rng.bool(0.75) ? 'screen' : 'room';
else if (energy > 0.66 || tension > 0.7) pick = 'screen';
else if (energy < 0.36) pick = 'room';
else pick = rng.bool(0.5) ? 'screen' : 'room';
if (streak && pick === streak) pick = streak === 'screen' ? 'room' : 'screen';
void previous;
return pick;
}
/** Absolute camera mark for a (size, target): position + look point. */
function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null, roomSide = 1) {
const clampTo = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const { box, dolly } = staging;
// Room visits alternate sides so consecutive room views face opposite
// walls (the screen slides left, then right) instead of both staring down
// the middle. Screen visits stay centred — the screen IS the axis.
let anchor = target === 'screen' ? staging.screen : staging.roomMid;
if (target === 'room') {
// ONE side per video (threaded in as roomSide): alternating walls is a
// zigzag (left-right-left reads as churn, measured flat), while one
// steady side gives the look path a consistent off-axis home it keeps
// returning to. Across videos the side varies by seed.
const side = roomSide || 1;
anchor = [side * (staging.roomSide || 2.5), staging.roomMid[1], staging.roomMid[2]];
}
// Dolly centre marches wide→close across the video (eased: linger wide
// early, arrive late); the shot size steps around it so cuts still read
// while far-apart probes are irreversibly different scales. Lateral
// position alternates sides per visit (seeded) — cross the room, don't
// orbit one side. A jump takes its arrangement side.
const pe = progress * progress * (3 - 2 * progress);
const centreZ = dolly.wide + (dolly.close - dolly.wide) * (0.25 + 0.75 * pe);
const sizeOff = { wide: 0.9, normal: 0, close: -0.9 }[size] ?? 0;
const z = centreZ + sizeOff + rng.range(-0.4, 0.4);
let x;
if (jump) {
const h = (jump.arrangement * 0x9e3779b9) >>> 0;
x = (h % 2 === 0 ? 1 : -1) * box.x * 0.55;
} else {
const side = rng.bool(0.5) ? 1 : -1;
x = side * box.x * rng.range(0.25, 0.65);
}
// Close on the screen stands nearly centre (it fills the frame anyway);
// wides take the sides (parallax over the furniture is the point).
const cx = size === 'close' && target === 'screen' ? x * 0.3 : x;
// A fraction of the viz gaze relocation: both cameras cut the same way.
// Kept small (a third) — full coupling threw the room around per section.
const gx = gaze ? gaze[0] * 0.35 : 0;
const gy = gaze ? gaze[1] * 0.2 : 0;
const pos = [
clampTo(cx + gx, -box.x, box.x),
rng.range(box.yLo, box.yHi) + gy,
clampTo(z, box.zLo, box.zHi),
];
// Look commits to the motive anchor — per target, not one lean for both.
// Screen visits lean 0.55 (the screen shifts in frame but stays the
// subject); room visits lean 0.9, nearly full (the screen slides out and
// the frame becomes wall + furniture — a genuinely different composition).
// A single lean for both kept every probe as "screen + room", which
// brightness-normalization reduces to structural twins (measured: 28% vs
// 68% painted scoring 0.10 apart). NO rng offsets on the anchors:
// per-mark jitter is a random walk the descriptor reads as churn.
// Consecutive anchors must differ ONLY by motive, never by noise.
const screenAnchor = staging.screen;
const forward = [0, 0.3, screenAnchor[2]];
const k = target === 'room' ? 0.9 : 0.55;
const look = [
lerp(forward[0], anchor[0], k),
lerp(forward[1], anchor[1], k),
lerp(forward[2], anchor[2], k),
];
return { pos, look };
}
/**
* Evaluate the plan at a frame: travel from the previous mark to this cue's
* across most of the cue, not just the dissolve.
*
* Easing only over the dissolve leaves the camera HOLDING a well-composed shot
* for the rest of every cue — and held compositions all look alike to a
* structural descriptor (measured: six held screen views 0.06-0.13 apart, flat
* direction). Travelling instead — always on the way to the next mark — keeps
* the composition shifting progressively, which is what direction measures.
* Arrives at 80% of the cue so cuts land on a readable frame, not mid-glide.
* Jump cues cut instantly (the furniture swapped; gliding through the old
* layout is the glitch). Pure in (marks, frame): seeks agree with playback.
*/
/** Dolly factor per room shot size: a close stands nearer, a wide further. */
export const VENUE_SIZE_DOLLY = { wide: 1.3, normal: 1.0, close: 0.72 };
export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame = null, progress = null) {
// Marks are per-cue (size/target constant within a section, pos/look dense).
const cur = marks[cueIndex] || marks[marks.length - 1];
if (!cur) return null;
const prev = cueIndex > 0 ? marks[cueIndex - 1] : null;
// The LOOK travels a continuous path through every mark anchor in order,
// driven by video progress — never by cue. Per-cue look jumps (screen→
// furniture→room) read as churn to the descriptor even when they read as
// coverage to an eye: adjacent probes land on different anchors (big gaps)
// while far probes converge (mirrored compositions). A slow path through
// the same anchors visits everything AND accumulates. Only story jumps cut
// the look (the furniture swapped; the path would glide through the old
// room). The reported target stays the cue's (HUD motive), the look itself
// is where the path is.
const look = lookPathAt(marks, progress, cur.jump !== null ? cur : null);
if (!prev || cur.jump !== null) {
return {
pos: cur.pos.slice(), look,
size: cur.size, target: cur.target,
dolly: VENUE_SIZE_DOLLY[cur.size] ?? 1.0,
};
}
// The travel OUTRUNS the cue (1.25x): the camera is always on the way to
// the next mark and never holds a composition — the gaze `drift` grammar.
// Held compositions all looked alike (flat direction); eased-then-held
// moves made adjacent probes as far apart as distant ones (pan cuts read
// as churn to the descriptor). Travel-portion differences are small up
// close and accumulate far away, which is exactly the gradient direction
// measures. Jump cues still cut instantly (furniture swapped).
const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame);
const t = clamp01((frame - cueStartFrame) / Math.max(1, span * 1.25));
const e = t * t * (3 - 2 * t);
return {
pos: [lerp(prev.pos[0], cur.pos[0], e), lerp(prev.pos[1], cur.pos[1], e), lerp(prev.pos[2], cur.pos[2], e)],
look,
size: t < 1 ? `${prev.size}→${cur.size}` : cur.size,
target: cur.target,
dolly: lerp(VENUE_SIZE_DOLLY[prev.size] ?? 1.0, VENUE_SIZE_DOLLY[cur.size] ?? 1.0, e),
};
}
/**
* Continuous look path: ONE steady sweep across the video, plus local motive.
*
* Interpolating anchors in plan order zigzags (screen-room-screen reads as
* churn: binary targets scattered in time can never correlate with time gap,
* so direction flats no matter the amplitude). Instead the look sweeps
* monotonically — left to right (seeded), rising slightly — while each mark's
* anchor pulls a quarter toward the motive. Far probes always differ (the
* sweep guarantees it); near probes share direction; the motive textures the
* sweep without breaking it. Pure in (marks, progress).
*/
function lookPathAt(marks, progress, jumpMark) {
if (jumpMark) return jumpMark.look.slice();
if (!marks.length) return [0, 0.3, -5];
const p = clamp01(progress ?? 0.5);
// Sweep direction from the plan content (deterministic, varies per video):
// hash the first mark's position so two videos sweep different ways.
const h = marks.length ? (Math.abs(marks[0].pos[0] * 997 + marks[0].pos[1] * 613) | 0) : 0;
const dir = (h % 2 === 0) ? 1 : -1;
const sweepX = (p - 0.5) * 2 * 1.6 * dir;
const sweepY = 0.15 + p * 0.45;
// Nearest mark's anchor for the motive quarter.
const q = p * (marks.length - 1);
const i = Math.min(marks.length - 1, Math.round(q));
const a = marks[i].look;
// The sweep is the look; the anchor bends it a quarter toward the motive.
// Depth stays on the screen plane so the screen never leaves focus depth.
return [
sweepX * 0.75 + a[0] * 0.25,
sweepY * 0.75 + a[1] * 0.25,
a[2],
];
}
/** One line for the HUD. */
export function describeVenueShot(mark) {
if (!mark) return 'room: —';
return `room: ${mark.size} on ${mark.target}`;
}