Actors and objects change only at camera cuts. int/bool/discrete params skip drift, lookahead and reactivity; discrete slowAxis steps on the section-held journey. Venues fade instead of toggling on interpolated popMul; roadside density is arrangement-only. DESIGN.md collects the project's design decisions; AGENTS.md points agents at it and the build-visualizer loop.
1018 lines
47 KiB
JavaScript
1018 lines
47 KiB
JavaScript
// Phase 11 gate — Epic 2. See EPIC-2.md.
|
||
//
|
||
// Phase 10 asked whether two tracks look different from each other. This phase
|
||
// asks the question that only shows up when you actually sit and watch one:
|
||
// does a single track hold five minutes?
|
||
//
|
||
// The failures it exists to catch were all measured on a working build, not
|
||
// imagined — a cut metronome, a palette that never moves, scenes that are
|
||
// violently animated and read as static, and a kind-to-family table that made
|
||
// every video make the same genre decisions.
|
||
|
||
import { check, expect } from './framework.js';
|
||
import { generateLook } from '../look/LookGenerator.js';
|
||
import { FeatureTrack } from '../audio/FeatureTrack.js';
|
||
import { synthesizeSectioned } from '../audio/synth.js';
|
||
import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS } from '../look/shots.js';
|
||
import { DIRECTORS, RESTFUL_FAMILIES, pickDirector } from '../look/directors.js';
|
||
import { scenes, scenesInFamily } from '../scenes/registry.js';
|
||
import { paletteShiftAt, MAX_HUE_ROTATION } from '../look/paletteArc.js';
|
||
import { shiftPalette, paletteContrast } from '../look/palette.js';
|
||
import { ArcDriver } from '../look/ArcDriver.js';
|
||
import { Engine } from '../engine/Engine.js';
|
||
import { featureProviderFor } from '../audio/FeatureTrack.js';
|
||
import { sampleValues, clampValue, canBackground, isDiscreteParam } 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';
|
||
import { reachFor, CURVE_NAMES } from '../look/Camera.js';
|
||
import { subjectOf } from '../look/stack.js';
|
||
|
||
/** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */
|
||
let cached = null;
|
||
function tempoBattery() {
|
||
if (!cached) {
|
||
cached = [
|
||
{ name: '90bpm', track: track(90) },
|
||
{ name: '124bpm', track: track(124) },
|
||
{ name: '150bpm', track: track(150) },
|
||
];
|
||
}
|
||
return cached;
|
||
}
|
||
function track(bpm) {
|
||
return FeatureTrack.fromAudioBuffer(
|
||
synthesizeSectioned({ bpm, duration: 300, changeAt: 150 }), { fps: 60 });
|
||
}
|
||
|
||
/** Every shot in a look, with its length in seconds, grouped by section. */
|
||
function shotLengths(look) {
|
||
return look.sections.map((s) => (s.shots || []).map((sh) => (sh.endFrame - sh.startFrame) / 60));
|
||
}
|
||
|
||
check(11, 'a section does not cut on a metronome', () => {
|
||
// The complaint, as a number. Before this, a five-minute track at 90 BPM
|
||
// was sixteen shots of 18.7 seconds — coefficient of variation about 0.01.
|
||
// A section has to show real spread between its shot lengths, or the eye
|
||
// starts predicting the cuts and the video reads as a slideshow.
|
||
const problems = [];
|
||
let measured = 0;
|
||
|
||
for (const { name, track: t } of tempoBattery()) {
|
||
for (let s = 0; s < 6; s++) {
|
||
const look = generateLook(t, { seed: 2100 + s * 7919 });
|
||
shotLengths(look).forEach((lengths, si) => {
|
||
if (lengths.length < 3) return; // too few shots to have a rhythm
|
||
measured++;
|
||
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
||
const sd = Math.sqrt(
|
||
lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
|
||
const cv = sd / Math.max(1e-6, mean);
|
||
if (cv < 0.12) {
|
||
problems.push(`${name} s${s}/§${si}: cv ${cv.toFixed(3)} over ` +
|
||
`${lengths.length} shots (${lengths.map((l) => l.toFixed(1)).join(', ')})`);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
return expect(measured > 0 && problems.length === 0,
|
||
problems.length ? problems.slice(0, 3).join(' · ')
|
||
: `${measured} sections all cut with varying shot lengths`);
|
||
});
|
||
|
||
check(11, 'the cutting rhythm repeats rather than wandering', () => {
|
||
// The counter-check, and the reason the gate above is not sufficient on its
|
||
// own: RANDOM shot lengths would pass it and would look worse than a
|
||
// metronome. The ear is following an eight-bar structure; the eye has to be
|
||
// following one too. So a section's shot lengths must come from a small set
|
||
// of values that recur, not from a continuum.
|
||
const problems = [];
|
||
let measured = 0;
|
||
|
||
for (const { name, track: t } of tempoBattery()) {
|
||
for (let s = 0; s < 6; s++) {
|
||
const look = generateLook(t, { seed: 3300 + s * 6841 });
|
||
shotLengths(look).forEach((lengths, si) => {
|
||
if (lengths.length < 5) return;
|
||
measured++;
|
||
// Quantise to a quarter second: two shots from the same pattern
|
||
// entry differ only by downbeat snapping.
|
||
const buckets = new Set(lengths.map((l) => Math.round(l * 4)));
|
||
if (buckets.size > Math.ceil(lengths.length * 0.7)) {
|
||
problems.push(`${name} s${s}/§${si}: ${buckets.size} distinct lengths ` +
|
||
`over ${lengths.length} shots — wandering, not phrased`);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
return expect(measured > 0 && problems.length === 0,
|
||
problems.length ? problems.slice(0, 3).join(' · ')
|
||
: `${measured} sections reuse a small set of shot lengths`);
|
||
});
|
||
|
||
check(11, 'shot length floor and ceiling still hold', () => {
|
||
// The rhythm work moves cuts around; these two limits are what keep it from
|
||
// producing a subliminal flash or a two-minute hold.
|
||
const problems = [];
|
||
let count = 0;
|
||
|
||
for (const { name, track: t } of tempoBattery()) {
|
||
for (let s = 0; s < 8; s++) {
|
||
const look = generateLook(t, { seed: 4400 + s * 15485863 });
|
||
shotLengths(look).forEach((lengths, si) => {
|
||
lengths.forEach((l, i) => {
|
||
count++;
|
||
// Half a frame of slack: shot bounds are rounded to frames.
|
||
if (l < MIN_SHOT_SECONDS - 0.02) problems.push(`${name} s${s}/§${si}#${i}: ${l.toFixed(2)}s under floor`);
|
||
if (l > MAX_SHOT_SECONDS + 0.02) problems.push(`${name} s${s}/§${si}#${i}: ${l.toFixed(2)}s over ceiling`);
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
return expect(problems.length === 0,
|
||
problems.length ? problems.slice(0, 4).join(' · ')
|
||
: `${count} shots all within ${MIN_SHOT_SECONDS}–${MAX_SHOT_SECONDS}s`);
|
||
});
|
||
|
||
check(11, 'cuts land on the beat grid', () => {
|
||
// A cut that lands across a phrase instead of on it reads as a mistake, and
|
||
// it is the thing most easily lost when shot timing stops being uniform.
|
||
const problems = [];
|
||
let total = 0;
|
||
let onGrid = 0;
|
||
|
||
for (const { name, track: t } of tempoBattery()) {
|
||
const beatSeconds = t.tempo.period / 60;
|
||
for (let s = 0; s < 6; s++) {
|
||
const look = generateLook(t, { seed: 5500 + s * 2654435761 });
|
||
look.sections.forEach((section) => {
|
||
(section.shots || []).forEach((shot, i) => {
|
||
if (i === 0) return; // section starts are not cuts
|
||
total++;
|
||
const at = shot.startFrame / 60;
|
||
// Distance to the nearest downbeat, in beats.
|
||
let best = Infinity;
|
||
for (const d of (t.tempo.downbeats || [])) {
|
||
best = Math.min(best, Math.abs(d - at));
|
||
}
|
||
if (best <= beatSeconds * 0.75) onGrid++;
|
||
});
|
||
});
|
||
}
|
||
if (!t.tempo.downbeats || !t.tempo.downbeats.length) problems.push(`${name}: no downbeat grid`);
|
||
}
|
||
|
||
const ratio = total ? onGrid / total : 0;
|
||
return expect(problems.length === 0 && total > 0 && ratio > 0.8,
|
||
`${onGrid}/${total} cuts within three quarters of a beat of a downbeat ` +
|
||
`(${(ratio * 100).toFixed(0)}%, floor 80%)`);
|
||
});
|
||
|
||
// --- the director ----------------------------------------------------------
|
||
// EPIC-2.md §3.2. The kind-to-family mapping used to be a module constant, so
|
||
// every video made the same genre decisions before a single seeded draw
|
||
// happened. It is now a per-track choice — see look/directors.js.
|
||
|
||
check(11, 'every scene in the library is reachable', () => {
|
||
// The number that made this worth doing: across twelve tracks, twenty-nine
|
||
// of forty-two scenes were cast in none of them. A scene no director can
|
||
// reach is dead weight, and the fault is in the mapping rather than in the
|
||
// scene, so this is asked of the mapping directly.
|
||
const reachable = new Set();
|
||
for (const d of DIRECTORS) {
|
||
for (const families of Object.values(d.families)) {
|
||
for (const f of families) {
|
||
for (const m of scenesInFamily(f)) reachable.add(m.name);
|
||
}
|
||
}
|
||
}
|
||
// Accents are cast by role rather than by family and are always eligible.
|
||
const missing = scenes.filter((m) => canBackground(m) && !reachable.has(m.name));
|
||
|
||
return expect(missing.length === 0,
|
||
missing.length ? `unreachable: ${missing.map((m) => m.name).join(', ')}`
|
||
: `all ${reachable.size} castable scenes reachable across ${DIRECTORS.length} directors`);
|
||
});
|
||
|
||
check(11, 'no director starves a section kind', () => {
|
||
// A director is a point of view, not a corner. Every kind it defines has to
|
||
// leave enough scenes to build a roster from and still have room for the
|
||
// signature filter to remove some.
|
||
const problems = [];
|
||
for (const d of DIRECTORS) {
|
||
for (const [kind, families] of Object.entries(d.families)) {
|
||
const pool = new Set();
|
||
for (const f of families) {
|
||
for (const m of scenesInFamily(f)) if (canBackground(m)) pool.add(m.name);
|
||
}
|
||
if (pool.size < 12) problems.push(`${d.name}/${kind}: only ${pool.size}`);
|
||
}
|
||
const kinds = Object.keys(d.families);
|
||
for (const k of ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']) {
|
||
if (!kinds.includes(k)) problems.push(`${d.name}: no mapping for '${k}'`);
|
||
}
|
||
}
|
||
return expect(problems.length === 0,
|
||
problems.length ? problems.join(' · ')
|
||
: `${DIRECTORS.length} directors, every kind backed by 12+ scenes`);
|
||
});
|
||
|
||
check(11, 'no director puts a loud family in a quiet section', () => {
|
||
// Phase 7 has enforced this since the minimal family existed, and the first
|
||
// draft of directors.js broke it — `corrupt` opened on glitch, `geometer`
|
||
// on geometric. An intro that opens strobing and a breakdown that answers a
|
||
// lull with a dense pattern are the two specific mistakes the family
|
||
// coupling exists to prevent, and a viewer meets them in the first fifteen
|
||
// seconds. Asserted against the mappings directly so a new director cannot
|
||
// reintroduce it without tripping this rather than a downstream render gate.
|
||
const problems = [];
|
||
for (const d of DIRECTORS) {
|
||
for (const kind of ['intro', 'breakdown', 'outro']) {
|
||
for (const f of d.families[kind] || []) {
|
||
if (!RESTFUL_FAMILIES.includes(f)) problems.push(`${d.name}/${kind}: ${f}`);
|
||
}
|
||
}
|
||
}
|
||
return expect(problems.length === 0,
|
||
problems.length ? problems.join(' · ')
|
||
: `${DIRECTORS.length} directors keep intro/breakdown/outro on ${RESTFUL_FAMILIES.join('/')}`);
|
||
});
|
||
|
||
check(11, 'every director is castable', () => {
|
||
// Weighting tilts the odds by timbre; none of it may drive a weight to zero,
|
||
// or a director exists in the file and never in a video.
|
||
const probes = [
|
||
{ meanFlatness: 0.02, meanCentroid: 0.15 }, // tonal and dark
|
||
{ meanFlatness: 0.5, meanCentroid: 0.9 }, // noisy and bright
|
||
{ meanFlatness: 0.2, meanCentroid: 0.5 },
|
||
];
|
||
const rng = { pickWeighted: (arr, w) => w }; // capture the weights
|
||
const problems = [];
|
||
for (const p of probes) {
|
||
const weights = pickDirector(p, rng);
|
||
weights.forEach((w, i) => {
|
||
if (!(w > 0)) problems.push(`${DIRECTORS[i].name} unreachable at flatness ${p.meanFlatness}`);
|
||
});
|
||
}
|
||
return expect(problems.length === 0,
|
||
problems.length ? problems.join(' · ')
|
||
: `all ${DIRECTORS.length} directors carry weight on every timbre probed`);
|
||
});
|
||
|
||
check(11, 'two tracks do not agree on what a section kind looks like', () => {
|
||
// The population question. Across a battery, the same section kind must be
|
||
// answered by more than one family, or the director layer is decorative.
|
||
const seen = new Map(); // kind -> Set of family names actually cast
|
||
const directors = new Set();
|
||
|
||
for (const { track: t } of tempoBattery()) {
|
||
for (let s = 0; s < 20; s++) {
|
||
const look = generateLook(t, { seed: 6600 + s * 7919 });
|
||
directors.add(look.director);
|
||
look.sections.forEach((section) => {
|
||
const set = seen.get(section.kind) || new Set();
|
||
(section.variants || [section.layers]).forEach((v) => set.add(subjectOf(v).module.family));
|
||
seen.set(section.kind, set);
|
||
});
|
||
}
|
||
}
|
||
|
||
const thin = [...seen.entries()].filter(([, set]) => set.size < 3)
|
||
.map(([kind, set]) => `${kind}: ${[...set].join('/')}`);
|
||
|
||
// Three of five in a population this size is a real spread; demanding all
|
||
// five would be demanding a particular draw rather than a working mechanism,
|
||
// and reachability is asserted directly by the check above.
|
||
return expect(directors.size >= 3 && thin.length === 0,
|
||
`${directors.size}/${DIRECTORS.length} directors cast · ` +
|
||
(thin.length ? `thin kinds — ${thin.join(' · ')}`
|
||
: [...seen.entries()].map(([k, v]) => `${k}:${v.size}fam`).join(' ')));
|
||
});
|
||
|
||
check(11, 'a track shows more of the library than it used to', () => {
|
||
// Twelve tracks used to reach thirteen distinct scenes between them. This
|
||
// asks the same question of the same size population.
|
||
const cast = new Set();
|
||
tempoBattery().forEach(({ track: t }, i) => {
|
||
for (let s = 0; s < 4; s++) {
|
||
const look = generateLook(t, { seed: (i * 2654435761 + s * 40503) >>> 0 });
|
||
look.sections.forEach((section) =>
|
||
(section.variants || [section.layers]).forEach((v) => cast.add(subjectOf(v).module.name)));
|
||
}
|
||
});
|
||
const pool = scenes.filter(canBackground).length;
|
||
return expect(cast.size >= pool * 0.4,
|
||
`${cast.size}/${pool} scenes cast across 12 tracks (floor ${Math.ceil(pool * 0.4)})`);
|
||
});
|
||
|
||
// --- colour movement -------------------------------------------------------
|
||
// EPIC-2.md §3.3. The palette was generated once and pushed to every layer of
|
||
// every section for the whole runtime — five minutes, one scheme, no movement,
|
||
// with colour being the strongest perceptual variable available.
|
||
|
||
check(11, 'the palette moves across a track', () => {
|
||
// The complaint as a number. Sample the arc from first frame to last and
|
||
// require the colours to actually end up somewhere else.
|
||
const moved = [];
|
||
const held = [];
|
||
|
||
for (const { track: t } of tempoBattery()) {
|
||
for (let s = 0; s < 8; s++) {
|
||
const look = generateLook(t, { seed: 7700 + s * 6841 });
|
||
const arc = look.paletteArc;
|
||
let worst = 0;
|
||
for (const kind of ['intro', 'drop', 'breakdown', 'outro']) {
|
||
for (const p of [0, 0.5, 1]) {
|
||
const shift = paletteShiftAt(arc, {
|
||
progress: p, sectionKind: kind,
|
||
features: { sectionEnergy: p, buildSlope: 0 },
|
||
});
|
||
const shifted = shiftPalette(look.palette, shift);
|
||
for (let i = 0; i < look.palette.length; i++) {
|
||
const a = look.palette[i], b = shifted[i];
|
||
worst = Math.max(worst, Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]));
|
||
}
|
||
}
|
||
}
|
||
(arc.mode === 'static' ? held : moved).push(worst);
|
||
}
|
||
}
|
||
|
||
const weak = moved.filter((d) => d < 0.03).length;
|
||
const ratio = moved.length / (moved.length + held.length);
|
||
return expect(weak === 0 && ratio > 0.6,
|
||
`${moved.length} moving / ${held.length} held · weakest move ` +
|
||
`${(moved.length ? Math.min(...moved) : 0).toFixed(3)} channel distance (floor 0.03)`);
|
||
});
|
||
|
||
check(11, 'colour movement never breaks the contrast floor', () => {
|
||
// The Phase 3 palette gate proves the palette is usable when it is
|
||
// generated. That says nothing about where the arc takes it — a saturation
|
||
// lift or a lightness shift can flatten a perfectly good set. So the floor
|
||
// is asserted at every sampled point of the movement rather than only at
|
||
// the two ends, which is where this would otherwise be silently violated.
|
||
const problems = [];
|
||
let sampled = 0;
|
||
|
||
for (const { track: t } of tempoBattery()) {
|
||
for (let s = 0; s < 6; s++) {
|
||
const look = generateLook(t, { seed: 8800 + s * 15485863 });
|
||
const base = paletteContrast(look.palette).luminanceSpread;
|
||
for (const kind of ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']) {
|
||
for (let p = 0; p <= 1.001; p += 0.25) {
|
||
for (const energy of [0, 0.5, 1]) {
|
||
sampled++;
|
||
const shift = paletteShiftAt(look.paletteArc, {
|
||
progress: p, sectionKind: kind,
|
||
features: { sectionEnergy: energy, buildSlope: energy },
|
||
});
|
||
const spread = paletteContrast(
|
||
shiftPalette(look.palette, shift)).luminanceSpread;
|
||
// Held against the palette's OWN spread, not an absolute:
|
||
// the arc must not degrade what generation achieved.
|
||
if (spread < Math.min(0.18, base * 0.75)) {
|
||
problems.push(`seed ${s} ${kind}@${p.toFixed(2)}/e${energy}: ` +
|
||
`spread ${spread.toFixed(3)} from base ${base.toFixed(3)}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return expect(problems.length === 0,
|
||
problems.length ? `${problems.length} of ${sampled} points muddy — ${problems[0]}`
|
||
: `${sampled} points across the movement all clear the contrast floor`);
|
||
});
|
||
|
||
check(11, 'colour movement stays inside its identity', () => {
|
||
// The counter-check. A palette that moves far enough is a different
|
||
// palette, and then the track has no colour identity at all — which would
|
||
// pass the movement gate above with room to spare.
|
||
const problems = [];
|
||
for (const { track: t } of tempoBattery()) {
|
||
for (let s = 0; s < 8; s++) {
|
||
const look = generateLook(t, { seed: 9900 + s * 7919 });
|
||
for (const kind of ['intro', 'drop', 'breakdown']) {
|
||
for (const p of [0, 0.5, 1]) {
|
||
const shift = paletteShiftAt(look.paletteArc, {
|
||
progress: p, sectionKind: kind,
|
||
features: { sectionEnergy: p, buildSlope: p },
|
||
});
|
||
if (Math.abs(shift.hue) > MAX_HUE_ROTATION + 1e-6) {
|
||
problems.push(`seed ${s}: hue ${shift.hue.toFixed(2)} past the ceiling`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return expect(problems.length === 0,
|
||
problems.length ? problems[0]
|
||
: `hue travel stays within ±${(MAX_HUE_ROTATION * 57.3).toFixed(0)}° of the track's palette`);
|
||
});
|
||
|
||
check(11, 'a moved palette is the same on a seek as on playback', () => {
|
||
// The movement is memoised on a rounded shift. Rounding is what keeps a
|
||
// seeked frame bit-identical to a played one rather than merely close, and
|
||
// getting that wrong would be a determinism fault that only shows up in an
|
||
// export.
|
||
const t = tempoBattery()[1].track;
|
||
const look = generateLook(t, { seed: 4321 });
|
||
const a = new ArcDriver(look, t);
|
||
const b = new ArcDriver(look, t);
|
||
|
||
const probes = [0, 900, 4500, 9000, 12000];
|
||
let worst = 0;
|
||
try {
|
||
// One driver plays through; the other jumps straight to each probe.
|
||
for (let f = 0; f <= 12000; f += 30) a.update(f, t.at(f));
|
||
for (const f of probes) {
|
||
const seq = a._paletteAt(f, t.at(f));
|
||
const jump = b._paletteAt(f, t.at(f));
|
||
for (let i = 0; i < seq.length; i++) {
|
||
for (let c = 0; c < 3; c++) worst = Math.max(worst, Math.abs(seq[i][c] - jump[i][c]));
|
||
}
|
||
}
|
||
} finally {
|
||
a.dispose(); b.dispose();
|
||
}
|
||
|
||
return expect(worst === 0,
|
||
`worst channel difference between seeked and played colours: ${worst}`);
|
||
});
|
||
|
||
// --- the slow axis ---------------------------------------------------------
|
||
// EPIC-2.md §3.4. Eleven scenes changed as much in half a second as in two
|
||
// minutes: everything moving in them was cyclic, so the eye adapted in about
|
||
// two seconds. Drift is a 20-70s LFO and an LFO returns; the slow axis is
|
||
// monotonic across the whole track. See ArcDriver._slowAxisFor.
|
||
|
||
check(11, 'every scene gets a slow axis with real travel', () => {
|
||
// Structural half of the gate, so a scene added later cannot quietly end up
|
||
// with nothing to evolve.
|
||
const t = tempoBattery()[1].track;
|
||
const look = generateLook(t, { seed: 20250 });
|
||
const arc = new ArcDriver(look, t);
|
||
const problems = [];
|
||
|
||
try {
|
||
for (const module of scenes) {
|
||
const axis = arc._slowAxisFor(module);
|
||
const eligible = Object.entries(module.params || {}).filter(([, d]) =>
|
||
d.type !== 'palette' && d.type !== 'bool' && !d.fixed && !d.rate && !d.noDrift && d.range);
|
||
if (!eligible.length) continue; // nothing it could legally move
|
||
|
||
if (!axis.length) { problems.push(`${module.name}: no axis`); continue; }
|
||
for (const item of axis) {
|
||
const [lo, hi] = item.def.range;
|
||
const fraction = Math.abs(item.travel) / (hi - lo);
|
||
if (fraction < 0.2) problems.push(`${module.name}.${item.name}: travels only ${(fraction * 100).toFixed(0)}%`);
|
||
if (item.def.rate) problems.push(`${module.name}.${item.name}: rate param on the axis`);
|
||
}
|
||
}
|
||
} finally {
|
||
arc.dispose();
|
||
}
|
||
|
||
return expect(problems.length === 0,
|
||
problems.length ? problems.slice(0, 3).join(' · ')
|
||
: `${scenes.length} scenes all carry a slow axis travelling 20%+ of range`);
|
||
});
|
||
|
||
check(11, 'the slow axis is a journey rather than a cycle', () => {
|
||
// The counter-check. An axis that returned to where it started would satisfy
|
||
// "params move" and would leave the churn exactly as it was — which is what
|
||
// the existing drift LFO already did.
|
||
const t = tempoBattery()[1].track;
|
||
const look = generateLook(t, { seed: 31337 });
|
||
const arc = new ArcDriver(look, t);
|
||
const problems = [];
|
||
|
||
try {
|
||
const cue = arc.cues[0];
|
||
// The shot, not the bed under it — the slow axis is a claim about the
|
||
// scene the section is about.
|
||
const slot = arc._subjectSlot(cue);
|
||
const at = (time) => arc._paramsAt(cue, slot, time, t.at(Math.round(time * 60)));
|
||
const spec = arc._specFor(cue.sectionIndex, cue.variant, slot);
|
||
const axis = arc._slowAxisFor(spec.module);
|
||
|
||
const start = at(t.duration * 0.05);
|
||
const mid = at(t.duration * 0.5);
|
||
const end = at(t.duration * 0.95);
|
||
|
||
for (const item of axis) {
|
||
// Discrete params step at cuts instead of travelling: held within
|
||
// the shot by construction (see schema.js isDiscreteParam), so the
|
||
// travel assertion below cannot apply. What is asserted instead is
|
||
// the hold itself — two samples inside one cue must agree exactly —
|
||
// while the travel magnitude is already covered structurally above.
|
||
if (isDiscreteParam(item.def)) {
|
||
const t0 = (cue.startFrame + 5) / 60;
|
||
const t1 = (cue.endFrame - 5) / 60;
|
||
if (t1 > t0) {
|
||
const va = at(t0)[item.name], vb = at(t1)[item.name];
|
||
if (va !== vb) {
|
||
problems.push(`${spec.module.name}.${item.name}: ${va} → ${vb} ` +
|
||
`mid-shot — discrete params change only at cuts`);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
const [lo, hi] = item.def.range;
|
||
const span = hi - lo;
|
||
const a = start[item.name], m = mid[item.name], z = end[item.name];
|
||
// Monotonic in the sense that matters: the end is further from the
|
||
// start than the middle is, in the direction of travel.
|
||
const total = Math.abs(z - a) / span;
|
||
if (total < 0.12) {
|
||
problems.push(`${spec.module.name}.${item.name}: start ${a.toFixed(3)} ` +
|
||
`mid ${m.toFixed(3)} end ${z.toFixed(3)} — only ${(total * 100).toFixed(0)}% travelled`);
|
||
}
|
||
}
|
||
if (!axis.length) problems.push('no axis on the opening scene');
|
||
} finally {
|
||
arc.dispose();
|
||
}
|
||
|
||
return expect(problems.length === 0,
|
||
problems.length ? problems.join(' · ') : 'the opening scene ends the track somewhere else');
|
||
});
|
||
|
||
/**
|
||
* Structure, with the churn averaged out.
|
||
*
|
||
* Single-frame distance cannot answer "did this develop?" for exactly the
|
||
* scenes that fail it: a churning scene's consecutive frames are already ~0.6
|
||
* apart, so every pair of its frames scores the same whether the structure
|
||
* moved or not. Averaging ten seconds of frames cancels the churn and leaves
|
||
* the structure — and is much closer to what a viewer perceives over seconds
|
||
* than any single frame is.
|
||
*
|
||
* Ten seconds was measured, not guessed. At a one-second window Moiré Grid's
|
||
* frozen-parameter control still read 0.024; at ten it reads 0.0095, while the
|
||
* axis-driven change stays at 0.037. The window has to be wide enough that the
|
||
* control collapses and the signal does not.
|
||
*/
|
||
function averagedFrame(engine, look, module, params, frame0, n = 90, step = 7) {
|
||
engine.setLayerSpecs([{ module, params, seed: 9, opacity: 1, blend: 'normal',
|
||
palette: look.palette, personality: look.personality }]);
|
||
engine.prime(frame0);
|
||
engine.compositor.reset();
|
||
let acc = null;
|
||
for (let k = 0; k < n; k++) {
|
||
const px = engine.readPixels(engine.renderFrame(frame0 + k * step));
|
||
if (!acc) acc = new Float64Array(px.length);
|
||
for (let i = 0; i < px.length; i++) acc[i] += px[i];
|
||
}
|
||
for (let i = 0; i < acc.length; i++) acc[i] /= n;
|
||
return acc;
|
||
}
|
||
|
||
function meanAbs(a, b) {
|
||
let sum = 0;
|
||
for (let i = 0; i < a.length; i++) sum += Math.abs(a[i] - b[i]);
|
||
return sum / a.length / 255;
|
||
}
|
||
|
||
/**
|
||
* How much the declared axis moves a scene, against the metric's own noise.
|
||
*
|
||
* The experiment holds TIME constant and varies only the axis, which the first
|
||
* version of this check did not — and getting that wrong made it measure
|
||
* something else entirely.
|
||
*
|
||
* That version compared the scene at thirty seconds against the scene at two
|
||
* and a half minutes, and divided by what the scene did between those points
|
||
* with its parameters frozen. But those are two different places in the song,
|
||
* so the denominator was full of audio reactivity: the check was really asking
|
||
* "does the slow axis move this scene MORE than the music does", which is a
|
||
* different and much harder question, and one no scene should have to pass.
|
||
* Circuit Bloom failed it at 0.92x while its mean luminance was moving 5.6x
|
||
* across the axis — a large structural change, scored as nothing.
|
||
*
|
||
* So: one frame window, two parameter sets, and a control that is the same
|
||
* parameters over the next window — the residual churn the ten-second average
|
||
* failed to cancel, which is exactly the noise floor this measurement has to
|
||
* beat. With the axis disabled the two parameter sets are identical and the
|
||
* ratio is 0.00 by construction, which is what makes the companion check below
|
||
* a real discriminator rather than a formality.
|
||
*/
|
||
function axisRatio(engine, arc, look, track, module, { withAxis }) {
|
||
const paramsAt = (seconds) => {
|
||
const out = sampleValues(module, new Rng(77), look.sections[0].bias,
|
||
look.personality.temperament);
|
||
if (!withAxis) return out;
|
||
const p = Math.min(1, seconds / track.duration);
|
||
const journey = p * p * (3 - 2 * p);
|
||
for (const item of arc._slowAxisFor(module)) {
|
||
if (typeof out[item.name] !== 'number') continue;
|
||
out[item.name] = clampValue(item.def, out[item.name] + item.travel * (journey - 0.5));
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const FRAME = 1800;
|
||
const WINDOW = 90 * 7;
|
||
const early = averagedFrame(engine, look, module, paramsAt(0), FRAME);
|
||
const late = averagedFrame(engine, look, module, paramsAt(track.duration), FRAME);
|
||
const control = averagedFrame(engine, look, module, paramsAt(0), FRAME + WINDOW);
|
||
|
||
const moved = meanAbs(early, late);
|
||
const noise = meanAbs(early, control);
|
||
return { moved, noise, ratio: moved / Math.max(1e-6, noise) };
|
||
}
|
||
|
||
check(11, 'a declared slow axis actually changes the scene', () => {
|
||
// EPIC-2.md §3.4, and the honest scope of it. A scene that declares an axis
|
||
// is claiming that walking that param is what it looks like changing, so
|
||
// the claim is measured: the structural change with the axis has to beat
|
||
// what the scene does on its own by a real margin.
|
||
const t = tempoBattery()[1].track;
|
||
const look = generateLook(t, { seed: 5150 });
|
||
const engine = new Engine({ width: 128, height: 72 });
|
||
engine.timeline.setDuration(t.duration);
|
||
engine.setFeatureProvider(featureProviderFor(t));
|
||
const arc = new ArcDriver(look, t);
|
||
|
||
const problems = [];
|
||
const detail = [];
|
||
try {
|
||
const declared = scenes.filter((m) => Object.values(m.params || {}).some((d) => d.slowAxis));
|
||
for (const module of declared) {
|
||
const r = axisRatio(engine, arc, look, t, module, { withAxis: true });
|
||
detail.push(`${module.name} ${r.ratio.toFixed(2)}x`);
|
||
if (r.ratio < 1.25) {
|
||
problems.push(`${module.name}: axis moved ${r.moved.toFixed(4)} against a ` +
|
||
`${r.noise.toFixed(4)} noise floor — only ${r.ratio.toFixed(2)}x`);
|
||
}
|
||
}
|
||
if (!declared.length) problems.push('no scene declares a slow axis');
|
||
} finally {
|
||
arc.dispose();
|
||
engine.dispose();
|
||
}
|
||
|
||
return expect(problems.length === 0,
|
||
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)}`);
|
||
}
|
||
// The gaze is live now — it is not on cue.framing, it is
|
||
// wherever the camera has travelled to by a given frame —
|
||
// so the bound has to be measured at frames rather than
|
||
// read off the plan. Its ceiling is the reach the shot's
|
||
// own size allows: see Camera.reachFor, which is why a
|
||
// close-up is permitted further off centre than a wide.
|
||
const ceiling = look.camera
|
||
? reachFor(f.scale, look.camera) + 1e-6 : 0.31;
|
||
for (const fr of [cue.startFrame, (cue.startFrame + cue.endFrame) >> 1,
|
||
cue.endFrame - 1]) {
|
||
const shift = arc._framingAt(cue.index, fr).shift;
|
||
if (Math.hypot(shift[0], shift[1]) > ceiling) {
|
||
problems.push(`§${cue.sectionIndex}#${cue.shotIndex}@${fr}: `
|
||
+ `|shift| ${Math.hypot(shift[0], shift[1]).toFixed(3)} > ${ceiling.toFixed(3)}`);
|
||
}
|
||
}
|
||
}
|
||
} 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) {
|
||
problems.push(`cue ${i}: scale ${fa.scale} vs ${fb.scale}`);
|
||
}
|
||
// The gaze is a position in time, so agreeing on the plan is not
|
||
// enough — two drivers have to agree on where the camera IS at a
|
||
// frame. This is the property a seek depends on.
|
||
const cue = a.cues[i];
|
||
for (const fr of [cue.startFrame, (cue.startFrame + cue.endFrame) >> 1,
|
||
cue.endFrame - 1]) {
|
||
const sa = a._framingAt(i, fr).shift;
|
||
const sb = b._framingAt(i, fr).shift;
|
||
if (sa[0] !== sb[0] || sa[1] !== sb[1]) {
|
||
problems.push(`cue ${i}@${fr}: gaze ${sa} vs ${sb}`);
|
||
}
|
||
}
|
||
}
|
||
} 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`);
|
||
});
|
||
|
||
/**
|
||
* Everything the camera exists to do, measured on one sweep of the battery.
|
||
*
|
||
* Stated as numbers because the device this replaces LOOKED right in the source
|
||
* and did nothing on screen: a recentre of a median 0.029 of a half-frame, at a
|
||
* fresh random angle every shot so successive offsets cancelled. Nothing in the
|
||
* old gates caught that, because the only bound was a ceiling — and a device
|
||
* doing nothing passes a ceiling comfortably.
|
||
*
|
||
* So each of these has a FLOOR. That is the lesson from the old one.
|
||
*/
|
||
function gazeStats() {
|
||
const offsets = []; const jumps = []; const travels = [];
|
||
const cameras = new Set(); const curves = new Set();
|
||
|
||
for (const { track: t } of tempoBattery()) {
|
||
for (let s = 0; s < 6; s++) {
|
||
const look = generateLook(t, { seed: 21400 + s * 5077 });
|
||
if (!look.camera) continue;
|
||
cameras.add(look.camera.name);
|
||
const arc = new ArcDriver(look, t);
|
||
try {
|
||
let prevEnd = null;
|
||
arc.cues.forEach((cue, i) => {
|
||
curves.add(arc.gaze[i].curve);
|
||
const start = arc._framingAt(i, cue.startFrame).shift;
|
||
const end = arc._framingAt(i, cue.endFrame - 1).shift;
|
||
offsets.push(Math.hypot(end[0], end[1]));
|
||
travels.push(Math.hypot(end[0] - start[0], end[1] - start[1]));
|
||
if (prevEnd) {
|
||
jumps.push(Math.hypot(start[0] - prevEnd[0], start[1] - prevEnd[1]));
|
||
}
|
||
prevEnd = end;
|
||
});
|
||
} finally {
|
||
arc.dispose();
|
||
}
|
||
}
|
||
}
|
||
const median = (a) => {
|
||
const v = a.slice().sort((x, y) => x - y);
|
||
return v.length ? v[v.length >> 1] : 0;
|
||
};
|
||
return {
|
||
offsets, jumps, travels, cameras, curves,
|
||
medOffset: median(offsets), medJump: median(jumps), medTravel: median(travels),
|
||
};
|
||
}
|
||
|
||
check(11, 'the camera moves far enough at a cut to be seen', () => {
|
||
// The floor is the whole point. The device this replaces had a median jump
|
||
// of 0.014 of a half-frame — present in every video, visible in none.
|
||
//
|
||
// Measured over the REFRAMES rather than over every cut. A match cut is a
|
||
// deliberate zero and there are enough of them to drag a plain median down;
|
||
// averaging the cuts that chose not to move together with the ones that did
|
||
// would let the reframes shrink to nothing without the number noticing,
|
||
// which is the exact failure this whole gate exists to catch.
|
||
const r = gazeStats();
|
||
const reframes = r.jumps.filter((j) => j >= 0.01);
|
||
const sorted = reframes.slice().sort((a, b) => a - b);
|
||
const med = sorted.length ? sorted[sorted.length >> 1] : 0;
|
||
return expect(med > 0.09 && reframes.length > 0,
|
||
`median reframe ${med.toFixed(3)} of a half-frame across ${reframes.length} reframes ` +
|
||
`· median offset ${r.medOffset.toFixed(3)} ` +
|
||
`(the old device measured 0.014 jump / 0.029 offset)`);
|
||
});
|
||
|
||
check(11, 'the camera moves DURING a shot, not only at cuts', () => {
|
||
// Framing used to be constant within a shot by design. The recentre is not
|
||
// framing — a camera that only ever steps is a slideshow of stills.
|
||
const r = gazeStats();
|
||
const moving = r.travels.filter((t) => t > 0.02).length;
|
||
const share = moving / Math.max(1, r.travels.length);
|
||
return expect(r.medTravel > 0.02 && share > 0.5,
|
||
`${moving}/${r.travels.length} shots travel (${(share * 100).toFixed(0)}%) · ` +
|
||
`median travel ${r.medTravel.toFixed(3)}`);
|
||
});
|
||
|
||
check(11, 'the camera cuts through as well as jumping', () => {
|
||
// A camera that relocates at EVERY cut is as much a single rule as one that
|
||
// never does. The match cut — where the gaze walks through the change and
|
||
// two scenes read as one place — has to survive as a real minority.
|
||
const r = gazeStats();
|
||
const matched = r.jumps.filter((j) => j < 0.01).length;
|
||
const share = matched / Math.max(1, r.jumps.length);
|
||
return expect(share > 0.04 && share < 0.6,
|
||
`${matched}/${r.jumps.length} cuts are match cuts (${(share * 100).toFixed(0)}%)`);
|
||
});
|
||
|
||
check(11, 'the library uses more than one camera and more than one curve', () => {
|
||
// Same argument directors.js makes about families: one camera applied to
|
||
// every track is how a library ends up with one look.
|
||
const r = gazeStats();
|
||
return expect(r.cameras.size >= 3 && r.curves.size >= 3,
|
||
`${r.cameras.size} cameras (${[...r.cameras].join(', ')}) · ` +
|
||
`${r.curves.size}/${CURVE_NAMES.length} curves used`);
|
||
});
|
||
|
||
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
|
||
// of the check above used single-frame distance, which is saturated on
|
||
// churning scenes, and passed while the axis was provably doing nothing.
|
||
//
|
||
// So the metric is verified by breaking what it is supposed to catch. Run
|
||
// the identical measurement with the axis disabled; it must come back at
|
||
// about 1.0 and fail the threshold the real check applies.
|
||
const t = tempoBattery()[1].track;
|
||
const look = generateLook(t, { seed: 5150 });
|
||
const engine = new Engine({ width: 128, height: 72 });
|
||
engine.timeline.setDuration(t.duration);
|
||
engine.setFeatureProvider(featureProviderFor(t));
|
||
const arc = new ArcDriver(look, t);
|
||
|
||
const problems = [];
|
||
const detail = [];
|
||
try {
|
||
const declared = scenes.filter((m) => Object.values(m.params || {}).some((d) => d.slowAxis));
|
||
for (const module of declared) {
|
||
const off = axisRatio(engine, arc, look, t, module, { withAxis: false });
|
||
detail.push(`${module.name} ${off.ratio.toFixed(2)}x`);
|
||
if (off.ratio >= 1.25) {
|
||
problems.push(`${module.name}: reads ${off.ratio.toFixed(2)}x with the axis ` +
|
||
`DISABLED — the measurement is not tracking the axis`);
|
||
}
|
||
}
|
||
} finally {
|
||
arc.dispose();
|
||
engine.dispose();
|
||
}
|
||
|
||
return expect(problems.length === 0,
|
||
problems.length ? problems.join(' · ')
|
||
: `axis disabled reads ${detail.join(', ')} — the measurement tracks the axis`);
|
||
}, { slow: true });
|
||
|
||
check(11, 'framing visibly changes every scene in the library', () => {
|
||
// The gate the framing work shipped without, and the reason this revisit
|
||
// found anything. The other framing checks assert that the PLAN differs —
|
||
// cue sizes, distribution, headroom — and a plan that never reaches the
|
||
// image passes all of them. It did: framing was applied inside sigCamera,
|
||
// which is gated on the `camera` personality trait, so any scene that
|
||
// declined the track's drift silently declined the shot size too. Scan
|
||
// Tear, Pylon Grid, Pitch Shatter and the 3D layer rendered identically at
|
||
// every size and nothing noticed.
|
||
//
|
||
// Measured relative to how much image there is, for the reason Phase 10
|
||
// documents: most of this library is mostly dark.
|
||
const PALETTE = [[1, 0.3, 0.2], [0.2, 0.8, 1], [1, 0.9, 0.3],
|
||
[0.6, 0.3, 1], [0.2, 1, 0.6], [1, 1, 1]];
|
||
const PERSON = {
|
||
shape: { sides: 0, roundness: 0.25, elongation: 1, tilt: 0 },
|
||
camera: { driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0.1, spin: 0, breathe: 0 },
|
||
space: { horizon: 0.5, depth: 0.5, washAngle: 0, wash: 0.2 },
|
||
style: { lineWeight: 0.5, softness: 0.5, texture: 0, symmetry: 1 },
|
||
};
|
||
|
||
const engine = new Engine({ width: 160, height: 90 });
|
||
engine.timeline.setDuration(60);
|
||
const problems = [];
|
||
let weakest = Infinity;
|
||
let weakestName = '';
|
||
|
||
try {
|
||
const shot = (module, framing) => {
|
||
engine.setLayerSpecs([{
|
||
module, params: sampleValues(module, new Rng(31), {}, null),
|
||
seed: 9, opacity: 1, blend: 'normal', palette: PALETTE, personality: PERSON,
|
||
}]);
|
||
// Framing is pushed onto live layers, the way the arc driver does it.
|
||
const layers = engine.compositor.layers || [];
|
||
layers.forEach((l) => l.setFraming && l.setFraming(framing));
|
||
engine.prime(600);
|
||
engine.compositor.reset();
|
||
layers.forEach((l) => l.setFraming && l.setFraming(framing));
|
||
return Uint8Array.from(engine.readPixels(engine.renderFrame(600)));
|
||
};
|
||
|
||
for (const module of scenes) {
|
||
const normal = shot(module, { size: 'normal', scale: 1, shift: [0, 0] });
|
||
const close = shot(module, { size: 'close', scale: SHOT_SIZES.close.scale, shift: [0.05, 0.02] });
|
||
const wide = shot(module, { size: 'wide', scale: SHOT_SIZES.wide.scale, shift: [0.05, 0.02] });
|
||
|
||
const brightness = Math.max(1e-3,
|
||
(frameLuminance(normal) + frameLuminance(close)) * 0.5);
|
||
const best = Math.max(frameDistance(normal, close), frameDistance(normal, wide))
|
||
/ brightness;
|
||
|
||
if (best < weakest) { weakest = best; weakestName = module.name; }
|
||
if (best < 0.05) {
|
||
problems.push(`${module.name}${module.kind === 'layer3d' ? ' [3D]' : ''}: ` +
|
||
`${best.toFixed(3)} — renders the same at every shot size`);
|
||
}
|
||
}
|
||
} finally {
|
||
engine.dispose();
|
||
}
|
||
|
||
return expect(problems.length === 0,
|
||
problems.length ? problems.join(' · ')
|
||
: `${scenes.length} scenes all respond to framing · weakest ` +
|
||
`${weakestName} at ${weakest.toFixed(2)} of its own brightness`);
|
||
}, { slow: true });
|