Ride: plant the planted shots (passerby/billboard camera + planner fixes)

driveRideCamera checked the passerby/billboard plants after the generic
chase branch, which returns on every planned shot — both plants were dead
code (camera orbited, only the car drive-through worked). Check them first.

Planner fixes for the now-live plants:
- a planted section keeps the plant on its first cue only; later cues fall
  back to the screen/room rotation (a whole section planted is a stuck
  camera with the car looping through one frame)
- venueShotAt holds plant position too, not just the look
- no consecutive planted sections in either order; the section-level
  history never forms streaks for single-cue sections, so gate on the
  previous section directly (draw-first, rng-neutral elsewhere)
- passerby plant back/up, aimed between approach and pass point so the
  whoosh does not swallow the frame

Verified: billboard holds centre-frame with the viz live while the car
crosses mid-cue; passerby plant static +/- handheld; tests 24/24, scene
lint clean, standard gates unchanged vs HEAD (P9/P12 + census
centre/drone fail pre-existing).
This commit is contained in:
Dejvino
2026-09-19 09:08:11 +02:00
parent ce79ccf387
commit fd0cbe0553
2 changed files with 155 additions and 40 deletions
+73 -13
View File
@@ -121,6 +121,13 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga
// history would break every streak on its third cue, since all cues in a // history would break every streak on its third cue, since all cues in a
// section share the target. // section share the target.
let sectionSize = null, sectionTarget = null, sectionIndex = -1; let sectionSize = null, sectionTarget = null, sectionIndex = -1;
// Planted shots (passerby/billboard) hold the camera still while the car
// does the travelling — one cue of that is a beat, a whole section of it
// is a stuck camera with the car looping through the same frame. The
// plant opens its section; the section's remaining cues fall back to the
// screen/room rotation. (The section still counts as planted in
// sectionTargets, so the adjacency rules stay conservative.)
let sectionPlants = 0;
const sectionTargets = []; const sectionTargets = [];
for (const cue of cues) { for (const cue of cues) {
const section = sections[cue.sectionIndex] || {}; const section = sections[cue.sectionIndex] || {};
@@ -135,6 +142,7 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga
const sharpCut = !!cue.hardCut && marks.length > 0; const sharpCut = !!cue.hardCut && marks.length > 0;
if (cue.sectionIndex !== sectionIndex || sharpCut) { if (cue.sectionIndex !== sectionIndex || sharpCut) {
sectionIndex = cue.sectionIndex; sectionIndex = cue.sectionIndex;
sectionPlants = 0;
const prevMark = marks.length ? marks[marks.length - 1] : null; const prevMark = marks.length ? marks[marks.length - 1] : null;
sectionSize = locked ? 'normal' : pickSize(prevMark, energy, story, style, rng); sectionSize = locked ? 'normal' : pickSize(prevMark, energy, story, style, rng);
if (sharpCut && prevMark && sectionSize === prevMark.size) { if (sharpCut && prevMark && sectionSize === prevMark.size) {
@@ -143,7 +151,7 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga
if (sharpCut && prevMark) { if (sharpCut && prevMark) {
// Flip the look direction outright: screen↔room, so the new // Flip the look direction outright: screen↔room, so the new
// composition cannot be the old one from a step aside. // composition cannot be the old one from a step aside.
// (Passerby has no opposite — re-draw it instead.) // (Passerby/billboard have no opposite — re-draw instead.)
const pt = prevMark.target; const pt = prevMark.target;
sectionTarget = pt === 'screen' ? 'room' sectionTarget = pt === 'screen' ? 'room'
: pt === 'room' ? 'screen' : pt === 'room' ? 'screen'
@@ -152,15 +160,28 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga
sectionTarget = pickTarget(prevMark, energy, story, rng, sectionTarget = pickTarget(prevMark, energy, story, rng,
sectionTargets.map((t) => ({ target: t })), archetype); sectionTargets.map((t) => ({ target: t })), archetype);
} }
// A billboard shot wants car + board in frame: force wide.
if (sectionTarget === 'billboard' && !locked) sectionSize = 'wide';
sectionTargets.push(sectionTarget); sectionTargets.push(sectionTarget);
} }
const progress = n > 1 ? cue.index / (n - 1) : 0.5; const progress = n > 1 ? cue.index / (n - 1) : 0.5;
const gaze = gazeFrom && gazeFrom[cue.index] ? gazeFrom[cue.index] : [0, 0]; const gaze = gazeFrom && gazeFrom[cue.index] ? gazeFrom[cue.index] : [0, 0];
const mark = placeMark(staging, sectionSize, sectionTarget, jump, rng, progress, gaze, roomSide); // Only the section's first planted cue keeps the plant; later ones
// rejoin the rotation, alternating away from whatever just played so
// the cut out of the plant always changes the composition.
let cueTarget = sectionTarget;
if (cueTarget === 'passerby' || cueTarget === 'billboard') {
sectionPlants++;
if (sectionPlants > 1) {
const pt = marks.length ? marks[marks.length - 1].target : null;
cueTarget = pt === 'screen' ? 'room' : 'screen';
}
}
const mark = placeMark(staging, sectionSize, cueTarget, jump, rng, progress, gaze, roomSide);
marks.push({ marks.push({
index: cue.index, index: cue.index,
size: sectionSize, target: sectionTarget, size: sectionSize, target: cueTarget,
pos: mark.pos, pos: mark.pos,
look: mark.look, look: mark.look,
jump: jump ? jump.arrangement : null, jump: jump ? jump.arrangement : null,
@@ -216,12 +237,29 @@ function pickTarget(previous, energy, story, rng, history, archetype = null) {
const tension = story.tension ?? 0.5; const tension = story.tension ?? 0.5;
// Ride-only: a passerby section plants the camera at the roadside and lets // Ride-only: a passerby section plants the camera at the roadside and lets
// the car drive through the frame. Offered on breaths (setup/resolution, // the car drive through the frame. Offered on breaths (setup/resolution,
// quiet passages) — never twice running, never at the climax. // quiet passages) — never twice running, never right after a billboard
if (archetype === 'ride' && act !== 'climax' && streak !== 'passerby' // section, never at the climax: two planted shots back to back read as
// stuck, in either order (the billboard rule below guards the mirror).
// The draw is consumed even when adjacency blocks the plant so the rng
// stream — and every plan without a blocked draw — stays exactly as
// before. (A streak check cannot do this: sectionTargets holds one entry
// per section, so single-cue plant sections never form a streak of two.)
const passerbyDraw = archetype === 'ride' && act !== 'climax'
&& (act === 'setup' || act === 'resolution' || energy < 0.42) && (act === 'setup' || act === 'resolution' || energy < 0.42)
&& rng.bool(0.45)) { && rng.bool(0.45);
if (passerbyDraw && last2[last2.length - 1] !== 'passerby'
&& last2[last2.length - 1] !== 'billboard') {
return 'passerby'; return 'passerby';
} }
// Ride-only: a billboard section aims a static camera at the roadside
// billboard while the car drives through the frame in front of it. Rarer
// than passerby (at most a couple per video), same breath-only rule, never
// adjacent to a passerby — two planted shots back to back read as stuck.
if (archetype === 'ride' && act !== 'climax' && !streak
&& (act === 'setup' || act === 'resolution' || energy < 0.42)
&& last2[last2.length - 1] !== 'passerby' && rng.bool(0.3)) {
return 'billboard';
}
let pick; let pick;
if (act === 'setup') pick = 'room'; if (act === 'setup') pick = 'room';
else if (act === 'resolution') pick = rng.bool(0.3) ? 'screen' : 'room'; else if (act === 'resolution') pick = rng.bool(0.3) ? 'screen' : 'room';
@@ -243,9 +281,25 @@ function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null
// Shoulder side follows the video's roomSide: one consistent side all song. // Shoulder side follows the video's roomSide: one consistent side all song.
if (target === 'passerby') { if (target === 'passerby') {
const side = roomSide || 1; const side = roomSide || 1;
// Planted a step back and up from the lane so the drive-by whoosh
// does not swallow the frame, aimed between the approach (far +z)
// and the pass point: the car grows head-on in the first third,
// crosses near centre mid-cue, and the tail holds road + glow.
return { return {
pos: [side * 3.6, 1.2, -2], pos: [side * 4.4, 1.5, -2],
look: [-side * 1.0, 0.2, 8], look: [-side * 0.5, 0.3, 3],
};
}
if (target === 'billboard') {
// Anchor where billboards live (far shoulder, panel height): the
// module aims at the live panel each frame, so this only bends the
// look-path sweep toward a sensible place for neighboring cues.
// Forced wide in the planner (see below) — a billboard shot wants
// the car AND the board in frame.
const side = roomSide || 1;
return {
pos: [side * 3.6, 1.4, 0],
look: [-side * 9.0, 3.0, -20],
}; };
} }
const clampTo = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const clampTo = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
@@ -342,10 +396,11 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame =
// the look (the furniture swapped; the path would glide through the old // 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 // room). The reported target stays the cue's (HUD motive), the look itself
// is where the path is. // is where the path is.
// Passerby holds its plant: the camera is a bystander, and any drift of // Passerby/billboard hold their plants: the camera is a bystander, and
// the look would read as a pan away from the road. driveT (0→1 across the // any drift of the look would read as a pan away from the road. driveT
// cue) is the car's cue — the module drives it from far +z to far -z on it. // (0→1 across the cue) is the car's cue — the module drives it from far
const look = cur.target === 'passerby' // +z to far -z on it (passerby) or aims at the live panel (billboard).
const look = (cur.target === 'passerby' || cur.target === 'billboard')
? cur.look.slice() ? cur.look.slice()
: lookPathAt(marks, progress, cur.jump !== null ? cur : null); : lookPathAt(marks, progress, cur.jump !== null ? cur : null);
const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame); const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame);
@@ -371,8 +426,13 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame =
// measures. Jump cues still cut instantly (furniture swapped). // measures. Jump cues still cut instantly (furniture swapped).
const t = clamp01((frame - cueStartFrame) / Math.max(1, span * 1.25)); const t = clamp01((frame - cueStartFrame) / Math.max(1, span * 1.25));
const e = t * t * (3 - 2 * t); const e = t * t * (3 - 2 * t);
// A planted camera holds its position too — the bystander does not drift
// down the road while the car passes. (The look already holds above; the
// module adds handheld micro on top.)
const planted = cur.target === 'passerby' || cur.target === 'billboard';
return { 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)], pos: planted ? cur.pos.slice()
: [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, look,
size: t < 1 ? `${prev.size}→${cur.size}` : cur.size, size: t < 1 ? `${prev.size}→${cur.size}` : cur.size,
target: cur.target, target: cur.target,
+82 -27
View File
@@ -610,21 +610,27 @@ export const ride = {
// far +z at cue start, past the lens mid-cue, far -z at the end — and // far +z at cue start, past the lens mid-cue, far -z at the end — and
// the world holds still around it. (A scrolling world under a driving // the world holds still around it. (A scrolling world under a driving
// car would double the motion and read as a slide projector.) // car would double the motion and read as a slide projector.)
// Billboard: same planted grammar, but aimed at the roadside
// billboard while the car drives through in front of it.
const shot = framing && framing.venueShot ? framing.venueShot : null; const shot = framing && framing.venueShot ? framing.venueShot : null;
const passerby = !!shot && shot.target === 'passerby'; const passerby = !!shot && shot.target === 'passerby';
const driveT = passerby && typeof shot.drive === 'number' const billboard = !!shot && shot.target === 'billboard';
const driveT = (passerby || billboard) && typeof shot.drive === 'number'
? Math.max(0, Math.min(1, shot.drive)) : null; ? Math.max(0, Math.min(1, shot.drive)) : null;
// World scroll: dashes, posts, roadside, chevrons and streaks stream // World scroll: dashes, posts, roadside, chevrons and streaks stream
// past toward +z (the car drives toward -z, so the world comes at the // past toward +z (the car drives toward -z, so the world comes at the
// camera). Frozen during passerby (see above). Parked mode keeps a 12% // camera). Frozen during passerby (see above) and during billboard —
// except the billboard slot itself, which keeps its analytic scroll
// (at half rate: it's a distant parallax layer, and holding it still
// while aimed at it would read as parked). Parked mode keeps a 12%
// idle creep plus a floor so the world never reads dead — a frozen // idle creep plus a floor so the world never reads dead — a frozen
// world with a crawling camera measured as "no movement at all". // world with a crawling camera measured as "no movement at all".
// Analytic in t (not += per frame): a seek must land on the same // Analytic in t (not += per frame): a seek must land on the same
// positions as playback. The chase rate reads as driving at a glance // positions as playback. The chase rate reads as driving at a glance
// (~3x the original). // (~3x the original).
{ {
const dz = passerby ? 0 const cruise = (instance.chase ? speed * 26 : speed * 26 * 0.12 + 0.25) * rate;
: (instance.chase ? speed * 26 : speed * 26 * 0.12 + 0.25) * rate; const dz = (passerby || billboard) ? 0 : cruise;
const kids = instance.dashes.children; const kids = instance.dashes.children;
for (let i = 0; i < kids.length; i++) { for (let i = 0; i < kids.length; i++) {
const z0 = -70 + i * 6; const z0 = -70 + i * 6;
@@ -641,9 +647,18 @@ export const ride = {
} }
// Roadside set dressing streams on the same clock: 12 slots spaced // Roadside set dressing streams on the same clock: 12 slots spaced
// 8 over [-84, +12]. Same wrap math, so houses/signs/trees and the // 8 over [-84, +12]. Same wrap math, so houses/signs/trees and the
// billboard hold formation with the posts. // billboard hold formation with the posts. Frozen whole during
// billboard shots (the snap lands under the cut, like passerby) —
// the motion in a billboard shot comes from the car, not the world.
const rs = instance.roadside.children; const rs = instance.roadside.children;
// During a billboard shot the board is seated at a showcase spot
// in view of the plant below — analytic in the shot flag, so no
// state and no mid-shot drift. (The freeze already snaps the world
// to base under the cut; seating the subject somewhere worth
// looking at costs nothing extra.)
const seatBill = billboard ? billSlotIndex(instance) : -1;
for (let i = 0; i < rs.length; i++) { for (let i = 0; i < rs.length; i++) {
if (i === seatBill) { rs[i].position.z = -8; continue; }
const z0 = -84 + i * 8; const z0 = -84 + i * 8;
const span = 96; const span = 96;
const z = z0 + t * dz; const z = z0 + t * dz;
@@ -691,9 +706,9 @@ export const ride = {
const sweepAmp = instance.chase ? 1.1 : 0.15; const sweepAmp = instance.chase ? 1.1 : 0.15;
const sweep = Math.sin(t * 0.133 + instance.seed * 0.001); const sweep = Math.sin(t * 0.133 + instance.seed * 0.001);
instance.car.position.x = sweep * sweepAmp; instance.car.position.x = sweep * sweepAmp;
// Passerby drive-through: +12 (far, small) to -14 (far, gone) eased // Passerby/billboard drive-through: +12 (far, small) to -14 (far,
// across the cue. Otherwise the car lives at the origin. // gone) eased across the cue. Otherwise the car lives at the origin.
if (passerby && driveT !== null) { if ((passerby || billboard) && driveT !== null) {
const e = driveT * driveT * (3 - 2 * driveT); const e = driveT * driveT * (3 - 2 * driveT);
instance.car.position.z = 12 + (-14 - 12) * e; instance.car.position.z = 12 + (-14 - 12) * e;
} else { } else {
@@ -712,7 +727,7 @@ export const ride = {
// axis (local x after the geometry pre-rotation). During a // axis (local x after the geometry pre-rotation). During a
// passerby the car covers ~26 units a cue, so the wheels spin at // passerby the car covers ~26 units a cue, so the wheels spin at
// drive speed rather than cruise speed. // drive speed rather than cruise speed.
const spin = passerby ? t * 14 : t * speed * 6 * rate; const spin = (passerby || billboard) ? t * 14 : t * speed * 6 * rate;
for (const w of instance.wheels) { for (const w of instance.wheels) {
if (w.userData.tire) w.userData.tire.rotation.z = spin; if (w.userData.tire) w.userData.tire.rotation.z = spin;
if (w.userData.hub) w.userData.hub.rotation.z = spin; if (w.userData.hub) w.userData.hub.rotation.z = spin;
@@ -751,8 +766,11 @@ export const ride = {
const n = instance.roadside.children.length; const n = instance.roadside.children.length;
const dense = (arrangement % 2 === 1) || (vState && vState.popMul > 0.8); const dense = (arrangement % 2 === 1) || (vState && vState.popMul > 0.8);
const showUpTo = dense ? n : Math.max(3, Math.floor(n * 0.45)); const showUpTo = dense ? n : Math.max(3, Math.floor(n * 0.45));
// A billboard shot with a hidden billboard is an empty road: the
// board's own slot is always on while it is the subject.
const forced = billboard ? billSlotIndex(instance) : -1;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
instance.roadside.children[i].visible = opacity > 0.001 && (i < showUpTo); instance.roadside.children[i].visible = opacity > 0.001 && (i < showUpTo || i === forced);
} }
} }
// Dressing takes the palette tint so houses/trees belong to the song; // Dressing takes the palette tint so houses/trees belong to the song;
@@ -826,6 +844,46 @@ function driveRideCamera({ instance, camera, timeline, framing, personality, ven
const lim = { x: 6.0, yLo: -0.2, yHi: 3.2, zLo: -10.0, zHi: 10.0 }; const lim = { x: 6.0, yLo: -0.2, yHi: 3.2, zLo: -10.0, zHi: 10.0 };
const isJump = frame.venueJump !== undefined && frame.venueJump !== null; const isJump = frame.venueJump !== undefined && frame.venueJump !== null;
// Passerby: the camera is a planted bystander — static plant, fixed road
// point, handheld micro only. The car drives through on driveT (see the
// car block below): far +z at cue start, past the lens mid-cue, far -z at
// the end. No orbit, no traverse, no tracking corrections — a static
// camera is the whole point of the shot. (Checked before the generic
// chase branch below, which would otherwise swallow every planned shot.)
if (planned && !isJump && planned.target === 'passerby') {
camera.position.set(
Math.max(-lim.x, Math.min(lim.x, planned.pos[0] + hx * 0.5)),
Math.max(lim.yLo, Math.min(lim.yHi, planned.pos[1] + hy * 0.5)),
Math.max(lim.zLo, Math.min(lim.zHi, planned.pos[2])),
);
camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0;
camera.lookAt(planned.look[0], planned.look[1], planned.look[2]);
camera.updateMatrixWorld();
return;
}
// Billboard: same planted grammar, aimed at the seated panel. The board
// holds at its showcase spot (see above) so the plant is static with
// handheld micro only; the plant sits on the opposite shoulder so the
// car crosses between camera and board on its drive-through.
if (planned && !isJump && planned.target === 'billboard') {
const bi = billSlotIndex(instance);
const bg = bi >= 0 ? instance.roadside.children[bi] : null;
const bx = bg ? bg.position.x : 9;
// Panel centre in world: group pos + scaled local offset (farK 1.2).
const panelPos = [bx, 3.1 * 1.2, -8];
const side = bx >= 0 ? -1 : 1;
camera.position.set(
Math.max(-lim.x, Math.min(lim.x, side * 4.2 + hx * 0.5)),
Math.max(lim.yLo, Math.min(lim.yHi, 1.5 + hy * 0.5)),
Math.max(lim.zLo, Math.min(lim.zHi, -2)),
);
camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0;
camera.lookAt(panelPos[0], panelPos[1], panelPos[2]);
camera.updateMatrixWorld();
return;
}
if (planned && !isJump) { if (planned && !isJump) {
// Wide base: the chase unit works FAR from the car (wides pull back // Wide base: the chase unit works FAR from the car (wides pull back
// to ~7) and HIGH (up to ~3). The old 3.4 base parked every shot in // to ~7) and HIGH (up to ~3). The old 3.4 base parked every shot in
@@ -893,23 +951,6 @@ function driveRideCamera({ instance, camera, timeline, framing, personality, ven
return; return;
} }
// Passerby: the camera is a planted bystander — static plant, fixed road
// point, handheld micro only. The car drives through on driveT (see the
// car block below): far +z at cue start, past the lens mid-cue, far -z at
// the end. No orbit, no traverse, no tracking corrections — a static
// camera is the whole point of the shot.
if (planned && !isJump && planned.target === 'passerby') {
camera.position.set(
Math.max(-lim.x, Math.min(lim.x, planned.pos[0] + hx * 0.5)),
Math.max(lim.yLo, Math.min(lim.yHi, planned.pos[1] + hy * 0.5)),
Math.max(lim.zLo, Math.min(lim.zHi, planned.pos[2])),
);
camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0;
camera.lookAt(planned.look[0], planned.look[1], planned.look[2]);
camera.updateMatrixWorld();
return;
}
// No plan (hand-built looks, tests): legacy traverse, forward look. // No plan (hand-built looks, tests): legacy traverse, forward look.
const baseZ = 3.4; const baseZ = 3.4;
const dolly = baseZ / Math.max(scale, 0.05) - Math.max(0, push); const dolly = baseZ / Math.max(scale, 0.05) - Math.max(0, push);
@@ -925,6 +966,20 @@ function driveRideCamera({ instance, camera, timeline, framing, personality, ven
camera.updateMatrixWorld(); camera.updateMatrixWorld();
} }
/**
* Index of the billboard slot in the roadside group (-1 if none — defensive;
* every build places exactly one). Read off roadsideMeta so plan and module
* agree without sharing rng streams.
*/
function billSlotIndex(instance) {
const meta = instance && instance.roadsideMeta;
if (!meta) return -1;
for (let i = 0; i < meta.length; i++) {
if (meta[i] && meta[i].kind === 'billboard') return i;
}
return -1;
}
function mulberry(seed) { function mulberry(seed) {
let a = seed >>> 0; let a = seed >>> 0;
return function () { return function () {