// 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'; /** * 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/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/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/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 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'); for (const module of list) { const { thumbs, variety, byBlock, descriptors, error } = renderScene(engine, module, ctx); const bed = renderScene(engine, module, bedCtx); 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)])), // 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 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(); }