Watching several finished tracks side by side turned up the problem neither Phase 8 (too few cuts) nor Phase 9 (no through-line) addressed: the same scene cast in two different videos looked like the same footage twice. Section bias is nearly identical between two tracks' drops, so both sampled their parameters around the same centre, and the library's own averageness did the rest. Three answers, none of them a new scene: Temperament — a per-track hand on every parameter dial: intensity, pace, detail, and an extremity that decides how far toward the ends of a range the track is willing to sample. Bias comes from the section and is shared between tracks; temperament comes from the track and is not. Overlays — sometimes a second full scene composited over the shot, from a different family, in a blend that preserves what is underneath and never above 0.6 opacity. Not always: a stack that always doubled up would read as permanently cluttered rather than as occasionally layered. A wider palette — hue now derives from SPECTRAL TILT, the log ratio of treble to body. The centroid is a number most masters sit in the middle of, and the plain body/(body+treble) fraction is worse: low frequencies carry most of the energy in all music, so it read 0.98-1.00 for everything and four different battery tracks came out within 0.02 of each other. The ratio is multiplicative, so its logarithm is what spreads — the same four measure -9.3, -5.0, -4.1, -3.8. Also both ways round the wheel (violet, magenta and pink were unreachable by construction), four new schemes, and seeded chroma profile and lightness curve. Closest battery pair went from 0.005 to 0.113. Twelve scenes take the library to 36, six per family: Aurora Veil, Vortex Drift, Tide Rings, Ink Bleed, Dust Chamber, Salt Flat, Cargo Belt, Gate Corridor, Circuit Bloom, Truchet Fold, Signal Decay, Storm Rift. Weighted toward the 'space' and 'shape' traits, which were thinnest and so the signatures most likely to run a track out of cast — the Phase 9 casting rule means the pool a track draws from is smaller than the library. Also fixes a real one in shots.js: heavy LRU weighting was not enough to make a section reach its whole roster, and a five-shot section still came out 0,2,0,2,0 about a fifth of the time. An unseen companion now wins outright; which one is still free, so only the coverage is guaranteed. Block Mosh declared the camera trait, assigned sigCamera(p) to a p it then never read, and passed the lint's evidence grep. The Phase 9 render gate measured its response to the camera at exactly zero. --- tooling --- Adding a scene was mostly boilerplate and round-trips, which is expensive in both senses. The irreducible cost is the shader body; everything around it is now mechanical: npm run new:scene -- "Name" --family=... --traits=... writes the module, registers it, and leaves a skeleton that already passes every gate, with name-derived constants so two skeletons are not twins. The lint grew the rules that previously needed a GPU to catch: the dead camera above, prev() with no base image, and large loops with no early break (with a `// lint: fixed-cost` opt-out for a genuinely fixed-cost sampling loop). checks.html?scene=Name runs the per-scene acceptance battery for one scene — ten lines and a verdict instead of rendering the whole library to find out whether one shader is alive. The same procedure is a repo skill under .claude/skills/build-visualizer/. --- checks changed, with the measurements --- P5 determinism compared two WebGL CONTEXTS, which is not what it is for. Measured: one context is bit-exact over 40 frames with feedback at 0.6; two contexts disagree by up to 2/255 whether feedback is on or off. It now asserts generation is byte-identical (hard) and rendering within 2/255, since feedback compounds single-level variance. P10's cross-track comparison measures distance RELATIVE to how much image there is. Most scenes are mostly dark, so two genuinely different renders — 25 bars against 53 — scored under 0.02 absolute purely because the black background agrees with itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
8.0 KiB
JavaScript
203 lines
8.0 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())}`);
|