music-video-gen/flow-state/tools/migration-status.js
Dejvino e5bb7d77e0 A reproducible migration, and the first eighteen scenes through it
The library is sixty-one scenes, which is too many to convert from memory or
from taste, so the migration is a queue with a gate rather than a judgement call
per file.

Three pieces. A classifier reads each shader and assigns a tier from evidence in
the source — drawn, figure, field, treatment — so two passes over the library
reach the same answer and the work has an order. MIGRATION.md is the recipe per
tier, written to be followed mechanically. And a gate makes the result
verifiable: `consumes` is now a schema field, the lint enforces it in both
directions, and the per-scene battery renders each scene under two deliberately
distant identities and requires the picture to change.

That gate is the part that matters. Without it `consumes` is a comment, and the
whole inversion becomes unverifiable at exactly the point where it stops being
checkable by eye. With it, a scene that declares the cast and ignores it fails.

Eighteen scenes migrated. Four by hand at the drawn tier — Firefly Drift,
Metaballs, Floating Geometry, Prism Bloom — and ten at the field tier by script,
which is one declaration and one wrapped return. All eighteen pass.

The field tier is honestly marginal and the gate says so: every one of the ten
moves by 37 to 39 of 255, against 173 to 255 for the drawn tier, and the
uniformity across ten unrelated scenes is the tell. That is one global
posterisation applying, not ten scenes expressing anything. Cheap, real, shallow.

The decomposition moved from identity being worth 54% of the container to 158%,
but the stage set changed underneath the measurement and part of that is
Metaballs expressing a cast better than Constellation did. What survives the
caveat is the useful finding: a migrated library scene carries the identity
better than a stage written from scratch to carry it. The four bespoke stages
were the wrong shape of effort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:59:24 +02:00

131 lines
5.7 KiB
JavaScript

// Which scenes have migrated to the identity artifacts, and what each of the
// rest needs. See MIGRATION.md for the recipe this reports progress against.
//
// Sixty-one scenes is too many to migrate from memory or from taste. The
// classifier below reads each shader and proposes a TIER from evidence in the
// source, so the work is a queue rather than a judgement call each time, and so
// two passes over the library reach the same answer.
//
// It is deliberately conservative: it proposes, the gate disposes. A scene is
// only migrated when `consumes` is declared AND checks.html?scene=<name> shows
// the matching `consumes:` line passing, which no amount of pattern matching
// can fake.
//
// node tools/migration-status.js progress plus the next ten
// node tools/migration-status.js --all every scene
// node tools/migration-status.js --tier drawn
import { scenes } from '../src/scenes/registry.js';
const has = (n) => process.argv.includes(`--${n}`);
const argOf = (n) => {
const i = process.argv.indexOf(`--${n}`);
return i >= 0 ? process.argv[i + 1] : null;
};
/**
* Tiers, in migration order. The order is by payoff per unit of risk: scenes
* that already loop over discrete elements are a near-mechanical change and are
* where the cast actually shows, while a full-frame field has no elements to
* replace and can only take the ink.
*/
const TIERS = {
drawn: {
consumes: ['cast', 'ink', 'staging'],
why: 'loops over discrete elements — replace the primitive with the cast, '
+ 'the placement with stageNode, the edge with inkMask',
},
figure: {
consumes: ['cast', 'ink'],
why: 'draws one or a few forms without a lattice — take the cast and the ink, '
+ 'keep its own composition',
},
field: {
consumes: ['ink'],
why: 'a continuous surface with no elements — inkValue on the way out, '
+ 'inkPattern where it already dithers or hatches',
},
treatment: {
consumes: ['ink'],
why: 'an effect over an image rather than an image — takes the value '
+ 'structure only, and should probably become an EFFECT rather than a scene',
},
};
const RX = {
migrated: /\b(castMain|castChorus|castSDF|inkMask|inkValue|stageNode)\s*\(/,
// A loop whose body places something at a computed position: the signature
// of an element-placing scene.
elementLoop: /for\s*\(\s*int\s+\w+\s*=\s*0[\s\S]{0,900}?(length\s*\(\s*p\s*-|p\s*-\s*(pos|centre|center|c)\b|sigForm\s*\(|smoothstep\s*\([^)]*\bd\b)/,
anyLoop: /for\s*\(\s*int\s+\w+\s*=\s*0/,
// A single distance-field subject, no loop needed.
figure: /\bsig(Shape|Form)\s*\(|\bsdf\w*\s*\(|length\s*\(\s*p\s*\)\s*-/,
// Screen-space corruption: it operates on the frame, not on a world.
treatment: /\bprev\s*\(|\bvUv\b[\s\S]{0,200}(tear|glitch|shift)|u_prev/,
field: /\b(fbm|vnoise|curl|voronoi|noise)\s*\(/,
};
function classify(module) {
const src = module.shader || '';
if (RX.migrated.test(src) || (module.consumes || []).length) return 'migrated';
if (module.role === 'accent') return 'accent';
if (RX.elementLoop.test(src)) return 'drawn';
if (RX.treatment.test(src)) return 'treatment';
if (RX.figure.test(src) && !RX.anyLoop.test(src)) return 'figure';
if (RX.field.test(src)) return 'field';
return 'figure';
}
const rows = scenes.map((m) => ({
name: m.name,
family: m.family,
role: m.role || 'stage',
tier: classify(m),
consumes: m.consumes || [],
lines: (m.shader || '').split('\n').length,
}));
const migrated = rows.filter((r) => r.tier === 'migrated');
const pending = rows.filter((r) => r.tier !== 'migrated' && r.tier !== 'accent');
const accents = rows.filter((r) => r.tier === 'accent');
const bar = (v, w = 28) => '█'.repeat(Math.round(v * w)) + '·'.repeat(w - Math.round(v * w));
console.log(`\nMIGRATION STATUS — ${migrated.length}/${migrated.length + pending.length} scenes on the identity artifacts\n`);
console.log(` ${bar(migrated.length / Math.max(1, migrated.length + pending.length))} ` +
`${(migrated.length / Math.max(1, migrated.length + pending.length) * 100).toFixed(0)}%` +
` (${accents.length} accents excluded — they are depth passes, not subjects)\n`);
console.log(' REMAINING BY TIER\n');
for (const [tier, spec] of Object.entries(TIERS)) {
const group = pending.filter((r) => r.tier === tier);
if (!group.length) continue;
console.log(` ${tier}${group.length} scenes → consumes: [${spec.consumes.join(', ')}]`);
console.log(` ${spec.why}`);
console.log(` ${group.map((r) => r.name).join(', ')}`);
console.log('');
}
if (has('all') || argOf('tier')) {
const want = argOf('tier');
console.log(' PER SCENE\n');
for (const r of rows.filter((x) => !want || x.tier === want)) {
console.log(` ${r.name.padEnd(24)} ${r.family.padEnd(11)} ${r.tier.padEnd(10)}` +
`${r.consumes.length ? `[${r.consumes.join(',')}]` : ''}`);
}
console.log('');
} else if (pending.length) {
// The queue: drawn first, biggest payoff and least invention required.
const order = ['drawn', 'figure', 'field', 'treatment'];
const next = pending.slice().sort(
(a, b) => order.indexOf(a.tier) - order.indexOf(b.tier) || a.lines - b.lines);
console.log(' NEXT TEN\n');
for (const r of next.slice(0, 10)) {
console.log(` ${r.name.padEnd(24)} ${r.tier.padEnd(10)} ${r.family.padEnd(11)} ${r.lines} shader lines`);
}
console.log('');
console.log(' Recipe: MIGRATION.md. Verify one with:');
console.log(` checks.html?scene=${encodeURIComponent(next[0].name)}`);
console.log(' and look for a passing "consumes:" line for every artifact declared.\n');
}