// 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
};

/**
 * The song's CAST and INK — Epic 3's content and style artifacts.
 *
 * These differ from the signature uniforms above in kind, not degree. A
 * signature uniform is a modifier on an image the shader already had, which is
 * why a scene is free to ignore one. A cast uniform IS the image: a stage that
 * ignores it has nothing to draw. See look/Identity.js.
 */
export const IDENTITY_UNIFORMS = {
    u_castSides: 'float',    // protagonist: 0 = round, else polygon sides
    u_castRound: 'float',
    u_castElong: 'float',
    u_castTilt: 'float',
    u_castNotchN: 'float',   // notches cut into the boundary, 0 = none
    u_castNotchD: 'float',
    u_castHollow: 'float',   // >0 makes it an annulus — a form with a hole

    u_chorusSides: 'float',  // the second member: a relative, not a stranger
    u_chorusRound: 'float',
    u_chorusElong: 'float',
    u_chorusTilt: 'float',
    u_chorusNotchN: 'float',
    u_chorusNotchD: 'float',
    u_chorusHollow: 'float',

    u_inkWeight: 'float',    // stroke width
    u_inkEdge: 'float',      // 0 = soft/airbrushed, 1 = hard vector
    u_inkFill: 'float',      // index into Identity.FILLS
    u_inkHatchAngle: 'float',
    u_inkHatchScale: 'float',
    u_inkOutline: 'float',   // 0..1 outline strength on top of the fill
    u_inkPosterize: 'float', // 0 = off, else levels

    u_latKind: 'float',      // index into Identity.LATTICES
    u_latJitter: 'float',    // how far off the lattice things sit
    u_latSpread: 'float',    // how much of the frame it occupies
    u_latScaleSpread: 'float', // 0 = all one size, 1 = a few large, many small
    u_latScaleBias: 'float',   // + puts the large ones in the middle
    u_latScale: 'float',       // the song's element size, ~0.1 tiny .. ~0.9 huge

    u_focusN: 'float',         // how many focal points, 0 = none
    u_focusR: 'float',         // their reach, in scene units
    u_focusPull: 'float',      // + draws the field in, - opens a void
};

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')}

${Object.entries(IDENTITY_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;
}

// --- the cast --------------------------------------------------------------
// The song's own forms. A stage that places discrete elements places THESE, and
// that is what makes two stages in one video look like one video — and two
// videos of different songs look like different work.

/** Signed distance to a cast member, radius ~1 at size 1. */
float castSDF(vec2 q, float sides, float rnd, float elong, float tilt,
              float notchN, float notchD, float hollow) {
    q = rot(tilt) * q;
    q.x /= max(elong, 0.05);

    float r = length(q);
    float a = atan(q.y, q.x);

    float d;
    if (sides < 2.5) {
        d = r - 1.0;
    } else {
        float seg = 6.28318530718 / sides;
        float folded = cos(mod(a + seg * 0.5, seg) - seg * 0.5);
        float poly = r * folded - cos(seg * 0.5);
        d = mix(poly, r - 1.0, clamp(rnd, 0.0, 1.0));
    }

    // Notches scallop the boundary. Approximate as a radial perturbation — it
    // is not a true distance any more, but every use here is a thresholded mask
    // and the error is far below a pixel at the sizes these are drawn.
    if (notchN > 0.5) d += notchD * cos(notchN * a);
    // A hole through the middle. Cheap, and the single most recognisable thing
    // a generated form can have.
    if (hollow > 0.001) d = abs(d) - hollow * 0.35;
    return d;
}

/** The protagonist, centred, radius ~1. */
float castMain(vec2 q) {
    return castSDF(q, u_castSides, u_castRound, u_castElong, u_castTilt,
                   u_castNotchN, u_castNotchD, u_castHollow);
}

/** The chorus member — many of these, small. */
float castChorus(vec2 q) {
    return castSDF(q, u_chorusSides, u_chorusRound, u_chorusElong, u_chorusTilt,
                   u_chorusNotchN, u_chorusNotchD, u_chorusHollow);
}

// --- the staging -----------------------------------------------------------
// Where things go. Shared, so two stages in one video agree about composition —
// and so the SIZE HIERARCHY is a decision the song makes once rather than one
// each stage makes for itself. The first four stages all placed similarly-sized
// elements, which left feature scale out of the measurement entirely.

/** Node i of n on the song's lattice: xy position, z scale multiplier. */
vec3 stageNode(float i, float n) {
    vec2 h = hash22(vec2(i * 1.37 + 3.1, i * 0.71 + 7.7));
    float total = max(n, 1.0);
    vec2 pos;

    if (u_latKind < 0.5) {                    // grid
        float cols = max(1.0, floor(sqrt(total) + 0.5));
        float rows = max(1.0, ceil(total / cols));
        pos = vec2((mod(i, cols) / max(cols - 1.0, 1.0) - 0.5) * 2.0,
                   (floor(i / cols) / max(rows - 1.0, 1.0) - 0.5) * 2.0);
    } else if (u_latKind < 1.5) {             // radial rings
        float rings = max(1.0, floor(sqrt(total * 0.5) + 0.5));
        float ring = mod(i, rings) + 1.0;
        float a = (i / total) * 6.28318530718 * 3.0;
        pos = vec2(cos(a), sin(a)) * (ring / rings);
    } else if (u_latKind < 2.5) {             // spiral, golden angle
        float a = i * 2.39996323;
        pos = vec2(cos(a), sin(a)) * sqrt(i / total);
    } else if (u_latKind < 3.5) {             // scatter
        pos = (h - 0.5) * 2.0;
    } else {                                  // strata
        float rows = max(1.0, floor(total / 4.0 + 0.5));
        pos = vec2((h.x - 0.5) * 2.0,
                   (mod(i, rows) / max(rows - 1.0, 1.0) - 0.5) * 2.0);
    }

    pos += (h - 0.5) * u_latJitter;
    pos *= u_latSpread;
    // Sits on the same ground every other scene in the track sits on.
    pos.y += sigHorizonY() * 0.3;

    // A power law when the song wants a hierarchy, near-uniform when it does
    // not. Biased toward the middle or the edges.
    float u = max(hash11(i * 7.13 + 1.7), 0.001);
    float size = mix(1.0, pow(u, 1.0 + u_latScaleSpread * 2.5) * 2.4, u_latScaleSpread);
    size *= 1.0 + u_latScaleBias * (0.5 - length(pos) * 0.5);

    // The song's own element size, relative to the neutral 0.35. A stage
    // multiplies its own size param by this rather than choosing outright, so
    // one song is made of a few huge forms and another of many small ones.
    size *= u_latScale / 0.35;

    return vec3(pos, max(size, 0.05));
}

/** The song's element size as a multiplier a stage applies to its own size. */
float stageScale() { return u_latScale / 0.35; }

// --- the focus -------------------------------------------------------------
// Somewhere for a full-frame field to be ABOUT.
//
// An edge-to-edge texture has no composition to vary: measured, two songs of
// Voronoi Shatter differ by 0.004 in the layout block, which is nothing, and
// that is honest rather than a metric failure — the cells change and the
// arrangement does not. These give the field one to three points to organise
// itself around, drawn from the same lattice everything else is placed on, so a
// scene using them is composing in the song's terms rather than inventing a
// centre of its own.

/** Where focal point i sits, in scene units. */
vec2 focusAt(float i) {
    return stageNode(i * 3.0 + 1.0, max(u_focusN, 1.0) * 3.0).xy * 0.8;
}

/**
 * Influence at p: 1 at a focal point, falling to 0 at its reach.
 * Zero everywhere when the song asked for no focus.
 */
float focusField(vec2 p) {
    if (u_focusN < 0.5) return 0.0;
    float best = 0.0;
    for (int i = 0; i < 3; i++) {
        if (float(i) >= u_focusN) break;
        float d = length(p - focusAt(float(i)));
        best = max(best, smoothstep(u_focusR, u_focusR * 0.15, d));
    }
    return best;
}

/**
 * Warp a coordinate toward the focus, or away from it.
 *
 * The amount argument is the scene's own appetite for it. A field applies this to the
 * coordinate it tiles in, and the tiling densifies or opens up around the point
 * without the scene needing to know where the point is or why.
 */
vec2 focusWarp(vec2 p, float amount) {
    if (u_focusN < 0.5 || amount <= 0.0) return p;
    vec2 q = p;
    for (int i = 0; i < 3; i++) {
        if (float(i) >= u_focusN) break;
        vec2 d = p - focusAt(float(i));
        float r = length(d);
        float w = smoothstep(u_focusR, 0.0, r);
        q += normalize(d + 1e-5) * w * u_focusPull * amount * u_focusR * 0.45;
    }
    return q;
}

// --- the ink ---------------------------------------------------------------
// How the cast is drawn. Changes every pixel of every stage at once, and does
// it structurally rather than chromatically — which is the point, since colour
// was already the only register doing any work.

// The ink and the STYLE TRAIT are one decision applied at two levels, so both
// feed the same two numbers.
//
// They were separate until surface treatment moved out of the scenes: sigGrain
// went to the post chain and sigEdge became inkStroke, which between them left
// the style trait with almost nothing to express — measured, a style+shape
// signature had two eligible scenes left in the whole library. The trait is not
// obsolete, it is now carried by the ink, and a scene that draws in the song's
// hand is honouring the track's line weight by construction.
float inkWeight() { return 0.004 + mix(u_inkWeight, u_sigLine, 0.4) * 0.055; }
float inkSoft()   { return mix(0.03, 0.0015, clamp(u_inkEdge * 0.6 + (1.0 - u_sigSoft) * 0.4, 0.0, 1.0)); }

/** The fill treatment as a 0..1 coverage pattern. 1 everywhere when flat. */
float inkPattern(vec2 uv) {
    int mode = int(u_inkFill + 0.5);
    if (mode == 2) {                                  // hatch
        vec2 h = rot(u_inkHatchAngle) * uv * u_inkHatchScale;
        return smoothstep(0.3, 0.7, 0.5 + 0.5 * sin(h.y));
    }
    if (mode == 3) {                                  // stipple
        return step(0.42, hash12(floor(uv * u_inkHatchScale * 2.0)));
    }
    if (mode == 4) {                                  // halftone
        vec2 g = fract(uv * u_inkHatchScale * 0.25) - 0.5;
        return smoothstep(0.38, 0.28, length(g));
    }
    return 1.0;
}

/**
 * Ink coverage for a signed distance: the fill in the track's treatment, plus
 * its outline. The 'hollow' fill treatment draws the outline only.
 */
float inkMask(float d, vec2 uv) {
    float soft = inkSoft();
    int mode = int(u_inkFill + 0.5);

    float fillA = smoothstep(soft, -soft, d) * inkPattern(uv);
    if (mode == 5) fillA = 0.0;

    float w = inkWeight();
    float strength = (mode == 5) ? 1.0 : u_inkOutline;
    float line = smoothstep(w + soft, w - soft, abs(d)) * strength;

    return clamp(max(fillA, line), 0.0, 1.0);
}

/**
 * Outline only, in the song's hand. The drop-in replacement for sigEdge.
 *
 * sigEdge draws a line at the track's line weight; this draws it at the
 * identity's, which is the same idea one layer up. Kept separate from inkMask
 * because a scene that only ever wanted an edge should not suddenly acquire a
 * fill when it migrates.
 */
float inkStroke(float d) {
    float w = inkWeight();
    float soft = inkSoft();
    return smoothstep(w + soft, w - soft, abs(d));
}

/**
 * The protagonist as a filled, inked mask at a point. Replaces sigForm.
 *
 * Same signature as the thing it supersedes so the substitution is mechanical
 * across the library — see MIGRATION.md. uv is recomputed here rather than
 * passed, so the call site does not have to change shape.
 */
float castForm(vec2 p, vec2 centre, float size) {
    float s = max(size, 1e-3);
    vec2 uv = vec2(p.x / u_aspect, p.y) * 0.5 + 0.5;
    return inkMask(castMain((p - centre) / s) * s, uv);
}

/** The track's value structure. Off unless the identity asked for it. */
vec3 inkValue(vec3 col) {
    if (u_inkPosterize < 1.5) return col;
    float n = u_inkPosterize;
    return floor(col * n + 0.5) / n;
}

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');
}
