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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(', ')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user