Comfort: agitation ceiling, composition validator and whole-screen caps
Whole-screen flashing/coherent motion is now a gated comfort signal rather than a hunt-and-tweak. Three-arm agitation (flash/3, tile-motion/0.32, structure/0.12) over a 1s floating window, worst window wins — engine/agitation.js. Policy centralised in look/comfort.js: director comfort appetite, per-scene agitationOf proxy, agitationWeight lean and stackAgitation. Canvas beat/flux capped in Layer.resolveParams (0.15/0.25, spike→smooth) so one contract change tames every full-frame scene; lint warns above the cap. Selection now filters before casting (look/scene-filter.js, 12+ ranged metrics from checks/scene-metrics.js, SCHEMA 7) and validates after stacking (look/composition-validator.js) with a deterministic retune: drop overlay → swap quiet composable → reselect filtered shot. Fix validator attempt-3 ground-layer bug that crashed ?song=dusk (LookGenerator now passes module, not layer). Eight canvas shaders window their beat/flux (focusField + per-seed hash mask) and report the scaled amount to Layer so the cap stays authoritative: scan-tear, gate-corridor, analog-wow, block-mosh, metaballs, pitch-shatter, scale-mosaic, tide-rings. Checks: scene-gate comfort gate (agitation ≤ 0.70), filmstrip/gallery meters + per-scene drawer, metadata SCHEMA 7 with ranged metrics and fingerprint over agitation/comfort sources. main.js surfaces COMPOSITION_REFUSED as a user-facing error. Lint: 114 files clean, 71 scenes ok. All 17 songs generate looks. Build passes. metadata.json refresh deferred to gallery → Refresh metadata (SCHEMA 7 writer in vite.config). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,8 @@ 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';
|
||||
import { agitationScore } from '../engine/agitation.js';
|
||||
import { probesForContext, ranged, METRIC_KEYS } from './scene-metrics.js';
|
||||
|
||||
const THUMB = { width: 256, height: 144 };
|
||||
|
||||
@@ -162,6 +164,108 @@ export function renderScene(engine, module, contexts) {
|
||||
const variance = frameVariance(thumbs[0]);
|
||||
const dead = lum < 0.002 || variance < 0.001;
|
||||
|
||||
// --- per-visualizer 12+ ranged metrics + legacy flat agitation --------
|
||||
// Legacy flat agitation/flashPeak/motionPeak/structPeak: busiest context
|
||||
// only (cheapest, what existing gallery.html reads). Ranged metrics:
|
||||
// 6 contexts × 90-frame strip each → {mean,min,max,std,p10,p90} per key
|
||||
// (see src/checks/scene-metrics.js). Vast preliminary library the director
|
||||
// filters BEFORE casting; some songs simply won't use certain scenes.
|
||||
let agitation = 0;
|
||||
let flashPeak = 0;
|
||||
let motionPeak = 0;
|
||||
let motionMean = 0;
|
||||
let structPeak = 0;
|
||||
let structMean = 0;
|
||||
let metrics = null;
|
||||
if (!dead && contexts && contexts.length) {
|
||||
try {
|
||||
// Busiest context = most energetic bias — legacy flat value.
|
||||
let probe = contexts[0];
|
||||
for (const c of contexts) {
|
||||
if ((c.bias.energy || 0) > (probe.bias.energy || 0)) probe = c;
|
||||
}
|
||||
engine.timeline.setDuration(probe.track.duration);
|
||||
engine.setFeatureProvider(featureProviderFor(probe.track));
|
||||
const arng = new Rng(hashString(`${module.name}:agitation:${probe.name}`));
|
||||
const aparams = sampleValues(module, arng, probe.bias, probe.personality.temperament);
|
||||
const aactorRng = new Rng(arng.fork(`actor:${module.actor || 'model'}`).seed);
|
||||
const aactorSpec = module.kind === 'model' && module.actor
|
||||
? generateActor({ summary: probe.track.summary, rng: aactorRng, archetype: module.actor, personality: probe.personality, identity: probe.personality.identity })
|
||||
: null;
|
||||
engine.setLayerSpecs([{
|
||||
module,
|
||||
params: aparams,
|
||||
seed: arng.int(0, 0x7fffffff),
|
||||
opacity: 1,
|
||||
blend: 'normal',
|
||||
palette: probe.palette,
|
||||
personality: probe.personality,
|
||||
actorSpec: aactorSpec,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
for (let f = probe.frame - 6; f < probe.frame; f++) engine.renderFrame(f);
|
||||
const STRIP = 90; // 1.5 s @ 60 fps — catches Voronoi recut between phrase windows
|
||||
const stripFrames = [];
|
||||
const luminances = [];
|
||||
for (let f = probe.frame; f < probe.frame + STRIP; f++) {
|
||||
const pix = Uint8Array.from(engine.readPixels(engine.renderFrame(f)));
|
||||
stripFrames.push(pix);
|
||||
luminances.push(frameLuminance(pix));
|
||||
}
|
||||
const score = agitationScore(luminances, stripFrames, 60, { width: THUMB.width, height: THUMB.height });
|
||||
agitation = score.agitation;
|
||||
flashPeak = score.peakFlashRate;
|
||||
motionPeak = score.peakMotion;
|
||||
motionMean = score.meanMotion;
|
||||
structPeak = score.peakStructure;
|
||||
structMean = score.meanStructure;
|
||||
} catch {
|
||||
// leave agitation at 0 — gallery will show 0.000 rather than crash
|
||||
}
|
||||
// Ranged metrics across all 6 contexts (one 90-frame strip each, just once)
|
||||
try {
|
||||
const perContext = [];
|
||||
for (const ctx of contexts) {
|
||||
engine.timeline.setDuration(ctx.track.duration);
|
||||
engine.setFeatureProvider(featureProviderFor(ctx.track));
|
||||
const crng = new Rng(hashString(`${module.name}:metrics:${ctx.name}`));
|
||||
const cparams = sampleValues(module, crng, ctx.bias, ctx.personality.temperament);
|
||||
const cActorRng = new Rng(crng.fork(`actor:${module.actor || 'model'}`).seed);
|
||||
const cActorSpec = module.kind === 'model' && module.actor
|
||||
? generateActor({ summary: ctx.track.summary, rng: cActorRng, archetype: module.actor, personality: ctx.personality, identity: ctx.personality.identity })
|
||||
: null;
|
||||
engine.setLayerSpecs([{
|
||||
module,
|
||||
params: cparams,
|
||||
seed: crng.int(0, 0x7fffffff),
|
||||
opacity: 1,
|
||||
blend: 'normal',
|
||||
palette: ctx.palette,
|
||||
personality: ctx.personality,
|
||||
actorSpec: cActorSpec,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f);
|
||||
const MSTRIP = 90;
|
||||
const mFrames = [];
|
||||
const mLums = [];
|
||||
for (let f = ctx.frame; f < ctx.frame + MSTRIP; f++) {
|
||||
const px = Uint8Array.from(engine.readPixels(engine.renderFrame(f)));
|
||||
mFrames.push(px);
|
||||
mLums.push(frameLuminance(px));
|
||||
}
|
||||
perContext.push(probesForContext({ luminances: mLums, stripFrames: mFrames, width: THUMB.width, height: THUMB.height }));
|
||||
}
|
||||
metrics = {};
|
||||
for (const key of METRIC_KEYS) {
|
||||
const vals = perContext.map((p) => p[key]).filter((v) => typeof v === 'number' && Number.isFinite(v));
|
||||
metrics[key] = ranged(vals);
|
||||
}
|
||||
} catch {
|
||||
// metrics stays null — gallery shows legacy flat values
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
thumbs,
|
||||
// The six per-context descriptors, so a caller can average them into a
|
||||
@@ -173,6 +277,13 @@ export function renderScene(engine, module, contexts) {
|
||||
variety: pairs ? total / pairs : 0,
|
||||
coverage: covered,
|
||||
byBlock,
|
||||
agitation,
|
||||
flashPeak,
|
||||
motionPeak,
|
||||
motionMean,
|
||||
structPeak,
|
||||
structMean,
|
||||
metrics,
|
||||
error: dead
|
||||
? `renders nothing — luminance ${lum.toFixed(4)}, variance ${variance.toFixed(4)}. ` +
|
||||
'A failed shader compile scores 0.000 on every block; check the console for GLSL errors.'
|
||||
|
||||
Reference in New Issue
Block a user