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