Comfort: agitation ceiling, composition validator and whole-screen caps

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>
This commit is contained in:
Dejvino
2026-09-13 19:42:50 +02:00
co-authored by Claude
parent a9b028b0bf
commit 7736390f9c
25 changed files with 1319 additions and 49 deletions
+45 -2
View File
@@ -44,6 +44,12 @@
.row.thin { border-color: #eab308; }
.row.good { border-color: #22c55e; }
.row.broken { border-color: #a855f7; background: #150e1c; }
.row.agitated { border-color: #f97316; }
.comfort { color: #9aa3b5; font-size: 11px; }
.comfort .meter i.low { background: #22c55e; }
.comfort .meter i.mid { background: #eab308; }
.comfort .meter i.high { background: #ef4444; }
.comfort .meter i.hot { background: #f97316; }
.head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; }
.name { font-size: 14px; color: #e6eaf2; }
.fam { color: #6b7280; }
@@ -132,6 +138,7 @@
<option value="name">sort: by name</option>
<option value="family">sort: by family</option>
<option value="coverage">sort: sparsest first</option>
<option value="agitation">sort: most agitating first</option>
</select>
</span>
<span id="sweepctl" hidden>
@@ -155,8 +162,11 @@
<script type="module">
import { buildGallery, galleryContexts, THUMB, blit } from '/src/checks/gallery.js';
import { measureLibrary, metadataDrift, writeMetadata, metadataIsFresh } from '/src/checks/metadata.js';
import { AGITATION_CEILING } from '/src/engine/agitation.js';
// A gallery showing numbers the generator is not using is a trap, so say so.
const STALE_NOTE = metadataIsFresh() ? '' : ' · metadata is STALE — press refresh';
const AGITATION_CEILING_FALLBACK = 0.70;
const agitationCeiling = (typeof AGITATION_CEILING !== 'undefined' ? AGITATION_CEILING : AGITATION_CEILING_FALLBACK);
import {
sourceFingerprint, loadGallery, saveGallery, clearGallery,
pixelsToBlob, blobToCanvas,
@@ -181,11 +191,13 @@ let contexts = [];
// the eye can still find the flattest scenes in a sorted list without anyone
// pretending a threshold decides whether a scene is allowed to exist.
const grade = (v, err) => (err ? 'broken' : v < 0.08 ? 'thin' : 'good');
const agitationGrade = (a) => (a >= agitationCeiling ? 'high' : a >= 0.50 ? 'mid' : 'low');
function draw() {
const mode = sortSel.value;
const sorted = rows.slice().sort((a, b) => (
mode === 'name' ? a.name.localeCompare(b.name)
mode === 'agitation' ? (b.agitation || 0) - (a.agitation || 0)
: 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));
@@ -193,9 +205,13 @@ function draw() {
out.innerHTML = '';
for (const row of sorted) {
const el = document.createElement('div');
el.className = `row ${grade(row.variety, row.error)}`;
el.className = `row ${grade(row.variety, row.error)}${(row.agitation || 0) >= agitationCeiling ? ' agitated' : ''}`;
const blocks = Object.entries(row.byBlock || {})
.map(([k, v]) => `${k} ${v.toFixed(3)}`).join(' · ');
const agit = row.agitation || 0;
const flashPeak = row.flashPeak || 0;
const motionPeak = row.motionPeak || 0;
// structPeak present after refresh; falls back to 0 on cached rows
el.innerHTML = `
<div class="head">
<span class="name"><a href="?scene=${encodeURIComponent(row.name)}" title="open this one on a wide grid of songs and seeds">${row.name}</a></span>
@@ -205,6 +221,10 @@ function draw() {
<span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i></span>
${row.variety.toFixed(3)}
</span>
<span class="comfort" title="worst 1s window — max(flash/3, motion/0.32, struct/0.12). Whole-screen reserved for specials; region motion fine. Tiled rings / full pattern now count via struct. Ceiling ${agitationCeiling.toFixed(2)}">
<span class="meter"><i class="${agitationGrade(agit)}" style="width:${Math.min(100, agit * 100)}%"></i></span>
agit ${agit.toFixed(2)} · flash ${flashPeak.toFixed(1)}/s · motion ${(motionPeak * 100).toFixed(0)}% · struct ${(row.structPeak ?? 0).toFixed(2)} · <a class="metrics-toggle" href="#" onclick="event.preventDefault(); this.closest('.row').querySelector('.metrics-drawer').hidden = !this.closest('.row').querySelector('.metrics-drawer').hidden;">metrics ▸</a>
</span>
<span class="blocks">${row.error ? `<span class="err">${row.error}</span>` : blocks}</span>
</div>
<div class="thumbs"></div>`;
@@ -222,6 +242,27 @@ function draw() {
// 390 images and decoding them all synchronously stalls the page.
blobToCanvas(canvas, blob);
});
const drawer = document.createElement('div');
drawer.className = 'metrics-drawer';
drawer.hidden = true;
drawer.style.cssText = 'font-size:10px;color:#8b94a7;margin-top:6px;display:flex;gap:6px;flex-wrap:wrap;align-items:center';
if (row.metrics) {
const order = ['agitation','structPeak','motionPeak','flashPeak','coverage','luminance','variance','drift','scaleEnergy','layoutEnergy','textureDensity'];
for (const k of order) {
const r = row.metrics[k];
if (!r) continue;
const chip = document.createElement('span');
chip.title = `${k}: mean ${r.mean.toFixed(3)} \u00b7 min ${r.min.toFixed(3)} \u00b7 max ${r.max.toFixed(3)} \u00b7 std ${r.std.toFixed(3)} — max drives filtering (some songs just won't use this scene)`;
chip.textContent = `${k} ${r.max.toFixed(3)}`;
const hot = (k === 'agitation' && r.max >= agitationCeiling) || (k === 'structPeak' && r.max > 0.16) || (k === 'motionPeak' && r.max > 0.38);
chip.style.cssText = `background:${hot ? '#3a1510' : '#1a1e28'};color:${hot ? '#f87171' : '#8b94a7'};padding:2px 6px;border-radius:3px;white-space:nowrap`;
drawer.appendChild(chip);
}
} else {
drawer.textContent = 'ranged metrics not yet measured \u2014 rebuild + Refresh metadata (SCHEMA 7)';
drawer.style.color = '#eab308';
}
el.appendChild(drawer);
out.appendChild(el);
}
}
@@ -238,6 +279,7 @@ function summarise(built) {
const canvases = rows.filter((r) => surfaceFor(r) === 'canvas').length;
return `${rows.length} scenes · ${canvases} canvas / ${rows.length - canvases} composable` +
(broken ? ` · ${broken} BROKEN` : '') +
` · ${rows.filter((r) => (r.agitation || 0) >= agitationCeiling).length} agitated (≥${agitationCeiling.toFixed(2)})` +
` · ${rows.filter((r) => (r.coverage || 0) < 0.3).length} paint under 30% of the frame` +
(built ? ` · built ${new Date(built).toLocaleString()}` : '') + STALE_NOTE;
}
@@ -271,6 +313,7 @@ async function build(fingerprint) {
role: row.module.role || 'stage', variety: row.variety,
coverage: row.coverage, surface: row.surface,
byBlock: row.byBlock, error: row.error,
agitation: row.agitation || 0, flashPeak: row.flashPeak || 0, motionPeak: row.motionPeak || 0, motionMean: row.motionMean || 0, structPeak: row.structPeak || 0, structMean: row.structMean || 0, metrics: row.metrics || null,
blobs: await Promise.all(row.thumbs.map(
(px) => pixelsToBlob(px, THUMB.width, THUMB.height))),
});