#!/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())}`);