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:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
sectionTarget = pickTarget(prevMark, energy, story, rng,
|
if (sharpCut && prevMark && sectionSize === prevMark.size) {
|
||||||
sectionTargets.map((t) => ({ target: t })), archetype);
|
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);
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user