"Twenty-three of sixty-one scenes are never cast" was an artefact of the song it was measured on. synthesizeSectioned has one change point, so it segments into exactly two sections and both are quiet kinds — and intro, breakdown and outro are restricted to the restful families for every director. Half the library was unreachable before a seed was drawn, and the measurement reported that as a casting failure. synthesizeArrangement builds a real one: intro, build, drop, breakdown, drop, outro, shaped to hit the segmenter's own classifier rather than to sound like anything. Measured against a bank of those, two scenes out of sixty-one are never a background, not twenty-three. The census exists because "never cast" without a reason is unactionable. A scene can die at the signature gate, at the director's family table, or in the roster draw, and those are three different repairs — a trait declaration, a table, and arithmetic that neither fixes. It reports which one, and it separates being cast as a background from appearing as a translucent overlay, because a library can look fully used while nine scenes carry every frame. What it finds is that eligibility is almost entirely a function of how many traits a scene declares. Four-trait scenes are eligible for every track and open half of all videos; two-trait scenes are eligible for one track in fourteen; the single scene declaring one trait is eligible for none, ever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
9.3 KiB
JavaScript
199 lines
9.3 KiB
JavaScript
// 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(' · ')}`);
|