Sharp transitions cut color instantly and always reframe

Hard-cut cues now obey a single rule: the colour jumps with the image
(palette blend lerp skipped, even under blend-mode directors — the 2-frame
image ramp stays for flash safety) and both cameras must land somewhere
new, never hold static.

- Viz gaze relocates unconditionally on hard cuts (was +0.3 chance)
- Venue marks force fresh size (≠ previous) and flip look target
  screen↔room on hard-cut cues, landed instantly via venueShotAt
- Motive yields to grammar: a cutaway needn't face the action

Verified on lattice (2 hard cuts): gaze jumps, venue flips size+target
with 1.4/2.6-unit moves. Phases 8/11 fast gates 30/30.
This commit is contained in:
Dejvino
2026-09-19 08:16:46 +02:00
parent 822f4b12ef
commit ce79ccf387
3 changed files with 33 additions and 6 deletions
+3 -1
View File
@@ -708,10 +708,12 @@ export class ArcDriver {
// During a blend transition, lerp from the previous cue's palette to this // 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. // 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 base = palettes[baseIndex] || palettes[0];
let blendT = 0; let blendT = 0;
let blendFrom = -1; 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]; const prevIndex = plan.cues[cueIndex - 1];
if (prevIndex !== baseIndex) { if (prevIndex !== baseIndex) {
const into = frame - cue.startFrame; const into = frame - cue.startFrame;
+3 -1
View File
@@ -279,9 +279,11 @@ function timingFor(spanFrames, story, energy, camera, curve, rng) {
*/ */
function relocatesAt(cue, story, rng) { function relocatesAt(cue, story, rng) {
if (!cue.hardCut && cue.atSectionStart) return true; // a new section is a new place 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; const tension = story ? story.tension : 0.5;
let chance = 0.12 + tension * 0.45; let chance = 0.12 + tension * 0.45;
if (cue.hardCut) chance += 0.3;
if (story) { if (story) {
if (story.act === 'climax') chance += 0.2; if (story.act === 'climax') chance += 0.2;
else if (story.act === 'resolution') chance *= 0.4; else if (story.act === 'resolution') chance *= 0.4;
+25 -2
View File
@@ -128,12 +128,30 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga
const energy = (section.bias && section.bias.energy) ?? 0.5; const energy = (section.bias && section.bias.energy) ?? 0.5;
const jump = isJump ? isJump(cue) : null; 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; sectionIndex = cue.sectionIndex;
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) {
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, sectionTarget = pickTarget(prevMark, energy, story, rng,
sectionTargets.map((t) => ({ target: t })), archetype); sectionTargets.map((t) => ({ target: t })), archetype);
}
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;
@@ -146,6 +164,7 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga
pos: mark.pos, pos: mark.pos,
look: mark.look, look: mark.look,
jump: jump ? jump.arrangement : null, jump: jump ? jump.arrangement : null,
hardCut: !!cue.hardCut,
// The move eases over the dissolve, except on a jump cut. // The move eases over the dissolve, except on a jump cut.
easeFrames: jump ? 0 : Math.max(1, cue.fadeFrames || 12), 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); : 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);
const driveT = clamp01((frame - cueStartFrame) / span); 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 { return {
pos: cur.pos.slice(), look, pos: cur.pos.slice(), look,
size: cur.size, target: cur.target, size: cur.size, target: cur.target,