Composable vs canvas: say which scenes are meant to sit on top of others

The top of the gallery is full of black frames with a bright element on them.
That is genuinely varied and thin to watch, and the reason it ranks is that the
gallery previews every scene ALONE — which is not how anyone watches one.

Measured, the split is not marginal: 37 of 66 scenes paint under 30% of the
frame, and the sparsest are exactly the ones topping the ranking. Storm Rift and
Pendulum Trace paint 0%, Droste Feedback and Spectrum Sculpture 2%. At the other
end Block Mosh covers 98% and Scan Tear 96%.

So a scene now declares a `surface`. A canvas fills the frame and belongs
underneath; two of them stacked is two pictures fighting rather than one picture
with depth. A composable scene is mostly empty by design and reads as elements
ON something. The distinction was implicit in `role: 'accent'`, which marked the
depth passes and said nothing about the sixty scenes treated as interchangeable
backgrounds while half of them were sparse.

Verified rather than trusted, in the pattern the rest of the project uses: a
gate renders every scene and measures what it actually paints, failing a
composable that covers more than 55% — it would hide whatever it sits on — and a
canvas that covers less than 8%, which is a canvas in name only. The gate also
prints the whole coverage table, since that list is how the library gets
labelled in the first place.

Casting uses it: only composable scenes are drawn as overlays, and because what
goes on top is now guaranteed to leave the shot underneath visible, layering
happens far more often — the overlay chance went from 0.12 to 0.3 at its base.

The gallery shows coverage and surface beside each score, sorts by sparsest, and
says in the header why the two numbers explain each other.

Nothing is labelled `composable` yet. Every scene keeps the behaviour it had
through `surfaceOf`, which defaults to canvas unless the scene is an accent, so
this commit changes the mechanism and not the output. The coverage table is what
the labelling should be read off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-18 18:20:24 +02:00
parent c099501ead
commit eda8ac3810
5 changed files with 143 additions and 7 deletions

View File

@ -55,6 +55,12 @@
.row.flat .meter i { background: #ef4444; } .row.flat .meter i { background: #ef4444; }
.row.thin .meter i { background: #eab308; } .row.thin .meter i { background: #eab308; }
.blocks { color: #5c6577; font-size: 11px; } .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 { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
.thumbs figure { margin: 0; } .thumbs figure { margin: 0; }
.thumbs canvas { width: 100%; display: block; background: #05070a; border-radius: 2px; aspect-ratio: 16 / 9; } .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. A scene whose six frames are interchangeable is one the song cannot change.
Sorted least-varied first, scored on the same structural descriptor the Sorted least-varied first, scored on the same structural descriptor the
variety harness uses, with colour excluded so six palettes cannot disguise variety harness uses, with colour excluded so six palettes cannot disguise
one image. <a href="/debug.html">← all debug tools</a> one image.
<strong>Coverage</strong> 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
<em>composable</em> — they are meant to sit on top of a <em>canvas</em>.
<a href="/debug.html">← all debug tools</a>
</p> </p>
<div class="bar" id="bar"> <div class="bar" id="bar">
@ -86,6 +97,7 @@
<option value="variety">sort: least varied first</option> <option value="variety">sort: least varied first</option>
<option value="name">sort: by name</option> <option value="name">sort: by name</option>
<option value="family">sort: by family</option> <option value="family">sort: by family</option>
<option value="coverage">sort: sparsest first</option>
</select> </select>
<span id="status">checking cache…</span> <span id="status">checking cache…</span>
<div class="ctx" id="ctx"></div> <div class="ctx" id="ctx"></div>
@ -119,6 +131,7 @@ function draw() {
const mode = sortSel.value; const mode = sortSel.value;
const sorted = rows.slice().sort((a, b) => ( const sorted = rows.slice().sort((a, b) => (
mode === 'name' ? a.name.localeCompare(b.name) 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) : mode === 'family' ? (a.family.localeCompare(b.family) || a.variety - b.variety)
: a.variety - b.variety)); : a.variety - b.variety));
@ -142,6 +155,7 @@ function draw() {
<div class="head"> <div class="head">
<span class="name">${row.name}</span> <span class="name">${row.name}</span>
<span class="fam">${row.family}${row.role === 'accent' ? ' · accent' : ''}</span> <span class="fam">${row.family}${row.role === 'accent' ? ' · accent' : ''}</span>
<span class="surf ${row.surface}" title="paints ${((row.coverage || 0) * 100).toFixed(0)}% of the frame">${row.surface || '?'} ${((row.coverage || 0) * 100).toFixed(0)}%</span>
<span class="score"> <span class="score">
<span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i><b style="left:${MIN_VARIETY * 500}%" title="minimum ${MIN_VARIETY}"></b></span> <span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i><b style="left:${MIN_VARIETY * 500}%" title="minimum ${MIN_VARIETY}"></b></span>
${row.variety.toFixed(3)} ${row.variety.toFixed(3)}
@ -174,6 +188,7 @@ function summarise(built) {
const broken = rows.filter((r) => r.error).length; const broken = rows.filter((r) => r.error).length;
return `${rows.length} scenes · ${flat} below the ${MIN_VARIETY} bar` + return `${rows.length} scenes · ${flat} below the ${MIN_VARIETY} bar` +
(broken ? ` · ${broken} BROKEN` : '') + (broken ? ` · ${broken} BROKEN` : '') +
` · ${rows.filter((r) => (r.coverage || 0) < 0.3).length} paint under 30% of the frame` +
(built ? ` · built ${new Date(built).toLocaleString()}` : ''); (built ? ` · built ${new Date(built).toLocaleString()}` : '');
} }
@ -204,6 +219,7 @@ async function build(fingerprint) {
rows.push({ rows.push({
name: row.module.name, family: row.module.family, name: row.module.name, family: row.module.family,
role: row.module.role || 'stage', variety: row.variety, role: row.module.role || 'stage', variety: row.variety,
coverage: row.coverage, surface: row.surface,
byBlock: row.byBlock, error: row.error, byBlock: row.byBlock, error: row.error,
blobs: await Promise.all(row.thumbs.map( blobs: await Promise.all(row.thumbs.map(
(px) => pixelsToBlob(px, THUMB.width, THUMB.height))), (px) => pixelsToBlob(px, THUMB.width, THUMB.height))),

View File

@ -17,7 +17,7 @@
import { Engine } from '../engine/Engine.js'; import { Engine } from '../engine/Engine.js';
import { scenes } from '../scenes/registry.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 { Rng, hashString } from '../engine/rng.js';
import { featureProviderFor } from '../audio/FeatureTrack.js'; import { featureProviderFor } from '../audio/FeatureTrack.js';
import { songBank } from '../audio/songbank.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; 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 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 // 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 // subject helpers were declared above the ink they call, the whole preamble
@ -162,6 +176,8 @@ export function renderScene(engine, module, contexts) {
return { return {
thumbs, thumbs,
variety: pairs ? total / pairs : 0, variety: pairs ? total / pairs : 0,
coverage: covered,
surface: surfaceOf(module),
byBlock, byBlock,
error: dead error: dead
? `renders nothing — luminance ${lum.toFixed(4)}, variance ${variance.toFixed(4)}. ` + ? `renders nothing — luminance ${lum.toFixed(4)}, variance ${variance.toFixed(4)}. ` +

View File

@ -20,6 +20,10 @@ import { song } from '../audio/songbank.js';
import { Show } from '../Show.js'; import { Show } from '../Show.js';
import { generateLook } from '../look/LookGenerator.js'; import { generateLook } from '../look/LookGenerator.js';
import { scenes } from '../scenes/registry.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 { frameDescriptor, rotate90, recolour } from './variety/descriptors.js';
import { descriptorDistance, signatureDistance } from './variety/signature.js'; import { descriptorDistance, signatureDistance } from './variety/signature.js';
import { import {
@ -178,3 +182,65 @@ check(12, 'seed variety · different seeds render structurally different videos'
(problems.length ? problems.join(' · ') + ' — ' : '') + (problems.length ? problems.join(' · ') + ' — ' : '') +
`separation ${r.separation.toFixed(2)} · ${blocks}`); `separation ${r.separation.toFixed(2)} · ${blocks}`);
}, { slow: true }); }, { 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 });

View File

@ -7,7 +7,7 @@
import { Rng, hashSamples } from '../engine/rng.js'; import { Rng, hashSamples } from '../engine/rng.js';
import { AudioPalette, generateUsablePalette } from './palette.js'; import { AudioPalette, generateUsablePalette } from './palette.js';
import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.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 { planShots } from './shots.js';
import { import {
generatePersonality, sceneHonours, signatureWeight, describePersonality, 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 // 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 // never on a background that is itself a full-frame glitch — two competing
// corruption passes is noise, not depth. // 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' const overlayChance = module.family === 'glitch'
? 0.05 ? 0.1
: 0.12 + bias.energy * 0.35 + (temperament ? Math.max(0, temperament.detail) * 0.2 : 0); : 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); // Only COMPOSABLE scenes go on top. A second canvas over the first is two
if (overlays.length && rng.bool(Math.min(0.6, overlayChance))) { // 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); const overlay = rng.pick(overlays);
// Screen and add keep the background readable underneath; softlight and // Screen and add keep the background readable underneath; softlight and
// overlay tint it instead. All four preserve the shot; 'normal' would // overlay tint it instead. All four preserve the shot; 'normal' would

View File

@ -62,6 +62,31 @@ export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse'];
*/ */
export const ARTIFACT_NAMES = ['cast', 'ink', 'staging']; 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. * 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 (!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 (module.consumes !== undefined) {
if (!Array.isArray(module.consumes)) { if (!Array.isArray(module.consumes)) {
errors.push(`${id}: \`consumes\` must be an array of ${ARTIFACT_NAMES.join('/')}`); errors.push(`${id}: \`consumes\` must be an array of ${ARTIFACT_NAMES.join('/')}`);