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
co-authored by Claude Opus 5
parent f050eaaad1
commit d14d473974
3 changed files with 305 additions and 21 deletions
+18 -21
View File
@@ -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(', ')}`;
}
+148
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];
}