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
+128
View File
@@ -24,6 +24,7 @@ import { featureProviderFor } from '../audio/FeatureTrack.js';
import { sampleValues, clampValue } from '../params/schema.js';
import { Rng } from '../engine/rng.js';
import { frameDistance, frameLuminance } from '../engine/hash.js';
import { SHOT_SIZES, SHOT_SIZE_NAMES, describeFraming } from '../look/framing.js';
/** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */
let cached = null;
@@ -617,6 +618,133 @@ check(11, 'a declared slow axis actually changes the scene', () => {
problems.length ? problems.join(' · ') : `${detail.join(', ')}`);
}, { slow: true });
// --- framing ---------------------------------------------------------------
// EPIC-2.md §3.5. Every scene was a locked-off, full-frame wide, so a cut changed
// the subject and never the framing. A shot now carries a scale and a recentre,
// applied in scene coordinates inside sigCamera — see look/framing.js. These
// gates hold the layer honest: it must actually change, stay within headroom, and
// be the same on a seek as on playback.
check(11, 'a shot cut actually changes the framing', () => {
// The complaint as a number: before this, every shot in every video was the
// same full-frame wide, and a cut moved the image and not the camera. The
// gate is on the population — most tracks should be framed at all, and of
// those, the cuts must really change the shot size rather than cosmetically
// vary a number no eye can see.
const tracks = tempoBattery();
const modes = [];
let nonLocked = 0;
let withSizeChange = 0;
let transitions = 0;
let changed = 0;
// Two seed families so the draw is not at the mercy of one: the whole gate
// ran once against a single family whose first few seeds happened to land
// heavy on `locked`, and a sample that small has no business holding a
// population claim. Locked is ~15% of tracks by design; 24 seeds a family
// put it far from that.
for (const { track: t } of tracks) {
for (const family of [11100, 12200]) {
for (let s = 0; s < 12; s++) {
const look = generateLook(t, { seed: family + s * 7919 });
const style = look.framing;
modes.push(style.mode);
if (style.mode === 'locked') continue;
nonLocked++;
const arc = new ArcDriver(look, t);
try {
for (let i = 1; i < arc.cues.length; i++) {
const a = arc.cues[i - 1].framing;
const b = arc.cues[i].framing;
if (!a || !b) continue;
transitions++;
if (a.size !== b.size) changed++;
}
const sizes = new Set(arc.cues.map((c) => c.framing && c.framing.size));
if (sizes.size > 1) withSizeChange++;
} finally {
arc.dispose();
}
}
}
}
const lockedShare = modes.filter((m) => m === 'locked').length / modes.length;
const changeRate = transitions ? changed / transitions : 0;
return expect(
nonLocked > 0 && withSizeChange >= nonLocked * 0.6
&& lockedShare < 0.5 && changeRate >= 0.15,
`${modes.length} tracks · locked ${(lockedShare * 100).toFixed(0)}% · ` +
`${withSizeChange}/${nonLocked} framed tracks show 2+ sizes · ` +
`${changed}/${transitions} cuts change size (${(changeRate * 100).toFixed(0)}%)`);
});
check(11, 'framing stays inside the library\'s headroom', () => {
// A size is only worth using if the scene still has detail at that size.
// Past ~2.2 the library runs out and a close-up is a blurry wide; below
// ~0.55 the subject is a speck. Those bounds are the measured edges of what
// is watchable, so every framed shot has to respect them.
const problems = [];
let framed = 0;
for (const { track: t } of tempoBattery()) {
for (let s = 0; s < 6; s++) {
const look = generateLook(t, { seed: 12200 + s * 6841 });
if (look.framing.mode === 'locked') continue;
const arc = new ArcDriver(look, t);
try {
for (const cue of arc.cues) {
const f = cue.framing;
if (!f) continue;
framed++;
if (f.scale < 0.55 || f.scale > 2.2) {
problems.push(`§${cue.sectionIndex}#${cue.shotIndex}: scale ${f.scale.toFixed(2)}`);
}
if (Math.abs(f.shift[0]) > 0.3 || Math.abs(f.shift[1]) > 0.3) {
problems.push(`§${cue.sectionIndex}#${cue.shotIndex}: shift ${f.shift.map((v) => v.toFixed(2))}`);
}
}
} finally {
arc.dispose();
}
}
}
return expect(problems.length === 0 && framed > 0,
problems.length ? problems.slice(0, 3).join(' · ')
: `${framed} framed shots all within scale headroom and off-centre`);
});
check(11, 'framing is identical on a seek and on playback', () => {
// Framing is planned once per cue, but a reroll re-plans it. The gate that
// matters is the same one every other layer honours: the same frame has to
// carry the same framing however you reach it. Two drivers over the same
// look must agree on every shot.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 31337 });
const a = new ArcDriver(look, t);
const b = new ArcDriver(look, t);
const problems = [];
try {
for (let i = 0; i < a.cues.length; i++) {
const fa = a.cues[i].framing;
const fb = b.cues[i].framing;
if (fa && fb && (fa.scale !== fb.scale
|| fa.shift[0] !== fb.shift[0] || fa.shift[1] !== fb.shift[1])) {
problems.push(`cue ${i}: ${JSON.stringify(fa)} vs ${JSON.stringify(fb)}`);
}
}
} finally {
a.dispose(); b.dispose();
}
return expect(problems.length === 0,
problems.length ? problems.slice(0, 3).join(' · ')
: `${a.cues.length} cues carry the same framing in both drivers`);
});
check(11, 'the axis measurement would notice if the axis stopped working', () => {
// EPIC-2.md §4 names this failure mode by name: a gate that measures the
// wrong thing. This one has already happened once here — the first version