Two devices that were built, wired up, and doing nothing. OVERLAYS. buildStack gated layering on `surfaceOf(m) === 'composable'`, and no scene in the library declared `surface` — so the only thing that could ever sit on top was the one scene declaring `role: 'accent'`, and the overlay roster excluded accents by construction. Empty intersection, every time: 0/144 stacks carried an overlay. Every layered frame in every song was the same particle field. Labelled the library from the phase 12 coverage gate rather than by eye: the 37 scenes painting under 30% of the frame are composable. Particle Field loses its privileged slot and becomes one of them, keeping only `background: false`, which is the honest part — points in empty space cannot carry a section alone. The reserved accent slot is gone; one roster, two passes at it. 1 distinct overlay scene becomes 27, and the rate lands at 34% of stacks after trimming a base chance that had been tuned while the branch was dead. THE CAMERA. framing.shift moved the frame by a median of 0.029 of a half-frame at a fresh random angle every shot, so successive offsets cancelled and the median jump at a cut was 0.014. Present in every frame, visible in none. look/Camera.js is the director's camera department: the story says tension, order and which act a section is in, and this turns that into where the frame looks and how it travels there. Each director names a camera. Jump distance follows tension and act, speed follows energy, and the curve is one of four. Cuts choose between reframing and matching, so two scenes can still read as one place. Median offset 0.190, median reframe 0.135, and 94% of shots now move during the shot rather than only at the cut. Four new gates, each with a FLOOR — the device this replaces passed every existing check while doing nothing, because the only bound was a ceiling. Also lands the in-progress Epic 4 story layer it builds on: Story.js, phase 13, and the direction statistic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
12 KiB
JavaScript
247 lines
12 KiB
JavaScript
// Phase 12 — the SEED VARIETY TEST.
|
|
//
|
|
// Every other phase asks whether one video is correct. This one asks whether
|
|
// two videos are different, which is the failure the rest of the suite is
|
|
// structurally unable to see: a generator that ignores its seed passes
|
|
// determinism, flash safety, liveness and end-to-end rendering perfectly.
|
|
//
|
|
// The measurement lives in checks/variety/. The two checks that come first here
|
|
// are not about the generator at all — they are about the instrument. A
|
|
// structural variety score is worthless unless it can be shown to ignore the
|
|
// cheap axes (recolour, rotate) and to react to the expensive one (a different
|
|
// scene), so those are gated before any number derived from them is trusted.
|
|
//
|
|
// The library sweep in the middle is the map the rest is drawn on: the seed can
|
|
// only produce as much variety as the library holds, so "how many structurally
|
|
// distinct looks are there" is measured before "how many does a seed reach".
|
|
|
|
import { check, expect } from './framework.js';
|
|
import { song } from '../audio/songbank.js';
|
|
import { Show } from '../Show.js';
|
|
import { generateLook } from '../look/LookGenerator.js';
|
|
import { scenes } from '../scenes/registry.js';
|
|
import { surfaceOf, canBackground } from '../params/schema.js';
|
|
import { Engine } from '../engine/Engine.js';
|
|
import { defaultValues } from '../params/schema.js';
|
|
import { featureProviderFor } from '../audio/FeatureTrack.js';
|
|
import { frameDescriptor, rotate90, recolour } from './variety/descriptors.js';
|
|
import { descriptorDistance, signatureDistance } from './variety/signature.js';
|
|
import {
|
|
measureVariety, measureSpecDiversity, signatureForScene, librarySweep,
|
|
} from './variety/report.js';
|
|
|
|
// `centre` is the bank's null hypothesis: the middle of every axis, a full
|
|
// six-stage arrangement. The gates used to run on a two-section synthetic whose
|
|
// only kinds were intro and outro, which left half the scene library unreachable
|
|
// and made every number here a measurement of that rather than of the seed.
|
|
export const varietyTrack = () => song('centre').track;
|
|
|
|
// The library sweep is the most expensive thing in the suite — every scene
|
|
// rendered — and three checks read it. Computed once per page load.
|
|
let cachedSweep = null;
|
|
export function librarySweepCached() {
|
|
if (!cachedSweep) cachedSweep = librarySweep(varietyTrack(), { probes: 3 });
|
|
return cachedSweep;
|
|
}
|
|
|
|
/** One frame of one seed, rendered the normal way. */
|
|
function sampleFrame(seed = 12001, width = 160, height = 90) {
|
|
const track = varietyTrack();
|
|
const show = new Show({ width, height });
|
|
try {
|
|
show.useTrack(track, generateLook(track, { seed }));
|
|
const section = show.look.sections[Math.floor(show.look.sections.length / 2)];
|
|
const frame = section.startFrame + Math.floor((section.endFrame - section.startFrame) / 2);
|
|
show.engine.compositor.reset();
|
|
for (let f = Math.max(0, frame - 20); f < frame; f++) show.renderFrame(f);
|
|
return Uint8Array.from(show.readPixels(show.renderFrame(frame)));
|
|
} finally {
|
|
show.dispose();
|
|
}
|
|
}
|
|
|
|
check(12, 'seed variety · the metric ignores colour and rotation', () => {
|
|
const w = 160, h = 90;
|
|
const pixels = sampleFrame(12001, w, h);
|
|
const base = frameDescriptor(pixels, w, h);
|
|
|
|
// Hue-rotated and brightened: the same picture in a different palette, which
|
|
// is precisely the difference we refuse to count as variety.
|
|
const graded = descriptorDistance(base, frameDescriptor(recolour(pixels), w, h));
|
|
// Turned ninety degrees. Layout is EXCLUDED here by design — where structure
|
|
// sits in the frame is real information, and a metric blind to it could not
|
|
// see a library that centres everything.
|
|
const spun = rotate90(pixels, w, h);
|
|
const turned = descriptorDistance(base, frameDescriptor(spun.pixels, spun.width, spun.height));
|
|
|
|
const structural = ['scale', 'orient', 'texture'];
|
|
const worstGrade = Math.max(...structural.concat('layout').map((b) => graded[b]));
|
|
const worstTurn = Math.max(...structural.map((b) => turned[b]));
|
|
|
|
return expect(worstGrade < 0.05 && worstTurn < 0.02 && graded.colour > 0.2,
|
|
`recolour moves structure ${worstGrade.toFixed(4)} (colour block ${graded.colour.toFixed(3)}) · ` +
|
|
`rotate moves structure ${worstTurn.toFixed(4)}`);
|
|
});
|
|
|
|
check(12, 'seed variety · the metric separates different scenes from the same scene', () => {
|
|
// The instrument's other half: it must react to the thing that IS a
|
|
// difference. Two renders of one scene must land far below two renders of
|
|
// two scenes, or a low variety score would just be a blind metric.
|
|
const track = varietyTrack();
|
|
const pool = scenes.filter((m) => canBackground(m) && m.kind === 'fragment');
|
|
const a = pool[0], b = pool[Math.floor(pool.length / 2)], c = pool[pool.length - 1];
|
|
|
|
const sigA = signatureForScene(track, a, 777, { probes: 2 });
|
|
const sigA2 = signatureForScene(track, a, 777, { probes: 2 });
|
|
const sigB = signatureForScene(track, b, 777, { probes: 2 });
|
|
const sigC = signatureForScene(track, c, 777, { probes: 2 });
|
|
|
|
const same = signatureDistance(sigA, sigA2).total;
|
|
const diff = Math.min(
|
|
signatureDistance(sigA, sigB).total,
|
|
signatureDistance(sigA, sigC).total,
|
|
signatureDistance(sigB, sigC).total,
|
|
);
|
|
|
|
return expect(same < 0.01 && diff > same * 8,
|
|
`same scene ${same.toFixed(4)} · different scenes ${diff.toFixed(4)} ` +
|
|
`(${a.name} / ${b.name} / ${c.name})`);
|
|
}, { slow: true });
|
|
|
|
check(12, 'seed variety · every visualization in the library is a distinct look', () => {
|
|
// Runs against ALL of them, not a sample. The existing per-scene gate does
|
|
// a version of this on raw pixels, where two scenes that are the same image
|
|
// in different colours pass comfortably; here colour is not counted, so a
|
|
// structural twin is visible as one.
|
|
//
|
|
// Twins are reported rather than failed on a fixed distance: what matters is
|
|
// that the library does not contain a CLUSTER of scenes that are one look
|
|
// wearing several names, because the casting code will happily "vary" a
|
|
// video by rotating between them.
|
|
const sweep = librarySweepCached();
|
|
const bigTwins = sweep.twins.filter((g) => g.length >= 3);
|
|
|
|
return expect(bigTwins.length === 0,
|
|
(bigTwins.length ? `structural twin groups: ${bigTwins.map((g) => g.join('≈')).join(' · ')} — ` : '') +
|
|
`${sweep.scenes.length} scenes · median distance ${sweep.median.toFixed(3)} · ` +
|
|
`closest ${sweep.closestPairs[0].name}≈${sweep.closestPairs[0].nearest} ` +
|
|
`at ${sweep.closestPairs[0].distance.toFixed(3)}`);
|
|
}, { slow: true });
|
|
|
|
check(12, 'seed variety · the generator casts a different show for a different seed', () => {
|
|
// Cheap, GPU-free, and the first thing to read when the rendered score
|
|
// drops: this says whether the generator ever DECIDED to make two different
|
|
// videos, before asking whether the pixels came out different.
|
|
const d = measureSpecDiversity(varietyTrack(), { seeds: 24 });
|
|
const problems = [];
|
|
if (d.sceneSetDistance < 0.6) problems.push(`scene sets only ${d.sceneSetDistance.toFixed(2)} apart`);
|
|
if (d.identicalCasts > 0) problems.push(`${d.identicalCasts} seed pairs cast identically`);
|
|
if (d.libraryCoverage < 0.6) {
|
|
problems.push(`only ${(d.libraryCoverage * 100).toFixed(0)}% of the library used ` +
|
|
`(never cast: ${d.uncast.slice(0, 6).join(', ')})`);
|
|
}
|
|
if (d.director.unique < 2) problems.push('one director for every seed');
|
|
|
|
return expect(problems.length === 0,
|
|
problems.length ? problems.join(' · ')
|
|
: `cast distance ${d.sceneSetDistance.toFixed(2)} · ` +
|
|
`library ${(d.libraryCoverage * 100).toFixed(0)}% · ` +
|
|
`${d.director.unique} directors · ${d.paletteScheme.unique} schemes · ` +
|
|
`${d.signature.unique} signatures · ${d.grain.unique} grain modes`);
|
|
});
|
|
|
|
check(12, 'seed variety · different seeds render structurally different videos', () => {
|
|
// THE gate. The threshold is a target the generator does not currently meet
|
|
// — a failure here is the known open defect, not a flaky check. The detail
|
|
// line carries the full breakdown so a run can be compared against the last
|
|
// one while the number is being moved.
|
|
const r = measureVariety(varietyTrack(), { seeds: 6, refScenes: 4, probes: 4 });
|
|
const problems = [];
|
|
|
|
// Against the floor: two seeds must differ by more than one seed differs
|
|
// from itself across its own sections. Below this the seed is decoration.
|
|
if (r.separation < 0.35) {
|
|
problems.push(`separation ${r.separation.toFixed(2)} (floor ${r.floor.toFixed(3)}, ` +
|
|
`observed ${r.observed.toFixed(3)}, ceiling ${r.ceiling.toFixed(3)})`);
|
|
}
|
|
// No seed may be shadowed by another. A healthy mean hides pairs that are
|
|
// the same video, and a viewer only ever sees the pair.
|
|
const shadowed = r.nearest.filter((d) => d < r.floor * 0.75).length;
|
|
if (shadowed) problems.push(`${shadowed} seeds shadowed by another seed`);
|
|
|
|
// Per block: it is not enough for the total to pass on colour-adjacent
|
|
// motion while every frame is composed the same way.
|
|
for (const [name, b] of Object.entries(r.byBlock)) {
|
|
if (name === 'colour') continue;
|
|
if (b.ratio < 0.25) problems.push(`${name} at ${(b.ratio * 100).toFixed(0)}% of achievable`);
|
|
}
|
|
|
|
const blocks = Object.entries(r.byBlock)
|
|
.map(([n, b]) => `${n} ${(b.ratio * 100).toFixed(0)}%`).join(' · ');
|
|
return expect(problems.length === 0,
|
|
(problems.length ? problems.join(' · ') + ' — ' : '') +
|
|
`separation ${r.separation.toFixed(2)} · ${blocks}`);
|
|
}, { slow: true });
|
|
|
|
|
|
/**
|
|
* How much of the frame a scene actually paints.
|
|
*
|
|
* A composable scene has to leave room for what it sits on. One that claims to
|
|
* and covers the frame anyway will hide its background completely, which is the
|
|
* failure the declaration exists to prevent — and it is not something you can
|
|
* see from the source, only from the render.
|
|
*/
|
|
function coverageOf(engine, module) {
|
|
engine.setLayerSpecs([{
|
|
module, params: defaultValues(module), seed: 4242,
|
|
opacity: 1, blend: 'normal',
|
|
palette: [[0.05, 0.05, 0.1], [0.9, 0.3, 0.5], [0.3, 0.8, 0.9], [0.95, 0.9, 0.4]],
|
|
}]);
|
|
engine.compositor.reset();
|
|
for (let f = 594; f < 600; f++) engine.renderFrame(f);
|
|
const px = Uint8Array.from(engine.readPixels(engine.renderFrame(600)));
|
|
let lit = 0;
|
|
for (let i = 0; i < px.length; i += 4) {
|
|
// Anything a viewer would read as painted rather than as backdrop.
|
|
if (px[i] + px[i + 1] + px[i + 2] > 90) lit++;
|
|
}
|
|
return lit / (px.length / 4);
|
|
}
|
|
|
|
check(12, 'surface · a composable scene leaves room for what it sits on', () => {
|
|
const engine = new Engine({ width: 128, height: 72 });
|
|
const track = varietyTrack();
|
|
engine.timeline.setDuration(track.duration);
|
|
engine.setFeatureProvider(featureProviderFor(track));
|
|
|
|
const wrong = [];
|
|
const measured = [];
|
|
try {
|
|
for (const module of scenes) {
|
|
if (module.kind !== 'fragment') continue;
|
|
const cover = coverageOf(engine, module);
|
|
const surface = surfaceOf(module);
|
|
measured.push({ name: module.name, surface, cover });
|
|
// A composable scene painting most of the frame hides its
|
|
// background; a canvas leaving it nearly empty is a canvas in name
|
|
// only and will read as a black frame when nothing is under it.
|
|
if (surface === 'composable' && cover > 0.55) wrong.push(`${module.name} claims composable but covers ${(cover * 100).toFixed(0)}%`);
|
|
if (surface === 'canvas' && cover < 0.08) wrong.push(`${module.name} claims canvas but covers only ${(cover * 100).toFixed(0)}%`);
|
|
}
|
|
} finally {
|
|
engine.dispose();
|
|
}
|
|
|
|
// Reported whatever the verdict, because the useful output of this check is
|
|
// the list itself — it is how the library gets labelled in the first place.
|
|
const sorted = measured.sort((a, b) => a.cover - b.cover);
|
|
window.__COVERAGE__ = sorted;
|
|
const sparse = sorted.filter((m) => m.cover < 0.3).length;
|
|
|
|
return expect(wrong.length === 0,
|
|
(wrong.length ? wrong.slice(0, 4).join(' · ') + ' — ' : '') +
|
|
`${measured.length} scenes · ${sparse} paint under 30% of the frame ` +
|
|
`· sparsest ${sorted[0].name} at ${(sorted[0].cover * 100).toFixed(0)}%`);
|
|
}, { slow: true });
|