music-video-gen/flow-state/tools/new-scene.js
Dejvino 89e05459c0 Every shot stands on something, and the frame has two ends
A section used to be one scene, and two thirds of the library is composable —
sparse by design, elements ON something. Cast as backgrounds anyway, they left
9 of 40 sampled frames under 20% painted, the darkest at 0.3%: a minute and a
half of a few bright things on black, invisible to every gate because every
gate on the stack was a limit rather than a floor.

Every section now stands on a GROUND: a canvas that fills the frame, cast per
section kind so a shot cut changes the shot and not the world. When the shot
fills the frame itself it IS the ground — two canvases stacked is two pictures
fighting. Above that, a coverage BUDGET: director appetite times the section's
energy times where the story is, capped at two frames' worth of material.

The measured facts move into the repo. scenes/metadata.json is generated from
the gallery — coverage as a shot, coverage as a bed, variety, the structural
profile — tracked in git, stamped with a fingerprint of the scenes and the
metric definitions, and refreshed from gallery.html. `surface` is derived from
it rather than declared; nine scenes claimed `canvas` while painting under a
third of the frame, and declaring it is now a lint error. The generator weights
every layering choice by measured structural distance, because family labels
and the render disagree: two `geometric` scenes can be 0.31 apart and a `flow`
and an `organic` scene 0.04.

The gallery's 0.1 red line is gone. It was right when a section was one scene
and wrong now — nineteen scenes were failing a bar for being consistent, which
is a virtue in an ingredient.

Chasing the numbers turned up four real faults:

  * A scene that reads prev() cannot be a ground. It returns the whole
    composited frame including the layers above it, so a datamosh under a shot
    is eating it: the render stopped reproducing from a seek and two WebGL
    contexts diverged by 91/255 against a tolerance of 4.
  * Screen was the wrong operator for a shot over a bed. It lightens, so a
    median quarter of every frame clipped to paper and whole sections rendered
    100% white. Replaced by a lumakey — the shot's brightness is its alpha.
  * Feedback was an accumulator: a still image settled at 2.3x its own
    brightness. Fine over black, fatal over a filled ground. Normalised at 0.6,
    plus a highlight shoulder so the top rolls off instead of clipping.
  * useTrack never prewarmed, so a fresh Show's first frame differed from every
    later render of it — the export-breaking hazard Compositor.prime documents.

Blazing is a decision now, not a side effect: directors declare an appetite for
it, a section must be loud and late in the story to earn one, and quiet kinds
never do. The ceiling gate matches that — a hard cap per section, and no more
than a fifth of them hot at all.

Rendered across twelve videos, middle of every section:

    painted        51% mean, darkest 0.3%  ->  87% mean, darkest 43%
    clipped white  24% median, worst 100%  ->   1% mean, worst 30%
    separation     0.10                    ->  0.44

Seven scenes can ground a section — five geometric, two organic — so every
quiet section of every video stands on one of two beds. That is the library's
largest hole and it is scene work: there is no minimal or flow canvas that
fills half the frame without reading prev().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:02:01 +02:00

209 lines
8.5 KiB
JavaScript

#!/usr/bin/env node
// Scaffold a new scene: writes the module, registers it, and leaves it in a
// state that already passes every static and GPU gate.
//
// The point is token economy as much as typing. Everything a new scene needs
// besides its shader body is mechanical — the params block, the trait
// declarations that match the helpers actually called, the registry import and
// entry, a reactive mapping that is not on a rate param — and every one of
// those has its own lint rule waiting to reject it. Generating them from a
// template means the only thing left to write is the part that needs judgement.
//
// Usage:
// npm run new:scene -- "Salt Flat" --family=minimal --traits=shape,camera,space,style
// npm run new:scene -- "Ink Bleed" --family=organic --traits=camera,style --feedback
//
// Then edit the `scene()` body. The skeleton renders a live, animated, seeded
// field, so the scene is gate-clean from the first run and stays that way while
// you replace the body a piece at a time.
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const SCENES = join(root, 'src/scenes/shader');
const REGISTRY = join(root, 'src/scenes/registry.js');
const FAMILIES = ['flow', 'organic', 'minimal', 'structural', 'geometric', 'glitch'];
const TRAITS = ['shape', 'camera', 'space', 'style'];
const args = process.argv.slice(2);
const flags = new Map(args.filter((a) => a.startsWith('--')).map((a) => {
const [k, v] = a.replace(/^--/, '').split('=');
return [k, v === undefined ? true : v];
}));
const name = args.find((a) => !a.startsWith('--'));
if (!name) {
console.error(`usage: npm run new:scene -- "Scene Name" --family=<${FAMILIES.join('|')}> ` +
`--traits=<${TRAITS.join(',')}> [--feedback]`);
process.exit(1);
}
const family = String(flags.get('family') || 'flow');
if (!FAMILIES.includes(family)) {
console.error(`unknown family '${family}' — one of ${FAMILIES.join(', ')}`);
process.exit(1);
}
const traits = String(flags.get('traits') || 'camera,style').split(',').map((t) => t.trim()).filter(Boolean);
for (const t of traits) {
if (!TRAITS.includes(t)) {
console.error(`unknown trait '${t}' — one of ${TRAITS.join(', ')}`);
process.exit(1);
}
}
const kebab = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const camel = kebab.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
const file = join(SCENES, `${kebab}.js`);
if (existsSync(file)) {
console.error(`${kebab}.js already exists`);
process.exit(1);
}
/**
* A per-scene constant baked into the skeleton.
*
* Two freshly scaffolded scenes would otherwise render identically and trip the
* "no two scenes render the same image" gate before either has been written.
* Deriving the constants from the name means the skeleton is already distinct.
*/
function salt(text, lo, hi) {
let h = 2166136261;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return +(lo + (h % 1000) / 1000 * (hi - lo)).toFixed(2);
}
const freq = salt(kebab, 2.5, 7.5);
const skew = salt(kebab + 'x', 0.4, 2.2);
const warp = salt(kebab + 'w', 0.3, 1.6);
// Trait expression: each declared trait gets a real call, because the lint
// greps for one and the Phase 9 render gate then measures that it changed the
// image. A trait declared and not used fails both, in that order.
const traitLines = {
camera: ' p = sigCamera(p);',
space: ' float horizon = sigHorizonY();\n p.y -= horizon * 0.3;',
shape: '', // used in the body below
style: '', // used in the body below
};
const head = traits.map((t) => traitLines[t]).filter(Boolean).join('\n');
const shapeLine = traits.includes('shape')
? '\n // TRAIT shape: the track\'s signature form, so this scene is cast from\n' +
' // the same actors as every other scene in the video.\n' +
' float form = sigShape(p / max(u_size, 1e-3)) * u_size;\n' +
' col += pal(3) * sigEdgeOrMask(form);'
: '';
const styleLine = traits.includes('style')
? '\n // TRAIT style: the track\'s art direction.\n col += sigGrain(uv);'
: '';
const spaceLine = traits.includes('space')
? '\n col = sigAir(col, p, smoothstep(0.0, 1.6, length(p)));'
: '';
const feedbackLine = flags.get('feedback')
? '\n // Feedback. The base field above must stand alone: a scene that only\n' +
' // reads prev() is black for its first frames and fragile under seek.\n' +
' col = max(col, prev(uv - vec2(0.0, 0.002)) * u_persist);'
: '';
const shapeHelper = traits.includes('shape')
? `
// Fill for the signature form, drawn in the track's line weight.
float sigEdgeOrMask(float d) {
return smoothstep(0.01, -0.01, d) * 0.6 + sigEdge(d);
}
`
: '';
const params = [
` scale: { type: 'float', range: [1, 12], default: ${freq}, uniform: 'u_scale', bias: 'density' },`,
` speed: { type: 'float', range: [0.05, 1.2], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },`,
` detail: { type: 'float', range: [0.2, 2.5], default: ${warp}, uniform: 'u_detail', bias: 'density' },`,
` glow: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_glow', bias: 'energy' },`,
traits.includes('shape')
? ` size: { type: 'float', range: [0.05, 0.6], default: 0.25, uniform: 'u_size' },`
: null,
flags.get('feedback')
? ` persist: { type: 'float', range: [0, 0.85], default: 0.4, uniform: 'u_persist' },`
: null,
` palette: { type: 'palette', count: 5 },`,
].filter(Boolean).join('\n');
const source = `// ${family[0].toUpperCase() + family.slice(1)} family: TODO one line on what this looks like.
//
// TODO: say what makes it DIFFERENT from the scenes it sits next to. That
// sentence is the scene's reason to exist, and "no two scenes render the same
// image" is a gate, not a guideline.
//
// Scaffolded by tools/new-scene.js. See HOWTO-visualizers.md.
export const ${camel} = {
name: '${name.trim()}',
family: '${family}',
kind: 'fragment',
traits: [${traits.map((t) => `'${t}'`).join(', ')}],
params: {
${params}
},
reactive: {
glow: { feature: 'beat', amount: 0.3, response: 'spike' },
detail: { feature: 'bandMid', amount: 0.25, response: 'smooth' },
},
shader: \`${shapeHelper}
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
${head}
// TODO: replace this field. It exists so the skeleton is live, animated and
// distinct from every other scene the moment it is registered.
float n = fbm(p * u_scale * 0.5 + vec2(t * 0.4, -t * ${skew}), 4);
float band = sin(n * u_detail * 6.0 + length(p) * ${freq} - t * 2.0) * 0.5 + 0.5;
vec3 col = mix(pal(0) * 0.08, pal(1), band);
col += pal(2) * pow(band, 4.0) * u_glow;${shapeLine}${spaceLine}${feedbackLine}${styleLine}
return vec4(col, 1.0);
}
\`,
};
export default ${camel};
`;
writeFileSync(file, source);
// --- register -------------------------------------------------------------
let registry = readFileSync(REGISTRY, 'utf8');
const importLine = `import { ${camel} } from './shader/${kebab}.js';`;
if (!registry.includes(importLine)) {
const lastImport = registry.lastIndexOf("} from './shader/");
const eol = registry.indexOf('\n', lastImport);
registry = registry.slice(0, eol + 1) + importLine + '\n' + registry.slice(eol + 1);
}
registry = registry.replace(/\n\];/, `\n ${camel},\n];`);
writeFileSync(REGISTRY, registry);
console.log(`created src/scenes/shader/${kebab}.js`);
console.log(`registered ${camel} (${family}, traits: ${traits.join(', ') || 'none'})`);
console.log('');
console.log('next:');
console.log(' 1. write the scene() body — everything else is done');
console.log(' 2. npm run lint:scenes');
console.log(` 3. open http://localhost:5180/checks.html?scene=${encodeURIComponent(name.trim())}`);
// A scene with no measured coverage cannot be a ground and fails the phase 12
// table check, which is the point: how much frame it paints decides where it is
// allowed to sit in a stack, and that is a render result rather than a claim.
console.log(' 4. open http://localhost:5180/checks.html?phase=12&slow=1 and paste');
console.log(' your scene\'s line from window.__COVERAGE_TABLE__ into');
console.log(' src/scenes/coverage.js — declare `surface` to match it');