music-video-gen/flow-state/src/engine/shader-contract.js
Dejvino e6f5a2f0d5 Epic 2.5.2: make the framing actually reach the image
Revisiting the framing layer turned up that it was not reaching four of the
forty-two scenes, and that none of its gates could have told us.

Those gates check the PLAN — cue sizes, the distribution of shot sizes across
a population, headroom, seek determinism — and a plan that never reaches the
image passes every one of them. Rendered at wide, normal and close, Scan
Tear, Pylon Grid, Pitch Shatter and the 3D Particle Field came back
byte-identical at every size.

The cause was a category error in the first implementation. Framing was
applied inside sigCamera, which is gated on the `camera` personality trait —
so a scene that declined the track's drift and sway silently declined the
shot size as well. That gating is right for a TRAIT and wrong for framing,
which is not one: framing is where the camera is standing for this shot, and
no scene should be exempt from it because of an unrelated art-direction
decision.

- Framing now lives in the shader epilogue, applied to the coordinate every
  fragment scene is handed, so honouring it is not optional. uv is left
  unframed on purpose: it is screen space, and prev() and sigGrain belong to
  the output image rather than to the scene being filmed.
- Scan Tear and Pitch Shatter build their image from uv deliberately — a
  signal artefact happens to the signal, not to the world behind it. They now
  slice on raw uv and build the field they displace from a new framedUv(p),
  so the tear stays locked to the frame while the imagery behind it is filmed
  wide or close.
- Particle Field receives framing in update() and honours it as a camera
  dolly, which is what framing literally is when a layer has a real camera.
  Distance divided by scale, matching the fragment path where the coordinate
  is divided by it.

New gate renders instead of inspecting: 42 of 42 scenes now respond to
framing, weakest Ridge Terrain at 0.22 of its own brightness, against a 0.05
floor. Also drops a stale comment on Layer.setFraming that still claimed
sigCamera applied it.

105/105 checks pass including the slow set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:18:08 +02:00

340 lines
12 KiB
JavaScript

// The uniform contract every shader scene is compiled against.
//
// Scenes do not write `main()`. They define:
//
// vec4 scene(vec2 uv, vec2 p)
//
// where `uv` is 0..1 across the frame and `p` is centred, aspect-corrected,
// roughly -1..1 on the short axis. Everything else — the preamble, the varying,
// main() itself — is injected here. That boilerplate reduction is what makes a
// thirty-scene library affordable to write and to keep consistent.
//
// RESOLUTION INDEPENDENCE: work in `uv`/`p`, never in pixels. If you genuinely
// need a pixel-sized feature, scale it by u_pixelScale so a 720p preview and a
// 4K export agree. The dual-resolution diff in tools/ exists to catch violations.
export const VERTEX_SHADER = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
/** Audio-reactive uniforms, filled per frame from the FeatureTrack. */
export const AUDIO_UNIFORMS = [
'u_loudness', // overall level, track-normalised 0..1
'u_rms',
'u_bandSub', // 20-60 Hz
'u_bandLow', // 60-250 Hz
'u_bandMid', // 250-2k
'u_bandHigh', // 2k-6k
'u_bandAir', // 6k-16k
'u_flux', // onset strength
'u_centroid', // spectral brightness 0..1
'u_flatness', // noisy vs tonal 0..1
'u_width', // stereo width 0..1
'u_beat', // decaying spike on each beat, 1 at the hit
'u_beatPhase', // 0..1 within the beat
'u_barPhase', // 0..1 within the bar
'u_phrasePhase',// 0..1 within an 8-bar phrase
'u_sectionProgress',
'u_sectionEnergy',
'u_buildSlope', // >0 while energy is ramping toward the next section
];
/**
* The track's personality, constant for the whole video. See look/Personality.js.
*
* These are what make sixteen unrelated shaders read as one production. A scene
* declares in `traits` which of them it honours, and the look generator will not
* cast a scene that cannot express what the track is built on.
*
* All of them are neutral by default, so a layer built without a personality
* renders exactly what it always rendered.
*/
export const SIGNATURE_UNIFORMS = {
u_sigSides: 'float', // signature form: 0 = round, else polygon sides
u_sigRound: 'float', // corner rounding of that form
u_sigElong: 'float', // how far from square the form is
u_sigTilt: 'float', // its resting angle
u_sigDrift: 'vec2', // camera translation per second
u_sigSway: 'float', // camera sway amplitude
u_sigSwayRate: 'float',
u_sigSpin: 'float', // slow camera roll, radians per second
u_sigBreathe: 'float', // bar-locked zoom
u_sigHorizon: 'float', // where the ground meets the sky, 0..1 up the frame
u_sigDepth: 'float', // distance falloff
u_sigWash: 'vec2', // background gradient direction and strength
u_sigLine: 'float', // line weight
u_sigSoft: 'float', // edge softness
u_sigTexture: 'float', // surface grain
u_sigFold: 'float', // kaleidoscopic folds, 1 = none
// FRAMING. Not a personality trait — this one is per SHOT, pushed by the
// arc driver rather than derived from the track. See look/framing.js.
//
// Every scene in the library is a locked-off, full-frame wide, and always
// has been. That is one shot type, held for the length of a song, and it is
// the reason cutting between two scenes changes the subject but never the
// FRAMING — which is at least half of how a real edit holds attention.
//
// Applied inside sigCamera, in scene coordinates, so a close-up is rendered
// close rather than being a magnified 720p image. That distinction is the
// whole reason this is a coordinate transform and not a post pass.
u_sigFrameScale: 'float', // >1 pushes in, <1 pulls back
u_sigFrameShift: 'vec2', // recentre, in scene units
};
export const FRAME_UNIFORMS = [
'u_time', 'u_frame', 'u_progress', 'u_seed',
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_opacity',
];
export const PREAMBLE = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_aspect;
uniform float u_pixelScale;
uniform float u_time;
uniform float u_frame;
uniform float u_progress;
uniform float u_seed;
uniform float u_opacity;
uniform vec3 u_colors[8];
uniform int u_colorCount;
${AUDIO_UNIFORMS.map((u) => `uniform float ${u};`).join('\n')}
${Object.entries(SIGNATURE_UNIFORMS).map(([u, t]) => `uniform ${t} ${u};`).join('\n')}
uniform sampler2D u_prev;
uniform int u_hasPrev;
varying vec2 vUv;
// --- palette helpers -------------------------------------------------------
// Scenes should reach for these instead of hardcoding colours, so the look
// generator can actually recolour them per track.
vec3 pal(int i) {
int n = max(u_colorCount, 1);
int k = int(mod(float(i), float(n)));
for (int j = 0; j < 8; j++) { if (j == k) return u_colors[j]; }
return u_colors[0];
}
/** Continuous ramp through the palette; t wraps. */
vec3 palRamp(float t) {
int n = max(u_colorCount, 1);
float f = fract(t) * float(n);
int i = int(floor(f));
return mix(pal(i), pal(i + 1), smoothstep(0.0, 1.0, fract(f)));
}
// --- noise -----------------------------------------------------------------
float hash11(float n) { return fract(sin(n) * 43758.5453123); }
float hash12(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453123); }
vec2 hash22(vec2 p) {
vec3 a = fract(vec3(p.xyx) * vec3(123.34, 234.34, 345.65));
a += dot(a, a + 34.45);
return fract(vec2(a.x * a.y, a.y * a.z));
}
float vnoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash12(i), b = hash12(i + vec2(1.0, 0.0));
float c = hash12(i + vec2(0.0, 1.0)), d = hash12(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(vec2 p, int octaves) {
float v = 0.0, amp = 0.5;
for (int i = 0; i < 8; i++) {
if (i >= octaves) break;
v += amp * vnoise(p);
p *= 2.02;
amp *= 0.5;
}
return v;
}
/** Cheap divergence-free-ish flow field. The backbone of the "flow" family. */
vec2 curl(vec2 p, float t) {
float e = 0.1;
float n1 = fbm(p + vec2(0.0, e) + t, 4);
float n2 = fbm(p - vec2(0.0, e) + t, 4);
float n3 = fbm(p + vec2(e, 0.0) + t, 4);
float n4 = fbm(p - vec2(e, 0.0) + t, 4);
return vec2(n1 - n2, n4 - n3) / (2.0 * e);
}
mat2 rot(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
/** N-fold kaleidoscopic fold of a centred coordinate. */
vec2 kaleido(vec2 p, float sides) {
if (sides < 1.5) return p;
float a = atan(p.y, p.x);
float r = length(p);
float seg = 6.28318530718 / sides;
a = abs(mod(a + seg * 0.5, seg) - seg * 0.5);
return vec2(cos(a), sin(a)) * r;
}
// --- personality -----------------------------------------------------------
// The four traits, as functions a scene applies in its own way. A scene that
// calls none of these must not declare the matching trait, or it will be cast
// in a track it cannot express. See look/Personality.js.
/**
* TRAIT: shape. Signed distance to the track's signature form, radius ~1.
* Round tracks return a circle, so a scene can call this unconditionally.
*/
float sigShape(vec2 q) {
q = rot(u_sigTilt) * q;
q.x /= max(u_sigElong, 0.05);
if (u_sigSides < 2.5) return length(q) - 1.0;
// Regular polygon by angular folding, then rounded back toward the circle.
float seg = 6.28318530718 / u_sigSides;
float a = atan(q.y, q.x);
float r = length(q);
float folded = cos(mod(a + seg * 0.5, seg) - seg * 0.5);
float poly = r * folded - cos(seg * 0.5);
return mix(poly, r - 1.0, clamp(u_sigRound, 0.0, 1.0));
}
/** TRAIT: shape, as a filled mask of the given radius, centred on a point. */
float sigForm(vec2 p, vec2 centre, float size) {
float d = sigShape((p - centre) / max(size, 1e-3)) * max(size, 1e-3);
return smoothstep(u_sigSoft * 0.25 + 0.004, -u_sigSoft * 0.25, d);
}
/**
* TRAIT: camera. The same operator filming every scene — a slow drift, a sway,
* a roll, and a bar-locked breath. Apply to a centred coordinate before using it.
*/
vec2 sigCamera(vec2 p) {
float t = u_time;
p = rot(u_sigSpin * t) * p;
p *= 1.0 - u_sigBreathe * sin(u_barPhase * 6.28318530718);
p += vec2(sin(t * u_sigSwayRate), cos(t * u_sigSwayRate * 0.83)) * u_sigSway;
// The pan RETURNS. A constant translation would be a camera on rails: two
// minutes in, every scene has left the frame entirely. This is a slow track
// across the subject and back, roughly a two-minute cycle.
p -= u_sigDrift * 20.0 * sin(t * 0.05);
return p;
}
/** TRAIT: style. Fold the frame the track's way. 1 fold means no fold. */
vec2 sigFolded(vec2 p) {
return kaleido(p, u_sigFold);
}
/** TRAIT: style. Turn a signed distance into an edge drawn in the track's hand. */
float sigEdge(float d) {
float w = 0.004 + u_sigLine * 0.03;
float soft = w * (0.25 + u_sigSoft * 1.5);
return smoothstep(w + soft, w - soft, abs(d));
}
/** TRAIT: style. The track's surface grain, for a scene to add at its own weight. */
float sigGrain(vec2 uv) {
if (u_sigTexture <= 0.001) return 0.0;
return (hash12(uv * 512.0 + floor(u_frame)) - 0.5) * u_sigTexture;
}
/**
* TRAIT: space. Height of the shared horizon in the same units as 'p'.
* Positive is up; a scene with any sense of ground should sit on it.
*/
float sigHorizonY() {
return (u_sigHorizon - 0.5) * 2.0;
}
/** TRAIT: space. The location's air: distance haze plus the background wash. */
vec3 sigAir(vec3 col, vec2 p, float distance01) {
vec3 far = pal(0) * (0.25 + 0.35 * u_sigDepth);
col = mix(col, far, clamp(distance01, 0.0, 1.0) * u_sigDepth);
col += pal(1) * dot(p, u_sigWash) * 0.35;
return col;
}
vec3 prev(vec2 uv) {
if (u_hasPrev == 0) return vec3(0.0);
return texture2D(u_prev, uv).rgb;
}
float sat(float x) { return clamp(x, 0.0, 1.0); }
vec3 sat3(vec3 x) { return clamp(x, 0.0, 1.0); }
/**
* The framed equivalent of uv, for scenes that build their image in uv space.
*
* Screen-space scenes — a scan tear, a vertical transposition — slice the FRAME
* and are right to do so: a signal artefact happens to the signal, not to the
* world behind it. But the imagery behind the slicing is still a subject, and a
* subject can be filmed wide or close. So the slice grid stays on raw uv while
* the field it displaces is built from this, which carries the shot's framing.
*/
vec2 framedUv(vec2 p) { return vec2(p.x / u_aspect, p.y) * 0.5 + 0.5; }
`;
const EPILOGUE = `
void main() {
vec2 uv = vUv;
vec2 p = (uv - 0.5) * 2.0;
p.x *= u_aspect;
// FRAMING is applied here, to the coordinate every scene is handed, rather
// than inside sigCamera where it started out.
//
// sigCamera is gated on the camera personality trait: a scene that does
// not want the track's drift and sway simply never calls it. That is
// correct for a TRAIT and wrong for framing, which is not one — it is where
// the camera is standing for this shot, and no scene should be exempt from
// it because of an unrelated art-direction decision. Measured, that
// accident left Scan Tear and Pylon Grid completely unframed, and the two
// are otherwise perfectly good candidates for a close-up.
//
// uv is deliberately NOT framed. It is screen space: prev() reads the
// feedback buffer with it and sigGrain speckles in it, and both of those
// belong to the output image rather than to the scene being filmed.
p = p / max(u_sigFrameScale, 0.05) + u_sigFrameShift;
vec4 col = scene(uv, p);
gl_FragColor = vec4(col.rgb, col.a * u_opacity);
}
`;
/**
* Assemble a complete fragment shader from a scene body plus its declared params.
* Param uniforms are appended to the preamble so a scene never declares them itself —
* the schema is the single source of truth, which is what the lint checks.
*/
export function buildFragmentShader(sceneModule) {
const paramUniforms = [];
for (const [name, def] of Object.entries(sceneModule.params || {})) {
if (!def.uniform) continue;
if (def.type === 'palette') continue; // palette rides in u_colors
const glslType = def.type === 'int' ? 'int' : def.type === 'vec2' ? 'vec2' : 'float';
paramUniforms.push(`uniform ${glslType} ${def.uniform}; // param: ${name}`);
}
return [
PREAMBLE,
paramUniforms.join('\n'),
'\n// ---- scene ----\n',
sceneModule.shader,
EPILOGUE,
].join('\n');
}