Epic 2.5.1: basis for framing layer
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user