From fd0cbe055398c3e8878221f556cf168f3df0a902 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Sat, 19 Sep 2026 09:08:11 +0200 Subject: [PATCH] Ride: plant the planted shots (passerby/billboard camera + planner fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- flow-state/src/look/venueShots.js | 86 +++++++++++++++++++---- flow-state/src/venues/ride.js | 109 ++++++++++++++++++++++-------- 2 files changed, 155 insertions(+), 40 deletions(-) diff --git a/flow-state/src/look/venueShots.js b/flow-state/src/look/venueShots.js index d972c5c..f6eea1a 100644 --- a/flow-state/src/look/venueShots.js +++ b/flow-state/src/look/venueShots.js @@ -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, diff --git a/flow-state/src/venues/ride.js b/flow-state/src/venues/ride.js index e0a8acb..5238898 100644 --- a/flow-state/src/venues/ride.js +++ b/flow-state/src/venues/ride.js @@ -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 () {