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>
408 lines
19 KiB
JavaScript
408 lines
19 KiB
JavaScript
// Phase 10 gate — variety.
|
||
//
|
||
// Phase 8 gave a track more cuts, Phase 9 gave it a coherent identity. Watching
|
||
// several tracks side by side exposed what neither addressed: the SAME scene
|
||
// cast in two different videos looked like the same footage twice. Section bias
|
||
// is nearly identical between two tracks' drops, so both sampled their params
|
||
// around the same centre, and the library's own averageness did the rest.
|
||
//
|
||
// Three answers, and this gate is what holds them honest:
|
||
//
|
||
// temperament — a per-track hand on every parameter dial (Personality.js)
|
||
// overlays — a second full scene composited over the first, sometimes
|
||
// palette — a wider reachable colour space, so two tracks differ on
|
||
// colour before they differ on anything else
|
||
//
|
||
// Two later additions are held here for the same reason. GRAIN was in every
|
||
// video — applied unconditionally per scene and again in the grade, with only
|
||
// its amount varying — so it is now a treatment most tracks go without and no
|
||
// two grainy tracks wear the same way (look/grain.js). And TEMPO now dominates
|
||
// how fast a scene animates, because a slow song was getting scenes that
|
||
// skittered over it.
|
||
//
|
||
// The hard part of testing "variety" is that it is a property of a POPULATION,
|
||
// not of one render. Every check here therefore samples many tracks and asks
|
||
// about the spread, never about a single value.
|
||
|
||
import { check, expect } from './framework.js';
|
||
import { Engine } from '../engine/Engine.js';
|
||
import { scenes } from '../scenes/registry.js';
|
||
import { generateLook } from '../look/LookGenerator.js';
|
||
import { AudioPalette, generateUsablePalette, relativeLuminance } from '../look/palette.js';
|
||
import { Rng } from '../engine/rng.js';
|
||
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
|
||
import { synthesizeSectioned } from '../audio/synth.js';
|
||
import { sampleValues } from '../params/schema.js';
|
||
import { frameDistance, frameLuminance } from '../engine/hash.js';
|
||
import { battery as timbreBattery } from './phase3.js';
|
||
import { grainEnvelope } from '../look/grain.js';
|
||
import { signatureUniforms } from '../look/Personality.js';
|
||
|
||
/**
|
||
* Several tracks that genuinely differ in what they sound like — the population
|
||
* every check here measures. Synthetic, so the gate does not depend on assets.
|
||
*/
|
||
let cachedBattery = null;
|
||
function battery() {
|
||
if (!cachedBattery) {
|
||
cachedBattery = [
|
||
{ name: 'slow ambient', buffer: synthesizeSectioned({ bpm: 84, duration: 110, changeAt: 55 }) },
|
||
{ name: 'mid house', buffer: synthesizeSectioned({ bpm: 122, duration: 110, changeAt: 50 }) },
|
||
{ name: 'fast techno', buffer: synthesizeSectioned({ bpm: 148, duration: 110, changeAt: 45 }) },
|
||
{ name: 'broken beat', buffer: synthesizeSectioned({ bpm: 104, duration: 110, changeAt: 70 }) },
|
||
].map((t) => ({ ...t, track: FeatureTrack.fromAudioBuffer(t.buffer, { fps: 60 }) }));
|
||
}
|
||
return cachedBattery;
|
||
}
|
||
|
||
function makeEngine(track, width = 160, height = 90) {
|
||
const engine = new Engine({ width, height });
|
||
engine.timeline.setDuration(track.duration);
|
||
engine.setFeatureProvider(featureProviderFor(track));
|
||
return engine;
|
||
}
|
||
|
||
/** Every layer stack in a look, flattened. */
|
||
const stacksOf = (look) => look.sections.flatMap((s) => s.variants || [s.layers]);
|
||
|
||
check(10, 'two tracks do not share a temperament', () => {
|
||
// The per-track hand on the dials. If these collapsed toward one value the
|
||
// whole mechanism would be decorative, and the symptom — every video
|
||
// sampling around the library average — is exactly what it was built for.
|
||
// Sampled over several seeds per track rather than one. Four samples of a
|
||
// ±0.8 draw can land close together by luck, and a gate that fails on that
|
||
// is measuring the seeds, not the mechanism. What the mechanism has to
|
||
// deliver is both: a wide population AND no two tracks landing on the same
|
||
// hand — so both are asserted.
|
||
const t = [];
|
||
battery().forEach(({ track }, i) => {
|
||
for (let s = 0; s < 3; s++) t.push(generateLook(track, { seed: 100 + i * 7919 + s * 104729 }).personality.temperament);
|
||
});
|
||
|
||
// Measured as a fraction of each dial's own range, because they are not the
|
||
// same width — extremity spans 0.5 in total and intensity over three times
|
||
// that, so one absolute floor would be either trivial or unreachable.
|
||
const dialRange = { intensity: 1.7, pace: 1.6, extremity: 0.5 };
|
||
const keys = Object.keys(dialRange);
|
||
const spread = (key) => Math.max(...t.map((x) => x[key])) - Math.min(...t.map((x) => x[key]));
|
||
const worst = Math.min(...keys.map((k) => spread(k) / dialRange[k]));
|
||
|
||
let closest = Infinity;
|
||
for (let a = 0; a < t.length; a++) {
|
||
for (let b = a + 1; b < t.length; b++) {
|
||
closest = Math.min(closest, Math.max(...keys.map((k) => Math.abs(t[a][k] - t[b][k]))));
|
||
}
|
||
}
|
||
|
||
return expect(worst > 0.5 && closest > 0.02,
|
||
`${t.length} tracks · worst dial covers ${(worst * 100).toFixed(0)}% of its range · ` +
|
||
`spread intensity ${spread('intensity').toFixed(2)} · ` +
|
||
`pace ${spread('pace').toFixed(2)} · detail ${spread('detail').toFixed(2)} · ` +
|
||
`extremity ${spread('extremity').toFixed(2)} · closest pair ${closest.toFixed(3)}`);
|
||
});
|
||
|
||
check(10, 'one scene looks different in two different videos', () => {
|
||
// The complaint, as a number. Take a scene, render it with the parameters
|
||
// and personality two different tracks gave it, and require the images to
|
||
// actually differ — far above the 1/255 the determinism checks treat as
|
||
// noise, but far below "unrecognisable". The same scene should still be
|
||
// itself; it just should not be the same footage.
|
||
const looks = battery().map(({ track }, i) => ({
|
||
track,
|
||
look: generateLook(track, { seed: 500 + i * 6841 }),
|
||
}));
|
||
|
||
const distances = [];
|
||
const problems = [];
|
||
|
||
for (const module of scenes.filter((m) => m.kind === 'fragment').slice(0, 12)) {
|
||
const engine = makeEngine(looks[0].track);
|
||
try {
|
||
const frames = looks.map(({ look }) => {
|
||
// Sample this scene the way each track's own generator would.
|
||
// If the track did not cast it, sample it anyway with that
|
||
// track's temperament — the question is what this scene WOULD
|
||
// look like in that video, and falling back to defaults would
|
||
// compare two identical parameter sets and prove nothing.
|
||
const stack = stacksOf(look).find((s) => s[0].module === module);
|
||
const params = stack ? stack[0].params : sampleValues(
|
||
module,
|
||
new Rng(look.seed ^ 0x51ed270b),
|
||
look.sections[0].bias,
|
||
look.personality.temperament,
|
||
);
|
||
engine.setLayerSpecs([{
|
||
module,
|
||
params,
|
||
seed: look.seed & 0x7fffffff,
|
||
opacity: 1,
|
||
blend: 'normal',
|
||
palette: look.palette,
|
||
personality: look.personality,
|
||
}]);
|
||
engine.prime(300);
|
||
engine.compositor.reset();
|
||
return Uint8Array.from(engine.readPixels(engine.renderFrame(300)));
|
||
});
|
||
|
||
for (let i = 0; i < frames.length; i++) {
|
||
for (let j = i + 1; j < frames.length; j++) {
|
||
// Relative to how much image there is, not absolute.
|
||
// frameDistance averages over every pixel, and most scenes
|
||
// here are mostly dark — two genuinely different renders of
|
||
// a sparse scene (25 bars versus 53) score under 0.02 in
|
||
// absolute terms simply because the black background agrees
|
||
// with itself. Dividing by the images' own brightness asks
|
||
// the question that was meant: how different is this, as a
|
||
// fraction of what is actually on screen.
|
||
const brightness = Math.max(1e-3,
|
||
(frameLuminance(frames[i]) + frameLuminance(frames[j])) * 0.5);
|
||
const d = frameDistance(frames[i], frames[j]) / brightness;
|
||
distances.push(d);
|
||
if (d < 0.12) problems.push(`${module.name}: ${d.toFixed(3)}`);
|
||
}
|
||
}
|
||
} finally {
|
||
engine.dispose();
|
||
}
|
||
}
|
||
|
||
const mean = distances.reduce((a, b) => a + b, 0) / Math.max(1, distances.length);
|
||
return expect(problems.length === 0,
|
||
problems.slice(0, 4).join(' · ') ||
|
||
`${distances.length} cross-track pairs, mean relative distance ${mean.toFixed(2)}, ` +
|
||
`closest ${Math.min(...distances).toFixed(2)} (floor 0.12)`);
|
||
}, { slow: true });
|
||
|
||
check(10, 'overlays happen sometimes and not always', () => {
|
||
// A second scene over the first is the variation valve. Always-on would read
|
||
// as permanently cluttered and never-on is the state this fixed, so the
|
||
// check is on the RATE across many tracks rather than on any one stack.
|
||
let stacks = 0;
|
||
let withOverlay = 0;
|
||
const blends = new Set();
|
||
|
||
for (const { track } of battery()) {
|
||
for (let s = 0; s < 6; s++) {
|
||
const look = generateLook(track, { seed: 900 + s * 5231 });
|
||
for (const stack of stacksOf(look)) {
|
||
stacks++;
|
||
const overlay = stack[1];
|
||
if (overlay) {
|
||
withOverlay++;
|
||
blends.add(overlay.blend);
|
||
if (overlay.blend === 'normal') {
|
||
// 'normal' would hide the shot entirely rather than
|
||
// compositing over it — that is what a cut is for.
|
||
return expect(false, `${overlay.module.name} overlaid with 'normal'`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const rate = withOverlay / Math.max(1, stacks);
|
||
return expect(rate > 0.05 && rate < 0.55,
|
||
`${withOverlay}/${stacks} stacks carry an overlay (${(rate * 100).toFixed(0)}%), ` +
|
||
`blends: ${[...blends].join(', ')}`);
|
||
});
|
||
|
||
check(10, 'an overlay never hides the shot underneath it', () => {
|
||
// Opacity is the other half of "composited over" — a 0.9 overlay is a
|
||
// replacement with extra steps.
|
||
const problems = [];
|
||
for (const { track } of battery()) {
|
||
for (let s = 0; s < 4; s++) {
|
||
for (const stack of stacksOf(generateLook(track, { seed: 1300 + s * 8641 }))) {
|
||
for (const layer of stack.slice(1)) {
|
||
if (layer.opacity > 0.6) {
|
||
problems.push(`${layer.module.name} at ${layer.opacity.toFixed(2)}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return expect(problems.length === 0, problems.slice(0, 3).join(' · ') || 'all overlays stay under 0.6');
|
||
});
|
||
|
||
check(10, 'the palette reaches the whole colour wheel', () => {
|
||
// Hue used to be a one-way sweep from red down to blue, which made violet,
|
||
// magenta and pink unreachable for every track ever generated — a third of
|
||
// the wheel the tool simply could not produce. Sampled across many tracks,
|
||
// every sixth of the wheel should now show up.
|
||
const buckets = new Array(6).fill(0);
|
||
const samples = 40;
|
||
|
||
for (let i = 0; i < samples; i++) {
|
||
const rng = new Rng(2000 + i * 7919);
|
||
const summary = {
|
||
meanCentroid: 0.2 + (i % 7) * 0.1,
|
||
meanFlatness: 0.1 + (i % 5) * 0.08,
|
||
dynamicRange: 0.3 + (i % 4) * 0.15,
|
||
bpm: 80 + (i % 9) * 12,
|
||
bandBalance: {
|
||
sub: 0.2 + (i % 5) * 0.12, low: 0.3 + (i % 3) * 0.2, mid: 0.25 + (i % 4) * 0.1,
|
||
high: 0.2 + (i % 6) * 0.1, air: 0.15 + (i % 7) * 0.09,
|
||
},
|
||
};
|
||
for (const [r, g, b] of generateUsablePalette(new AudioPalette(summary, rng), 6)) {
|
||
const max = Math.max(r, g, b);
|
||
const min = Math.min(r, g, b);
|
||
if (max - min < 0.08) continue; // greys carry no hue
|
||
let h;
|
||
if (max === r) h = ((g - b) / (max - min) + 6) % 6;
|
||
else if (max === g) h = (b - r) / (max - min) + 2;
|
||
else h = (r - g) / (max - min) + 4;
|
||
buckets[Math.floor(h) % 6]++;
|
||
}
|
||
}
|
||
|
||
const empty = buckets.filter((n) => n === 0).length;
|
||
const names = ['red', 'yellow', 'green', 'cyan', 'blue', 'magenta'];
|
||
return expect(empty === 0,
|
||
buckets.map((n, i) => `${names[i]}:${n}`).join(' ') +
|
||
(empty ? ` — ${empty} sixth(s) unreachable` : ''));
|
||
});
|
||
|
||
check(10, 'two tracks do not get the same palette', () => {
|
||
// Distinctness, not just coverage: a wheel that is fully reachable but where
|
||
// every track lands in the same place would pass the check above.
|
||
//
|
||
// Measured on the Phase 3 battery rather than this file's, because those
|
||
// four tracks differ in TIMBRE and this file's differ mostly in tempo.
|
||
// Palette follows timbre by design, so two tracks that sound alike SHOULD
|
||
// get similar colour — asking otherwise would be asking the generator to
|
||
// ignore its own input.
|
||
const palettes = timbreBattery().map(({ track }) =>
|
||
generateLook(track, { seed: 61 }).palette); // fixed seed: difference must come from the audio
|
||
|
||
let closest = Infinity;
|
||
for (let i = 0; i < palettes.length; i++) {
|
||
for (let j = i + 1; j < palettes.length; j++) {
|
||
let sum = 0;
|
||
for (let k = 0; k < palettes[i].length; k++) {
|
||
const a = palettes[i][k], b = palettes[j][k];
|
||
sum += Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||
}
|
||
closest = Math.min(closest, sum / palettes[i].length);
|
||
}
|
||
}
|
||
const lums = palettes.map((p) => p.map(relativeLuminance).join(','));
|
||
return expect(closest > 0.08 && new Set(lums).size === palettes.length,
|
||
`closest pair mean channel distance ${closest.toFixed(3)} (floor 0.08), ` +
|
||
`${new Set(lums).size}/${palettes.length} distinct`);
|
||
});
|
||
|
||
// --- grain -----------------------------------------------------------------
|
||
// The complaint these answer: grain was in every video. It was applied twice
|
||
// unconditionally — once per scene, once in the grade — so the only thing that
|
||
// varied between tracks was how much. See look/grain.js.
|
||
|
||
check(10, 'most tracks carry no grain at all', () => {
|
||
const modes = [];
|
||
for (let i = 0; i < 60; i++) {
|
||
modes.push(generateLook(battery()[i % 4].track, { seed: 900 + i * 5779 }).grain.mode);
|
||
}
|
||
const off = modes.filter((m) => m === 'off').length / modes.length;
|
||
const distinct = new Set(modes).size;
|
||
|
||
// A quarter to two thirds. Never grainy is as much a failure as always
|
||
// grainy — the treatment has to remain available.
|
||
return expect(off > 0.25 && off < 0.7 && distinct >= 4,
|
||
`${(off * 100).toFixed(0)}% of 60 tracks have no grain · ${distinct} modes used: ` +
|
||
`${[...new Set(modes)].join(', ')}`);
|
||
});
|
||
|
||
check(10, 'two grainy tracks are not grainy the same way', () => {
|
||
// Amount alone was never the difference that mattered. Cell size, refresh
|
||
// rate, mask and chroma are, so the population has to spread across them.
|
||
const specs = [];
|
||
for (let i = 0; i < 80 && specs.length < 30; i++) {
|
||
const g = generateLook(battery()[i % 4].track, { seed: 4100 + i * 7717 }).grain;
|
||
if (g.mode !== 'off') specs.push(g);
|
||
}
|
||
const uniq = (key) => new Set(specs.map((s) => s[key])).size;
|
||
const signatures = new Set(specs.map((s) => `${s.mode}/${s.scale}/${s.rate}/${s.mask}`)).size;
|
||
|
||
return expect(uniq('scale') >= 3 && uniq('rate') >= 3 && uniq('mask') >= 3
|
||
&& signatures >= specs.length * 0.5,
|
||
`${specs.length} grainy tracks · ${uniq('scale')} cell sizes · ${uniq('rate')} rates · ` +
|
||
`${uniq('mask')} masks · ${signatures} distinct treatments`);
|
||
});
|
||
|
||
check(10, 'grain that is not constant actually comes and goes', () => {
|
||
// A 'swell' or 'sections' grain that never reaches zero is just constant
|
||
// grain with extra steps, and one that never reaches full is decoration.
|
||
const { track } = battery()[1];
|
||
const problems = [];
|
||
let tested = 0;
|
||
|
||
for (let i = 0; i < 120 && tested < 6; i++) {
|
||
const look = generateLook(track, { seed: 7000 + i * 3571 });
|
||
const g = look.grain;
|
||
if (g.mode === 'off' || g.mode === 'constant') continue;
|
||
tested++;
|
||
|
||
let lo = Infinity, hi = -Infinity;
|
||
for (let f = 0; f < track.frameCount; f += 7) {
|
||
const env = grainEnvelope(g, {
|
||
time: f / 60,
|
||
sectionKind: track.sectionAt(f).kind,
|
||
features: track.at(f),
|
||
});
|
||
lo = Math.min(lo, env);
|
||
hi = Math.max(hi, env);
|
||
}
|
||
if (lo > 0.05 || hi < 0.4) problems.push(`${g.mode}: ${lo.toFixed(2)}..${hi.toFixed(2)}`);
|
||
}
|
||
|
||
return expect(tested > 0 && problems.length === 0,
|
||
problems.length ? problems.join(' · ') : `${tested} time-varying grains all reach 0 and full`);
|
||
});
|
||
|
||
check(10, 'a scene that refuses grain never gets any', () => {
|
||
// `texture: 0` on a module has to survive a track that wants maximum grit,
|
||
// because the point of it is that crisp line work stays crisp.
|
||
const gritty = { style: { lineWeight: 0.5, softness: 0.5, texture: 1, symmetry: 1 },
|
||
shape: { sides: 0, roundness: 0, elongation: 1, tilt: 0 },
|
||
camera: { driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0, spin: 0, breathe: 0 },
|
||
space: { horizon: 0.5, depth: 0, washAngle: 0, wash: 0 } };
|
||
|
||
const refusing = scenes.filter((m) => m.texture === 0);
|
||
const leaked = refusing.filter((m) => signatureUniforms(gritty, m).u_sigTexture > 0);
|
||
const takesIt = signatureUniforms(gritty, scenes.find((m) => m.texture === undefined));
|
||
|
||
return expect(refusing.length > 0 && leaked.length === 0 && takesIt.u_sigTexture === 1,
|
||
`${refusing.length} scenes opt out, ${leaked.length} leaked · ` +
|
||
`an opted-in scene still gets ${takesIt.u_sigTexture}`);
|
||
});
|
||
|
||
// --- tempo -----------------------------------------------------------------
|
||
|
||
check(10, 'a slow track animates slower than a fast one', () => {
|
||
// Motion used to be mostly energy with tempo as a small correction, so a
|
||
// 70bpm ballad's drop asked its scenes for nearly as much speed as a
|
||
// 150bpm track's — and the scenes obliged, over a song that was not moving.
|
||
const slow = generateLook(battery()[0].track, { seed: 31 }); // 84bpm
|
||
const fast = generateLook(battery()[2].track, { seed: 31 }); // 148bpm
|
||
|
||
const rateMean = (look) => {
|
||
const vals = [];
|
||
for (const stack of stacksOf(look)) {
|
||
for (const layer of stack) {
|
||
for (const [name, def] of Object.entries(layer.module.params || {})) {
|
||
if (!def.rate || typeof layer.params[name] !== 'number') continue;
|
||
const [lo, hi] = def.range;
|
||
vals.push((layer.params[name] - lo) / (hi - lo)); // normalised
|
||
}
|
||
}
|
||
}
|
||
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
||
};
|
||
|
||
const s = rateMean(slow), f = rateMean(fast);
|
||
return expect(f > s * 1.25,
|
||
`mean normalised rate: 84bpm ${s.toFixed(3)} vs 148bpm ${f.toFixed(3)} ` +
|
||
`(${(f / Math.max(1e-6, s)).toFixed(2)}×)`);
|
||
});
|