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
+44
View File
@@ -27,6 +27,8 @@ import { hashString } from '../engine/rng.js';
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
import { descriptorDistance, directionOf, STRUCTURAL } from './variety/signature.js';
import { describeStory } from '../look/Story.js';
import { frameLuminance } from '../engine/hash.js';
import { agitationScore, AGITATION_CEILING } from '../engine/agitation.js';
export const STRIP = { width: 256, height: 144 };
@@ -126,11 +128,53 @@ export function renderSong(show, name, { duration, every }) {
}
}
// Agitation: dense 60 fps burst in the busiest section, so the filmstrip is
// also the agitation audit. The 30 s probes are 30 s apart and cannot see
// beat-locked whole-screen motion — the metric needs consecutive frames.
// A 120-frame (2 s) burst at the drop/busiest section captures the worst
// 1 s floating window at its most energetic, mirroring gallery.js which
// samples its 60-frame strip at the busiest context. Whole-screen
// flash/coherent motion reserved for special cases, region motion fine.
let score = { agitation: 0, peakFlashRate: 0, peakMotion: 0, meanMotion: 0, peakStructure: 0, meanStructure: 0 };
let agitated = false;
try {
const busiest = look.sections.reduce(
(best, s) => ((s.bias?.energy ?? 0) > (best.bias?.energy ?? 0) ? s : best),
look.sections[0] || { startFrame: 0, endFrame: Math.min(track.frameCount, 120) },
);
const probe = busiest.startFrame + Math.floor((busiest.endFrame - busiest.startFrame) * 0.5);
const STRIP_AGIT = 120; // 2 s @ 60 fps → 1 s window worst
show.warmUp(probe, WARMUP);
const agFrames = [];
const agLums = [];
for (let f = probe; f < probe + STRIP_AGIT; f++) {
const ff = Math.min(track.frameCount - 1, f);
const px = Uint8Array.from(show.readPixels(show.renderFrame(ff)));
agFrames.push(px);
agLums.push(frameLuminance(px));
}
score = agitationScore(agLums, agFrames, 60, { width: STRIP.width, height: STRIP.height });
agitated = score.agitation >= AGITATION_CEILING;
} catch {
// leave agitation at 0 — row still renders, just grey
}
return {
name,
frames,
drift: mean(dists),
direction: directionOf(gaps, dists),
// Agitation over the strip: worst 1 s floating window, max(flash/3,
// motion/0.55) → 0..1. The filmstrip already renders the whole video,
// so this is the natural audit for "how much of the view moves/flashes
// together". Gallery's agitation is per-scene; this one is per-song.
agitation: score.agitation,
agitationFlash: score.peakFlashRate,
agitationMotion: score.peakMotion,
agitationMean: score.meanMotion,
agitationStructPeak: score.peakStructure,
agitationStruct: score.meanStructure,
agitated,
// How much the FIRST half of the video resembles the last — the reading
// that separates a video which ends somewhere new from one that has
// come back on purpose. A recap should show up here and nowhere else.
+111
View File
@@ -28,6 +28,8 @@ import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
import { frameLuminance, frameVariance } from '../engine/hash.js';
import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
import { generateActor } from '../actors/ActorGenerator.js';
import { agitationScore } from '../engine/agitation.js';
import { probesForContext, ranged, METRIC_KEYS } from './scene-metrics.js';
const THUMB = { width: 256, height: 144 };
@@ -162,6 +164,108 @@ export function renderScene(engine, module, contexts) {
const variance = frameVariance(thumbs[0]);
const dead = lum < 0.002 || variance < 0.001;
// --- per-visualizer 12+ ranged metrics + legacy flat agitation --------
// Legacy flat agitation/flashPeak/motionPeak/structPeak: busiest context
// only (cheapest, what existing gallery.html reads). Ranged metrics:
// 6 contexts × 90-frame strip each → {mean,min,max,std,p10,p90} per key
// (see src/checks/scene-metrics.js). Vast preliminary library the director
// filters BEFORE casting; some songs simply won't use certain scenes.
let agitation = 0;
let flashPeak = 0;
let motionPeak = 0;
let motionMean = 0;
let structPeak = 0;
let structMean = 0;
let metrics = null;
if (!dead && contexts && contexts.length) {
try {
// Busiest context = most energetic bias — legacy flat value.
let probe = contexts[0];
for (const c of contexts) {
if ((c.bias.energy || 0) > (probe.bias.energy || 0)) probe = c;
}
engine.timeline.setDuration(probe.track.duration);
engine.setFeatureProvider(featureProviderFor(probe.track));
const arng = new Rng(hashString(`${module.name}:agitation:${probe.name}`));
const aparams = sampleValues(module, arng, probe.bias, probe.personality.temperament);
const aactorRng = new Rng(arng.fork(`actor:${module.actor || 'model'}`).seed);
const aactorSpec = module.kind === 'model' && module.actor
? generateActor({ summary: probe.track.summary, rng: aactorRng, archetype: module.actor, personality: probe.personality, identity: probe.personality.identity })
: null;
engine.setLayerSpecs([{
module,
params: aparams,
seed: arng.int(0, 0x7fffffff),
opacity: 1,
blend: 'normal',
palette: probe.palette,
personality: probe.personality,
actorSpec: aactorSpec,
}]);
engine.compositor.reset();
for (let f = probe.frame - 6; f < probe.frame; f++) engine.renderFrame(f);
const STRIP = 90; // 1.5 s @ 60 fps — catches Voronoi recut between phrase windows
const stripFrames = [];
const luminances = [];
for (let f = probe.frame; f < probe.frame + STRIP; f++) {
const pix = Uint8Array.from(engine.readPixels(engine.renderFrame(f)));
stripFrames.push(pix);
luminances.push(frameLuminance(pix));
}
const score = agitationScore(luminances, stripFrames, 60, { width: THUMB.width, height: THUMB.height });
agitation = score.agitation;
flashPeak = score.peakFlashRate;
motionPeak = score.peakMotion;
motionMean = score.meanMotion;
structPeak = score.peakStructure;
structMean = score.meanStructure;
} catch {
// leave agitation at 0 — gallery will show 0.000 rather than crash
}
// Ranged metrics across all 6 contexts (one 90-frame strip each, just once)
try {
const perContext = [];
for (const ctx of contexts) {
engine.timeline.setDuration(ctx.track.duration);
engine.setFeatureProvider(featureProviderFor(ctx.track));
const crng = new Rng(hashString(`${module.name}:metrics:${ctx.name}`));
const cparams = sampleValues(module, crng, ctx.bias, ctx.personality.temperament);
const cActorRng = new Rng(crng.fork(`actor:${module.actor || 'model'}`).seed);
const cActorSpec = module.kind === 'model' && module.actor
? generateActor({ summary: ctx.track.summary, rng: cActorRng, archetype: module.actor, personality: ctx.personality, identity: ctx.personality.identity })
: null;
engine.setLayerSpecs([{
module,
params: cparams,
seed: crng.int(0, 0x7fffffff),
opacity: 1,
blend: 'normal',
palette: ctx.palette,
personality: ctx.personality,
actorSpec: cActorSpec,
}]);
engine.compositor.reset();
for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f);
const MSTRIP = 90;
const mFrames = [];
const mLums = [];
for (let f = ctx.frame; f < ctx.frame + MSTRIP; f++) {
const px = Uint8Array.from(engine.readPixels(engine.renderFrame(f)));
mFrames.push(px);
mLums.push(frameLuminance(px));
}
perContext.push(probesForContext({ luminances: mLums, stripFrames: mFrames, width: THUMB.width, height: THUMB.height }));
}
metrics = {};
for (const key of METRIC_KEYS) {
const vals = perContext.map((p) => p[key]).filter((v) => typeof v === 'number' && Number.isFinite(v));
metrics[key] = ranged(vals);
}
} catch {
// metrics stays null — gallery shows legacy flat values
}
}
return {
thumbs,
// The six per-context descriptors, so a caller can average them into a
@@ -173,6 +277,13 @@ export function renderScene(engine, module, contexts) {
variety: pairs ? total / pairs : 0,
coverage: covered,
byBlock,
agitation,
flashPeak,
motionPeak,
motionMean,
structPeak,
structMean,
metrics,
error: dead
? `renders nothing — luminance ${lum.toFixed(4)}, variance ${variance.toFixed(4)}. ` +
'A failed shader compile scores 0.000 on every block; check the console for GLSL errors.'
+40 -2
View File
@@ -25,6 +25,7 @@ import { galleryContexts, renderScene, THUMB } from './gallery.js';
import { readsHistory } from '../params/schema.js';
import { GROUND_BIAS, groundTemperamentFrom, groundPersonalityFrom } from '../scenes/surface.js';
import metadata from '../scenes/metadata.json';
import { METRIC_KEYS } from './scene-metrics.js';
/**
* What invalidates the measurements.
@@ -45,8 +46,16 @@ const SOURCES = {
...import.meta.glob('/src/checks/variety/descriptors.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/variety/signature.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/gallery.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/scene-metrics.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/metadata.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/shader-contract.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/agitation.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/flash.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/Layer.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/comfort.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/scene-filter.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/composition-validator.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/scenes/surface.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/Identity.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/Personality.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/palette.js', { query: '?raw', import: 'default', eager: true }),
@@ -55,7 +64,7 @@ const SOURCES = {
};
/** The version of the measurement itself. Bump to force a refresh of everything. */
export const SCHEMA = 5;
export const SCHEMA = 7;
export function metricsFingerprint() {
let h = 0x811c9dc5 >>> 0;
@@ -133,8 +142,18 @@ export function measureLibrary({ onScene = null, contexts = null } = {}) {
try {
const list = scenes.filter((m) => m.kind === 'fragment' || m.kind === 'model');
for (const module of list) {
const { thumbs, variety, byBlock, descriptors, error } = renderScene(engine, module, ctx);
const { thumbs, variety, byBlock, descriptors, error, agitation, flashPeak, motionPeak, motionMean, structPeak, structMean, metrics } = renderScene(engine, module, ctx);
const bed = renderScene(engine, module, bedCtx);
// Round ranged metrics to 3dp (4dp for agitation trio) — small, stable diffs
const roundedMetrics = {};
if (metrics) {
for (const [k, r] of Object.entries(metrics)) {
roundedMetrics[k] = {
mean: round(r.mean, 3), min: round(r.min, 3), max: round(r.max, 3),
std: round(r.std, 3), p10: round(r.p10, 3), p90: round(r.p90, 3),
};
}
}
out[module.name] = {
coverage: round(thumbs.reduce((s, px) => s + litFraction(px), 0) / thumbs.length, 3),
// The WORST of the six, not the mean. What a ground has to
@@ -151,6 +170,16 @@ export function measureLibrary({ onScene = null, contexts = null } = {}) {
variety: round(variety, 3),
blocks: Object.fromEntries(
Object.entries(byBlock).map(([b, v]) => [b, round(v, 3)])),
// Legacy flat (busiest-context) — compat with existing comfort.js:agitationOf
agitation: round(agitation || 0, 3),
flashPeak: round(flashPeak || 0, 2),
motionPeak: round(motionPeak || 0, 3),
motionMean: round(motionMean || 0, 3),
structPeak: round(structPeak || 0, 3),
structMean: round(structMean || 0, 3),
// Ranged metrics — 12+ keys × {mean,min,max,std,p10,p90}
// Vast preliminary library the director filters before casting
metrics: roundedMetrics,
// Declared, not measured, and carried here anyway: it is a fact
// about the scene that the composition rules read, and having
// every compositional input in one file is the point.
@@ -193,6 +222,15 @@ export function metadataDrift(fresh) {
delta,
});
}
const aNew = row.agitation ?? 0;
const aWas = was.agitation ?? 0;
const aDelta = aNew - aWas;
if (Math.abs(aDelta) > 0.08) {
moved.push({
name, note: `agitation ${aWas.toFixed(2)} → ${aNew.toFixed(2)}`,
delta: aDelta,
});
}
}
const gone = Object.keys(previous).filter((n) => !fresh.scenes[n]);
return {
+23
View File
@@ -22,6 +22,7 @@ import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js';
import { peakFlashRate } from '../engine/flash.js';
import { AGITATION_CEILING, agitationScore } from '../engine/agitation.js';
import { generatePersonality } from '../look/Personality.js';
import { generateIdentity } from '../look/Identity.js';
import { generateActor } from '../actors/ActorGenerator.js';
@@ -154,6 +155,28 @@ export function runSceneGate(name) {
const rate = peakFlashRate(luminance, 60);
record(rate <= 3, 'flash rate', `${rate}/s at aggressive settings (ceiling 3)`);
// --- comfort / agitation -----------------------------------------
// Whole-screen flash + coherent motion as a single 0..1 score over a
// 1 s floating window. Mirrors the gallery's agitation measurement but
// sampled at the hot bias (energy/density/motion high) — the worst this
// scene is allowed to be when the track asks for it. Region-based motion
// (a ribbon in the centre) stays low; full-screen translation/zoom and
// uniform luminance pumping score high. See engine/agitation.js.
{
// Reuse the 300-frame luminance series above plus a frame strip for motion.
// Motion needs actual pixels; keep engine sized at the gate's 192x108.
const W = engine.width, H = engine.height;
const frames = [];
for (let f = 600; f < 900; f++) {
frames.push(Uint8Array.from(engine.readPixels(engine.renderFrame(f))));
}
const score = agitationScore(luminance, frames, 60, { width: W, height: H });
const detail = `agitation ${score.agitation.toFixed(2)} ` +
`(flash ${score.peakFlashRate}/s, motion ${(score.peakMotion * 100).toFixed(0)}% tiles, struct ${score.peakStructure.toFixed(2)}) ` +
`ceiling ${AGITATION_CEILING.toFixed(2)}`;
record(score.agitation <= AGITATION_CEILING, 'comfort', detail);
}
// --- identity response -----------------------------------------------
// The migration gate. A scene that declares it consumes the cast must
// produce a DIFFERENT PICTURE when the song's cast changes — otherwise
+109
View File
@@ -0,0 +1,109 @@
// Per-scene metric collection — the 12+ ranged metrics the director filters on.
//
// Extracted from gallery.js so both the gallery and the scene gate share the
// strip measurement (agitation + structure + coverage/brightness). gallery.js keeps
// the 90-frame busiest-context strip; scene-metrics.js knows how to reduce 6 such
// probe values into ranged stats {mean,min,max,std,p10,p90} and how to define each
// metric's read. Measured once per scene (6 contexts × one strip), not per bias.
import { frameVariance } from '../engine/hash.js';
import { agitationScore } from '../engine/agitation.js';
import { frameDescriptor } from './variety/descriptors.js';
import { descriptorDistance } from './variety/signature.js';
/** Fraction lit > 90, 0..1. */
export function litFraction(pixels) {
let lit = 0;
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i] + pixels[i + 1] + pixels[i + 2] > 90) lit++;
}
return lit / (pixels.length / 4);
}
/**
* Ranged stats over an array of numbers.
* @returns {{mean:number, min:number, max:number, std:number, p10:number, p90:number}}
*/
export function ranged(values) {
if (!values || !values.length) return { mean: 0, min: 0, max: 0, std: 0, p10: 0, p90: 0 };
const sorted = [...values].sort((a, b) => a - b);
const n = sorted.length;
const mean = sorted.reduce((a, b) => a + b, 0) / n;
const variance = sorted.reduce((s, v) => s + (v - mean) ** 2, 0) / n;
const pAt = (p) => {
const at = (n - 1) * p;
const lo = Math.floor(at), hi = Math.ceil(at);
return lo === hi ? sorted[lo] : sorted[lo] * (1 - (at - lo)) + sorted[hi] * (at - lo);
};
return {
mean, min: sorted[0], max: sorted[n - 1],
std: Math.sqrt(variance),
p10: pAt(0.10), p90: pAt(0.90),
};
}
function driftFromFrames(frames) {
if (!frames || frames.length < 2) return 0;
let sum = 0, n = 0;
for (let i = 1; i < frames.length; i++) {
try {
const w = frames[i].width || 256, h = frames[i].height || 144;
const a = frames[i - 1].pixels || frames[i - 1];
const b = frames[i].pixels || frames[i];
const da = frameDescriptor(a, w, h);
const db = frameDescriptor(b, w, h);
const d = descriptorDistance(da, db);
let s = 0, c = 0;
for (const k of ['scale', 'orient', 'layout', 'region', 'texture']) {
if (d[k] !== undefined) { s += d[k]; c++; }
}
sum += c ? s / c : 0;
n++;
} catch { /* ignore one bad pair */ }
}
return n ? sum / n : 0;
}
/**
* Per-context numeric probes (single strip at that context). Call 6× and ranged() each key.
* Returns one number per metric for this context.
*/
export function probesForContext({ luminances, stripFrames, width, height }) {
const score = agitationScore(luminances, stripFrames, 60, { width, height });
const lums = luminances;
const meanLum = lums.length ? lums.reduce((a, b) => a + b, 0) / lums.length : 0;
let meanVar = 0;
for (let i = 0; i < stripFrames.length; i++) {
try { meanVar += frameVariance(stripFrames[i]); } catch { /* ignore */ }
}
meanVar = stripFrames.length ? meanVar / stripFrames.length : 0;
let scaleEnergy = 0, layoutEnergy = 0, textureDensity = 0;
try {
const d = frameDescriptor(stripFrames[0], width, height);
if (d.scale && d.scale.length) scaleEnergy = d.scale.reduce((a, b) => a + b, 0) / d.scale.length;
if (d.layout && d.layout.length) layoutEnergy = d.layout.reduce((a, b) => a + Math.abs(b), 0) / d.layout.length;
if (d.texture && d.texture.length >= 2) textureDensity = (d.texture[0] + d.texture[1]) / 2;
} catch { /* keep 0 */ }
const drift = driftFromFrames(stripFrames.map((p) => ({ pixels: p, width, height })));
const cov = stripFrames.length ? litFraction(stripFrames[0]) : 0;
return {
coverage: cov,
luminance: meanLum,
variance: meanVar,
flashPeak: score.peakFlashRate,
motionPeak: score.peakMotion,
structPeak: score.peakStructure,
agitation: score.agitation,
drift,
scaleEnergy,
layoutEnergy,
textureDensity,
};
}
// Keys that participate in ranged stats (12 incl. costHint handled separately)
export const METRIC_KEYS = [
'coverage', 'luminance', 'variance',
'flashPeak', 'motionPeak', 'structPeak', 'agitation',
'drift', 'scaleEnergy', 'layoutEnergy', 'textureDensity',
];
+31 -4
View File
@@ -5,6 +5,12 @@ import {
} from './shader-contract.js';
import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.js';
import { clampValue } from '../params/schema.js';
import { surfaceOf } from '../scenes/surface.js';
import {
CANVAS_BEAT_CAP,
CANVAS_FLUX_CAP,
CANVAS_BEAT_SOFTEN_SPIKE,
} from '../look/comfort.js';
export const BLEND_MODES = ['normal', 'add', 'screen', 'multiply', 'overlay', 'softlight'];
@@ -185,6 +191,16 @@ export class Layer {
if (!features) return out;
// A — whole-screen guard: canvas scenes (≥50% coverage) must not swerve
// or jump with the beat. Every mid-tempo kick (≈2 Hz at 124 BPM) then
// moves the ENTIRE view in sync — the nausea source on
// ?song=centre&seed=3837488838. Only smaller regions/elements should
// react wildly; they light few tiles. This is the immediate, central cap:
// beat → ≤0.15 and spike→smooth, flux → ≤0.25. Composable scenes are
// untouched. Tunable in look/comfort.js; stale metadata detected via
// checks/metadata.js SOURCES. See engine/agitation.js for the tile metric.
const isCanvas = surfaceOf(this.module) === 'canvas';
for (const [name, r] of Object.entries(reactive)) {
const def = defs[name];
if (!def || def.type === 'palette') continue;
@@ -194,20 +210,31 @@ export class Layer {
const raw = features[r.feature];
if (raw === undefined) continue;
const shaped = applyResponse(Math.max(0, Math.min(1, raw)), r.response);
let amount = r.amount;
let response = r.response;
if (isCanvas) {
if (r.feature === 'beat') {
amount = Math.min(amount, CANVAS_BEAT_CAP);
if (CANVAS_BEAT_SOFTEN_SPIKE && response === 'spike') response = 'smooth';
} else if (r.feature === 'flux') {
amount = Math.min(amount, CANVAS_FLUX_CAP);
}
}
const shaped = applyResponse(Math.max(0, Math.min(1, raw)), response);
const [lo, hi] = def.range || [0, 1];
const span = hi - lo;
const base = out[name] !== undefined ? out[name] : lo;
if (def.type === 'vec2') {
out[name] = [
clampValue(def, [base[0] + shaped * r.amount * span, 0])[0],
clampValue(def, [0, base[1] + shaped * r.amount * span])[1],
clampValue(def, [base[0] + shaped * amount * span, 0])[0],
clampValue(def, [0, base[1] + shaped * amount * span])[1],
];
} else if (def.type === 'bool') {
out[name] = base;
} else {
out[name] = clampValue(def, base + shaped * r.amount * span);
out[name] = clampValue(def, base + shaped * amount * span);
}
}
return out;
+217
View File
@@ -0,0 +1,217 @@
// Whole-screen agitation — flash and large coherent motion + structure as a single comfort score.
//
// Mean luminance flash (engine/flash.js) catches WCAG 2.3.1 but an inverting
// checkerboard leaves the mean unchanged while the retina sees a full flash, and
// a full-frame pan never changes the mean at all yet is the dominant nausea
// source (vection). This module adds spatial reasoning: how much of the VIEW is
// changing together. A ribbon in the centre lights ~15 % of tiles; a coherent
// zoom/pan lights 80–100 %. The two arms are normalised independently and the
// larger one is the agitation — the worst thing the frame is doing right now.
//
// Revision 2: even 0.0 songs with moving rings / whole-screen pattern were
// aggressive. Tile luma threshold 0.05 (~13/255) missed slow ring drift
// (tile mean ~0.02-0.04) and Voronoi recut is a structure event, not a luma
// one. So:
// - threshold 0.05 -> 0.018 (5 luma units register; jitter ~1-2 stays below)
// - MOTION_CEILING 0.55 -> 0.32 (≥32% tiles = whole-screen, not 55%)
// - new third arm: structural distance via checks/variety descriptors
// (96×96 luma pyramid, scale/orient/layout/region/texture). Rings
// translating 2px score ~0.12 structure where tile luma is 0.02.
// Agitation is still max-of-arms (worst thing), now max of three.
import { peakFlashRate } from './flash.js';
import { frameDescriptor } from '../checks/variety/descriptors.js';
import { descriptorDistance } from '../checks/variety/signature.js';
export const FLASH_CEILING = 3; // flashes/s — WCAG 2.3.1 / Harding
export const MOTION_CEILING = 0.32; // ≥32% of tiles active = whole-screen (was 0.55)
export const STRUCTURE_CEILING = 0.12; // structural distance 0..1, ~0.10 separates calm/aggressive
export const WINDOW_SECONDS = 1; // floating window length
export const AGITATION_CEILING = 0.70; // scene-gate + gallery red line
export const WHOLE_THRESHOLD = 0.018; // tile mean luma delta to count active (was 0.05)
// How far below the ceiling a track still reads as calm vs. caution.
export const AGITATION_CAUTION = 0.50;
/**
* Fraction of tiles whose mean luma delta exceeds `threshold`.
*/
export function wholeScreenMotion(a, b, width, height, { grid = 4, threshold = WHOLE_THRESHOLD } = {}) {
if (a.length !== b.length || a.length === 0) return 0;
const cols = Math.max(1, grid | 0);
const rows = cols;
const tileW = Math.max(1, Math.floor(width / cols));
const tileH = Math.max(1, Math.floor(height / rows));
let active = 0;
const total = cols * rows;
for (let ty = 0; ty < rows; ty++) {
for (let tx = 0; tx < cols; tx++) {
const x0 = tx * tileW;
const y0 = ty * tileH;
const x1 = tx === cols - 1 ? width : x0 + tileW;
const y1 = ty === rows - 1 ? height : y0 + tileH;
let sum = 0;
let count = 0;
for (let y = y0; y < y1; y++) {
let base = y * width * 4;
for (let x = x0; x < x1; x++) {
const i = base + x * 4;
const la = (0.2126 * a[i] + 0.7152 * a[i + 1] + 0.0722 * a[i + 2]) / 255;
const lb = (0.2126 * b[i] + 0.7152 * b[i + 1] + 0.0722 * b[i + 2]) / 255;
sum += Math.abs(la - lb);
count++;
}
}
const mean = count ? sum / count : 0;
if (mean > threshold) active++;
}
}
return active / total;
}
/**
* Structural distance 0..1 between two frames (threshold-free).
* Uses the same descriptor the variety harness uses — scale/orient/layout/
* region/texture, colour excluded — via frameDescriptor + descriptorDistance.
* Cheap enough at 96×96; ~0.5ms/frame.
*/
export function wholeScreenStructure(a, b, width, height) {
if (a.length !== b.length || a.length === 0) return 0;
try {
const da = frameDescriptor(a, width, height);
const db = frameDescriptor(b, width, height);
const d = descriptorDistance(da, db);
// Structural mean over the six structural blocks (signature.js blockMean but inlined to avoid extra import)
let sum = 0, n = 0;
for (const k of ['scale', 'orient', 'layout', 'region', 'texture', 'motion']) {
if (d[k] !== undefined) { sum += d[k]; n++; }
}
// When motion block missing (no pair), da/db have no motion — exclude it
// blockMean in signature.js flat weights; do the same here.
return n ? sum / n : 0;
} catch {
return 0;
}
}
function peakFromDeltas(deltas, fps) {
if (!deltas.length) return 0;
const windowFrames = Math.max(1, Math.round(fps * WINDOW_SECONDS));
if (deltas.length <= windowFrames) {
return deltas.reduce((s, v) => s + v, 0) / deltas.length;
}
let worst = 0;
const step = Math.max(1, Math.round(fps / 4));
for (let i = 0; i + windowFrames <= deltas.length; i += step) {
let sum = 0;
for (let j = 0; j < windowFrames; j++) sum += deltas[i + j];
const mean = sum / windowFrames;
if (mean > worst) worst = mean;
}
if ((deltas.length - windowFrames) % step !== 0) {
let sum = 0;
const start = deltas.length - windowFrames;
for (let j = 0; j < windowFrames; j++) sum += deltas[start + j];
const mean = sum / windowFrames;
if (mean > worst) worst = mean;
}
return worst;
}
/**
* Peak whole-screen motion in any WINDOW_SECONDS window (floating, worst window wins).
*/
export function peakWholeScreenMotion(frames, { fps = 60, grid = 4, threshold = WHOLE_THRESHOLD, width, height } = {}) {
if (!frames || frames.length < 2) return 0;
if (width == null || height == null) return 0;
const deltas = [];
for (let i = 1; i < frames.length; i++) {
deltas.push(wholeScreenMotion(frames[i - 1], frames[i], width, height, { grid, threshold }));
}
return peakFromDeltas(deltas, fps);
}
/**
* Peak structural change in any WINDOW_SECONDS window (floating, worst window wins).
* Threshold-free — even subtle pattern shift registers.
*/
export function peakStructure(frames, { fps = 60, width, height } = {}) {
if (!frames || frames.length < 2) return 0;
if (width == null || height == null) return 0;
const deltas = [];
for (let i = 1; i < frames.length; i++) {
deltas.push(wholeScreenStructure(frames[i - 1], frames[i], width, height));
}
return peakFromDeltas(deltas, fps);
}
/**
* Combined comfort score and its components (three arms).
*
* Each arm is normalised against its own ceiling, then the larger one is the
* score. Keeps message actionable ("flash 4.2/s" vs "motion 62%" vs "struct 0.14")
* while giving a single sortable number. Max-of-three preserves "worst thing"
* semantics — a single noisy arm does not sum with the others.
*/
export function agitationScore(luminance, frames, fps, opts = {}) {
const {
grid = 4,
threshold = WHOLE_THRESHOLD,
width,
height,
} = opts;
const peakFlash = peakFlashRate(luminance, fps);
const flashMean = (() => {
const seconds = luminance.length / fps;
if (seconds <= 0) return 0;
let flashes = 0;
{
const extrema = [];
for (let i = 1; i < luminance.length - 1; i++) {
const a = luminance[i - 1], b = luminance[i], c = luminance[i + 1];
if ((b > a && b >= c) || (b < a && b <= c)) extrema.push({ index: i, value: b, isMax: b > a });
}
for (let i = 1; i < extrema.length - 1; i++) {
const prev = extrema[i - 1], here = extrema[i], next = extrema[i + 1];
if (!here.isMax) continue;
if (here.value - prev.value >= 0.1 && here.value - next.value >= 0.1) flashes++;
}
}
return flashes / seconds;
})();
let peakMotion = 0;
let meanMotion = 0;
let peakStruct = 0;
let meanStruct = 0;
if (frames && frames.length >= 2 && width && height) {
peakMotion = peakWholeScreenMotion(frames, { fps, grid, threshold, width, height });
const deltas = [];
for (let i = 1; i < frames.length; i++) deltas.push(wholeScreenMotion(frames[i - 1], frames[i], width, height, { grid, threshold }));
if (deltas.length) meanMotion = deltas.reduce((s, v) => s + v, 0) / deltas.length;
peakStruct = peakStructure(frames, { fps, width, height });
const sDeltas = [];
for (let i = 1; i < frames.length; i++) sDeltas.push(wholeScreenStructure(frames[i - 1], frames[i], width, height));
if (sDeltas.length) meanStruct = sDeltas.reduce((s, v) => s + v, 0) / sDeltas.length;
}
const flashNorm = FLASH_CEILING > 0 ? peakFlash / FLASH_CEILING : 0;
const motionNorm = MOTION_CEILING > 0 ? peakMotion / MOTION_CEILING : 0;
const structNorm = STRUCTURE_CEILING > 0 ? peakStruct / STRUCTURE_CEILING : 0;
const agitation = Math.max(flashNorm, motionNorm, structNorm);
return {
flashRate: flashMean,
peakFlashRate: peakFlash,
peakMotion,
meanMotion,
peakStructure: peakStruct,
meanStructure: meanStruct,
agitation,
};
}
+171 -9
View File
@@ -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]);
+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));
}
@@ -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 };
+24
View File
@@ -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 ───────────────────────────────────────────────
/**
+81
View File
@@ -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 };
+7 -2
View File
@@ -101,8 +101,13 @@ async function loadFile(file, { seed = null } = {}) {
document.title = `flow-state · ${state.show.fileName}`;
console.info('[flow-state]', describeLook(state.show.look));
} catch (err) {
dom.thLabel.textContent = 'analysis failed';
dom.thStep.textContent = `failed: ${err.message}`;
const refused = err && err.code === 'COMPOSITION_REFUSED';
dom.thLabel.textContent = refused ? 'high agitation — refuses to render' : 'analysis failed';
const detail = refused
? `director refused song: ${err.message} — try another seed, or a calmer song (murk is dark-noisy bright-hiss; validator expected static bed + small floaters)`
: `failed: ${err.message}`;
dom.thStep.textContent = detail;
if (refused) dom.thStep.title = detail;
console.error(err);
dom.changeTrack.hidden = false;
dom.changeTrack.textContent = 'try again';
+6 -3
View File
@@ -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);
+6 -5
View File
@@ -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);
}
}
+6 -2
View File
@@ -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
+6 -2
View File
@@ -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);
+13 -5
View File
@@ -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);
}
`,
+6 -3
View File
@@ -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);