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:
@@ -21,6 +21,11 @@ import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
|
||||
import {
|
||||
pickDirector, directorByName, crowdOf, blazeOf, RESTFUL_FAMILIES, QUIET_KINDS,
|
||||
} from './directors.js';
|
||||
import {
|
||||
agitationOf, agitationWeight, comfortCeilingFor, stackAgitation,
|
||||
} from './comfort.js';
|
||||
import { filterScenesSync } from './scene-filter.js';
|
||||
import { validateSection } from './composition-validator.js';
|
||||
import { derivePaletteArc, describePaletteArc } from './paletteArc.js';
|
||||
import { deriveFramingStyle, describeFraming } from './framing.js';
|
||||
import { deriveCamera, describeCamera } from './Camera.js';
|
||||
@@ -246,10 +251,17 @@ function castingPool(rng, signature, size = POOL_SIZE) {
|
||||
function candidatesForKind(kind, used, signature = [], director, pool = null) {
|
||||
const families = director.families[kind] || Object.keys(FAMILIES);
|
||||
const allowed = pool ? new Set(pool.map((m) => m.name)) : null;
|
||||
// Vast preliminary library: hard filter BEFORE the lean. Reads ranged
|
||||
// `agitation.max / structPeak.max` (SCHEMA 7) and per-kind comfort;
|
||||
// some songs simply won't use certain scenes. Lean still applies after.
|
||||
const eligibleSet = new Set(filterScenesSync(scenes.filter(canBackground), { director, sectionKind: kind }).map((m) => m.name));
|
||||
// Preliminary comfort ceiling for this kind (base appetite, before per-section
|
||||
// story nudge). Filter already excluded the hottest; lean penalises the edge.
|
||||
const comfortCeiling = comfortCeilingFor(director);
|
||||
const candidates = [];
|
||||
for (const family of families) {
|
||||
const inFamily = scenesInFamily(family)
|
||||
.filter((m) => canBackground(m) && (!allowed || allowed.has(m.name)));
|
||||
.filter((m) => canBackground(m) && (!allowed || allowed.has(m.name)) && eligibleSet.has(m.name));
|
||||
// Weight by family preference order, and push down anything already
|
||||
// used so a five-section track doesn't show one scene five times.
|
||||
const weight = families.length - families.indexOf(family);
|
||||
@@ -259,16 +271,23 @@ function candidatesForKind(kind, used, signature = [], director, pool = null) {
|
||||
// The signature is a lean now, not a wall. See
|
||||
// Personality.signatureWeight for why it had to stop being one.
|
||||
weight: weight * signatureWeight(scene, signature)
|
||||
* (used.has(scene.name) ? 0.15 : 1),
|
||||
* (used.has(scene.name) ? 0.15 : 1)
|
||||
* agitationWeight(scene, comfortCeiling),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!candidates.length) {
|
||||
// This track's pool holds nothing in the families the director wants for
|
||||
// this kind. Widen to the pool, then to the library — the track keeps
|
||||
// its scenes either way.
|
||||
// Empty after filter — widen once via filterScenesSync's 1.15× ceiling,
|
||||
// else fall back to pool→library so a section is never empty; validator
|
||||
// still rejects if composite hot and re-rolls.
|
||||
const byFilter = filterScenesSync(scenes.filter(canBackground), { director, sectionKind: kind });
|
||||
const byFilterInPool = byFilter.filter((m) => !allowed || allowed.has(m.name));
|
||||
const found = byFilterInPool.length ? byFilterInPool : byFilter;
|
||||
if (found.length) {
|
||||
return found.map((scene) => ({ scene, weight: signatureWeight(scene, signature) * agitationWeight(scene, comfortCeiling) }));
|
||||
}
|
||||
const fallback = (pool && pool.length) ? pool : scenes.filter(canBackground);
|
||||
return fallback.map((scene) => ({ scene, weight: signatureWeight(scene, signature) }));
|
||||
return fallback.map((scene) => ({ scene, weight: signatureWeight(scene, signature) * agitationWeight(scene, comfortCeiling) }));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
@@ -363,6 +382,11 @@ function castGround(kind, roster, rng, signature, director, used) {
|
||||
// and it is worse underneath than on top because nothing hides it.
|
||||
if (quiet && !RESTFUL_FAMILIES.includes(m.family)) w *= 0.15;
|
||||
w *= signatureWeight(m, signature);
|
||||
// Whole-screen comfort: a ground that itself is hot (worst 1 s window) is
|
||||
// felt for the entire section — it is underneath everything. Penalise it
|
||||
// more steeply than a shot, because a shot is cut away from and a ground
|
||||
// is not.
|
||||
w *= agitationWeight(m, comfortCeilingFor(director));
|
||||
// Overshooting the budget is allowed and discouraged: the ground is
|
||||
// mandatory, so an oversized one is spent frame the shot cannot use.
|
||||
w /= 1 + 4 * Math.max(0, sceneCoverage(m) - headroom);
|
||||
@@ -617,9 +641,21 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
|
||||
// The shot is sampled first and from the caller's rng, so a stack draws the
|
||||
// same shot it always did and the ground arrives underneath it rather than
|
||||
// in front of it in the seed stream.
|
||||
//
|
||||
// Comfort: on quiet kinds (intro/breakdown/outro) a hot shot is down-weighted
|
||||
// at roster construction (candidatesForKind) and here we try a single
|
||||
// param re-sample on a fresh fork before committing. The per-scene meter is
|
||||
// preliminary — per-param agitation is not measured — so one attempt is the
|
||||
// most this level can do; the roster weighting is the real lever. See
|
||||
// look/comfort.js for the ceiling and "different scene or settings" handling.
|
||||
let shotParams = sampleValues(module, rng, bias, temperament);
|
||||
if (QUIET_KINDS.includes(sectionKind) && agitationOf(module) > comfortCeilingFor(director, story)) {
|
||||
const fork = rng.fork(`comfort:${module.name}`);
|
||||
shotParams = sampleValues(module, fork, bias, temperament);
|
||||
}
|
||||
const shot = {
|
||||
module,
|
||||
params: sampleValues(module, rng, bias, temperament),
|
||||
params: shotParams,
|
||||
seed: rng.int(0, 0x7fffffff),
|
||||
// Keyed over the ground on its own brightness by default: what the shot
|
||||
// leaves unpainted is then the ground rather than black, which is the
|
||||
@@ -731,7 +767,24 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
|
||||
// with something happening in it. See scenes/surface.js.
|
||||
const offFamily = available.filter((m) => m.family !== module.family);
|
||||
const pool = offFamily.length ? offFamily : available;
|
||||
const overlay = rng.pickWeighted(pool, pool.map((m) => contrastWeight(m, layers)));
|
||||
// Comfort lean: an overlay that is itself hot, or that would push the
|
||||
// stack over the ceiling when added, is down-weighted. Whole-screen is
|
||||
// reserved for special cases — a calm shot with a hot shimmer over it
|
||||
// exceeds the floating-window promise just as surely as a hot shot alone.
|
||||
const comfortCeiling = comfortCeilingFor(director, story);
|
||||
const overlay = rng.pickWeighted(pool, pool.map((m) => {
|
||||
let w = contrastWeight(m, layers);
|
||||
w *= agitationWeight(m, comfortCeiling);
|
||||
// Stack-level proxy: adding this overlay on top of what is already
|
||||
// on screen. Two moderately agitated layers together count a bit more
|
||||
// than either alone, so check the combined proxy as well.
|
||||
const combined = stackAgitation([...layers, { module: m }]);
|
||||
if (combined > comfortCeiling) {
|
||||
const excess = Math.max(0, Math.min(1, (combined - comfortCeiling) / (1 - comfortCeiling)));
|
||||
w *= Math.max(0.08, 1 - excess * 0.85);
|
||||
}
|
||||
return w;
|
||||
}));
|
||||
spent += sceneCoverage(overlay);
|
||||
available = available.filter((m) => m.name !== overlay.name
|
||||
&& m.family !== overlay.family);
|
||||
@@ -906,6 +959,112 @@ export function generateLook(track, {
|
||||
summary,
|
||||
};
|
||||
|
||||
// Validate + re-roll — director rejects a section whose predicted
|
||||
// agitation exceeds the desired ceiling for its kind (kindFactor 0.38–0.72,
|
||||
// murk-like stillness + flatness pull down further; composite penalty +
|
||||
// 0.28 ground+shot canvases). Up to MAX attempts: (1) drop overlay,
|
||||
// (2) swap overlay for composable <0.30, (3) reselect shot variant filtered
|
||||
// by ranged metrics. Validator may refuse a section (> desired+0.22) and if
|
||||
// ≥2 sections refuse the song refuses to render (validateLook.songRefused).
|
||||
// Deterministic via forked rng; never empty (keep quietest). See
|
||||
// src/look/composition-validator.js + src/look/scene-filter.js.
|
||||
const MAX_VALIDATE_ATTEMPTS = 6;
|
||||
const compositionRefused = [];
|
||||
for (const sec of sections) {
|
||||
const state = sec.story || null;
|
||||
let bestLayers = sec.layers;
|
||||
let bestScore = validateSection(sec, { director, story: state, summary });
|
||||
if (bestScore.ok) continue;
|
||||
let bestPredicted = bestScore.predicted;
|
||||
// Try to retune this section's anchor stack (variants[0] == layers)
|
||||
for (let attempt = 1; attempt <= MAX_VALIDATE_ATTEMPTS; attempt++) {
|
||||
const tuneRng = rng.fork(`validator:${sec.index}:${attempt}`);
|
||||
const temper = temperamentFor(personality.temperament, state);
|
||||
const curLayers = sec.layers;
|
||||
let candidateLayers = null;
|
||||
if (attempt === 1) {
|
||||
// 1) Drop overlays — ground + shot only
|
||||
const ground = curLayers.find((l) => l.role === 'ground') || null;
|
||||
const shot = curLayers.find((l) => !l.role || l.role !== 'ground') || curLayers[0];
|
||||
candidateLayers = ground ? [ground, shot] : [shot];
|
||||
} else if (attempt === 2) {
|
||||
// 2) Swap overlay for a quiet composable <0.30 coverage
|
||||
const ground = curLayers.find((l) => l.role === 'ground') || null;
|
||||
const shot = curLayers.find((l) => !l.role || l.role !== 'ground') || curLayers[0];
|
||||
const baseLayers = ground ? [ground, shot] : [shot];
|
||||
// Pick a quiet composable filtered by scene-filter
|
||||
const pool = scenes.filter((m) => canBackground(m) || m.kind === 'layer3d');
|
||||
const quietCandidates = filterScenesSync(pool, { director, sectionKind: sec.kind });
|
||||
// Use coverage + agitation to pick quietest composable
|
||||
const composables = quietCandidates.filter((m) => {
|
||||
try { return !canGround(m); } catch { return false; }
|
||||
});
|
||||
const quietPool = composables.length ? composables : quietCandidates;
|
||||
let bestOverlay = null;
|
||||
let bestAgit = Infinity;
|
||||
for (const cand of quietPool) {
|
||||
// Prefer low coverage + low agitation
|
||||
const cov = (() => { try { return sceneCoverage(cand); } catch { return 1; } })();
|
||||
if (cov > 0.30) continue;
|
||||
const a = agitationOf(cand);
|
||||
if (a < bestAgit) { bestAgit = a; bestOverlay = cand; }
|
||||
}
|
||||
if (bestOverlay) {
|
||||
const ov = {
|
||||
module: bestOverlay,
|
||||
params: sampleValues(bestOverlay, tuneRng, sec.bias, temper),
|
||||
seed: tuneRng.int(0, 0x7fffffff),
|
||||
blend: tuneRng.pickWeighted(['screen', 'add', 'softlight', 'overlay'], [3, 2, 2, 1]),
|
||||
opacity: tuneRng.range(0.22, 0.45),
|
||||
};
|
||||
candidateLayers = [...baseLayers, ov];
|
||||
} else {
|
||||
candidateLayers = baseLayers;
|
||||
}
|
||||
} else {
|
||||
// 3) Reselect shot variant from roster filtered by ranged metrics
|
||||
const kindRoster = rosterByKind.get(sec.kind) || [scenes[0]];
|
||||
const filtered = filterScenesSync(kindRoster, { director, sectionKind: sec.kind });
|
||||
const pickPool = filtered.length ? filtered : kindRoster;
|
||||
const picked = tuneRng.pick(pickPool);
|
||||
const groundLayer = curLayers.find((l) => l.role === 'ground') || null;
|
||||
const groundModule = groundLayer ? groundLayer.module : null;
|
||||
candidateLayers = buildStack(
|
||||
picked, overlayRoster, sec.bias,
|
||||
tuneRng, temper, state,
|
||||
{ ground: groundModule, director, kind: sec.kind },
|
||||
);
|
||||
}
|
||||
// Score candidate (pass summary so murk-like stillness lowers desired)
|
||||
const fakeSection = { ...sec, layers: candidateLayers };
|
||||
const candScore = validateSection(fakeSection, { director, story: state, summary });
|
||||
if (candScore.ok) {
|
||||
sec.layers = candidateLayers;
|
||||
sec.variants = [candidateLayers, ...sec.variants.slice(1)];
|
||||
bestScore = candScore;
|
||||
bestLayers = candidateLayers;
|
||||
break;
|
||||
}
|
||||
if (candScore.predicted < bestPredicted) {
|
||||
bestPredicted = candScore.predicted;
|
||||
bestLayers = candidateLayers;
|
||||
bestScore = candScore;
|
||||
}
|
||||
}
|
||||
// Keep quietest attempt (never empty); record refusal for song-level fail
|
||||
if (bestLayers !== sec.layers) {
|
||||
sec.layers = bestLayers;
|
||||
sec.variants[0] = bestLayers;
|
||||
}
|
||||
if (bestScore.refused) compositionRefused.push(sec.index);
|
||||
}
|
||||
if (compositionRefused.length >= 2) {
|
||||
const err = new Error(`high agitation: director refused ${compositionRefused.length} sections (${compositionRefused.join(',')}) — predicted > desired+0.22`);
|
||||
err.code = 'COMPOSITION_REFUSED';
|
||||
err.refused = compositionRefused.slice();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return overrides ? applyOverrides(look, overrides) : look;
|
||||
}
|
||||
|
||||
@@ -926,12 +1085,15 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
|
||||
|
||||
// Re-roll the whole roster, not just the anchor: the section's shots cut
|
||||
// between all of them, so replacing one would leave the section half old.
|
||||
// Respect comfort — a hot scene stays less likely even on reroll, but is not
|
||||
// excluded so the user can still land on it if the pool is thin.
|
||||
const rerollCeiling = comfortCeilingFor(director);
|
||||
const size = Math.min(rosterSizeFor(section.kind), Math.max(1, candidates.length));
|
||||
const roster = [];
|
||||
while (roster.length < size) {
|
||||
const options = candidates.filter((m) => !roster.includes(m));
|
||||
if (!options.length) break;
|
||||
roster.push(rng.pickWeighted(options, options.map((m) => signatureWeight(m, signature))));
|
||||
roster.push(rng.pickWeighted(options, options.map((m) => signatureWeight(m, signature) * agitationWeight(m, rerollCeiling))));
|
||||
}
|
||||
if (!roster.length) roster.push(scenes[0]);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Composition validator — per-section check with re-roll + tune.
|
||||
//
|
||||
// After `generateLook` has chosen rosters, ground and variants, the actual
|
||||
// stacks (ground+shot+0-2 overlays) exist. The director's desired agitation per
|
||||
// section kind is `comfortCeilingFor(director, story) * kindFactor * stillnessAdj`.
|
||||
// `stillnessAdj` pulls the ceiling down for still, dark-noisy material (murk).
|
||||
// Predicted agitation is `stackAgitation([...layers])` (max-of-3-arms over the
|
||||
// 3-arm Rev 2 agitation) PLUS composite penalties for wall-to-wall canvases —
|
||||
// without this the proxy 0.21 for two canvases reads calm while the eye sees
|
||||
// swirl. If predicted exceeds desired the section is *rejected* and the
|
||||
// director must re-build it (re-roll + tune) until it passes, or the section
|
||||
// is marked refused. If too many sections refuse, the song refuses to render.
|
||||
// Tunable via `look/comfort.js` ceilings; no rendering here (uses preliminary
|
||||
// per-scene maxima, now 12+ ranged metrics). A future pass can render a 60-frame
|
||||
// composite burst for exact validation if needed.
|
||||
|
||||
import { comfortCeilingFor, stackAgitation } from './comfort.js';
|
||||
import { surfaceOf } from '../scenes/surface.js';
|
||||
|
||||
const KIND_DESIRED_FACTOR = {
|
||||
intro: 0.38,
|
||||
build: 0.62,
|
||||
drop: 0.72,
|
||||
sustain: 0.68,
|
||||
breakdown: 0.38,
|
||||
outro: 0.42,
|
||||
};
|
||||
|
||||
function kindFactor(kind) {
|
||||
return KIND_DESIRED_FACTOR[kind] ?? 0.65;
|
||||
}
|
||||
|
||||
function desiredFor(director, story, kind, summary = null) {
|
||||
const base = comfortCeilingFor(director, story);
|
||||
let v = Math.min(0.95, base * kindFactor(kind));
|
||||
// Murk-like: still, dark-noisy material should be calm even in its drop.
|
||||
// When a track is noisy and bright-hiss, the director wanted overload but
|
||||
// the material wants a static bed + floaters — pull the ceiling down.
|
||||
// (Personality.motion.stillness is not wired through summary; the flat/
|
||||
// centroid heuristic is the only signal available here.)
|
||||
if (summary) {
|
||||
const flat = summary.meanFlatness ?? 0;
|
||||
const centroid = summary.meanCentroid ?? 0.5;
|
||||
const isMurkLike = flat > 0.55 && centroid > 0.75;
|
||||
if (isMurkLike) v *= 0.72;
|
||||
}
|
||||
// Story tension still nudges via comfortCeilingFor, but clamp low for calm kinds
|
||||
if (kind === 'intro' || kind === 'breakdown' || kind === 'outro') v = Math.min(v, 0.45);
|
||||
return Math.max(0.18, Math.min(0.95, v));
|
||||
}
|
||||
|
||||
export function predictSectionAgitation(layers) {
|
||||
let base = stackAgitation(layers);
|
||||
// Composite penalty: wall-to-wall canvases integrate as one field even when
|
||||
// each scene individually is 0.15–0.25 after B-windowing. Eye sees swirl, proxy sees calm.
|
||||
const canvasCount = layers.filter((l) => {
|
||||
try { return surfaceOf(l.module) === 'canvas'; } catch { return false; }
|
||||
}).length;
|
||||
const hasGroundCanvas = layers.some((l) => l.role === 'ground' && (() => { try { return surfaceOf(l.module) === 'canvas'; } catch { return false; }})());
|
||||
const hasShotCanvas = layers.some((l) => (!l.role || l.role !== 'ground') && (() => { try { return surfaceOf(l.module) === 'canvas'; } catch { return false; }})());
|
||||
if (hasGroundCanvas && hasShotCanvas) base += 0.28;
|
||||
else if (canvasCount >= 2) base += 0.18;
|
||||
// Dense stacks (ground+shot+overlay) read as heavier even if per-scene agitation is low.
|
||||
if (layers.length >= 3) base += 0.07;
|
||||
return Math.min(1, base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one built section's layers.
|
||||
* @param {object} section - LookGenerator section with `layers` (ground+shot+overlays)
|
||||
* @param {object} opts - {director, story}
|
||||
* @returns {{ok:boolean, reason:string|null, desired:number, predicted:number}}
|
||||
*/
|
||||
export function validateSection(section, { director, story = null, summary = null } = {}) {
|
||||
if (!section || !section.layers || !section.layers.length) {
|
||||
return { ok: true, reason: null, desired: 1, predicted: 0 };
|
||||
}
|
||||
const kind = section.kind || 'sustain';
|
||||
const desired = desiredFor(director, story, kind, summary);
|
||||
const predicted = predictSectionAgitation(section.layers);
|
||||
if (predicted <= desired + 1e-9) {
|
||||
return { ok: true, reason: null, desired, predicted };
|
||||
}
|
||||
const primary = section.layers.find((l) => !l.role || l.role !== 'ground') || section.layers[0];
|
||||
const name = primary && primary.module ? primary.module.name : '?';
|
||||
const reason = `agitation ${predicted.toFixed(2)} > desired ${desired.toFixed(2)} for ${kind} (shot ${name})`;
|
||||
return { ok: false, reason, desired, predicted, refused: predicted > desired + 0.22 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all sections of a look.
|
||||
* @returns {{sections: object[], ok:boolean, rejected:int[]}}
|
||||
*/
|
||||
export function validateLook(look, _track = null) {
|
||||
const sections = [];
|
||||
let ok = true;
|
||||
const rejected = [];
|
||||
const refused = [];
|
||||
const directorObj = typeof look.director === 'string'
|
||||
? { name: look.director }
|
||||
: look.director || { name: look.director };
|
||||
const summary = look.summary || (_track && _track.summary) || null;
|
||||
for (const sec of look.sections) {
|
||||
const story = sec.story || null;
|
||||
const v = validateSection(sec, { director: directorObj, story, summary });
|
||||
sections.push({ index: sec.index, kind: sec.kind, ...v });
|
||||
if (!v.ok) { ok = false; rejected.push(sec.index); if (v.refused) refused.push(sec.index); }
|
||||
}
|
||||
const songRefused = refused.length >= 2;
|
||||
return { sections, ok, rejected, refused, songRefused, refusesToRender: songRefused };
|
||||
}
|
||||
|
||||
export { KIND_DESIRED_FACTOR };
|
||||
@@ -64,6 +64,18 @@ const DEFAULT_CROWD = 1.2;
|
||||
*/
|
||||
const DEFAULT_BLAZE = 0.15;
|
||||
|
||||
/**
|
||||
* Whole-screen comfort — the floating-window cap that keeps uniform flashing
|
||||
* and coherent full-frame motion out of the wrong places.
|
||||
*
|
||||
* Each director declares how much agitation (engine/agitation.js, 0..1, worst
|
||||
* 1 s window, max of flash/3 and motion/0.55) it tolerates. Regions moving is
|
||||
* fine; the whole screen moving or flashing in sync is reserved for special
|
||||
* cases. Absent -> DEFAULT_COMFORT (0.70). Lower = stricter. See look/comfort.js
|
||||
* for the story-tension nudge and the weighting the generator reads.
|
||||
*/
|
||||
const DEFAULT_COMFORT = 0.70;
|
||||
|
||||
/**
|
||||
* Each director maps every section kind to three families, most-preferred
|
||||
* first. Every kind must be present — a missing one silently falls back to the
|
||||
@@ -72,6 +84,7 @@ const DEFAULT_BLAZE = 0.15;
|
||||
export const DIRECTORS = [
|
||||
{
|
||||
name: 'ambient',
|
||||
comfort: 0.60,
|
||||
// Space, not glare. A blaze here is the exception that proves it.
|
||||
blaze: 0.1,
|
||||
// Space is the subject, so the frame stays owed to it: one
|
||||
@@ -97,6 +110,7 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'brutalist',
|
||||
comfort: 0.60,
|
||||
// Mass does not glow. Almost never.
|
||||
blaze: 0.05,
|
||||
// One large thing, seen whole. Layering hides mass, which is the
|
||||
@@ -120,6 +134,7 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'organicist',
|
||||
comfort: 0.65,
|
||||
// Light through leaves — bloom belongs to this world.
|
||||
blaze: 0.2,
|
||||
// Growth accumulates. Things overlap here because that is what
|
||||
@@ -143,6 +158,7 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'corrupt',
|
||||
comfort: 0.85,
|
||||
// Overload is the subject. Half its drops go white.
|
||||
blaze: 0.5,
|
||||
// Everything over everything. The damage is the subject and it is
|
||||
@@ -167,6 +183,7 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'geometer',
|
||||
comfort: 0.70,
|
||||
// Exact, and occasionally exact and incandescent.
|
||||
blaze: 0.15,
|
||||
// Pattern on pattern is a moiré, which is a pattern. Layers are
|
||||
@@ -232,6 +249,13 @@ export function directorByName(name) {
|
||||
return DIRECTORS.find((d) => d.name === name) || DIRECTORS[0];
|
||||
}
|
||||
|
||||
/** How much whole-screen agitation this director tolerates (0..1). See DEFAULT_COMFORT. */
|
||||
export function comfortOf(director) {
|
||||
return (director && director.comfort !== undefined) ? director.comfort : DEFAULT_COMFORT;
|
||||
}
|
||||
|
||||
export { DEFAULT_COMFORT };
|
||||
|
||||
// ── palette progression ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Scene filter — hard exclude before casting, from ranged preliminary metrics.
|
||||
//
|
||||
// The director used to lean (agitationWeight) and a 0.70 ceiling; a lean cannot
|
||||
// exclude — `corrupt 0.85 + tension→0.95` let most canvases through. Now every
|
||||
// scene carries 12+ ranged metrics `{mean,min,max,std,p10,p90}` in
|
||||
// `src/scenes/metadata.json:7` (see `src/checks/gallery.js` 6×90-frame ranged
|
||||
// + `src/checks/metadata.js`). The filter reads `max` (worst draw) against a
|
||||
// per-kind desired ceiling and returns the eligible subset. Some songs simply
|
||||
// won't use certain scenes — by design. If the kind's eligible set empties, we
|
||||
// widen `ceiling*1.15` once before falling back to full `canBackground` so a
|
||||
// section is never empty; validator still rejects if still hot.
|
||||
//
|
||||
// Pure function, no rendering. Tunable via `src/look/comfort.js` ceilings.
|
||||
|
||||
import { comfortCeilingFor } from './comfort.js';
|
||||
import { canBackground } from '../params/schema.js';
|
||||
import metadataBundled from '../scenes/metadata.json';
|
||||
|
||||
// Per-kind comfort multipliers: quiet kinds demand much calmer maxima.
|
||||
const KIND_COMFORT_FACTOR = {
|
||||
intro: 0.62,
|
||||
build: 0.85,
|
||||
drop: 1.00,
|
||||
sustain: 0.92,
|
||||
breakdown: 0.62,
|
||||
outro: 0.65,
|
||||
};
|
||||
|
||||
function kindFactor(kind) {
|
||||
return KIND_COMFORT_FACTOR[kind] ?? 0.85;
|
||||
}
|
||||
|
||||
function maxForMetric(scene, key) {
|
||||
const row = metadataBundled.scenes && metadataBundled.scenes[scene.name];
|
||||
if (!row) return null;
|
||||
const r = row.metrics && row.metrics[key];
|
||||
if (r && typeof r.max === 'number') return r.max;
|
||||
const flat = { agitation: row.agitation, flashPeak: row.flashPeak, motionPeak: row.motionPeak, structPeak: row.structPeak, coverage: row.coverage };
|
||||
const v = flat[key];
|
||||
return typeof v === 'number' ? v : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scene passes the per-kind ranged gate.
|
||||
* Checks agitation.max (composite), plus motion/struct peaks — a ring field can
|
||||
* hide behind a middling composite while structure max alone is hot.
|
||||
*/
|
||||
function passesKind(scene, ceiling, kind) {
|
||||
const aMax = maxForMetric(scene, 'agitation');
|
||||
const mMax = maxForMetric(scene, 'motionPeak');
|
||||
const sMax = maxForMetric(scene, 'structPeak');
|
||||
const cMax = maxForMetric(scene, 'coverage');
|
||||
const want = ceiling * kindFactor(kind);
|
||||
if (aMax !== null && aMax > want) return false;
|
||||
if (sMax !== null && sMax > 0.16) return false;
|
||||
if (kind === 'intro' || kind === 'breakdown' || kind === 'outro') {
|
||||
if (mMax !== null && mMax > 0.38) return false;
|
||||
if (cMax !== null && cMax > 0.88) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter scenes before casting — hard exclude, not a weight.
|
||||
* Synchronous, uses bundled metadata. Never returns empty.
|
||||
*/
|
||||
export function filterScenesSync(scenes, { director, story = null, sectionKind = 'sustain' } = {}) {
|
||||
if (!scenes || !scenes.length) return [];
|
||||
const baseCeiling = comfortCeilingFor(director, story);
|
||||
for (const scale of [1, 1.15]) {
|
||||
const ceiling = Math.min(0.95, baseCeiling * scale);
|
||||
const eligible = scenes.filter((s) => passesKind(s, ceiling, sectionKind));
|
||||
if (eligible.length) return eligible;
|
||||
}
|
||||
const fb = scenes.filter((s) => { try { return canBackground(s); } catch { return true; } });
|
||||
return fb.length ? fb : scenes.slice(0, Math.max(1, scenes.length));
|
||||
}
|
||||
|
||||
// Keep alias for callers that expect filterScenes
|
||||
export const filterScenes = filterScenesSync;
|
||||
export { KIND_COMFORT_FACTOR };
|
||||
Reference in New Issue
Block a user