wip: instrument guards

This commit is contained in:
Dejvino 2026-08-17 20:10:15 +02:00
parent 2bbf5bcf42
commit a946f0e105
5 changed files with 264 additions and 83 deletions

View File

@ -33,9 +33,12 @@ export async function varietyReportLines({ seeds = 8, probes = 5, library = true
await new Promise((r) => setTimeout(r, 0));
const r = measureVariety(track, { seeds, refScenes: 5, probes });
const ok = r.separation >= 0.35;
const headline = `seed separation ${r.separation.toFixed(2)} ` +
`(${ok ? 'acceptable' : 'TOO LOW'}) · ${pct(r.identity)} of what the library can express`;
const ok = r.ceilingValid && r.separation >= 0.35;
const headline = r.ceilingValid
? `seed separation ${r.separation.toFixed(2)} (${ok ? 'acceptable' : 'TOO LOW'}) · ` +
`${pct(r.identity)} of what the library can express`
: `observed ${r.observed.toFixed(3)} vs floor ${r.floor.toFixed(3)} · ` +
'reference below the floor, separation not computable';
lines.push('SEED VARIETY — one track, one analysis, only the seed changes');
lines.push('');
@ -43,7 +46,11 @@ export async function varietyReportLines({ seeds = 8, probes = 5, library = true
lines.push(` observed ${r.observed.toFixed(4)} two seeds against each other`);
lines.push(` ceiling ${r.ceiling.toFixed(4)} same pipeline, casts that share no scenes at all`);
lines.push('');
lines.push(` separation ${bar(r.separation)} ${r.separation.toFixed(2)}`);
lines.push(r.ceilingValid
? ` separation ${bar(r.separation)} ${r.separation.toFixed(2)}`
: ' separation — not computable: the reference landed below the floor,\n' +
' which means real seeds already differ by more than videos\n' +
' built from casts that share no scenes.');
lines.push(' 0 = the seed changes nothing a viewer could name');
lines.push(' 1 = two seeds as unalike as two randomly assembled videos');
lines.push('');
@ -143,9 +150,10 @@ export async function songVarietyReportLines({ songs = 6, probes = 5 } = {}) {
const r = measureSongVariety({ songs, probes });
const f = measureFingerprint({ songs: Math.min(songs, 5), probes: 3 });
const ok = r.separation >= 0.45 && r.coupling >= 0.3;
const headline = `song separation ${r.separation.toFixed(2)} · coupling ${r.coupling.toFixed(2)} ` +
`(${ok ? 'acceptable' : 'TOO LOW'})`;
const ok = r.ceilingValid && r.separation >= 0.45 && r.coupling >= 0.3;
const headline = (r.ceilingValid ? `song separation ${r.separation.toFixed(2)}` :
`observed ${r.observed.toFixed(3)} vs floor ${r.floor.toFixed(3)} (no valid ceiling)`) +
` · coupling ${r.coupling.toFixed(2)} (${ok ? 'acceptable' : 'TOO LOW'})`;
lines.push('SONG VARIETY — different songs, each with its own audio-derived seed');
lines.push('');
@ -155,7 +163,9 @@ export async function songVarietyReportLines({ songs = 6, probes = 5 } = {}) {
lines.push(` observed ${r.observed.toFixed(4)} two songs against each other`);
lines.push(` ceiling ${r.ceiling.toFixed(4)} same pipeline, casts that share no scenes at all`);
lines.push('');
lines.push(` separation ${bar(r.separation)} ${r.separation.toFixed(2)}`);
lines.push(r.ceilingValid
? ` separation ${bar(r.separation)} ${r.separation.toFixed(2)}`
: ' separation — not computable: reference below the floor');
lines.push(` coupling ${bar(Math.max(0, r.coupling))} ${r.coupling.toFixed(2)}`);
lines.push(' coupling is how strongly musical distance predicts visual distance.');
lines.push(' near 0 means the look is unrelated to the song — separation without');

View File

@ -142,28 +142,23 @@ export function signatureForScene(track, module, seed, options = {}) {
export function ceilingSignatures(track, { count = 4, probes = 4, seed = 0xbadc0de } = {}) {
const rng = new Rng(seed >>> 0);
const pool = rng.shuffle(scenes.filter((m) => m.role !== 'accent'));
const slice = Math.max(2, Math.floor(pool.length / count));
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) break;
if (mine.length < 4) break;
const show = new Show({ ...RENDER });
try {
const look = generateLook(track, { seed: (seed + i * 40503) >>> 0 });
const prng = new Rng((seed + i * 2654435761) >>> 0);
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 = prng.pick(mine);
layer.params = sampleValues(layer.module, prng, section.bias, temperament);
layer.seed = prng.int(0, 0x7fffffff);
}
}
section.layers = section.variants[0];
}
show.useTrack(track, look);
// 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();
@ -305,8 +300,11 @@ export function measureVariety(track, {
const ceilingPairs = pairwise(refSigs, (a, b) => signatureDistance(a, b));
const ceiling = mean(ceilingPairs.map((d) => d.total));
const span = Math.max(1e-6, ceiling - floor);
const separation = (observed - floor) / span;
// 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 = {};
@ -338,6 +336,7 @@ export function measureVariety(track, {
ceiling,
observed,
separation,
ceilingValid: valid,
identity: ceiling > 1e-6 ? observed / ceiling : 0,
byBlock,
nearest,
@ -526,7 +525,8 @@ export function measureSongVariety({ songs = 6, probes = 5, refScenes = 4 } = {}
floor,
ceiling,
observed,
separation: (observed - floor) / Math.max(1e-6, ceiling - floor),
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,

View File

@ -220,10 +220,14 @@ export function signatureDistance(a, b) {
const other = byKey.get(probe.key);
if (other) pairs.push([probe, other]);
}
// Two songs can share no section kinds at all — a beatless ambient track
// against a club tool. Falling back to position is the only comparison left,
// and it is fair enough when neither has a structure to align to.
if (!pairs.length) {
// Too few shared labels to average over. A two-section ambient track shares
// only its outro with a five-section club track, and a distance computed
// from one probe pair is not comparable to one computed from five — which
// silently made the comparison depend on how alike the two ARRANGEMENTS
// were, the very thing being measured. Below three matches, fall back to
// position so every pair is averaged over the same number of probes.
if (pairs.length < 3) {
pairs.length = 0;
const n = Math.min(a.probes.length, b.probes.length);
for (let i = 0; i < n; i++) pairs.push([a.probes[i], b.probes[i]]);
}

View File

@ -9,7 +9,9 @@ import { AudioPalette, generateUsablePalette } from './palette.js';
import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js';
import { sampleValues, defaultValues } from '../params/schema.js';
import { planShots } from './shots.js';
import { generatePersonality, sceneHonours, describePersonality } from './Personality.js';
import {
generatePersonality, sceneHonours, signatureWeight, describePersonality,
} from './Personality.js';
import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
import { pickDirector, directorByName } from './directors.js';
import { derivePaletteArc, describePaletteArc } from './paletteArc.js';
@ -33,7 +35,7 @@ const KIND_ENERGY = {
* and `energy` up; a breakdown pulls them down. Seed variation still dominates,
* so two tracks with the same structure do not converge on the same look.
*/
function biasFor(section, summary) {
function biasFor(section, summary, motion = null) {
const kindEnergy = KIND_ENERGY[section.kind] ?? 0.5;
const measured = Math.min(1, section.energy / Math.max(1e-6, summary.meanLoudness * 1.6));
const energy = kindEnergy * 0.6 + measured * 0.4;
@ -45,14 +47,23 @@ function biasFor(section, summary) {
// biased to 0.9 motion and scenes that skittered over it.
const tempo = clamp01((summary.bpm - 60) / 120);
// The track's motion CHARACTER, on top of its tempo. Tempo alone compresses
// — 124 and 138bpm are the same number to a viewer — and motion was the
// weakest axis in every measurement because it was the only lever. Stillness
// is allowed to halve the animation rate or half again raise it, which is a
// difference anyone can see, and it is a property of the track rather than
// of the section. See look/Personality.js.
const still = motion ? motion.stillness : 0.5;
const churn = motion ? motion.churn : 0.25;
return {
energy,
density: Math.min(1, energy * 0.7 + section.flux * 1.2),
motion: clamp01(0.12 + tempo * 0.55 + energy * 0.28),
motion: clamp01(0.12 + tempo * 0.4 + energy * 0.2 + (1 - still) * 0.35),
// Applied on top of every `rate: true` param, so absolute animation
// speed scales with the song rather than only its sampled position in
// a range. Bounded well short of a stop or a blur. See params/schema.js.
rateScale: 0.45 + tempo * 0.95,
rateScale: (0.45 + tempo * 0.95) * (1.35 - still * 0.75) * (1 + churn * 0.25),
};
}
@ -66,26 +77,67 @@ const clamp01 = (x) => Math.max(0, Math.min(1, x));
* on is not a worse choice, it is the shot that was clearly filmed somewhere
* else. See look/Personality.js.
*/
function candidatesForKind(kind, used, signature = [], director) {
/**
* The scenes THIS TRACK is allowed to cast from a seeded subset of the
* library, not the whole thing.
*
* There is a real tension here, and the first attempt at fixing the signature
* gate walked straight into it. The hard trait filter was doing two jobs at
* once: it was collapsing the library onto eleven over-declared scenes, which
* was the bug, and it was also giving each track a DIFFERENT pool to cast from,
* which was load-bearing. Replacing it with a soft weight fixed the collapse and
* removed the differentiation every track then drew from the same weighted
* library, and measured song separation went from 0.03 to -0.15. Two songs came
* out more alike than before.
*
* So the differentiation is kept and its bias removed. Every track gets its own
* pool of about a third of the library, sampled without replacement, weighted by
* the signature so the track still has a point of view. What changes is that a
* scene declaring two traits is now merely less likely to be drawn than one
* declaring four, instead of being ineligible for six tracks in seven.
*/
function castingPool(rng, signature, size = 24) {
const pool = scenes.filter((m) => m.role !== 'accent');
const remaining = pool.slice();
const weights = remaining.map((m) => signatureWeight(m, signature));
const picked = [];
const target = Math.min(size, remaining.length);
while (picked.length < target && remaining.length) {
const chosen = rng.pickWeighted(remaining, weights);
const at = remaining.indexOf(chosen);
remaining.splice(at, 1);
weights.splice(at, 1);
picked.push(chosen);
}
return picked;
}
function candidatesForKind(kind, used, signature = [], director, pool = null) {
const families = director.families[kind] || Object.keys(FAMILIES);
const allowed = pool ? new Set(pool.map((m) => m.name)) : null;
const candidates = [];
for (const family of families) {
const inFamily = scenesInFamily(family)
.filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
.filter((m) => m.role !== 'accent' && (!allowed || allowed.has(m.name)));
// Weight by family preference order, and push down anything already
// used so a five-section track doesn't show one scene five times.
const weight = families.length - families.indexOf(family);
for (const scene of inFamily) {
candidates.push({ scene, weight: weight * (used.has(scene.name) ? 0.15 : 1) });
candidates.push({
scene,
// The signature is a lean now, not a wall. See
// Personality.signatureWeight for why it had to stop being one.
weight: weight * signatureWeight(scene, signature)
* (used.has(scene.name) ? 0.15 : 1),
});
}
}
if (!candidates.length) {
// Every family for this kind was emptied by the signature filter. Widen
// to the whole library, still honouring the signature; only if that is
// empty too does the personality lose and the video keep its scenes.
const anywhere = scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
const pool = anywhere.length ? anywhere : scenes.filter((m) => m.role !== 'accent');
return pool.map((scene) => ({ scene, weight: 1 }));
// This track's pool holds nothing in the families the director wants for
// this kind. Widen to the pool, then to the library — the track keeps
// its scenes either way.
const fallback = (pool && pool.length) ? pool : scenes.filter((m) => m.role !== 'accent');
return fallback.map((scene) => ({ scene, weight: signatureWeight(scene, signature) }));
}
return candidates;
}
@ -116,7 +168,7 @@ function rosterSizeFor(kind) {
* Variation between two sections of the same kind comes from their parameter
* sets, from where their shots fall, and from the arc driver's drift.
*/
function assignRostersByKind(sections, rng, signature = [], director) {
function assignRostersByKind(sections, rng, signature = [], director, pool = null) {
const byKind = new Map();
const used = new Set();
@ -131,7 +183,7 @@ function assignRostersByKind(sections, rng, signature = [], director) {
const size = rosterSizeFor(kind);
for (let slot = 0; slot < size; slot++) {
const pool = candidatesForKind(kind, used, signature, director)
const options = candidatesForKind(kind, used, signature, director, pool)
.filter((c) => !roster.includes(c.scene))
.map((c) => ({
scene: c.scene,
@ -140,9 +192,10 @@ function assignRostersByKind(sections, rng, signature = [], director) {
// whole visual language.
weight: c.weight * (roster.length && c.scene.family === roster[0].family ? 3 : 1),
}));
if (!pool.length) break;
if (!options.length) break;
const chosen = rng.pickWeighted(pool.map((c) => c.scene), pool.map((c) => c.weight));
const chosen = rng.pickWeighted(
options.map((c) => c.scene), options.map((c) => c.weight));
roster.push(chosen);
used.add(chosen.name);
}
@ -266,7 +319,9 @@ function buildStack(module, accentRoster, overlayRoster, bias, rng, temperament)
* @param {object} options
* @returns {object} LookSpec
*/
export function generateLook(track, { seed = null, samples = null, overrides = null } = {}) {
export function generateLook(track, {
seed = null, samples = null, overrides = null, pool: poolOverride = null,
} = {}) {
const resolvedSeed = seed !== null
? seed >>> 0
: samples ? hashSamples(samples) : 0x9e3779b9;
@ -280,14 +335,24 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
// The production design, decided before a single scene is cast — casting
// depends on it. See look/Personality.js.
const personality = generatePersonality(summary, rng.fork('personality'), (signature) =>
scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature)).length);
scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature)).length,
track.sections.length);
// The track's point of view about what a song looks like. Cast before any
// scene is, because it decides which scenes are even candidates.
const director = pickDirector(summary, rng.fork('director'));
// This track's cast, drawn before any section is assigned. See castingPool.
//
// `poolOverride` exists for the variety harness, which needs reference
// videos that share no scenes with each other but are otherwise built by
// exactly this code — a reference assembled by any other path stops being
// comparable to the thing it is bounding.
const pool = poolOverride && poolOverride.length
? poolOverride
: castingPool(rng.fork('pool'), personality.signature);
const rosterByKind = assignRostersByKind(
track.sections, rng.fork('scenes'), personality.signature, director);
track.sections, rng.fork('scenes'), personality.signature, director, pool);
// The grain treatment: usually none, and when present described rather than
// dialled. See look/grain.js.
const grain = deriveGrain(summary, rng.fork('grain'));
@ -303,19 +368,17 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
// Accents honour the signature too where they can. If none can, the track
// goes without depth layers rather than putting an off-design element into
// every stack.
const accentRoster = scenes.filter((m) => m.role === 'accent'
&& sceneHonours(m, personality.signature));
const accentRoster = scenes.filter((m) => m.role === 'accent');
// Scenes eligible to be composited OVER a background. Same casting rule as
// everything else — an overlay is on screen as much as the shot under it,
// so an off-design one would be just as visible.
const overlayRoster = scenes.filter((m) => m.role !== 'accent'
&& sceneHonours(m, personality.signature));
const overlayRoster = pool.length >= 4 ? pool : scenes.filter((m) => m.role !== 'accent');
const sections = track.sections.map((section) => {
const roster = rosterByKind.get(section.kind) || [scenes[0]];
const sectionRng = rng.fork(`section:${section.index}:${roster[0].name}`);
const bias = biasFor(section, summary);
const bias = biasFor(section, summary, personality.motion);
const variants = roster.map((module, v) => buildStack(
module, accentRoster, overlayRoster, bias,
@ -370,25 +433,25 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
const rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0);
const signature = (look.personality && look.personality.signature) || [];
const families = directorByName(look.director).families[section.kind] || Object.keys(FAMILIES);
// A reroll re-draws this section's cast from the same kind of pool the track
// was built with, weighted by the signature rather than filtered by it.
let candidates = families.flatMap((f) => scenesInFamily(f))
.filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
if (!candidates.length) {
candidates = scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
}
.filter((m) => m.role !== 'accent');
if (!candidates.length) candidates = scenes.filter((m) => m.role !== 'accent');
// Re-roll the whole roster, not just the anchor: the section's shots cut
// between all of them, so replacing one would leave the section half old.
const size = Math.min(rosterSizeFor(section.kind), Math.max(1, candidates.length));
const roster = [];
while (roster.length < size) {
const pool = candidates.filter((m) => !roster.includes(m));
if (!pool.length) break;
roster.push(rng.pick(pool));
const options = candidates.filter((m) => !roster.includes(m));
if (!options.length) break;
roster.push(rng.pickWeighted(options, options.map((m) => signatureWeight(m, signature))));
}
if (!roster.length) roster.push(scenes[0]);
const accentRoster = scenes.filter((m) => m.role === 'accent');
const overlayRoster = scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
const overlayRoster = castingPool(rng.fork('pool'), signature);
section.variants = roster.map((module, v) => buildStack(
module, accentRoster, overlayRoster, section.bias, rng.fork(`variant:${v}`),
look.personality && look.personality.temperament,

View File

@ -40,6 +40,8 @@
export const TRAITS = ['shape', 'camera', 'space', 'style'];
const clamp01 = (x) => Math.max(0, Math.min(1, x));
/**
* Traits eligible to be a track's signature, and how often.
*
@ -51,6 +53,38 @@ export const TRAITS = ['shape', 'camera', 'space', 'style'];
*/
const SIGNATURE_WEIGHTS = { shape: 4, space: 3, camera: 2, style: 2 };
/**
* How the audio tilts the choice of signature.
*
* The signature decides which scenes a track can even cast, so if it is picked
* from the seed alone then the single most consequential decision in the whole
* generator has no relationship to the music. Measured, that is exactly what
* happened: across seven songs the correlation between how different two tracks
* SOUND and how different their videos LOOK was -0.03. The videos differed; the
* differences just had nothing to do with the songs.
*
* A tilt, not a rule. Every trait stays reachable for every track a mapping
* rigid enough to predict is the failure this layer exists to avoid but a
* track built on transients leans toward shape, a spacious one toward space, a
* fast one toward camera and a noisy one toward style.
*/
function signatureTilt(summary, sections) {
const bright = summary.meanCentroid ?? 0.5;
const noisy = Math.min(1, (summary.meanFlatness ?? 0.2) * 3);
const fast = clamp01(((summary.bpm ?? 120) - 80) / 80);
const dynamic = clamp01(summary.dynamicRange ?? 0.5);
// A track that keeps changing what it is doing has structure to draw on;
// one that states an idea and holds it has a place instead.
const busy = clamp01((sections - 2) / 5);
return {
shape: 0.5 + busy * 1.2 + (1 - noisy) * 0.5,
space: 0.5 + dynamic * 1.1 + (1 - busy) * 0.6,
camera: 0.5 + fast * 1.2,
style: 0.5 + noisy * 1.3 + bright * 0.4,
};
}
/** Minimum scenes that must survive the signature filter for it to be usable. */
export const MIN_ELIGIBLE_SCENES = 6;
@ -68,18 +102,34 @@ export const MIN_ELIGIBLE_SCENES = 6;
* How many scenes would survive a given signature. Injected rather than
* imported so this module never has to know the registry exists.
*/
export function generatePersonality(summary, rng, countEligible = null) {
export function generatePersonality(summary, rng, countEligible = null, sections = 4) {
const bright = summary.meanCentroid;
const noisy = Math.min(1, summary.meanFlatness * 3);
const fast = Math.min(1, Math.max(0, (summary.bpm - 80) / 80));
const loud = Math.min(1, (summary.dynamicRange ?? 0.5) + (summary.meanLoudness ?? 0.3));
const dynamic = clamp01(summary.dynamicRange ?? 0.5);
// Audio sets the CENTRE of each distribution and the seed picks within it.
//
// The alternative — deriving values from the audio outright — buys coupling
// by destroying seed variety, since two seeds on one song would then agree
// about everything. Centring keeps both: two songs land in different regions
// of the space, two seeds land in different places inside one region. The
// window is deliberately wide enough that the mapping cannot be read off a
// finished video.
const around = (centre, window, lo, hi) =>
Math.max(lo, Math.min(hi, centre + rng.range(-window, window)));
const shape = {
// 0 sides means round. Everything else is a polygon the whole track
// shares — the single most recognisable thing here.
sides: rng.pickWeighted([0, 3, 4, 5, 6, 8], [3, 2, 3, 2, 3, 1]),
roundness: rng.range(0.05, 0.5),
elongation: rng.range(0.85, 1.45),
// shares — the single most recognisable thing here. Noise and transients
// earn corners; tonal, smooth material stays round.
sides: rng.pickWeighted(
[0, 3, 4, 5, 6, 8],
[3 + (1 - noisy) * 4, 1 + noisy * 2, 2 + noisy * 2,
1 + noisy * 1.5, 2 + noisy * 2, 0.5 + noisy * 1.5]),
roundness: around(0.28 - noisy * 0.15, 0.18, 0.05, 0.5),
elongation: around(1.15, 0.3, 0.85, 1.45),
tilt: rng.range(0, Math.PI),
};
@ -87,19 +137,21 @@ export function generatePersonality(summary, rng, countEligible = null) {
driftAngle: rng.range(0, Math.PI * 2),
// A slow track should not be filmed from a moving car.
driftRate: rng.range(0.01, 0.06) * (0.6 + fast * 0.8),
sway: rng.range(0.0, 0.06),
swayRate: rng.range(0.05, 0.22),
spin: rng.range(-0.05, 0.05),
sway: around(0.01 + fast * 0.035, 0.02, 0, 0.06),
swayRate: around(0.08 + fast * 0.1, 0.06, 0.05, 0.22),
spin: around((rng.bool() ? 1 : -1) * fast * 0.03, 0.025, -0.05, 0.05),
// Breathing is locked to the bar, so it is the one camera move that
// reads as musical rather than as drifting.
breathe: rng.range(0.0, 0.05),
// reads as musical rather than as drifting. A dynamic track breathes.
breathe: around(dynamic * 0.03, 0.02, 0, 0.05),
};
const space = {
horizon: rng.range(0.32, 0.62),
depth: rng.range(0.2, 0.9),
// A bright track sits high in its frame and a dark one sits low; a
// dynamic one has depth to fall away into.
horizon: around(0.36 + bright * 0.2, 0.1, 0.32, 0.62),
depth: around(0.25 + dynamic * 0.5, 0.25, 0.2, 0.9),
washAngle: rng.range(0, Math.PI * 2),
wash: rng.range(0.1, 0.5),
wash: around(0.18 + (1 - bright) * 0.2, 0.15, 0.1, 0.5),
};
const style = {
@ -115,7 +167,24 @@ export function generatePersonality(summary, rng, countEligible = null) {
// Fold counts stay low and are usually off. Symmetry is the fastest way
// to make a library look like one series and also the fastest way to
// make every track look like a screensaver.
symmetry: rng.pickWeighted([1, 1, 2, 3, 4, 6], [6, 4, 2, 2, 2, 1]),
symmetry: rng.pickWeighted([1, 1, 2, 3, 4, 6],
[6, 4, 1 + (1 - noisy) * 2, 1 + (1 - noisy) * 2, 1 + (1 - noisy) * 2, 0.5 + (1 - noisy)]),
};
// How this track MOVES, as a character rather than a rate.
//
// Motion was the weakest block in every measurement — 47% of achievable
// across seeds — because the only lever on it was a tempo-derived rate
// multiplier, and tempo compresses. Two tracks at 124 and 138bpm got
// essentially the same movement. Stillness is the missing axis: some music
// wants an image that hangs almost motionless and some wants one that never
// settles, and that is not the same question as how fast it animates.
const motion = {
// 0 = hangs, 1 = never settles. Dynamic, spacious material earns the
// stillness; dense, fast material does not get it.
stillness: clamp01(around(0.5 + dynamic * 0.35 - fast * 0.45, 0.3, 0, 1)),
// Whether the movement is steady or agitated, independent of its speed.
churn: clamp01(around(0.25 + noisy * 0.5, 0.3, 0, 1)),
};
// How hard this track pushes every scene it casts. Deliberately wide, and
@ -137,9 +206,9 @@ export function generatePersonality(summary, rng, countEligible = null) {
extremity: rng.range(0.45, 1.0),
};
const signature = pickSignature(rng, countEligible);
const signature = pickSignature(rng, countEligible, signatureTilt(summary, sections));
return { signature, shape, camera, space, style, temperament };
return { signature, shape, camera, space, style, motion, temperament };
}
/**
@ -150,10 +219,11 @@ export function generatePersonality(summary, rng, countEligible = null) {
* rosters from, fall back to the stronger of the two rather than shipping a
* track whose every section is forced onto the same two scenes.
*/
function pickSignature(rng, countEligible) {
const primary = rng.pickWeighted(TRAITS, TRAITS.map((t) => SIGNATURE_WEIGHTS[t]));
function pickSignature(rng, countEligible, tilt = null) {
const weightOf = (t) => SIGNATURE_WEIGHTS[t] * (tilt ? tilt[t] : 1);
const primary = rng.pickWeighted(TRAITS, TRAITS.map(weightOf));
const rest = TRAITS.filter((t) => t !== primary);
const secondary = rng.pickWeighted(rest, rest.map((t) => SIGNATURE_WEIGHTS[t]));
const secondary = rng.pickWeighted(rest, rest.map(weightOf));
const pair = [primary, secondary];
if (!countEligible || countEligible(pair) >= MIN_ELIGIBLE_SCENES) return pair;
@ -169,6 +239,35 @@ export function sceneHonours(module, signature) {
return signature.every((t) => traits.includes(t));
}
/**
* How well a scene fits the signature, 0..1 and the casting WEIGHT that
* follows from it.
*
* `sceneHonours` was a hard filter, and as a filter it was the single largest
* cause of sameness in the generator. Measured across the library: a scene
* declaring all four traits is eligible for every track and opens half of all
* videos, while a scene declaring two is eligible for one track in fourteen.
* Eleven scenes out of sixty-one carried nearly every video, five of them from
* the same family, and that was the house style not a decision anyone made,
* just an artefact of which scenes happened to declare four traits.
*
* A lean rather than a wall. Honouring the whole signature is worth six times
* the weight of honouring none of it, which is more than enough for the track to
* read as one production, while leaving the rest of the library reachable
* instead of disqualified.
*/
export function signatureAffinity(module, signature) {
if (!signature || !signature.length) return 1;
const traits = module.traits || [];
let hit = 0;
for (const t of signature) if (traits.includes(t)) hit++;
return hit / signature.length;
}
export function signatureWeight(module, signature) {
return 1 + signatureAffinity(module, signature) * 5;
}
/**
* Flatten to the uniform values the shader contract expects.
*
@ -245,6 +344,11 @@ export function describePersonality(personality) {
SHAPE_NAMES[shape.sides] || `${shape.sides}-sided`,
];
if (style.symmetry > 1) parts.push(`${style.symmetry}-fold`);
if (personality.motion) {
const m = personality.motion;
parts.push(`${m.stillness > 0.6 ? 'still' : m.stillness < 0.3 ? 'restless' : 'moving'}` +
`${m.churn > 0.6 ? '+churn' : ''}`);
}
if (personality.temperament) {
const t = personality.temperament;
parts.push(`${t.intensity >= 0 ? 'hot' : 'cool'} ${t.extremity.toFixed(2)} bold`);