diff --git a/flow-state/src/checks/variety/print.js b/flow-state/src/checks/variety/print.js index cbc0735..4c66a0d 100644 --- a/flow-state/src/checks/variety/print.js +++ b/flow-state/src/checks/variety/print.js @@ -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'); diff --git a/flow-state/src/checks/variety/report.js b/flow-state/src/checks/variety/report.js index 26c6707..3de01af 100644 --- a/flow-state/src/checks/variety/report.js +++ b/flow-state/src/checks/variety/report.js @@ -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, diff --git a/flow-state/src/checks/variety/signature.js b/flow-state/src/checks/variety/signature.js index a2c3fd8..d365db0 100644 --- a/flow-state/src/checks/variety/signature.js +++ b/flow-state/src/checks/variety/signature.js @@ -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]]); } diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js index 74e5e87..7e02072 100644 --- a/flow-state/src/look/LookGenerator.js +++ b/flow-state/src/look/LookGenerator.js @@ -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, diff --git a/flow-state/src/look/Personality.js b/flow-state/src/look/Personality.js index 4fd8a78..d2641bc 100644 --- a/flow-state/src/look/Personality.js +++ b/flow-state/src/look/Personality.js @@ -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`);