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
+31 -4
View File
@@ -5,6 +5,12 @@ import {
} from './shader-contract.js';
import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.js';
import { clampValue } from '../params/schema.js';
import { surfaceOf } from '../scenes/surface.js';
import {
CANVAS_BEAT_CAP,
CANVAS_FLUX_CAP,
CANVAS_BEAT_SOFTEN_SPIKE,
} from '../look/comfort.js';
export const BLEND_MODES = ['normal', 'add', 'screen', 'multiply', 'overlay', 'softlight'];
@@ -185,6 +191,16 @@ export class Layer {
if (!features) return out;
// A — whole-screen guard: canvas scenes (≥50% coverage) must not swerve
// or jump with the beat. Every mid-tempo kick (≈2 Hz at 124 BPM) then
// moves the ENTIRE view in sync — the nausea source on
// ?song=centre&seed=3837488838. Only smaller regions/elements should
// react wildly; they light few tiles. This is the immediate, central cap:
// beat → ≤0.15 and spike→smooth, flux → ≤0.25. Composable scenes are
// untouched. Tunable in look/comfort.js; stale metadata detected via
// checks/metadata.js SOURCES. See engine/agitation.js for the tile metric.
const isCanvas = surfaceOf(this.module) === 'canvas';
for (const [name, r] of Object.entries(reactive)) {
const def = defs[name];
if (!def || def.type === 'palette') continue;
@@ -194,20 +210,31 @@ export class Layer {
const raw = features[r.feature];
if (raw === undefined) continue;
const shaped = applyResponse(Math.max(0, Math.min(1, raw)), r.response);
let amount = r.amount;
let response = r.response;
if (isCanvas) {
if (r.feature === 'beat') {
amount = Math.min(amount, CANVAS_BEAT_CAP);
if (CANVAS_BEAT_SOFTEN_SPIKE && response === 'spike') response = 'smooth';
} else if (r.feature === 'flux') {
amount = Math.min(amount, CANVAS_FLUX_CAP);
}
}
const shaped = applyResponse(Math.max(0, Math.min(1, raw)), response);
const [lo, hi] = def.range || [0, 1];
const span = hi - lo;
const base = out[name] !== undefined ? out[name] : lo;
if (def.type === 'vec2') {
out[name] = [
clampValue(def, [base[0] + shaped * r.amount * span, 0])[0],
clampValue(def, [0, base[1] + shaped * r.amount * span])[1],
clampValue(def, [base[0] + shaped * amount * span, 0])[0],
clampValue(def, [0, base[1] + shaped * amount * span])[1],
];
} else if (def.type === 'bool') {
out[name] = base;
} else {
out[name] = clampValue(def, base + shaped * r.amount * span);
out[name] = clampValue(def, base + shaped * amount * span);
}
}
return out;