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>
257 lines
12 KiB
JavaScript
257 lines
12 KiB
JavaScript
// Measuring every visualizer, and writing the answers back into the repo.
|
||
//
|
||
// The library has always had two kinds of fact about a scene. Declared ones —
|
||
// family, traits, `consumes` — which say what the scene is FOR, and measured
|
||
// ones, which say what it actually does when rendered. Declared facts belong in
|
||
// the scene file. Measured facts do not: hand-written, they drift the moment a
|
||
// shader changes, and nine scenes declaring `surface: 'canvas'` while painting
|
||
// under a third of the frame is what that drift looks like.
|
||
//
|
||
// So the measured half lives in scenes/metadata.json, generated from here,
|
||
// tracked in git, and stamped with a fingerprint of everything that could
|
||
// change it. When the fingerprint stops matching, the numbers are stale and the
|
||
// phase 12 gate says so — the file is a cache of a render, and a cache nobody
|
||
// can tell is stale is worse than no cache.
|
||
//
|
||
// The measurement is the GALLERY's: six songs, sampled parameters, real
|
||
// identities and palettes — a scene as it is actually cast, not as it renders
|
||
// at default parameters. The difference is not academic. Salt Flat paints 65%
|
||
// of the frame at defaults and 32% across six real songs, and the generator
|
||
// chooses grounds with this number.
|
||
|
||
import { Engine } from '../engine/Engine.js';
|
||
import { scenes } from '../scenes/registry.js';
|
||
import { galleryContexts, renderScene, THUMB } from './gallery.js';
|
||
import { readsHistory } from '../params/schema.js';
|
||
import { GROUND_BIAS, groundTemperamentFrom, groundPersonalityFrom } from '../scenes/surface.js';
|
||
import metadata from '../scenes/metadata.json';
|
||
import { METRIC_KEYS } from './scene-metrics.js';
|
||
|
||
/**
|
||
* What invalidates the measurements.
|
||
*
|
||
* Deliberately NOT every file under src/, which is what the gallery cache
|
||
* fingerprints: that changes when the UI changes, and it would mark the
|
||
* metadata stale for edits that cannot move a single number. What can move one
|
||
* is the scenes themselves, the contract they are compiled against, the
|
||
* identities and palettes they are handed, and the metric definitions — so
|
||
* those, and nothing else.
|
||
*
|
||
* Globbed rather than listed wherever a whole directory qualifies, because the
|
||
* file that invalidates a measurement is exactly the one nobody remembers to
|
||
* add to a list.
|
||
*/
|
||
const SOURCES = {
|
||
...import.meta.glob('/src/scenes/**/*.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/checks/variety/descriptors.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/checks/variety/signature.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/checks/gallery.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/checks/scene-metrics.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/checks/metadata.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/engine/shader-contract.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/engine/agitation.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/engine/flash.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/engine/Layer.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/look/comfort.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/look/scene-filter.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/look/composition-validator.js', { query: '?raw', import: 'default', eager: true }),
|
||
...import.meta.glob('/src/scenes/surface.js', { query: '?raw', import: 'default', eager: true }),
|
||
...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 = 7;
|
||
|
||
export function metricsFingerprint() {
|
||
let h = 0x811c9dc5 >>> 0;
|
||
const mix = (str) => {
|
||
for (let i = 0; i < str.length; i++) {
|
||
h ^= str.charCodeAt(i);
|
||
h = Math.imul(h, 0x01000193) >>> 0;
|
||
}
|
||
};
|
||
mix(`schema:${SCHEMA}`);
|
||
for (const path of Object.keys(SOURCES).sort()) {
|
||
mix(path);
|
||
mix(SOURCES[path]);
|
||
}
|
||
return h.toString(16).padStart(8, '0');
|
||
}
|
||
|
||
/** Whether the checked-in metadata was measured from the code that is here now. */
|
||
export function metadataIsFresh() {
|
||
return metadata.fingerprint === metricsFingerprint();
|
||
}
|
||
|
||
/** Anything a viewer would read as painted rather than as backdrop. */
|
||
function litFraction(pixels) {
|
||
let lit = 0;
|
||
for (let i = 0; i < pixels.length; i += 4) {
|
||
if (pixels[i] + pixels[i + 1] + pixels[i + 2] > 90) lit++;
|
||
}
|
||
return lit / (pixels.length / 4);
|
||
}
|
||
|
||
const round = (x, places = 4) => Number(x.toFixed(places));
|
||
|
||
/** Mean of the six per-context descriptors, block by block. */
|
||
function meanProfile(descriptors) {
|
||
const out = {};
|
||
for (const block of Object.keys(descriptors[0])) {
|
||
const length = descriptors[0][block].length;
|
||
const acc = new Array(length).fill(0);
|
||
for (const d of descriptors) {
|
||
for (let i = 0; i < length; i++) acc[i] += d[block][i] / descriptors.length;
|
||
}
|
||
out[block] = acc.map((v) => round(v));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Measure the whole library.
|
||
*
|
||
* Per scene: how much frame it paints, how much it changes between songs, and
|
||
* its mean structural profile — the descriptor the variety harness compares
|
||
* videos with, averaged over the six renders. The profile is what makes this
|
||
* more than a list of numbers: two profiles can be compared, so the generator
|
||
* can ask whether a shot and the thing under it are the same picture twice.
|
||
*
|
||
* @returns {object} the metadata file's contents
|
||
*/
|
||
export function measureLibrary({ onScene = null, contexts = null } = {}) {
|
||
const ctx = contexts || galleryContexts(6);
|
||
// The same six songs, sampled the way a BED is. Coverage is mostly a
|
||
// function of a scene's parameters, so "how much does this paint" has two
|
||
// answers and the generator needs both: one for the budget, and one for
|
||
// whether it may be a ground at all. See GROUND_BIAS.
|
||
const bedCtx = ctx.map((c) => ({
|
||
...c,
|
||
bias: { ...c.bias, ...GROUND_BIAS },
|
||
personality: {
|
||
...groundPersonalityFrom(c.personality),
|
||
temperament: groundTemperamentFrom(c.personality.temperament),
|
||
},
|
||
}));
|
||
const engine = new Engine({ ...THUMB });
|
||
const out = {};
|
||
try {
|
||
const list = scenes.filter((m) => m.kind === 'fragment' || m.kind === 'model');
|
||
for (const module of list) {
|
||
const { thumbs, variety, byBlock, descriptors, error, agitation, flashPeak, motionPeak, motionMean, structPeak, structMean, metrics } = renderScene(engine, module, ctx);
|
||
const bed = renderScene(engine, module, bedCtx);
|
||
// Round ranged metrics to 3dp (4dp for agitation trio) — small, stable diffs
|
||
const roundedMetrics = {};
|
||
if (metrics) {
|
||
for (const [k, r] of Object.entries(metrics)) {
|
||
roundedMetrics[k] = {
|
||
mean: round(r.mean, 3), min: round(r.min, 3), max: round(r.max, 3),
|
||
std: round(r.std, 3), p10: round(r.p10, 3), p90: round(r.p90, 3),
|
||
};
|
||
}
|
||
}
|
||
out[module.name] = {
|
||
coverage: round(thumbs.reduce((s, px) => s + litFraction(px), 0) / thumbs.length, 3),
|
||
// The WORST of the six, not the mean. What a ground has to
|
||
// promise is a filled frame in the video it lands in, and the
|
||
// spread across identities is enormous: a track whose ink
|
||
// treatment is `hollow` draws outlines instead of fills, so a
|
||
// scene that paints 61% averaged over six songs paints 2% in
|
||
// the one that asked for outlines — measured, and it is how a
|
||
// section with a ground under it still rendered near-black.
|
||
// A mean cannot make a promise; a minimum can.
|
||
bedCoverage: round(Math.min(...bed.thumbs.map(litFraction)), 3),
|
||
bedCoverageMean: round(
|
||
bed.thumbs.reduce((s, px) => s + litFraction(px), 0) / bed.thumbs.length, 3),
|
||
variety: round(variety, 3),
|
||
blocks: Object.fromEntries(
|
||
Object.entries(byBlock).map(([b, v]) => [b, round(v, 3)])),
|
||
// Legacy flat (busiest-context) — compat with existing comfort.js:agitationOf
|
||
agitation: round(agitation || 0, 3),
|
||
flashPeak: round(flashPeak || 0, 2),
|
||
motionPeak: round(motionPeak || 0, 3),
|
||
motionMean: round(motionMean || 0, 3),
|
||
structPeak: round(structPeak || 0, 3),
|
||
structMean: round(structMean || 0, 3),
|
||
// Ranged metrics — 12+ keys × {mean,min,max,std,p10,p90}
|
||
// Vast preliminary library the director filters before casting
|
||
metrics: roundedMetrics,
|
||
// Declared, not measured, and carried here anyway: it is a fact
|
||
// about the scene that the composition rules read, and having
|
||
// every compositional input in one file is the point.
|
||
readsHistory: readsHistory(module),
|
||
profile: meanProfile(descriptors),
|
||
...(error ? { error } : {}),
|
||
};
|
||
if (onScene) onScene(Object.keys(out).length, list.length, module.name);
|
||
}
|
||
} finally {
|
||
engine.dispose();
|
||
}
|
||
|
||
return {
|
||
fingerprint: metricsFingerprint(),
|
||
schema: SCHEMA,
|
||
measured: new Date().toISOString().slice(0, 10),
|
||
contexts: ctx.map((c) => c.name),
|
||
scenes: out,
|
||
};
|
||
}
|
||
|
||
/** What moved against the checked-in file. */
|
||
export function metadataDrift(fresh) {
|
||
// Tolerates a missing or half-written file on purpose: this runs on the way
|
||
// to REPLACING it, and refusing to report because the thing being replaced
|
||
// is malformed is the least useful moment to be strict.
|
||
const previous = (metadata && metadata.scenes) || {};
|
||
const moved = [];
|
||
for (const [name, row] of Object.entries(fresh.scenes)) {
|
||
const was = previous[name];
|
||
if (!was) {
|
||
moved.push({ name, note: 'new' });
|
||
continue;
|
||
}
|
||
const delta = row.coverage - was.coverage;
|
||
if (Math.abs(delta) > 0.02) {
|
||
moved.push({
|
||
name, note: `coverage ${(was.coverage * 100).toFixed(0)}% → ${(row.coverage * 100).toFixed(0)}%`,
|
||
delta,
|
||
});
|
||
}
|
||
const aNew = row.agitation ?? 0;
|
||
const aWas = was.agitation ?? 0;
|
||
const aDelta = aNew - aWas;
|
||
if (Math.abs(aDelta) > 0.08) {
|
||
moved.push({
|
||
name, note: `agitation ${aWas.toFixed(2)} → ${aNew.toFixed(2)}`,
|
||
delta: aDelta,
|
||
});
|
||
}
|
||
}
|
||
const gone = Object.keys(previous).filter((n) => !fresh.scenes[n]);
|
||
return {
|
||
moved: moved.sort((a, b) => Math.abs(b.delta || 0) - Math.abs(a.delta || 0)),
|
||
gone,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Ask the dev server to write the file back into the source tree.
|
||
*
|
||
* Dev-only by construction — the endpoint is a middleware in vite.config.js. A
|
||
* built page has no source tree to write to, and failing there is correct.
|
||
*/
|
||
export async function writeMetadata(fresh) {
|
||
const response = await fetch('/__metadata', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(fresh, null, 2) + '\n',
|
||
});
|
||
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
|
||
return response.text();
|
||
}
|