// The per-scene acceptance battery, runnable for ONE scene. // // The library-wide gates iterate the registry, so a new scene is covered the // moment it is registered — but running them means rendering all thirty-six // scenes and reading a page of results to find out whether the one you just // wrote is alive. That is slow to run and expensive to read, and it is the loop // you are in constantly while writing a scene. // // This runs the same acceptance criteria against a single scene and prints one // line per criterion plus a single verdict. Open: // // checks.html?scene=Aurora%20Veil // // The criteria are deliberately the same ones Phase 2, 5 and 7 apply — this is // a filter over the existing gates, not a second, weaker set of them. import { Engine } from '../engine/Engine.js'; import { sceneByName, scenes } from '../scenes/registry.js'; import { defaultValues, sampleValues, sweepValues, validateModule } from '../params/schema.js'; import { Rng } from '../engine/rng.js'; import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js'; import { synthesizeSectioned } from '../audio/synth.js'; import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js'; import { peakFlashRate } from '../engine/flash.js'; import { generatePersonality } from '../look/Personality.js'; import { generateIdentity } from '../look/Identity.js'; import { generateActor } from '../actors/ActorGenerator.js'; const PALETTE = [ [0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95], [0.98, 0.85, 0.35], [0.55, 0.25, 0.85], [0.2, 0.95, 0.6], ]; const SUMMARY = { meanCentroid: 0.5, meanFlatness: 0.25, dynamicRange: 0.5, bpm: 126, meanLoudness: 0.4 }; /** * @param {string} name scene name as registered * @returns {{ok: boolean, lines: string[]}} */ export function runSceneGate(name) { const module = sceneByName(name); const lines = []; if (!module) { return { ok: false, lines: [`FAIL no scene named "${name}" — registered: ${scenes.map((m) => m.name).join(', ')}`], }; } let ok = true; const record = (pass, label, detail) => { ok = ok && pass; lines.push(`${pass ? 'PASS' : 'FAIL'} ${label.padEnd(26)} ${detail}`); }; const errors = validateModule(module); record(errors.length === 0, 'schema', errors.length ? errors.join(' · ') : 'valid'); const track = FeatureTrack.fromAudioBuffer( synthesizeSectioned({ bpm: 128, duration: 60, changeAt: 30 }), { fps: 60 }); const engine = new Engine({ width: 192, height: 108 }); engine.timeline.setDuration(track.duration); engine.setFeatureProvider(featureProviderFor(track)); const personality = generatePersonality(SUMMARY, new Rng(9001)); // Model stages need a deterministic ActorSpec — derived the same way // gallery/metadata derive it: fork from the scene seed + song context. const actorSpec = (module.kind === 'model' && module.actor) ? generateActor({ summary: SUMMARY, rng: new Rng(4242).fork(`actor:${module.actor}`), archetype: module.actor, personality, identity: personality.identity }) : null; const draw = (params, frame, seed = 4242) => { // For model stages the per-draw ActorSpec stays consistent within a // gate run (same personality), so geometry parity is testable. const actor = actorSpec && module.kind === 'model' ? actorSpec : null; engine.setLayerSpecs([{ module, params, seed, opacity: 1, blend: 'normal', palette: PALETTE, personality, actorSpec: actor, }]); engine.compositor.reset(); return Uint8Array.from(engine.readPixels(engine.renderFrame(frame))); }; // Re-derive actor for consumptive identity probes — the identity is swapped. const actorFor = (personalityOverride) => { if (!(module.kind === 'model' && module.actor)) return null; return generateActor({ summary: SUMMARY, rng: new Rng(31337).fork(`actor:${module.actor}`), archetype: module.actor, personality: personalityOverride, identity: personalityOverride.identity }); }; try { // --- alive ------------------------------------------------------- const base = draw(defaultValues(module), 600); const lum = frameLuminance(base); const variance = frameVariance(base); record(lum > 0.004 && variance > 0.0008, 'renders something', `luminance ${lum.toFixed(4)} · variance ${variance.toFixed(4)}`); // --- animates ---------------------------------------------------- const later = draw(defaultValues(module), 600 + 120); const motion = frameMaxDelta(base, later); record(motion > 3, 'animates', `max channel delta ${motion} over 2s`); // --- deterministic ----------------------------------------------- const again = draw(defaultValues(module), 600); const repeat = frameMaxDelta(base, again); record(repeat <= 1, 'deterministic', `repeat delta ${repeat}/255`); // --- distinct from every other scene ------------------------------- let closest = 255; let closestName = ''; for (const other of scenes) { if (other === module) continue; const otherActor = (other.kind === 'model' && other.actor) ? generateActor({ summary: SUMMARY, rng: new Rng(4242).fork(`actor:${other.actor}`), archetype: other.actor, personality, identity: personality.identity }) : null; engine.setLayerSpecs([{ module: other, params: defaultValues(other), seed: 4242, opacity: 1, blend: 'normal', palette: PALETTE, personality, actorSpec: otherActor, }]); engine.compositor.reset(); const d = frameMaxDelta(base, Uint8Array.from(engine.readPixels(engine.renderFrame(600)))); if (d < closest) { closest = d; closestName = other.name; } } record(closest >= 24, 'distinct', `closest ${closestName} at ${closest} (floor 24)`); // --- param sweep --------------------------------------------------- const dead = []; for (const [pname, def] of Object.entries(module.params || {})) { if (def.type === 'palette') continue; for (const value of sweepValues(def, 4)) { const params = { ...defaultValues(module), [pname]: value }; const pixels = draw(params, 700); const l = frameLuminance(pixels); const v = frameVariance(pixels); if (!(l > 0.002) || !(v > 0.0002) || l > 0.97) { dead.push(`${pname}=${Array.isArray(value) ? value.join(',') : value}`); } } } record(dead.length === 0, 'param sweep', dead.length ? `dead/blown at ${dead.slice(0, 4).join(', ')}` : 'all values live'); // --- flash rate ------------------------------------------------------ const hot = sampleValues(module, new Rng(77), { energy: 0.95, density: 0.9, motion: 0.9 }); const hotActor = actorSpec && module.kind === 'model' ? generateActor({ summary: SUMMARY, rng: new Rng(99).fork(`actor:${module.actor}`), archetype: module.actor, personality, identity: personality.identity }) : null; engine.setLayerSpecs([{ module, params: hot, seed: 99, opacity: 1, blend: 'normal', palette: PALETTE, personality, actorSpec: hotActor, }]); engine.compositor.reset(); const luminance = []; for (let f = 600; f < 900; f++) { luminance.push(frameLuminance(engine.readPixels(engine.renderFrame(f)))); } const rate = peakFlashRate(luminance, 60); record(rate <= 3, 'flash rate', `${rate}/s at aggressive settings (ceiling 3)`); // --- identity response ----------------------------------------------- // The migration gate. A scene that declares it consumes the cast must // produce a DIFFERENT PICTURE when the song's cast changes — otherwise // `consumes` is a comment and the whole inversion is unverifiable at // library scale. Deliberately a much larger threshold than the trait // check: a trait may be honoured subtly, but content is the subject. for (const artifact of module.consumes || []) { const other = generatePersonality(SUMMARY, new Rng(9001)); // Two identities as far apart as the generator can make them. const alt = generateIdentity( { ...SUMMARY, meanFlatness: 0.6, meanCentroid: 0.85, bpm: 168 }, new Rng(31337), 6); // Force the always-visible ink decisions on. A field scene's whole // migration may be `inkValue`, and posterisation is off for most // identities — without pinning it the probe would sometimes hand the // scene two identities that ask it for the same picture and then // fail it for complying. alt.ink = { ...alt.ink, posterize: 4, fill: 'hatch', outline: 0.8, weight: 0.8 }; other.identity = alt; if (artifact === 'cast') { // The protagonist's geometry is read from the signature form. other.shape = { sides: 8, roundness: 0.02, elongation: 1.4, tilt: 0.9 }; } if (artifact === 'form') { // Pin an assembly that is unmistakably not the fallback profile: // a five-fold radial with a limb carved out of the body. A scene // that renders this the same as a plain extrusion is treating // the solid as a modifier, which is what the gate is for. alt.form = { symmetry: 'radial', symmetryN: 5, blend: 0.22, depth: 1.5, chorus: { count: 2, symmetry: 'mirror', symmetryN: 3, flat: 1.5, thin: 0.6 }, parts: [ { kind: 'prism', op: 'union', offset: [0, 0, 0], scale: [0.8, 0.7, 0.5], yaw: 0.4, pitch: 0.2, round: 0.1 }, { kind: 'capsule', op: 'blend', offset: [0.6, 0.25, 0.1], scale: [0.3, 0.5, 0.3], yaw: 1.1, pitch: -0.4, round: 0.2 }, { kind: 'torus', op: 'carve', offset: [0, 0.1, 0], scale: [0.55, 0.3, 0.4], yaw: 0.2, pitch: 0.8, round: 0 }, ], }; } const otherActor = actorFor(other); engine.setLayerSpecs([{ module, params: defaultValues(module), seed: 4242, opacity: 1, blend: 'normal', palette: PALETTE, personality: other, actorSpec: otherActor, }]); engine.compositor.reset(); const changed = frameMaxDelta(base, Uint8Array.from(engine.readPixels(engine.renderFrame(600)))); record(changed > 24, `consumes: ${artifact}`, `delta ${changed}/255 (floor 24)`); } // --- personality response -------------------------------------------- // Every declared trait must move the image; a trait declared and ignored // gets the scene cast in tracks it cannot express. for (const trait of module.traits || []) { const other = generatePersonality(SUMMARY, new Rng(9001)); if (trait === 'shape') other.shape = { sides: 6, roundness: 0.05, elongation: 1.3, tilt: 0.7 }; if (trait === 'camera') other.camera = { ...other.camera, driftAngle: 1.1, driftRate: 0.06, sway: 0.06, swayRate: 0.2, spin: 0.05, breathe: 0.05 }; if (trait === 'space') other.space = { horizon: 0.68, depth: 0.9, washAngle: 2.4, wash: 0.5 }; if (trait === 'style') other.style = { lineWeight: 0.95, softness: 0.9, texture: 0.5, symmetry: 4 }; engine.setLayerSpecs([{ module, params: defaultValues(module), seed: 4242, opacity: 1, blend: 'normal', palette: PALETTE, personality: other, }]); engine.compositor.reset(); const changed = frameMaxDelta(base, Uint8Array.from(engine.readPixels(engine.renderFrame(600)))); record(changed > 1, `trait: ${trait}`, `delta ${changed}/255`); } } finally { engine.dispose(); } return { ok, lines }; }