simple_editor\engine/
effects.rs

1//! Per-clip effects (CPU, RGBA8). `apply` runs on the decoded layer image (at its decode size, straight
2//! alpha) in stack order, before placement/blending. Pixel-sized parameters (blur radius, pixel block,
3//! wobble amplitude) are project pixels; `scale` = canvas px per project px converts them.
4//! `Wobble` is geometric: it does not touch pixels — the compositor adds `wobble()` to the placement.
5//!
6//! Implementations (all O(pixels), no per-call allocation beyond `scratch`):
7//!  * Blur: 3 passes of a separable box blur ≈ Gaussian (radius*scale px).
8//!  * Pixelate: average over blocks of (size*scale) px.
9//!  * Tint: mix each pixel towards (r,g,b) by `amount`.
10//!  * Color: brightness (add), contrast (around 0.5), saturation (mix with luma), hue (rotate in YIQ),
11//!    gamma (LUT); build one 256-entry LUT per channel where possible.
12//!  * Vignette: darken by `strength` outside `radius` (normalised to half the diagonal) with `softness`.
13//!  * Sharpen: unsharp mask (img + amount * (img - blur(radius))).
14//!  * Invert / Grayscale: mix by `amount`.
15//!  * Flip: horizontal / vertical mirror (params >= 0.5 = on).
16//!  * Crop: fractions cut from each edge (alpha = 0), with an optional feathered edge.
17//!  * Threshold / Levels / Curves: one 256-entry LUT per channel.
18//!  * HueShift: RGB -> HSL -> RGB per pixel.
19//!  * ChromaKey: Cb/Cr distance to the key colour with spill removal (no edge shrink — that needs
20//!    neighbourhood taps; the GPU body does it).
21//!  * RecDot: a blinking dot plus a seven-segment HH:MM:SS timecode drawn as rectangles.
22//!
23//! Still GPU-only (engine/shaders.rs has the fragment bodies, the CPU path leaves the layer alone):
24//! Vhs, MotionBlur, EdgeGlow, JpegCompress, the BlobTrack overlay and user Shaders. `track` below is
25//! the CPU half of BlobTrack — it runs anywhere, so the tracked centroid can drive properties even
26//! without a GL context.
27
28use crate::media::Frame;
29use crate::model::{Effect, EffectKind};
30
31/// A pixel-sized parameter (project px) as whole image px at `scale`, floored at `min`. A non-zero
32/// request never rounds down to nothing, so a small radius still shows in the small preview canvas
33/// instead of appearing only on export (preview == export).
34fn px(v: f64, scale: f32, min: f64) -> usize {
35    if v <= 0.0 {
36        0
37    } else {
38        (v * scale as f64).round().max(min) as usize
39    }
40}
41
42/// True for kinds that only exist as GPU shaders (engine/gpu): `apply` leaves the layer untouched, so
43/// previews and exports on a machine without a GL context degrade gracefully instead of panicking.
44/// Callers that must warn about what a CPU render drops (export) share this list.
45pub fn gpu_only(kind: EffectKind) -> bool {
46    matches!(
47        kind,
48        EffectKind::JpegCompress
49            | EffectKind::MotionBlur
50            | EffectKind::Plane3d
51            | EffectKind::EdgeGlow
52            | EffectKind::BlobTrack
53            | EffectKind::Vhs
54            | EffectKind::Shader
55    )
56}
57
58/// Apply one effect in place at clip-local time `t`. `scratch` is a reusable buffer.
59pub fn apply(effect: &Effect, t: f64, scale: f32, img: &mut Frame, scratch: &mut Frame) {
60    if img.is_empty() || !effect.enabled || gpu_only(effect.kind) {
61        return;
62    }
63    let e = |i: usize| effect.at(i, t);
64    match effect.kind {
65        EffectKind::Blur => {
66            // 3 box passes of radius r/2 ≈ Gaussian with sigma ≈ r/2 (visual radius ≈ r)
67            let r = px(e(0) * 0.5, scale, 1.0);
68            if r > 0 {
69                for _ in 0..3 {
70                    blur_pass(img, scratch, r);
71                }
72            }
73        }
74        // block 1 is the no-op, so a requested block of ≥ 2 keeps at least 2 px at preview scale
75        EffectKind::Pixelate => pixelate(img, if e(0) >= 2.0 { px(e(0), scale, 2.0) as u32 } else { 1 }),
76        EffectKind::Tint => {
77            let a = e(3).clamp(0.0, 1.0);
78            let target = [e(0), e(1), e(2)];
79            let mut lut = [[0u8; 256]; 3];
80            for c in 0..3 {
81                for (v, o) in lut[c].iter_mut().enumerate() {
82                    *o = (v as f64 + (target[c].clamp(0.0, 255.0) - v as f64) * a + 0.5) as u8;
83                }
84            }
85            for px in img.rgba.chunks_exact_mut(4) {
86                px[0] = lut[0][px[0] as usize];
87                px[1] = lut[1][px[1] as usize];
88                px[2] = lut[2][px[2] as usize];
89            }
90        }
91        EffectKind::Color => color(img, e(0), e(1), e(2), e(3), e(4)),
92        EffectKind::Vignette => vignette(img, e(0) as f32, e(1) as f32, e(2) as f32),
93        EffectKind::Sharpen => sharpen(img, scratch, e(0) as f32, px(e(1), scale, 1.0)),
94        EffectKind::Invert => {
95            let a = e(0).clamp(0.0, 1.0);
96            let mut lut = [0u8; 256];
97            for (v, o) in lut.iter_mut().enumerate() {
98                *o = (v as f64 + (255.0 - 2.0 * v as f64) * a + 0.5) as u8;
99            }
100            for px in img.rgba.chunks_exact_mut(4) {
101                px[0] = lut[px[0] as usize];
102                px[1] = lut[px[1] as usize];
103                px[2] = lut[px[2] as usize];
104            }
105        }
106        EffectKind::Grayscale => {
107            let ai = (e(0).clamp(0.0, 1.0) * 256.0) as i32;
108            for px in img.rgba.chunks_exact_mut(4) {
109                // Rec.709 luma in 8.8 fixed point, then integer mix: v + (l - v) * a
110                let l = ((px[0] as u32 * 54 + px[1] as u32 * 183 + px[2] as u32 * 19) >> 8) as i32;
111                for c in 0..3 {
112                    let v = px[c] as i32;
113                    px[c] = (v + (((l - v) * ai) >> 8)) as u8;
114                }
115            }
116        }
117        EffectKind::Flip => flip(img, e(0) >= 0.5, e(1) >= 0.5),
118        EffectKind::Crop => crop(img, e(0) as f32, e(1) as f32, e(2) as f32, e(3) as f32, e(4) as f32),
119        EffectKind::Threshold => threshold(img, e(0) as f32, e(1) as f32, e(2) >= 0.5),
120        EffectKind::HueShift => hue_shift(img, e(0) as f32, e(1) as f32, e(2) as f32),
121        EffectKind::Levels => levels(img, e(0), e(1), e(2), e(3), e(4)),
122        EffectKind::Curves => curves(img, &std::array::from_fn::<f64, 12, _>(|i| e(i))),
123        EffectKind::ChromaKey => {
124            chroma_key(img, [e(0) as f32, e(1) as f32, e(2) as f32], e(3) as f32, e(4) as f32, e(5) >= 0.5, e(6) as f32)
125        }
126        EffectKind::ColorReplace => color_replace(
127            img,
128            [e(0) as f32, e(1) as f32, e(2) as f32],
129            [e(3) as f32, e(4) as f32, e(5) as f32],
130            e(6) as f32,
131            e(7) as f32,
132        ),
133        EffectKind::RecDot => rec_dot(img, t, scale, e(0), e(1), e(2), e(3) >= 0.5, e(4)),
134        // geometric — handled by the compositor's placement (see `wobble`)
135        EffectKind::Wobble | EffectKind::Plane3d => {}
136        // GPU-only kinds returned above; listed so a new kind is a compile error, not a silent no-op
137        EffectKind::JpegCompress
138        | EffectKind::MotionBlur
139        | EffectKind::EdgeGlow
140        | EffectKind::BlobTrack
141        | EffectKind::Vhs
142        | EffectKind::Shader => {}
143    }
144}
145
146/// Placement deltas for a geometric effect at clip-local time t: (dx, dy) in project px,
147/// (roll, yaw, pitch) in degrees.
148///
149/// `Wobble` is camera shake: deterministic from the seed, smooth (sum of a few incommensurate sines),
150/// frequency in Hz. `Plane3d` is static — its yaw/pitch/roll go straight to the placement, which the
151/// CPU compositor already renders as a homography. Distance / field of view / offset Z only exist in
152/// the GPU path; the CPU fallback ignores them.
153pub fn wobble(effect: &Effect, t: f64) -> (f64, f64, f64, f64, f64) {
154    if effect.kind == EffectKind::Plane3d {
155        return (0.0, 0.0, effect.at(2, t), effect.at(0, t), effect.at(1, t));
156    }
157    let seed = effect.at(6, t);
158    // Smoothness slows the motion down (1 = an eighth of the set frequency), so a shake can be made as
159    // slow and gentle as wanted without editing every amplitude.
160    // Motion (7) and Smoothness (8) only exist on effects created after round 3; older saved projects
161    // keep the original layered-sine look at their set frequency.
162    let method = if effect.params.len() > 7 { effect.at(7, t) } else { 1.0 };
163    let smooth = if effect.params.len() > 8 { effect.at(8, t).clamp(0.0, 1.0) } else { 0.0 };
164    let freq = effect.at(5, t).max(0.0) / (1.0 + 7.0 * smooth);
165    let w = std::f64::consts::TAU * freq * t;
166    let n = |axis: f64| -> f64 { wobble_wave(method, w, seed * 1.7 + axis * 13.37) };
167    (
168        effect.at(0, t) * n(1.0),
169        effect.at(1, t) * n(2.0),
170        effect.at(2, t) * n(3.0),
171        effect.at(3, t) * n(4.0),
172        effect.at(4, t) * n(5.0),
173    )
174}
175
176/// One shake axis in -1..1. `method` picks the waveform (see `model::WOBBLE_MOTIONS`), `w` is the phase
177/// in radians and `p` the per-axis offset so the axes never move together.
178fn wobble_wave(method: f64, w: f64, p: f64) -> f64 {
179    // deterministic value noise: hash the step index, smoothstep or cubic-interpolate between steps
180    let hash = |i: f64| -> f64 {
181        let x = (i * 127.1 + p * 311.7).sin() * 43758.545;
182        2.0 * (x - x.floor()) - 1.0
183    };
184    let x = w / std::f64::consts::TAU;
185    let (i, f) = (x.floor(), x - x.floor());
186    match method.round() as i32 {
187        // a single clean wave
188        0 => (w + p).sin(),
189        // smoothed random steps (cubic / Catmull-Rom through the noise values)
190        2 => {
191            let (a, b, c, d) = (hash(i - 1.0), hash(i), hash(i + 1.0), hash(i + 2.0));
192            let (f2, f3) = (f * f, f * f * f);
193            0.5 * ((2.0 * b) + (c - a) * f + (2.0 * a - 5.0 * b + 4.0 * c - d) * f2 + (3.0 * (b - c) + d - a) * f3)
194        }
195        // triangle
196        3 => {
197            let u = (x + p * 0.1).fract().abs();
198            4.0 * (u - 0.5).abs() - 1.0
199        }
200        // stepped random (holds each value for a whole cycle)
201        4 => hash(i),
202        // layered sines — the original look, and the default
203        _ => 0.5 * (w + p).sin() + 0.35 * (w * 1.618_034 + p * 2.236).sin() + 0.15 * (w * 2.718_281_8 + p * 3.19).sin(),
204    }
205}
206
207/// `BlobTrack` on the CPU: the centroid of every pixel within `Tolerance` of the target colour, as
208/// (cx, cy) in 0..1 layer coordinates plus the matched area as a fraction of the frame. `None` when
209/// nothing matches. `params` is the effect's parameter list (`EffectKind::BlobTrack.params()` order).
210///
211/// ponytail: centroid of *all* matching pixels, not the largest connected component — upgrade to a
212/// union-find labelling if a second blob of the same colour ever needs to be ignored.
213pub fn track(frame: &Frame, params: &[f64]) -> Option<(f64, f64, f64)> {
214    if frame.is_empty() || params.len() < 4 {
215        return None;
216    }
217    let target = [params[0] as f32 / 255.0, params[1] as f32 / 255.0, params[2] as f32 / 255.0];
218    let tol = (params[3].clamp(0.0, 1.0) as f32) * 1.2;
219    let tol2 = tol * tol;
220    let (w, h) = (frame.width as usize, frame.height as usize);
221    let (mut sx, mut sy, mut n) = (0f64, 0f64, 0usize);
222    for y in 0..h {
223        let row = y * frame.stride();
224        for x in 0..w {
225            let p = &frame.rgba[row + x * 4..row + x * 4 + 4];
226            if p[3] < 8 {
227                continue;
228            }
229            let mut d2 = 0.0f32;
230            for c in 0..3 {
231                let d = p[c] as f32 / 255.0 - target[c];
232                d2 += d * d;
233            }
234            if d2 <= tol2 {
235                sx += x as f64 + 0.5;
236                sy += y as f64 + 0.5;
237                n += 1;
238            }
239        }
240    }
241    if n == 0 {
242        return None;
243    }
244    let nf = n as f64;
245    Some((sx / nf / w as f64, sy / nf / h as f64, nf / (w * h) as f64))
246}
247
248/// One separable box pass (radius `r`, edge-replicated): rows img→scratch, columns scratch→img.
249fn blur_pass(img: &mut Frame, scratch: &mut Frame, r: usize) {
250    scratch.resize(img.width, img.height);
251    blur_dir(img, scratch, true, r);
252    blur_dir(scratch, img, false, r);
253}
254
255/// 1-D sliding-window box blur of all 4 channels along rows (`horiz`) or columns, edge replicate.
256fn blur_dir(src: &Frame, dst: &mut Frame, horiz: bool, r: usize) {
257    let (w, h) = (src.width as usize, src.height as usize);
258    if w == 0 || h == 0 {
259        return;
260    }
261    let (lines, len, ls, es) = if horiz { (h, w, w, 1) } else { (w, h, 1, w) };
262    let r = r.min(len - 1);
263    if r == 0 {
264        dst.rgba.copy_from_slice(&src.rgba);
265        return;
266    }
267    let norm = (2 * r + 1) as u32;
268    for l in 0..lines {
269        let at = |j: usize| (l * ls + j * es) * 4;
270        let mut sum = [0u32; 4];
271        let a0 = at(0);
272        for (k, s) in sum.iter_mut().enumerate() {
273            *s = (r as u32 + 1) * src.rgba[a0 + k] as u32;
274        }
275        for j in 1..=r {
276            let a = at(j.min(len - 1));
277            for (k, s) in sum.iter_mut().enumerate() {
278                *s += src.rgba[a + k] as u32;
279            }
280        }
281        for j in 0..len {
282            let d = at(j);
283            for (k, s) in sum.iter().enumerate() {
284                dst.rgba[d + k] = ((s + norm / 2) / norm) as u8;
285            }
286            let add = at((j + r + 1).min(len - 1));
287            let sub = at(j.saturating_sub(r));
288            for (k, s) in sum.iter_mut().enumerate() {
289                *s += src.rgba[add + k] as u32;
290                *s -= src.rgba[sub + k] as u32;
291            }
292        }
293    }
294}
295
296/// Average each block of `block`×`block` px in place.
297fn pixelate(img: &mut Frame, block: u32) {
298    if block <= 1 {
299        return;
300    }
301    let (w, h) = (img.width as usize, img.height as usize);
302    let b = block as usize;
303    let stride = img.stride();
304    for by in (0..h).step_by(b) {
305        let y1 = (by + b).min(h);
306        for bx in (0..w).step_by(b) {
307            let x1 = (bx + b).min(w);
308            let mut sum = [0u32; 4];
309            for y in by..y1 {
310                let row = y * stride;
311                for x in bx..x1 {
312                    let p = row + x * 4;
313                    for (k, s) in sum.iter_mut().enumerate() {
314                        *s += img.rgba[p + k] as u32;
315                    }
316                }
317            }
318            let n = ((y1 - by) * (x1 - bx)) as u32;
319            let avg = [
320                ((sum[0] + n / 2) / n) as u8,
321                ((sum[1] + n / 2) / n) as u8,
322                ((sum[2] + n / 2) / n) as u8,
323                ((sum[3] + n / 2) / n) as u8,
324            ];
325            for y in by..y1 {
326                let row = y * stride;
327                for x in bx..x1 {
328                    img.rgba[row + x * 4..row + x * 4 + 4].copy_from_slice(&avg);
329                }
330            }
331        }
332    }
333}
334
335/// Brightness/contrast/gamma via one LUT; saturation + hue via one combined 3×3 matrix (Rec.709 luma).
336fn color(img: &mut Frame, bright: f64, contrast: f64, sat: f64, hue_deg: f64, gamma: f64) {
337    let g = if gamma > 0.001 { gamma } else { 1.0 };
338    let mut lut = [0u8; 256];
339    for (v, o) in lut.iter_mut().enumerate() {
340        let f = ((v as f64 / 255.0 - 0.5) * contrast + 0.5 + bright).clamp(0.0, 1.0);
341        *o = (f.powf(1.0 / g) * 255.0 + 0.5) as u8;
342    }
343    let use_mat = (sat - 1.0).abs() > 1e-3 || hue_deg.abs() > 1e-3;
344    if !use_mat {
345        for px in img.rgba.chunks_exact_mut(4) {
346            px[0] = lut[px[0] as usize];
347            px[1] = lut[px[1] as usize];
348            px[2] = lut[px[2] as usize];
349        }
350        return;
351    }
352    const L: [f64; 3] = [0.2126, 0.7152, 0.0722];
353    // saturation: s*I + (1-s)*luma
354    let mut sm = [[0.0f64; 3]; 3];
355    for (i, row) in sm.iter_mut().enumerate() {
356        for (j, m) in row.iter_mut().enumerate() {
357            *m = (1.0 - sat) * L[j] + if i == j { sat } else { 0.0 };
358        }
359    }
360    // hue rotation about the gray axis (SVG feColorMatrix hueRotate)
361    let (s, c) = hue_deg.to_radians().sin_cos();
362    let hm = [
363        [0.213 + c * 0.787 - s * 0.213, 0.715 - c * 0.715 - s * 0.715, 0.072 - c * 0.072 + s * 0.928],
364        [0.213 - c * 0.213 + s * 0.143, 0.715 + c * 0.285 + s * 0.140, 0.072 - c * 0.072 - s * 0.283],
365        [0.213 - c * 0.213 - s * 0.787, 0.715 - c * 0.715 + s * 0.715, 0.072 + c * 0.928 + s * 0.072],
366    ];
367    // combined = hue * sat
368    let mut m = [[0.0f32; 3]; 3];
369    for i in 0..3 {
370        for j in 0..3 {
371            m[i][j] = (hm[i][0] * sm[0][j] + hm[i][1] * sm[1][j] + hm[i][2] * sm[2][j]) as f32;
372        }
373    }
374    for px in img.rgba.chunks_exact_mut(4) {
375        let (r, gr, b) = (px[0] as f32, px[1] as f32, px[2] as f32);
376        for (i, row) in m.iter().enumerate() {
377            let v = (row[0] * r + row[1] * gr + row[2] * b).clamp(0.0, 255.0) as usize;
378            px[i] = lut[v];
379        }
380    }
381}
382
383fn smoothstep(x: f32) -> f32 {
384    let x = x.clamp(0.0, 1.0);
385    x * x * (3.0 - 2.0 * x)
386}
387
388/// Darken by `strength` outside `radius` (0..1.5 of the half diagonal) with a `softness` falloff.
389fn vignette(img: &mut Frame, radius: f32, softness: f32, strength: f32) {
390    let (w, h) = (img.width as usize, img.height as usize);
391    let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
392    let inv_hd = 1.0 / (cx * cx + cy * cy).sqrt().max(1.0);
393    let soft = softness.max(1e-3);
394    let stride = img.stride();
395    for y in 0..h {
396        let dy = y as f32 + 0.5 - cy;
397        let row = y * stride;
398        for x in 0..w {
399            let dx = x as f32 + 0.5 - cx;
400            let d = (dx * dx + dy * dy).sqrt() * inv_hd;
401            let fall = smoothstep((d - radius) / soft);
402            if fall <= 0.0 {
403                continue;
404            }
405            let k = 1.0 - strength.clamp(0.0, 1.0) * fall;
406            let px = &mut img.rgba[row + x * 4..row + x * 4 + 4];
407            px[0] = (px[0] as f32 * k) as u8;
408            px[1] = (px[1] as f32 * k) as u8;
409            px[2] = (px[2] as f32 * k) as u8;
410        }
411    }
412}
413
414/// Unsharp mask: img + amount * (img - box_blur(img, r)). One separable box pass; the vertical
415/// pass reads the h-blurred scratch and combines with the untouched original in place.
416fn sharpen(img: &mut Frame, scratch: &mut Frame, amount: f32, r: usize) {
417    let (w, h) = (img.width as usize, img.height as usize);
418    if w == 0 || h == 0 || amount <= 0.0 {
419        return;
420    }
421    scratch.resize(img.width, img.height);
422    blur_dir(img, scratch, true, r);
423    let r = r.min(h - 1);
424    let norm = (2 * r + 1) as f32;
425    for x in 0..w {
426        let at = |y: usize| (y * w + x) * 4;
427        let mut sum = [0f32; 4];
428        let a0 = at(0);
429        for (k, s) in sum.iter_mut().enumerate() {
430            *s = (r as f32 + 1.0) * scratch.rgba[a0 + k] as f32;
431        }
432        for j in 1..=r {
433            let a = at(j.min(h - 1));
434            for (k, s) in sum.iter_mut().enumerate() {
435                *s += scratch.rgba[a + k] as f32;
436            }
437        }
438        for y in 0..h {
439            let d = at(y);
440            for k in 0..3 {
441                let blurred = sum[k] / norm;
442                let o = img.rgba[d + k] as f32;
443                img.rgba[d + k] = (o + amount * (o - blurred)).clamp(0.0, 255.0) as u8;
444            }
445            let add = at((y + r + 1).min(h - 1));
446            let sub = at(y.saturating_sub(r));
447            for (k, s) in sum.iter_mut().enumerate() {
448                *s += scratch.rgba[add + k] as f32;
449                *s -= scratch.rgba[sub + k] as f32;
450            }
451        }
452    }
453}
454
455fn flip(img: &mut Frame, horizontal: bool, vertical: bool) {
456    let (w, h) = (img.width as usize, img.height as usize);
457    let stride = img.stride();
458    if horizontal {
459        for y in 0..h {
460            let row = &mut img.rgba[y * stride..y * stride + w * 4];
461            for x in 0..w / 2 {
462                let (a, b) = (x * 4, (w - 1 - x) * 4);
463                for k in 0..4 {
464                    row.swap(a + k, b + k);
465                }
466            }
467        }
468    }
469    if vertical {
470        for y in 0..h / 2 {
471            let (top, rest) = img.rgba.split_at_mut((h - 1 - y) * stride);
472            top[y * stride..y * stride + stride].swap_with_slice(&mut rest[..stride]);
473        }
474    }
475}
476
477/// Cut `l/r/t/b` fractions from the edges (alpha 0 outside), feathering over `feather * min(w,h)` px.
478fn crop(img: &mut Frame, l: f32, r: f32, tp: f32, b: f32, feather: f32) {
479    let (w, h) = (img.width as f32, img.height as f32);
480    let (x0, x1) = (l.clamp(0.0, 0.5) * w, w - r.clamp(0.0, 0.5) * w);
481    let (y0, y1) = (tp.clamp(0.0, 0.5) * h, h - b.clamp(0.0, 0.5) * h);
482    let fe = feather.clamp(0.0, 0.5) * w.min(h);
483    let stride = img.stride();
484    for y in 0..img.height as usize {
485        let yc = y as f32 + 0.5;
486        let dy = (yc - y0).min(y1 - yc);
487        let row = y * stride;
488        for x in 0..img.width as usize {
489            let xc = x as f32 + 0.5;
490            let d = (xc - x0).min(x1 - xc).min(dy);
491            let a = if d <= 0.0 {
492                0.0
493            } else if fe > 0.0 {
494                (d / fe).min(1.0)
495            } else {
496                1.0
497            };
498            if a >= 1.0 {
499                continue;
500            }
501            let p = row + x * 4 + 3;
502            img.rgba[p] = (img.rgba[p] as f32 * a) as u8;
503        }
504    }
505}
506
507/// Build a 256-entry LUT from a 0..1 -> 0..1 function.
508fn lut_from(f: impl Fn(f64) -> f64) -> [u8; 256] {
509    std::array::from_fn(|v| (f(v as f64 / 255.0).clamp(0.0, 1.0) * 255.0 + 0.5) as u8)
510}
511
512fn apply_lut(img: &mut Frame, lut: &[u8; 256]) {
513    for px in img.rgba.chunks_exact_mut(4) {
514        px[0] = lut[px[0] as usize];
515        px[1] = lut[px[1] as usize];
516        px[2] = lut[px[2] as usize];
517    }
518}
519
520/// Posterise to black/white at `level` with a `softness`-wide ramp, on luma or per channel.
521fn threshold(img: &mut Frame, level: f32, softness: f32, per_channel: bool) {
522    let sm = softness.max(1e-4) * 0.5;
523    let lut: [u8; 256] = lut_from(|x| smoothstep((x as f32 - (level - sm)) / (2.0 * sm)) as f64);
524    if per_channel {
525        apply_lut(img, &lut);
526        return;
527    }
528    for px in img.rgba.chunks_exact_mut(4) {
529        let l = ((px[0] as u32 * 54 + px[1] as u32 * 183 + px[2] as u32 * 19) >> 8).min(255) as usize;
530        let v = lut[l];
531        px[0] = v;
532        px[1] = v;
533        px[2] = v;
534    }
535}
536
537/// Rec.601-free HSL round trip (matches the GPU body): hue in turns, s/l in 0..1.
538fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
539    let mx = r.max(g).max(b);
540    let mn = r.min(g).min(b);
541    let l = (mx + mn) * 0.5;
542    let d = mx - mn;
543    if d <= 1e-5 {
544        return (0.0, 0.0, l);
545    }
546    let s = d / (1.0 - (2.0 * l - 1.0).abs()).max(1e-5);
547    let h = if mx == r {
548        ((g - b) / d).rem_euclid(6.0)
549    } else if mx == g {
550        (b - r) / d + 2.0
551    } else {
552        (r - g) / d + 4.0
553    };
554    (h / 6.0, s, l)
555}
556
557fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
558    let (s, l) = (s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
559    if s <= 0.0 {
560        return (l, l, l);
561    }
562    let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
563    let p = 2.0 * l - q;
564    let c = |t: f32| {
565        let u = t.rem_euclid(1.0);
566        if u < 1.0 / 6.0 {
567            p + (q - p) * 6.0 * u
568        } else if u < 0.5 {
569            q
570        } else if u < 2.0 / 3.0 {
571            p + (q - p) * (2.0 / 3.0 - u) * 6.0
572        } else {
573            p
574        }
575    };
576    (c(h + 1.0 / 3.0), c(h), c(h - 1.0 / 3.0))
577}
578
579/// Rotate hue by `deg`, scale saturation, offset lightness.
580fn hue_shift(img: &mut Frame, deg: f32, sat: f32, light: f32) {
581    let turn = deg / 360.0;
582    for px in img.rgba.chunks_exact_mut(4) {
583        let (h, s, l) = rgb_to_hsl(px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0);
584        let (r, g, b) = hsl_to_rgb(h + turn, (s * sat.max(0.0)).clamp(0.0, 1.0), l + light);
585        px[0] = (r.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
586        px[1] = (g.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
587        px[2] = (b.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
588    }
589}
590
591/// Remap [in_black, in_white] onto [out_black, out_white] through `gamma` (v^(1/g), like Color).
592fn levels(img: &mut Frame, inb: f64, inw: f64, gamma: f64, outb: f64, outw: f64) {
593    let span = (inw - inb).max(1e-4);
594    let g = gamma.max(0.01);
595    let lut = lut_from(|x| outb + ((x - inb) / span).clamp(0.0, 1.0).powf(1.0 / g) * (outw - outb));
596    apply_lut(img, &lut);
597}
598
599/// Monotone cubic (Fritsch–Carlson) through (0,0) (0.25,a) (0.5,b) (0.75,c) (1,1) — the exact curve
600/// `shaders::CURVES` evaluates on the GPU, so preview and export agree.
601pub fn curve_at(x: f64, a: f64, b: f64, c: f64) -> f64 {
602    let y = [0.0, a, b, c, 1.0];
603    let mut d = [0.0f64; 4];
604    for i in 0..4 {
605        d[i] = (y[i + 1] - y[i]) * 4.0;
606    }
607    let mut m = [0.0f64; 5];
608    m[0] = d[0];
609    m[4] = d[3];
610    for i in 1..4 {
611        m[i] = if d[i - 1] * d[i] <= 0.0 { 0.0 } else { (d[i - 1] + d[i]) * 0.5 };
612    }
613    for i in 0..4 {
614        if d[i].abs() < 1e-6 {
615            m[i] = 0.0;
616            m[i + 1] = 0.0;
617        } else {
618            let (ai, bi) = (m[i] / d[i], m[i + 1] / d[i]);
619            let s = ai * ai + bi * bi;
620            if s > 9.0 {
621                let k = 3.0 / s.sqrt();
622                m[i] = k * ai * d[i];
623                m[i + 1] = k * bi * d[i];
624            }
625        }
626    }
627    let xc = x.clamp(0.0, 1.0);
628    let i = ((xc * 4.0).floor() as usize).min(3);
629    let t = xc * 4.0 - i as f64;
630    let (t2, t3) = (t * t, t * t * t);
631    (2.0 * t3 - 3.0 * t2 + 1.0) * y[i]
632        + (t3 - 2.0 * t2 + t) * 0.25 * m[i]
633        + (-2.0 * t3 + 3.0 * t2) * y[i + 1]
634        + (t3 - t2) * 0.25 * m[i + 1]
635}
636
637/// Per-channel curves then the master curve (12 knots: master, R, G, B).
638fn curves(img: &mut Frame, k: &[f64; 12]) {
639    let master = lut_from(|x| curve_at(x, k[0], k[1], k[2]));
640    let chans: [[u8; 256]; 3] = [
641        lut_from(|x| curve_at(x, k[3], k[4], k[5])),
642        lut_from(|x| curve_at(x, k[6], k[7], k[8])),
643        lut_from(|x| curve_at(x, k[9], k[10], k[11])),
644    ];
645    // fold the master into each channel LUT so it stays one lookup per pixel
646    let fold: [[u8; 256]; 3] = std::array::from_fn(|c| std::array::from_fn(|v| master[chans[c][v] as usize]));
647    for px in img.rgba.chunks_exact_mut(4) {
648        px[0] = fold[0][px[0] as usize];
649        px[1] = fold[1][px[1] as usize];
650        px[2] = fold[2][px[2] as usize];
651    }
652}
653
654fn to_ycc(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
655    (0.299 * r + 0.587 * g + 0.114 * b, -0.168736 * r - 0.331264 * g + 0.5 * b, 0.5 * r - 0.418688 * g - 0.081312 * b)
656}
657
658/// Key out colours near `key` (0..255) by their Cb/Cr distance, with spill removal. No edge shrink —
659/// that needs neighbourhood taps; `shaders::CHROMA_KEY` does it on the GPU.
660/// Blend every pixel within `tol` of `from` towards `to`, fading out over `tol .. tol + soft`.
661/// Distances are normalised so 1.0 is the full black-to-white diagonal, matching the GPU body.
662fn color_replace(img: &mut Frame, from: [f32; 3], to: [f32; 3], tol: f32, soft: f32) {
663    let f = from.map(|c| (c / 255.0).clamp(0.0, 1.0));
664    let t = to.map(|c| (c / 255.0).clamp(0.0, 1.0));
665    let tol = tol.clamp(0.0, 1.0);
666    let soft = soft.clamp(0.0, 1.0).max(0.0005);
667    for px in img.rgba.chunks_exact_mut(4) {
668        let c = [px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0];
669        let d = ((c[0] - f[0]).powi(2) + (c[1] - f[1]).powi(2) + (c[2] - f[2]).powi(2)).sqrt() / 1.732_050_8;
670        let hit = 1.0 - smoothstep(((d - tol) / soft).clamp(0.0, 1.0));
671        if hit <= 0.0 {
672            continue;
673        }
674        for i in 0..3 {
675            px[i] = ((c[i] + (t[i] - c[i]) * hit).clamp(0.0, 1.0) * 255.0).round() as u8;
676        }
677    }
678}
679
680fn chroma_key(img: &mut Frame, key: [f32; 3], similarity: f32, smoothness: f32, show_mask: bool, spill: f32) {
681    let (_, kb, kr) = to_ycc(key[0] / 255.0, key[1] / 255.0, key[2] / 255.0);
682    let klen = (kb * kb + kr * kr).sqrt();
683    let sim = similarity.clamp(0.0, 1.0) * 0.5;
684    let width = (smoothness.clamp(0.0, 1.0) * 0.5).max(0.0005);
685    let spill = spill.clamp(0.0, 1.0);
686    for px in img.rgba.chunks_exact_mut(4) {
687        let (r, g, b) = (px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0);
688        let (y, cb, cr) = to_ycc(r, g, b);
689        let a = smoothstep((((cb - kb).powi(2) + (cr - kr).powi(2)).sqrt() - sim) / width);
690        if show_mask {
691            let v = (a * 255.0 + 0.5) as u8;
692            px[0] = v;
693            px[1] = v;
694            px[2] = v;
695            px[3] = 255;
696            continue;
697        }
698        if spill > 0.0 && klen > 1e-3 {
699            let proj = (cb * kb + cr * kr) / klen;
700            if proj > 0.0 {
701                let (nb, nr) = (cb - kb / klen * proj * spill, cr - kr / klen * proj * spill);
702                let rgb = [y + 1.402 * nr, y - 0.344136 * nb - 0.714136 * nr, y + 1.772 * nb];
703                for c in 0..3 {
704                    px[c] = (rgb[c].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
705                }
706            }
707        }
708        px[3] = (px[3] as f32 * a) as u8;
709    }
710}
711
712/// Seven-segment masks for 0..9 (bit 0 = top, then clockwise from top-right, bit 6 = middle).
713const SEVEN_SEG: [u8; 10] = [0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F];
714/// Segment geometry in digit-cell units (cx, cy, half-w, half-h) with `T` the stroke thickness.
715const SEG_T: f32 = 0.11;
716const SEGMENTS: [(f32, f32, f32, f32); 7] = [
717    (0.5, SEG_T, 0.5 - SEG_T, SEG_T),
718    (1.0 - SEG_T, 0.25, SEG_T, 0.25 - SEG_T),
719    (1.0 - SEG_T, 0.75, SEG_T, 0.25 - SEG_T),
720    (0.5, 1.0 - SEG_T, 0.5 - SEG_T, SEG_T),
721    (SEG_T, 0.75, SEG_T, 0.25 - SEG_T),
722    (SEG_T, 0.25, SEG_T, 0.25 - SEG_T),
723    (0.5, 0.5, 0.5 - SEG_T, SEG_T),
724];
725
726/// Opaque axis-aligned rectangle (canvas px, clipped).
727fn fill_rect(img: &mut Frame, x0: f32, y0: f32, x1: f32, y1: f32, rgb: [u8; 3]) {
728    let (w, h) = (img.width as i64, img.height as i64);
729    let xs = (x0.round() as i64).clamp(0, w);
730    let xe = (x1.round() as i64).clamp(0, w);
731    let ys = (y0.round() as i64).clamp(0, h);
732    let ye = (y1.round() as i64).clamp(0, h);
733    let stride = img.stride();
734    for y in ys..ye {
735        let row = y as usize * stride;
736        for x in xs..xe {
737            let p = row + x as usize * 4;
738            img.rgba[p..p + 3].copy_from_slice(&rgb);
739            img.rgba[p + 3] = 255;
740        }
741    }
742}
743
744fn draw_digit(img: &mut Frame, x: f32, y: f32, w: f32, h: f32, digit: usize, rgb: [u8; 3]) {
745    let mask = SEVEN_SEG[digit.min(9)];
746    for (i, (cx, cy, hx, hy)) in SEGMENTS.iter().enumerate() {
747        if mask & (1 << i) != 0 {
748            fill_rect(img, x + (cx - hx) * w, y + (cy - hy) * h, x + (cx + hx) * w, y + (cy + hy) * h, rgb);
749        }
750    }
751}
752
753/// Blinking record dot in a corner (0 = TL, 1 = TR, 2 = BL, 3 = BR) plus an optional HH:MM:SS timecode.
754#[allow(clippy::too_many_arguments)]
755fn rec_dot(img: &mut Frame, t: f64, scale: f32, size: f64, hz: f64, corner: f64, timecode: bool, margin: f64) {
756    let sz = (size.max(2.0) * scale as f64) as f32;
757    let mg = (margin.max(0.0) * scale as f64) as f32;
758    let corner = corner.clamp(0.0, 3.0).round() as u8;
759    let (right, bottom) = (corner == 1 || corner == 3, corner == 2 || corner == 3);
760    let (w, h) = (img.width as f32, img.height as f32);
761    let ax = if right { w - mg - sz * 0.5 } else { mg + sz * 0.5 };
762    let ay = if bottom { h - mg - sz * 0.5 } else { mg + sz * 0.5 };
763    let on = hz <= 0.0 || (t * hz).rem_euclid(1.0) < 0.5;
764    if on {
765        let r = sz * 0.5;
766        let stride = img.stride();
767        for y in ((ay - r).max(0.0) as usize)..((ay + r).ceil().min(h) as usize) {
768            let row = y * stride;
769            for x in ((ax - r).max(0.0) as usize)..((ax + r).ceil().min(w) as usize) {
770                let (dx, dy) = (x as f32 + 0.5 - ax, y as f32 + 0.5 - ay);
771                let d = (dx * dx + dy * dy).sqrt();
772                let a = (r + 0.5 - d).clamp(0.0, 1.0);
773                if a <= 0.0 {
774                    continue;
775                }
776                let p = row + x * 4;
777                for (c, v) in [230u8, 30, 30].into_iter().enumerate() {
778                    img.rgba[p + c] = (img.rgba[p + c] as f32 + (v as f32 - img.rgba[p + c] as f32) * a) as u8;
779                }
780                img.rgba[p + 3] = img.rgba[p + 3].max((a * 255.0) as u8);
781            }
782        }
783    }
784    if !timecode {
785        return;
786    }
787    let (dh, dw, gap) = (sz, sz * 0.55, sz * 0.12);
788    let (cw, colw) = (dw + gap, sz * 0.3 + gap);
789    let total = 6.0 * cw + 2.0 * colw;
790    let mut x = if right { ax - sz * 0.5 - gap * 2.0 - total } else { ax + sz * 0.5 + gap * 2.0 };
791    let y0 = ay - dh * 0.5;
792    let secs = t.max(0.0) as u64;
793    let digits = [
794        (secs / 3600 % 100) / 10,
795        (secs / 3600 % 100) % 10,
796        (secs / 60 % 60) / 10,
797        (secs / 60 % 60) % 10,
798        (secs % 60) / 10,
799        (secs % 60) % 10,
800    ];
801    for (i, d) in digits.into_iter().enumerate() {
802        draw_digit(img, x, y0, dw, dh, d as usize, [235, 235, 235]);
803        x += cw;
804        if i == 1 || i == 3 {
805            let cx = x + colw * 0.5;
806            let r = sz * 0.07;
807            fill_rect(img, cx - r, y0 + dh * 0.33 - r, cx + r, y0 + dh * 0.33 + r, [235, 235, 235]);
808            fill_rect(img, cx - r, y0 + dh * 0.7 - r, cx + r, y0 + dh * 0.7 + r, [235, 235, 235]);
809            x += colw;
810        }
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use crate::model::EffectKind as K;
818
819    fn eff(kind: K, params: &[f64]) -> Effect {
820        let mut e = Effect::new(kind);
821        for (i, v) in params.iter().enumerate() {
822            e.params[i].value = *v;
823        }
824        e
825    }
826
827    /// Deterministic pseudo-random test frame.
828    fn noisy(w: u32, h: u32) -> Frame {
829        let mut f = Frame::new(w, h);
830        let mut s = 0x12345678u32;
831        for px in f.rgba.chunks_exact_mut(4) {
832            for c in px.iter_mut().take(3) {
833                s = s.wrapping_mul(1664525).wrapping_add(1013904223);
834                *c = (s >> 24) as u8;
835            }
836            px[3] = 255;
837        }
838        f
839    }
840
841    fn variance(f: &Frame) -> f64 {
842        let vals: Vec<f64> = f.rgba.chunks_exact(4).map(|p| p[0] as f64).collect();
843        let m = vals.iter().sum::<f64>() / vals.len() as f64;
844        vals.iter().map(|v| (v - m) * (v - m)).sum::<f64>() / vals.len() as f64
845    }
846
847    fn mean(f: &Frame, c: usize) -> f64 {
848        let n = (f.width * f.height) as f64;
849        f.rgba.chunks_exact(4).map(|p| p[c] as f64).sum::<f64>() / n
850    }
851
852    #[test]
853    fn blur_lowers_variance() {
854        let mut img = noisy(16, 16);
855        let v0 = variance(&img);
856        let mut scratch = Frame::default();
857        apply(&eff(K::Blur, &[4.0]), 0.0, 1.0, &mut img, &mut scratch);
858        assert!(variance(&img) < v0 * 0.5, "{} -> {}", v0, variance(&img));
859        // zero radius: no-op
860        let mut img2 = noisy(16, 16);
861        let before = img2.rgba.clone();
862        apply(&eff(K::Blur, &[0.0]), 0.0, 1.0, &mut img2, &mut scratch);
863        assert_eq!(img2.rgba, before);
864        // a small radius at preview scale still blurs (must not round down to 0 px — preview == export)
865        for s in [0.05f32, 0.36, 0.5] {
866            let mut small = noisy(16, 16);
867            let v0 = variance(&small);
868            apply(&eff(K::Blur, &[1.0]), 0.0, s, &mut small, &mut scratch);
869            assert!(variance(&small) < v0, "scale {s} did not blur");
870        }
871    }
872
873    #[test]
874    fn pixelate_makes_blocks_uniform() {
875        let mut img = noisy(8, 8);
876        let mut scratch = Frame::default();
877        // at preview scale a requested block keeps at least 2 px (never silently a no-op)…
878        let mut small = noisy(8, 8);
879        let before = small.rgba.clone();
880        apply(&eff(K::Pixelate, &[4.0]), 0.0, 0.1, &mut small, &mut scratch);
881        assert_ne!(small.rgba, before, "block rounded down to a no-op");
882        // …while a block of 1 stays a no-op at full scale
883        let mut one = noisy(8, 8);
884        let before = one.rgba.clone();
885        apply(&eff(K::Pixelate, &[1.0]), 0.0, 1.0, &mut one, &mut scratch);
886        assert_eq!(one.rgba, before);
887        apply(&eff(K::Pixelate, &[4.0]), 0.0, 1.0, &mut img, &mut scratch);
888        for by in [0u32, 4] {
889            for bx in [0u32, 4] {
890                let i0 = ((by * 8 + bx) * 4) as usize;
891                let first = img.rgba[i0..i0 + 4].to_vec();
892                for y in by..by + 4 {
893                    for x in bx..bx + 4 {
894                        let i = ((y * 8 + x) * 4) as usize;
895                        assert_eq!(&img.rgba[i..i + 4], &first[..], "block {bx},{by} at {x},{y}");
896                    }
897                }
898            }
899        }
900    }
901
902    #[test]
903    fn tint_moves_towards_colour() {
904        let mut img = noisy(8, 8);
905        let d0 = mean(&img, 0);
906        let mut scratch = Frame::default();
907        apply(&eff(K::Tint, &[255.0, 0.0, 0.0, 0.5]), 0.0, 1.0, &mut img, &mut scratch);
908        assert!(mean(&img, 0) > d0, "red should rise");
909        assert!(mean(&img, 1) < 128.0, "green should fall towards 0");
910        // amount 1 = exactly the colour
911        apply(&eff(K::Tint, &[10.0, 20.0, 30.0, 1.0]), 0.0, 1.0, &mut img, &mut scratch);
912        for p in img.rgba.chunks_exact(4) {
913            assert_eq!(&p[..3], &[10, 20, 30]);
914        }
915    }
916
917    #[test]
918    fn color_brightness_contrast_saturation() {
919        let mut scratch = Frame::default();
920        // brightness raises the mean monotonically
921        let base = noisy(8, 8);
922        let mut prev = 0.0;
923        for b in [-0.5, 0.0, 0.5] {
924            let mut img = base.clone();
925            apply(&eff(K::Color, &[b, 1.0, 1.0, 0.0, 1.0]), 0.0, 1.0, &mut img, &mut scratch);
926            let m = mean(&img, 0);
927            assert!(m >= prev, "brightness {b}: {m} < {prev}");
928            prev = m;
929        }
930        // higher contrast -> higher variance
931        let mut low = base.clone();
932        apply(&eff(K::Color, &[0.0, 0.5, 1.0, 0.0, 1.0]), 0.0, 1.0, &mut low, &mut scratch);
933        let mut high = base.clone();
934        apply(&eff(K::Color, &[0.0, 2.0, 1.0, 0.0, 1.0]), 0.0, 1.0, &mut high, &mut scratch);
935        assert!(variance(&low) < variance(&base));
936        assert!(variance(&high) > variance(&low));
937        // saturation 0 -> gray (r == g == b)
938        let mut gray = base.clone();
939        apply(&eff(K::Color, &[0.0, 1.0, 0.0, 0.0, 1.0]), 0.0, 1.0, &mut gray, &mut scratch);
940        for p in gray.rgba.chunks_exact(4) {
941            assert!((p[0] as i32 - p[1] as i32).abs() <= 1 && (p[1] as i32 - p[2] as i32).abs() <= 1, "{p:?}");
942        }
943        // gamma > 1 brightens mid-tones (v^(1/g))
944        let mut g = Frame::new(2, 2);
945        g.fill([128, 128, 128, 255]);
946        apply(&eff(K::Color, &[0.0, 1.0, 1.0, 0.0, 2.0]), 0.0, 1.0, &mut g, &mut scratch);
947        assert!(g.rgba[0] > 150, "{}", g.rgba[0]);
948    }
949
950    #[test]
951    fn invert_and_grayscale() {
952        let mut img = Frame::new(2, 1);
953        img.rgba.copy_from_slice(&[200, 50, 100, 255, 0, 255, 30, 255]);
954        let mut scratch = Frame::default();
955        apply(&eff(K::Invert, &[1.0]), 0.0, 1.0, &mut img, &mut scratch);
956        assert_eq!(&img.rgba[..4], &[55, 205, 155, 255]);
957        assert_eq!(&img.rgba[4..8], &[255, 0, 225, 255]);
958        // amount 0 = no-op
959        let before = img.rgba.clone();
960        apply(&eff(K::Invert, &[0.0]), 0.0, 1.0, &mut img, &mut scratch);
961        assert_eq!(img.rgba, before);
962        // grayscale 1: channels equalish, luma-weighted
963        let mut img = Frame::new(1, 1);
964        img.rgba.copy_from_slice(&[255, 0, 0, 255]);
965        apply(&eff(K::Grayscale, &[1.0]), 0.0, 1.0, &mut img, &mut scratch);
966        let p = img.rgba[..3].to_vec();
967        assert!((p[0] as i32 - p[1] as i32).abs() <= 1, "{p:?}");
968        assert!(p[0] > 40 && p[0] < 70, "Rec709 red luma ~54: {p:?}");
969    }
970
971    #[test]
972    fn flip_mirrors() {
973        let mut img = Frame::new(2, 2);
974        // TL red, TR green, BL blue, BR white
975        img.rgba.copy_from_slice(&[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255]);
976        let mut scratch = Frame::default();
977        apply(&eff(K::Flip, &[1.0, 0.0]), 0.0, 1.0, &mut img, &mut scratch);
978        assert_eq!(&img.rgba[..4], &[0, 255, 0, 255], "horizontal: TL is old TR");
979        apply(&eff(K::Flip, &[0.0, 1.0]), 0.0, 1.0, &mut img, &mut scratch);
980        assert_eq!(&img.rgba[..4], &[255, 255, 255, 255], "vertical after horizontal: TL is old BR");
981    }
982
983    #[test]
984    fn crop_zeroes_alpha_outside() {
985        let mut img = Frame::new(10, 10);
986        img.fill([200, 200, 200, 255]);
987        let mut scratch = Frame::default();
988        apply(&eff(K::Crop, &[0.2, 0.2, 0.2, 0.2, 0.0]), 0.0, 1.0, &mut img, &mut scratch);
989        let a = |img: &Frame, x: u32, y: u32| img.rgba[((y * 10 + x) * 4 + 3) as usize];
990        assert_eq!(a(&img, 0, 5), 0);
991        assert_eq!(a(&img, 9, 5), 0);
992        assert_eq!(a(&img, 5, 0), 0);
993        assert_eq!(a(&img, 5, 9), 0);
994        assert_eq!(a(&img, 5, 5), 255);
995        // feather: partial alpha near the edge
996        let mut img = Frame::new(10, 10);
997        img.fill([200, 200, 200, 255]);
998        apply(&eff(K::Crop, &[0.2, 0.2, 0.2, 0.2, 0.3]), 0.0, 1.0, &mut img, &mut scratch);
999        let edge = a(&img, 2, 5);
1000        assert!(edge > 0 && edge < 255, "feathered edge: {edge}");
1001        assert_eq!(a(&img, 0, 5), 0);
1002    }
1003
1004    #[test]
1005    fn vignette_darkens_corners_not_centre() {
1006        let mut img = Frame::new(16, 16);
1007        img.fill([200, 200, 200, 255]);
1008        let mut scratch = Frame::default();
1009        apply(&eff(K::Vignette, &[0.5, 0.3, 1.0]), 0.0, 1.0, &mut img, &mut scratch);
1010        let v = |x: u32, y: u32| img.rgba[((y * 16 + x) * 4) as usize];
1011        assert_eq!(v(8, 8), 200, "centre untouched");
1012        assert!(v(0, 0) < 100, "corner darkened: {}", v(0, 0));
1013    }
1014
1015    #[test]
1016    fn sharpen_raises_edge_contrast() {
1017        // vertical step edge
1018        let mut img = Frame::new(16, 8);
1019        for y in 0..8u32 {
1020            for x in 0..16u32 {
1021                let c = if x < 8 { 50 } else { 200 };
1022                let i = ((y * 16 + x) * 4) as usize;
1023                img.rgba[i..i + 4].copy_from_slice(&[c, c, c, 255]);
1024            }
1025        }
1026        let mut scratch = Frame::default();
1027        apply(&eff(K::Sharpen, &[1.5, 2.0]), 0.0, 1.0, &mut img, &mut scratch);
1028        let v = |x: u32| img.rgba[((4 * 16 + x) * 4) as usize];
1029        assert!(v(7) < 50, "dark side overshoots darker: {}", v(7));
1030        assert!(v(8) > 200, "bright side overshoots brighter: {}", v(8));
1031        assert_eq!(v(0), 50, "flat areas untouched");
1032    }
1033
1034    #[test]
1035    fn wobble_deterministic_and_zero_at_zero_amplitude() {
1036        let e = eff(K::Wobble, &[20.0, 10.0, 2.0, 3.0, 3.0, 2.0, 7.0]);
1037        let a = wobble(&e, 0.4);
1038        let b = wobble(&e, 0.4);
1039        assert_eq!(a, b, "deterministic");
1040        assert!(a.0.abs() <= 20.0 && a.1.abs() <= 10.0 && a.2.abs() <= 2.0);
1041        // moves at some point
1042        let moved = (0..50).any(|i| wobble(&e, i as f64 * 0.1).0.abs() > 1.0);
1043        assert!(moved);
1044        // different seed, different path
1045        let e2 = eff(K::Wobble, &[20.0, 10.0, 2.0, 3.0, 3.0, 2.0, 8.0]);
1046        assert_ne!(wobble(&e, 0.4), wobble(&e2, 0.4));
1047        // zero amplitude = exactly zero
1048        let z = eff(K::Wobble, &[0.0, 0.0, 0.0, 0.0, 0.0, 2.0, 7.0]);
1049        assert_eq!(wobble(&z, 1.23), (0.0, 0.0, 0.0, 0.0, 0.0));
1050    }
1051
1052    #[test]
1053    fn geometric_flag() {
1054        assert!(EffectKind::Wobble.is_geometric());
1055        assert!(EffectKind::Plane3d.is_geometric());
1056        assert!(!EffectKind::Blur.is_geometric());
1057        // every kind `apply` no-ops is flagged gpu_only, and no other kind is
1058        assert!(gpu_only(EffectKind::Vhs) && gpu_only(EffectKind::Shader));
1059        assert!(!gpu_only(EffectKind::Blur) && !gpu_only(EffectKind::Wobble));
1060    }
1061
1062    /// A frame of one flat colour.
1063    fn flat(w: u32, h: u32, rgba: [u8; 4]) -> Frame {
1064        let mut f = Frame::new(w, h);
1065        f.fill(rgba);
1066        f
1067    }
1068
1069    fn run(kind: K, params: &[f64], img: &mut Frame) {
1070        apply(&eff(kind, params), 0.0, 1.0, img, &mut Frame::default());
1071    }
1072
1073    #[test]
1074    fn threshold_splits_at_the_level() {
1075        // per channel, no softness: below the level -> 0, above -> 255
1076        let mut img = Frame::new(2, 1);
1077        img.rgba.copy_from_slice(&[100, 100, 100, 255, 155, 155, 155, 255]);
1078        run(K::Threshold, &[0.5, 0.0, 1.0], &mut img);
1079        assert_eq!(&img.rgba[..3], &[0, 0, 0]);
1080        assert_eq!(&img.rgba[4..7], &[255, 255, 255]);
1081        // the level moves the split
1082        let mut img = flat(2, 1, [100, 100, 100, 255]);
1083        run(K::Threshold, &[0.3, 0.0, 1.0], &mut img);
1084        assert_eq!(&img.rgba[..3], &[255, 255, 255]);
1085        // luma mode greys the output out of a colour
1086        let mut img = flat(1, 1, [255, 0, 0, 255]);
1087        run(K::Threshold, &[0.1, 0.0, 0.0], &mut img);
1088        assert_eq!(&img.rgba[..3], &[255, 255, 255]);
1089        // softness leaves mid values in between
1090        let mut img = flat(1, 1, [128, 128, 128, 255]);
1091        run(K::Threshold, &[0.5, 0.5, 1.0], &mut img);
1092        assert!((100..=160).contains(&img.rgba[0]), "{}", img.rgba[0]);
1093    }
1094
1095    #[test]
1096    fn hue_shift_turns_red_into_green() {
1097        let mut img = flat(1, 1, [255, 0, 0, 255]);
1098        run(K::HueShift, &[120.0, 1.0, 0.0], &mut img);
1099        assert_eq!(&img.rgba[..3], &[0, 255, 0]);
1100        // and another 120 degrees is blue
1101        run(K::HueShift, &[120.0, 1.0, 0.0], &mut img);
1102        assert_eq!(&img.rgba[..3], &[0, 0, 255]);
1103        // saturation 0 -> grey, lightness lifts it
1104        let mut img = flat(1, 1, [255, 0, 0, 255]);
1105        run(K::HueShift, &[0.0, 0.0, 0.0], &mut img);
1106        assert_eq!(&img.rgba[..3], &[128, 128, 128]);
1107        run(K::HueShift, &[0.0, 1.0, 0.5], &mut img);
1108        assert!(img.rgba[0] > 200, "{}", img.rgba[0]);
1109    }
1110
1111    #[test]
1112    fn levels_clamp_and_remap() {
1113        let mut img = Frame::new(3, 1);
1114        img.rgba.copy_from_slice(&[0, 0, 0, 255, 100, 100, 100, 255, 255, 255, 255, 255]);
1115        run(K::Levels, &[0.5, 1.0, 1.0, 0.0, 1.0], &mut img);
1116        assert_eq!(img.rgba[0], 0, "below in-black clamps to 0");
1117        assert_eq!(img.rgba[4], 0, "0.39 is below in-black 0.5");
1118        assert_eq!(img.rgba[8], 255, "in-white stays white");
1119        // output range compresses
1120        let mut img = flat(2, 1, [0, 255, 0, 255]);
1121        run(K::Levels, &[0.0, 1.0, 1.0, 0.2, 0.8], &mut img);
1122        assert_eq!(img.rgba[0], 51);
1123        assert_eq!(img.rgba[1], 204);
1124        // gamma > 1 lifts the mid tones
1125        let mut img = flat(1, 1, [128, 128, 128, 255]);
1126        run(K::Levels, &[0.0, 1.0, 2.0, 0.0, 1.0], &mut img);
1127        assert!(img.rgba[0] > 150, "{}", img.rgba[0]);
1128    }
1129
1130    #[test]
1131    fn curves_are_identity_at_the_default_knots() {
1132        for x in [0.0, 0.25, 0.5, 0.75, 1.0, 0.1, 0.9] {
1133            assert!((curve_at(x, 0.25, 0.5, 0.75) - x).abs() < 1e-9, "x = {x}");
1134        }
1135        let base = noisy(8, 8);
1136        let mut img = base.clone();
1137        run(K::Curves, &[0.25, 0.5, 0.75, 0.25, 0.5, 0.75, 0.25, 0.5, 0.75, 0.25, 0.5, 0.75], &mut img);
1138        assert_eq!(img.rgba, base.rgba, "default knots must be a no-op");
1139        // a lifted master curve brightens without inverting the order
1140        let mut img = flat(1, 1, [128, 128, 128, 255]);
1141        run(K::Curves, &[0.4, 0.7, 0.9, 0.25, 0.5, 0.75, 0.25, 0.5, 0.75, 0.25, 0.5, 0.75], &mut img);
1142        assert!(img.rgba[0] > 160, "{}", img.rgba[0]);
1143        // per-channel: only red is pushed down
1144        let mut img = flat(1, 1, [200, 200, 200, 255]);
1145        run(K::Curves, &[0.25, 0.5, 0.75, 0.05, 0.1, 0.2, 0.25, 0.5, 0.75, 0.25, 0.5, 0.75], &mut img);
1146        assert!(img.rgba[0] < 100, "{}", img.rgba[0]);
1147        assert_eq!(img.rgba[1], 200);
1148        // knots out of order: the spline must still stay inside the knot range, never ring past it
1149        for i in 0..=100 {
1150            let v = curve_at(i as f64 / 100.0, 0.9, 0.1, 0.95);
1151            assert!((-1e-9..=1.0 + 1e-9).contains(&v), "out of range at {i}: {v}");
1152        }
1153        // sane knots stay monotone
1154        let mut prev = f64::MIN;
1155        for i in 0..=100 {
1156            let v = curve_at(i as f64 / 100.0, 0.1, 0.4, 0.8);
1157            assert!(v >= prev - 1e-9, "not monotone at {i}: {v} < {prev}");
1158            prev = v;
1159        }
1160    }
1161
1162    #[test]
1163    fn color_replace_swaps_only_matching_pixels() {
1164        let mut img = Frame::new(3, 1);
1165        // red (the source), blue, white
1166        img.rgba.copy_from_slice(&[255, 0, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255]);
1167        // red -> green, tight tolerance
1168        run(K::ColorReplace, &[255.0, 0.0, 0.0, 0.0, 255.0, 0.0, 0.05, 0.02], &mut img);
1169        assert_eq!(&img.rgba[..4], &[0, 255, 0, 255], "the source colour is replaced");
1170        assert_eq!(&img.rgba[4..8], &[0, 0, 255, 255], "blue is untouched");
1171        assert_eq!(&img.rgba[8..12], &[255, 255, 255, 255], "white is untouched");
1172        // a wide tolerance catches near-matches too
1173        let mut img = flat(1, 1, [230, 20, 20, 255]);
1174        run(K::ColorReplace, &[255.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.1], &mut img);
1175        assert!(img.rgba[0] < 60, "near-red should have been replaced: {:?}", &img.rgba[..3]);
1176    }
1177
1178    #[test]
1179    fn chroma_key_removes_the_key_colour_only() {
1180        let mut img = Frame::new(3, 1);
1181        // green (the key), red, white
1182        img.rgba.copy_from_slice(&[0, 255, 0, 255, 255, 0, 0, 255, 255, 255, 255, 255]);
1183        run(K::ChromaKey, &[0.0, 255.0, 0.0, 0.4, 0.1, 0.0, 0.5, 0.0], &mut img);
1184        assert_eq!(img.rgba[3], 0, "the key colour is gone");
1185        assert_eq!(img.rgba[7], 255, "red survives");
1186        assert_eq!(&img.rgba[4..7], &[255, 0, 0], "red is not despilled");
1187        assert_eq!(img.rgba[11], 255, "white survives");
1188        // show mask paints the matte instead
1189        let mut img = Frame::new(2, 1);
1190        img.rgba.copy_from_slice(&[0, 255, 0, 255, 255, 0, 0, 255]);
1191        run(K::ChromaKey, &[0.0, 255.0, 0.0, 0.4, 0.1, 1.0, 0.5, 0.0], &mut img);
1192        assert_eq!(&img.rgba[..4], &[0, 0, 0, 255]);
1193        assert_eq!(&img.rgba[4..8], &[255, 255, 255, 255]);
1194        // spill removal pulls green out of a greenish skin tone but keeps it opaque
1195        let mut img = flat(1, 1, [200, 190, 150, 255]);
1196        run(K::ChromaKey, &[0.0, 255.0, 0.0, 0.4, 0.1, 0.0, 1.0, 0.0], &mut img);
1197        assert_eq!(img.rgba[3], 255);
1198    }
1199
1200    #[test]
1201    fn blob_tracking_finds_a_red_square() {
1202        let mut f = flat(20, 20, [0, 0, 0, 255]);
1203        for y in 8..12u32 {
1204            for x in 8..12u32 {
1205                let i = ((y * 20 + x) * 4) as usize;
1206                f.rgba[i..i + 4].copy_from_slice(&[255, 0, 0, 255]);
1207            }
1208        }
1209        let p = K::BlobTrack.params().iter().map(|s| s.default).collect::<Vec<_>>();
1210        let (cx, cy, area) = track(&f, &p).expect("found");
1211        assert!((cx - 0.5).abs() < 1e-9 && (cy - 0.5).abs() < 1e-9, "{cx} {cy}");
1212        assert!((area - 16.0 / 400.0).abs() < 1e-9, "{area}");
1213        // off-centre square moves the centroid
1214        let mut f2 = flat(20, 20, [0, 0, 0, 255]);
1215        for y in 0..4u32 {
1216            for x in 0..4u32 {
1217                let i = ((y * 20 + x) * 4) as usize;
1218                f2.rgba[i..i + 4].copy_from_slice(&[255, 0, 0, 255]);
1219            }
1220        }
1221        let (cx2, cy2, _) = track(&f2, &p).expect("found");
1222        assert!(cx2 < 0.2 && cy2 < 0.2, "{cx2} {cy2}");
1223        // nothing matching, and a degenerate frame
1224        assert!(track(&flat(4, 4, [0, 0, 255, 255]), &p).is_none());
1225        assert!(track(&Frame::default(), &p).is_none());
1226        assert!(track(&f, &[]).is_none());
1227    }
1228
1229    #[test]
1230    fn plane3d_drives_the_placement_on_the_cpu() {
1231        let e = eff(K::Plane3d, &[30.0, -10.0, 5.0, 2.0, 45.0, 0.0]);
1232        assert_eq!(wobble(&e, 0.0), (0.0, 0.0, 5.0, 30.0, -10.0));
1233    }
1234
1235    #[test]
1236    fn rec_dot_paints_a_dot_and_a_timecode() {
1237        let mut img = flat(96, 40, [0, 0, 0, 255]);
1238        // size 12, always on, top-left, timecode, margin 4
1239        apply(&eff(K::RecDot, &[12.0, 0.0, 0.0, 1.0, 4.0]), 0.0, 1.0, &mut img, &mut Frame::default());
1240        let at = |img: &Frame, x: u32, y: u32| {
1241            let i = ((y * 96 + x) * 4) as usize;
1242            [img.rgba[i], img.rgba[i + 1], img.rgba[i + 2]]
1243        };
1244        let dot = at(&img, 10, 10);
1245        assert!(dot[0] > 150 && dot[1] < 80, "dot at the top-left: {dot:?}");
1246        let ink = img.rgba.chunks_exact(4).filter(|p| p[0] > 200 && p[1] > 200 && p[2] > 200).count();
1247        assert!(ink > 40, "timecode digits drawn: {ink}");
1248        // blink: off during the second half of the cycle
1249        let mut img = flat(96, 40, [0, 0, 0, 255]);
1250        apply(&eff(K::RecDot, &[12.0, 1.0, 0.0, 0.0, 4.0]), 0.6, 1.0, &mut img, &mut Frame::default());
1251        assert_eq!(at(&img, 10, 10), [0, 0, 0], "dot is blinked off");
1252        // a different corner moves it
1253        let mut img = flat(96, 40, [0, 0, 0, 255]);
1254        apply(&eff(K::RecDot, &[12.0, 0.0, 3.0, 0.0, 4.0]), 0.0, 1.0, &mut img, &mut Frame::default());
1255        assert_eq!(at(&img, 10, 10), [0, 0, 0]);
1256        assert!(at(&img, 88, 30)[0] > 150, "dot at the bottom-right: {:?}", at(&img, 88, 30));
1257    }
1258
1259    #[test]
1260    fn wobble_motion_methods_and_smoothness() {
1261        use crate::model::{Effect, EffectKind};
1262        let mut e = Effect::new(EffectKind::Wobble);
1263        assert_eq!(e.params.len(), 9, "Motion + Smoothness knobs");
1264        // every method stays inside the amplitude it was given, and is deterministic
1265        for m in 0..5 {
1266            e.params[7] = crate::model::Animated::new(m as f64);
1267            for i in 0..40 {
1268                let t = i as f64 * 0.05;
1269                let (dx, dy, roll, ..) = wobble(&e, t);
1270                assert!(dx.abs() <= 20.0 + 1e-6 && dy.abs() <= 20.0 + 1e-6, "method {m} amplitude {dx} {dy}");
1271                assert!(roll.abs() <= 2.0 + 1e-6);
1272                assert_eq!(wobble(&e, t).0, dx, "method {m} is deterministic");
1273            }
1274        }
1275        // Smoothness slows it down: over one second the path travels much less ground
1276        let travel = |e: &Effect| -> f64 {
1277            (1..200)
1278                .map(|i| {
1279                    let (a, _, _, _, _) = wobble(e, (i - 1) as f64 * 0.005);
1280                    let (b, _, _, _, _) = wobble(e, i as f64 * 0.005);
1281                    (b - a).abs()
1282                })
1283                .sum()
1284        };
1285        e.params[7] = crate::model::Animated::new(1.0);
1286        e.params[8] = crate::model::Animated::new(0.0);
1287        let fast = travel(&e);
1288        e.params[8] = crate::model::Animated::new(1.0);
1289        let slow = travel(&e);
1290        assert!(slow < fast * 0.4, "smoothness should calm the motion: {slow} vs {fast}");
1291        // Sine is a pure wave: it crosses zero exactly twice per period
1292        e.params[7] = crate::model::Animated::new(0.0);
1293        e.params[8] = crate::model::Animated::new(0.0);
1294        e.params[5] = crate::model::Animated::new(1.0);
1295        let signs: Vec<bool> = (0..200).map(|i| wobble(&e, i as f64 * 0.005).0 >= 0.0).collect();
1296        let flips = signs.windows(2).filter(|w| w[0] != w[1]).count();
1297        assert_eq!(flips, 2, "one sine period has two zero crossings");
1298    }
1299
1300    #[test]
1301    fn gpu_only_kinds_are_still_no_ops() {
1302        for k in [K::Vhs, K::MotionBlur, K::EdgeGlow, K::JpegCompress, K::BlobTrack, K::Shader] {
1303            let mut img = noisy(8, 8);
1304            let before = img.rgba.clone();
1305            let mut e = Effect::new(k);
1306            for p in e.params.iter_mut() {
1307                p.value = 1.0;
1308            }
1309            apply(&e, 0.0, 1.0, &mut img, &mut Frame::default());
1310            assert_eq!(img.rgba, before, "{k:?} must leave the CPU path alone");
1311        }
1312    }
1313}