simple_editor\engine/
shaders.rs

1//! GLSL sources for every GPU effect (engine/gpu.rs compiles these).
2//!
3//! Convention: one full-screen triangle vertex shader (`VERT`) plus a fragment shader per effect built
4//! from `PRELUDE` + the effect body. A **per-effect body** (everything below the divider near the
5//! bottom of this file) is a complete shader: it defines its own `void main()`, samples `tex` and mixes
6//! its result back through the mask itself — see "Rules every body below follows".
7//!
8//! The `vec4 effect(vec4 src, vec2 uv)` + `MAIN` convention is used only where the caller supplies the
9//! body: `EffectKind::Shader` (via `user_shader`) and gpu.rs's `COPY_BODY` / `MATTE_BODY`. `MAIN`
10//! samples `tex`, calls `effect` and applies the mask, so those bodies never write `out_color`.
11//! `fragment()` does the assembly for both cases.
12//!
13//! Uniforms available to every effect:
14//!   sampler2D tex      the layer being processed (straight alpha, top-down)
15//!   vec2  u_res        layer size in pixels
16//!   float u_time       clip-local seconds
17//!   float u_scale      canvas px per project px (pixel-sized params must multiply by this)
18//!   float p0..p7       the effect parameters in `EffectKind::params()` order
19//!   sampler2D u_mask   mask coverage (1 = apply); `u_has_mask` is 0 when there is none
20//! Fragment output is `out_color` (straight alpha).
21//!
22//! Textures are uploaded top-down and the vertex shader flips the clip-space Y instead, so `uv.y = 0`
23//! is the top of the image everywhere — in the sampler, in `glReadPixels` output and in egui.
24
25use crate::model::EffectKind;
26
27/// Full-screen triangle; no vertex buffer needed (gl_VertexID based).
28pub const VERT: &str = r#"#version 330 core
29out vec2 v_uv;
30void main() {
31    vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
32    v_uv = p;
33    // uv.y = 0 lands on GL row 0, so a top-down upload stays top-down in the FBO, in glReadPixels
34    // output and in egui — no flip anywhere.
35    gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
36}
37"#;
38
39/// Shared header: version, precision, varyings, the uniform block and helpers (rgb<->hsl, luma, hash
40/// noise, clamped sampling, mask application).
41pub const PRELUDE: &str = r#"#version 330 core
42precision highp float;
43
44in vec2 v_uv;
45out vec4 out_color;
46
47uniform sampler2D tex;
48uniform vec2 u_res;
49uniform float u_time;
50uniform float u_scale;
51uniform float p0;
52uniform float p1;
53uniform float p2;
54uniform float p3;
55uniform float p4;
56uniform float p5;
57uniform float p6;
58uniform float p7;
59uniform sampler2D u_mask;
60uniform int u_has_mask;
61
62float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }
63
64float hash12(vec2 p) {
65    vec3 q = fract(vec3(p.xyx) * 0.1031);
66    q += dot(q, q.yzx + 33.33);
67    return fract((q.x + q.y) * q.z);
68}
69
70float hash11(float x) { return hash12(vec2(x, x * 1.7 + 0.37)); }
71
72vec3 rgb2hsl(vec3 c) {
73    float mx = max(c.r, max(c.g, c.b));
74    float mn = min(c.r, min(c.g, c.b));
75    float l = (mx + mn) * 0.5;
76    float d = mx - mn;
77    float h = 0.0;
78    float s = 0.0;
79    if (d > 1e-6) {
80        s = (l > 0.5) ? d / max(2.0 - mx - mn, 1e-6) : d / max(mx + mn, 1e-6);
81        if (mx == c.r) {
82            h = (c.g - c.b) / d + ((c.g < c.b) ? 6.0 : 0.0);
83        } else if (mx == c.g) {
84            h = (c.b - c.r) / d + 2.0;
85        } else {
86            h = (c.r - c.g) / d + 4.0;
87        }
88        h /= 6.0;
89    }
90    return vec3(h, s, l);
91}
92
93float hue2rgb(float p, float q, float t) {
94    t = fract(t);
95    if (t < 1.0 / 6.0) { return p + (q - p) * 6.0 * t; }
96    if (t < 0.5) { return q; }
97    if (t < 2.0 / 3.0) { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }
98    return p;
99}
100
101vec3 hsl2rgb(vec3 c) {
102    if (c.y <= 0.0) { return vec3(c.z); }
103    float q = (c.z < 0.5) ? c.z * (1.0 + c.y) : c.z + c.y - c.z * c.y;
104    float p = 2.0 * c.z - q;
105    return vec3(hue2rgb(p, q, c.x + 1.0 / 3.0), hue2rgb(p, q, c.x), hue2rgb(p, q, c.x - 1.0 / 3.0));
106}
107
108/// Sample `t` with the uv clamped to the half-texel border of a `u_res`-sized image.
109vec4 sample_clamped(sampler2D t, vec2 uv) {
110    vec2 half_texel = 0.5 / max(u_res, vec2(1.0));
111    return texture(t, clamp(uv, half_texel, 1.0 - half_texel));
112}
113
114float mask_at(vec2 uv) { return (u_has_mask == 0) ? 1.0 : texture(u_mask, uv).r; }
115
116/// Blend an effect result back over the untouched source through the mask.
117vec4 apply_mask(vec4 src, vec4 fx, vec2 uv) { return mix(src, fx, mask_at(uv)); }
118"#;
119
120/// Entry point appended after a `vec4 effect(vec4 src, vec2 uv)` body: sample, run `effect`, mask.
121/// Used by `user_shader` (`EffectKind::Shader`) and gpu.rs's copy/matte programs only — the built-in
122/// bodies below write `out_color` themselves.
123pub const MAIN: &str = r#"
124void main() {
125    vec2 uv = v_uv;
126    vec4 src = texture(tex, uv);
127    out_color = apply_mask(src, effect(src, uv), uv);
128}
129"#;
130
131/// Body for each `EffectKind` that runs on the GPU. `None` = geometric or CPU-only.
132///
133/// A body is a complete shader minus the prelude: it defines `void main()`, samples `tex` itself and
134/// ends with `out_color = mix(src, res, m)` so masking is explicit (see the rules by the bodies).
135/// Parameters arrive as `p0..p7` in `EffectKind::params()` order (a kind with more declares the extras
136/// itself); pixel-sized ones must be multiplied by `u_scale`.
137pub fn body(kind: crate::model::EffectKind) -> Option<&'static str> {
138    use crate::model::EffectKind as K;
139    Some(match kind {
140        K::Blur => BLUR,
141        K::Pixelate => PIXELATE,
142        K::Tint => TINT,
143        K::Color => COLOR,
144        K::Vignette => VIGNETTE,
145        K::Sharpen => SHARPEN,
146        K::Invert => INVERT,
147        K::Grayscale => GRAYSCALE,
148        K::Flip => FLIP,
149        K::Crop => CROP,
150        K::ChromaKey => CHROMA_KEY,
151        K::ColorReplace => COLOR_REPLACE,
152        K::Curves => CURVES,
153        K::Levels => LEVELS,
154        K::HueShift => HUE_SHIFT,
155        K::JpegCompress => JPEG_COMPRESS,
156        K::MotionBlur => MOTION_BLUR,
157        K::EdgeGlow => EDGE_GLOW,
158        K::Threshold => THRESHOLD,
159        K::BlobTrack => BLOB_TRACK,
160        K::Vhs => VHS,
161        K::RecDot => REC_DOT,
162        // Wobble / Plane3d move the layer (see engine::effects::wobble / plane3d_matrix);
163        // Shader is wrapped by `user_shader` instead.
164        K::Wobble | K::Plane3d | K::Shader => return None,
165    })
166}
167
168/// The complete fragment source for one effect, or None when it has no GPU body.
169pub fn fragment(kind: EffectKind, user_src: &str) -> Option<String> {
170    if kind == EffectKind::Shader {
171        return Some(user_shader(user_src));
172    }
173    // No `MAIN` here: a built-in body is already a complete shader. Appending it would define
174    // `main()` twice and call an `effect()` no body declares — the program would not link.
175    Some(format!("{PRELUDE}\n{}", body(kind)?))
176}
177
178/// Blend modes as a GLSL function (`vec3 blend(int mode, vec3 s, vec3 d)`) mirroring engine::blend.
179/// The int is `BlendMode::ALL`'s index — see `blend_index` in engine/gpu.rs.
180pub const BLEND: &str = r#"
181float blend_1(int mode, float s, float d) {
182    if (mode == 1) { return s * d; }                                    // Multiply
183    if (mode == 2) { return 1.0 - (1.0 - s) * (1.0 - d); }              // Screen
184    if (mode == 3) {                                                    // Overlay
185        return (d <= 0.5) ? 2.0 * s * d : 1.0 - 2.0 * (1.0 - s) * (1.0 - d);
186    }
187    if (mode == 4) { return min(s, d); }                                // Darken
188    if (mode == 5) { return max(s, d); }                                // Lighten
189    if (mode == 6) { return min(s + d, 1.0); }                          // Add
190    if (mode == 7) { return max(d - s, 0.0); }                          // Subtract
191    if (mode == 8) { return abs(s - d); }                               // Difference
192    if (mode == 9) {                                                    // SoftLight
193        if (s <= 0.5) { return d - (1.0 - 2.0 * s) * d * (1.0 - d); }
194        float g = (d <= 0.25) ? ((16.0 * d - 12.0) * d + 4.0) * d : sqrt(d);
195        return d + (2.0 * s - 1.0) * (g - d);
196    }
197    if (mode == 10) {                                                   // HardLight
198        return (s <= 0.5) ? 2.0 * s * d : 1.0 - 2.0 * (1.0 - s) * (1.0 - d);
199    }
200    if (mode == 11) {                                                   // ColorDodge
201        if (d <= 0.0) { return 0.0; }
202        if (s >= 1.0) { return 1.0; }
203        return min(d / (1.0 - s), 1.0);
204    }
205    if (mode == 12) {                                                   // ColorBurn
206        if (d >= 1.0) { return 1.0; }
207        if (s <= 0.0) { return 0.0; }
208        return 1.0 - min((1.0 - d) / s, 1.0);
209    }
210    return s;                                                           // Normal
211}
212
213vec3 blend(int mode, vec3 s, vec3 d) {
214    if (mode == 0) { return s; }
215    return vec3(blend_1(mode, s.r, d.r), blend_1(mode, s.g, d.g), blend_1(mode, s.b, d.b));
216}
217"#;
218
219/// Mask rasteriser (rect / ellipse / polygon / path with feather, expand, invert, rotation).
220/// Appended to `PRELUDE`; everything is in canvas pixels, relative to the layer centre.
221/// Writes coverage to every channel so it reads as `.r` (a matte) or as alpha.
222pub const MASK: &str = r#"
223uniform int m_shape;
224uniform vec2 m_center;
225uniform vec2 m_radius;
226uniform float m_rot;
227uniform float m_feather;
228uniform float m_expand;
229uniform float m_opacity;
230uniform int m_invert;
231uniform int m_count;
232// ponytail: 64 points is plenty for hand-drawn masks; a point texture would lift the cap.
233uniform vec2 m_points[64];
234
235float sd_box(vec2 p, vec2 b) {
236    vec2 d = abs(p) - b;
237    return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0);
238}
239
240// ponytail: cheap ellipse SDF (exact only on circles); good enough for a feathered edge.
241float sd_ellipse(vec2 p, vec2 r) {
242    vec2 rr = max(r, vec2(1e-4));
243    return (length(p / rr) - 1.0) * min(rr.x, rr.y);
244}
245
246float sd_poly(vec2 p) {
247    int n = min(m_count, 64);
248    if (n < 3) { return 1e6; }
249    vec2 w0 = p - m_points[0];
250    float d = dot(w0, w0);
251    float s = 1.0;
252    for (int i = 0, j = n - 1; i < n; j = i, i++) {
253        vec2 e = m_points[j] - m_points[i];
254        vec2 w = p - m_points[i];
255        vec2 b = w - e * clamp(dot(w, e) / max(dot(e, e), 1e-9), 0.0, 1.0);
256        d = min(d, dot(b, b));
257        bvec3 c = bvec3(p.y >= m_points[i].y, p.y < m_points[j].y, e.x * w.y > e.y * w.x);
258        if (all(c) || all(not(c))) { s = -s; }
259    }
260    return s * sqrt(d);
261}
262
263void main() {
264    vec2 q = v_uv * u_res - m_center;
265    float a = radians(m_rot);
266    float ca = cos(a);
267    float sa = sin(a);
268    q = vec2(q.x * ca + q.y * sa, -q.x * sa + q.y * ca);
269    float d;
270    if (m_shape == 0) {
271        d = sd_box(q, m_radius);
272    } else if (m_shape == 1) {
273        d = sd_ellipse(q, m_radius);
274    } else {
275        d = sd_poly(q);
276    }
277    d -= m_expand;
278    float cov = clamp(0.5 - d / max(m_feather, 1.0), 0.0, 1.0);
279    if (m_invert != 0) { cov = 1.0 - cov; }
280    out_color = vec4(cov * m_opacity);
281}
282"#;
283
284/// Composite/transform shader: samples a layer through an inverse 3x3 (perspective-capable) matrix with
285/// the chosen `Scaler` (0 = nearest, 1 = bilinear, 2 = bicubic Catmull-Rom) and blends it onto the canvas.
286/// Appended to `PRELUDE` + `BLEND`. `u_inv` maps canvas pixels (y down) to layer uv.
287pub const COMPOSITE: &str = r#"
288uniform sampler2D u_dst;
289uniform vec2 u_layer;
290uniform mat3 u_inv;
291uniform int u_scaler;
292uniform int u_mode;
293uniform float u_opacity;
294
295vec4 tap(vec2 ip) {
296    return texelFetch(tex, ivec2(clamp(ip, vec2(0.0), u_layer - 1.0)), 0);
297}
298
299vec4 catmull_w(float t) {
300    float t2 = t * t;
301    float t3 = t2 * t;
302    return 0.5 * vec4(-t3 + 2.0 * t2 - t, 3.0 * t3 - 5.0 * t2 + 2.0, -3.0 * t3 + 4.0 * t2 + t, t3 - t2);
303}
304
305// Alpha-weighted accumulation (matches the CPU compositor: transparent texels must not darken edges).
306vec4 sample_layer(vec2 uv) {
307    vec2 sp = uv * u_layer - 0.5;
308    if (u_scaler == 0) { return tap(floor(sp + 0.5)); }
309    vec4 acc = vec4(0.0);
310    if (u_scaler == 1) {
311        vec2 f = floor(sp);
312        vec2 t = sp - f;
313        for (int j = 0; j < 2; j++) {
314            for (int i = 0; i < 2; i++) {
315                float w = (i == 0 ? 1.0 - t.x : t.x) * (j == 0 ? 1.0 - t.y : t.y);
316                vec4 c = tap(f + vec2(float(i), float(j)));
317                acc += vec4(c.rgb * c.a * w, c.a * w);
318            }
319        }
320    } else {
321        vec2 f = floor(sp);
322        vec4 wx = catmull_w(sp.x - f.x);
323        vec4 wy = catmull_w(sp.y - f.y);
324        for (int j = 0; j < 4; j++) {
325            for (int i = 0; i < 4; i++) {
326                float w = wx[i] * wy[j];
327                vec4 c = tap(f + vec2(float(i - 1), float(j - 1)));
328                acc += vec4(c.rgb * c.a * w, c.a * w);
329            }
330        }
331    }
332    if (acc.a <= 0.0) { return vec4(0.0); }
333    return vec4(clamp(acc.rgb / acc.a, 0.0, 1.0), clamp(acc.a, 0.0, 1.0));
334}
335
336void main() {
337    vec3 h = u_inv * vec3(v_uv * u_res, 1.0);
338    float z = (abs(h.z) < 1e-9) ? 1e-9 : h.z;
339    vec2 uv = h.xy / z;
340    // derivatives must be taken outside any branch
341    vec2 e = max(fwidth(uv), vec2(1e-8));
342    vec2 dd = min(uv, 1.0 - uv);
343    float cov = clamp(min(dd.x / e.x, dd.y / e.y) + 0.5, 0.0, 1.0);
344    vec4 d = texture(u_dst, v_uv);
345    if (cov <= 0.0 || h.z <= 0.0) { out_color = d; return; }
346    vec4 s = sample_layer(uv);
347    float a = s.a * u_opacity * cov * mask_at(v_uv);
348    if (a <= 0.0) { out_color = d; return; }
349    float ao = a + d.a * (1.0 - a);
350    if (ao <= 0.0) { out_color = vec4(0.0); return; }
351    vec3 b = blend(u_mode, s.rgb, d.rgb);
352    vec3 co = ((1.0 - d.a) * a * s.rgb + (1.0 - a) * d.a * d.rgb + a * d.a * b) / ao;
353    out_color = vec4(co, ao);
354}
355"#;
356
357/// Wrap a user shader body (`EffectKind::Shader`) so it sees the same prelude and knobs `u1..u8`.
358/// The body must define `vec4 effect(vec4 src, vec2 uv)` (see `model::DEFAULT_SHADER`).
359pub fn user_shader(src: &str) -> String {
360    let mut s = String::with_capacity(PRELUDE.len() + src.len() + 512);
361    s.push_str(PRELUDE);
362    for i in 0..8 {
363        s.push_str(&format!("#define u{} p{}\n", i + 1, i));
364    }
365    s.push('\n');
366    s.push_str(src);
367    s.push_str(MAIN);
368    s
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    fn balanced(src: &str) -> bool {
376        let mut n = 0i32;
377        for c in src.chars() {
378            match c {
379                '{' => n += 1,
380                '}' => {
381                    n -= 1;
382                    if n < 0 {
383                        return false;
384                    }
385                }
386                _ => {}
387            }
388        }
389        n == 0
390    }
391
392    /// Identifiers the source declares: uniforms, `#define`s and function names.
393    fn declared(src: &str) -> Vec<String> {
394        let mut out = Vec::new();
395        for line in src.lines() {
396            let l = line.trim();
397            if let Some(rest) = l.strip_prefix("uniform ") {
398                // "uniform vec2 m_points[64];" -> m_points
399                if let Some(name) = rest.split_whitespace().nth(1) {
400                    out.push(name.trim_end_matches(';').split('[').next().unwrap_or(name).to_string());
401                }
402            } else if let Some(rest) = l.strip_prefix("#define ") {
403                if let Some(name) = rest.split_whitespace().next() {
404                    out.push(name.to_string());
405                }
406            } else if let Some(i) = l.find('(') {
407                // "vec4 effect(vec4 src, vec2 uv) {" -> effect
408                let head = &l[..i];
409                let mut w = head.split_whitespace();
410                if let (Some(_ty), Some(name)) = (w.next(), w.next()) {
411                    if w.next().is_none() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
412                        out.push(name.to_string());
413                    }
414                }
415            }
416            if let Some(rest) = l.strip_prefix("in ").or_else(|| l.strip_prefix("out ")) {
417                if let Some(name) = rest.split_whitespace().nth(1) {
418                    out.push(name.trim_end_matches(';').to_string());
419                }
420            }
421        }
422        out
423    }
424
425    /// Every `u_*` / `p0..p7` / `m_*` identifier used by `src` must be declared in it.
426    fn undeclared_uniforms(src: &str) -> Vec<String> {
427        let decl = declared(src);
428        let mut bad = Vec::new();
429        let mut word = String::new();
430        for c in src.chars().chain(std::iter::once(' ')) {
431            if c.is_alphanumeric() || c == '_' {
432                word.push(c);
433                continue;
434            }
435            let w = std::mem::take(&mut word);
436            let interesting = w.starts_with("u_")
437                || w.starts_with("m_")
438                || (w.len() == 2 && w.starts_with('p') && w.as_bytes()[1].is_ascii_digit());
439            if interesting && !decl.contains(&w) && !bad.contains(&w) {
440                bad.push(w);
441            }
442        }
443        bad
444    }
445
446    const SAMPLE_BODY: &str = "vec4 effect(vec4 src, vec2 uv) {\n    vec4 c = sample_clamped(tex, uv);\n    \
447         return vec4(mix(c.rgb, vec3(luma(c.rgb)), p0) * u_scale, c.a);\n}\n";
448
449    #[test]
450    fn prelude_plus_body_is_well_formed() {
451        let src = format!("{PRELUDE}\n{SAMPLE_BODY}\n{MAIN}");
452        assert!(balanced(&src), "unbalanced braces");
453        assert!(src.starts_with("#version 330 core"));
454        assert_eq!(undeclared_uniforms(&src), Vec::<String>::new());
455        for name in ["tex", "u_res", "u_time", "u_scale", "u_mask", "u_has_mask"] {
456            assert!(src.contains(&format!("uniform {name};")) || src.contains(&format!(" {name};")), "{name}");
457        }
458        for i in 0..8 {
459            assert!(src.contains(&format!("uniform float p{i};")), "p{i}");
460        }
461        // helpers the bodies rely on
462        for f in ["luma(", "rgb2hsl(", "hsl2rgb(", "hash12(", "sample_clamped(", "apply_mask("] {
463            assert!(src.contains(f), "missing helper {f}");
464        }
465    }
466
467    #[test]
468    fn shared_programs_are_well_formed() {
469        for (name, src) in [
470            ("vert", VERT.to_string()),
471            ("composite", format!("{PRELUDE}{BLEND}{COMPOSITE}")),
472            ("mask", format!("{PRELUDE}{MASK}")),
473        ] {
474            assert!(balanced(&src), "{name}: unbalanced braces");
475            assert!(src.contains("void main()"), "{name}: no entry point");
476            assert_eq!(undeclared_uniforms(&src), Vec::<String>::new(), "{name}");
477        }
478    }
479
480    #[test]
481    fn user_shader_wraps_body_with_knobs_and_entry_point() {
482        let s = user_shader(crate::model::DEFAULT_SHADER);
483        assert!(s.starts_with("#version 330 core"));
484        assert!(balanced(&s));
485        for i in 1..=8 {
486            assert!(s.contains(&format!("#define u{} p{}\n", i, i - 1)), "knob u{i}");
487        }
488        assert!(s.contains("vec4 effect(vec4 src, vec2 uv)"), "entry point missing");
489        assert!(s.contains("out_color = apply_mask("), "main must call effect through the mask");
490        assert_eq!(undeclared_uniforms(&s), Vec::<String>::new());
491        // the user body's own uses resolve through the #defines
492        assert!(s.contains("u1") && s.contains("u_time"));
493    }
494
495    /// The bug this guards: `fragment()` used to append `MAIN` to bodies that already define `main()`,
496    /// so every built-in effect program failed to link and the GPU preview applied nothing.
497    #[test]
498    fn built_in_fragments_have_one_entry_point_and_no_undefined_effect() {
499        for k in crate::model::EffectKind::ALL {
500            if k == EffectKind::Shader {
501                continue;
502            }
503            let Some(src) = fragment(k, "") else { continue };
504            assert_eq!(src.matches("void main()").count(), 1, "{k:?}: not exactly one entry point");
505            assert!(!src.contains("effect(src, uv)"), "{k:?}: calls an effect() no body defines");
506            assert!(balanced(&src), "{k:?}: unbalanced braces");
507            assert!(src.starts_with("#version 330 core"), "{k:?}");
508            assert_eq!(undeclared_uniforms(&src), Vec::<String>::new(), "{k:?}");
509        }
510    }
511
512    #[test]
513    fn fragment_uses_the_user_source_for_shader_kind() {
514        let f = fragment(EffectKind::Shader, "vec4 effect(vec4 s, vec2 uv) { return s * p3; }").unwrap();
515        assert!(f.contains("return s * p3;"));
516        assert!(f.ends_with(MAIN));
517        // geometric kinds never compile a program
518        assert!(fragment(EffectKind::Wobble, "").is_none());
519        assert!(fragment(EffectKind::Plane3d, "").is_none());
520    }
521}
522
523// ===========================================================================================
524// Per-effect fragment bodies (owned by the gpu-effects group). Everything above this line is
525// the shared GPU core.
526//
527// Rules every body below follows, so `PRELUDE` stays the only shared surface:
528//   * texture coordinates come from `gl_FragCoord.xy / u_res`, never from a varying, and
529//     **v = 0 is the top of the layer** (frames are top-down; gpu.rs must render the full-screen
530//     pass so gl_FragCoord.y grows downward). Only Crop/Flip/RecDot/VHS care about the direction.
531//   * helper functions are prefixed `fx_` and defined inside the body that needs them, so a body
532//     never depends on a helper name in `PRELUDE`.
533//   * the mask is applied explicitly at the end: `mix(src, res, coverage)`.
534//   * pixel-sized parameters are multiplied by `u_scale` (preview == export).
535//   * kinds with more than 8 parameters (Curves) declare the extra `pN` uniforms themselves;
536//     `PRELUDE` must declare exactly p0..p7 and gpu.rs must set one uniform per `params()` entry.
537//   * two kinds need extra uniforms gpu.rs has to bind (documented at their constant):
538//     MotionBlur -> u_prev / u_next / u_frames / u_motion, BlobTrack -> u_blob.
539// ===========================================================================================
540
541/// 24-tap Vogel disc, Gaussian-weighted; radius `p0` project px.
542const BLUR: &str = r#"
543void main() {
544    vec2 uv = gl_FragCoord.xy / u_res;
545    vec4 src = texture(tex, uv);
546    vec4 res = src;
547    float r = p0 * u_scale;
548    if (r >= 0.5) {
549        // ponytail: one-pass Vogel disc instead of a separable Gaussian - if quality matters,
550        // add a direction knob and let gpu.rs run this program twice (H then V).
551        vec4 acc = vec4(0.0);
552        float wsum = 0.0;
553        for (int i = 0; i < 24; i++) {
554            float fi = float(i) + 0.5;
555            float rad = sqrt(fi / 24.0) * r;
556            float ang = fi * 2.39996323;
557            float w = exp(-2.0 * rad * rad / (r * r));
558            acc += texture(tex, uv + vec2(cos(ang), sin(ang)) * rad / u_res) * w;
559            wsum += w;
560        }
561        res = acc / wsum;
562    }
563    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
564    out_color = mix(src, res, m);
565}
566"#;
567
568/// Snap to a `p0`-project-px block grid and sample its centre.
569const PIXELATE: &str = r#"
570void main() {
571    vec2 uv = gl_FragCoord.xy / u_res;
572    vec4 src = texture(tex, uv);
573    float b = max(p0, 1.0) * max(u_scale, 0.01);
574    vec4 res = src;
575    if (b > 1.0) {
576        vec2 q = (floor(gl_FragCoord.xy / b) + 0.5) * b;
577        res = texture(tex, q / u_res);
578    }
579    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
580    out_color = mix(src, res, m);
581}
582"#;
583
584/// Mix towards (p0,p1,p2) 0..255 by p3.
585const TINT: &str = r#"
586void main() {
587    vec2 uv = gl_FragCoord.xy / u_res;
588    vec4 src = texture(tex, uv);
589    vec4 res = vec4(mix(src.rgb, vec3(p0, p1, p2) / 255.0, clamp(p3, 0.0, 1.0)), src.a);
590    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
591    out_color = mix(src, res, m);
592}
593"#;
594
595/// Saturation + hue matrix (same feColorMatrix pair as the CPU path), then brightness/contrast/gamma.
596const COLOR: &str = r#"
597void main() {
598    vec2 uv = gl_FragCoord.xy / u_res;
599    vec4 src = texture(tex, uv);
600    vec3 c = src.rgb;
601    float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
602    c = mix(vec3(l), c, p2);
603    float a = radians(p3), cs = cos(a), sn = sin(a);
604    mat3 hm = mat3(
605        0.213 + cs * 0.787 - sn * 0.213, 0.213 - cs * 0.213 + sn * 0.143, 0.213 - cs * 0.213 - sn * 0.787,
606        0.715 - cs * 0.715 - sn * 0.715, 0.715 + cs * 0.285 + sn * 0.140, 0.715 - cs * 0.715 + sn * 0.715,
607        0.072 - cs * 0.072 + sn * 0.928, 0.072 - cs * 0.072 - sn * 0.283, 0.072 + cs * 0.928 + sn * 0.072);
608    c = clamp((clamp(hm * c, 0.0, 1.0) - 0.5) * p1 + 0.5 + p0, 0.0, 1.0);
609    c = pow(c, vec3(1.0 / max(p4, 0.001)));
610    vec4 res = vec4(c, src.a);
611    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
612    out_color = mix(src, res, m);
613}
614"#;
615
616/// Darken outside `p0` (fraction of the half diagonal) with `p1` softness by `p2`.
617const VIGNETTE: &str = r#"
618void main() {
619    vec2 uv = gl_FragCoord.xy / u_res;
620    vec4 src = texture(tex, uv);
621    float d = length((uv - 0.5) * u_res) / max(length(u_res * 0.5), 1.0);
622    float fall = smoothstep(0.0, 1.0, (d - p0) / max(p1, 0.001));
623    float k = 1.0 - clamp(p2, 0.0, 1.0) * fall;
624    vec4 res = vec4(src.rgb * k, src.a);
625    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
626    out_color = mix(src, res, m);
627}
628"#;
629
630/// Unsharp mask against a 3x3 box at `p1` project px spacing.
631const SHARPEN: &str = r#"
632void main() {
633    vec2 uv = gl_FragCoord.xy / u_res;
634    vec4 src = texture(tex, uv);
635    vec2 o = vec2(max(p1, 0.5) * u_scale) / u_res;
636    vec3 b = texture(tex, uv + vec2(-o.x, -o.y)).rgb + texture(tex, uv + vec2(0.0, -o.y)).rgb
637           + texture(tex, uv + vec2(o.x, -o.y)).rgb + texture(tex, uv + vec2(-o.x, 0.0)).rgb
638           + src.rgb + texture(tex, uv + vec2(o.x, 0.0)).rgb
639           + texture(tex, uv + vec2(-o.x, o.y)).rgb + texture(tex, uv + vec2(0.0, o.y)).rgb
640           + texture(tex, uv + vec2(o.x, o.y)).rgb;
641    vec4 res = vec4(clamp(src.rgb + max(p0, 0.0) * (src.rgb - b / 9.0), 0.0, 1.0), src.a);
642    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
643    out_color = mix(src, res, m);
644}
645"#;
646
647const INVERT: &str = r#"
648void main() {
649    vec2 uv = gl_FragCoord.xy / u_res;
650    vec4 src = texture(tex, uv);
651    vec4 res = vec4(mix(src.rgb, 1.0 - src.rgb, clamp(p0, 0.0, 1.0)), src.a);
652    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
653    out_color = mix(src, res, m);
654}
655"#;
656
657const GRAYSCALE: &str = r#"
658void main() {
659    vec2 uv = gl_FragCoord.xy / u_res;
660    vec4 src = texture(tex, uv);
661    float l = dot(src.rgb, vec3(0.2126, 0.7152, 0.0722));
662    vec4 res = vec4(mix(src.rgb, vec3(l), clamp(p0, 0.0, 1.0)), src.a);
663    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
664    out_color = mix(src, res, m);
665}
666"#;
667
668/// p0 = horizontal, p1 = vertical (>= 0.5 = on).
669const FLIP: &str = r#"
670void main() {
671    vec2 uv = gl_FragCoord.xy / u_res;
672    vec4 src = texture(tex, uv);
673    vec2 f = vec2(p0 >= 0.5 ? 1.0 - uv.x : uv.x, p1 >= 0.5 ? 1.0 - uv.y : uv.y);
674    vec4 res = texture(tex, f);
675    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
676    out_color = mix(src, res, m);
677}
678"#;
679
680/// Left/Right/Top/Bottom fractions cut away (alpha 0) with a `p4` feather.
681const CROP: &str = r#"
682void main() {
683    vec2 uv = gl_FragCoord.xy / u_res;
684    vec4 src = texture(tex, uv);
685    vec2 q = uv * u_res;
686    float x0 = clamp(p0, 0.0, 0.5) * u_res.x;
687    float x1 = u_res.x - clamp(p1, 0.0, 0.5) * u_res.x;
688    float y0 = clamp(p2, 0.0, 0.5) * u_res.y;
689    float y1 = u_res.y - clamp(p3, 0.0, 0.5) * u_res.y;
690    float d = min(min(q.x - x0, x1 - q.x), min(q.y - y0, y1 - q.y));
691    float fe = clamp(p4, 0.0, 0.5) * min(u_res.x, u_res.y);
692    float a = d <= 0.0 ? 0.0 : (fe > 0.0 ? min(d / fe, 1.0) : 1.0);
693    vec4 res = vec4(src.rgb, src.a * a);
694    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
695    out_color = mix(src, res, m);
696}
697"#;
698
699/// Chroma key: distance in the Cb/Cr plane to the key colour, with spill removal, matte erode/dilate
700/// (p7 project px, + shrinks) and a show-mask switch (p5).
701const CHROMA_KEY: &str = r#"
702vec3 fx_ycc(vec3 c) {
703    return vec3(dot(c, vec3(0.299, 0.587, 0.114)),
704                dot(c, vec3(-0.168736, -0.331264, 0.5)) + 0.5,
705                dot(c, vec3(0.5, -0.418688, -0.081312)) + 0.5);
706}
707vec3 fx_rgb(vec3 y) {
708    float cb = y.y - 0.5;
709    float cr = y.z - 0.5;
710    return vec3(y.x + 1.402 * cr, y.x - 0.344136 * cb - 0.714136 * cr, y.x + 1.772 * cb);
711}
712float fx_alpha(vec2 uv) {
713    vec2 c = fx_ycc(texture(tex, uv).rgb).yz;
714    vec2 k = fx_ycc(clamp(vec3(p0, p1, p2) / 255.0, 0.0, 1.0)).yz;
715    float sim = clamp(p3, 0.0, 1.0) * 0.5;
716    return smoothstep(sim, sim + max(clamp(p4, 0.0, 1.0) * 0.5, 0.0005), distance(c, k));
717}
718void main() {
719    vec2 uv = gl_FragCoord.xy / u_res;
720    vec4 src = texture(tex, uv);
721    float a = fx_alpha(uv);
722    float sh = p7 * u_scale;
723    if (abs(sh) > 0.01) {
724        vec2 step_ = vec2(abs(sh)) / u_res;
725        for (int i = 0; i < 8; i++) {
726            float ang = float(i) * 0.78539816;
727            float n = fx_alpha(uv + vec2(cos(ang), sin(ang)) * step_);
728            a = sh > 0.0 ? min(a, n) : max(a, n);
729        }
730    }
731    vec3 rgb = src.rgb;
732    float spill = clamp(p6, 0.0, 1.0);
733    if (spill > 0.0) {
734        vec3 y = fx_ycc(rgb);
735        vec2 kc = fx_ycc(clamp(vec3(p0, p1, p2) / 255.0, 0.0, 1.0)).yz - 0.5;
736        if (length(kc) > 0.001) {
737            vec2 dir = normalize(kc);
738            vec2 cc = y.yz - 0.5;
739            float proj = dot(cc, dir);
740            if (proj > 0.0) {
741                cc -= dir * proj * spill;
742                rgb = clamp(fx_rgb(vec3(y.x, cc + 0.5)), 0.0, 1.0);
743            }
744        }
745    }
746    vec4 res = vec4(rgb, src.a * a);
747    if (p5 >= 0.5) {
748        res = vec4(vec3(a), 1.0);
749    }
750    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
751    out_color = mix(src, res, m);
752}
753"#;
754
755/// Replace one colour with another: RGB distance to the source colour, faded out over
756/// `Tolerance .. Tolerance + Softness` (distance normalised so 1.0 is black-to-white).
757const COLOR_REPLACE: &str = r#"
758void main() {
759    vec2 uv = gl_FragCoord.xy / u_res;
760    vec4 src = texture(tex, uv);
761    vec3 from = clamp(vec3(p0, p1, p2) / 255.0, 0.0, 1.0);
762    vec3 to = clamp(vec3(p3, p4, p5) / 255.0, 0.0, 1.0);
763    float tol = clamp(p6, 0.0, 1.0);
764    float soft = max(clamp(p7, 0.0, 1.0), 0.0005);
765    float d = distance(src.rgb, from) / 1.7320508;
766    float hit = 1.0 - smoothstep(tol, tol + soft, d);
767    vec4 res = vec4(mix(src.rgb, to, hit), src.a);
768    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
769    out_color = mix(src, res, m);
770}
771"#;
772
773/// Twelve knots (master, R, G, B) driving a monotone cubic through (0,0) (.25,a) (.5,b) (.75,c) (1,1).
774/// Fritsch-Carlson tangents keep the spline monotone, so a curve never folds back on itself.
775/// Declares p8..p11 itself: `PRELUDE` only carries p0..p7.
776const CURVES: &str = r#"
777uniform float p8;
778uniform float p9;
779uniform float p10;
780uniform float p11;
781float fx_curve(float x, float a, float b, float c) {
782    float y[5];
783    y[0] = 0.0; y[1] = a; y[2] = b; y[3] = c; y[4] = 1.0;
784    float d[4];
785    for (int i = 0; i < 4; i++) {
786        d[i] = (y[i + 1] - y[i]) * 4.0;
787    }
788    float m[5];
789    m[0] = d[0];
790    m[4] = d[3];
791    for (int i = 1; i < 4; i++) {
792        m[i] = (d[i - 1] * d[i] <= 0.0) ? 0.0 : (d[i - 1] + d[i]) * 0.5;
793    }
794    for (int i = 0; i < 4; i++) {
795        if (abs(d[i]) < 1e-6) {
796            m[i] = 0.0;
797            m[i + 1] = 0.0;
798        } else {
799            float ai = m[i] / d[i];
800            float bi = m[i + 1] / d[i];
801            float s = ai * ai + bi * bi;
802            if (s > 9.0) {
803                float k = 3.0 / sqrt(s);
804                m[i] = k * ai * d[i];
805                m[i + 1] = k * bi * d[i];
806            }
807        }
808    }
809    float xc = clamp(x, 0.0, 1.0);
810    int i = int(min(floor(xc * 4.0), 3.0));
811    float t = xc * 4.0 - float(i);
812    float t2 = t * t;
813    float t3 = t2 * t;
814    return (2.0 * t3 - 3.0 * t2 + 1.0) * y[i] + (t3 - 2.0 * t2 + t) * 0.25 * m[i]
815         + (-2.0 * t3 + 3.0 * t2) * y[i + 1] + (t3 - t2) * 0.25 * m[i + 1];
816}
817void main() {
818    vec2 uv = gl_FragCoord.xy / u_res;
819    vec4 src = texture(tex, uv);
820    vec3 c = vec3(fx_curve(src.r, p3, p4, p5), fx_curve(src.g, p6, p7, p8), fx_curve(src.b, p9, p10, p11));
821    c = vec3(fx_curve(c.r, p0, p1, p2), fx_curve(c.g, p0, p1, p2), fx_curve(c.b, p0, p1, p2));
822    vec4 res = vec4(clamp(c, 0.0, 1.0), src.a);
823    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
824    out_color = mix(src, res, m);
825}
826"#;
827
828/// In black/white, gamma, out black/white (same convention as Color's gamma: v^(1/g)).
829const LEVELS: &str = r#"
830void main() {
831    vec2 uv = gl_FragCoord.xy / u_res;
832    vec4 src = texture(tex, uv);
833    vec3 c = clamp((src.rgb - p0) / max(p1 - p0, 0.0001), 0.0, 1.0);
834    c = pow(c, vec3(1.0 / max(p2, 0.01)));
835    c = clamp(p3 + c * (p4 - p3), 0.0, 1.0);
836    vec4 res = vec4(c, src.a);
837    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
838    out_color = mix(src, res, m);
839}
840"#;
841
842/// Hue rotate (p0 degrees), saturation multiply (p1) and lightness offset (p2) in HSL.
843const HUE_SHIFT: &str = r#"
844vec3 fx_rgb2hsl(vec3 c) {
845    float mx = max(c.r, max(c.g, c.b));
846    float mn = min(c.r, min(c.g, c.b));
847    float l = (mx + mn) * 0.5;
848    float h = 0.0;
849    float s = 0.0;
850    float d = mx - mn;
851    if (d > 1e-5) {
852        s = d / max(1.0 - abs(2.0 * l - 1.0), 1e-5);
853        if (mx == c.r) {
854            h = mod((c.g - c.b) / d, 6.0);
855        } else if (mx == c.g) {
856            h = (c.b - c.r) / d + 2.0;
857        } else {
858            h = (c.r - c.g) / d + 4.0;
859        }
860        h /= 6.0;
861    }
862    return vec3(h, s, l);
863}
864float fx_h2c(float a, float b, float t) {
865    float u = fract(t);
866    if (u < 1.0 / 6.0) return a + (b - a) * 6.0 * u;
867    if (u < 0.5) return b;
868    if (u < 2.0 / 3.0) return a + (b - a) * (2.0 / 3.0 - u) * 6.0;
869    return a;
870}
871vec3 fx_hsl2rgb(vec3 hsl) {
872    float l = clamp(hsl.z, 0.0, 1.0);
873    float s = clamp(hsl.y, 0.0, 1.0);
874    if (s <= 0.0) return vec3(l);
875    float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
876    float a = 2.0 * l - q;
877    return vec3(fx_h2c(a, q, hsl.x + 1.0 / 3.0), fx_h2c(a, q, hsl.x), fx_h2c(a, q, hsl.x - 1.0 / 3.0));
878}
879void main() {
880    vec2 uv = gl_FragCoord.xy / u_res;
881    vec4 src = texture(tex, uv);
882    vec3 hsl = fx_rgb2hsl(clamp(src.rgb, 0.0, 1.0));
883    hsl.x = fract(hsl.x + p0 / 360.0);
884    hsl.y = clamp(hsl.y * max(p1, 0.0), 0.0, 1.0);
885    hsl.z = clamp(hsl.z + p2, 0.0, 1.0);
886    vec4 res = vec4(clamp(fx_hsl2rgb(hsl), 0.0, 1.0), src.a);
887    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
888    out_color = mix(src, res, m);
889}
890"#;
891
892/// JPEG-ish: the block DC (4 taps) is quantised coarsely, the AC residual more coarsely still, and the
893/// chroma optionally read from a 2x2 grid (4:2:0). This is an *approximation* - there is no DCT here,
894/// but the visible artefacts (blocking, colour smear, banding) track `Quality` the same way.
895const JPEG_COMPRESS: &str = r#"
896vec3 fx_ycc(vec3 c) {
897    return vec3(dot(c, vec3(0.299, 0.587, 0.114)),
898                dot(c, vec3(-0.168736, -0.331264, 0.5)) + 0.5,
899                dot(c, vec3(0.5, -0.418688, -0.081312)) + 0.5);
900}
901vec3 fx_rgb(vec3 y) {
902    float cb = y.y - 0.5;
903    float cr = y.z - 0.5;
904    return vec3(y.x + 1.402 * cr, y.x - 0.344136 * cb - 0.714136 * cr, y.x + 1.772 * cb);
905}
906void main() {
907    vec2 uv = gl_FragCoord.xy / u_res;
908    vec4 src = texture(tex, uv);
909    float sc = max(u_scale, 0.05);
910    float b = max(p1, 2.0) * sc;
911    vec2 org = floor(gl_FragCoord.xy / b) * b;
912    vec3 dc = texture(tex, (org + b * vec2(0.25, 0.25)) / u_res).rgb
913            + texture(tex, (org + b * vec2(0.75, 0.25)) / u_res).rgb
914            + texture(tex, (org + b * vec2(0.25, 0.75)) / u_res).rgb
915            + texture(tex, (org + b * vec2(0.75, 0.75)) / u_res).rgb;
916    dc *= 0.25;
917    float q = clamp(p0, 1.0, 100.0);
918    float qs = q < 50.0 ? 50.0 / q : 2.0 - q / 50.0;
919    float step_ = clamp(qs * 0.05, 0.004, 0.6);
920    vec3 ydc = fx_ycc(dc);
921    vec3 ypx = fx_ycc(src.rgb);
922    float dcs = step_ * 0.5;
923    float luma = floor(ydc.x / dcs + 0.5) * dcs + floor((ypx.x - ydc.x) / step_ + 0.5) * step_;
924    vec2 ch = ypx.yz;
925    if (p2 >= 0.5) {
926        vec2 sub = (floor(gl_FragCoord.xy / (2.0 * sc)) + 0.5) * (2.0 * sc);
927        ch = fx_ycc(texture(tex, sub / u_res).rgb).yz;
928    }
929    float cstep = step_ * 2.0;
930    ch = floor((ch - 0.5) / cstep + 0.5) * cstep + 0.5;
931    vec4 res = vec4(clamp(fx_rgb(vec3(luma, ch)), 0.0, 1.0), src.a);
932    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
933    out_color = mix(src, res, m);
934}
935"#;
936
937/// Shutter-window average of the frames gpu.rs supplies.
938///
939/// Extra uniforms gpu.rs must bind (this kind reports `EffectKind::needs_motion()`):
940///   sampler2D u_prev / u_next  the layer one frame before / after (bind `tex` when missing)
941///   float     u_frames         how many of those are real: 0, 1 (prev only) or 2
942///   vec2      u_motion         the layer's motion in layer pixels per frame, for the 0-frame fallback
943const MOTION_BLUR: &str = r#"
944uniform sampler2D u_prev;
945uniform sampler2D u_next;
946uniform float u_frames;
947uniform vec2 u_motion;
948void main() {
949    vec2 uv = gl_FragCoord.xy / u_res;
950    vec4 src = texture(tex, uv);
951    float sh = clamp(p0, 0.0, 360.0) / 360.0;
952    int n = int(clamp(p1, 2.0, 32.0));
953    vec4 acc = vec4(0.0);
954    vec4 prev = src;
955    vec4 next = src;
956    if (u_frames > 0.5) {
957        prev = texture(u_prev, uv);
958        next = u_frames > 1.5 ? texture(u_next, uv) : prev;
959        for (int i = 0; i < 32; i++) {
960            if (i >= n) break;
961            float o = ((float(i) + 0.5) / float(n) - 0.5) * sh;
962            acc += o < 0.0 ? mix(src, prev, clamp(-o, 0.0, 1.0)) : mix(src, next, clamp(o, 0.0, 1.0));
963        }
964    } else {
965        vec2 d = u_motion / u_res * sh;
966        for (int i = 0; i < 32; i++) {
967            if (i >= n) break;
968            acc += texture(tex, uv + d * ((float(i) + 0.5) / float(n) - 0.5));
969        }
970    }
971    vec4 res = acc / float(n);
972    if (p2 >= 0.5 && u_frames > 0.5) {
973        float mv = length(prev.rgb - src.rgb) + length(next.rgb - src.rgb);
974        res = mix(src, res, clamp(mv * 4.0, 0.0, 1.0));
975    }
976    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
977    out_color = mix(src, res, m);
978}
979"#;
980
981/// Sobel on luma with the tap spacing set by `Width` (p1) - a wider kernel is the cheap stand-in for
982/// dilating the edge - then a coloured glow, optionally over the source.
983const EDGE_GLOW: &str = r#"
984float fx_l(vec2 uv) {
985    return dot(texture(tex, uv).rgb, vec3(0.2126, 0.7152, 0.0722));
986}
987void main() {
988    vec2 uv = gl_FragCoord.xy / u_res;
989    vec4 src = texture(tex, uv);
990    vec2 o = vec2(max(p1, 0.5) * max(u_scale, 0.05)) / u_res;
991    float tl = fx_l(uv + vec2(-o.x, -o.y));
992    float tc = fx_l(uv + vec2(0.0, -o.y));
993    float tr = fx_l(uv + vec2(o.x, -o.y));
994    float ml = fx_l(uv + vec2(-o.x, 0.0));
995    float mr = fx_l(uv + vec2(o.x, 0.0));
996    float bl = fx_l(uv + vec2(-o.x, o.y));
997    float bc = fx_l(uv + vec2(0.0, o.y));
998    float br = fx_l(uv + vec2(o.x, o.y));
999    float gx = (tr + 2.0 * mr + br) - (tl + 2.0 * ml + bl);
1000    float gy = (bl + 2.0 * bc + br) - (tl + 2.0 * tc + tr);
1001    float e = length(vec2(gx, gy)) * 0.25;
1002    float edge = smoothstep(p0, p0 + 0.15, e);
1003    vec3 glow = vec3(p3, p4, p5) / 255.0 * max(p2, 0.0) * edge;
1004    vec4 res;
1005    if (p6 >= 0.5) {
1006        res = vec4(clamp(src.rgb + glow, 0.0, 1.0), src.a);
1007    } else {
1008        res = vec4(clamp(glow, 0.0, 1.0), edge * src.a);
1009    }
1010    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
1011    out_color = mix(src, res, m);
1012}
1013"#;
1014
1015/// Hard/soft threshold on luma, or per channel when p2 >= 0.5.
1016const THRESHOLD: &str = r#"
1017void main() {
1018    vec2 uv = gl_FragCoord.xy / u_res;
1019    vec4 src = texture(tex, uv);
1020    float sm = max(p1, 0.0001) * 0.5;
1021    vec3 c;
1022    if (p2 >= 0.5) {
1023        c = smoothstep(vec3(p0 - sm), vec3(p0 + sm), src.rgb);
1024    } else {
1025        c = vec3(smoothstep(p0 - sm, p0 + sm, dot(src.rgb, vec3(0.2126, 0.7152, 0.0722))));
1026    }
1027    vec4 res = vec4(c, src.a);
1028    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
1029    out_color = mix(src, res, m);
1030}
1031"#;
1032
1033/// Overlay for the colour tracker: tints everything within `Tolerance` of the target and draws a
1034/// crosshair at the centroid.
1035///
1036/// Extra uniform gpu.rs must bind: `vec3 u_blob` = (cx, cy, area) from `engine::effects::track`,
1037/// cx/cy in 0..1 layer coordinates and area <= 0 when nothing was found (then only the mask shows).
1038const BLOB_TRACK: &str = r#"
1039uniform vec3 u_blob;
1040void main() {
1041    vec2 uv = gl_FragCoord.xy / u_res;
1042    vec4 src = texture(tex, uv);
1043    vec4 res = src;
1044    if (p4 >= 0.5) {
1045        float d = distance(src.rgb, clamp(vec3(p0, p1, p2) / 255.0, 0.0, 1.0));
1046        float tol = clamp(p3, 0.0, 1.0) * 1.2;
1047        float hit = 1.0 - smoothstep(tol, tol + 0.05, d);
1048        res = vec4(mix(src.rgb, vec3(1.0, 0.25, 0.25), hit * 0.5), src.a);
1049        if (u_blob.z > 0.0) {
1050            vec2 dxy = abs(uv - u_blob.xy) * u_res;
1051            float lw = max(1.0, u_scale);
1052            float arm = 16.0 * max(u_scale, 0.25);
1053            float cross_ = ((dxy.y <= lw && dxy.x <= arm) || (dxy.x <= lw && dxy.y <= arm)) ? 1.0 : 0.0;
1054            res = vec4(mix(res.rgb, vec3(1.0, 1.0, 0.2), cross_), max(res.a, cross_));
1055        }
1056    }
1057    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
1058    out_color = mix(src, res, m);
1059}
1060"#;
1061
1062/// One-pass VHS: per-line tracking jitter, chroma bleed to the right of an edge, luma noise,
1063/// scanlines, sharpening ringing, a head-switching band at the bottom and tape-wear dropouts.
1064/// Cheap approximations of what ntsc-rs models properly - no line-rate filtering, no real comb filter.
1065const VHS: &str = r#"
1066float fx_hash(vec2 p) {
1067    return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
1068}
1069vec3 fx_ycc(vec3 c) {
1070    return vec3(dot(c, vec3(0.299, 0.587, 0.114)),
1071                dot(c, vec3(-0.168736, -0.331264, 0.5)) + 0.5,
1072                dot(c, vec3(0.5, -0.418688, -0.081312)) + 0.5);
1073}
1074vec3 fx_rgb(vec3 y) {
1075    float cb = y.y - 0.5;
1076    float cr = y.z - 0.5;
1077    return vec3(y.x + 1.402 * cr, y.x - 0.344136 * cb - 0.714136 * cr, y.x + 1.772 * cb);
1078}
1079void main() {
1080    vec2 uv = gl_FragCoord.xy / u_res;
1081    vec4 src = texture(tex, uv);
1082    float full = p6 >= 0.5 ? 0.0 : 1.0;   // "colour bleed only" keeps just the chroma stage
1083    float noise = clamp(p0, 0.0, 1.0) * full;
1084    float bleed = clamp(p1, 0.0, 1.0);
1085    float scan = clamp(p2, 0.0, 1.0) * full;
1086    float jit = clamp(p3, 0.0, 1.0) * full;
1087    float head = clamp(p4, 0.0, 1.0) * full;
1088    float ring = clamp(p5, 0.0, 1.0);
1089    float wear = clamp(p7, 0.0, 1.0) * full;
1090    float sc = max(u_scale, 0.1);
1091    float line = floor(gl_FragCoord.y / sc);
1092    float t = u_time;
1093    float band = 1.0 - smoothstep(0.0, max(0.05 * head, 0.001), 1.0 - uv.y);
1094    band *= step(0.001, head);
1095    float j = (fx_hash(vec2(line, floor(t * 30.0))) - 0.5) * jit * 0.02
1096            + sin(line * 0.31 + t * 6.0) * jit * 0.002
1097            + band * head * 0.08 * (fx_hash(vec2(line, floor(t * 12.0))) * 2.0 - 1.0);
1098    vec2 suv = clamp(vec2(uv.x + j, uv.y), vec2(0.0), vec2(1.0));
1099    vec3 base = texture(tex, suv).rgb;
1100    float bpx = bleed * 6.0 * sc / max(u_res.x, 1.0);
1101    vec3 b1 = texture(tex, clamp(suv - vec2(bpx, 0.0), 0.0, 1.0)).rgb;
1102    vec3 b2 = texture(tex, clamp(suv - vec2(bpx * 2.0, 0.0), 0.0, 1.0)).rgb;
1103    vec3 y = fx_ycc(base);
1104    y.yz = mix(y.yz, fx_ycc((b1 + b2) * 0.5).yz, bleed);
1105    y.x += (y.x - fx_ycc(b1).x) * ring * 1.5;
1106    y.x *= 1.0 - scan * 0.5 * (0.5 + 0.5 * cos(gl_FragCoord.y * 3.14159265 / sc));
1107    y.x += (fx_hash(gl_FragCoord.xy + vec2(t * 97.0, t * 31.0)) - 0.5) * noise * 0.25;
1108    if (wear > 0.0 && fx_hash(vec2(floor(line * 0.5), floor(t * 6.0))) > 1.0 - wear * 0.06) {
1109        float drop = fx_hash(vec2(floor(uv.x * 40.0), line));
1110        y.x = mix(y.x, 0.75 + 0.25 * drop, 0.7);
1111        y.yz = mix(y.yz, vec2(0.5), 0.8);
1112    }
1113    y.x = mix(y.x, 0.45 + 0.55 * fx_hash(gl_FragCoord.xy + vec2(t)), band * 0.8);
1114    y.yz = mix(y.yz, vec2(0.5), band);
1115    vec4 res = vec4(clamp(fx_rgb(y), 0.0, 1.0), src.a);
1116    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
1117    out_color = mix(src, res, m);
1118}
1119"#;
1120
1121/// Blinking record dot in a corner (p2: 0 = top-left, 1 = top-right, 2 = bottom-left, 3 = bottom-right)
1122/// plus an optional HH:MM:SS timecode drawn as seven-segment quads from the clip-local time.
1123const REC_DOT: &str = r#"
1124float fx_rect(vec2 q, vec2 c, vec2 h) {
1125    vec2 d = abs(q - c) - h;
1126    return max(d.x, d.y) < 0.0 ? 1.0 : 0.0;
1127}
1128float fx_digit(vec2 q, int d) {
1129    if (q.x < 0.0 || q.x > 1.0 || q.y < 0.0 || q.y > 1.0) return 0.0;
1130    int seg[10] = int[10](63, 6, 91, 79, 102, 109, 125, 7, 127, 111);
1131    int m = seg[clamp(d, 0, 9)];
1132    float t = 0.11;
1133    float o = 0.0;
1134    if ((m & 1) != 0) o = max(o, fx_rect(q, vec2(0.5, t), vec2(0.5 - t, t)));
1135    if ((m & 2) != 0) o = max(o, fx_rect(q, vec2(1.0 - t, 0.25), vec2(t, 0.25 - t)));
1136    if ((m & 4) != 0) o = max(o, fx_rect(q, vec2(1.0 - t, 0.75), vec2(t, 0.25 - t)));
1137    if ((m & 8) != 0) o = max(o, fx_rect(q, vec2(0.5, 1.0 - t), vec2(0.5 - t, t)));
1138    if ((m & 16) != 0) o = max(o, fx_rect(q, vec2(t, 0.75), vec2(t, 0.25 - t)));
1139    if ((m & 32) != 0) o = max(o, fx_rect(q, vec2(t, 0.25), vec2(t, 0.25 - t)));
1140    if ((m & 64) != 0) o = max(o, fx_rect(q, vec2(0.5, 0.5), vec2(0.5 - t, t)));
1141    return o;
1142}
1143void main() {
1144    vec2 uv = gl_FragCoord.xy / u_res;
1145    vec4 src = texture(tex, uv);
1146    vec2 q = gl_FragCoord.xy;
1147    float sc = max(u_scale, 0.05);
1148    float sz = max(p0, 2.0) * sc;
1149    float mg = max(p4, 0.0) * sc;
1150    int corner = int(clamp(p2, 0.0, 3.0) + 0.5);
1151    bool right = (corner == 1 || corner == 3);
1152    bool bottom = (corner == 2 || corner == 3);
1153    vec2 anchor = vec2(right ? u_res.x - mg - sz * 0.5 : mg + sz * 0.5,
1154                       bottom ? u_res.y - mg - sz * 0.5 : mg + sz * 0.5);
1155    float hz = max(p1, 0.0);
1156    float on = hz <= 0.0 ? 1.0 : (fract(u_time * hz) < 0.5 ? 1.0 : 0.0);
1157    float dot_ = 1.0 - smoothstep(sz * 0.5 - 1.0, sz * 0.5 + 1.0, distance(q, anchor));
1158    vec3 c = mix(src.rgb, vec3(0.9, 0.12, 0.12), dot_ * on);
1159    float cover = dot_ * on;
1160    if (p3 >= 0.5) {
1161        float dh = sz;
1162        float dw = sz * 0.55;
1163        float gap = sz * 0.12;
1164        float cw = dw + gap;
1165        float colw = sz * 0.3 + gap;
1166        float total = 6.0 * cw + 2.0 * colw;
1167        float ox = right ? anchor.x - sz * 0.5 - gap * 2.0 - total : anchor.x + sz * 0.5 + gap * 2.0;
1168        float oy = anchor.y - dh * 0.5;
1169        int tot = int(max(u_time, 0.0));
1170        int hh = (tot / 3600) - (tot / 360000) * 100;
1171        int mm = (tot / 60) - (tot / 3600) * 60;
1172        int ss = tot - (tot / 60) * 60;
1173        int digs[6] = int[6](hh / 10, hh - (hh / 10) * 10, mm / 10, mm - (mm / 10) * 10,
1174                             ss / 10, ss - (ss / 10) * 10);
1175        float ink = 0.0;
1176        float x = ox;
1177        for (int i = 0; i < 6; i++) {
1178            ink = max(ink, fx_digit(vec2((q.x - x) / dw, (q.y - oy) / dh), digs[i]));
1179            x += cw;
1180            if (i == 1 || i == 3) {
1181                float cx = x + colw * 0.5;
1182                ink = max(ink, fx_rect(q, vec2(cx, oy + dh * 0.33), vec2(sz * 0.07)));
1183                ink = max(ink, fx_rect(q, vec2(cx, oy + dh * 0.7), vec2(sz * 0.07)));
1184                x += colw;
1185            }
1186        }
1187        c = mix(c, vec3(1.0), ink * 0.9);
1188        cover = max(cover, ink * 0.9);
1189    }
1190    vec4 res = vec4(c, max(src.a, cover));
1191    float m = u_has_mask > 0.5 ? texture(u_mask, uv).r : 1.0;
1192    out_color = mix(src, res, m);
1193}
1194"#;
1195
1196#[cfg(test)]
1197mod shader_body_tests {
1198    use super::*;
1199    use crate::model::EffectKind as K;
1200
1201    /// Uniforms `PRELUDE` guarantees (p0..p7 handled separately).
1202    const PRELUDE_UNIFORMS: [&str; 5] = ["tex", "u_res", "u_time", "u_scale", "u_mask"];
1203
1204    /// Parameters a body deliberately ignores (the CPU tracker consumes BlobTrack's smoothing).
1205    const UNUSED_PARAMS: [(K, usize); 1] = [(K::BlobTrack, 5)];
1206
1207    fn bodies() -> Vec<(K, &'static str)> {
1208        K::ALL.iter().filter_map(|&k| body(k).map(|s| (k, s))).collect()
1209    }
1210
1211    /// Every `u_xxx` / `tex` identifier the body reads, and every `pN` index it reads.
1212    fn identifiers(src: &str) -> (Vec<String>, Vec<usize>) {
1213        let mut words: Vec<String> = Vec::new();
1214        let mut cur = String::new();
1215        for ch in src.chars() {
1216            if ch.is_ascii_alphanumeric() || ch == '_' {
1217                cur.push(ch);
1218            } else if !cur.is_empty() {
1219                words.push(std::mem::take(&mut cur));
1220            }
1221        }
1222        if !cur.is_empty() {
1223            words.push(cur);
1224        }
1225        let params = words
1226            .iter()
1227            .filter_map(|w| w.strip_prefix('p').and_then(|n| n.parse::<usize>().ok().filter(|_| !n.is_empty())))
1228            .collect();
1229        (words, params)
1230    }
1231
1232    /// Uniform names the body declares itself (`uniform <type> <name>;`).
1233    fn declared(src: &str) -> Vec<String> {
1234        src.split("uniform ")
1235            .skip(1)
1236            .filter_map(|rest| {
1237                let mut it = rest.split_whitespace();
1238                let _ty = it.next()?;
1239                Some(it.next()?.trim_end_matches(';').to_string())
1240            })
1241            .collect()
1242    }
1243
1244    #[test]
1245    fn every_gpu_kind_has_a_body() {
1246        for k in K::ALL {
1247            let has = body(k).is_some();
1248            let want = !matches!(k, K::Wobble | K::Plane3d | K::Shader);
1249            assert_eq!(has, want, "{:?}", k);
1250        }
1251        assert_eq!(bodies().len(), 22);
1252    }
1253
1254    #[test]
1255    fn bodies_are_well_formed_glsl_text() {
1256        for (k, src) in bodies() {
1257            assert!(!src.contains("#version"), "{k:?}: the prelude owns #version");
1258            assert!(!src.contains("precision "), "{k:?}: the prelude owns precision");
1259            assert!(src.contains("void main()"), "{k:?}: no entry point");
1260            assert!(src.contains("out_color ="), "{k:?}: never writes out_color");
1261            for (open, close) in [('{', '}'), ('(', ')'), ('[', ']')] {
1262                let mut depth = 0i32;
1263                for ch in src.chars() {
1264                    if ch == open {
1265                        depth += 1;
1266                    } else if ch == close {
1267                        depth -= 1;
1268                        assert!(depth >= 0, "{k:?}: unbalanced {close}");
1269                    }
1270                }
1271                assert_eq!(depth, 0, "{k:?}: unbalanced {open}{close}");
1272            }
1273            // one statement per line keeps the driver logs readable; no tabs, no trailing space
1274            for line in src.lines() {
1275                assert!(!line.contains('\t'), "{k:?}: tab in {line:?}");
1276                assert_eq!(line.trim_end(), line, "{k:?}: trailing space in {line:?}");
1277            }
1278        }
1279    }
1280
1281    #[test]
1282    fn bodies_only_touch_declared_uniforms() {
1283        for (k, src) in bodies() {
1284            let own = declared(src);
1285            let (words, _) = identifiers(src);
1286            for w in words.iter().filter(|w| w.starts_with("u_") || *w == "tex") {
1287                let ok = PRELUDE_UNIFORMS.contains(&w.as_str()) || w == "u_has_mask" || own.contains(w);
1288                assert!(ok, "{k:?}: undeclared uniform {w}");
1289            }
1290        }
1291    }
1292
1293    #[test]
1294    fn bodies_match_the_parameter_list() {
1295        for (k, src) in bodies() {
1296            let n = k.params().len();
1297            let own = declared(src);
1298            let (_, used) = identifiers(src);
1299            for i in &used {
1300                assert!(*i < n, "{k:?}: reads p{i} but only has {n} params");
1301                // > 8 params means the body has to declare the extras itself
1302                if *i >= 8 {
1303                    assert!(own.contains(&format!("p{i}")), "{k:?}: p{i} is past the prelude and undeclared");
1304                }
1305            }
1306            for i in 0..n {
1307                let known_unused = UNUSED_PARAMS.contains(&(k, i));
1308                assert!(used.contains(&i) || known_unused, "{k:?}: {} (p{i}) is never read", k.params()[i].name);
1309            }
1310        }
1311    }
1312
1313    #[test]
1314    fn masked_and_scaled_where_it_matters() {
1315        for (k, src) in bodies() {
1316            assert!(src.contains("u_has_mask"), "{k:?}: ignores its mask");
1317        }
1318        // pixel-sized parameters must go through u_scale or preview and export disagree
1319        for k in [K::Blur, K::Pixelate, K::Sharpen, K::EdgeGlow, K::RecDot, K::ChromaKey, K::Vhs, K::JpegCompress] {
1320            assert!(body(k).unwrap().contains("u_scale"), "{k:?}: pixel sizes not scaled");
1321        }
1322    }
1323
1324    #[test]
1325    fn extra_uniforms_are_documented() {
1326        // the two bodies that need gpu.rs to bind more than the standard set
1327        assert_eq!(declared(MOTION_BLUR), ["u_prev", "u_next", "u_frames", "u_motion"]);
1328        assert_eq!(declared(BLOB_TRACK), ["u_blob"]);
1329        assert_eq!(declared(CURVES), ["p8", "p9", "p10", "p11"]);
1330        for (_, src) in bodies() {
1331            if src != MOTION_BLUR && src != BLOB_TRACK && src != CURVES {
1332                assert!(declared(src).is_empty(), "undocumented extra uniform in {:?}", declared(src));
1333            }
1334        }
1335    }
1336}