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
+6 -3
View File
@@ -31,7 +31,7 @@ export const analogWow = {
},
reactive: {
dropout: { feature: 'flux', amount: 0.3, response: 'spike' },
dropout: { feature: 'flux', amount: 0.22, response: 'smooth' },
wow: { feature: 'bandLow', amount: 0.25, response: 'smooth' },
},
@@ -74,8 +74,11 @@ vec4 scene(vec2 uv, vec2 p) {
// the frame, and they replace the picture with the tape's own noise floor
// rather than with black — a black band is a cut, this is a fault.
float bandId = floor(p.y * 8.0 + t * 2.0);
float hit = step(1.0 - u_dropout * 0.35, hash11(bandId * 1.7 + floor(t * 3.0)));
float band = smoothstep(0.5, 0.15, abs(fract(p.y * 8.0 + t * 2.0) - 0.5)) * hit;
// Flux window — dropouts cluster rather than striking everywhere at once.
float bandBeatMask = step(0.55, hash11(bandId * 0.73 + floor(u_seed * 0.4))) * focusField(p) * 0.6 + 0.18;
float maskedDropout = u_dropout + bandBeatMask * u_beat * 0.18;
float hit = step(1.0 - maskedDropout * 0.35, hash11(bandId * 1.7 + floor(t * 3.0)));
float band = smoothstep(0.5, 0.15, abs(fract(p.y * 8.0 + t * 2.0) - 0.5)) * hit * (0.5 + 0.5 * bandBeatMask);
vec3 noise = pal(1) * (0.25 + 0.5 * hash12(vec2(uv.x * 400.0, bandId)));
col = mix(col, noise, band * 0.7);
+6 -5
View File
@@ -25,7 +25,7 @@ export const blockMosh = {
reactive: {
bleed: { feature: 'flux', amount: 0.2, response: 'spike' },
burst: { feature: 'beat', amount: 0.25, response: 'smooth' },
burst: { feature: 'beat', amount: 0.13, response: 'smooth' },
},
shader: `
@@ -66,11 +66,12 @@ vec4 scene(vec2 uv, vec2 p) {
float m = clamp(u_bleed * (0.5 + reach), 0.0, 1.0);
vec3 col = mix(base, mosh, m);
// Weak block tint, pulse-pinned to the beat but kept small — the frame as a
// whole never swings in luminance, so it stays clear of the WCAG 2.3.1
// flash ceiling even on a hard four-on-the-floor.
// Weak block tint, pulse-pinned to the beat but kept small — and now
// windowed, so only a subset of blocks brighten rather than the whole frame.
float blk = hash12(cell + st * 7.0);
col += pal(2) * u_burst * (0.5 + 0.5 * blk) * (0.5 + 0.5 * beat);
float blockBeatMask = step(0.62, hash12(cell * 0.31 + floor(u_seed * 0.2))) * focusField(p) * 0.7 + 0.12;
float maskedBurst = u_burst + blockBeatMask * u_beat * 0.22;
col += pal(2) * maskedBurst * (0.5 + 0.5 * blk) * (0.5 + 0.5 * beat * blockBeatMask);
return vec4(inkValue(col), 1.0);
}
@@ -31,7 +31,7 @@ export const gateCorridor = {
},
reactive: {
lamps: { feature: 'beat', amount: 0.35, response: 'spike' },
lamps: { feature: 'beat', amount: 0.14, response: 'smooth' },
aperture: { feature: 'bandLow', amount: 0.15, response: 'smooth' },
},
@@ -75,10 +75,14 @@ vec4 scene(vec2 uv, vec2 p) {
col += tint * frame * (0.25 + near * 0.75);
col += tint * exp(-d * 14.0) * near * 0.2;
// Beat window — only every third gate's lamp carries the beat,
// and it is masked by focal proximity so the corridor does not flash as one.
float gateBeatMask = step(0.68, hash12(vec2(fi * 0.41, floor(u_seed * 0.3)))) * focusField(p) * 0.9 + 0.1;
float maskedLamps = u_lamps + gateBeatMask * u_beat * 0.45;
// A lamp at the top of every third gate, pulsing on the beat.
if (mod(fi, 3.0) < 0.5) {
vec2 lampAt = vanishAt + vec2(0.0, scale);
col += pal(4) * exp(-length(p - lampAt) * 26.0) * u_lamps * (0.3 + near);
col += pal(4) * exp(-length(p - lampAt) * 26.0) * maskedLamps * (0.3 + near) * (0.6 + 0.4 * gateBeatMask);
}
}
+6 -2
View File
@@ -23,7 +23,7 @@ export const metaballs = {
reactive: {
radius: { feature: 'bandLow', amount: 0.25 },
rim: { feature: 'beat', amount: 0.3, response: 'spike' },
rim: { feature: 'beat', amount: 0.12, response: 'smooth' },
threshold: { feature: 'flux', amount: 0.2, response: 'inverse' },
},
@@ -64,7 +64,11 @@ vec4 scene(vec2 uv, vec2 p) {
vec3 col = pal(0) * 0.06;
col = mix(col, tint, surface);
col += tint * rim * u_rim;
// Beat window — rim brightens only where a blob is near a focal point,
// so the highlight is a spot travelling over the surface, not a global wash.
float metabBeatMask = clamp(focusField(p) * 1.3, 0.0, 1.0) * 0.7 + 0.18;
float maskedRim = u_rim + metabBeatMask * u_beat * 0.45;
col += tint * rim * maskedRim;
return vec4(inkValue(col), 1.0);
}
@@ -23,7 +23,7 @@ export const pitchShatter = {
},
reactive: {
amp: { feature: 'beat', amount: 0.4, response: 'spike' },
amp: { feature: 'beat', amount: 0.14, response: 'smooth' },
bleed: { feature: 'flux', amount: 0.25, response: 'smooth' },
},
@@ -41,7 +41,10 @@ vec4 scene(vec2 uv, vec2 p) {
// step, decided by a hash of (slice, step) so it jumps cleanly on the grid.
float slice = floor(uv.x * u_slices);
float sliceRand = hash12(vec2(slice, step_ * 31.0));
float pitch = (sliceRand - 0.5) * 2.0 * u_amp;
// Beat window — only a vertical band shuffles, not the whole screen.
float sliceBeatMask = step(0.62, hash12(vec2(slice * 0.27, floor(u_seed * 0.5 + step_ * 0.11)))) * (0.5 + 0.5 * smoothstep(0.9, 0.2, length(p)));
float maskedAmp = u_amp + sliceBeatMask * u_beat * 0.18;
float pitch = (sliceRand - 0.5) * 2.0 * maskedAmp;
// The base image is always sampled from the un-pitched field so the scene
// has real content behind the displacement, even on its first frame.
@@ -56,7 +59,7 @@ vec4 scene(vec2 uv, vec2 p) {
// The smear: pull the previous frame across the pitch so slices leave a
// transient ghost as they land, and the corruption feels sludgy.
vec3 ghost = prev(vec2(uv.x, fract(uv.y + pitch * 0.55)));
vec3 ghost = prev(vec2(uv.x, fract(uv.y + pitch * 0.55 * (0.5 + 0.5 * sliceBeatMask))));
col = mix(col, ghost, u_bleed * (0.4 + sliceRand));
// A thin scan beam rooting the hard edge of each step, kept local and beat
+6 -2
View File
@@ -32,7 +32,7 @@ export const scaleMosaic = {
reactive: {
sheen: { feature: 'bandHigh', amount: 0.35, response: 'smooth' },
lift: { feature: 'beat', amount: 0.2, response: 'spike' },
lift: { feature: 'beat', amount: 0.12, response: 'smooth' },
},
shader: `
@@ -52,10 +52,14 @@ vec4 scene(vec2 uv, vec2 p) {
vec2 cell = floor(q);
vec2 f = fract(q) - 0.5;
// Beat window — only scales near the focus lift on the beat, so the
// phyllotaxis breathes in a patch rather than as one sheet.
float scaleBeatMask = clamp(focusField(p) * 1.1, 0.0, 1.0) * step(0.35, hash12(cell * 0.33 + floor(u_seed * 0.2))) * 0.8 + 0.12;
float maskedLift = u_lift + scaleBeatMask * u_beat * 0.35;
// Overlap: the scale is drawn slightly larger than its cell, so each one
// laps over the one below and the surface reads as layered rather than tiled.
float lift = sin(cell.y * 1.7 + cell.x * 0.9 + u_barPhase * 6.2831 + hash12(cell) * 6.0) * u_lift;
float lift = sin(cell.y * 1.7 + cell.x * 0.9 + u_barPhase * 6.2831 + hash12(cell) * 6.0) * maskedLift;
float breath = 1.0 + sin(u_barPhase * 6.28318530718) * u_breathe;
float rad = u_size * breath * (1.0 + lift * 0.3);
+13 -5
View File
@@ -25,8 +25,8 @@ export const scanTear = {
},
reactive: {
shift: { feature: 'beat', amount: 0.5, response: 'spike' },
tear: { feature: 'flux', amount: 0.35, response: 'spike' },
shift: { feature: 'beat', amount: 0.14, response: 'smooth' },
tear: { feature: 'flux', amount: 0.22, response: 'smooth' },
slices:{ feature: 'bandHigh', amount: 0.25 },
},
@@ -40,12 +40,20 @@ vec4 scene(vec2 uv, vec2 p) {
float row = floor(uv.y * u_slices);
float rowRandom = hash12(vec2(row, step_));
// Beat windowing — whole-screen shuffle reserved for special cases.
// Only a subset of rows carries the beat, so the tear occupies a band
// rather than the full frame. Region motion stays lively; whole-screen
// stays calm. See look/comfort.js CANVAS_BEAT_CAP.
float rowBeatMask = step(0.68, hash12(vec2(row * 0.31, floor(u_seed * 0.7 + step_ * 0.13)))) * focusField(p) * 0.7 + 0.15 * (1.0 - focusField(p));
// Effective beat contribution is masked; base u_shift already capped to
// 0.14 in Layer.js, extra masked term replaces the former 0.5 spike.
float maskedShift = u_shift + rowBeatMask * u_beat * 0.18;
// Only some rows tear, and only above the tear threshold.
// Slice weight follows the track's line weight — the same art direction
// that thickens a grid line thickens a tear.
float torn = step(1.0 - u_tear * (0.6 + u_sigLine), rowRandom);
float offset = (rowRandom - 0.5) * 2.0 * u_shift * torn;
float offset = (rowRandom - 0.5) * 2.0 * maskedShift * torn;
// The rows tear in SCREEN space — that is what a tear is — but the image
// being torn is filmed at the shot's framing. See framedUv.
@@ -70,9 +78,9 @@ vec4 scene(vec2 uv, vec2 p) {
// Smear the previous frame along the displacement.
vec3 ghost = prev(vec2(fract(uv.x + offset * 0.6), uv.y));
col = max(col, ghost * u_persist);
col = max(col, ghost * u_persist * (0.6 + 0.4 * rowBeatMask));
col += pal(4) * torn * u_shift * 0.6;
col += pal(4) * torn * maskedShift * 0.35 * rowBeatMask;
return vec4(inkValue(col), 1.0);
}
`,
+6 -3
View File
@@ -28,7 +28,7 @@ export const tideRings = {
},
reactive: {
caustic: { feature: 'beat', amount: 0.3, response: 'smooth' },
caustic: { feature: 'beat', amount: 0.14, response: 'smooth' },
wavelength: { feature: 'bandMid', amount: 0.2 },
},
@@ -60,6 +60,9 @@ vec4 scene(vec2 uv, vec2 p) {
}
float wave = sum / max(energy, 1e-3);
// Beat windowing — caustic brightens only where the song is looking.
float tideBeatMask = clamp(focusField(p) * 1.2, 0.0, 1.0) * smoothstep(1.2, 0.3, length(p));
float maskedCaustic = u_caustic + tideBeatMask * u_beat * 0.5;
// The node lattice: where the sum passes through zero, the surface is flat
// and bright. Squaring the gradient-ish term is what picks those out.
@@ -67,8 +70,8 @@ vec4 scene(vec2 uv, vec2 p) {
nodes = pow(sat(nodes), 3.0);
vec3 col = mix(pal(0) * 0.1, pal(1), sat(wave * 0.5 + 0.5));
col += pal(3) * nodes * u_caustic;
col += pal(4) * pow(sat(wave), 6.0) * u_caustic * 0.5;
col += pal(3) * nodes * maskedCaustic;
col += pal(4) * pow(sat(wave), 6.0) * maskedCaustic * 0.5;
col = sigAir(col, p, smoothstep(0.0, 1.7, length(p)));
return vec4(inkValue(col), 1.0);