Epic 2.5.1: basis for framing layer

This commit is contained in:
Dejvino
2026-08-06 10:29:32 +02:00
parent 189328587d
commit 19bfff8651
9 changed files with 346 additions and 3 deletions
+37
View File
@@ -3,6 +3,7 @@ import { Rng } from '../engine/rng.js';
import { clampValue } from '../params/schema.js';
import { paletteShiftAt } from './paletteArc.js';
import { shiftPalette } from './palette.js';
import { frameShot, neutralFraming } from './framing.js';
/**
* Drives the look across the song.
@@ -39,9 +40,35 @@ export class ArcDriver {
this.driftPlans = new Map();
this.activeLayers = [];
this.cues = this._buildCues();
this.framingStyle = (look.framing && look.framing.mode !== 'locked')
? look.framing : null;
this._planFraming();
this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
}
/**
* Give every cue the framing its shot will be played with.
*
* Framing is the one device that lives at the SHOT level — a wide answered
* by a close is the whole point of it, and both belong to the same scene.
* Planned here, once, walking the cues in order, so the whole video gets a
* consistent hand and a seek always finds the same framing as playback.
* A track that decided to stay locked-off (or a hand-built look with no
* framing) gets the neutral, unframed read everywhere.
*/
_planFraming() {
if (!this.framingStyle) return;
const rng = new Rng((this.look.seed ^ 0x517cc1b7) >>> 0);
let previous = null;
for (const cue of this.cues) {
const section = this.look.sections[cue.sectionIndex];
const energy = (section.bias && section.bias.energy) || 0;
const framing = frameShot(this.framingStyle, previous, energy, rng);
cue.framing = framing;
previous = framing;
}
}
dispose() {
for (const layer of this.layerCache.values()) layer.dispose();
this.layerCache.clear();
@@ -386,6 +413,12 @@ export class ArcDriver {
const t = fading ? framesIntoCue / cue.fadeFrames : 1;
const eased = t * t * (3 - 2 * t);
// The shot being played INTO carries its own framing; the shot fading
// out keeps the framing it was filmed with, so a cut changes the size
// exactly when the cut changes the image rather than half a beat after.
const framing = cue.framing || neutralFraming();
const outgoingFraming = previous ? (previous.framing || neutralFraming()) : framing;
const layers = [];
if (fading) {
@@ -409,6 +442,7 @@ export class ArcDriver {
layer.blend = slot === 0 ? 'normal' : spec.blend;
layer.setPalette(palette);
layer.setPersonality(this.look.personality);
layer.setFraming(outgoingFraming);
layers.push(layer);
}
}
@@ -421,6 +455,7 @@ export class ArcDriver {
layer.blend = slot === 0 ? 'normal' : spec.blend;
layer.setPalette(palette);
layer.setPersonality(this.look.personality);
layer.setFraming(framing);
layers.push(layer);
}
@@ -462,6 +497,7 @@ export class ArcDriver {
}
// A reroll re-plans the section's shots, so the cue list is stale too.
this.cues = this._buildCues();
this._planFraming();
this._slopeCache = null;
}
@@ -469,6 +505,7 @@ export class ArcDriver {
this.dispose();
this.driftPlans.clear();
this.cues = this._buildCues();
this._planFraming();
this._slopeCache = null;
}
+5 -1
View File
@@ -13,6 +13,7 @@ import { generatePersonality, sceneHonours, describePersonality } from './Person
import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
import { pickDirector, directorByName } from './directors.js';
import { derivePaletteArc, describePaletteArc } from './paletteArc.js';
import { deriveFramingStyle, describeFraming } from './framing.js';
// Which families suit which section kind now comes from the track's DIRECTOR
// (look/directors.js) rather than from a constant here. The coupling it
@@ -292,6 +293,8 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
const grain = deriveGrain(summary, rng.fork('grain'));
// How the palette MOVES over the track. See look/paletteArc.js.
const paletteArc = derivePaletteArc(summary, rng.fork('paletteArc'));
// Whether shots change SIZE at the cut, and how boldly. See look/framing.js.
const framing = deriveFramingStyle(summary, rng.fork('framing'));
const { post, feedback } = derivePost(summary, rng.fork('post'), grain);
// Scenes that declare role 'accent' composite over a background rather than
@@ -348,6 +351,7 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
paletteScheme: paletteSource.lastScheme,
director: director.name,
paletteArc,
framing,
grain,
post,
feedback,
@@ -427,7 +431,7 @@ export function describeLook(look) {
const kinds = look.sections.map((s) => `${s.kind}:${s.layers[0].module.name}`);
return `seed ${look.seed.toString(16)} · ${look.director} · ${look.paletteScheme} · ` +
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
`${describePaletteArc(look.paletteArc)} · ` +
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
`${[...new Set(kinds)].join(', ')}`;
}
+7
View File
@@ -205,6 +205,11 @@ export function signatureUniforms(personality, module = null) {
u_sigSoft: style.softness,
u_sigTexture: style.texture * textureAffinity,
u_sigFold: style.symmetry,
// Framing is per shot, not per track: the arc driver overwrites these
// every frame. Neutral here so a layer built without one is unframed.
u_sigFrameScale: 1,
u_sigFrameShift: [0, 0],
};
}
@@ -225,6 +230,8 @@ export const NEUTRAL_UNIFORMS = {
u_sigSoft: 0.5,
u_sigTexture: 0,
u_sigFold: 1,
u_sigFrameScale: 1,
u_sigFrameShift: [0, 0],
};
const SHAPE_NAMES = { 0: 'round', 3: 'triangular', 4: 'square', 5: 'pentagonal', 6: 'hexagonal', 8: 'octagonal' };
+114
View File
@@ -0,0 +1,114 @@
// How a shot is FRAMED, as opposed to what it contains.
//
// Every scene in the library is a locked-off, full-frame wide, and always has
// been. That is one shot type, held for the length of a song. Cutting between
// two scenes therefore changes the subject and never the framing — and framing
// is at least half of how a real edit holds attention. A wide answered by a
// close reads as two shots of one thing; two wides read as two things.
//
// So a shot now carries a scale and a recentre, applied in SCENE coordinates
// inside sigCamera. That distinction matters: a close-up is rendered close
// rather than being a magnified 720p frame, which is why this is a coordinate
// transform and not a post pass. It is also why it costs nothing at 4K.
//
// Framing is per shot and constant within it. A zoom that moves during a shot
// is a different device — one that would fight the drift LFO and the slow axis,
// both of which already own continuous motion.
/**
* The shot sizes, as multipliers on the scene's coordinate scale.
*
* Bounded much more tightly than a real camera would be. Past about 2.2 most
* scenes in this library run out of detail and a close-up is just a blurry
* wide; below about 0.55 the subject is a speck in an empty frame. Both were
* measured by pushing until the image stopped being worth looking at.
*/
export const SHOT_SIZES = {
wide: { scale: 0.62, drift: 0.06 },
normal: { scale: 1.0, drift: 0.05 },
close: { scale: 1.7, drift: 0.10 },
};
export const SHOT_SIZE_NAMES = Object.keys(SHOT_SIZES);
/**
* Whether this track uses framing at all, and how boldly.
*
* A track that never changes size is a legitimate look — locked-off is a style,
* and it is the one the whole library was built in — so it stays reachable.
* What is not acceptable is it being the only option, which is what it was.
*/
export function deriveFramingStyle(summary, rng) {
const mode = rng.pickWeighted(['locked', 'gentle', 'edited'], [1, 2.5, 3]);
return {
mode,
// How far from `normal` this track is willing to go.
range: mode === 'locked' ? 0 : mode === 'gentle' ? 0.45 : 1,
// Chance a cut also changes the shot size, rather than only the image.
changeChance: mode === 'locked' ? 0 : mode === 'gentle' ? 0.35 : 0.6,
};
}
/**
* Choose the framing for one shot.
*
* `previous` is the framing of the shot before it, and it is the whole point:
* a size only means something relative to the size before it. The rule is that
* a change of size must be a real change — a wide answered by a slightly less
* wide is not a cut, it is a mistake — so sizes step rather than slide.
*
* @param {object} style from deriveFramingStyle
* @param {object|null} previous the previous shot's framing
* @param {number} energy section energy, 0..1
* @param {Rng} rng
*/
export function frameShot(style, previous, energy, rng) {
if (style.mode === 'locked') return neutralFraming();
const keep = previous && !rng.bool(style.changeChance);
if (keep) return { ...previous };
// Loud material earns the close-ups; quiet material earns the wides. This
// is a lean rather than a rule, so an intro can still land on a close and
// read as intimate instead of empty.
const weights = [
1 + (1 - energy) * 2.5, // wide
2, // normal
1 + energy * 2.5, // close
];
let size = rng.pickWeighted(SHOT_SIZE_NAMES, weights);
// Never repeat the previous size when we have decided to change: repeating
// it is what "no change" already means.
if (previous && size === previous.size) {
const others = SHOT_SIZE_NAMES.filter((n) => n !== size);
size = rng.pick(others);
}
const spec = SHOT_SIZES[size];
// Scale toward 1 for a timid track, so `gentle` is genuinely gentle rather
// than the same sizes drawn less often.
const scale = 1 + (spec.scale - 1) * style.range;
// Recentring is what stops a close-up being a centre crop of the wide. Held
// small: the scenes are centred compositions and pushing far off centre
// finds their empty corners.
const angle = rng.range(0, Math.PI * 2);
const amount = spec.drift * style.range * rng.range(0.3, 1);
return {
size,
scale,
shift: [Math.cos(angle) * amount, Math.sin(angle) * amount],
};
}
export function neutralFraming() {
return { size: 'normal', scale: 1, shift: [0, 0] };
}
/** One line for the HUD and check output. */
export function describeFraming(style) {
if (!style || style.mode === 'locked') return 'framing: locked off';
return `framing: ${style.mode}`;
}