// 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)); }