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
+166
View File
@@ -0,0 +1,166 @@
// Comfort / agitation guard — the floating-window cap that keeps whole-screen
// flashing and coherent motion out of the places a viewer needs calm.
//
// The metric itself lives in engine/agitation.js (tile-based whole-screen motion
// + luminance flash, 1 s floating window, worst window wins, normalised to 0..1).
// This file is the composition policy: what the ceiling is, how it varies with
// director and story, and how the generator reads the measured per-scene value
// (scenes/metadata.json → agitation) and turns it into a weight.
//
// Whole-screen is reserved for special cases. Region-based motion is fine — a
// ribbon in the centre, particles, a small form turning — and scores low because
// only a few tiles are active. A full-frame pan/zoom or a uniform luminance pump
// lights most tiles at once and drives the score high. The director must choose
// a different scene or different settings when the cap would be exceeded, with a
// preliminary per-visualizer proxy at generation time (this file) and a later
// full-stack floating-window audit over the rendered Show.
//
// Configurable in one place: change the constants here and press "refresh
// metadata" in the gallery — the fingerprint covers agitation.js so staleness is
// detectable rather than silent.
import metadata from '../scenes/metadata.json';
import { AGITATION_CEILING } from '../engine/agitation.js';
// The checked-in ceiling the gallery and the scene gate enforce. Directors may
// be stricter or looser per point of view (see directors.js `comfort`), and the
// story may nudge it per section (tension). Absent, this is the promise.
export const DEFAULT_COMFORT_CEILING = AGITATION_CEILING;
// --- A: whole-screen beat/flux caps --------------------------------------
//
// Canvas scenes paint ≥50% of the frame (see scenes/surface.js GROUND_MIN).
// When a canvas swerves, shuffles or jumps with `u_beat` on a `spike`, the
// ENTIRE view moves in sync on every kick (centre/124 BPM ≈ 2 Hz). That is
// the nausea source on `?song=centre&seed=3837488838`: every mid-tempo track
// does it, and the per-scene `agitation` metric already flags it but
// selection-weighting alone cannot quiet a canvas already on screen. Only
// smaller regions/elements should react wildly — they light few tiles.
//
// These caps are applied centrally in engine/Layer.js:resolveParams so no
// per-shader edit is needed for the immediate relief, and lint-scenes.js warns
// when a declaration exceeds them. Tunable in one place; `npm run lint:scenes`
// is the feedback, and a changed cap makes metadata stale (see checks/metadata.js
// SOURCES) so `gallery → Refresh metadata` is required — the workflow already
// documented in gallery.html. Composable scenes (coverage < 0.50) are untouched.
export const CANVAS_BEAT_CAP = 0.15; // beat amount ceiling on canvas
export const CANVAS_FLUX_CAP = 0.25; // flux amount ceiling on canvas
export const CANVAS_BEAT_SOFTEN_SPIKE = true; // spike → smooth on canvas (same peak, less step)
// How far below the ceiling a scene still reads as calm vs. caution in the UI.
export const COMFORT_CAUTION = 0.50;
/**
* Measured agitation for a scene module (0..1, worst 1 s window over the
* gallery strip). Missing or not yet measured → 0 (unmeasured scenes must not
* be penalised before the first refresh, otherwise the roster collapses).
*
* This is a proxy — per-scene, not per-section, and at the gallery's busiest
* bias — so it is a lean, not a hard exclusion.
*/
export function agitationOf(module) {
if (!module || !module.name) return 0;
const row = metadata.scenes && metadata.scenes[module.name];
if (!row) return 0;
const v = row.agitation;
return typeof v === 'number' && isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
}
/**
* The ceiling that applies to a director in a given story state.
*
* `tension` 0..1 (Story.js) is how far into the arc this section sits. The
* climax tolerates slightly more whole-screen energy than an intro; the intro
* does not, and the quiet-kind coupling in LookGenerator tightens it further.
* Bounded so the knob never inverts the library — even `corrupt` at climax
* does not go above 0.95, and even `ambient` in an intro does not go below 0.30.
*
* `director.comfort` is the point-of-view appetite (absent → DEFAULT). Story
* null → base without the arc nudge, which is what roster construction uses
* before per-section story state exists.
*/
export function comfortCeilingFor(director, story = null) {
const base = director && director.comfort !== undefined
? director.comfort
: DEFAULT_COMFORT_CEILING;
if (!story) return clamp(base, 0.30, 0.95);
const tension = clamp(story.tension ?? 0.5, 0, 1);
// tension 0.5 → base, 1.0 → base*1.08, 0.0 → base*0.90
const factor = 0.90 + tension * 0.18;
return clamp(base * factor, 0.30, 0.95);
}
/** Director's own comfort appetite, without the story nudge. */
export function comfortOf(director) {
return director && director.comfort !== undefined
? director.comfort
: DEFAULT_COMFORT_CEILING;
}
/**
* Weight multiplier for a candidate scene given the ceiling that applies where
* it would be cast.
*
* Only scenes above the ceiling are penalised — a scene at 0.69 with a 0.70
* ceiling is not pushed down, which keeps the library from collapsing when most
* canvases sit near the line. Above it, the falloff is steep but never to zero:
* a hot scene stays reachable for a `corrupt` drop, just very unlikely for a
* quiet intro. This is the "must choose a different scene or settings" lever at
* generation time; the floating-window truth is checked over the rendered Show
* later (the per-visualizer value here is the preliminary tracking the brief
* asks for).
*
* agitation ≤ ceiling → 1.0
* ceiling < agitation < 1.0 → 1.0 → 0.05, linear in excess
* agitation ≥ 1.0 → 0.05 (floor, never excluded)
*/
export function agitationWeight(module, ceiling) {
const a = agitationOf(module);
if (!a || a <= ceiling) return 1;
const c = clamp(ceiling, 0.05, 0.95);
const excess = clamp((a - c) / (1 - c), 0, 1);
return Math.max(0.05, 1 - excess * 0.95);
}
/**
* Stack-level agitation proxy from the shot + overlays already chosen.
*
* At generation time we have no pixels — only per-scene proxies — so the
* stack's agitation is approximated as the max of its layers, with a small
* blend from the mean (two moderately agitated layers together are a bit more
* than either alone, but not double). Used to decide whether another overlay
* would push the section over the ceiling.
*/
export function stackAgitation(layers) {
if (!layers || !layers.length) return 0;
let max = 0;
let sum = 0;
for (const l of layers) {
const a = agitationOf(l.module);
if (a > max) max = a;
sum += a;
}
const mean = sum / layers.length;
return Math.max(max, max * 0.75 + mean * 0.25);
}
/**
* Ranged metric for a scene module: {mean,min,max,std,p10,p90} or null.
* Reads from metadata.json `metrics[key]` produced by gallery (SCHEMA 7).
* Cost-agnostic — covers agitation, motionPeak, structPeak, coverage, etc.
*/
export function metricRangeOf(module, key) {
if (!module || !module.name || !key) return null;
const row = metadata.scenes && metadata.scenes[module.name];
if (!row) return null;
if (row.metrics && row.metrics[key]) return row.metrics[key];
// Fallback to legacy flat value → treat as mean-only range
const flatMap = { agitation: row.agitation, flashPeak: row.flashPeak, motionPeak: row.motionPeak, structPeak: row.structPeak, coverage: row.coverage };
const v = flatMap[key];
if (typeof v === 'number' && Number.isFinite(v)) return { mean: v, min: v, max: v, std: 0, p10: v, p90: v };
return null;
}
function clamp(x, lo, hi) {
return Math.max(lo, Math.min(hi, x));
}