diff --git a/flow-state/src/checks/phase11.js b/flow-state/src/checks/phase11.js index e6ed609..a8a8fda 100644 --- a/flow-state/src/checks/phase11.js +++ b/flow-state/src/checks/phase11.js @@ -14,6 +14,8 @@ import { generateLook } from '../look/LookGenerator.js'; import { FeatureTrack } from '../audio/FeatureTrack.js'; import { synthesizeSectioned } from '../audio/synth.js'; import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS } from '../look/shots.js'; +import { DIRECTORS, RESTFUL_FAMILIES, pickDirector } from '../look/directors.js'; +import { scenes, scenesInFamily } from '../scenes/registry.js'; /** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */ let cached = null; @@ -157,3 +159,140 @@ check(11, 'cuts land on the beat grid', () => { `${onGrid}/${total} cuts within three quarters of a beat of a downbeat ` + `(${(ratio * 100).toFixed(0)}%, floor 80%)`); }); + +// --- the director ---------------------------------------------------------- +// EPIC-2.md §3.2. The kind-to-family mapping used to be a module constant, so +// every video made the same genre decisions before a single seeded draw +// happened. It is now a per-track choice — see look/directors.js. + +check(11, 'every scene in the library is reachable', () => { + // The number that made this worth doing: across twelve tracks, twenty-nine + // of forty-two scenes were cast in none of them. A scene no director can + // reach is dead weight, and the fault is in the mapping rather than in the + // scene, so this is asked of the mapping directly. + const reachable = new Set(); + for (const d of DIRECTORS) { + for (const families of Object.values(d.families)) { + for (const f of families) { + for (const m of scenesInFamily(f)) reachable.add(m.name); + } + } + } + // Accents are cast by role rather than by family and are always eligible. + const missing = scenes.filter((m) => m.role !== 'accent' && !reachable.has(m.name)); + + return expect(missing.length === 0, + missing.length ? `unreachable: ${missing.map((m) => m.name).join(', ')}` + : `all ${reachable.size} non-accent scenes reachable across ${DIRECTORS.length} directors`); +}); + +check(11, 'no director starves a section kind', () => { + // A director is a point of view, not a corner. Every kind it defines has to + // leave enough scenes to build a roster from and still have room for the + // signature filter to remove some. + const problems = []; + for (const d of DIRECTORS) { + for (const [kind, families] of Object.entries(d.families)) { + const pool = new Set(); + for (const f of families) { + for (const m of scenesInFamily(f)) if (m.role !== 'accent') pool.add(m.name); + } + if (pool.size < 12) problems.push(`${d.name}/${kind}: only ${pool.size}`); + } + const kinds = Object.keys(d.families); + for (const k of ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']) { + if (!kinds.includes(k)) problems.push(`${d.name}: no mapping for '${k}'`); + } + } + return expect(problems.length === 0, + problems.length ? problems.join(' · ') + : `${DIRECTORS.length} directors, every kind backed by 12+ scenes`); +}); + +check(11, 'no director puts a loud family in a quiet section', () => { + // Phase 7 has enforced this since the minimal family existed, and the first + // draft of directors.js broke it — `corrupt` opened on glitch, `geometer` + // on geometric. An intro that opens strobing and a breakdown that answers a + // lull with a dense pattern are the two specific mistakes the family + // coupling exists to prevent, and a viewer meets them in the first fifteen + // seconds. Asserted against the mappings directly so a new director cannot + // reintroduce it without tripping this rather than a downstream render gate. + const problems = []; + for (const d of DIRECTORS) { + for (const kind of ['intro', 'breakdown', 'outro']) { + for (const f of d.families[kind] || []) { + if (!RESTFUL_FAMILIES.includes(f)) problems.push(`${d.name}/${kind}: ${f}`); + } + } + } + return expect(problems.length === 0, + problems.length ? problems.join(' · ') + : `${DIRECTORS.length} directors keep intro/breakdown/outro on ${RESTFUL_FAMILIES.join('/')}`); +}); + +check(11, 'every director is castable', () => { + // Weighting tilts the odds by timbre; none of it may drive a weight to zero, + // or a director exists in the file and never in a video. + const probes = [ + { meanFlatness: 0.02, meanCentroid: 0.15 }, // tonal and dark + { meanFlatness: 0.5, meanCentroid: 0.9 }, // noisy and bright + { meanFlatness: 0.2, meanCentroid: 0.5 }, + ]; + const rng = { pickWeighted: (arr, w) => w }; // capture the weights + const problems = []; + for (const p of probes) { + const weights = pickDirector(p, rng); + weights.forEach((w, i) => { + if (!(w > 0)) problems.push(`${DIRECTORS[i].name} unreachable at flatness ${p.meanFlatness}`); + }); + } + return expect(problems.length === 0, + problems.length ? problems.join(' · ') + : `all ${DIRECTORS.length} directors carry weight on every timbre probed`); +}); + +check(11, 'two tracks do not agree on what a section kind looks like', () => { + // The population question. Across a battery, the same section kind must be + // answered by more than one family, or the director layer is decorative. + const seen = new Map(); // kind -> Set of family names actually cast + const directors = new Set(); + + for (const { track: t } of tempoBattery()) { + for (let s = 0; s < 20; s++) { + const look = generateLook(t, { seed: 6600 + s * 7919 }); + directors.add(look.director); + look.sections.forEach((section) => { + const set = seen.get(section.kind) || new Set(); + (section.variants || [section.layers]).forEach((v) => set.add(v[0].module.family)); + seen.set(section.kind, set); + }); + } + } + + const thin = [...seen.entries()].filter(([, set]) => set.size < 3) + .map(([kind, set]) => `${kind}: ${[...set].join('/')}`); + + // Three of five in a population this size is a real spread; demanding all + // five would be demanding a particular draw rather than a working mechanism, + // and reachability is asserted directly by the check above. + return expect(directors.size >= 3 && thin.length === 0, + `${directors.size}/${DIRECTORS.length} directors cast · ` + + (thin.length ? `thin kinds — ${thin.join(' · ')}` + : [...seen.entries()].map(([k, v]) => `${k}:${v.size}fam`).join(' '))); +}); + +check(11, 'a track shows more of the library than it used to', () => { + // Twelve tracks used to reach thirteen distinct scenes between them. This + // asks the same question of the same size population. + const cast = new Set(); + tempoBattery().forEach(({ track: t }, i) => { + for (let s = 0; s < 4; s++) { + const look = generateLook(t, { seed: (i * 2654435761 + s * 40503) >>> 0 }); + look.sections.forEach((section) => + (section.variants || [section.layers]).forEach((v) => cast.add(v[0].module.name))); + } + }); + const pool = scenes.filter((m) => m.role !== 'accent').length; + return expect(cast.size >= pool * 0.4, + `${cast.size}/${pool} scenes cast across 12 tracks (floor ${Math.ceil(pool * 0.4)})`); +}); diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js index e10417e..6d51f24 100644 --- a/flow-state/src/look/LookGenerator.js +++ b/flow-state/src/look/LookGenerator.js @@ -11,21 +11,13 @@ import { sampleValues, defaultValues } from '../params/schema.js'; import { planShots } from './shots.js'; import { generatePersonality, sceneHonours, describePersonality } from './Personality.js'; import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js'; +import { pickDirector, directorByName } from './directors.js'; -/** - * Which families suit which section kind, in preference order. - * - * This is the coupling that stops a breakdown landing on a strobing glitch scene - * and an intro opening at full density. It is also why families exist at all. - */ -const FAMILY_BY_KIND = { - intro: ['minimal', 'flow', 'organic'], - build: ['structural', 'geometric', 'flow'], - drop: ['geometric', 'glitch', 'structural'], - sustain: ['organic', 'flow', 'geometric'], - breakdown: ['minimal', 'organic', 'flow'], - outro: ['minimal', 'flow', 'organic'], -}; +// Which families suit which section kind now comes from the track's DIRECTOR +// (look/directors.js) rather than from a constant here. The coupling it +// provides is the same — it is what stops a breakdown landing on a strobing +// glitch scene and an intro opening at full density — but which coupling a +// given track gets is a decision, not a fact about the program. const KIND_ENERGY = { intro: 0.25, build: 0.55, drop: 0.95, sustain: 0.6, breakdown: 0.25, outro: 0.2, @@ -72,8 +64,8 @@ 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 = []) { - const families = FAMILY_BY_KIND[kind] || Object.keys(FAMILIES); +function candidatesForKind(kind, used, signature = [], director) { + const families = director.families[kind] || Object.keys(FAMILIES); const candidates = []; for (const family of families) { const inFamily = scenesInFamily(family) @@ -122,7 +114,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 = []) { +function assignRostersByKind(sections, rng, signature = [], director) { const byKind = new Map(); const used = new Set(); @@ -137,7 +129,7 @@ function assignRostersByKind(sections, rng, signature = []) { const size = rosterSizeFor(kind); for (let slot = 0; slot < size; slot++) { - const pool = candidatesForKind(kind, used, signature) + const pool = candidatesForKind(kind, used, signature, director) .filter((c) => !roster.includes(c.scene)) .map((c) => ({ scene: c.scene, @@ -288,8 +280,12 @@ export function generateLook(track, { seed = null, samples = null, overrides = n const personality = generatePersonality(summary, rng.fork('personality'), (signature) => scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature)).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')); + const rosterByKind = assignRostersByKind( - track.sections, rng.fork('scenes'), personality.signature); + track.sections, rng.fork('scenes'), personality.signature, director); // The grain treatment: usually none, and when present described rather than // dialled. See look/grain.js. const grain = deriveGrain(summary, rng.fork('grain')); @@ -347,6 +343,7 @@ export function generateLook(track, { seed = null, samples = null, overrides = n palette, personality, paletteScheme: paletteSource.lastScheme, + director: director.name, grain, post, feedback, @@ -364,7 +361,7 @@ 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 = FAMILY_BY_KIND[section.kind] || Object.keys(FAMILIES); + const families = directorByName(look.director).families[section.kind] || Object.keys(FAMILIES); let candidates = families.flatMap((f) => scenesInFamily(f)) .filter((m) => m.role !== 'accent' && sceneHonours(m, signature)); if (!candidates.length) { @@ -424,7 +421,7 @@ function applyOverrides(look, overrides) { /** Compact description, used by the HUD and by check output. */ export function describeLook(look) { const kinds = look.sections.map((s) => `${s.kind}:${s.layers[0].module.name}`); - return `seed ${look.seed.toString(16)} · ${look.paletteScheme} · ` + + return `seed ${look.seed.toString(16)} · ${look.director} · ${look.paletteScheme} · ` + `${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` + `${[...new Set(kinds)].join(', ')}`; } diff --git a/flow-state/src/look/directors.js b/flow-state/src/look/directors.js new file mode 100644 index 0000000..8b94107 --- /dev/null +++ b/flow-state/src/look/directors.js @@ -0,0 +1,148 @@ +// Which families answer which section kind — as a per-track CHOICE rather than +// as a constant. +// +// This was a module-level table in LookGenerator, identical for every track ever +// generated: an intro was always minimal/flow/organic, a drop always +// geometric/glitch/structural, and intro and outro were literally the same list. +// Every video therefore made the same genre decisions before a single seeded +// draw happened, which is a large source of cross-track sameness hiding inside +// something that looks like configuration. +// +// Measured, the cost was concrete: across twelve tracks, twenty-nine of +// forty-two scenes were cast in none of them. The library was not too small — +// most of it was unreachable. +// +// A director is one coherent point of view about what a song looks like. Not a +// shuffle of families: each mapping is internally consistent, and the ordering +// within a kind matters because the first family is weighted three times the +// third. Two tracks with different directors disagree about what a drop IS, +// which is the level at which videos should differ. + +/** + * Families a QUIET section is allowed to draw from, whatever the director + * thinks. Phase 7 has enforced this since minimal existed, and the first draft + * of this file broke it — `corrupt` opened on glitch and `geometer` opened on + * geometric, on the theory that a point of view should apply everywhere. + * + * It should not. An intro that opens on a strobing scene and a breakdown that + * answers a lull with a dense pattern are not bold, they are the two specific + * mistakes the family coupling was introduced to prevent, and a viewer meets + * them within fifteen seconds of pressing play. So intro, breakdown and outro + * are off limits to the loud families for every director. + * + * The identity lives in build, drop and sustain — half the kinds, and the half + * anyone remembers — plus which of the restful families a director leads with + * when it is being quiet. + */ +export const RESTFUL_FAMILIES = ['minimal', 'flow', 'organic']; +const QUIET_KINDS = ['intro', 'breakdown', 'outro']; + +/** + * Each director maps every section kind to three families, most-preferred + * first. Every kind must be present — a missing one silently falls back to the + * whole library and the point of view is lost exactly where it matters most. + */ +export const DIRECTORS = [ + { + name: 'ambient', + // The original table. A drop resolves into geometry; everything quiet is + // minimal. Still the most broadly applicable, so it keeps the most weight. + weight: 3, + families: { + intro: ['minimal', 'flow', 'organic'], + build: ['structural', 'geometric', 'flow'], + drop: ['geometric', 'glitch', 'structural'], + sustain: ['organic', 'flow', 'geometric'], + breakdown: ['minimal', 'organic', 'flow'], + outro: ['minimal', 'flow', 'organic'], + }, + }, + { + name: 'brutalist', + // Everything is architecture. Quiet means empty rather than soft, so it + // leads on minimal and reaches for organic last. + weight: 2, + families: { + intro: ['minimal', 'organic', 'flow'], + build: ['structural', 'geometric', 'glitch'], + drop: ['structural', 'glitch', 'geometric'], + sustain: ['structural', 'geometric', 'flow'], + breakdown: ['minimal', 'flow', 'organic'], + outro: ['minimal', 'organic', 'flow'], + }, + }, + { + name: 'organicist', + // Nothing is ever built; things grow and dissolve. Deliberately never + // reaches for glitch — a point of view is defined by what it refuses. + weight: 2, + families: { + intro: ['organic', 'flow', 'minimal'], + build: ['organic', 'flow', 'structural'], + drop: ['organic', 'geometric', 'flow'], + sustain: ['organic', 'flow', 'minimal'], + breakdown: ['organic', 'minimal', 'flow'], + outro: ['flow', 'organic', 'minimal'], + }, + }, + { + name: 'corrupt', + // The signal is damaged and the damage is the subject — everywhere the + // damage is allowed to be. Its quiet sections lead on flow, so the calm + // reads as signal drifting rather than as rest. + weight: 2, + families: { + intro: ['flow', 'minimal', 'organic'], + build: ['glitch', 'structural', 'geometric'], + drop: ['glitch', 'geometric', 'structural'], + sustain: ['glitch', 'organic', 'flow'], + breakdown: ['minimal', 'flow', 'organic'], + outro: ['flow', 'minimal', 'organic'], + }, + }, + { + name: 'geometer', + // Pattern first, everywhere, at every energy. The drop is not an + // explosion, it is the pattern at its densest. + weight: 2, + families: { + intro: ['minimal', 'flow', 'organic'], + build: ['geometric', 'structural', 'flow'], + drop: ['geometric', 'glitch', 'structural'], + sustain: ['geometric', 'organic', 'flow'], + breakdown: ['minimal', 'organic', 'flow'], + outro: ['minimal', 'flow', 'organic'], + }, + }, +]; + +export const DIRECTOR_NAMES = DIRECTORS.map((d) => d.name); + +/** + * Cast the director for a track. + * + * The audio tilts the odds and never decides: a noisy, loud track is more + * likely to be filmed as corrupt or brutalist and a tonal one as organicist, + * but every director stays reachable for every track. Predictable mapping from + * measured features to look is the failure mode this whole layer exists to + * avoid — it is how a library ends up with one house style per genre. + */ +export function pickDirector(summary, rng) { + const noisy = Math.min(1, (summary.meanFlatness ?? 0.2) * 3); + const bright = summary.meanCentroid ?? 0.5; + + const weights = DIRECTORS.map((d) => { + let w = d.weight; + if (d.name === 'corrupt') w *= 0.5 + noisy * 2.0; + if (d.name === 'brutalist') w *= 0.6 + noisy * 1.2; + if (d.name === 'organicist') w *= 0.6 + (1 - noisy) * 1.4; + if (d.name === 'geometer') w *= 0.7 + bright * 1.0; + return w; + }); + + return rng.pickWeighted(DIRECTORS, weights); +} + +export function directorByName(name) { + return DIRECTORS.find((d) => d.name === name) || DIRECTORS[0]; +}