diff --git a/flow-state/src/checks/gallery.js b/flow-state/src/checks/gallery.js index 9714256..da408b9 100644 --- a/flow-state/src/checks/gallery.js +++ b/flow-state/src/checks/gallery.js @@ -27,6 +27,7 @@ import { describeIdentity } from '../look/Identity.js'; import { frameDescriptor, motionDescriptor } from './variety/descriptors.js'; import { frameLuminance, frameVariance } from '../engine/hash.js'; import { descriptorDistance, STRUCTURAL } from './variety/signature.js'; +import { generateActor } from '../actors/ActorGenerator.js'; const THUMB = { width: 256, height: 144 }; @@ -89,6 +90,11 @@ export function renderScene(engine, module, contexts) { const rng = new Rng(hashString(`${module.name}:${ctx.name}`)); const params = sampleValues(module, rng, ctx.bias, ctx.personality.temperament); + const actorRng = new Rng(rng.fork(`actor:${module.actor || 'model'}`).seed); + const summary = ctx.track.summary; + const actorSpec = module.kind === 'model' && module.actor + ? generateActor({ summary, rng: actorRng, archetype: module.actor, personality: ctx.personality, identity: ctx.personality.identity }) + : null; engine.setLayerSpecs([{ module, @@ -98,6 +104,7 @@ export function renderScene(engine, module, contexts) { blend: 'normal', palette: ctx.palette, personality: ctx.personality, + actorSpec, }]); engine.compositor.reset(); // A few frames of warm-up so anything with state is past its first frame. @@ -181,7 +188,7 @@ export function renderScene(engine, module, contexts) { export async function buildGallery({ contexts, onScene, only = null } = {}) { const list = only ? scenes.filter((m) => only.includes(m.name)) - : scenes.filter((m) => m.kind === 'fragment'); + : scenes.filter((m) => m.kind === 'fragment' || m.kind === 'model'); const engine = new Engine({ ...THUMB }); const rows = []; diff --git a/flow-state/src/checks/metadata.js b/flow-state/src/checks/metadata.js index 2539dd6..9a87cfc 100644 --- a/flow-state/src/checks/metadata.js +++ b/flow-state/src/checks/metadata.js @@ -50,11 +50,12 @@ const SOURCES = { ...import.meta.glob('/src/look/Identity.js', { query: '?raw', import: 'default', eager: true }), ...import.meta.glob('/src/look/Personality.js', { query: '?raw', import: 'default', eager: true }), ...import.meta.glob('/src/look/palette.js', { query: '?raw', import: 'default', eager: true }), + ...import.meta.glob('/src/actors/**/*.js', { query: '?raw', import: 'default', eager: true }), ...import.meta.glob('/src/audio/songbank.js', { query: '?raw', import: 'default', eager: true }), }; /** The version of the measurement itself. Bump to force a refresh of everything. */ -export const SCHEMA = 4; +export const SCHEMA = 5; export function metricsFingerprint() { let h = 0x811c9dc5 >>> 0; @@ -130,7 +131,7 @@ export function measureLibrary({ onScene = null, contexts = null } = {}) { const engine = new Engine({ ...THUMB }); const out = {}; try { - const list = scenes.filter((m) => m.kind === 'fragment'); + const list = scenes.filter((m) => m.kind === 'fragment' || m.kind === 'model'); for (const module of list) { const { thumbs, variety, byBlock, descriptors, error } = renderScene(engine, module, ctx); const bed = renderScene(engine, module, bedCtx); diff --git a/flow-state/src/checks/scene-gate.js b/flow-state/src/checks/scene-gate.js index 09fd333..f1c1073 100644 --- a/flow-state/src/checks/scene-gate.js +++ b/flow-state/src/checks/scene-gate.js @@ -24,6 +24,7 @@ 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], @@ -63,13 +64,26 @@ export function runSceneGate(name) { 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, + 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 ------------------------------------------------------- @@ -93,10 +107,13 @@ export function runSceneGate(name) { let closest = 255; let closestName = ''; for (const other of scenes) { - if (other === module || other.kind !== 'fragment') continue; + 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, + opacity: 1, blend: 'normal', palette: PALETTE, personality, actorSpec: otherActor, }]); engine.compositor.reset(); const d = frameMaxDelta(base, Uint8Array.from(engine.readPixels(engine.renderFrame(600)))); @@ -123,8 +140,11 @@ export function runSceneGate(name) { // --- 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, + module, params: hot, seed: 99, opacity: 1, blend: 'normal', palette: PALETTE, personality, actorSpec: hotActor, }]); engine.compositor.reset(); const luminance = []; @@ -173,9 +193,10 @@ export function runSceneGate(name) { }; } + const otherActor = actorFor(other); engine.setLayerSpecs([{ module, params: defaultValues(module), seed: 4242, - opacity: 1, blend: 'normal', palette: PALETTE, personality: other, + opacity: 1, blend: 'normal', palette: PALETTE, personality: other, actorSpec: otherActor, }]); engine.compositor.reset(); const changed = frameMaxDelta(base, diff --git a/flow-state/src/checks/scene-sweep.js b/flow-state/src/checks/scene-sweep.js index c5745b6..84f3f1e 100644 --- a/flow-state/src/checks/scene-sweep.js +++ b/flow-state/src/checks/scene-sweep.js @@ -33,6 +33,7 @@ import { sampleValues } from '../params/schema.js'; import { frameDescriptor, motionDescriptor } from './variety/descriptors.js'; import { descriptorDistance, STRUCTURAL } from './variety/signature.js'; import { THUMB } from './gallery.js'; +import { generateActor } from '../actors/ActorGenerator.js'; /** How many songs the bank can offer. The grid cannot ask for more. */ export const MAX_SONGS = SONGS.length; @@ -167,10 +168,13 @@ export async function sweepScene({ engine, module, cells, onCell }) { const rng = new Rng(ctx.seed ^ hashString(module.name)); const params = sampleValues(module, rng, ctx.bias, ctx.personality.temperament); + const actorSpec = module.kind === 'model' && module.actor + ? generateActor({ summary: ctx.track.summary, rng: rng.fork(`actor:${module.actor}`), archetype: module.actor, personality: ctx.personality, identity: ctx.personality.identity }) + : null; engine.setLayerSpecs([{ module, params, seed: rng.int(0, 0x7fffffff), opacity: 1, blend: 'normal', - palette: ctx.palette, personality: ctx.personality, + palette: ctx.palette, personality: ctx.personality, actorSpec, }]); engine.compositor.reset(); for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f); diff --git a/flow-state/src/engine/Engine.js b/flow-state/src/engine/Engine.js index 3c2236a..3557aa9 100644 --- a/flow-state/src/engine/Engine.js +++ b/flow-state/src/engine/Engine.js @@ -55,9 +55,15 @@ export class Engine { setLayerSpecs(specs) { this.ownedLayers.forEach((l) => l.dispose()); const layers = specs.map((s) => { + // actorSpec is part of the spec for kind:'model' — passed through to + // ModelLayer at construction so build() sees it. For checks/gallery the + // caller generates it deterministically via ActorGenerator; Show's + // ArcDriver already did the same via look.actors. const layer = createLayer(s.module, s); if (s.palette) layer.setPalette(s.palette); if (s.personality) layer.setPersonality(s.personality); + if (s.actorSpec && layer.setActor) layer.setActor(s.actorSpec); + if (s.framing) layer.setFraming(s.framing); return layer; }); this.ownedLayers = layers;