Epic 2.2: cast a director per track

FAMILY_BY_KIND was a module constant, 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 made the same genre decisions before a single seeded draw
happened — cross-track sameness hiding inside something that looked like
configuration. Measured, twenty-nine of forty-two scenes were cast in none of
twelve tracks: the library was not too small, most of it was unreachable.

There are now five directors, each a coherent point of view about what a song
looks like — ambient, brutalist, organicist, corrupt, geometer — and a track
casts one, seeded, with the audio tilting the odds and never deciding. Twelve
tracks now reach 25 of 41 scenes, up from 13 of 42.

The first draft of this got a real thing wrong, and the existing gates caught
it. Applying a point of view to every kind meant `corrupt` opened on glitch
and `geometer` answered a breakdown with a dense pattern — which broke a
Phase 7 invariant that has held since the minimal family existed. That
invariant is right: an intro that opens strobing is not bold, it is the exact
mistake the family coupling was introduced to prevent, and the viewer meets
it fifteen seconds in. So intro, breakdown and outro are restricted to
minimal/flow/organic for every director, and the identity lives in build,
drop and sustain plus which restful family a director leads with. The
constraint is now asserted against the mappings directly, so a sixth director
cannot reintroduce it without tripping a gate that names the reason.

Four new Phase 11 checks: every scene reachable, no kind starved, no loud
family in a quiet section, every director castable.

Unrelated pre-existing flake worth recording: Phase 7's "every scene is
deterministic" reports Horizon Lines at 2/255 against a 1/255 tolerance when
phase 7 runs in isolation, and passes in a full run. Verified present on the
previous commit, so it is load-dependent GPU precision rather than anything
in this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-06 07:36:51 +02:00
parent f050eaaad1
commit d14d473974
3 changed files with 305 additions and 21 deletions

View File

@ -14,6 +14,8 @@ import { generateLook } from '../look/LookGenerator.js';
import { FeatureTrack } from '../audio/FeatureTrack.js'; import { FeatureTrack } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js'; import { synthesizeSectioned } from '../audio/synth.js';
import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS } from '../look/shots.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. */ /** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */
let cached = null; 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 ` + `${onGrid}/${total} cuts within three quarters of a beat of a downbeat ` +
`(${(ratio * 100).toFixed(0)}%, floor 80%)`); `(${(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)})`);
});

View File

@ -11,21 +11,13 @@ import { sampleValues, defaultValues } from '../params/schema.js';
import { planShots } from './shots.js'; import { planShots } from './shots.js';
import { generatePersonality, sceneHonours, describePersonality } from './Personality.js'; import { generatePersonality, sceneHonours, describePersonality } from './Personality.js';
import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js'; import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
import { pickDirector, directorByName } from './directors.js';
/** // Which families suit which section kind now comes from the track's DIRECTOR
* Which families suit which section kind, in preference order. // (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
* This is the coupling that stops a breakdown landing on a strobing glitch scene // glitch scene and an intro opening at full density — but which coupling a
* and an intro opening at full density. It is also why families exist at all. // given track gets is a decision, not a fact about the program.
*/
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'],
};
const KIND_ENERGY = { const KIND_ENERGY = {
intro: 0.25, build: 0.55, drop: 0.95, sustain: 0.6, breakdown: 0.25, outro: 0.2, 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 * on is not a worse choice, it is the shot that was clearly filmed somewhere
* else. See look/Personality.js. * else. See look/Personality.js.
*/ */
function candidatesForKind(kind, used, signature = []) { function candidatesForKind(kind, used, signature = [], director) {
const families = FAMILY_BY_KIND[kind] || Object.keys(FAMILIES); const families = director.families[kind] || Object.keys(FAMILIES);
const candidates = []; const candidates = [];
for (const family of families) { for (const family of families) {
const inFamily = scenesInFamily(family) const inFamily = scenesInFamily(family)
@ -122,7 +114,7 @@ function rosterSizeFor(kind) {
* Variation between two sections of the same kind comes from their parameter * 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. * 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 byKind = new Map();
const used = new Set(); const used = new Set();
@ -137,7 +129,7 @@ function assignRostersByKind(sections, rng, signature = []) {
const size = rosterSizeFor(kind); const size = rosterSizeFor(kind);
for (let slot = 0; slot < size; slot++) { 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)) .filter((c) => !roster.includes(c.scene))
.map((c) => ({ .map((c) => ({
scene: c.scene, 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) => 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);
// 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( 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 // The grain treatment: usually none, and when present described rather than
// dialled. See look/grain.js. // dialled. See look/grain.js.
const grain = deriveGrain(summary, rng.fork('grain')); const grain = deriveGrain(summary, rng.fork('grain'));
@ -347,6 +343,7 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
palette, palette,
personality, personality,
paletteScheme: paletteSource.lastScheme, paletteScheme: paletteSource.lastScheme,
director: director.name,
grain, grain,
post, post,
feedback, 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 rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0);
const signature = (look.personality && look.personality.signature) || []; 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)) let candidates = families.flatMap((f) => scenesInFamily(f))
.filter((m) => m.role !== 'accent' && sceneHonours(m, signature)); .filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
if (!candidates.length) { if (!candidates.length) {
@ -424,7 +421,7 @@ function applyOverrides(look, overrides) {
/** Compact description, used by the HUD and by check output. */ /** Compact description, used by the HUD and by check output. */
export function describeLook(look) { export function describeLook(look) {
const kinds = look.sections.map((s) => `${s.kind}:${s.layers[0].module.name}`); 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)} · ` + `${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
`${[...new Set(kinds)].join(', ')}`; `${[...new Set(kinds)].join(', ')}`;
} }

View File

@ -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];
}