music-video-gen/flow-state/src/checks/scene-gate.js
Dejvino 93a5dc8437 Epic 5 Phase 2.3 — instrument Assembly for measurement
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>
2026-08-20 18:38:21 +02:00

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 };
}