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:
@@ -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,
|
||||
|
||||
@@ -610,21 +610,27 @@ export const ride = {
|
||||
// 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
|
||||
// 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 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;
|
||||
// World scroll: dashes, posts, roadside, chevrons and streaks stream
|
||||
// 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
|
||||
// world with a crawling camera measured as "no movement at all".
|
||||
// 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
|
||||
// (~3x the original).
|
||||
{
|
||||
const dz = passerby ? 0
|
||||
: (instance.chase ? speed * 26 : speed * 26 * 0.12 + 0.25) * rate;
|
||||
const cruise = (instance.chase ? speed * 26 : speed * 26 * 0.12 + 0.25) * rate;
|
||||
const dz = (passerby || billboard) ? 0 : cruise;
|
||||
const kids = instance.dashes.children;
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
const z0 = -70 + i * 6;
|
||||
@@ -641,9 +647,18 @@ export const ride = {
|
||||
}
|
||||
// Roadside set dressing streams on the same clock: 12 slots spaced
|
||||
// 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;
|
||||
// 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++) {
|
||||
if (i === seatBill) { rs[i].position.z = -8; continue; }
|
||||
const z0 = -84 + i * 8;
|
||||
const span = 96;
|
||||
const z = z0 + t * dz;
|
||||
@@ -691,9 +706,9 @@ export const ride = {
|
||||
const sweepAmp = instance.chase ? 1.1 : 0.15;
|
||||
const sweep = Math.sin(t * 0.133 + instance.seed * 0.001);
|
||||
instance.car.position.x = sweep * sweepAmp;
|
||||
// Passerby drive-through: +12 (far, small) to -14 (far, gone) eased
|
||||
// across the cue. Otherwise the car lives at the origin.
|
||||
if (passerby && driveT !== null) {
|
||||
// Passerby/billboard drive-through: +12 (far, small) to -14 (far,
|
||||
// gone) eased across the cue. Otherwise the car lives at the origin.
|
||||
if ((passerby || billboard) && driveT !== null) {
|
||||
const e = driveT * driveT * (3 - 2 * driveT);
|
||||
instance.car.position.z = 12 + (-14 - 12) * e;
|
||||
} else {
|
||||
@@ -712,7 +727,7 @@ export const ride = {
|
||||
// axis (local x after the geometry pre-rotation). During a
|
||||
// passerby the car covers ~26 units a cue, so the wheels spin at
|
||||
// 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) {
|
||||
if (w.userData.tire) w.userData.tire.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 dense = (arrangement % 2 === 1) || (vState && vState.popMul > 0.8);
|
||||
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++) {
|
||||
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;
|
||||
@@ -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 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) {
|
||||
// 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
|
||||
@@ -893,23 +951,6 @@ function driveRideCamera({ instance, camera, timeline, framing, personality, ven
|
||||
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.
|
||||
const baseZ = 3.4;
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
let a = seed >>> 0;
|
||||
return function () {
|
||||
|
||||
Reference in New Issue
Block a user