// The variety measurement: does changing the seed actually change the video? // // A raw distance between two seeds means nothing on its own — 0.14 is not // interpretable. It is only a number once it sits between two references that // the same instrument produced: // // FLOOR how far one video travels from ITSELF over its own length (drift). // Two seeds that differ by less than this are, in the only sense that // matters, the same video shown twice. // CEILING how far two videos are when the same pipeline is run with the // design deliberately thrown away — every layer recast at random. // This is the most variety the library can express, so it is what the // generator is measured against, not some abstract 1.0. // // separation = (between-seed - floor) / (ceiling - floor) // // 0 means the seed does nothing a viewer could name. 1 means two seeds are as // unalike as two randomly assembled videos. The honest target is somewhere well below // 1 — a generator with a house style SHOULD land under the ceiling — but it has // to clear the floor by a wide margin, and the per-block breakdown is what says // where the missing variety went. // // Alongside the pixel measurement there is a SPEC measurement, which needs no // GPU: how much the generator's own decisions differ across seeds. Pixels // measure the symptom, the spec measures the cause. If spec diversity is high // and pixel variety is low, the generator is deciding freely and the renderer // or the post chain is flattening it. If spec diversity is also low, the // casting is the bottleneck and no amount of shader work will fix it. import { Show } from '../../Show.js'; import { generateLook } from '../../look/LookGenerator.js'; import { scenes } from '../../scenes/registry.js'; import { defaultValues, sampleValues, canBackground } from '../../params/schema.js'; import { Rng } from '../../engine/rng.js'; import { videoSignature, signatureDistance, STRUCTURAL } from './signature.js'; import { songBank } from '../../audio/songbank.js'; import { hashString } from '../../engine/rng.js'; import { subjectOf, isGround } from '../../look/stack.js'; import { canGround } from '../../scenes/surface.js'; const RENDER = { width: 160, height: 90 }; /** Signature for one seed, rendered through the whole normal pipeline. */ export function signatureForSeed(track, seed, options = {}) { const { pool = null, poolSize, ...rest } = options; const show = new Show({ ...RENDER }); try { show.useTrack(track, generateLook(track, { seed: seed >>> 0, pool, ...(poolSize ? { poolSize } : {}), })); return videoSignature(show, rest); } finally { show.dispose(); } } /** * The ceiling reference: a video with the design thrown away. * * Same pipeline, same shot planning, same post — but every layer is recast to a * scene picked uniformly at random and its parameters resampled without regard * for the section. Two of these agree about nothing, so the distance between * them is the most this library and this renderer can express. That is the * honest thing to measure the generator against: not 1.0, which no pipeline * reaches, and not two default-parameter scenes either — that was the first * attempt, and it produced videos so internally uneventful that real seeds * scored ABOVE the supposed ceiling on three blocks out of five. */ export function signatureForChaos(track, seed, options = {}) { const show = new Show({ ...RENDER }); try { const look = generateLook(track, { seed: seed >>> 0 }); const rng = new Rng((seed * 2246822519) >>> 0); const pool = scenes.filter(canBackground); // The ground keeps its JOB when its identity is thrown away. Recasting // it from the whole library would give the reference videos thin, // half-black frames no real video can have any more, and a ceiling // measured on those is a ceiling for a pipeline that does not exist — // it fell below the floor the first time this ran. const groundPool = scenes.filter(canGround); const temperament = look.personality && look.personality.temperament; for (const section of look.sections) { for (const variant of section.variants) { for (const layer of variant) { layer.module = rng.pick(isGround(layer) ? groundPool : pool); layer.params = sampleValues(layer.module, rng, section.bias, temperament); layer.seed = rng.int(0, 0x7fffffff); } } section.layers = section.variants[0]; } show.useTrack(track, look); return videoSignature(show, options); } finally { show.dispose(); } } /** * Signature for a video forced onto ONE library scene. * * `sampled` swaps default parameters for a seeded draw, which is what the * ceiling wants: defaults are the middle of every range and make a scene look * tamer than the generator would ever cast it. */ export function signatureForScene(track, module, seed, options = {}) { const { sampled = false, ...rest } = options; const show = new Show({ ...RENDER }); try { const look = generateLook(track, { seed: seed >>> 0 }); const prng = new Rng((seed * 40503) >>> 0); for (const section of look.sections) { const layers = [{ module, params: sampled ? sampleValues(module, prng, section.bias, look.personality && look.personality.temperament) : defaultValues(module), seed: seed >>> 0, blend: 'normal', opacity: 1, }]; section.variants = [layers]; section.layers = layers; for (const shot of section.shots || []) shot.variant = 0; } show.useTrack(track, look); return videoSignature(show, rest); } finally { show.dispose(); } } /** * The ceiling: videos with the same STRUCTURE as real ones, cast from pools that * share no scenes at all. * * Getting this reference right took three attempts, and both failures were * instructive enough to record. * * 1. Recast every layer at random. Averaging a dozen random scenes converges * on the same generic busy image every time, so two "chaos" videos came out * closer to each other than two real ones. * 2. One scene per video, no rotation. That collapsed the other way: a video * that never changes scene has almost no internal variation, so the ceiling * landed BELOW the floor, which is a within-video quantity. * * The reference has to match what it is bounding. These keep the real pipeline — * rosters, shots, per-section sampling, so a reference video rotates between * three or four scenes exactly as a real one does — while the pools they draw * from are disjoint slices of the library. Same complexity, nothing in common. */ export function ceilingSignatures(track, { count = 4, probes = 4, seed = 0xbadc0de } = {}) { const rng = new Rng(seed >>> 0); const pool = rng.shuffle(scenes.filter(canBackground)); const slice = Math.max(4, Math.floor(pool.length / count)); const out = []; for (let i = 0; i < count; i++) { const mine = pool.slice(i * slice, (i + 1) * slice); if (mine.length < 4) break; const show = new Show({ ...RENDER }); try { // The real generator, given a restricted cast. An earlier version // reached in and reassigned every layer at random instead, and that // averaged a dozen scenes per video — the more sections a track had, // the more its references converged on the same generic image, so // the ceiling fell BELOW the floor on any track with five sections. show.useTrack(track, generateLook(track, { seed: (seed + i * 40503) >>> 0, pool: mine, })); out.push(videoSignature(show, { probes })); } finally { show.dispose(); } } return out; } /** * Every visualization in the library, measured structurally, against every * other one. * * This is the map the seed test is drawn on. A library of sixty names is not a * library of sixty looks: two scenes built from different maths can land on the * same image — the same feature scale, the same composition, the same kind of * movement — and once they do, no amount of casting variety can produce a * different-looking video by choosing between them. * * Note what this catches that the existing per-scene "distinct" gate cannot. * That one compares raw pixels, so two scenes that are structurally the same * image in different colours pass it comfortably. Here colour is not counted at * all, so structural twins have nowhere to hide. * * Default parameters throughout: the question is what a scene inherently looks * like, and sampled parameters would make the answer depend on which roll it * got. */ export function librarySweep(track, { probes = 3, onProgress = null } = {}) { const pool = scenes.filter(canBackground); const sigs = []; for (let i = 0; i < pool.length; i++) { sigs.push(signatureForScene(track, pool[i], 4242, { probes })); if (onProgress) onProgress(i + 1, pool.length, pool[i].name); } const n = pool.length; const matrix = Array.from({ length: n }, () => new Float64Array(n)); for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { const d = signatureDistance(sigs[i], sigs[j]).total; matrix[i][j] = d; matrix[j][i] = d; } } const all = []; for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) all.push(matrix[i][j]); all.sort((a, b) => a - b); const nearest = pool.map((module, i) => { let best = Infinity, at = -1; for (let j = 0; j < n; j++) { if (i !== j && matrix[i][j] < best) { best = matrix[i][j]; at = j; } } return { name: module.name, family: module.family, nearest: pool[at].name, distance: best }; }); // COMPLETE-link clustering at the twin threshold: a scene joins a group only // if it is close to every member, not merely to one of them. // // Single link was the first attempt and it lied. Structural distance is // chainable — A near B, B near C, C near D — so it reported fourteen scenes // as one look when what actually existed was a chain of overlapping pairs. // A group here means every pair inside it is a twin, which is a claim worth // acting on. const twinAt = all[Math.floor(all.length * 0.02)]; // the closest 2% of pairs const order = []; for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) order.push([matrix[i][j], i, j]); order.sort((a, b) => a[0] - b[0]); const groupOf = new Array(n).fill(-1); const clusters = []; for (const [d, i, j] of order) { if (d > twinAt) break; const gi = groupOf[i], gj = groupOf[j]; const fits = (member, group) => group.every((k) => matrix[member][k] <= twinAt); if (gi < 0 && gj < 0) { groupOf[i] = groupOf[j] = clusters.length; clusters.push([i, j]); } else if (gi >= 0 && gj < 0 && fits(j, clusters[gi])) { clusters[gi].push(j); groupOf[j] = gi; } else if (gj >= 0 && gi < 0 && fits(i, clusters[gj])) { clusters[gj].push(i); groupOf[i] = gj; } } const groups = clusters.map((c) => c.map((i) => pool[i].name)); return { scenes: pool.map((m) => m.name), matrix, nearest, median: all[Math.floor(all.length / 2)], mean: mean(all), p05: all[Math.floor(all.length * 0.05)], twinAt, twins: groups.filter((g) => g.length > 1).sort((a, b) => b.length - a.length), twinPairs: order.filter(([d]) => d <= twinAt) .map(([d, i, j]) => ({ a: pool[i].name, b: pool[j].name, distance: d })), closestPairs: nearest.slice().sort((a, b) => a.distance - b.distance).slice(0, 8), }; } function pairwise(items, fn) { const out = []; for (let i = 0; i < items.length; i++) { for (let j = i + 1; j < items.length; j++) out.push(fn(items[i], items[j], i, j)); } return out; } const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0); /** * The full measurement. * * @param {FeatureTrack} track one analysed track — held FIXED, so the only * variable is the seed. Measuring across different audio would confound * "the generator ignores its seed" with "these songs are alike". * @param {object} options * @returns {object} report */ export function measureVariety(track, { seeds = 8, refScenes = 5, probes = 5, seed0 = 0x5eed, } = {}) { const seedList = []; for (let i = 0; i < seeds; i++) seedList.push((seed0 + i * 2654435761) >>> 0); const sigs = seedList.map((s) => signatureForSeed(track, s, { probes })); // --- floor: a video against itself, later --------------------------- const floor = mean(sigs.map((s) => s.drift)); // --- between-seed --------------------------------------------------- const between = pairwise(sigs, (a, b) => signatureDistance(a, b)); const observed = mean(between.map((d) => d.total)); // --- ceiling: single-scene videos, each on a different scene --------- const refSigs = ceilingSignatures(track, { count: refScenes, probes }); const ceilingPairs = pairwise(refSigs, (a, b) => signatureDistance(a, b)); const ceiling = mean(ceilingPairs.map((d) => d.total)); // The same statistic on the single-scene references — a video with no story // in it at all. It is the zero this measurement is read against, rather than // a theoretical 0: probes are not evenly spaced and a slow scene drifts on // its own, so an arcless video does not score exactly nothing. const directionFloor = mean(refSigs.map((s) => s.direction ?? 0)); // If the reference is not above the floor it is not a ceiling, and the // ratio built on it is meaningless rather than large. Say so instead of // printing four digits of nonsense. const valid = ceiling > floor * 1.05; const separation = valid ? (observed - floor) / (ceiling - floor) : NaN; // Per block, the same three numbers — this is the diagnosis. const byBlock = {}; for (const block of [...STRUCTURAL, 'colour']) { const b = mean(between.map((d) => d.byBlock[block] ?? 0)); const c = mean(ceilingPairs.map((d) => d.byBlock[block] ?? 0)); byBlock[block] = { between: b, ceiling: c, ratio: c > 1e-6 ? b / c : 0, }; } // Nearest-neighbour collapse: for each seed, how close is its closest // sibling? A healthy generator has no seed that another seed shadows. A mean // can look acceptable while two of eight seeds are visually the same video. const nearest = sigs.map((_, i) => { let best = 1; for (let j = 0; j < sigs.length; j++) { if (i === j) continue; best = Math.min(best, signatureDistance(sigs[i], sigs[j]).total); } return best; }); return { seeds: seedList, floor, // Of that floor, how much is a video GOING somewhere rather than merely // changing. The floor alone cannot tell the two apart, and a video with // a story raises it on purpose. See variety/signature.js directionOf. direction: mean(sigs.map((s) => s.direction ?? 0)), directionFloor, ceiling, observed, separation, ceilingValid: valid, identity: ceiling > 1e-6 ? observed / ceiling : 0, byBlock, nearest, worstPair: worstPairOf(seedList, between), motion: mean(sigs.map((s) => s.motion)), }; } function worstPairOf(seedList, between) { let idx = 0, best = Infinity, k = 0; for (let i = 0; i < seedList.length; i++) { for (let j = i + 1; j < seedList.length; j++) { if (between[k].total < best) { best = between[k].total; idx = k; } k++; } } k = 0; for (let i = 0; i < seedList.length; i++) { for (let j = i + 1; j < seedList.length; j++) { if (k === idx) return { a: i, b: j, distance: best, byBlock: between[k].byBlock }; k++; } } return null; } // --- spec-level diversity ------------------------------------------------- // No GPU, milliseconds to run. This is the one to run first when the score // drops, because it says whether the generator ever MEANT to make two different // videos. function entropy(values) { const counts = new Map(); for (const v of values) counts.set(v, (counts.get(v) || 0) + 1); let h = 0; for (const c of counts.values()) { const p = c / values.length; h -= p * Math.log2(p); } const max = Math.log2(Math.max(2, counts.size)); return { unique: counts.size, entropy: h, normalized: max > 0 ? h / Math.log2(values.length) : 0 }; } export function measureSpecDiversity(track, { seeds = 32, seed0 = 0x5eed } = {}) { const looks = []; for (let i = 0; i < seeds; i++) { looks.push(generateLook(track, { seed: (seed0 + i * 2654435761) >>> 0 })); } // Overlay-only scenes excluded on both sides of the ratio. They were // counted in the numerator and not the denominator, which reported 102% // coverage once the casting pool started reaching them. const sceneSets = looks.map((l) => [...new Set( l.sections.flatMap((s) => s.variants.flatMap( (v) => v.filter((layer) => canBackground(layer.module)) .map((layer) => layer.module.name))), )].sort()); const usable = scenes.filter(canBackground); const covered = new Set(sceneSets.flat()); const uncast = usable.filter((m) => !covered.has(m.name)).map((m) => m.name); // Jaccard distance between the scene SETS of two seeds: the most direct // possible statement of "did the generator cast a different show". const jaccard = pairwise(sceneSets, (a, b) => { const A = new Set(a), B = new Set(b); let inter = 0; for (const x of A) if (B.has(x)) inter++; const union = A.size + B.size - inter; return union ? 1 - inter / union : 0; }); return { seeds, libraryCoverage: covered.size / usable.length, uncast, sceneSetDistance: mean(jaccard), identicalCasts: pairwise(sceneSets, (a, b) => (a.join('|') === b.join('|') ? 1 : 0)) .reduce((x, y) => x + y, 0), director: entropy(looks.map((l) => l.director)), paletteScheme: entropy(looks.map((l) => l.paletteScheme)), signature: entropy(looks.map((l) => l.personality.signature.join('+'))), grain: entropy(looks.map((l) => l.grain.mode)), framing: entropy(looks.map((l) => l.framing.mode)), paletteArc: entropy(looks.map((l) => l.paletteArc.mode)), anchorScenes: entropy(looks.map((l) => l.sections.map((s) => subjectOf(s.layers).module.name).join('>'))), }; } // --- the SONG variety test ------------------------------------------------- // // The seed test holds the song fixed and varies the seed, which answers "does // the generator's randomness do anything". This varies the SONG, which is the // question that actually matters: two different tracks should not obviously // come out of the same software. // // It needs one thing the seed test does not. Separation alone can be reached by // a generator that ignores the audio entirely and hashes the file — that would // score perfectly and be completely wrong, because the video would have nothing // to do with the music. So coupling is measured alongside it: songs that sound // alike should look alike, and songs that sound different should look different. // A high separation with zero coupling is not variety, it is noise. /** Distance between two tracks as MUSIC, on the statistics the generator reads. */ function musicalDistance(a, b) { const axes = [ [(t) => t.summary.bpm, 120], [(t) => t.summary.meanCentroid, 0.7], [(t) => t.summary.meanFlatness, 0.8], [(t) => t.summary.dynamicRange, 0.7], [(t) => t.sections.length, 6], ]; let d = 0; for (const [of_, span] of axes) d += Math.min(1, Math.abs(of_(a) - of_(b)) / span); return d / axes.length; } /** Spearman rank correlation — monotone association, robust to the scales. */ function spearman(xs, ys) { const rank = (values) => { const order = values.map((v, i) => [v, i]).sort((p, q) => p[0] - q[0]); const r = new Array(values.length); order.forEach(([, i], k) => { r[i] = k; }); return r; }; const rx = rank(xs), ry = rank(ys); const n = xs.length; let sum = 0; for (let i = 0; i < n; i++) sum += (rx[i] - ry[i]) ** 2; return 1 - (6 * sum) / (n * (n * n - 1) || 1); } /** * @param {object} options * @returns {object} report */ export function measureSongVariety({ songs = 6, probes = 5, refScenes = 4, pool = null, poolSize = null, seedSalt = 0, } = {}) { const bank = songBank({ count: songs }); // The seed is derived from the audio in the real pipeline, so each song must // get its own — deriving it from the name is the same relationship without // needing the samples. const sigs = bank.map((entry) => signatureForSeed(entry.track, (hashString(entry.name) + seedSalt) >>> 0, { probes, pool, poolSize })); const floor = mean(sigs.map((s) => s.drift)); const between = []; const musical = []; const visual = []; for (let i = 0; i < bank.length; i++) { for (let j = i + 1; j < bank.length; j++) { const d = signatureDistance(sigs[i], sigs[j]); between.push({ ...d, a: bank[i].name, b: bank[j].name }); musical.push(musicalDistance(bank[i].track, bank[j].track)); visual.push(d.total); } } const observed = mean(visual); // Ceiling on the same songs, so it is not a different measurement. const refSigs = ceilingSignatures(bank[0].track, { count: refScenes, probes }); const ceilingPairs = []; for (let i = 0; i < refSigs.length; i++) { for (let j = i + 1; j < refSigs.length; j++) { ceilingPairs.push(signatureDistance(refSigs[i], refSigs[j])); } } const ceiling = mean(ceilingPairs.map((d) => d.total)); const byBlock = {}; for (const block of [...STRUCTURAL, 'colour']) { const b = mean(between.map((d) => d.byBlock[block] ?? 0)); const c = mean(ceilingPairs.map((d) => d.byBlock[block] ?? 0)); byBlock[block] = { between: b, ceiling: c, ratio: c > 1e-6 ? b / c : 0 }; } const nearest = sigs.map((_, i) => { let best = 1, at = i; for (let j = 0; j < sigs.length; j++) { if (i === j) continue; const d = signatureDistance(sigs[i], sigs[j]).total; if (d < best) { best = d; at = j; } } return { song: bank[i].name, nearest: bank[at].name, distance: best }; }); return { bank: bank.map((b) => ({ name: b.name, covers: b.covers, kinds: b.track.sections.map((s) => s.kind) })), floor, ceiling, observed, separation: ceiling > floor * 1.05 ? (observed - floor) / (ceiling - floor) : NaN, ceilingValid: ceiling > floor * 1.05, identity: ceiling > 1e-6 ? observed / ceiling : 0, coupling: spearman(musical, visual), byBlock, nearest, pairs: between.map((d, k) => ({ ...d, musical: musical[k] })) .sort((x, y) => x.total - y.total), }; } /** * The house fingerprint: which descriptor dimensions never move. * * This is the direct measurement of "you can tell it came from the same * software". A dimension whose variance across real outputs is a small fraction * of its variance across randomly assembled ones is a constant the generator * imposes on every video it makes — and constants are exactly what a viewer * learns to recognise. Reported per block, in the order they most give the game * away. */ export function measureFingerprint({ songs = 6, probes = 4, refScenes = 5 } = {}) { const bank = songBank({ count: songs }); const ours = bank.map((entry) => signatureForSeed(entry.track, hashString(entry.name), { probes })); const refs = ceilingSignatures(bank[0].track, { count: refScenes, probes, seed: 0x1337 }); // One vector per VIDEO, not per probe. Pooling probes mixes in how much each // video varies over its own length, which is large for everything and washed // the answer out to a flat 100% — the measurement said nothing was frozen // while the separation score said almost everything was. const flatten = (sigs, block) => sigs.map((s) => { const rows = s.probes.map((p) => p[block]); const out = new Array(rows[0].length).fill(0); for (const r of rows) for (let i = 0; i < r.length; i++) out[i] += r[i] / rows.length; return out; }); const variance = (rows) => { if (!rows.length) return []; const n = rows[0].length; const out = new Array(n).fill(0); for (let d = 0; d < n; d++) { const col = rows.map((r) => r[d]); const m = col.reduce((a, b) => a + b, 0) / col.length; out[d] = col.reduce((a, b) => a + (b - m) ** 2, 0) / col.length; } return out; }; const tells = []; for (const block of [...STRUCTURAL, 'colour']) { const vo = variance(flatten(ours, block)); const vr = variance(flatten(refs, block)); const ratios = vo.map((v, d) => (vr[d] > 1e-12 ? v / vr[d] : 1)); const blockRatio = mean(ratios); tells.push({ block, ratio: blockRatio, frozen: ratios.filter((r) => r < 0.15).length, dims: ratios.length, }); } return { tells: tells.sort((a, b) => a.ratio - b.ratio) }; } // --- where does visual difference actually come from? ---------------------- // // The identity census settled one question and opened a better one. Across // twelve songs the identities are genuinely far apart — 0.43 mean distance, // no near-identical pairs, every fill, lattice, form and scale used — while the // videos separate by about half of what the reference reaches. So the // bottleneck is not the identity's range. Either the stages fail to turn // identity differences into different frames, or the instrument cannot see the // difference when they do. // // Those are opposite problems with opposite fixes, and one experiment separates // them: hold the container fixed and vary only the identity, then hold the // identity fixed and vary only the container. /** * @returns {{identityOnly:number, containerOnly:number, both:number, sameBoth:number}} */ export function measureDecomposition({ songs = 6, probes = 3, stageNames = null } = {}) { const bank = songBank({ count: songs }); const track = bank[0].track; // Every scene that actually draws the cast, not a hardcoded list — so this // number tracks the migration instead of being pinned to the four scenes // that happened to be written first. const stages = stageNames ? stageNames.map((n) => scenes.find((m) => m.name === n)).filter(Boolean) : scenes.filter((m) => (m.consumes || []).includes('cast') && canBackground(m)); // Each song's identity, lifted off its own look so it can be transplanted. const looks = bank.map((e) => generateLook(e.track, { seed: hashString(e.name) })); /** One stage, on one fixed track, wearing a given song's identity. */ const render = (stage, look, seed) => { const show = new Show({ ...RENDER }); try { const base = generateLook(track, { seed: seed >>> 0, pool: [stage] }); // Transplant the identity AND the signature form it reads from — // the protagonist's geometry lives in personality.shape. base.personality = { ...base.personality, identity: look.personality.identity, shape: look.personality.shape, }; show.useTrack(track, base); return videoSignature(show, { probes }); } finally { show.dispose(); } }; const dist = (list) => { const out = []; for (let i = 0; i < list.length; i++) { for (let j = i + 1; j < list.length; j++) out.push(signatureDistance(list[i], list[j]).total); } return mean(out); }; // A: one container, many identities. This is the whole point of Epic 3 — // if it is near zero, the inversion cannot work no matter how many // registers get added. const oneStage = stages[1] || stages[0]; const identityOnly = dist(looks.map((l) => render(oneStage, l, 4242))); // B: one identity, many containers. The old lever, measured on its own. const containerOnly = dist(stages.map((st) => render(st, looks[0], 4242))); // C: both vary, which is what the generator actually does. const both = dist(looks.map((l, i) => render(stages[i % stages.length], l, 4242 + i))); // D: nothing varies — the noise floor of the instrument itself. const sameBoth = dist([ render(oneStage, looks[0], 4242), render(oneStage, looks[0], 4242), ]); return { identityOnly, containerOnly, both, sameBoth, stage: oneStage.name, songs: bank.length, stageCount: stages.length, }; }