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