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>
209 lines
8.5 KiB
JavaScript
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');
|