Files
music-video-gen/flow-state/src/scenes/shader/metaballs.js
T
DejvinoandClaude 7736390f9c 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>
2026-09-13 19:42:50 +02:00

79 lines
3.1 KiB
JavaScript

// Organic family: merging metaballs on an analytic orbit.
//
// Positions come from closed-form orbits rather than any simulation, which keeps
// the scene seek-exact — the same rule the 3D particle layer follows.
export const metaballs = {
name: 'Metaballs',
family: 'organic',
kind: 'fragment',
// Personality: see look/Personality.js.
consumes: ['cast', 'ink', 'staging'],
traits: ['shape', 'camera', 'style'],
params: {
count: { type: 'int', range: [2, 10], default: 5, uniform: 'u_count', bias: 'density' },
radius: { type: 'float', range: [0.1, 0.6], default: 0.3, uniform: 'u_radius', bias: 'energy' },
threshold: { type: 'float', range: [0.4, 2.2], default: 1.0, uniform: 'u_threshold' },
speed: { type: 'float', range: [0.03, 0.5], default: 0.15, uniform: 'u_speed', bias: 'motion', rate: true },
spread: { type: 'float', range: [0.2, 1.1], default: 0.6, uniform: 'u_spread' },
rim: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_rim' },
palette: { type: 'palette', count: 5 },
},
reactive: {
radius: { feature: 'bandLow', amount: 0.25 },
rim: { feature: 'beat', amount: 0.12, response: 'smooth' },
threshold: { feature: 'flux', amount: 0.2, response: 'inverse' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
p = sigCamera(p);
float field = 0.0;
vec3 tint = vec3(0.0);
for (int i = 0; i < 10; i++) {
if (i >= u_count) break;
float fi = float(i);
float s = u_seed + fi * 71.3;
// Homes on the song's lattice, orbited by this scene's own motion.
vec3 node = stageNode(fi, float(u_count));
vec2 centre = node.xy * u_spread * vec2(1.0, 0.62)
+ vec2(sin(t * (0.7 + fract(s * 0.13)) + s),
cos(t * (0.5 + fract(s * 0.29)) + s * 1.7)) * 0.25;
// Distance measured in the song's FORM rather than as a circle: for a
// round cast this is exactly length(p - centre), and for a hexagonal
// one the blobs merge as hexagons.
float radius = u_radius * node.z;
float d = castMain((p - centre) / max(radius, 1e-3)) * radius + radius;
float contribution = (radius * radius) / max(d * d, 1e-4);
field += contribution;
tint += pal(i) * contribution;
}
tint /= max(field, 1e-4);
float surface = smoothstep(u_threshold - 0.25, u_threshold + 0.25, field);
float rim = smoothstep(u_threshold + 0.35, u_threshold, field)
* smoothstep(u_threshold - 0.3, u_threshold, field);
vec3 col = pal(0) * 0.06;
col = mix(col, tint, surface);
// 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);
}
`,
};
export default metaballs;