diff --git a/flow-state/gallery.html b/flow-state/gallery.html index f24bec6..0c2437c 100644 --- a/flow-state/gallery.html +++ b/flow-state/gallery.html @@ -55,6 +55,12 @@ .row.flat .meter i { background: #ef4444; } .row.thin .meter i { background: #eab308; } .blocks { color: #5c6577; font-size: 11px; } + /* Coverage sits next to the score because the two explain each other: a + scene painting 2% of the frame scores well for variety and is thin to + watch alone. */ + .surf { font-size: 11px; padding: 1px 6px; border-radius: 3px; } + .surf.canvas { background: #16232b; color: #7dd3fc; } + .surf.composable { background: #2a1f30; color: #d8b4fe; } .thumbs { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; } .thumbs figure { margin: 0; } .thumbs canvas { width: 100%; display: block; background: #05070a; border-radius: 2px; aspect-ratio: 16 / 9; } @@ -76,7 +82,12 @@ A scene whose six frames are interchangeable is one the song cannot change. Sorted least-varied first, scored on the same structural descriptor the variety harness uses, with colour excluded so six palettes cannot disguise - one image. ← all debug tools + one image. + Coverage is shown beside each score because the two explain + each other: the highest-scoring scenes are often a few bright elements on + black, which is varied and thin to watch on its own. Those are + composable — they are meant to sit on top of a canvas. + ← all debug tools

@@ -86,6 +97,7 @@ + checking cache…
@@ -119,6 +131,7 @@ function draw() { const mode = sortSel.value; const sorted = rows.slice().sort((a, b) => ( mode === 'name' ? a.name.localeCompare(b.name) + : mode === 'coverage' ? (a.coverage || 0) - (b.coverage || 0) : mode === 'family' ? (a.family.localeCompare(b.family) || a.variety - b.variety) : a.variety - b.variety)); @@ -142,6 +155,7 @@ function draw() {
${row.name} ${row.family}${row.role === 'accent' ? ' · accent' : ''} + ${row.surface || '?'} ${((row.coverage || 0) * 100).toFixed(0)}% ${row.variety.toFixed(3)} @@ -174,6 +188,7 @@ function summarise(built) { const broken = rows.filter((r) => r.error).length; return `${rows.length} scenes · ${flat} below the ${MIN_VARIETY} bar` + (broken ? ` · ${broken} BROKEN` : '') + + ` · ${rows.filter((r) => (r.coverage || 0) < 0.3).length} paint under 30% of the frame` + (built ? ` · built ${new Date(built).toLocaleString()}` : ''); } @@ -204,6 +219,7 @@ async function build(fingerprint) { rows.push({ name: row.module.name, family: row.module.family, role: row.module.role || 'stage', variety: row.variety, + coverage: row.coverage, surface: row.surface, byBlock: row.byBlock, error: row.error, blobs: await Promise.all(row.thumbs.map( (px) => pixelsToBlob(px, THUMB.width, THUMB.height))), diff --git a/flow-state/src/checks/gallery.js b/flow-state/src/checks/gallery.js index 2ea2844..781040f 100644 --- a/flow-state/src/checks/gallery.js +++ b/flow-state/src/checks/gallery.js @@ -17,7 +17,7 @@ import { Engine } from '../engine/Engine.js'; import { scenes } from '../scenes/registry.js'; -import { sampleValues } from '../params/schema.js'; +import { sampleValues, surfaceOf } from '../params/schema.js'; import { Rng, hashString } from '../engine/rng.js'; import { featureProviderFor } from '../audio/FeatureTrack.js'; import { songBank } from '../audio/songbank.js'; @@ -150,6 +150,20 @@ export function renderScene(engine, module, contexts) { } for (const b of Object.keys(byBlock)) byBlock[b] /= pairs || 1; + // How much of the frame this scene paints, averaged over the six. Free — + // the pixels are already here — and it is the number that explains the + // ranking: a scene covering 2% of the frame is a few bright things on + // black, which scores well for variety and is thin to watch on its own. + // Whether that is a problem depends on whether it is ever layered. + let covered = 0; + for (const px of thumbs) { + let lit = 0; + for (let i = 0; i < px.length; i += 4) { + if (px[i] + px[i + 1] + px[i + 2] > 90) lit++; + } + covered += lit / (px.length / 4) / thumbs.length; + } + // A dead shader scores zero on every block, which is indistinguishable from // a very boring scene if you only read the number — and it happened: the // subject helpers were declared above the ink they call, the whole preamble @@ -162,6 +176,8 @@ export function renderScene(engine, module, contexts) { return { thumbs, variety: pairs ? total / pairs : 0, + coverage: covered, + surface: surfaceOf(module), byBlock, error: dead ? `renders nothing — luminance ${lum.toFixed(4)}, variance ${variance.toFixed(4)}. ` + diff --git a/flow-state/src/checks/phase12.js b/flow-state/src/checks/phase12.js index b4e972b..6b7a867 100644 --- a/flow-state/src/checks/phase12.js +++ b/flow-state/src/checks/phase12.js @@ -20,6 +20,10 @@ import { song } from '../audio/songbank.js'; import { Show } from '../Show.js'; import { generateLook } from '../look/LookGenerator.js'; import { scenes } from '../scenes/registry.js'; +import { surfaceOf } from '../params/schema.js'; +import { Engine } from '../engine/Engine.js'; +import { defaultValues } from '../params/schema.js'; +import { featureProviderFor } from '../audio/FeatureTrack.js'; import { frameDescriptor, rotate90, recolour } from './variety/descriptors.js'; import { descriptorDistance, signatureDistance } from './variety/signature.js'; import { @@ -178,3 +182,65 @@ check(12, 'seed variety · different seeds render structurally different videos' (problems.length ? problems.join(' · ') + ' — ' : '') + `separation ${r.separation.toFixed(2)} · ${blocks}`); }, { slow: true }); + + +/** + * How much of the frame a scene actually paints. + * + * A composable scene has to leave room for what it sits on. One that claims to + * and covers the frame anyway will hide its background completely, which is the + * failure the declaration exists to prevent — and it is not something you can + * see from the source, only from the render. + */ +function coverageOf(engine, module) { + engine.setLayerSpecs([{ + module, params: defaultValues(module), seed: 4242, + opacity: 1, blend: 'normal', + palette: [[0.05, 0.05, 0.1], [0.9, 0.3, 0.5], [0.3, 0.8, 0.9], [0.95, 0.9, 0.4]], + }]); + engine.compositor.reset(); + for (let f = 594; f < 600; f++) engine.renderFrame(f); + const px = Uint8Array.from(engine.readPixels(engine.renderFrame(600))); + let lit = 0; + for (let i = 0; i < px.length; i += 4) { + // Anything a viewer would read as painted rather than as backdrop. + if (px[i] + px[i + 1] + px[i + 2] > 90) lit++; + } + return lit / (px.length / 4); +} + +check(12, 'surface · a composable scene leaves room for what it sits on', () => { + const engine = new Engine({ width: 128, height: 72 }); + const track = varietyTrack(); + engine.timeline.setDuration(track.duration); + engine.setFeatureProvider(featureProviderFor(track)); + + const wrong = []; + const measured = []; + try { + for (const module of scenes) { + if (module.kind !== 'fragment') continue; + const cover = coverageOf(engine, module); + const surface = surfaceOf(module); + measured.push({ name: module.name, surface, cover }); + // A composable scene painting most of the frame hides its + // background; a canvas leaving it nearly empty is a canvas in name + // only and will read as a black frame when nothing is under it. + if (surface === 'composable' && cover > 0.55) wrong.push(`${module.name} claims composable but covers ${(cover * 100).toFixed(0)}%`); + if (surface === 'canvas' && cover < 0.08) wrong.push(`${module.name} claims canvas but covers only ${(cover * 100).toFixed(0)}%`); + } + } finally { + engine.dispose(); + } + + // Reported whatever the verdict, because the useful output of this check is + // the list itself — it is how the library gets labelled in the first place. + const sorted = measured.sort((a, b) => a.cover - b.cover); + window.__COVERAGE__ = sorted; + const sparse = sorted.filter((m) => m.cover < 0.3).length; + + return expect(wrong.length === 0, + (wrong.length ? wrong.slice(0, 4).join(' · ') + ' — ' : '') + + `${measured.length} scenes · ${sparse} paint under 30% of the frame ` + + `· sparsest ${sorted[0].name} at ${(sorted[0].cover * 100).toFixed(0)}%`); +}, { slow: true }); diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js index cc78a8c..7956e4e 100644 --- a/flow-state/src/look/LookGenerator.js +++ b/flow-state/src/look/LookGenerator.js @@ -7,7 +7,7 @@ import { Rng, hashSamples } from '../engine/rng.js'; import { AudioPalette, generateUsablePalette } from './palette.js'; import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js'; -import { sampleValues, defaultValues } from '../params/schema.js'; +import { sampleValues, defaultValues, surfaceOf } from '../params/schema.js'; import { planShots } from './shots.js'; import { generatePersonality, sceneHonours, signatureWeight, describePersonality, @@ -296,12 +296,22 @@ function buildStack(module, accentRoster, overlayRoster, bias, rng, temperament) // Roughly a third of stacks on busy material, rarely on quiet material, and // never on a background that is itself a full-frame glitch — two competing // corruption passes is noise, not depth. + // Layering is much more likely now that what goes on top is guaranteed to + // leave the shot underneath visible. const overlayChance = module.family === 'glitch' - ? 0.05 - : 0.12 + bias.energy * 0.35 + (temperament ? Math.max(0, temperament.detail) * 0.2 : 0); + ? 0.1 + : 0.3 + bias.energy * 0.4 + (temperament ? Math.max(0, temperament.detail) * 0.2 : 0); - const overlays = overlayRoster.filter((m) => m.family !== module.family && m.name !== module.name); - if (overlays.length && rng.bool(Math.min(0.6, overlayChance))) { + // Only COMPOSABLE scenes go on top. A second canvas over the first is two + // pictures fighting rather than one picture with depth, and it is what the + // library did for as long as every scene was treated as interchangeable. + // + // The other half of the trade: a composable scene alone is a few bright + // things on black, which scores well for variety and is thin to watch. + // Layering is what turns both halves into one image. + const overlays = overlayRoster.filter((m) => m.name !== module.name + && surfaceOf(m) === 'composable'); + if (overlays.length && rng.bool(Math.min(0.8, overlayChance))) { const overlay = rng.pick(overlays); // Screen and add keep the background readable underneath; softlight and // overlay tint it instead. All four preserve the shot; 'normal' would diff --git a/flow-state/src/params/schema.js b/flow-state/src/params/schema.js index 38ca0da..4c0aea4 100644 --- a/flow-state/src/params/schema.js +++ b/flow-state/src/params/schema.js @@ -62,6 +62,31 @@ export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse']; */ export const ARTIFACT_NAMES = ['cast', 'ink', 'staging']; +/** + * What a scene is for, compositionally. + * + * canvas fills the frame. Belongs underneath, and two of them stacked is + * two pictures fighting rather than one picture with depth. + * composable mostly empty by design. Reads as elements ON something, and + * shown alone it is a few bright things on black — which scores + * well for variety and is thin to watch. + * + * The distinction was implicit in `role: 'accent'` and that was not enough: it + * marked the depth passes and said nothing about the sixty scenes that are all + * treated as interchangeable backgrounds despite half of them being sparse. + * + * Verified rather than trusted — see the coverage gate in checks/phase12. A + * scene that claims to be composable and paints the whole frame will cover + * whatever it is layered over. + */ +export const SURFACES = ['canvas', 'composable']; + +/** What a scene is, defaulting to the behaviour it had before this existed. */ +export function surfaceOf(module) { + if (module.surface) return module.surface; + return module.role === 'accent' ? 'composable' : 'canvas'; +} + /** * Personality traits a scene can honour. See look/Personality.js. * @@ -240,6 +265,9 @@ export function validateModule(module) { if (!TRAIT_NAMES.includes(t)) errors.push(`${id}: unknown trait '${t}'`); } } + if (module.surface !== undefined && !SURFACES.includes(module.surface)) { + errors.push(`${id}: unknown surface '${module.surface}' — one of ${SURFACES.join('/')}`); + } if (module.consumes !== undefined) { if (!Array.isArray(module.consumes)) { errors.push(`${id}: \`consumes\` must be an array of ${ARTIFACT_NAMES.join('/')}`);