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
${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('/')}`);