// Which visualizations does the generator actually reach, and why not the rest? // // A library entry that no song ever sees is not a library entry. But "never cast // on one track across many seeds" and "never cast at all" are completely // different findings, and the first is what the variety report measures, so this // varies the SONG as well as the seed and separates the two. // // The casting funnel has four gates, and a scene can die at any of them: // // 1. role accents are never a section's primary scene, by design. // 2. signature the track picks one or two traits and a scene that cannot // honour both is disqualified for that whole track. // 3. director the track's point of view maps each section KIND to three // families; a scene in none of them is not a candidate. // 4. roster a kind takes only three or four scenes, weighted. // // Reporting a scene as "never cast" without saying which gate killed it is // unactionable — gate 2 is a trait declaration to fix on the scene, gate 3 is a // director table to widen, gate 4 is arithmetic that no amount of either fixes. // // node tools/cast-census.js 12 songs x 40 seeds // node tools/cast-census.js 24 80 more of both import { FeatureTrack } from '../src/audio/FeatureTrack.js'; import { synthesizeArrangement } from '../src/audio/synth.js'; import { generateLook } from '../src/look/LookGenerator.js'; import { scenes } from '../src/scenes/registry.js'; import { sceneHonours, TRAITS } from '../src/look/Personality.js'; import { DIRECTORS } from '../src/look/directors.js'; const SONGS = Number(process.argv[2]) || 12; const SEEDS = Number(process.argv[3]) || 40; /** * A spread of songs, not a spread of seeds. * * Tempo, density and brightness are the three things the generator actually * reads, so the bank spans them deliberately rather than randomly — a random * bank would cluster in the middle and under-report exactly the scenes that only * a fast, bright, noisy track can reach. * * Arrangements, not `synthesizeSectioned`. A two-section track is all quiet * kinds, and quiet kinds are restricted to the restful families for every * director, so half the library would be unreachable for reasons that have * nothing to do with casting. */ function songBank(count) { const out = []; for (let i = 0; i < count; i++) { const t = count === 1 ? 0.5 : i / (count - 1); const bpm = 70 + t * 104; out.push({ label: `bpm ${Math.round(bpm)}`, track: FeatureTrack.fromAudioBuffer(synthesizeArrangement({ bpm, duration: 120, brightness: 0.6 + ((i * 7) % 5) / 5 * 1.2, density: 0.55 + ((i * 3) % 4) / 4 * 0.8, }), { fps: 60 }), }); } return out; } const pool = scenes.filter((m) => m.role !== 'accent'); const stat = new Map(pool.map((m) => [m.name, { module: m, eligible: 0, candidate: 0, cast: 0, background: 0, songs: new Set(), }])); const kindTally = new Map(); const songs = songBank(SONGS); let looks = 0; for (const { track } of songs) { for (let s = 0; s < SEEDS; s++) { const seed = (0x51ee7 + s * 2654435761) >>> 0; const look = generateLook(track, { seed }); looks++; const signature = look.personality.signature; const director = DIRECTORS.find((d) => d.name === look.director); const kinds = [...new Set(look.sections.map((x) => x.kind))]; for (const k of kinds) kindTally.set(k, (kindTally.get(k) || 0) + 1); const families = new Set(kinds.flatMap((k) => director.families[k] || [])); for (const m of pool) { const st = stat.get(m.name); const honours = sceneHonours(m, signature); if (honours) st.eligible++; // A candidate is a scene that survived BOTH the signature and the // director for at least one kind this track actually has. if (honours && families.has(m.family)) st.candidate++; } // Background vs any-layer, kept apart. A scene that only ever appears as // a translucent overlay is not "in the video" in the sense that matters: // it is texture over someone else's shot, and counting it as a cast is // how a library can look fully used while the same nine scenes carry // every frame. Overlays are also drawn from the whole signature-eligible // library rather than the director's families, so counting them together // hides which gate is doing the work. const usedHere = new Set(look.sections.flatMap( (sec) => sec.variants.flatMap((v) => v.map((l) => l.module.name)))); const backgroundsHere = new Set(look.sections.flatMap( (sec) => sec.variants.map((v) => v[0].module.name))); for (const name of usedHere) { const st = stat.get(name); if (!st) continue; // an accent layer st.cast++; st.songs.add(track); } for (const name of backgroundsHere) { const st = stat.get(name); if (st) st.background++; } } } const rows = [...stat.values()].map((st) => ({ name: st.module.name, family: st.module.family, traits: (st.module.traits || []).map((t) => t[0]).join(''), eligible: st.eligible / looks, candidate: st.candidate / looks, cast: st.cast / looks, background: st.background / looks, songs: st.songs.size, // The conditional is the whole point: a scene that is a candidate often and // cast never is losing the roster draw, which is a different bug from a // scene that is never a candidate at all. given: st.candidate ? st.cast / st.candidate : 0, })).sort((a, b) => a.background - b.background || a.cast - b.cast); const pc = (x) => `${(x * 100).toFixed(1)}%`.padStart(6); console.log(`\n${looks} looks · ${SONGS} songs x ${SEEDS} seeds · ${pool.length} castable scenes\n`); console.log(' scene family traits eligible candidate as bg any layer songs'); console.log(' ' + '-'.repeat(96)); for (const r of rows) { const flag = r.background === 0 && r.cast === 0 ? ' ← NEVER' : r.background === 0 ? ' ← overlay only' : r.background < 0.02 ? ' ← rare' : ''; console.log(` ${r.name.padEnd(24)} ${r.family.padEnd(11)} ${r.traits.padEnd(6)} ` + `${pc(r.eligible)} ${pc(r.candidate)} ${pc(r.background)} ${pc(r.cast)} ` + `${String(r.songs).padStart(3)}/${SONGS}${flag}`); } console.log(`\n section kinds the bank produced: ` + [...kindTally.entries()].sort((a, b) => b[1] - a[1]) .map(([k, v]) => `${k} ${(v / looks * 100).toFixed(0)}%`).join(' · ')); // --- where the losses happen --------------------------------------------- const never = rows.filter((r) => r.background === 0); const neverCandidate = never.filter((r) => r.candidate === 0); const neverEligible = never.filter((r) => r.eligible === 0); const lostInRoster = never.filter((r) => r.candidate > 0); console.log(`\n never cast as a background: ${never.length}/${pool.length}` + ` (of those, ${never.filter((r) => r.cast > 0).length} appear only as overlays)`); console.log(` disqualified by the signature gate, always: ${neverEligible.length}` + (neverEligible.length ? ` (${neverEligible.map((r) => r.name).join(', ')})` : '')); console.log(` eligible but never a candidate (director): ${neverCandidate.length - neverEligible.length}` + (neverCandidate.length - neverEligible.length ? ` (${neverCandidate.filter((r) => r.eligible > 0).map((r) => r.name).join(', ')})` : '')); console.log(` a candidate but never won a roster slot: ${lostInRoster.length}` + (lostInRoster.length ? ` (${lostInRoster.map((r) => r.name).join(', ')})` : '')); // --- the trait declarations behind gate 2 --------------------------------- console.log('\n trait coverage across the library (a signature is one or two of these):'); for (const trait of TRAITS) { const n = pool.filter((m) => (m.traits || []).includes(trait)).length; console.log(` ${trait.padEnd(7)} ${String(n).padStart(3)}/${pool.length} scenes declare it`); } const byCount = new Map(); for (const m of pool) { const k = (m.traits || []).length; byCount.set(k, (byCount.get(k) || 0) + 1); } console.log('\n scenes by number of traits declared (fewer traits = harder to cast):'); for (const k of [...byCount.keys()].sort()) { console.log(` ${k} traits ${String(byCount.get(k)).padStart(3)} scenes` + (k <= 1 ? ' ← can only survive a signature it happens to match' : '')); } // --- family reachability per director ------------------------------------- console.log('\n family demand — how many of the 6 section kinds each director opens to a family:'); const families = [...new Set(pool.map((m) => m.family))].sort(); const head = families.map((f) => f.slice(0, 6).padStart(7)).join(''); console.log(` ${'director'.padEnd(12)}${head}`); for (const d of DIRECTORS) { const counts = families.map((f) => { const n = Object.values(d.families).filter((list) => list.includes(f)).length; return String(n).padStart(7); }).join(''); console.log(` ${d.name.padEnd(12)}${counts}`); } const sizes = families.map((f) => `${f} ${pool.filter((m) => m.family === f).length}`); console.log(` library holds: ${sizes.join(' · ')}`);