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
// section share the target.
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 = [];
for (const cue of cues) {
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;
if (cue.sectionIndex !== sectionIndex || sharpCut) {
sectionIndex = cue.sectionIndex;
sectionPlants = 0;
const prevMark = marks.length ? marks[marks.length - 1] : null;
sectionSize = locked ? 'normal' : pickSize(prevMark, energy, story, style, rng);
if (sharpCut && prevMark && sectionSize === prevMark.size) {
@@ -143,7 +151,7 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga
if (sharpCut && prevMark) {
// Flip the look direction outright: screen↔room, so the new
// 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;
sectionTarget = pt === 'screen' ? 'room'
: pt === 'room' ? 'screen'
@@ -152,15 +160,28 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga
sectionTarget = pickTarget(prevMark, energy, story, rng,
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);
}
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);
// 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({
index: cue.index,
size: sectionSize, target: sectionTarget,
size: sectionSize, target: cueTarget,
pos: mark.pos,
look: mark.look,
jump: jump ? jump.arrangement : null,
@@ -216,12 +237,29 @@ function pickTarget(previous, energy, story, rng, history, archetype = null) {
const tension = story.tension ?? 0.5;
// Ride-only: a passerby section plants the camera at the roadside and lets
// the car drive through the frame. Offered on breaths (setup/resolution,
// quiet passages) — never twice running, never at the climax.
if (archetype === 'ride' && act !== 'climax' && streak !== 'passerby'
// quiet passages) — never twice running, never right after a billboard
// 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)
&& rng.bool(0.45)) {
&& rng.bool(0.45);
if (passerbyDraw && last2[last2.length - 1] !== 'passerby'
&& last2[last2.length - 1] !== 'billboard') {
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;
if (act === 'setup') pick = '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.
if (target === 'passerby') {
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 {
pos: [side * 3.6, 1.2, -2],
look: [-side * 1.0, 0.2, 8],
pos: [side * 4.4, 1.5, -2],
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));
@@ -342,10 +396,11 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame =
// 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.
// Passerby holds its plant: the camera is a bystander, and any drift of
// the look would read as a pan away from the road. driveT (0→1 across the
// cue) is the car's cue — the module drives it from far +z to far -z on it.
const look = cur.target === 'passerby'
// Passerby/billboard hold their plants: the camera is a bystander, and
// any drift of the look would read as a pan away from the road. driveT
// (0→1 across the cue) is the car's cue — the module drives it from far
// +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()
: lookPathAt(marks, progress, cur.jump !== null ? cur : null);
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).
const t = clamp01((frame - cueStartFrame) / Math.max(1, span * 1.25));
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 {
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,
size: t < 1 ? `${prev.size}→${cur.size}` : cur.size,
target: cur.target,