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:
@@ -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)})`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user