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:
+45
-2
@@ -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))),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user