diff --git a/flow-state/src/look/ArcDriver.js b/flow-state/src/look/ArcDriver.js index efd8ead..9e7dadc 100644 --- a/flow-state/src/look/ArcDriver.js +++ b/flow-state/src/look/ArcDriver.js @@ -708,10 +708,12 @@ export class ArcDriver { // During a blend transition, lerp from the previous cue's palette to this // one's over the cue's fadeFrames, in OKLCH so hue travel stays perceptual. + // Sharp transitions never blend: on a hard cut the colour jumps + // instantly with the image, even under a blend-mode director. let base = palettes[baseIndex] || palettes[0]; let blendT = 0; let blendFrom = -1; - if (plan && plan.transition === 'blend' && cue && cueIndex > 0) { + if (plan && plan.transition === 'blend' && cue && cueIndex > 0 && !cue.hardCut) { const prevIndex = plan.cues[cueIndex - 1]; if (prevIndex !== baseIndex) { const into = frame - cue.startFrame; diff --git a/flow-state/src/look/Camera.js b/flow-state/src/look/Camera.js index 985d0fb..62f6dfb 100644 --- a/flow-state/src/look/Camera.js +++ b/flow-state/src/look/Camera.js @@ -279,9 +279,11 @@ function timingFor(spanFrames, story, energy, camera, curve, rng) { */ function relocatesAt(cue, story, rng) { if (!cue.hardCut && cue.atSectionStart) return true; // a new section is a new place + // A sharp transition ALWAYS cuts the camera: the image swaps, so the gaze + // must land somewhere new with it — never a static hold across a hard cut. + if (cue.hardCut) return true; const tension = story ? story.tension : 0.5; let chance = 0.12 + tension * 0.45; - if (cue.hardCut) chance += 0.3; if (story) { if (story.act === 'climax') chance += 0.2; else if (story.act === 'resolution') chance *= 0.4; diff --git a/flow-state/src/look/venueShots.js b/flow-state/src/look/venueShots.js index e02ce1c..d972c5c 100644 --- a/flow-state/src/look/venueShots.js +++ b/flow-state/src/look/venueShots.js @@ -128,12 +128,30 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga const energy = (section.bias && section.bias.energy) ?? 0.5; const jump = isJump ? isJump(cue) : null; - if (cue.sectionIndex !== sectionIndex) { + // A sharp transition re-picks the shot even mid-section: the room + // camera must cut to a different angle/position/direction with the + // image, never hold static across a hard cut. Motive yields to + // grammar here — a cutaway needn't face the action. + const sharpCut = !!cue.hardCut && marks.length > 0; + if (cue.sectionIndex !== sectionIndex || sharpCut) { 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 })), archetype); + if (sharpCut && prevMark && sectionSize === prevMark.size) { + sectionSize = rng.pick(VENUE_SHOT_SIZES.filter((s) => s !== sectionSize)); + } + 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.) + const pt = prevMark.target; + sectionTarget = pt === 'screen' ? 'room' + : pt === 'room' ? 'screen' + : (rng.bool(0.5) ? 'screen' : 'room'); + } else { + sectionTarget = pickTarget(prevMark, energy, story, rng, + sectionTargets.map((t) => ({ target: t })), archetype); + } sectionTargets.push(sectionTarget); } const progress = n > 1 ? cue.index / (n - 1) : 0.5; @@ -146,6 +164,7 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga pos: mark.pos, look: mark.look, jump: jump ? jump.arrangement : null, + hardCut: !!cue.hardCut, // The move eases over the dissolve, except on a jump cut. easeFrames: jump ? 0 : Math.max(1, cue.fadeFrames || 12), }); @@ -331,7 +350,11 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame = : lookPathAt(marks, progress, cur.jump !== null ? cur : null); const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame); const driveT = clamp01((frame - cueStartFrame) / span); - if (!prev || cur.jump !== null) { + // A sharp transition lands instantly on the new mark — position, size and + // look together. The image swaps on this frame, so the reframe lands + // exactly when the eye is least able to see it move. (Same rationale as + // the jump branch: easing through a cut reads as a glitch, not an edit.) + if (!prev || cur.jump !== null || cur.hardCut) { return { pos: cur.pos.slice(), look, size: cur.size, target: cur.target,