A section used to be one scene, and two thirds of the library is composable —
sparse by design, elements ON something. Cast as backgrounds anyway, they left
9 of 40 sampled frames under 20% painted, the darkest at 0.3%: a minute and a
half of a few bright things on black, invisible to every gate because every
gate on the stack was a limit rather than a floor.
Every section now stands on a GROUND: a canvas that fills the frame, cast per
section kind so a shot cut changes the shot and not the world. When the shot
fills the frame itself it IS the ground — two canvases stacked is two pictures
fighting. Above that, a coverage BUDGET: director appetite times the section's
energy times where the story is, capped at two frames' worth of material.
The measured facts move into the repo. scenes/metadata.json is generated from
the gallery — coverage as a shot, coverage as a bed, variety, the structural
profile — tracked in git, stamped with a fingerprint of the scenes and the
metric definitions, and refreshed from gallery.html. `surface` is derived from
it rather than declared; nine scenes claimed `canvas` while painting under a
third of the frame, and declaring it is now a lint error. The generator weights
every layering choice by measured structural distance, because family labels
and the render disagree: two `geometric` scenes can be 0.31 apart and a `flow`
and an `organic` scene 0.04.
The gallery's 0.1 red line is gone. It was right when a section was one scene
and wrong now — nineteen scenes were failing a bar for being consistent, which
is a virtue in an ingredient.
Chasing the numbers turned up four real faults:
* A scene that reads prev() cannot be a ground. It returns the whole
composited frame including the layers above it, so a datamosh under a shot
is eating it: the render stopped reproducing from a seek and two WebGL
contexts diverged by 91/255 against a tolerance of 4.
* Screen was the wrong operator for a shot over a bed. It lightens, so a
median quarter of every frame clipped to paper and whole sections rendered
100% white. Replaced by a lumakey — the shot's brightness is its alpha.
* Feedback was an accumulator: a still image settled at 2.3x its own
brightness. Fine over black, fatal over a filled ground. Normalised at 0.6,
plus a highlight shoulder so the top rolls off instead of clipping.
* useTrack never prewarmed, so a fresh Show's first frame differed from every
later render of it — the export-breaking hazard Compositor.prime documents.
Blazing is a decision now, not a side effect: directors declare an appetite for
it, a section must be loud and late in the story to earn one, and quiet kinds
never do. The ceiling gate matches that — a hard cap per section, and no more
than a fifth of them hot at all.
Rendered across twelve videos, middle of every section:
painted 51% mean, darkest 0.3% -> 87% mean, darkest 43%
clipped white 24% median, worst 100% -> 1% mean, worst 30%
separation 0.10 -> 0.44
Seven scenes can ground a section — five geometric, two organic — so every
quiet section of every video stands on one of two beds. That is the library's
largest hole and it is scene work: there is no minimal or flow canvas that
fills half the frame without reading prev().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
703 lines
29 KiB
JavaScript
703 lines
29 KiB
JavaScript
// 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,
|
|
};
|
|
}
|