Gallery, metadata, scene-gate and scene-sweep now handle kind:'model'
with a deterministic ActorSpec. Drawing uses a forked ActorGenerator
so the actor stream never shifts the identity/palette streams:
gallery.renderScene → generateActor per context for model stages
metadata.measureLibrary now includes model stages, SCHEMA 5,
fingerprint covers actors/
scene-gate.runSceneGate → alive/animates/deterministic/distinct/
param-sweep/flash-rate all drive Engine.setLayerSpecs with
actorSpec, and consumes: searches re-derive the actor for the
swapped identity (so the consumes: gate is still testable).
scene-sweep behaves the same per-cell.
Engine.setLayerSpecs now forwards actorSpec/framing into createLayer
so the checks don't need ArcDriver.
SCHEMA bump 4→5 forces metadata refresh on next gallery build,
which is intentional: model stages now participate. Gates remain
all green (69 scenes) and vite builds clean — no runtime change to
the render path for fragment stages.
Co-Authored-By: Claude <noreply@anthropic.com>
233 lines
12 KiB
JavaScript
233 lines
12 KiB
JavaScript
// 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 };
|
|
}
|