diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs new file mode 100644 index 00000000..95853e7c --- /dev/null +++ b/crates/effects/src/blur2.rs @@ -0,0 +1,712 @@ +//! Blur & Sharpen, batch 2: edge-preserving blurs (Bilateral, Smart), per-channel and map-driven +//! blurs (Channel, Compound, CC Vector), bokeh (Camera Lens Blur), radial streaks and small +//! utilities (Reduce Interlace Flicker, CC Cross Blur). + +use effectcraft_color::luminance; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px, gaussian_blur}; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Blur & Sharpen", params, render, gpu: false, float: true } +} + +#[inline] +fn plum(p: Px) -> f32 { + luminance(p[0], p[1], p[2]) +} + +// --------------------------------------------------------------------------------------------- +// Bilateral / Smart Blur + +/// Edge-preserving neighbourhood average. `hard` = box spatial kernel + hard luminance range test +/// (Smart Blur); otherwise Gaussian spatial × Gaussian range weights (bilateral filter, Tomasi & +/// Manduchi 1998). Large radii are sampled on a stride to stay fast. +fn edge_preserving(src: &Image, r: f64, range: f32, hard: bool) -> Image { + let ri = r.ceil() as i64; + let stride = ((r / 6.0).ceil() as i64).max(1); + let sig_s2 = 2.0 * (r / 2.0).max(0.5).powi(2); + let sig_r2 = 2.0 * range.max(1e-3).powi(2); + let mut out = Image::new(src.width, src.height); + out.rows_mut().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let c = src.data[y * src.width as usize + x]; + let cl = plum(c); + let mut acc = [0.0f32; 4]; + let mut wsum = 0.0f32; + let mut dy = -ri; + while dy <= ri { + let mut dx = -ri; + while dx <= ri { + let d2 = (dx * dx + dy * dy) as f64; + if d2 <= r * r + 0.5 { + let q = src.get_clamped(x as i64 + dx, y as i64 + dy); + let w = if hard { + if (plum(q) - cl).abs() <= range { 1.0 } else { 0.0 } + } else { + let dc = (q[0] - c[0]).powi(2) + (q[1] - c[1]).powi(2) + (q[2] - c[2]).powi(2) + (q[3] - c[3]).powi(2); + ((-d2 / sig_s2) as f32 - dc / sig_r2).exp() + }; + if w > 0.0 { + for k in 0..4 { + acc[k] += q[k] * w; + } + wsum += w; + } + } + dx += stride; + } + dy += stride; + } + *o = if wsum > 0.0 { acc.map(|v| v / wsum) } else { c }; + } + }); + out +} + +fn bilateral(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = ctx.params.f("radius") * b.scale; + if r < 0.5 { + return b; + } + let range = ctx.params.f("threshold") as f32 / 100.0; + b.img = edge_preserving(&b.img, r, range, false); + if !ctx.params.b("colorize") { + b.img.map_straight(|c| { + let l = luminance(c[0], c[1], c[2]); + [l, l, l] + }); + } + b +} + +/// Sobel gradient magnitude of premultiplied luminance. +fn sobel(img: &Image) -> Plane { + let l = Plane::from_image(img, plum); + let mut out = Plane::new(l.w, l.h); + if l.w == 0 { + return out; + } + out.data.par_chunks_mut(l.w).enumerate().for_each(|(y, row)| { + let y = y as i64; + for (x, o) in row.iter_mut().enumerate() { + let x = x as i64; + let g = |dx: i64, dy: i64| l.get_clamped(x + dx, y + dy); + let gx = g(1, -1) + 2.0 * g(1, 0) + g(1, 1) - g(-1, -1) - 2.0 * g(-1, 0) - g(-1, 1); + let gy = g(-1, 1) + 2.0 * g(0, 1) + g(1, 1) - g(-1, -1) - 2.0 * g(0, -1) - g(1, -1); + *o = (gx * gx + gy * gy).sqrt() * 0.25; + } + }); + out +} + +fn smart_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = ctx.params.f("radius") * b.scale; + let thr = ctx.params.f("threshold") as f32 / 100.0; + let mode = ctx.params.e("mode"); + let blurred = if r >= 0.5 { edge_preserving(&b.img, r, thr, true) } else { b.img.clone() }; + if mode == 0 { + b.img = blurred; + return b; + } + let edges = sobel(&blurred); + let et = thr.max(0.01) * 0.5; + let mut out = blurred; + out.data.par_iter_mut().zip(edges.data.par_iter()).for_each(|(px, &e)| { + let a = px[3]; + let on = e > et; + if mode == 1 { + let v = if on { 1.0 } else { 0.0 }; + *px = premul([v; 3], a); + } else if on { + *px = premul([1.0; 3], a); + } + }); + b.img = out; + b +} + +// --------------------------------------------------------------------------------------------- +// Channel Blur + +fn dims_xy(dim: u32) -> (f64, f64) { + match dim { + 1 => (1.0, 0.0), + 2 => (0.0, 1.0), + _ => (1.0, 1.0), + } +} + +fn channel_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let sig = ["redBlurriness", "greenBlurriness", "blueBlurriness", "alphaBlurriness"].map(|id| ctx.params.f(id).max(0.0) * 0.5 * b.scale); + if sig.iter().all(|&s| s < 0.05) { + return b; + } + let (kx, ky) = dims_xy(ctx.params.e("dimensions")); + let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; + let maxs = sig.iter().cloned().fold(0.0, f64::max); + if !repeat { + b.pad((maxs * 3.0).ceil() as u32 + 1); + } + // The buffer is padded with transparency (or edges repeat), so an edge-clamped plane blur fits both. + let blur = |pl: &Plane, s: f64| -> Plane { gauss_plane(pl, s * kx, s * ky) }; + let chans: [Plane; 4] = crate::util::split(&b.img); + let alpha = &chans[3]; + let mut alpha_cache: Vec<(u64, Plane)> = Vec::new(); + let mut get_alpha = |s: f64| -> Plane { + let key = s.to_bits(); + if let Some((_, pl)) = alpha_cache.iter().find(|(k, _)| *k == key) { + return pl.clone(); + } + let pl = if s < 0.05 { alpha.clone() } else { blur(alpha, s) }; + alpha_cache.push((key, pl.clone())); + pl + }; + let new_a = get_alpha(sig[3]); + let mut straight: Vec = Vec::with_capacity(3); + for c in 0..3 { + if sig[c] < 0.05 { + straight.push(chans[c].zip_map(alpha, |v, a| if a > 1e-6 { v / a } else { 0.0 })); + } else { + let bc = blur(&chans[c], sig[c]); + let ba = get_alpha(sig[c]); + straight.push(bc.zip_map(&ba, |v, a| if a > 1e-6 { v / a } else { 0.0 })); + } + } + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let a = new_a.data[i].clamp(0.0, 1.0); + *px = premul([straight[0].data[i], straight[1].data[i], straight[2].data[i]], a); + }); + b +} + +// --------------------------------------------------------------------------------------------- +// Compound Blur + +fn compound_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let max_sigma = ctx.params.f("maximumBlur").max(0.0) * 0.5 * b.scale; + if max_sigma < 0.05 { + return b; + } + let invert = ctx.params.b("invertBlur"); + // The blur map is the layer's own luminance (sampled before padding grows the buffer). + if !ctx.adjustment { + b.pad((max_sigma * 3.0).ceil() as u32 + 1); + } + let map = Plane::from_image(&b.img, |p| { + let (c, a) = unpremul(p); + let l = luminance(c[0], c[1], c[2]) * a; + if invert { 1.0 - l } else { l } + }); + const N: usize = 5; + let levels: Vec = (0..N) + .into_par_iter() + .map(|k| { + if k == 0 { + b.img.clone() + } else { + gaussian_blur(&b.img, max_sigma * k as f64 / (N - 1) as f64, max_sigma * k as f64 / (N - 1) as f64, ctx.adjustment) + } + }) + .collect(); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let t = map.data[i].clamp(0.0, 1.0) * (N - 1) as f32; + let k = (t.floor() as usize).min(N - 2); + let f = t - k as f32; + let a = levels[k].data[i]; + let c = levels[k + 1].data[i]; + *px = crate::util::lerp4(a, c, f); + }); + b +} + +// --------------------------------------------------------------------------------------------- +// Camera Lens Blur + +/// Half-open horizontal spans (dy, x_left, x_right) of a polygonal / round iris of radius `r`. +fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64, i64, i64)> { + let ri = r.ceil() as i64; + let rot = rotation_deg.to_radians(); + let verts: Vec<(f64, f64)> = (0..sides) + .map(|k| { + let a = rot - std::f64::consts::FRAC_PI_2 + k as f64 * std::f64::consts::TAU / sides.max(3) as f64; + (r * a.cos(), r * a.sin()) + }) + .collect(); + let mut spans = Vec::new(); + for dy in -ri..=ri { + let y = dy as f64; + let circ = if y.abs() <= r { Some((r * r - y * y).max(0.0).sqrt()) } else { None }; + let poly = if sides >= 3 { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for k in 0..verts.len() { + let (x0, y0) = verts[k]; + let (x1, y1) = verts[(k + 1) % verts.len()]; + if (y0 - y) * (y1 - y) <= 0.0 && (y1 - y0).abs() > 1e-12 { + let x = x0 + (y - y0) / (y1 - y0) * (x1 - x0); + lo = lo.min(x); + hi = hi.max(x); + } + } + if lo <= hi { Some((lo, hi)) } else { None } + } else { + circ.map(|c| (-c, c)) + }; + let span = match (poly, circ) { + (Some((lo, hi)), Some(c)) => Some((lo + (-c - lo) * roundness, hi + (c - hi) * roundness)), + (Some(s), None) => Some(s), + (None, Some(c)) if roundness > 0.5 => Some((-c, c)), + _ => None, + }; + if let Some((lo, hi)) = span { + let (l, h) = (lo.round() as i64, hi.round() as i64); + if l <= h { + spans.push((dy, l, h)); + } + } + } + if spans.is_empty() { + spans.push((0, 0, 0)); + } + spans +} + +fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + if r < 0.5 { + return b; + } + let shape = ctx.params.e("irisShape"); + let sides = if shape >= 8 { 0 } else { shape + 3 }; + let spans = iris_spans(r, sides, ctx.params.f("irisRotation"), ctx.params.f("irisRoundness").clamp(0.0, 100.0) / 100.0); + let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; + if !repeat { + b.pad(r.ceil() as u32 + 1); + } + // Specular highlights: boost bright pixels before the blur so they bloom into bokeh shapes. + let thr = ctx.params.f("specularThreshold") as f32 / 255.0; + let boost = 1.0 + ctx.params.f("specularBrightness").max(0.0) as f32 / 100.0 * 4.0; + let mut src = b.img.clone(); + if boost > 1.0 { + src.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + if a > 0.0 && luminance(c[0], c[1], c[2]) >= thr { + for k in 0..3 { + px[k] *= boost; + } + } + }); + } + let (w, h) = (src.width as usize, src.height as usize); + // Per-row prefix sums (f64 for precision). + let prefix: Vec> = src + .data + .par_chunks(w.max(1)) + .map(|row| { + let mut v = Vec::with_capacity(w + 1); + let mut acc = [0.0f64; 4]; + v.push(acc); + for px in row { + for c in 0..4 { + acc[c] += px[c] as f64; + } + v.push(acc); + } + v + }) + .collect(); + let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum(); + let norm = 1.0 / count as f64; + let mut out = Image::new(src.width, src.height); + out.rows_mut().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let mut acc = [0.0f64; 4]; + for &(dy, l, rr) in &spans { + let mut yy = y as i64 + dy; + if yy < 0 || yy >= h as i64 { + if !repeat { + continue; + } + yy = yy.clamp(0, h as i64 - 1); + } + let pre = &prefix[yy as usize]; + let (mut a, mut bnd) = (x as i64 + l, x as i64 + rr); + if repeat { + if a < 0 { + let n = (-a).min(bnd - a + 1) as f64; + let first = src.data[yy as usize * w]; + for c in 0..4 { + acc[c] += first[c] as f64 * n; + } + a = 0; + } + if bnd >= w as i64 { + let n = (bnd - (w as i64 - 1)).min(bnd - a + 1) as f64; + let last = src.data[yy as usize * w + w - 1]; + for c in 0..4 { + acc[c] += last[c] as f64 * n; + } + bnd = w as i64 - 1; + } + } else { + a = a.max(0); + bnd = bnd.min(w as i64 - 1); + } + if a <= bnd { + for c in 0..4 { + acc[c] += pre[bnd as usize + 1][c] - pre[a as usize][c]; + } + } + } + let mut px = acc.map(|v| (v * norm) as f32); + px[3] = px[3].clamp(0.0, 1.0); + *o = px; + } + }); + b.img = out; + b +} + +// --------------------------------------------------------------------------------------------- +// CC Radial Fast Blur + +fn radial_fast_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5; + if s <= 0.0 { + return b; + } + let c = b.to_px(ctx.params.v2("center")); + let mode = ctx.params.e("zoom"); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let len = (dx * dx + dy * dy).sqrt() * s; + let n = (len.ceil() as usize).clamp(2, 64); + let mut acc = match mode { + 1 => [f32::NEG_INFINITY; 4], + 2 => [f32::INFINITY; 4], + _ => [0.0; 4], + }; + for i in 0..n { + let k = 1.0 - s * i as f64 / (n - 1) as f64; + let q = src.sample_bilinear(c.0 + dx * k, c.1 + dy * k); + for ch in 0..4 { + acc[ch] = match mode { + 1 => acc[ch].max(q[ch]), + 2 => acc[ch].min(q[ch]), + _ => acc[ch] + q[ch] / n as f32, + }; + } + } + for ch in 0..3 { + acc[ch] = acc[ch].min(acc[3]).max(0.0); + } + acc[3] = acc[3].clamp(0.0, 1.0); + *o = acc; + } + }); + b +} + +// --------------------------------------------------------------------------------------------- +// CC Vector Blur + +fn vector_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amount = ctx.params.f("amount").max(0.0) * b.scale; + if amount < 0.5 { + return b; + } + let kind = ctx.params.e("type"); + let off = ctx.params.f("angleOffset").to_radians(); + let soft = ctx.params.f("mapSoftness").max(0.0) * 0.5 * b.scale; + let map = gauss_plane(&Plane::from_image(&b.img, plum), soft, soft); + let src = b.img.clone(); + let (w, h) = (map.w as i64, map.h as i64); + b.img.rows_mut().for_each(|(y, row)| { + let y = y as i64; + for (x, o) in row.iter_mut().enumerate() { + let x = x as i64; + let gx = (map.get_clamped((x + 1).min(w - 1), y) - map.get_clamped((x - 1).max(0), y)) * 0.5; + let gy = (map.get_clamped(x, (y + 1).min(h - 1)) - map.get_clamped(x, (y - 1).max(0))) * 0.5; + let mag = (gx * gx + gy * gy).sqrt() as f64; + let m = map.get_clamped(x, y) as f64; + let (ang, len) = match kind { + 0..=2 => { + if mag < 1e-6 { + continue; + } + let g = (gy as f64).atan2(gx as f64); + let base = if kind == 2 { g } else { g + std::f64::consts::FRAC_PI_2 }; + let len = if kind == 1 { amount } else { amount * (mag * 10.0).min(1.0) }; + (base + off, len) + } + 3 => (m * std::f64::consts::TAU + off, amount), + _ => (m * std::f64::consts::TAU + off, amount * m), + }; + if len < 0.5 { + continue; + } + let (dx, dy) = (ang.cos() * len, ang.sin() * len); + let n = ((len * 2.0).ceil() as usize).clamp(2, 48); + let mut acc = [0.0f32; 4]; + for i in 0..n { + let t = i as f64 / (n - 1) as f64 - 0.5; + let q = src.sample_bilinear_clamped(x as f64 + 0.5 + dx * t, y as f64 + 0.5 + dy * t); + for c in 0..4 { + acc[c] += q[c] / n as f32; + } + } + *o = acc; + } + }); + b +} + +// --------------------------------------------------------------------------------------------- +// Reduce Interlace Flicker / CC Cross Blur + +fn reduce_flicker(ctx: &EffectCtx, mut b: Buf) -> Buf { + let s = ctx.params.f("softness").max(0.0) * b.scale; + if s < 0.05 { + return b; + } + b.img = gaussian_blur(&b.img, 0.0, s, true); + b +} + +fn cross_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let rx = (ctx.params.f("radiusX").max(0.0) * b.scale).round() as usize; + let ry = (ctx.params.f("radiusY").max(0.0) * b.scale).round() as usize; + if rx == 0 && ry == 0 { + return b; + } + if !ctx.adjustment { + b.pad((rx.max(ry) * 3) as u32 + 1); + } + let repeat = ctx.adjustment; + let hz = effectcraft_raster::box_blur(&b.img, rx, 0, 3, repeat); + let vt = effectcraft_raster::box_blur(&b.img, 0, ry, 3, repeat); + let mode = ctx.params.e("transferMode"); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (a, v) = (hz.data[i], vt.data[i]); + let mut o = [0.0f32; 4]; + for c in 0..4 { + o[c] = match mode { + 1 => a[c] + v[c], + 2 => a[c] + v[c] - a[c] * v[c], + 3 => a[c].max(v[c]), + _ => (a[c] + v[c]) * 0.5, + }; + } + o[3] = o[3].clamp(0.0, 1.0); + *px = o; + }); + b +} + +pub fn specs() -> Vec { + let dims = || popup(&["Horizontal and Vertical", "Horizontal", "Vertical"]); + let blur_s = || slider(0.0, 3000.0, 0.0, 50.0, 1); + vec![ + spec( + "ec.blur.bilateral", + "Bilateral Blur", + vec![ + p("radius", "Radius", num(5.0), slider(0.0, 200.0, 0.0, 50.0, 1)), + p("threshold", "Threshold", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("colorize", "Colorize", Value::Bool(true), ParamUi::Checkbox), + ], + bilateral, + ), + spec( + "ec.blur.smart", + "Smart Blur", + vec![ + p("radius", "Radius", num(3.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("threshold", "Threshold", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("mode", "Mode", Value::Enum(0), popup(&["Normal", "Edge Only", "Edge Overlay"])), + ], + smart_blur, + ), + spec( + "ec.blur.channel", + "Channel Blur", + vec![ + p("redBlurriness", "Red Blurriness", num(0.0), blur_s()), + p("greenBlurriness", "Green Blurriness", num(0.0), blur_s()), + p("blueBlurriness", "Blue Blurriness", num(0.0), blur_s()), + p("alphaBlurriness", "Alpha Blurriness", num(0.0), blur_s()), + p("repeatEdge", "Repeat Edge Pixels", Value::Bool(false), ParamUi::Checkbox), + p("dimensions", "Blur Dimensions", Value::Enum(0), dims()), + ], + channel_blur, + ), + spec( + "ec.blur.compound", + "Compound Blur", + vec![ + p("maximumBlur", "Maximum Blur", num(20.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("invertBlur", "Invert Blur", Value::Bool(false), ParamUi::Checkbox), + ], + compound_blur, + ), + spec( + "ec.blur.cameralens", + "Camera Lens Blur", + vec![ + p("blurRadius", "Blur Radius", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p( + "irisShape", + "Iris Shape", + Value::Enum(3), + popup(&["Triangle", "Square", "Pentagon", "Hexagon", "Heptagon", "Octagon", "Nonagon", "Decagon", "Circle"]), + ), + p("irisRotation", "Iris Rotation", num(0.0), ParamUi::Angle), + p("irisRoundness", "Iris Roundness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("specularThreshold", "Specular Threshold", num(255.0), slider(0.0, 255.0, 0.0, 255.0, 0)), + p("specularBrightness", "Specular Brightness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("repeatEdge", "Repeat Edge Pixels", Value::Bool(false), ParamUi::Checkbox), + ], + camera_lens_blur, + ), + spec( + "ec.blur.ccradialfast", + "CC Radial Fast Blur", + vec![ + p("center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point), + p("amount", "Amount", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("zoom", "Zoom", Value::Enum(0), popup(&["Standard", "Brightest", "Darkest"])), + ], + radial_fast_blur, + ), + spec( + "ec.blur.ccvector", + "CC Vector Blur", + vec![ + p("type", "Type", Value::Enum(0), popup(&["Natural", "Constant Length", "Perpendicular", "Direction Center", "Direction Fading"])), + p("amount", "Amount", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p("angleOffset", "Angle Offset", num(0.0), ParamUi::Angle), + p("mapSoftness", "Map Softness", num(15.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + vector_blur, + ), + spec("ec.blur.reduceflicker", "Reduce Interlace Flicker", vec![p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 2))], reduce_flicker), + spec( + "ec.blur.cccross", + "CC Cross Blur", + vec![ + p("radiusX", "Radius X", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p("radiusY", "Radius Y", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p("transferMode", "Transfer Mode", Value::Enum(0), popup(&["Blend", "Add", "Screen", "Lighten"])), + ], + cross_blur, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Params, find}; + + fn run(id: &str, img: &Image, over: &[(&str, Value)]) -> Buf { + let s = find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in over { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }) + } + + fn edge_img() -> Image { + let mut img = Image::new(32, 16); + for y in 0..16 { + for x in 0..32 { + let v = if x < 16 { 0.1 } else { 0.9 }; + img.set(x, y, [v, v, v, 1.0]); + } + } + img + } + + #[test] + fn bilateral_keeps_edge_sharper_than_gaussian() { + let img = edge_img(); + let bl = run("ec.blur.bilateral", &img, &[("radius", num(4.0))]).img; + let g = gaussian_blur(&img, 2.0, 2.0, true); + let jump_b = bl.get(16, 8)[0] - bl.get(15, 8)[0]; + let jump_g = g.get(16, 8)[0] - g.get(15, 8)[0]; + assert!(jump_b > 0.7, "{jump_b}"); + assert!(jump_b > jump_g + 0.3); + } + + #[test] + fn channel_blur_zero_is_identity() { + let img = edge_img(); + let out = run("ec.blur.channel", &img, &[]); + assert_eq!(out.img, img); + } + + #[test] + fn channel_blur_only_touches_selected_channel() { + let img = edge_img(); + let out = run("ec.blur.channel", &img, &[("redBlurriness", num(6.0)), ("repeatEdge", Value::Bool(true))]).img; + let (l, r) = (out.get(15, 8), out.get(16, 8)); + assert!(r[0] - l[0] < 0.6); + assert!((l[1] - 0.1).abs() < 1e-5 && (r[1] - 0.9).abs() < 1e-5); + } + + #[test] + fn lens_blur_preserves_constant_and_energy() { + let img = Image::filled(24, 20, [0.4, 0.3, 0.2, 1.0]); + let out = run("ec.blur.cameralens", &img, &[("repeatEdge", Value::Bool(true))]).img; + assert!(out.data.iter().all(|p| (p[0] - 0.4).abs() < 1e-4 && (p[3] - 1.0).abs() < 1e-4)); + let mut dot = Image::new(9, 9); + dot.set(4, 4, [1.0, 1.0, 1.0, 1.0]); + let out = run("ec.blur.cameralens", &dot, &[("blurRadius", num(5.0))]); + let total: f32 = out.img.data.iter().map(|p| p[3]).sum(); + assert!((total - 1.0).abs() < 1e-3, "{total}"); + assert!(out.img.width > 9); + } + + #[test] + fn iris_spans_hexagon_is_symmetric() { + let s = iris_spans(10.0, 6, 0.0, 0.0); + let s0 = s.iter().find(|t| t.0 == 0).unwrap(); + assert_eq!(s0.1, -s0.2); + let circle = iris_spans(10.0, 0, 0.0, 0.0); + assert_eq!(circle.len(), 21); + } + + #[test] + fn reduce_flicker_blurs_vertically_only() { + let mut img = Image::filled(8, 8, [0.0, 0.0, 0.0, 1.0]); + for x in 0..8 { + img.set(x, 4, [1.0, 1.0, 1.0, 1.0]); + } + let out = run("ec.blur.reduceflicker", &img, &[("softness", num(1.0))]).img; + assert!(out.get(3, 4)[0] < 0.9 && out.get(3, 3)[0] > 0.05); + assert!((out.get(0, 4)[0] - out.get(7, 4)[0]).abs() < 1e-6); + } + + #[test] + fn compound_blur_dark_pixels_stay_sharp() { + let mut img = Image::new(16, 16); + for y in 0..16 { + for x in 0..16 { + img.set(x, y, if (x + y) % 2 == 0 { [0.0, 0.0, 0.0, 1.0] } else { [0.02, 0.02, 0.02, 1.0] }); + } + } + let out = run("ec.blur.compound", &img, &[("maximumBlur", num(10.0))]); + // Luminance ≈ 0 → (almost) no blur. + let pad = (out.img.width - 16) / 2; + assert!(out.img.get(pad as i64, pad as i64)[0] < 0.005); + } +} diff --git a/crates/effects/src/channel.rs b/crates/effects/src/channel.rs new file mode 100644 index 00000000..161fde37 --- /dev/null +++ b/crates/effects/src/channel.rs @@ -0,0 +1,520 @@ +//! Channel effects: channel routing (Set/Shift Channels, Channel Combiner, Set Matte), morphology +//! (Minimax), per-channel arithmetic, solid compositing and un-matting. + +use effectcraft_color::{BlendMode, blend_pixel, hsl_to_rgb, luminance, rgb_to_hsl}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use rayon::prelude::*; + +use crate::util::{Plane, SRC_NAMES, Src, join, morph_plane, pick, premul, src_at, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Channel", params, render, gpu: false, float: true } +} + +/// Route four sources into R, G, B, A (straight colour), premultiplying the result. +fn route(b: &mut Buf, srcs: [Src; 4]) { + if srcs == [Src::Red, Src::Green, Src::Blue, Src::Alpha] { + return; + } + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + let v = srcs.map(|s| pick(s, c, a)); + *px = premul([v[0], v[1], v[2]], v[3].clamp(0.0, 1.0)); + }); +} + +fn set_channels(ctx: &EffectCtx, mut b: Buf) -> Buf { + let s = ["setRedTo", "setGreenTo", "setBlueTo", "setAlphaTo"].map(|id| src_at(ctx.params.e(id))); + route(&mut b, s); + b +} + +const SHIFT_ORDER: [Src; 11] = + [Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Half, Src::Off]; +const SHIFT_NAMES: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Half On", "Full Off"]; + +fn shift_channels(ctx: &EffectCtx, mut b: Buf) -> Buf { + let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| SHIFT_ORDER[(ctx.params.e(id) as usize).min(10)]); + route(&mut b, s); + b +} + +fn rgb_to_yuv(c: [f32; 3]) -> [f32; 3] { + let y = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; + [y, 0.492 * (c[2] - y) + 0.5, 0.877 * (c[0] - y) + 0.5] +} + +fn yuv_to_rgb(c: [f32; 3]) -> [f32; 3] { + let (y, u, v) = (c[0], c[1] - 0.5, c[2] - 0.5); + let r = y + v / 0.877; + let b = y + u / 0.492; + let g = (y - 0.299 * r - 0.114 * b) / 0.587; + [r, g, b] +} + +fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf { + let from = ctx.params.e("from"); + let to = ctx.params.e("to"); + let inv = ctx.params.b("invert"); + b.img.data.par_iter_mut().for_each(|px| { + let (c, mut a) = unpremul(*px); + if a <= 0.0 && from != 7 { + return; + } + let iv = |v: f32| if inv { 1.0 - v } else { v }; + let out = match from { + 0..=3 => { + let o = match from { + 0 => { + let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]); + [h, l, s] + } + 1 => { + let (r, g, bb) = hsl_to_rgb(c[0], c[2], c[1]); + [r, g, bb] + } + 2 => rgb_to_yuv(c), + _ => yuv_to_rgb(c), + }; + o.map(iv) + } + _ => { + let v = iv(match from { + 4 => c[0], + 5 => c[1], + 6 => c[2], + 7 => a, + 8 => rgb_to_hsl(c[0], c[1], c[2]).2, + 9 => rgb_to_hsl(c[0], c[1], c[2]).0, + 10 => rgb_to_hsl(c[0], c[1], c[2]).1, + 11 => luminance(c[0], c[1], c[2]), + 12 => c[0].max(c[1]).max(c[2]), + _ => c[0].min(c[1]).min(c[2]), + }); + let mut o = c; + match to { + 0 => o[0] = v, + 1 => o[1] = v, + 2 => o[2] = v, + 3 => a = v.clamp(0.0, 1.0), + 4..=6 => { + let (mut h, mut s, mut l) = rgb_to_hsl(c[0], c[1], c[2]); + match to { + 4 => l = v, + 5 => h = v, + _ => s = v, + } + let (r, g, bb) = hsl_to_rgb(h, s.clamp(0.0, 1.0), l); + o = [r, g, bb]; + } + _ => o = [v, v, v], + } + o + } + }; + *px = premul(out.map(|v| v.max(0.0)), a); + }); + b +} + +fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + if r == 0 { + return b; + } + let op = ctx.params.e("operation"); + let ch = ctx.params.e("channel"); + let (rx, ry) = match ctx.params.e("direction") { + 1 => (r, 0), + 2 => (0, r), + _ => (r, r), + }; + // Which straight channels (R, G, B, A) to process. + let sel: [bool; 4] = match ch { + 1 => [false, false, false, true], + 2 => [true, true, true, true], + 3 => [true, false, false, false], + 4 => [false, true, false, false], + 5 => [false, false, true, false], + _ => [true, true, true, false], + }; + if sel[3] && op != 0 && !ctx.adjustment { + b.pad((rx.max(ry)) as u32); + } + let mut planes = [0, 1, 2, 3].map(|k| Plane::from_image(&b.img, |px| if k == 3 { px[3] } else { unpremul(px).0[k] })); + for (k, pl) in planes.iter_mut().enumerate() { + if !sel[k] { + continue; + } + *pl = match op { + 0 => morph_plane(pl, rx, ry, false), + 1 => morph_plane(pl, rx, ry, true), + 2 => morph_plane(&morph_plane(pl, rx, ry, false), rx, ry, true), + _ => morph_plane(&morph_plane(pl, rx, ry, true), rx, ry, false), + }; + } + let mut img = join(&planes); + img.data.par_iter_mut().for_each(|px| { + let a = px[3].clamp(0.0, 1.0); + *px = premul([px[0], px[1], px[2]], a); + }); + b.img = img; + b +} + +fn arithmetic(ctx: &EffectCtx, mut b: Buf) -> Buf { + let op = ctx.params.e("operator"); + let k = ["redValue", "greenValue", "blueValue"].map(|id| ctx.params.f(id) as f32 / 255.0); + let clip = ctx.params.b("clip"); + b.img.map_straight(|c| { + let mut o = [0.0f32; 3]; + for i in 0..3 { + let (v, kv) = (c[i], k[i]); + let iv = (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let ik = (kv * 255.0).round() as u8; + let r = match op { + 0 => (iv & ik) as f32 / 255.0, + 1 => (iv | ik) as f32 / 255.0, + 2 => (iv ^ ik) as f32 / 255.0, + 3 => v + kv, + 4 => v - kv, + 5 => (v - kv).abs(), + 6 => v.min(kv), + 7 => v.max(kv), + 8 => { + if v > kv { + 0.0 + } else { + v + } + } + 9 => { + if v < kv { + 0.0 + } else { + v + } + } + 10 => { + if v >= kv { + 1.0 + } else { + 0.0 + } + } + 11 => v * kv, + _ => 1.0 - (1.0 - v) * (1.0 - kv), + }; + o[i] = if clip { r.clamp(0.0, 1.0) } else { r.max(0.0) }; + } + o + }); + b +} + +const SOLID_MODES: [BlendMode; 12] = [ + BlendMode::Normal, + BlendMode::Add, + BlendMode::Multiply, + BlendMode::Screen, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::Darken, + BlendMode::Lighten, + BlendMode::Difference, + BlendMode::Color, + BlendMode::Luminosity, +]; +const SOLID_MODE_NAMES: [&str; 12] = + ["Normal", "Add", "Multiply", "Screen", "Overlay", "Soft Light", "Hard Light", "Darken", "Lighten", "Difference", "Color", "Luminosity"]; + +fn solid_composite(ctx: &EffectCtx, mut b: Buf) -> Buf { + let so = ctx.params.f("sourceOpacity") as f32 / 100.0; + let c = ctx.params.color("color"); + let op = (ctx.params.f("opacity") as f32 / 100.0 * c[3]).clamp(0.0, 1.0); + let mode = SOLID_MODES[(ctx.params.e("blendingMode") as usize).min(SOLID_MODES.len() - 1)]; + let solid = premul([c[0], c[1], c[2]], op); + b.img.data.par_iter_mut().for_each(|px| { + let src = px.map(|v| v * so); + let mut o = blend_pixel(mode, solid, src, 0.5); + o[3] = o[3].clamp(0.0, 1.0); + for v in o.iter_mut().take(3) { + *v = v.max(0.0); + } + *px = o; + }); + b +} + +fn remove_color_matting(ctx: &EffectCtx, mut b: Buf) -> Buf { + let bg = ctx.params.color("backgroundColor"); + let clip = ctx.params.b("clipHdr"); + b.img.data.par_iter_mut().for_each(|px| { + let a = px[3]; + if a <= 1e-6 { + *px = [0.0; 4]; + return; + } + for i in 0..3 { + let s = px[i] / a; + let mut v = (s - bg[i] * (1.0 - a)).max(0.0); + if clip { + v = v.min(a); + } + px[i] = v; + } + }); + b +} + +const MATTE_ORDER: [Src; 10] = [Src::Red, Src::Green, Src::Blue, Src::Alpha, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off]; + +fn set_matte(ctx: &EffectCtx, mut b: Buf) -> Buf { + let src = MATTE_ORDER[(ctx.params.e("takeMatteFrom") as usize).min(9)]; + let inv = ctx.params.b("invertMatte"); + let comp = ctx.params.b("compositeMatteWithOriginal"); + let pre = ctx.params.b("premultiplyMatteLayer"); + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + let mut m = pick(src, c, a); + if pre && !matches!(src, Src::Alpha | Src::Full | Src::Off) { + m *= a; + } + let m = m.clamp(0.0, 1.0); + let m = if inv { 1.0 - m } else { m }; + // The layer's own (non-inverted) alpha already is the original matte: don't square it. + let na = if comp && (src != Src::Alpha || inv) { a * m } else { m }; + *px = premul(c, na.clamp(0.0, 1.0)); + }); + b +} + +pub fn specs() -> Vec { + let srcp = |id: &'static str, name: &'static str, d: u32| p(id, name, Value::Enum(d), popup(&SRC_NAMES)); + let shp = |id: &'static str, name: &'static str, d: u32| p(id, name, Value::Enum(d), popup(&SHIFT_NAMES)); + let v255 = || slider(0.0, 255.0, 0.0, 255.0, 0); + vec![ + spec( + "ec.channel.setchannels", + "Set Channels", + vec![ + srcp("setRedTo", "Set Red To Source 1's", 0), + srcp("setGreenTo", "Set Green To Source 2's", 1), + srcp("setBlueTo", "Set Blue To Source 3's", 2), + srcp("setAlphaTo", "Set Alpha To Source 4's", 3), + ], + set_channels, + ), + spec( + "ec.channel.shiftchannels", + "Shift Channels", + vec![ + shp("takeAlphaFrom", "Take Alpha From", 0), + shp("takeRedFrom", "Take Red From", 1), + shp("takeGreenFrom", "Take Green From", 2), + shp("takeBlueFrom", "Take Blue From", 3), + ], + shift_channels, + ), + spec( + "ec.channel.combiner", + "Channel Combiner", + vec![ + p( + "from", + "From", + Value::Enum(8), + popup(&[ + "RGB to HLS", + "HLS to RGB", + "RGB to YUV", + "YUV to RGB", + "Red", + "Green", + "Blue", + "Alpha", + "Lightness", + "Hue", + "Saturation", + "Luminance", + "Max RGB", + "Min RGB", + ]), + ), + p("to", "To", Value::Enum(7), popup(&["Red Only", "Green Only", "Blue Only", "Alpha Only", "Lightness", "Hue", "Saturation", "Grayscale"])), + p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + ], + channel_combiner, + ), + spec( + "ec.channel.minimax", + "Minimax", + vec![ + p("operation", "Operation", Value::Enum(1), popup(&["Minimum", "Maximum", "Minimum then Maximum", "Maximum then Minimum"])), + p("radius", "Radius", num(0.0), slider(0.0, 500.0, 0.0, 100.0, 0)), + p("channel", "Channel", Value::Enum(0), popup(&["Color", "Alpha", "Alpha and Color", "Red", "Green", "Blue"])), + p("direction", "Direction", Value::Enum(0), popup(&["Horizontal & Vertical", "Horizontal Only", "Vertical Only"])), + ], + minimax, + ), + spec( + "ec.channel.arithmetic", + "Arithmetic", + vec![ + p( + "operator", + "Operator", + Value::Enum(3), + popup(&["And", "Or", "Xor", "Add", "Subtract", "Difference", "Min", "Max", "Block Above", "Block Below", "Slice", "Multiply", "Screen"]), + ), + p("redValue", "Red Value", num(0.0), v255()), + p("greenValue", "Green Value", num(0.0), v255()), + p("blueValue", "Blue Value", num(0.0), v255()), + p("clip", "Clip Result Values", Value::Bool(true), ParamUi::Checkbox), + ], + arithmetic, + ), + spec( + "ec.channel.solidcomposite", + "Solid Composite", + vec![ + p("sourceOpacity", "Source Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("blendingMode", "Blending Mode", Value::Enum(0), popup(&SOLID_MODE_NAMES)), + ], + solid_composite, + ), + spec( + "ec.channel.removecolormatting", + "Remove Color Matting", + vec![p("backgroundColor", "Background Color", col(0.0, 0.0, 0.0), ParamUi::Color), p("clipHdr", "Clipping", Value::Bool(true), ParamUi::Checkbox)], + remove_color_matting, + ), + spec( + "ec.channel.setmatte", + "Set Matte", + vec![ + p( + "takeMatteFrom", + "Use For Matte", + Value::Enum(3), + popup(&["Red", "Green", "Blue", "Alpha", "Luminance", "Hue", "Lightness", "Saturation", "Full", "Off"]), + ), + p("invertMatte", "Invert Matte", Value::Bool(false), ParamUi::Checkbox), + p("compositeMatteWithOriginal", "Composite Matte with Original", Value::Bool(true), ParamUi::Checkbox), + p("premultiplyMatteLayer", "Premultiply Matte Layer", Value::Bool(true), ParamUi::Checkbox), + ], + set_matte, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Params, apply, find}; + use effectcraft_raster::Image; + + fn run(id: &str, over: &[(&str, Value)], img: Image) -> Image { + let s = find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in over { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true }; + apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img + } + + fn sample() -> Image { + let mut img = Image::new(8, 8); + for y in 0..8 { + for x in 0..8 { + let a = if x < 6 { 1.0 } else { 0.5 }; + img.set(x, y, [0.2 * a, 0.5 * a, 0.9 * a, a]); + } + } + img + } + + fn close(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-5) + } + + #[test] + fn set_channels_defaults_are_identity_and_swap_works() { + let img = sample(); + assert_eq!(run("ec.channel.setchannels", &[], img.clone()), img); + let out = run("ec.channel.setchannels", &[("setRedTo", Value::Enum(2)), ("setBlueTo", Value::Enum(0))], img); + assert!(close(out.get(0, 0), [0.9, 0.5, 0.2, 1.0])); + } + + #[test] + fn shift_channels_takes_red_from_green() { + let out = run("ec.channel.shiftchannels", &[("takeRedFrom", Value::Enum(2))], sample()); + assert!(close(out.get(7, 0), [0.25, 0.25, 0.45, 0.5])); + } + + #[test] + fn channel_combiner_hls_round_trip() { + let img = sample(); + let hls = run("ec.channel.combiner", &[("from", Value::Enum(0))], img.clone()); + let back = run("ec.channel.combiner", &[("from", Value::Enum(1))], hls); + assert!(close(back.get(0, 0), img.get(0, 0)), "{:?}", back.get(0, 0)); + } + + #[test] + fn minimax_maximum_grows_bright_point() { + let mut img = Image::filled(9, 9, [0.0, 0.0, 0.0, 1.0]); + img.set(4, 4, [1.0, 1.0, 1.0, 1.0]); + let out = run("ec.channel.minimax", &[("radius", num(2.0))], img); + assert_eq!(out.get(2, 2)[0], 1.0); + assert_eq!(out.get(1, 4)[0], 0.0); + let n: usize = out.data.iter().filter(|p| p[0] > 0.5).count(); + assert_eq!(n, 25); + } + + #[test] + fn arithmetic_add_and_slice() { + let img = Image::filled(1, 1, [0.4, 0.4, 0.4, 1.0]); + let out = run("ec.channel.arithmetic", &[("redValue", num(51.0))], img.clone()); + assert!((out.get(0, 0)[0] - 0.6).abs() < 1e-5 && (out.get(0, 0)[1] - 0.4).abs() < 1e-6); + let out = run("ec.channel.arithmetic", &[("operator", Value::Enum(10)), ("redValue", num(200.0)), ("greenValue", num(10.0))], img); + assert_eq!(&out.get(0, 0)[..3], &[0.0, 1.0, 1.0]); + } + + #[test] + fn remove_color_matting_inverts_matting() { + // Foreground colour f, alpha a, matted against bg: stored straight value = f·a + bg·(1−a). + let (f, a, bg) = ([0.8f32, 0.3, 0.1], 0.4f32, [0.2f32, 0.6, 0.9]); + let s: Vec = (0..3).map(|i| f[i] * a + bg[i] * (1.0 - a)).collect(); + let img = Image::filled(1, 1, [s[0] * a, s[1] * a, s[2] * a, a]); + let out = run("ec.channel.removecolormatting", &[("backgroundColor", Value::Color([0.2, 0.6, 0.9, 1.0]))], img); + let px = out.get(0, 0); + for i in 0..3 { + assert!((px[i] - f[i] * a).abs() < 1e-5, "{px:?}"); + } + } + + #[test] + fn set_matte_from_luminance() { + let mut img = Image::new(2, 1); + img.set(0, 0, [1.0, 1.0, 1.0, 1.0]); + img.set(1, 0, [0.0, 0.0, 0.0, 1.0]); + let out = run("ec.channel.setmatte", &[("takeMatteFrom", Value::Enum(4))], img); + assert!((out.get(0, 0)[3] - 1.0).abs() < 1e-5); + assert_eq!(out.get(1, 0)[3], 0.0); + } + + #[test] + fn solid_composite_fills_behind() { + let mut img = Image::new(2, 1); + img.set(0, 0, [0.0, 0.0, 1.0, 1.0]); + let out = run("ec.channel.solidcomposite", &[("color", Value::Color([1.0, 0.0, 0.0, 1.0]))], img); + assert!(close(out.get(0, 0), [0.0, 0.0, 1.0, 1.0])); + assert!(close(out.get(1, 0), [1.0, 0.0, 0.0, 1.0])); + } +} diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs new file mode 100644 index 00000000..136ba00e --- /dev/null +++ b/crates/effects/src/color2.rs @@ -0,0 +1,1062 @@ +//! Color Correction, batch 2: Curves, Levels (Individual Controls), automatic corrections +//! (Auto Levels / Contrast / Color, Equalize), Shadow/Highlight, Selective Color, Color Balance +//! (HLS), Color Link, Broadcast Colors and the CC toners/offsets/kernel. + +use effectcraft_color::{BlendMode, blend_pixel, hsl_to_rgb, luminance, rgb_to_hsl}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::Image; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, lerp3, premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Color Correction", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(-100.0, 100.0, -100.0, 100.0, 1) +} + +/// Map straight colour + alpha of every pixel (row-parallel). +fn map_ca(img: &mut Image, f: impl Fn([f32; 3], f32) -> ([f32; 3], f32) + Sync) { + img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + if a <= 0.0 && px[0] == 0.0 && px[1] == 0.0 && px[2] == 0.0 { + // Fully transparent: only alpha transforms can change it. + let (_, na) = f([0.0; 3], 0.0); + if na > 0.0 { + *px = [0.0, 0.0, 0.0, na.clamp(0.0, 1.0)]; + } + return; + } + let (nc, na) = f(c, a); + *px = premul(nc, na.clamp(0.0, 1.0)); + }); +} + +// --------------------------------------------------------------------------------------------- +// Curves + +/// A tone curve through control points, interpolated with a monotone cubic (Fritsch–Carlson +/// 1980) Hermite spline and extended linearly outside the first/last point. +#[derive(Clone, Debug)] +pub struct Curve { + xs: Vec, + ys: Vec, + ms: Vec, + lut: Vec, +} + +const LUT_N: usize = 1024; + +impl Curve { + /// Parse "x,y x,y …" (separators: whitespace or ';'). Returns None for an identity curve or + /// fewer than two valid points. + pub fn parse(s: &str) -> Option { + let mut pts: Vec<(f32, f32)> = s + .split(|c: char| c.is_whitespace() || c == ';') + .filter(|t| !t.is_empty()) + .filter_map(|t| { + let mut it = t.split(','); + let x = it.next()?.trim().parse::().ok()?; + let y = it.next()?.trim().parse::().ok()?; + (x.is_finite() && y.is_finite()).then_some((x, y)) + }) + .collect(); + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); + if pts.len() < 2 || pts.iter().all(|(x, y)| (x - y).abs() < 1e-6) { + return None; + } + let xs: Vec = pts.iter().map(|p| p.0).collect(); + let ys: Vec = pts.iter().map(|p| p.1).collect(); + let n = xs.len(); + let d: Vec = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect(); + let mut ms = vec![0.0f32; n]; + ms[0] = d[0]; + ms[n - 1] = d[n - 2]; + for k in 1..n - 1 { + ms[k] = if d[k - 1] * d[k] <= 0.0 { 0.0 } else { (d[k - 1] + d[k]) * 0.5 }; + } + for k in 0..n - 1 { + if d[k].abs() < 1e-12 { + ms[k] = 0.0; + ms[k + 1] = 0.0; + } else { + let a = ms[k] / d[k]; + let b = ms[k + 1] / d[k]; + let s = a * a + b * b; + if s > 9.0 { + let t = 3.0 / s.sqrt(); + ms[k] = t * a * d[k]; + ms[k + 1] = t * b * d[k]; + } + } + } + let mut c = Curve { xs, ys, ms, lut: Vec::new() }; + c.lut = (0..=LUT_N).map(|i| c.eval_exact(i as f32 / LUT_N as f32)).collect(); + Some(c) + } + + fn eval_exact(&self, x: f32) -> f32 { + let n = self.xs.len(); + if x <= self.xs[0] { + return self.ys[0] + (x - self.xs[0]) * self.ms[0]; + } + if x >= self.xs[n - 1] { + return self.ys[n - 1] + (x - self.xs[n - 1]) * self.ms[n - 1]; + } + let k = self.xs.partition_point(|&v| v <= x).saturating_sub(1).min(n - 2); + let h = self.xs[k + 1] - self.xs[k]; + let t = (x - self.xs[k]) / h; + let (t2, t3) = (t * t, t * t * t); + let h00 = 2.0 * t3 - 3.0 * t2 + 1.0; + let h10 = t3 - 2.0 * t2 + t; + let h01 = -2.0 * t3 + 3.0 * t2; + let h11 = t3 - t2; + h00 * self.ys[k] + h10 * h * self.ms[k] + h01 * self.ys[k + 1] + h11 * h * self.ms[k + 1] + } + + pub fn eval(&self, x: f32) -> f32 { + if !(0.0..=1.0).contains(&x) { + return self.eval_exact(x); + } + let f = x * LUT_N as f32; + let i = (f as usize).min(LUT_N - 1); + let t = f - i as f32; + self.lut[i] + (self.lut[i + 1] - self.lut[i]) * t + } +} + +fn ev(c: &Option, v: f32) -> f32 { + c.as_ref().map(|c| c.eval(v)).unwrap_or(v) +} + +/// Curves. Each channel's curve is a hidden string parameter (`rgb`, `red`, `green`, `blue`, +/// `alpha`) holding control points as `"x,y x,y …"` in 0..1 (whitespace or `;` separated, +/// sorted on load); the default `"0,0 1,1"` is the identity. Colour channels go through the +/// master RGB curve first, then their own curve. +fn curves(ctx: &EffectCtx, mut b: Buf) -> Buf { + let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse(ctx.params.s(id))); + if m.is_none() && r.is_none() && g.is_none() && bl.is_none() && a.is_none() { + return b; + } + let chans = [r, g, bl]; + map_ca(&mut b.img, |c, al| { + let o = [0, 1, 2].map(|i| ev(&chans[i], ev(&m, c[i]))); + (o, ev(&a, al)) + }); + b +} + +// --------------------------------------------------------------------------------------------- +// Levels (Individual Controls) + +const LV_IDS: [[&str; 5]; 5] = [ + ["rgbInBlack", "rgbInWhite", "rgbGamma", "rgbOutBlack", "rgbOutWhite"], + ["redInBlack", "redInWhite", "redGamma", "redOutBlack", "redOutWhite"], + ["greenInBlack", "greenInWhite", "greenGamma", "greenOutBlack", "greenOutWhite"], + ["blueInBlack", "blueInWhite", "blueGamma", "blueOutBlack", "blueOutWhite"], + ["alphaInBlack", "alphaInWhite", "alphaGamma", "alphaOutBlack", "alphaOutWhite"], +]; +const LV_NAMES: [[&str; 5]; 5] = [ + ["Input Black", "Input White", "Gamma", "Output Black", "Output White"], + ["Red Input Black", "Red Input White", "Red Gamma", "Red Output Black", "Red Output White"], + ["Green Input Black", "Green Input White", "Green Gamma", "Green Output Black", "Green Output White"], + ["Blue Input Black", "Blue Input White", "Blue Gamma", "Blue Output Black", "Blue Output White"], + ["Alpha Input Black", "Alpha Input White", "Alpha Gamma", "Alpha Output Black", "Alpha Output White"], +]; + +#[derive(Clone, Copy)] +struct Lv { + ib: f32, + iw: f32, + g: f32, + ob: f32, + ow: f32, +} + +impl Lv { + fn identity(&self) -> bool { + self.ib == 0.0 && self.iw == 1.0 && self.g == 1.0 && self.ob == 0.0 && self.ow == 1.0 + } + fn apply(&self, v: f32) -> f32 { + if self.identity() { + return v; + } + let t = ((v - self.ib) / (self.iw - self.ib).max(1e-6)).clamp(0.0, 1.0); + self.ob + (self.ow - self.ob) * t.powf(1.0 / self.g.max(0.01)) + } +} + +fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf { + let lv = LV_IDS.map(|ids| Lv { + ib: ctx.params.f(ids[0]) as f32, + iw: ctx.params.f(ids[1]) as f32, + g: ctx.params.f(ids[2]) as f32, + ob: ctx.params.f(ids[3]) as f32, + ow: ctx.params.f(ids[4]) as f32, + }); + if lv.iter().all(Lv::identity) { + return b; + } + map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[i + 1].apply(lv[0].apply(c[i]))), lv[4].apply(a))); + b +} + +// --------------------------------------------------------------------------------------------- +// Histograms and automatic corrections + +const BINS: usize = 1024; + +/// Alpha-weighted histograms of straight R, G, B and luminance. +fn histograms(img: &Image) -> [Vec; 4] { + img.data + .par_chunks(4096) + .map(|chunk| { + let mut h: [Vec; 4] = std::array::from_fn(|_| vec![0.0; BINS]); + for &px in chunk { + let (c, a) = unpremul(px); + if a <= 1e-4 { + continue; + } + let bin = |v: f32| ((v.clamp(0.0, 1.0) * (BINS - 1) as f32).round() as usize).min(BINS - 1); + for k in 0..3 { + h[k][bin(c[k])] += a as f64; + } + h[3][bin(luminance(c[0], c[1], c[2]))] += a as f64; + } + h + }) + .reduce( + || std::array::from_fn(|_| vec![0.0; BINS]), + |mut a, b| { + for k in 0..4 { + for i in 0..BINS { + a[k][i] += b[k][i]; + } + } + a + }, + ) +} + +/// (low, high) values clipping `black`/`white` fractions of the histogram. +fn clip_points(h: &[f64], black: f64, white: f64) -> (f32, f32) { + let total: f64 = h.iter().sum(); + if total <= 0.0 { + return (0.0, 1.0); + } + let mut acc = 0.0; + let mut lo = 0; + for (i, v) in h.iter().enumerate() { + acc += v; + if acc > total * black { + lo = i; + break; + } + } + acc = 0.0; + let mut hi = BINS - 1; + for (i, v) in h.iter().enumerate().rev() { + acc += v; + if acc > total * white { + hi = i; + break; + } + } + let (lo, hi) = (lo as f32 / (BINS - 1) as f32, hi as f32 / (BINS - 1) as f32); + if hi - lo < 1e-3 { (0.0, 1.0) } else { (lo, hi) } +} + +fn stretch(v: f32, lo: f32, hi: f32) -> f32 { + ((v - lo) / (hi - lo)).clamp(0.0, 1.0) +} + +/// 0 = Auto Levels (per channel), 1 = Auto Contrast (shared), 2 = Auto Color (per channel + neutral mids). +fn auto_correct(ctx: &EffectCtx, mut b: Buf, kind: u32) -> Buf { + let black = ctx.params.f("blackClip").clamp(0.0, 49.0) / 100.0; + let white = ctx.params.f("whiteClip").clamp(0.0, 49.0) / 100.0; + let blend = (ctx.params.f("blend") / 100.0).clamp(0.0, 1.0) as f32; + let h = histograms(&b.img); + let ranges: [(f32, f32); 3] = if kind == 1 { + let mut combined = vec![0.0; BINS]; + for k in 0..3 { + for i in 0..BINS { + combined[i] += h[k][i]; + } + } + let r = clip_points(&combined, black, white); + [r; 3] + } else { + [0, 1, 2].map(|k| clip_points(&h[k], black, white)) + }; + // Auto Color: per-channel gamma that moves each channel's mean to the common mean. + let mut gam = [1.0f32; 3]; + if kind == 2 && ctx.params.b("snapNeutralMidtones") { + let mut sums = [0.0f64; 4]; + for px in &b.img.data { + let (c, a) = unpremul(*px); + if a > 1e-4 { + for k in 0..3 { + sums[k] += (stretch(c[k], ranges[k].0, ranges[k].1) * a) as f64; + } + sums[3] += a as f64; + } + } + if sums[3] > 0.0 { + let means = [0, 1, 2].map(|k| (sums[k] / sums[3]) as f32); + let target = (means[0] + means[1] + means[2]) / 3.0; + for k in 0..3 { + if means[k] > 1e-3 && means[k] < 0.999 && target > 1e-3 && target < 0.999 { + gam[k] = (target.ln() / means[k].ln()).clamp(0.2, 5.0); + } + } + } + } + map_ca(&mut b.img, |c, a| { + let o = [0, 1, 2].map(|k| stretch(c[k], ranges[k].0, ranges[k].1).powf(gam[k])); + (lerp3(o, c, blend), a) + }); + b +} + +fn auto_levels(ctx: &EffectCtx, b: Buf) -> Buf { + auto_correct(ctx, b, 0) +} +fn auto_contrast(ctx: &EffectCtx, b: Buf) -> Buf { + auto_correct(ctx, b, 1) +} +fn auto_color(ctx: &EffectCtx, b: Buf) -> Buf { + auto_correct(ctx, b, 2) +} + +fn cdf(h: &[f64]) -> Vec { + let total: f64 = h.iter().sum::().max(1e-12); + let mut acc = 0.0; + h.iter() + .map(|v| { + acc += v; + (acc / total) as f32 + }) + .collect() +} + +fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amt = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) as f32; + if amt <= 0.0 { + return b; + } + let style = ctx.params.e("style"); + let h = histograms(&b.img); + let look = |c: &Vec, v: f32| c[((v.clamp(0.0, 1.0) * (BINS - 1) as f32).round() as usize).min(BINS - 1)]; + match style { + 1 => { + let c = cdf(&h[3]); + map_ca(&mut b.img, |col, a| { + let l = luminance(col[0], col[1], col[2]); + let nl = look(&c, l); + let o = if l > 1e-5 { col.map(|v| v * nl / l) } else { [nl; 3] }; + (lerp3(col, o, amt), a) + }); + } + 2 => { + let mut combined = vec![0.0; BINS]; + for k in 0..3 { + for i in 0..BINS { + combined[i] += h[k][i]; + } + } + let c = cdf(&combined); + map_ca(&mut b.img, |col, a| (lerp3(col, col.map(|v| look(&c, v)), amt), a)); + } + _ => { + let cs = [cdf(&h[0]), cdf(&h[1]), cdf(&h[2])]; + map_ca(&mut b.img, |col, a| (lerp3(col, [0, 1, 2].map(|k| look(&cs[k], col[k])), amt), a)); + } + } + b +} + +// --------------------------------------------------------------------------------------------- +// Shadow/Highlight + +fn shadow_highlight(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (mut s_amt, mut h_amt) = (ctx.params.f("shadowAmount") as f32 / 100.0, ctx.params.f("highlightAmount") as f32 / 100.0); + let luma = Plane::luma(&b.img); + if ctx.params.b("autoAmounts") { + let (mut sum, mut wsum) = (0.0f64, 0.0f64); + for (px, l) in b.img.data.iter().zip(&luma.data) { + sum += (*l * px[3]) as f64; + wsum += px[3] as f64; + } + let mean = if wsum > 0.0 { (sum / wsum) as f32 } else { 0.5 }; + s_amt = ((0.6 - mean) * 1.5).clamp(0.0, 1.0) * 0.8 + 0.1; + h_amt = ((mean - 0.6) * 1.5).clamp(0.0, 1.0) * 0.8; + } + let s_tw = (ctx.params.f("shadowTonalWidth") as f32 / 100.0).max(0.01); + let h_tw = (ctx.params.f("highlightTonalWidth") as f32 / 100.0).max(0.01); + let s_r = ctx.params.f("shadowRadius").max(0.0) * b.scale / 2.0; + let h_r = ctx.params.f("highlightRadius").max(0.0) * b.scale / 2.0; + let cc = ctx.params.f("colorCorrection") as f32 / 100.0; + let mc = ctx.params.f("midtoneContrast") as f32 / 100.0; + let blend = (ctx.params.f("blend") / 100.0).clamp(0.0, 1.0) as f32; + let base_s = gauss_plane(&luma, s_r, s_r); + let base_h = if (h_r - s_r).abs() < 1e-9 { base_s.clone() } else { gauss_plane(&luma, h_r, h_r) }; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + let ws = (1.0 - base_s.data[i] / s_tw).clamp(0.0, 1.0).powi(2); + let wh = ((base_h.data[i] - (1.0 - h_tw)) / h_tw).clamp(0.0, 1.0).powi(2); + let gain = (1.0 + s_amt * ws * 2.0) * (1.0 - h_amt * wh * 0.5); + let l = luminance(c[0], c[1], c[2]); + let nl = l * gain; + let sat = gain.max(1e-3).powf(cc); + let mut o = c.map(|v| nl + (v - l) * sat); + if mc != 0.0 { + let shift = (0.5 + (nl - 0.5) * (1.0 + mc)) - nl; + o = o.map(|v| v + shift); + } + let o = lerp3(o.map(|v| v.max(0.0)), c, blend); + *px = premul(o, a); + }); + b +} + +// --------------------------------------------------------------------------------------------- +// Selective Color + +const SC_IDS: [[&str; 4]; 9] = [ + ["redsCyan", "redsMagenta", "redsYellow", "redsBlack"], + ["yellowsCyan", "yellowsMagenta", "yellowsYellow", "yellowsBlack"], + ["greensCyan", "greensMagenta", "greensYellow", "greensBlack"], + ["cyansCyan", "cyansMagenta", "cyansYellow", "cyansBlack"], + ["bluesCyan", "bluesMagenta", "bluesYellow", "bluesBlack"], + ["magentasCyan", "magentasMagenta", "magentasYellow", "magentasBlack"], + ["whitesCyan", "whitesMagenta", "whitesYellow", "whitesBlack"], + ["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"], + ["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"], +]; +const SC_NAMES: [[&str; 4]; 9] = [ + ["Reds Cyan", "Reds Magenta", "Reds Yellow", "Reds Black"], + ["Yellows Cyan", "Yellows Magenta", "Yellows Yellow", "Yellows Black"], + ["Greens Cyan", "Greens Magenta", "Greens Yellow", "Greens Black"], + ["Cyans Cyan", "Cyans Magenta", "Cyans Yellow", "Cyans Black"], + ["Blues Cyan", "Blues Magenta", "Blues Yellow", "Blues Black"], + ["Magentas Cyan", "Magentas Magenta", "Magentas Yellow", "Magentas Black"], + ["Whites Cyan", "Whites Magenta", "Whites Yellow", "Whites Black"], + ["Neutrals Cyan", "Neutrals Magenta", "Neutrals Yellow", "Neutrals Black"], + ["Blacks Cyan", "Blacks Magenta", "Blacks Yellow", "Blacks Black"], +]; + +/// How much a colour belongs to each Selective Color range. +fn sc_weights(c: [f32; 3]) -> [f32; 9] { + let c = c.map(|v| v.clamp(0.0, 1.0)); + let mut idx = [0usize, 1, 2]; + idx.sort_by(|&a, &b| c[b].total_cmp(&c[a])); + let (mx, md, mn) = (c[idx[0]], c[idx[1]], c[idx[2]]); + let mut w = [0.0f32; 9]; + // Primaries: weight = max − mid when that primary is the largest component. + w[[0, 2, 4][idx[0]]] = mx - md; + // Secondaries: weight = mid − min when the complementary primary is the smallest. + w[[3, 5, 1][idx[2]]] = md - mn; + w[6] = ((mn - 0.5) * 2.0).max(0.0); + w[8] = ((0.5 - mx) * 2.0).max(0.0); + w[7] = (1.0 - ((mx - 0.5).abs() + (mn - 0.5).abs())).clamp(0.0, 1.0); + w +} + +fn selective_color(ctx: &EffectCtx, mut b: Buf) -> Buf { + let adj = SC_IDS.map(|ids| ids.map(|id| ctx.params.f(id) as f32 / 100.0)); + if adj.iter().all(|r| r.iter().all(|v| *v == 0.0)) { + return b; + } + let relative = ctx.params.e("method") == 0; + map_ca(&mut b.img, |c, a| { + let w = sc_weights(c); + let mut o = c; + for (r, wr) in w.iter().enumerate() { + if *wr <= 0.0 { + continue; + } + let k = adj[r][3]; + for i in 0..3 { + let ink = 1.0 - c[i]; + let delta = adj[r][i] + k; + let delta = if relative { delta * ink.max(0.0) } else { delta }; + o[i] -= wr * delta; + } + } + (o.map(|v| v.max(0.0)), a) + }); + b +} + +// --------------------------------------------------------------------------------------------- +// Color Balance (HLS), Color Link, Broadcast Colors + +fn color_balance_hls(ctx: &EffectCtx, mut b: Buf) -> Buf { + let dh = (ctx.params.f("hue") / 360.0) as f32; + let dl = (ctx.params.f("lightness") / 100.0) as f32; + let ds = (ctx.params.f("saturation") / 100.0) as f32; + if dh == 0.0 && dl == 0.0 && ds == 0.0 { + return b; + } + b.img.map_straight(|c| { + let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]); + let l = if dl > 0.0 { l + (1.0 - l) * dl } else { l + l * dl }; + let s = if ds > 0.0 { s + (1.0 - s) * ds } else { s + s * ds }; + let (r, g, bb) = hsl_to_rgb(h + dh, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)); + [r, g, bb] + }); + b +} + +const LINK_MODES: [(&str, BlendMode); 13] = [ + ("Normal", BlendMode::Normal), + ("Add", BlendMode::Add), + ("Multiply", BlendMode::Multiply), + ("Screen", BlendMode::Screen), + ("Overlay", BlendMode::Overlay), + ("Soft Light", BlendMode::SoftLight), + ("Hard Light", BlendMode::HardLight), + ("Darken", BlendMode::Darken), + ("Lighten", BlendMode::Lighten), + ("Difference", BlendMode::Difference), + ("Hue", BlendMode::Hue), + ("Color", BlendMode::Color), + ("Luminosity", BlendMode::Luminosity), +]; + +/// Representative colour of an image: 0 average, 1 brightest, 2 darkest, 3 max RGB, 4 min RGB. +fn sample_color(img: &Image, how: u32) -> [f32; 3] { + let mut sum = [0.0f64; 4]; + let mut best: Option<([f32; 3], f32)> = None; + let mut mx = [0.0f32; 3]; + let mut mn = [f32::INFINITY; 3]; + for px in &img.data { + let (c, a) = unpremul(*px); + if a <= 1e-3 { + continue; + } + for k in 0..3 { + sum[k] += (c[k] * a) as f64; + mx[k] = mx[k].max(c[k]); + mn[k] = mn[k].min(c[k]); + } + sum[3] += a as f64; + let l = luminance(c[0], c[1], c[2]); + let better = match best { + None => true, + Some((_, bl)) => (how == 1 && l > bl) || (how == 2 && l < bl), + }; + if better { + best = Some((c, l)); + } + } + if sum[3] <= 0.0 { + return [0.0; 3]; + } + match how { + 1 | 2 => best.map(|b| b.0).unwrap_or([0.0; 3]), + 3 => mx, + 4 => mn, + _ => [0, 1, 2].map(|k| (sum[k] / sum[3]) as f32), + } +} + +fn color_link(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = sample_color(&b.img, ctx.params.e("sampleSource")); + let op = (ctx.params.f("opacity") / 100.0).clamp(0.0, 1.0) as f32; + let mode = LINK_MODES[(ctx.params.e("blendingMode") as usize).min(LINK_MODES.len() - 1)].1; + let stencil = ctx.params.b("stencilOriginalAlpha"); + let src = [c[0] * op, c[1] * op, c[2] * op, op]; + b.img.data.par_iter_mut().for_each(|px| { + let a0 = px[3]; + let mut o = blend_pixel(mode, *px, src, 0.5); + o[3] = o[3].clamp(0.0, 1.0); + if stencil { + let (sc, _) = unpremul(o); + o = premul(sc, a0); + } + *px = o; + }); + b +} + +fn broadcast_colors(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pal = ctx.params.e("locale") == 1; + let how = ctx.params.e("howToMakeSafe"); + let limit = ctx.params.f("maxSignal") as f32; + let (setup, span) = if pal { (0.0, 100.0) } else { (7.5, 92.5) }; + let chroma = |c: [f32; 3]| -> (f32, f32) { + let y = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; + let ch = if pal { + let (u, v) = (0.492 * (c[2] - y), 0.877 * (c[0] - y)); + (u * u + v * v).sqrt() + } else { + let i = 0.596 * c[0] - 0.274 * c[1] - 0.322 * c[2]; + let q = 0.211 * c[0] - 0.523 * c[1] + 0.312 * c[2]; + (i * i + q * q).sqrt() + }; + (y, ch) + }; + let max_norm = (limit - setup) / span; + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + let (y, ch) = chroma(c); + let unsafe_ = y + ch > max_norm + 1e-5; + match how { + 2 => { + if unsafe_ { + *px = [0.0; 4]; + } + } + 3 => { + if !unsafe_ { + *px = [0.0; 4]; + } + } + 1 => { + if unsafe_ { + let target = (max_norm - y).max(0.0); + let s = if ch > 1e-6 { target / ch } else { 1.0 }; + let mut o = c.map(|v| y + (v - y) * s); + if y > max_norm { + o = o.map(|v| v * max_norm / y); + } + *px = premul(o, a); + } + } + _ => { + if unsafe_ { + let k = max_norm / (y + ch); + *px = premul(c.map(|v| v * k), a); + } + } + } + }); + b +} + +// --------------------------------------------------------------------------------------------- +// CC Toner, CC Color Offset, CC Kernel + +fn cc_toner(ctx: &EffectCtx, mut b: Buf) -> Buf { + let g = |id: &str| { + let c = ctx.params.color(id); + [c[0], c[1], c[2]] + }; + let stops: Vec<[f32; 3]> = match ctx.params.e("tones") { + 0 => vec![g("shadows"), g("highlights")], + 2 => vec![g("shadows"), g("darktones"), g("midtones"), g("brights"), g("highlights")], + _ => vec![g("shadows"), g("midtones"), g("highlights")], + }; + let blend = (ctx.params.f("blend") / 100.0).clamp(0.0, 1.0) as f32; + let n = stops.len() - 1; + b.img.map_straight(|c| { + let t = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) * n as f32; + let k = (t.floor() as usize).min(n - 1); + let tone = lerp3(stops[k], stops[k + 1], t - k as f32); + lerp3(tone, c, blend) + }); + b +} + +fn cc_color_offset(ctx: &EffectCtx, mut b: Buf) -> Buf { + let offs = ["redPhase", "greenPhase", "bluePhase"].map(|id| (ctx.params.f(id) / 360.0).rem_euclid(1.0) as f32); + if offs.iter().all(|o| *o == 0.0) { + return b; + } + let overflow = ctx.params.e("overflow"); + b.img.map_straight(|c| { + [0, 1, 2].map(|k| { + if offs[k] == 0.0 { + return c[k]; + } + let x = c[k] + offs[k]; + if x <= 1.0 { + return x; + } + match overflow { + 1 => (2.0 - x).max(0.0), + 2 => { + if x - 1.0 < 0.5 { + 0.0 + } else { + 1.0 + } + } + _ => x.rem_euclid(1.0), + } + }) + }); + b +} + +const K_IDS: [&str; 9] = ["k1", "k2", "k3", "k4", "k5", "k6", "k7", "k8", "k9"]; +const K_NAMES: [&str; 9] = ["Top Left", "Top", "Top Right", "Left", "Center", "Right", "Bottom Left", "Bottom", "Bottom Right"]; + +fn cc_kernel(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = K_IDS.map(|id| ctx.params.f(id) as f32); + let scale = ctx.params.f("scale") as f32; + let offset = ctx.params.f("offset") as f32; + let abs = ctx.params.b("absolute"); + let identity = k.iter().enumerate().all(|(i, v)| if i == 4 { *v == 1.0 } else { *v == 0.0 }); + if identity && scale == 1.0 && offset == 0.0 { + return b; + } + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let a = px[3]; + if a <= 0.0 { + continue; + } + let mut acc = [0.0f32; 3]; + for j in 0..3 { + for i in 0..3 { + let w = k[j * 3 + i]; + if w == 0.0 { + continue; + } + let q = src.get_clamped(x as i64 + i as i64 - 1, y as i64 + j as i64 - 1); + let (c, _) = unpremul(q); + for ch in 0..3 { + acc[ch] += c[ch] * w; + } + } + } + let o = acc.map(|v| { + let v = v * scale + offset; + (if abs { v.abs() } else { v }).max(0.0) + }); + *px = premul(o, a); + } + }); + b +} + +pub fn specs() -> Vec { + let mut lv_params = Vec::new(); + for (ids, names) in LV_IDS.iter().zip(LV_NAMES.iter()) { + lv_params.push(p(ids[0], names[0], num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); + lv_params.push(p(ids[1], names[1], num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); + lv_params.push(p(ids[2], names[2], num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2))); + lv_params.push(p(ids[3], names[3], num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); + lv_params.push(p(ids[4], names[4], num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); + } + let mut sc_params = vec![p("method", "Method", Value::Enum(0), popup(&["Relative", "Absolute"]))]; + for (ids, names) in SC_IDS.iter().zip(SC_NAMES.iter()) { + for k in 0..4 { + sc_params.push(p(ids[k], names[k], num(0.0), pct())); + } + } + let clip_params = |extra: bool| { + let mut v = vec![ + p("blackClip", "Black Clip", num(0.1), slider(0.0, 49.0, 0.0, 10.0, 2)), + p("whiteClip", "White Clip", num(0.1), slider(0.0, 49.0, 0.0, 10.0, 2)), + p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ]; + if extra { + v.insert(0, p("snapNeutralMidtones", "Snap Neutral Midtones", Value::Bool(true), ParamUi::Checkbox)); + } + v + }; + let mut k_params: Vec = + K_IDS.iter().zip(K_NAMES.iter()).map(|(id, n)| p(id, n, num(if *id == "k5" { 1.0 } else { 0.0 }), slider(-100.0, 100.0, -10.0, 10.0, 2))).collect(); + k_params.push(p("scale", "Scale", num(1.0), slider(-100.0, 100.0, 0.0, 4.0, 3))); + k_params.push(p("offset", "Offset", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 3))); + k_params.push(p("absolute", "Absolute Value", Value::Bool(false), ParamUi::Checkbox)); + let curve = |id: &'static str, name: &'static str| p(id, name, Value::Str("0,0 1,1".into()), ParamUi::Hidden); + vec![ + spec( + "ec.color.curves", + "Curves", + vec![curve("rgb", "RGB"), curve("red", "Red"), curve("green", "Green"), curve("blue", "Blue"), curve("alpha", "Alpha")], + curves, + ), + spec("ec.color.levelsic", "Levels (Individual Controls)", lv_params, levels_ic), + spec("ec.color.autolevels", "Auto Levels", clip_params(false), auto_levels), + spec("ec.color.autocontrast", "Auto Contrast", clip_params(false), auto_contrast), + spec("ec.color.autocolor", "Auto Color", clip_params(true), auto_color), + spec( + "ec.color.equalize", + "Equalize", + vec![ + p("style", "Equalize", Value::Enum(0), popup(&["RGB", "Brightness", "Photoshop Style"])), + p("amount", "Amount to Equalize", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + equalize, + ), + spec( + "ec.color.shadowhighlight", + "Shadow/Highlight", + vec![ + p("autoAmounts", "Auto Amounts", Value::Bool(true), ParamUi::Checkbox), + p("shadowAmount", "Shadow Amount", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("highlightAmount", "Highlight Amount", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("shadowTonalWidth", "Shadow Tonal Width", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("shadowRadius", "Shadow Radius", num(30.0), slider(0.0, 2500.0, 0.0, 100.0, 0)), + p("highlightTonalWidth", "Highlight Tonal Width", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("highlightRadius", "Highlight Radius", num(30.0), slider(0.0, 2500.0, 0.0, 100.0, 0)), + p("colorCorrection", "Color Correction", num(20.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + p("midtoneContrast", "Midtone Contrast", num(0.0), pct()), + p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + shadow_highlight, + ), + spec("ec.color.selectivecolor", "Selective Color", sc_params, selective_color), + spec( + "ec.color.colorbalancehls", + "Color Balance (HLS)", + vec![p("hue", "Hue", num(0.0), ParamUi::Angle), p("lightness", "Lightness", num(0.0), pct()), p("saturation", "Saturation", num(0.0), pct())], + color_balance_hls, + ), + spec( + "ec.color.colorlink", + "Color Link", + vec![ + p("sampleSource", "Sample", Value::Enum(0), popup(&["Average", "Brightest", "Darkest", "Max RGB", "Min RGB"])), + p("stencilOriginalAlpha", "Stencil Original Alpha", Value::Bool(true), ParamUi::Checkbox), + p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("blendingMode", "Blending Mode", Value::Enum(0), popup(&LINK_MODES.map(|m| m.0))), + ], + color_link, + ), + spec( + "ec.color.broadcast", + "Broadcast Colors", + vec![ + p("locale", "Broadcast Locale", Value::Enum(0), popup(&["NTSC", "PAL"])), + p( + "howToMakeSafe", + "How to Make Color Safe", + Value::Enum(0), + popup(&["Reduce Luminance", "Reduce Saturation", "Key Out Unsafe", "Key Out Safe"]), + ), + p("maxSignal", "Maximum Signal Amplitude (IRE)", num(110.0), slider(90.0, 120.0, 90.0, 120.0, 0)), + ], + broadcast_colors, + ), + spec( + "ec.color.cctoner", + "CC Toner", + vec![ + p("tones", "Tones", Value::Enum(1), popup(&["Duotone", "Tritone", "Pentone"])), + p("highlights", "Highlights", col(1.0, 1.0, 1.0), ParamUi::Color), + p("brights", "Brights", col(0.86, 0.78, 0.64), ParamUi::Color), + p("midtones", "Midtones", col(0.55, 0.43, 0.28), ParamUi::Color), + p("darktones", "Darktones", col(0.26, 0.18, 0.1), ParamUi::Color), + p("shadows", "Shadows", col(0.0, 0.0, 0.0), ParamUi::Color), + p("blend", "Blend w. Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + cc_toner, + ), + spec( + "ec.color.cccoloroffset", + "CC Color Offset", + vec![ + p("redPhase", "Red Phase", num(0.0), ParamUi::Angle), + p("greenPhase", "Green Phase", num(0.0), ParamUi::Angle), + p("bluePhase", "Blue Phase", num(0.0), ParamUi::Angle), + p("overflow", "Overflow", Value::Enum(0), popup(&["Wrap", "Solarize", "Polarize"])), + ], + cc_color_offset, + ), + spec("ec.color.cckernel", "CC Kernel", k_params, cc_kernel), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Params, find}; + + fn run(id: &str, img: &Image, over: &[(&str, Value)]) -> Image { + let s = find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in over { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img + } + + fn ramp() -> Image { + let mut img = Image::new(16, 8); + for y in 0..8 { + for x in 0..16 { + let v = x as f32 / 15.0; + let a = if y == 0 { 0.5 } else { 1.0 }; + img.set(x, y, [v * a, (1.0 - v) * a, 0.3 * a, a]); + } + } + img + } + + fn close(a: &Image, b: &Image, eps: f32) -> bool { + a.data.iter().zip(&b.data).all(|(p, q)| (0..4).all(|i| (p[i] - q[i]).abs() <= eps)) + } + + #[test] + fn curve_parsing() { + assert!(Curve::parse("0,0 1,1").is_none()); + assert!(Curve::parse("garbage").is_none()); + let c = Curve::parse("1,1; 0,0 0.5,0.7").unwrap(); + assert_eq!(c.xs, vec![0.0, 0.5, 1.0]); + assert!((c.eval(0.5) - 0.7).abs() < 1e-3); + // Monotone: never decreases. + let mut last = -1.0; + for i in 0..=100 { + let v = c.eval(i as f32 / 100.0); + assert!(v >= last - 1e-6); + last = v; + } + } + + #[test] + fn curves_identity_is_identity() { + let img = ramp(); + assert_eq!(run("ec.color.curves", &img, &[]), img); + let explicit = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.25,0.25 1,1".into()))]); + assert!(close(&explicit, &img, 1e-4)); + } + + #[test] + fn curves_midpoint_brightens() { + let img = Image::filled(4, 4, [0.5, 0.5, 0.5, 1.0]); + let out = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.5,0.75 1,1".into()))]); + assert!((out.data[0][0] - 0.75).abs() < 1e-3); + let red = run("ec.color.curves", &img, &[("red", Value::Str("0,1 1,1".into()))]); + assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6); + } + + #[test] + fn levels_ic_identity_and_red_only() { + let img = ramp(); + assert_eq!(run("ec.color.levelsic", &img, &[]), img); + let out = run("ec.color.levelsic", &img, &[("redOutWhite", num(0.5))]); + let p = out.get(15, 3); + assert!((p[0] - 0.5).abs() < 1e-4 && (p[1] - 0.0).abs() < 1e-4); + } + + #[test] + fn auto_levels_stretches_low_contrast() { + let mut img = Image::new(32, 4); + for y in 0..4 { + for x in 0..32 { + let v = 0.4 + 0.2 * x as f32 / 31.0; + img.set(x, y, [v, v, v, 1.0]); + } + } + let out = run("ec.color.autolevels", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]); + let lo = out.data.iter().map(|p| p[0]).fold(1.0, f32::min); + let hi = out.data.iter().map(|p| p[0]).fold(0.0, f32::max); + assert!(lo < 0.02 && hi > 0.98, "{lo} {hi}"); + let ac = run("ec.color.autocontrast", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]); + assert!(ac.data.iter().map(|p| p[0]).fold(0.0, f32::max) > 0.98); + } + + #[test] + fn auto_color_neutralises_cast() { + let mut img = Image::new(16, 4); + for y in 0..4 { + for x in 0..16 { + let v = x as f32 / 15.0; + img.set(x, y, [v * 0.6 + 0.1, v * 0.9, v * 0.5, 1.0]); + } + } + let out = run("ec.color.autocolor", &img, &[]); + let p = out.get(8, 1); + assert!((p[0] - p[1]).abs() < 0.08 && (p[1] - p[2]).abs() < 0.08, "{p:?}"); + } + + #[test] + fn equalize_flattens_histogram() { + let mut img = Image::new(64, 1); + for x in 0..64 { + let v = if x < 48 { 0.1 + x as f32 * 0.001 } else { 0.9 }; + img.set(x, 0, [v, v, v, 1.0]); + } + let out = run("ec.color.equalize", &img, &[]); + // The crowded dark values are spread across the range. + assert!(out.get(47, 0)[0] > 0.6, "{}", out.get(47, 0)[0]); + assert!(out.get(0, 0)[0] < 0.1); + assert_eq!(run("ec.color.equalize", &img, &[("amount", num(0.0))]), img); + } + + #[test] + fn selective_color_zero_is_identity_and_reds_respond() { + let img = ramp(); + assert_eq!(run("ec.color.selectivecolor", &img, &[]), img); + let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]); + let out = run("ec.color.selectivecolor", &red, &[("redsCyan", num(100.0)), ("method", Value::Enum(1))]); + assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]); + let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]); + assert_eq!(run("ec.color.selectivecolor", &blue, &[("redsCyan", num(100.0))]), blue); + } + + #[test] + fn color_offset_full_turn_is_identity() { + let img = ramp(); + let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(360.0)), ("greenPhase", num(720.0))]); + assert_eq!(out, img); + let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(180.0))]); + assert!((out.get(0, 3)[0] - 0.5).abs() < 1e-4); + } + + #[test] + fn broadcast_reduces_unsafe_color() { + let img = Image::filled(2, 2, [1.0, 1.0, 0.0, 1.0]); + let out = run("ec.color.broadcast", &img, &[]); + assert!(out.data[0][0] < 1.0); + let key = run("ec.color.broadcast", &img, &[("howToMakeSafe", Value::Enum(2))]); + assert_eq!(key.data[0][3], 0.0); + let gray = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]); + assert_eq!(run("ec.color.broadcast", &gray, &[]), gray); + } + + #[test] + fn color_balance_hls_zero_identity_and_hue_shift() { + let img = ramp(); + assert_eq!(run("ec.color.colorbalancehls", &img, &[]), img); + let red = Image::filled(1, 1, [1.0, 0.0, 0.0, 1.0]); + let out = run("ec.color.colorbalancehls", &red, &[("hue", num(120.0))]); + assert!(out.data[0][1] > 0.99 && out.data[0][0] < 0.01); + } + + #[test] + fn color_link_fills_with_average_keeping_alpha() { + let mut img = Image::new(2, 1); + img.set(0, 0, [1.0, 0.0, 0.0, 1.0]); + img.set(1, 0, [0.0, 0.0, 0.5, 0.5]); + let out = run("ec.color.colorlink", &img, &[]); + assert!((out.data[1][3] - 0.5).abs() < 1e-5); + let (c, _) = unpremul(out.data[0]); + assert!((c[0] - 2.0 / 3.0).abs() < 1e-3 && (c[2] - 1.0 / 3.0).abs() < 1e-3, "{c:?}"); + } + + #[test] + fn cc_kernel_identity_and_toner_duotone() { + let img = ramp(); + assert_eq!(run("ec.color.cckernel", &img, &[]), img); + let g = Image::filled(1, 1, [0.5, 0.5, 0.5, 1.0]); + let t = run("ec.color.cctoner", &g, &[("tones", Value::Enum(0)), ("highlights", col(1.0, 0.0, 0.0))]); + assert!((t.data[0][0] - 0.5).abs() < 1e-3 && t.data[0][1] < 1e-3); + } + + #[test] + fn shadow_highlight_lifts_shadows() { + let img = Image::filled(8, 8, [0.1, 0.1, 0.1, 1.0]); + let out = run("ec.color.shadowhighlight", &img, &[("autoAmounts", Value::Bool(false))]); + assert!(out.data[0][0] > 0.15); + } +} diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs new file mode 100644 index 00000000..20eb1bdc --- /dev/null +++ b/crates/effects/src/distort2.rs @@ -0,0 +1,805 @@ +//! Distort effects, batch 2: Turbulent Displace, Displacement Map (self as map), Optics +//! Compensation, Magnify, Mesh Warp, Bezier Warp, CC Bend It, CC Lens, CC Page Turn, CC Tiler, +//! CC Griddler. +//! +//! Mesh Warp and Bezier Warp share a grid rasteriser: a (nx+1)×(ny+1) grid of destination points +//! and matching source points is split into triangles, bucketed per output row and filled +//! row-parallel with inverse barycentric sampling. +//! +//! Mesh Warp's distortion mesh is stored in the hidden string parameter `mesh`: whitespace (or +//! `;`) separated `dx,dy` pairs, row-major over the (rows+1)×(columns+1) vertices, giving each +//! vertex's offset in layer pixels. An empty string (or one with the wrong count) is the +//! undistorted mesh. + +use std::f64::consts::{FRAC_PI_2, PI}; + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::generate::value_noise; +use crate::util::{SRC_NAMES, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true } +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +fn pct(max: f64) -> ParamUi { + slider(0.0, max, 0.0, max.min(100.0), 1) +} + +// ---------------------------------------------------------------- Turbulent Displace + +fn fbm(u: f64, v: f64, z: f64, seed: u32, octaves: f64, falloff: f32) -> f32 { + let n = octaves.ceil().max(1.0) as usize; + let frac = (octaves - octaves.floor()) as f32; + let (mut sum, mut norm, mut amp, mut f) = (0.0f32, 0.0f32, 1.0f32, 1.0f32); + for o in 0..n { + let w = if o + 1 == n && frac > 0.0 { frac } else { 1.0 }; + sum += value_noise(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w; + norm += amp * w; + amp *= falloff; + f *= 2.0; + } + sum / norm.max(1e-6) +} + +fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amount = ctx.params.f("amount") * b.scale; + if amount.abs() < 1e-9 { + return b; + } + let kind = ctx.params.e("displacement"); + let size = (ctx.params.f("size") * b.scale).max(1.0); + let off = b.to_px(ctx.params.v2("offset")); + let oct = ctx.params.f("complexity").clamp(1.0, 10.0); + let evo = ctx.params.f("evolution") / 360.0; + let pin = ctx.params.e("pinning"); + let falloff = if kind == 3 { 0.3 } else { 0.5 }; + let (w, h) = (b.img.width as f64, b.img.height as f64); + let edge = (w.min(h) * 0.1).max(1.0); + let seed = ctx.seed ^ 0x7d15; + let n = |u: f64, v: f64, s: u32| fbm(u, v, evo, seed.wrapping_add(s), oct, falloff) as f64 - 0.5; + b.img = remap(&b.img, false, |x, y| { + let (u, v) = ((x - off.0) / size, (y - off.1) / size); + let (dx, dy) = match kind { + 1 | 2 => { + let e = 0.05; + let gx = (n(u + e, v, 0) - n(u - e, v, 0)) / (2.0 * e); + let gy = (n(u, v + e, 0) - n(u, v - e, 0)) / (2.0 * e); + let k = amount * 0.25; + if kind == 1 { (gx * k, gy * k) } else { (-gy * k, gx * k) } + } + 4 => (n(u, v, 0) * amount, 0.0), + 5 => (0.0, n(u, v, 17) * amount), + 6 => { + let a = n(u, v, 0) * amount; + (a, a) + } + _ => (n(u, v, 0) * amount, n(u, v, 17) * amount), + }; + let ex = (x.min(w - x) / edge).clamp(0.0, 1.0); + let ey = (y.min(h - y) / edge).clamp(0.0, 1.0); + let k = match pin { + 1 => ex.min(ey), + 2 => ey, + 3 => ex, + _ => 1.0, + }; + Some((x + dx * k, y + dy * k)) + }); + b +} + +// ---------------------------------------------------------------- Displacement Map + +fn displacement_map(ctx: &EffectCtx, mut b: Buf) -> Buf { + let hs = src_at(ctx.params.e("useForHorizontal")); + let vs = src_at(ctx.params.e("useForVertical")); + let mh = ctx.params.f("maxHorizontal") * b.scale; + let mv = ctx.params.f("maxVertical") * b.scale; + let wrap = ctx.params.b("wrapPixelsAround"); + let src = b.img.clone(); + let (w, h) = (src.width as f64, src.height as f64); + map_xy(&mut b.img, |x, y, px| { + let (c, a) = unpremul(px); + let dx = (pick(hs, c, a) as f64 - 0.5) * 2.0 * mh; + let dy = (pick(vs, c, a) as f64 - 0.5) * 2.0 * mv; + if dx == 0.0 && dy == 0.0 { + return px; + } + let (mut sx, mut sy) = (x as f64 + 0.5 + dx, y as f64 + 0.5 + dy); + if wrap { + sx = sx.rem_euclid(w); + sy = sy.rem_euclid(h); + src.sample_bilinear_clamped(sx, sy) + } else { + src.sample_bilinear(sx, sy) + } + }); + b +} + +// ---------------------------------------------------------------- Optics Compensation + +fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf { + let fov = ctx.params.f("fieldOfView").clamp(0.0, 179.0).to_radians(); + if fov < 1e-6 { + return b; + } + let reverse = ctx.params.b("reverseLensDistortion"); + let (_, _, lw, lh) = layer_rect(ctx, &b); + let r_ref = match ctx.params.e("fovOrientation") { + 1 => lh * 0.5, + 2 => (lw * lw + lh * lh).sqrt() * 0.5, + _ => lw * 0.5, + } + .max(1.0); + let c = b.to_px(ctx.params.v2("viewCenter")); + let half = fov * 0.5; + let f = r_ref / half.tan(); + b.img = remap(&b.img, false, |x, y| { + let (dx, dy) = (x - c.0, y - c.1); + let r = (dx * dx + dy * dy).sqrt(); + if r < 1e-9 { + return Some((x, y)); + } + let rs = if reverse { + (r / f).atan() / half * r_ref + } else { + let th = r / r_ref * half; + if th >= FRAC_PI_2 - 1e-4 { + return None; + } + f * th.tan() + }; + let k = rs / r; + Some((c.0 + dx * k, c.1 + dy * k)) + }); + b +} + +// ---------------------------------------------------------------- Magnify + +fn magnify(ctx: &EffectCtx, mut b: Buf) -> Buf { + let square = ctx.params.e("shape") == 1; + let c = b.to_px(ctx.params.v2("center")); + let mag = (ctx.params.f("magnification") / 100.0).max(0.01); + let link = ctx.params.e("link"); + let mut size = ctx.params.f("size") * b.scale; + let mut feather = ctx.params.f("feather") * b.scale; + if link >= 1 { + size *= mag; + } + if link == 2 { + feather *= mag; + } + let op = (ctx.params.f("opacity") / 100.0) as f32; + let src = b.img.clone(); + map_xy(&mut b.img, |x, y, px| { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let d = if square { dx.abs().max(dy.abs()) } else { (dx * dx + dy * dy).sqrt() }; + let w = if feather > 0.01 { 1.0 - smoothstep((size - feather) as f32, size as f32, d as f32) } else { (size - d + 0.5).clamp(0.0, 1.0) as f32 } * op; + if w <= 0.0 { + return px; + } + let m = src.sample_bilinear(c.0 + dx / mag, c.1 + dy / mag); + lerp4(px, m, w) + }); + b +} + +// ---------------------------------------------------------------- grid rasteriser + +type Pt = (f64, f64); + +/// Warp `src` into a `w`×`h` image: grid point `i` (row-major, (nx+1)×(ny+1)) at `dest[i]` shows +/// the source at `srcp[i]`. Uncovered pixels are transparent. +fn grid_warp(src: &Image, w: u32, h: u32, nx: usize, ny: usize, dest: &[Pt], srcp: &[Pt]) -> Image { + let idx = |i: usize, j: usize| j * (nx + 1) + i; + let mut tris: Vec<[usize; 3]> = Vec::with_capacity(nx * ny * 2); + for j in 0..ny { + for i in 0..nx { + let (a, b2, c, d) = (idx(i, j), idx(i + 1, j), idx(i + 1, j + 1), idx(i, j + 1)); + tris.push([a, b2, c]); + tris.push([a, c, d]); + } + } + let hh = h as usize; + let mut buckets: Vec> = vec![Vec::new(); hh]; + for (t, tri) in tris.iter().enumerate() { + let ys = tri.map(|k| dest[k].1); + let y0 = (ys[0].min(ys[1]).min(ys[2]) - 0.5).floor().max(0.0) as usize; + let y1 = (ys[0].max(ys[1]).max(ys[2]) + 0.5).ceil().max(0.0) as usize; + for y in y0..y1.min(hh) { + buckets[y].push(t); + } + } + let mut out = Image::new(w, h); + out.rows_mut().for_each(|(y, row)| { + let py = y as f64 + 0.5; + for &t in &buckets[y] { + let [ia, ib, ic] = tris[t]; + let (a, bb, c) = (dest[ia], dest[ib], dest[ic]); + let den = (bb.1 - c.1) * (a.0 - c.0) + (c.0 - bb.0) * (a.1 - c.1); + if den.abs() < 1e-12 { + continue; + } + let x0 = (a.0.min(bb.0).min(c.0) - 0.5).floor().max(0.0) as usize; + let x1 = ((a.0.max(bb.0).max(c.0) + 0.5).ceil().max(0.0) as usize).min(row.len()); + for x in x0..x1 { + if row[x][3] > 0.0 { + continue; + } + let px = x as f64 + 0.5; + let l1 = ((bb.1 - c.1) * (px - c.0) + (c.0 - bb.0) * (py - c.1)) / den; + let l2 = ((c.1 - a.1) * (px - c.0) + (a.0 - c.0) * (py - c.1)) / den; + let l3 = 1.0 - l1 - l2; + let e = -1e-9; + if l1 < e || l2 < e || l3 < e { + continue; + } + let (sa, sb, sc) = (srcp[ia], srcp[ib], srcp[ic]); + let sx = sa.0 * l1 + sb.0 * l2 + sc.0 * l3; + let sy = sa.1 * l1 + sb.1 * l2 + sc.1 * l3; + row[x] = src.sample_bilinear(sx, sy); + } + } + }); + out +} + +fn parse_mesh(s: &str) -> Vec { + s.split(|c: char| c.is_whitespace() || c == ';') + .filter(|t| !t.is_empty()) + .filter_map(|t| { + let mut it = t.split(','); + let x = it.next()?.trim().parse::().ok()?; + let y = it.next()?.trim().parse::().ok()?; + Some((x, y)) + }) + .collect() +} + +fn mesh_warp(ctx: &EffectCtx, mut b: Buf) -> Buf { + let rows = ctx.params.f("rows").round().clamp(1.0, 31.0) as usize; + let cols = ctx.params.f("columns").round().clamp(1.0, 31.0) as usize; + let q = ctx.params.f("quality").round().clamp(1.0, 10.0) as usize; + let offs = parse_mesh(ctx.params.s("mesh")); + if offs.len() != (rows + 1) * (cols + 1) || offs.iter().all(|o| o.0 == 0.0 && o.1 == 0.0) { + return b; + } + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + let vert = |i: usize, j: usize| { + let o = offs[j * (cols + 1) + i]; + b.to_px([i as f64 / cols as f64 * lw + o.0, j as f64 / rows as f64 * lh + o.1]) + }; + let (nx, ny) = (cols * q, rows * q); + let mut dest = Vec::with_capacity((nx + 1) * (ny + 1)); + let mut srcp = Vec::with_capacity((nx + 1) * (ny + 1)); + for gj in 0..=ny { + for gi in 0..=nx { + let (ci, cj) = ((gi / q).min(cols - 1), (gj / q).min(rows - 1)); + let (u, v) = ((gi - ci * q) as f64 / q as f64, (gj - cj * q) as f64 / q as f64); + let (p00, p10, p01, p11) = (vert(ci, cj), vert(ci + 1, cj), vert(ci, cj + 1), vert(ci + 1, cj + 1)); + let x = (p00.0 * (1.0 - u) + p10.0 * u) * (1.0 - v) + (p01.0 * (1.0 - u) + p11.0 * u) * v; + let y = (p00.1 * (1.0 - u) + p10.1 * u) * (1.0 - v) + (p01.1 * (1.0 - u) + p11.1 * u) * v; + dest.push((x, y)); + srcp.push(b.to_px([gi as f64 / nx as f64 * lw, gj as f64 / ny as f64 * lh])); + } + } + b.img = grid_warp(&b.img, b.img.width, b.img.height, nx, ny, &dest, &srcp); + b +} + +// ---------------------------------------------------------------- Bezier Warp + +fn bez(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] { + let u = 1.0 - t; + let (a, bb, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + [p0[0] * a + p1[0] * bb + p2[0] * c + p3[0] * d, p0[1] * a + p1[1] * bb + p2[1] * c + p3[1] * d] +} + +fn bezier_warp(ctx: &EffectCtx, mut b: Buf) -> Buf { + let g = |id: &str| ctx.params.v2(id); + let (tl, tlt, trt, tr) = (g("topLeftVertex"), g("topLeftTangent"), g("topRightTangent"), g("rightTopVertex")); + let (rtt, rbt, br) = (g("rightTopTangent"), g("rightBottomTangent"), g("bottomRightVertex")); + let (brt, blt, bl) = (g("bottomRightTangent"), g("bottomLeftTangent"), g("leftBottomVertex")); + let (lbt, ltt) = (g("leftBottomTangent"), g("leftTopTangent")); + let n = (ctx.params.f("quality").round().clamp(1.0, 10.0) as usize) * 4; + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + let mut dest = Vec::with_capacity((n + 1) * (n + 1)); + let mut srcp = Vec::with_capacity((n + 1) * (n + 1)); + for j in 0..=n { + let v = j as f64 / n as f64; + let d0 = bez(tl, ltt, lbt, bl, v); + let d1 = bez(tr, rtt, rbt, br, v); + for i in 0..=n { + let u = i as f64 / n as f64; + let c0 = bez(tl, tlt, trt, tr, u); + let c1 = bez(bl, blt, brt, br, u); + let mut s = [0.0; 2]; + for k in 0..2 { + let bil = (1.0 - u) * (1.0 - v) * tl[k] + u * (1.0 - v) * tr[k] + (1.0 - u) * v * bl[k] + u * v * br[k]; + s[k] = (1.0 - v) * c0[k] + v * c1[k] + (1.0 - u) * d0[k] + u * d1[k] - bil; + } + dest.push(b.to_px(s)); + srcp.push(b.to_px([u * lw, v * lh])); + } + } + b.img = grid_warp(&b.img, b.img.width, b.img.height, n, n, &dest, &srcp); + b +} + +// ---------------------------------------------------------------- CC Bend It + +fn bend_it(ctx: &EffectCtx, mut b: Buf) -> Buf { + let mut beta = ctx.params.f("bend").to_radians(); + if beta.abs() < 1e-6 { + return b; + } + let s = b.to_px(ctx.params.v2("start")); + let e = b.to_px(ctx.params.v2("end")); + let static_pre = ctx.params.e("renderPrestart") == 1; + let l = ((e.0 - s.0).powi(2) + (e.1 - s.1).powi(2)).sqrt(); + if l < 1e-6 { + return b; + } + let a = ((e.0 - s.0) / l, (e.1 - s.1) / l); + let mut n = (-a.1, a.0); + if beta < 0.0 { + beta = -beta; + n = (-n.0, -n.1); + } + let rho = l / beta; + let pe = (rho * beta.sin(), rho - rho * beta.cos()); + let (t, nn) = ((beta.cos(), beta.sin()), (-beta.sin(), beta.cos())); + let src = b.img.clone(); + let to_src = |u: f64, w: f64| (s.0 + a.0 * u + n.0 * w, s.1 + a.1 * u + n.1 * w); + map_xy(&mut b.img, |x, y, _| { + let (vx, vy) = (x as f64 + 0.5 - s.0, y as f64 + 0.5 - s.1); + let (lx, ly) = (vx * a.0 + vy * a.1, vx * n.0 + vy * n.1); + let mut cands: [Option<(f64, f64)>; 3] = [None; 3]; + // Arc. + let (dx, dy) = (lx, ly - rho); + let th = dx.atan2(-dy); + if (0.0..=beta).contains(&th) { + cands[0] = Some((rho * th, rho - (dx * dx + dy * dy).sqrt())); + } + // Rigid continuation beyond the end. + let (ex, ey) = (lx - pe.0, ly - pe.1); + let u2 = ex * t.0 + ey * t.1; + if u2 >= 0.0 { + cands[1] = Some((l + u2, ex * nn.0 + ey * nn.1)); + } + if lx < 0.0 && static_pre { + cands[2] = Some((lx, ly)); + } + for (u, w) in cands.into_iter().flatten() { + let (sx, sy) = to_src(u, w); + let v = src.sample_bilinear(sx, sy); + if v[3] > 1e-4 { + return v; + } + } + [0.0; 4] + }); + b +} + +// ---------------------------------------------------------------- CC Lens + +fn cc_lens(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = b.to_px(ctx.params.v2("center")); + let (_, _, lw, lh) = layer_rect(ctx, &b); + let r = (ctx.params.f("size") / 100.0 * lw.max(lh) * 0.5).max(0.5); + let k = 2f64.powf(ctx.params.f("convergence") / 50.0); + b.img = remap(&b.img, false, |x, y| { + let (dx, dy) = (x - c.0, y - c.1); + let d = (dx * dx + dy * dy).sqrt(); + if d >= r { + return None; + } + if d < 1e-9 { + return Some((x, y)); + } + let g = ((d / r).asin() / FRAC_PI_2).powf(k); + let s = r * g / d; + Some((c.0 + dx * s, c.1 + dy * s)) + }); + b +} + +// ---------------------------------------------------------------- CC Page Turn + +fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf { + let f = b.to_px(ctx.params.v2("foldPosition")); + let phi = ctx.params.f("foldDirection").to_radians(); + let r = (ctx.params.f("foldRadius") * b.scale).max(0.5); + let lam = ctx.params.f("lightDirection").to_radians(); + let render = ctx.params.e("render"); + let bo = (ctx.params.f("backPageOpacity") / 100.0) as f32; + let paper = ctx.params.color("paperColor"); + // The fold travels along `fd`; the lifted part lies on the other side (toward `n`). + let n = (-phi.sin(), phi.cos()); + let ldot = (n.0 * lam.sin() - n.1 * lam.cos()) as f32; + let (show_front, show_back) = (render != 1, render != 2); + let src = b.img.clone(); + map_xy(&mut b.img, |x, y, _| { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let d = (px - f.0) * n.0 + (py - f.1) * n.1; + if d > r { + return [0.0; 4]; + } + // (u, back side?, shade) from the top layer down. + let mut cands: [Option<(f64, bool, f32)>; 4] = [None; 4]; + if d <= 0.0 { + cands[0] = Some((PI * r - d, true, 0.9)); + cands[3] = Some((d, false, 1.0)); + } else { + let a = (d / r).clamp(-1.0, 1.0).asin(); + cands[1] = Some((PI * r - r * a, true, 0.6 + 0.4 * a.cos() as f32)); + let psi = (a / FRAC_PI_2) as f32; + cands[2] = Some((r * a, false, 1.0 - 0.4 * psi * (0.5 + 0.5 * ldot))); + } + for (u, back, shade) in cands.into_iter().flatten() { + if (back && !show_back) || (!back && !show_front) { + continue; + } + let k = u - d; + let v = src.sample_bilinear(px + k * n.0, py + k * n.1); + if v[3] <= 1e-4 { + continue; + } + let (c, a) = unpremul(v); + let c = if back { [0, 1, 2].map(|i| paper[i] * (1.0 - bo) + c[i] * bo) } else { c }; + return premul(c.map(|v| v * shade), a); + } + [0.0; 4] + }); + b +} + +// ---------------------------------------------------------------- CC Tiler / CC Griddler + +fn cc_tiler(ctx: &EffectCtx, mut b: Buf) -> Buf { + let s = (ctx.params.f("scale") / 100.0).max(0.001); + let c = b.to_px(ctx.params.v2("center")); + let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; + let (x0, y0, w, h) = layer_rect(ctx, &b); + let (w, h) = (w.max(1.0), h.max(1.0)); + let src = b.img.clone(); + map_xy(&mut b.img, |x, y, px| { + let qx = c.0 + (x as f64 + 0.5 - c.0) / s; + let qy = c.1 + (y as f64 + 0.5 - c.1) / s; + let sx = x0 + (qx - x0).rem_euclid(w); + let sy = y0 + (qy - y0).rem_euclid(h); + let t: Px = src.sample_bilinear_clamped(sx, sy); + lerp4(t, px, blend) + }); + b +} + +fn cc_griddler(ctx: &EffectCtx, mut b: Buf) -> Buf { + let hs = (ctx.params.f("horizontalScale") / 100.0).max(0.001); + let vs = (ctx.params.f("verticalScale") / 100.0).max(0.001); + let rot = ctx.params.f("rotation").to_radians(); + let cut = ctx.params.b("cutTiles"); + let (x0, y0, lw, _) = layer_rect(ctx, &b); + let t = (ctx.params.f("tileSize") / 100.0 * lw).max(1.0); + if (hs - 1.0).abs() < 1e-9 && (vs - 1.0).abs() < 1e-9 && rot.abs() < 1e-9 && !cut { + return b; + } + let (sr, cr) = rot.sin_cos(); + let src = b.img.clone(); + map_xy(&mut b.img, |x, y, _| { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let tcx = x0 + (((px - x0) / t).floor() + 0.5) * t; + let tcy = y0 + (((py - y0) / t).floor() + 0.5) * t; + let (lx, ly) = (px - tcx, py - tcy); + if cut && lx.abs().max(ly.abs()) > 0.45 * t { + return [0.0; 4]; + } + let (rx, ry) = (lx * cr + ly * sr, -lx * sr + ly * cr); + src.sample_bilinear(tcx + rx / hs, tcy + ry / vs) + }); + b +} + +pub fn specs() -> Vec { + let srcs = || popup(&SRC_NAMES); + vec![ + spec( + "ec.distort.turbulentdisplace", + "Turbulent Displace", + vec![ + p( + "displacement", + "Displacement", + Value::Enum(0), + popup(&["Turbulent", "Bulge", "Twist", "Turbulent Smoother", "Horizontal Displacement", "Vertical Displacement", "Cross Displacement"]), + ), + p("amount", "Amount", num(50.0), slider(-1000.0, 1000.0, -200.0, 200.0, 1)), + p("size", "Size", num(100.0), slider(2.0, 4000.0, 2.0, 400.0, 1)), + p("offset", "Offset (Turbulence)", pt(0.5, 0.5), ParamUi::Point), + p("complexity", "Complexity", num(1.0), slider(1.0, 10.0, 1.0, 10.0, 1)), + p("evolution", "Evolution", num(0.0), ParamUi::Angle), + p("pinning", "Pinning", Value::Enum(0), popup(&["None", "Pin All Edges", "Pin Horizontal Edges", "Pin Vertical Edges"])), + ], + turbulent_displace, + ), + spec( + "ec.distort.displacementmap", + "Displacement Map", + vec![ + p("useForHorizontal", "Use For Horizontal Displacement", Value::Enum(0), srcs()), + p("maxHorizontal", "Max Horizontal Displacement", num(5.0), slider(-32000.0, 32000.0, -100.0, 100.0, 1)), + p("useForVertical", "Use For Vertical Displacement", Value::Enum(1), srcs()), + p("maxVertical", "Max Vertical Displacement", num(5.0), slider(-32000.0, 32000.0, -100.0, 100.0, 1)), + p("wrapPixelsAround", "Wrap Pixels Around", Value::Bool(false), ParamUi::Checkbox), + ], + displacement_map, + ), + spec( + "ec.distort.opticscompensation", + "Optics Compensation", + vec![ + p("fieldOfView", "Field Of View (FOV)", num(0.0), slider(0.0, 180.0, 0.0, 180.0, 1)), + p("reverseLensDistortion", "Reverse Lens Distortion", Value::Bool(false), ParamUi::Checkbox), + p("fovOrientation", "FOV Orientation", Value::Enum(0), popup(&["Horizontal", "Vertical", "Diagonal"])), + p("viewCenter", "View Center", pt(0.5, 0.5), ParamUi::Point), + ], + optics_compensation, + ), + spec( + "ec.distort.magnify", + "Magnify", + vec![ + p("shape", "Shape", Value::Enum(0), popup(&["Circle", "Square"])), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("magnification", "Magnification", num(200.0), slider(1.0, 10000.0, 100.0, 600.0, 1)), + p("link", "Link", Value::Enum(0), popup(&["None", "Size To Magnification", "Size & Feather To Magnification"])), + p("size", "Size", num(100.0), slider(0.0, 4000.0, 0.0, 1000.0, 1)), + p("feather", "Feather", num(0.0), slider(0.0, 4000.0, 0.0, 200.0, 1)), + p("opacity", "Opacity", num(100.0), pct(100.0)), + ], + magnify, + ), + spec( + "ec.distort.meshwarp", + "Mesh Warp", + vec![ + p("rows", "Rows", num(7.0), slider(1.0, 31.0, 1.0, 31.0, 0)), + p("columns", "Columns", num(7.0), slider(1.0, 31.0, 1.0, 31.0, 0)), + p("quality", "Quality", num(8.0), slider(1.0, 10.0, 1.0, 10.0, 0)), + p("mesh", "Distortion Mesh", Value::Str(String::new()), ParamUi::Hidden), + ], + mesh_warp, + ), + spec( + "ec.distort.bezierwarp", + "Bezier Warp", + vec![ + p("topLeftVertex", "Top Left Vertex", pt(0.0, 0.0), ParamUi::Point), + p("topLeftTangent", "Top Left Tangent", pt(1.0 / 3.0, 0.0), ParamUi::Point), + p("topRightTangent", "Top Right Tangent", pt(2.0 / 3.0, 0.0), ParamUi::Point), + p("rightTopVertex", "Right Top Vertex", pt(1.0, 0.0), ParamUi::Point), + p("rightTopTangent", "Right Top Tangent", pt(1.0, 1.0 / 3.0), ParamUi::Point), + p("rightBottomTangent", "Right Bottom Tangent", pt(1.0, 2.0 / 3.0), ParamUi::Point), + p("bottomRightVertex", "Bottom Right Vertex", pt(1.0, 1.0), ParamUi::Point), + p("bottomRightTangent", "Bottom Right Tangent", pt(2.0 / 3.0, 1.0), ParamUi::Point), + p("bottomLeftTangent", "Bottom Left Tangent", pt(1.0 / 3.0, 1.0), ParamUi::Point), + p("leftBottomVertex", "Left Bottom Vertex", pt(0.0, 1.0), ParamUi::Point), + p("leftBottomTangent", "Left Bottom Tangent", pt(0.0, 2.0 / 3.0), ParamUi::Point), + p("leftTopTangent", "Left Top Tangent", pt(0.0, 1.0 / 3.0), ParamUi::Point), + p("quality", "Quality", num(8.0), slider(1.0, 10.0, 1.0, 10.0, 0)), + ], + bezier_warp, + ), + spec( + "ec.distort.ccbendit", + "CC Bend It", + vec![ + p("bend", "Bend", num(0.0), ParamUi::Angle), + p("start", "Start", pt(0.5, 0.2), ParamUi::Point), + p("end", "End", pt(0.5, 0.8), ParamUi::Point), + p("renderPrestart", "Render Prestart", Value::Enum(1), popup(&["None", "Static"])), + ], + bend_it, + ), + spec( + "ec.distort.cclens", + "CC Lens", + vec![ + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("size", "Size", num(50.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("convergence", "Convergence", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + ], + cc_lens, + ), + spec( + "ec.distort.ccpageturn", + "CC Page Turn", + vec![ + p("foldPosition", "Fold Position", pt(0.7, 0.7), ParamUi::Point), + p("foldDirection", "Fold Direction", num(315.0), ParamUi::Angle), + p("foldRadius", "Fold Radius", num(20.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + p("render", "Render", Value::Enum(0), popup(&["Front & Back Page", "Back Page", "Front Page"])), + p("backPageOpacity", "Back Opacity", num(75.0), pct(100.0)), + p("paperColor", "Paper Color", col(1.0, 1.0, 1.0), ParamUi::Color), + ], + page_turn, + ), + spec( + "ec.distort.cctiler", + "CC Tiler", + vec![ + p("scale", "Scale", num(50.0), slider(1.0, 10000.0, 1.0, 200.0, 1)), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("blendWithOriginal", "Blend w. Original", num(0.0), pct(100.0)), + ], + cc_tiler, + ), + spec( + "ec.distort.ccgriddler", + "CC Griddler", + vec![ + p("horizontalScale", "Horizontal Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)), + p("verticalScale", "Vertical Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)), + p("tileSize", "Tile Size", num(10.0), slider(0.5, 100.0, 0.5, 100.0, 1)), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("cutTiles", "Cut Tiles", Value::Bool(false), ParamUi::Checkbox), + ], + cc_griddler, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Params; + + fn test_img(w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let a = 1.0; + img.set(x, y, [x as f32 / w as f32 * a, y as f32 / h as f32 * a, ((x * 7 + y * 3) % 11) as f32 / 11.0 * a, a]); + } + } + img + } + + fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + let ls = [img.width as f64, img.height as f64]; + for p in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { + params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn max_diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) + } + + #[test] + fn displacement_map_half_is_identity() { + let img = test_img(32, 24); + let o = run("ec.distort.displacementmap", &[("useForHorizontal", Value::Enum(9)), ("useForVertical", Value::Enum(9))], img.clone()); + assert_eq!(o.img, img); + let o = run( + "ec.distort.displacementmap", + &[("useForHorizontal", Value::Enum(8)), ("useForVertical", Value::Enum(10)), ("maxHorizontal", num(2.0))], + img.clone(), + ); + // Full → +2 px horizontally: pixel x shows source x + 2. + assert!((o.img.get(5, 5)[0] - img.get(7, 5)[0]).abs() < 1e-5); + } + + #[test] + fn mesh_warp_empty_and_zero_are_identity() { + let img = test_img(32, 24); + assert_eq!(run("ec.distort.meshwarp", &[], img.clone()).img, img); + let zeros = vec!["0,0"; 64].join(" "); + assert_eq!(run("ec.distort.meshwarp", &[("mesh", Value::Str(zeros))], img.clone()).img, img); + } + + #[test] + fn mesh_warp_uniform_offset_translates() { + let img = test_img(32, 24); + let shift = vec!["4,0"; 64].join(" "); + let o = run("ec.distort.meshwarp", &[("mesh", Value::Str(shift))], img.clone()); + for y in 2..22 { + for x in 6..30 { + let (a, b2) = (o.img.get(x, y), img.get(x - 4, y)); + assert!((0..4).all(|i| (a[i] - b2[i]).abs() < 1e-4), "({x},{y}) {a:?} vs {b2:?}"); + } + } + assert!(o.img.get(1, 10)[3] < 1e-6); + } + + #[test] + fn bezier_warp_default_is_identity() { + let img = test_img(32, 24); + let o = run("ec.distort.bezierwarp", &[], img.clone()); + assert!(max_diff(&o.img, &img) < 1e-3, "{}", max_diff(&o.img, &img)); + } + + #[test] + fn optics_fov_zero_is_identity_and_nonzero_warps() { + let img = test_img(32, 24); + assert_eq!(run("ec.distort.opticscompensation", &[], img.clone()).img, img); + assert!(max_diff(&run("ec.distort.opticscompensation", &[("fieldOfView", num(90.0))], img.clone()).img, &img) > 1e-3); + } + + #[test] + fn bend_it_zero_is_identity() { + let img = test_img(32, 24); + assert_eq!(run("ec.distort.ccbendit", &[], img.clone()).img, img); + assert!(max_diff(&run("ec.distort.ccbendit", &[("bend", num(45.0))], img.clone()).img, &img) > 1e-3); + } + + #[test] + fn magnify_100_is_identity() { + let img = test_img(32, 24); + assert!(max_diff(&run("ec.distort.magnify", &[("magnification", num(100.0))], img.clone()).img, &img) < 1e-6); + let o = run("ec.distort.magnify", &[("size", num(8.0))], img.clone()); + // Centre pixel unchanged; slightly off-centre samples move toward the centre. + assert!(max_diff(&o.img, &img) > 1e-3); + assert_eq!(o.img.get(0, 0), img.get(0, 0)); + } + + #[test] + fn tiler_100_is_identity_and_50_tiles() { + let img = test_img(32, 24); + assert!(max_diff(&run("ec.distort.cctiler", &[("scale", num(100.0))], img.clone()).img, &img) < 1e-6); + let o = run("ec.distort.cctiler", &[], img.clone()); + assert!(o.img.data.iter().all(|p| p[3] > 0.99)); + } + + #[test] + fn turbulent_displace_zero_amount_is_identity() { + let img = test_img(32, 24); + assert_eq!(run("ec.distort.turbulentdisplace", &[("amount", num(0.0))], img.clone()).img, img); + assert!(max_diff(&run("ec.distort.turbulentdisplace", &[], img.clone()).img, &img) > 1e-3); + } + + #[test] + fn griddler_default_is_identity() { + let img = test_img(32, 24); + assert_eq!(run("ec.distort.ccgriddler", &[], img.clone()).img, img); + } + + #[test] + fn page_turn_removes_corner_and_keeps_far_side() { + let img = test_img(64, 64); + let o = run("ec.distort.ccpageturn", &[("foldRadius", num(4.0))], img.clone()); + // Bottom-right corner is lifted away; top-left untouched. + assert!(o.img.get(63, 63)[3] < 1e-6); + assert_eq!(o.img.get(2, 2), img.get(2, 2)); + } + + #[test] + fn cc_lens_clears_outside() { + let img = test_img(32, 32); + let o = run("ec.distort.cclens", &[], img); + assert!(o.img.get(0, 0)[3] < 1e-6); + assert!(o.img.get(16, 16)[3] > 0.99); + } +} diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs new file mode 100644 index 00000000..784f1916 --- /dev/null +++ b/crates/effects/src/generate2.rs @@ -0,0 +1,805 @@ +//! Generate effects, batch 2: Cell Pattern (Worley), Ellipse, Lens Flare, Beam, Radio Waves, +//! Advanced Lightning, CC Light Rays, CC Light Burst 2.5, CC Light Sweep. +//! +//! All designs are original procedural renderings written from the public descriptions of what +//! each effect produces. + +use std::f64::consts::PI; + +use effectcraft_color::luminance; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::Px; +use rayon::prelude::*; + +use crate::util::{Plane, Rng, gauss_plane, hash1, layer_rect, lerp3, map_xy, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +/// Premultiplied "over": `src` on top of `dst`. +#[inline] +fn over(dst: Px, src: Px) -> Px { + let k = 1.0 - src[3]; + [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k] +} + +/// Add light (straight rgb `l`, coverage `la`) onto a premultiplied pixel. +#[inline] +fn add_light(px: Px, l: [f32; 3], la: f32) -> Px { + let la = la.clamp(0.0, 1.0); + [px[0] + l[0], px[1] + l[1], px[2] + l[2], (px[3] + la * (1.0 - px[3])).clamp(0.0, 1.0)] +} + +// ---------------------------------------------------------------- Cell Pattern + +/// Worley distances for point (u, v) in cell units: (F1, F2, hash of the nearest cell). +fn worley(u: f64, v: f64, disperse: f64, evo: f64, seed: u32) -> (f64, f64, f32) { + let (cx, cy) = (u.floor() as i64, v.floor() as i64); + let mut f1 = f64::INFINITY; + let mut f2 = f64::INFINITY; + let mut id = 0.0f32; + for j in -1..=1 { + for i in -1..=1 { + let (gx, gy) = (cx + i, cy + j); + let (hx, hy) = (gx as i32 as u32, gy as i32 as u32); + let h1 = hash1(hx, hy, seed) as f64; + let h2 = hash1(hx, hy, seed.wrapping_add(1)) as f64; + let h3 = hash1(hx, hy, seed.wrapping_add(2)) as f64; + let ang = 2.0 * PI * (h3 + evo); + let px = gx as f64 + 0.5 + (h1 - 0.5) * disperse + 0.15 * disperse * ang.cos(); + let py = gy as f64 + 0.5 + (h2 - 0.5) * disperse + 0.15 * disperse * ang.sin(); + let d = ((px - u).powi(2) + (py - v).powi(2)).sqrt(); + if d < f1 { + f2 = f1; + f1 = d; + id = hash1(hx, hy, seed.wrapping_add(3)); + } else if d < f2 { + f2 = d; + } + } + } + (f1, f2, id) +} + +fn cell_value(pattern: u32, f1: f64, f2: f64, id: f32) -> f32 { + let (f1, f2) = (f1 as f32, f2 as f32); + let e = f2 - f1; + match pattern { + 0 => (1.0 - f1 * 1.3).clamp(0.0, 1.0), // Bubbles + 1 => id * 0.8 + 0.2 * (1.0 - f1).clamp(0.0, 1.0), // Crystals + 2 => (e * 2.0).clamp(0.0, 1.0), // Plates + 3 => id * smoothstep(0.0, 0.06, e), // Static Plates + 4 => id, // Crystallize + 5 => (1.0 - f1 * f1 * 2.0).clamp(0.0, 1.0), // Pillow + 6 => id * (e * 3.0).clamp(0.0, 1.0), // Mixed Crystals + 7 => smoothstep(0.35, 0.25, f1), // Dots + _ => 1.0 - ((e - 0.15).abs() * 5.0).clamp(0.0, 1.0), // Tubular + } +} + +fn overflow(v: f32, mode: u32) -> f32 { + match mode { + 1 => 0.5 + 0.5 * ((v - 0.5) * 2.0).tanh(), + 2 => { + let t = v.rem_euclid(2.0); + if t > 1.0 { 2.0 - t } else { t } + } + _ => v.clamp(0.0, 1.0), + } +} + +fn cell_pattern(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pattern = ctx.params.e("cellPattern"); + let invert = ctx.params.b("invert"); + let contrast = ctx.params.f("contrast") as f32 / 100.0; + let ov = ctx.params.e("overflow"); + let disperse = ctx.params.f("disperse").clamp(0.0, 1.5); + let size = (ctx.params.f("size") * b.scale).max(1.0); + let off = b.to_px(ctx.params.v2("offset")); + // Static Plates keep their shades still while the cells move. + let evo = ctx.params.f("evolution") / 360.0; + let seed = (ctx.params.f("randomSeed") as i64 as u32).wrapping_mul(7919) ^ 0xce11; + map_xy(&mut b.img, |x, y, _| { + let u = (x as f64 + 0.5 - off.0) / size; + let v = (y as f64 + 0.5 - off.1) / size; + let (f1, f2, id) = worley(u, v, disperse, evo, seed); + let mut val = cell_value(pattern, f1, f2, id); + val = overflow((val - 0.5) * contrast + 0.5, ov); + if invert { + val = 1.0 - val; + } + [val, val, val, 1.0] + }); + b +} + +// ---------------------------------------------------------------- Ellipse + +fn ellipse(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = b.to_px(ctx.params.v2("center")); + let rx = (ctx.params.f("width") * b.scale * 0.5).max(0.5); + let ry = (ctx.params.f("height") * b.scale * 0.5).max(0.5); + let half = (ctx.params.f("thickness") * b.scale * 0.5).max(0.25); + let soft = (ctx.params.f("softness") / 100.0).clamp(0.0, 1.0); + let inside = ctx.params.color("insideColor"); + let outside = ctx.params.color("outsideColor"); + let composite = ctx.params.b("compositeOnOriginal"); + map_xy(&mut b.img, |x, y, px| { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let q = ((dx / rx).powi(2) + (dy / ry).powi(2)).sqrt(); + let dist = if q < 1e-9 { + rx.min(ry) + } else { + let gx = dx / (rx * rx) / q; + let gy = dy / (ry * ry) / q; + ((q - 1.0) / (gx * gx + gy * gy).sqrt().max(1e-9)).abs() + }; + let t = (dist / half) as f32; + let a = if soft > 1e-3 { 1.0 - smoothstep(1.0 - soft as f32, 1.0, t) } else { (half - dist + 0.5).clamp(0.0, 1.0) as f32 }; + let cc = lerp3([inside[0], inside[1], inside[2]], [outside[0], outside[1], outside[2]], smoothstep(0.0, 1.0, t)); + let g = [cc[0] * a, cc[1] * a, cc[2] * a, a]; + if composite { over(px, g) } else { g } + }); + b +} + +// ---------------------------------------------------------------- Lens Flare + +struct Ghost { + t: f64, + r: f64, + c: [f32; 3], + k: f32, + ring: bool, +} + +fn ghosts(lens: u32) -> Vec { + let g = |t, r, c: [f32; 3], k, ring| Ghost { t, r, c, k, ring }; + match lens { + 1 => vec![ + g(0.45, 0.03, [0.4, 0.7, 1.0], 0.20, false), + g(0.9, 0.06, [0.5, 1.0, 0.6], 0.12, false), + g(1.3, 0.12, [1.0, 0.6, 0.3], 0.10, true), + g(1.6, 0.04, [0.8, 0.5, 1.0], 0.15, false), + ], + 2 => vec![g(0.6, 0.02, [1.0, 0.8, 0.5], 0.25, false), g(1.1, 0.05, [0.6, 0.6, 1.0], 0.12, false), g(1.4, 0.09, [0.4, 0.9, 1.0], 0.08, true)], + _ => vec![ + g(0.25, 0.02, [1.0, 0.9, 0.6], 0.25, false), + g(0.5, 0.04, [0.3, 1.0, 0.5], 0.15, false), + g(0.75, 0.025, [0.5, 0.6, 1.0], 0.2, false), + g(1.15, 0.07, [1.0, 0.5, 0.3], 0.10, false), + g(1.45, 0.1, [0.6, 0.4, 1.0], 0.08, true), + g(1.8, 0.05, [0.4, 0.9, 1.0], 0.15, false), + g(2.1, 0.14, [1.0, 0.8, 0.4], 0.06, true), + ], + } +} + +fn lens_flare(ctx: &EffectCtx, mut b: Buf) -> Buf { + let f = b.to_px(ctx.params.v2("flareCenter")); + let bright = (ctx.params.f("flareBrightness") / 100.0) as f32; + let lens = ctx.params.e("lensType"); + let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; + let (x0, y0, w, h) = layer_rect(ctx, &b); + let c = (x0 + w * 0.5, y0 + h * 0.5); + let diag = (w * w + h * h).sqrt().max(1.0); + let gs = ghosts(lens); + let (core_k, rays, halo_r) = match lens { + 1 => (0.035, 6.0, 0.18), + 2 => (0.02, 12.0, 0.3), + _ => (0.03, 8.0, 0.25), + }; + map_xy(&mut b.img, |x, y, px| { + let (dx, dy) = (x as f64 + 0.5 - f.0, y as f64 + 0.5 - f.1); + let r = (dx * dx + dy * dy).sqrt() / diag; + let th = dy.atan2(dx); + let core = 1.5 * (-(r / core_k).powi(2)).exp() + 0.25 * (-r / 0.12).exp(); + let streak = (rays * 0.5 * th).cos().abs().powi(40) * (-r / 0.3).exp() * 0.35; + let halo = (-((r - halo_r) / 0.012).powi(2)).exp() * 0.12; + let base = (core + streak) as f32; + let mut l = [base, base * 0.95, base * 0.85]; + let hal = halo as f32; + l[0] += hal * 0.6; + l[1] += hal * 0.8; + l[2] += hal; + for g in &gs { + let gx = f.0 + (c.0 - f.0) * g.t; + let gy = f.1 + (c.1 - f.1) * g.t; + let d = ((x as f64 + 0.5 - gx).powi(2) + (y as f64 + 0.5 - gy).powi(2)).sqrt() / diag; + let v = if g.ring { (-((d - g.r) / (g.r * 0.12)).powi(2)).exp() as f32 } else { smoothstep(g.r as f32, g.r as f32 * 0.8, d as f32) }; + for i in 0..3 { + l[i] += g.c[i] * g.k * v; + } + } + let l = l.map(|v| v * bright); + let la = l[0].max(l[1]).max(l[2]).min(1.0); + let o = add_light(px, l, la); + crate::util::lerp4(o, px, blend) + }); + b +} + +// ---------------------------------------------------------------- Beam + +fn beam(ctx: &EffectCtx, mut b: Buf) -> Buf { + let s = b.to_px(ctx.params.v2("startPoint")); + let e = b.to_px(ctx.params.v2("endPoint")); + let len = (ctx.params.f("length") / 100.0).clamp(0.0, 1.0); + let time = (ctx.params.f("time") / 100.0).clamp(0.0, 1.0); + let st = ctx.params.f("startThickness") * b.scale; + let et = ctx.params.f("endThickness") * b.scale; + let soft = (ctx.params.f("softness") / 100.0).clamp(0.0, 1.0) as f32; + let inside = ctx.params.color("insideColor"); + let outside = ctx.params.color("outsideColor"); + let composite = ctx.params.b("compositeOnOriginal"); + let t0 = time * (1.0 - len); + let t1 = t0 + len; + let (dx, dy) = (e.0 - s.0, e.1 - s.1); + let l2 = (dx * dx + dy * dy).max(1e-9); + map_xy(&mut b.img, |x, y, px| { + let (vx, vy) = (x as f64 + 0.5 - s.0, y as f64 + 0.5 - s.1); + let tc = ((vx * dx + vy * dy) / l2).clamp(t0, t1); + let (qx, qy) = (s.0 + dx * tc, s.1 + dy * tc); + let dist = ((x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2)).sqrt(); + let half = ((st + (et - st) * tc) * 0.5).max(0.25); + let tt = (dist / half) as f32; + let a = if len <= 0.0 { + 0.0 + } else if soft > 1e-3 { + 1.0 - smoothstep(1.0 - soft, 1.0, tt) + } else { + (half - dist + 0.5).clamp(0.0, 1.0) as f32 + }; + let cc = lerp3([inside[0], inside[1], inside[2]], [outside[0], outside[1], outside[2]], smoothstep(0.0, 1.0, tt)); + let g = [cc[0] * a, cc[1] * a, cc[2] * a, a]; + if composite { over(px, g) } else { g } + }); + b +} + +// ---------------------------------------------------------------- Radio Waves + +fn radio_waves(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = b.to_px(ctx.params.v2("producerPoint")); + let sides = ctx.params.f("sides").round().clamp(3.0, 64.0); + let freq = ctx.params.f("frequency").max(0.01); + let vel = ctx.params.f("velocity") * b.scale; + let spin = ctx.params.f("spin").to_radians(); + let life = ctx.params.f("lifespan").max(0.01); + let color = ctx.params.color("color"); + let op = (ctx.params.f("opacity") / 100.0) as f32; + let fin = ctx.params.f("fadeInTime").max(0.0); + let fout = ctx.params.f("fadeOutTime").max(0.0); + let sw = ctx.params.f("startWidth") * b.scale; + let ew = ctx.params.f("endWidth") * b.scale; + let t = ctx.time.max(0.0); + let k_hi = (t * freq).floor() as i64; + let k_lo = (((t - life) * freq).floor() as i64).max(0).max(k_hi - 256); + // (radius, half width, rotation, fade) per live wave. + let waves: Vec<(f64, f64, f64, f32)> = (k_lo..=k_hi) + .filter_map(|k| { + let age = t - k as f64 / freq; + if !(0.0..life).contains(&age) { + return None; + } + let fi = if fin > 0.0 { (age / fin).min(1.0) } else { 1.0 }; + let fo = if fout > 0.0 { ((life - age) / fout).min(1.0) } else { 1.0 }; + Some((vel * age, ((sw + (ew - sw) * age / life) * 0.5).max(0.25), spin * age, (fi * fo) as f32)) + }) + .collect(); + let seg = 2.0 * PI / sides; + let circle = sides >= 64.0; + map_xy(&mut b.img, |x, y, px| { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let r = (dx * dx + dy * dy).sqrt(); + let th = dy.atan2(dx); + let mut a = 0.0f32; + for &(rad, hw, rot, fade) in &waves { + let rb = if circle { rad } else { rad * (PI / sides).cos() / (((th - rot).rem_euclid(seg)) - seg * 0.5).cos() }; + let d = (r - rb).abs(); + a = a.max((hw - d + 0.5).clamp(0.0, 1.0) as f32 * fade); + } + let a = a * op * color[3]; + over(px, [color[0] * a, color[1] * a, color[2] * a, a]) + }); + b +} + +// ---------------------------------------------------------------- Advanced Lightning + +type Seg = ((f64, f64), (f64, f64), f32); + +fn bolt(a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, turb: f64, fork: f64, decay: f32, rng: &mut Rng, out: &mut Vec) { + if out.len() > 20_000 { + return; + } + let (dx, dy) = (z.0 - a.0, z.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if depth == 0 || len < 1.0 { + out.push((a, z, inten)); + return; + } + let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9)); + let off = rng.s() * len * 0.22 * turb; + let m = ((a.0 + z.0) * 0.5 + nx * off, (a.1 + z.1) * 0.5 + ny * off); + bolt(a, m, depth - 1, inten, turb, fork, decay, rng, out); + bolt(m, z, depth - 1, inten, turb, fork, decay, rng, out); + if rng.f() < fork && inten > 0.05 { + let ang = rng.s() * 0.7; + let (s, c) = ang.sin_cos(); + let (fx, fy) = ((m.0 - a.0) * c - (m.1 - a.1) * s, (m.0 - a.0) * s + (m.1 - a.1) * c); + let k = 1.2; + bolt(m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), inten * (1.0 - decay), turb, fork, decay, rng, out); + } +} + +fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { + let kind = ctx.params.e("lightningType"); + let o = b.to_px(ctx.params.v2("origin")); + let d = b.to_px(ctx.params.v2("direction")); + let turb = ctx.params.f("turbulence").max(0.0); + let fork = (ctx.params.f("forking") / 100.0).clamp(0.0, 1.0); + let decay = ctx.params.f("decay").clamp(0.0, 1.0) as f32; + let depth = ctx.params.f("complexity").round().clamp(1.0, 12.0) as u32; + let state = ctx.params.f("conductivityState").floor() as i64 as u64; + let mut rng = Rng::new(state ^ ((ctx.seed as u64) << 32)); + let mut segs = Vec::new(); + let (h, w) = (b.img.height as f64, b.img.width as f64); + match kind { + 1 => { + let z = (o.0 + (d.0 - o.0) * 3.0, o.1 + (d.1 - o.1) * 3.0); + bolt(o, z, depth, 1.0, turb, fork, decay, &mut rng, &mut segs); + } + 2 => { + let len = ((d.0 - o.0).powi(2) + (d.1 - o.1).powi(2)).sqrt().max(w.min(h) * 0.25); + for _ in 0..4 { + let a = rng.f() * 2.0 * PI; + let z = (o.0 + a.cos() * len, o.1 + a.sin() * len); + bolt(o, z, depth, 1.0, turb, fork, decay, &mut rng, &mut segs); + } + } + 3 => bolt(o, (o.0, h), depth, 1.0, turb, fork, decay, &mut rng, &mut segs), + _ => bolt(o, d, depth, 1.0, turb, fork, decay, &mut rng, &mut segs), + } + segs +} + +fn advanced_lightning(ctx: &EffectCtx, mut b: Buf) -> Buf { + let core_r = (ctx.params.f("coreRadius") * b.scale).max(0.3); + let core_op = (ctx.params.f("coreOpacity") / 100.0) as f32; + let core_c = ctx.params.color("coreColor"); + let glow_r = ctx.params.f("glowRadius") * b.scale; + let glow_op = (ctx.params.f("glowOpacity") / 100.0) as f32; + let glow_c = ctx.params.color("glowColor"); + let composite = ctx.params.b("compositeOnOriginal"); + let segs = lightning_segments(ctx, &b); + let (w, h) = (b.img.width as usize, b.img.height as usize); + // Bucket segments per row (by bbox) so rows rasterise in parallel. + let mut buckets: Vec> = vec![Vec::new(); h]; + for (i, (a, z, _)) in segs.iter().enumerate() { + let y0 = ((a.1.min(z.1) - core_r - 1.0).floor().max(0.0)) as usize; + let y1 = ((a.1.max(z.1) + core_r + 1.0).ceil()).min(h as f64) as i64; + for y in y0..(y1.max(0) as usize).min(h) { + buckets[y].push(i); + } + } + let mut core = Plane::new(w, h); + core.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + let py = y as f64 + 0.5; + for &i in &buckets[y] { + let (a, z, inten) = segs[i]; + let x0 = ((a.0.min(z.0) - core_r - 1.0).floor().max(0.0)) as usize; + let x1 = ((a.0.max(z.0) + core_r + 1.0).ceil().max(0.0) as usize).min(w); + let (dx, dy) = (z.0 - a.0, z.1 - a.1); + let l2 = (dx * dx + dy * dy).max(1e-12); + for x in x0..x1 { + let px = x as f64 + 0.5; + let t = (((px - a.0) * dx + (py - a.1) * dy) / l2).clamp(0.0, 1.0); + let d = ((px - a.0 - dx * t).powi(2) + (py - a.1 - dy * t).powi(2)).sqrt(); + let v = (core_r - d + 0.5).clamp(0.0, 1.0) as f32 * inten; + if v > row[x] { + row[x] = v; + } + } + } + }); + let glow = if glow_r > 0.5 { gauss_plane(&core, glow_r / 3.0, glow_r / 3.0).map(|v| (v * 3.0).min(1.0)) } else { Plane::new(w, h) }; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let cv = core.data[i] * core_op; + let gv = glow.data[i] * glow_op; + let l = [core_c[0] * cv + glow_c[0] * gv, core_c[1] * cv + glow_c[1] * gv, core_c[2] * cv + glow_c[2] * gv]; + let la = cv.max(gv).min(1.0); + *px = if composite { add_light(*px, l, la) } else { [l[0], l[1], l[2], la] }; + }); + b +} + +// ---------------------------------------------------------------- CC Light Rays / Burst + +const RAY_STEPS: usize = 32; + +fn light_rays(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = b.to_px(ctx.params.v2("center")); + let k = (ctx.params.f("intensity") / 100.0 * 0.5).clamp(0.0, 0.95); + let gain = (ctx.params.f("intensity") / 100.0 * 2.0) as f32; + let rad = (ctx.params.f("radius") * b.scale).max(0.5); + let ws = (ctx.params.f("warpSoftness") / 50.0).max(0.0); + let square = ctx.params.e("shape") == 1; + let from_src = ctx.params.b("colorFromSource"); + let color = ctx.params.color("color"); + let mode = ctx.params.e("transferMode"); + let src = b.img.clone(); + let mask = |x: f64, y: f64| { + let (dx, dy) = ((x - c.0).abs(), (y - c.1).abs()); + let d = if square { dx.max(dy) } else { (dx * dx + dy * dy).sqrt() }; + 1.0 - smoothstep(rad as f32, (rad * (1.0 + ws)) as f32 + 1e-3, d as f32) + }; + map_xy(&mut b.img, |x, y, px| { + let (pxx, pyy) = (x as f64 + 0.5, y as f64 + 0.5); + let mut acc = [0.0f32; 4]; + for i in 0..RAY_STEPS { + let s = 1.0 - k * i as f64 / RAY_STEPS as f64; + let (qx, qy) = (c.0 + (pxx - c.0) * s, c.1 + (pyy - c.1) * s); + let m = mask(qx, qy); + if m <= 0.0 { + continue; + } + let v = src.sample_bilinear(qx, qy); + for j in 0..4 { + acc[j] += v[j] * m; + } + } + let mut ray = acc.map(|v| v / RAY_STEPS as f32 * gain); + if !from_src { + let (sc, _) = unpremul(ray); + let l = luminance(sc[0], sc[1], sc[2]) * ray[3]; + ray = [color[0] * l, color[1] * l, color[2] * l, ray[3]]; + } + let la = ray[3].min(1.0); + match mode { + 1 => [px[0].max(ray[0]), px[1].max(ray[1]), px[2].max(ray[2]), px[3].max(la)], + 2 => { + let s = |a: f32, b: f32| a + b - a * b; + [s(px[0], ray[0]), s(px[1], ray[1]), s(px[2], ray[2]), s(px[3], la)] + } + 3 => [ray[0], ray[1], ray[2], la], + _ => add_light(px, [ray[0], ray[1], ray[2]], la), + } + }); + b +} + +fn light_burst(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = b.to_px(ctx.params.v2("center")); + let inten = (ctx.params.f("intensity") / 100.0) as f32; + let k = (ctx.params.f("rayLength") / 100.0).clamp(0.0, 1.0); + let mode = ctx.params.e("burst"); + let set_color = ctx.params.b("setColor"); + let color = ctx.params.color("color"); + if k <= 0.0 && (inten - 1.0).abs() < 1e-6 && !set_color { + return b; + } + let src = b.img.clone(); + map_xy(&mut b.img, |x, y, _| { + let (pxx, pyy) = (x as f64 + 0.5, y as f64 + 0.5); + let mut acc = [0.0f32; 4]; + let mut wsum = 0.0f32; + let mut best = [0.0f32; 4]; + let mut best_l = -1.0f32; + for i in 0..RAY_STEPS { + let f = i as f64 / RAY_STEPS as f64; + let s = 1.0 - k * f; + let v = src.sample_bilinear(c.0 + (pxx - c.0) * s, c.1 + (pyy - c.1) * s); + match mode { + 0 => { + let l = luminance(v[0], v[1], v[2]) + v[3] * 1e-3; + if l > best_l { + best_l = l; + best = v; + } + } + 1 => { + let w = 1.0 - f as f32; + for j in 0..4 { + acc[j] += v[j] * w; + } + wsum += w; + } + _ => { + for j in 0..4 { + acc[j] += v[j]; + } + wsum += 1.0; + } + } + } + let mut o = if mode == 0 { best } else { acc.map(|v| v / wsum.max(1e-6)) }; + if set_color { + let (sc, a) = unpremul(o); + let l = luminance(sc[0], sc[1], sc[2]) * a; + o = [color[0] * l, color[1] * l, color[2] * l, a]; + } + [o[0] * inten, o[1] * inten, o[2] * inten, (o[3] * inten).clamp(0.0, 1.0)] + }); + b +} + +// ---------------------------------------------------------------- CC Light Sweep + +fn light_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = b.to_px(ctx.params.v2("center")); + let dir = ctx.params.f("direction").to_radians(); + let shape = ctx.params.e("shape"); + let half = (ctx.params.f("width") * b.scale * 0.5).max(0.5); + let sweep = (ctx.params.f("sweepIntensity") / 100.0) as f32; + let edge_i = (ctx.params.f("edgeIntensity") / 100.0) as f32; + let edge_t = ctx.params.f("edgeThickness") * b.scale; + let lc = ctx.params.color("lightColor"); + let mode = ctx.params.e("lightReceptionMode"); + let (nx, ny) = (dir.cos(), dir.sin()); + let alpha = Plane::alpha(&b.img); + let soft_a = if edge_t > 0.05 { gauss_plane(&alpha, edge_t * 0.5, edge_t * 0.5) } else { alpha }; + map_xy(&mut b.img, |x, y, px| { + let d = ((x as f64 + 0.5 - c.0) * nx + (y as f64 + 0.5 - c.1) * ny).abs(); + let band = match shape { + 0 => (1.0 - d / half).max(0.0) as f32, + 2 => (half - d + 0.5).clamp(0.0, 1.0) as f32, + _ => smoothstep(half as f32, 0.0, d as f32), + }; + if band <= 0.0 { + return if mode == 2 { [0.0; 4] } else { px }; + } + let (xi, yi) = (x as i64, y as i64); + let gx = soft_a.get_clamped(xi + 1, yi) - soft_a.get_clamped(xi - 1, yi); + let gy = soft_a.get_clamped(xi, yi + 1) - soft_a.get_clamped(xi, yi - 1); + let edge = ((gx * gx + gy * gy).sqrt() * 2.0).min(1.0); + let light = band * sweep + band * edge * edge_i; + let a = px[3]; + match mode { + 1 => { + let la = (band * sweep).min(1.0); + [px[0] + lc[0] * light, px[1] + lc[1] * light, px[2] + lc[2] * light, (a + la * (1.0 - a)).min(1.0)] + } + 2 => { + let la = (light * a).min(1.0); + [lc[0] * la, lc[1] * la, lc[2] * la, la] + } + _ => [px[0] + lc[0] * light * a, px[1] + lc[1] * light * a, px[2] + lc[2] * light * a, a], + } + }); + b +} + +pub fn specs() -> Vec { + let ang = || ParamUi::Angle; + vec![ + spec( + "ec.generate.cellpattern", + "Cell Pattern", + vec![ + p( + "cellPattern", + "Cell Pattern", + Value::Enum(0), + popup(&["Bubbles", "Crystals", "Plates", "Static Plates", "Crystallize", "Pillow", "Mixed Crystals", "Dots", "Tubular"]), + ), + p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + p("contrast", "Contrast", num(100.0), slider(0.0, 10000.0, 0.0, 400.0, 1)), + p("overflow", "Overflow", Value::Enum(0), popup(&["Clip", "Soft Clamp", "Wrap Back"])), + p("disperse", "Disperse", num(1.0), slider(0.0, 1.5, 0.0, 1.5, 2)), + p("size", "Size", num(60.0), slider(2.0, 4000.0, 2.0, 400.0, 1)), + p("offset", "Offset", pt(0.5, 0.5), ParamUi::Point), + p("evolution", "Evolution", num(0.0), ang()), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), + ], + cell_pattern, + ), + spec( + "ec.generate.ellipse", + "Ellipse", + vec![ + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("width", "Width", num(100.0), slider(0.0, 30000.0, 0.0, 1000.0, 1)), + p("height", "Height", num(100.0), slider(0.0, 30000.0, 0.0, 1000.0, 1)), + p("thickness", "Thickness", num(10.0), slider(0.0, 3000.0, 0.0, 100.0, 1)), + p("softness", "Softness", num(0.0), pct()), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("outsideColor", "Outside Color", col(0.0, 0.6, 1.0), ParamUi::Color), + p("compositeOnOriginal", "Composite On Original", Value::Bool(false), ParamUi::Checkbox), + ], + ellipse, + ), + spec( + "ec.generate.lensflare", + "Lens Flare", + vec![ + p("flareCenter", "Flare Center", pt(0.25, 0.25), ParamUi::Point), + p("flareBrightness", "Flare Brightness", num(100.0), slider(0.0, 300.0, 10.0, 300.0, 0)), + p("lensType", "Lens Type", Value::Enum(0), popup(&["50-300mm Zoom", "35mm Prime", "105mm Prime"])), + p("blendWithOriginal", "Blend With Original", num(0.0), pct()), + ], + lens_flare, + ), + spec( + "ec.generate.beam", + "Beam", + vec![ + p("startPoint", "Starting Point", pt(0.25, 0.5), ParamUi::Point), + p("endPoint", "Ending Point", pt(0.75, 0.5), ParamUi::Point), + p("length", "Length", num(25.0), pct()), + p("time", "Time", num(0.0), pct()), + p("startThickness", "Starting Thickness", num(8.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("endThickness", "Ending Thickness", num(8.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("softness", "Softness", num(20.0), pct()), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("outsideColor", "Outside Color", col(0.4, 0.4, 1.0), ParamUi::Color), + p("compositeOnOriginal", "Composite On Original", Value::Bool(true), ParamUi::Checkbox), + ], + beam, + ), + spec( + "ec.generate.radiowaves", + "Radio Waves", + vec![ + p("producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point), + p("sides", "Sides", num(64.0), slider(3.0, 64.0, 3.0, 64.0, 0)), + p("frequency", "Frequency", num(1.0), slider(0.01, 100.0, 0.01, 10.0, 2)), + p("velocity", "Velocity", num(100.0), slider(0.0, 10000.0, 0.0, 1000.0, 1)), + p("spin", "Spin", num(0.0), slider(-3600.0, 3600.0, -360.0, 360.0, 1)), + p("lifespan", "Lifespan (sec)", num(2.0), slider(0.01, 100.0, 0.01, 10.0, 2)), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + p("fadeInTime", "Fade-in Time", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("fadeOutTime", "Fade-out Time", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("startWidth", "Start Width", num(5.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("endWidth", "End Width", num(5.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + ], + radio_waves, + ), + spec( + "ec.generate.advancedlightning", + "Advanced Lightning", + vec![ + p("lightningType", "Lightning Type", Value::Enum(0), popup(&["Strike", "Direction", "Omni", "Vertical"])), + p("origin", "Origin", pt(0.25, 0.2), ParamUi::Point), + p("direction", "Direction", pt(0.75, 0.8), ParamUi::Point), + p("conductivityState", "Conductivity State", num(10.0), slider(0.0, 100000.0, 0.0, 100.0, 1)), + p("coreRadius", "Core Radius", num(3.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("coreOpacity", "Core Opacity", num(75.0), pct()), + p("coreColor", "Core Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("glowRadius", "Glow Radius", num(50.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + p("glowOpacity", "Glow Opacity", num(50.0), pct()), + p("glowColor", "Glow Color", col(0.25, 0.35, 1.0), ParamUi::Color), + p("turbulence", "Turbulence", num(1.0), slider(0.0, 10.0, 0.0, 4.0, 2)), + p("forking", "Forking", num(25.0), pct()), + p("decay", "Decay", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("complexity", "Complexity", num(6.0), slider(1.0, 12.0, 1.0, 12.0, 0)), + p("compositeOnOriginal", "Composite on Original", Value::Bool(true), ParamUi::Checkbox), + ], + advanced_lightning, + ), + spec( + "ec.generate.cclightrays", + "CC Light Rays", + vec![ + p("intensity", "Intensity", num(100.0), slider(0.0, 600.0, 0.0, 200.0, 1)), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("radius", "Radius", num(40.0), slider(0.0, 4000.0, 0.0, 400.0, 1)), + p("warpSoftness", "Warp Softness", num(50.0), slider(0.0, 400.0, 0.0, 100.0, 1)), + p("shape", "Shape", Value::Enum(0), popup(&["Round", "Square"])), + p("colorFromSource", "Color from Source", Value::Bool(true), ParamUi::Checkbox), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("transferMode", "Transfer Mode", Value::Enum(0), popup(&["Add", "Lighten", "Screen", "None"])), + ], + light_rays, + ), + spec( + "ec.generate.cclightburst", + "CC Light Burst 2.5", + vec![ + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("intensity", "Intensity", num(100.0), slider(0.0, 600.0, 0.0, 200.0, 1)), + p("rayLength", "Ray Length", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("burst", "Burst", Value::Enum(0), popup(&["Straight", "Fade", "Center"])), + p("setColor", "Set Color", Value::Bool(false), ParamUi::Checkbox), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + ], + light_burst, + ), + spec( + "ec.generate.cclightsweep", + "CC Light Sweep", + vec![ + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("direction", "Direction", num(-30.0), ang()), + p("shape", "Shape", Value::Enum(1), popup(&["Linear", "Smooth", "Sharp"])), + p("width", "Width", num(50.0), slider(0.0, 4000.0, 0.0, 400.0, 1)), + p("sweepIntensity", "Sweep Intensity", num(50.0), slider(0.0, 500.0, 0.0, 200.0, 1)), + p("edgeIntensity", "Edge Intensity", num(100.0), slider(0.0, 500.0, 0.0, 200.0, 1)), + p("edgeThickness", "Edge Thickness", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 1)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightReceptionMode", "Light Reception", Value::Enum(0), popup(&["Add", "Composite", "Cutout"])), + ], + light_sweep, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Params; + use effectcraft_raster::Image; + + fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + let ls = [img.width as f64, img.height as f64]; + for p in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { + params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) + } + + #[test] + fn cell_pattern_is_deterministic_and_seeded() { + let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); + let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30)); + assert_eq!(a.img, b2.img); + let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("randomSeed", num(5.0))], Image::new(40, 30)); + assert_ne!(a.img, c.img); + assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0]))); + } + + #[test] + fn ellipse_ring_coverage() { + let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64)); + // Ring passes through (32 + 20, 32); centre is empty. + assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32)); + assert!(o.img.get(32, 32)[3] < 1e-3); + assert!(o.img.get(2, 2)[3] < 1e-3); + } + + #[test] + fn lightning_is_deterministic_per_conductivity() { + let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + assert_eq!(a.img, b2.img); + assert!(a.img.data.iter().any(|p| p[3] > 0.5)); + let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48)); + assert_ne!(a.img, c.img); + } + + #[test] + fn beam_draws_between_points() { + let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32)); + assert!(o.img.get(32, 16)[3] > 0.99); + assert!(o.img.get(32, 2)[3] < 1e-3); + assert!(o.img.get(2, 16)[3] < 1e-3); + } + + #[test] + fn lens_flare_brightest_at_center() { + let o = run("ec.generate.lensflare", &[], Image::new(64, 64)); + let c = o.img.get(16, 16)[0]; + assert!(c > o.img.get(60, 4)[0]); + assert!(c > 0.5); + } +} diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs new file mode 100644 index 00000000..106a9d2f --- /dev/null +++ b/crates/effects/src/keying.rs @@ -0,0 +1,712 @@ +//! Keying effects: colour, luma, difference, range and screen keyers plus spill suppression and +//! matte clean-up. Written from public behaviour descriptions and the standard colour-difference / +//! blue-screen matting literature (Vlahos; Smith & Blinn, "Blue Screen Matting"). + +use effectcraft_color::{luminance, rgb_to_hsl, srgb_to_linear}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, gaussian_blur}; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, morph_frac, premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, category: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category, params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +fn view_popup() -> ParamUi { + popup(&["Final Output", "Source Only", "Matte Only"]) +} + +/// Apply a keyed matte `m` (1 = keep) to the image according to the view mode. +fn apply_matte(img: &mut Image, m: &Plane, view: u32) { + if view == 1 { + return; + } + img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, &k)| { + let k = k.clamp(0.0, 1.0); + if view == 2 { + let a = px[3] * k; + *px = [a, a, a, 1.0]; + } else { + for c in px.iter_mut() { + *c *= k; + } + } + }); +} + +/// Edge thin (positive erodes, negative grows) and feather (Gaussian) of a matte, in buffer pixels. +fn thin_feather(m: Plane, thin: f64, feather: f64) -> Plane { + let m = if thin > 0.0 { + morph_frac(&m, thin, false) + } else if thin < 0.0 { + morph_frac(&m, -thin, true) + } else { + m + }; + if feather > 0.0 { gauss_plane(&m, feather * 0.5, feather * 0.5) } else { m } +} + +/// Plane of `f(straight colour, alpha)` over the image. +fn matte_of(img: &Image, f: impl Fn([f32; 3], f32) -> f32 + Sync) -> Plane { + Plane::from_image(img, |px| { + let (c, a) = unpremul(px); + f(c, a) + }) +} + +/// Index of the largest component and the other two. +fn primary(c: [f32; 4]) -> (usize, usize, usize) { + if c[1] >= c[0] && c[1] >= c[2] { + (1, 0, 2) + } else if c[2] >= c[0] && c[2] >= c[1] { + (2, 0, 1) + } else { + (0, 1, 2) + } +} + +// ---- Linear Color Key ---- + +fn hue_dist(a: [f32; 3], b: [f32; 3]) -> f32 { + let (ha, sa, _) = rgb_to_hsl(a[0], a[1], a[2]); + let (hb, _, _) = rgb_to_hsl(b[0], b[1], b[2]); + if sa < 0.02 { + return 1.0; + } + let d = (ha - hb).abs(); + d.min(1.0 - d) * 2.0 +} + +fn chroma(c: [f32; 3]) -> [f32; 2] { + let s = c[0] + c[1] + c[2]; + if s <= 1e-6 { [1.0 / 3.0, 1.0 / 3.0] } else { [c[0] / s, c[1] / s] } +} + +fn linear_color_key(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = ctx.params.color("keyColor"); + let key = [k[0], k[1], k[2]]; + let mode = ctx.params.e("matchColors"); + let tol = ctx.params.f("tolerance") as f32 / 100.0; + let soft = ctx.params.f("softness") as f32 / 100.0; + let keep = ctx.params.e("keyOperation") == 1; + let kc = chroma(key); + let m = matte_of(&b.img, |c, _| { + let d = match mode { + 1 => hue_dist(c, key), + 2 => { + let pc = chroma(c); + ((pc[0] - kc[0]).powi(2) + (pc[1] - kc[1]).powi(2)).sqrt() / std::f32::consts::SQRT_2 + } + _ => ((c[0] - key[0]).powi(2) + (c[1] - key[1]).powi(2) + (c[2] - key[2]).powi(2)).sqrt() / 3f32.sqrt(), + }; + let v = if soft > 1e-6 { + ((d - tol) / soft).clamp(0.0, 1.0) + } else if d > tol { + 1.0 + } else { + 0.0 + }; + if keep { 1.0 - v } else { v } + }); + apply_matte(&mut b.img, &m, ctx.params.e("view")); + b +} + +// ---- Color Key (obsolete) ---- + +fn color_key(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = ctx.params.color("keyColor"); + let tol = ctx.params.f("colorTolerance") as f32 / 255.0; + let m = matte_of(&b.img, |c, _| { + let d = (c[0] - k[0]).abs().max((c[1] - k[1]).abs()).max((c[2] - k[2]).abs()); + if d <= tol { 0.0 } else { 1.0 } + }); + let m = thin_feather(m, ctx.params.f("edgeThin") * b.scale, ctx.params.f("edgeFeather") * b.scale); + apply_matte(&mut b.img, &m, 0); + b +} + +// ---- Luma Key ---- + +fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf { + let kind = ctx.params.e("keyType"); + let thr = ctx.params.f("threshold") as f32 / 255.0; + let tol = ctx.params.f("tolerance") as f32 / 255.0; + let m = matte_of(&b.img, |c, _| { + let l = luminance(c[0], c[1], c[2]); + match kind { + 0 => { + if tol > 0.0 { + ((thr + tol - l) / tol).clamp(0.0, 1.0) + } else if l > thr { + 0.0 + } else { + 1.0 + } + } + 2 => { + if (l - thr).abs() <= tol { + 0.0 + } else { + 1.0 + } + } + 3 => { + if (l - thr).abs() <= tol { + 1.0 + } else { + 0.0 + } + } + _ => { + if tol > 0.0 { + ((l - (thr - tol)) / tol).clamp(0.0, 1.0) + } else if l < thr { + 0.0 + } else { + 1.0 + } + } + } + }); + let m = thin_feather(m, ctx.params.f("edgeThin") * b.scale, ctx.params.f("edgeFeather") * b.scale); + apply_matte(&mut b.img, &m, 0); + b +} + +// ---- Color Difference Key ---- + +fn color_difference_key(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = ctx.params.color("keyColor"); + let (pi, o1, o2) = primary(k); + let kd = (k[pi] - k[o1].max(k[o2])).max(1e-3); + let black = ctx.params.f("blackLevel") as f32 / 255.0; + let white = ctx.params.f("whiteLevel") as f32 / 255.0; + let gamma = ctx.params.f("gamma").max(0.01) as f32; + let m = matte_of(&b.img, |c, _| { + let diff = c[pi] - c[o1].max(c[o2]); + let a = 1.0 - (diff / kd).clamp(0.0, 1.0); + let v = ((a - black) / (white - black).max(1e-4)).clamp(0.0, 1.0); + v.powf(1.0 / gamma) + }); + apply_matte(&mut b.img, &m, ctx.params.e("view")); + b +} + +// ---- Color Range ---- + +fn to_lab(c: [f32; 3]) -> [f32; 3] { + let l = c.map(|v| srgb_to_linear(v.clamp(0.0, 1.0))); + let x = (0.4124 * l[0] + 0.3576 * l[1] + 0.1805 * l[2]) / 0.95047; + let y = 0.2126 * l[0] + 0.7152 * l[1] + 0.0722 * l[2]; + let z = (0.0193 * l[0] + 0.1192 * l[1] + 0.9505 * l[2]) / 1.08883; + let f = |t: f32| if t > 0.008_856 { t.cbrt() } else { 7.787 * t + 16.0 / 116.0 }; + let (fx, fy, fz) = (f(x), f(y), f(z)); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +/// Colour in the chosen space as three 0..255 components. +fn range_components(space: u32, c: [f32; 3]) -> [f32; 3] { + match space { + 1 => { + let y = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; + let u = -0.147_13 * c[0] - 0.288_86 * c[1] + 0.436 * c[2]; + let v = 0.615 * c[0] - 0.514_99 * c[1] - 0.100_01 * c[2]; + [y * 255.0, u * 255.0 + 128.0, v * 255.0 + 128.0] + } + 2 => c.map(|v| v * 255.0), + _ => { + let lab = to_lab(c); + [lab[0] * 2.55, lab[1] + 128.0, lab[2] + 128.0] + } + } +} + +fn color_range(ctx: &EffectCtx, mut b: Buf) -> Buf { + let space = ctx.params.e("colorSpace"); + let lo = ["minL", "minA", "minB"].map(|i| ctx.params.f(i) as f32); + let hi = ["maxL", "maxA", "maxB"].map(|i| ctx.params.f(i) as f32); + let fuzz = ctx.params.f("fuzziness") as f32; + let m = matte_of(&b.img, |c, _| { + let v = range_components(space, c); + let mut out = 0.0f32; + for i in 0..3 { + out = out.max(lo[i] - v[i]).max(v[i] - hi[i]); + } + if out <= 0.0 { + 0.0 + } else if fuzz > 0.0 { + (out / fuzz).clamp(0.0, 1.0) + } else { + 1.0 + } + }); + apply_matte(&mut b.img, &m, 0); + b +} + +// ---- Extract ---- + +fn extract(ctx: &EffectCtx, mut b: Buf) -> Buf { + let ch = ctx.params.e("channel"); + let bp = ctx.params.f("blackPoint") as f32; + let wp = ctx.params.f("whitePoint") as f32; + let bs = ctx.params.f("blackSoftness") as f32; + let ws = ctx.params.f("whiteSoftness") as f32; + let inv = ctx.params.b("invert"); + let m = matte_of(&b.img, |c, a| { + let v = 255.0 + * match ch { + 1 => c[0], + 2 => c[1], + 3 => c[2], + 4 => a, + _ => luminance(c[0], c[1], c[2]), + }; + let mut k = 1.0f32; + if v < bp { + k = if bs > 0.0 { (1.0 - (bp - v) / bs).clamp(0.0, 1.0) } else { 0.0 }; + } else if v > wp { + k = if ws > 0.0 { (1.0 - (v - wp) / ws).clamp(0.0, 1.0) } else { 0.0 }; + } + if inv { 1.0 - k } else { k } + }); + apply_matte(&mut b.img, &m, 0); + b +} + +// ---- Screen Key (Keylight-style) ---- + +/// Secondary-channel estimate: `balance` weights the larger of the two secondaries. +#[inline] +fn secondary(c: [f32; 3], o1: usize, o2: usize, bal: f32) -> f32 { + let (hi, lo) = if c[o1] > c[o2] { (c[o1], c[o2]) } else { (c[o2], c[o1]) }; + bal * hi + (1.0 - bal) * lo +} + +fn screen_key(ctx: &EffectCtx, mut b: Buf) -> Buf { + let view = ctx.params.e("view"); + if view == 3 { + return b; + } + let sc = ctx.params.color("screenColor"); + let screen = [sc[0], sc[1], sc[2]]; + let (pi, o1, o2) = primary(sc); + let gain = ctx.params.f("screenGain") as f32 / 100.0; + let bal = ctx.params.f("screenBalance") as f32 / 100.0; + let despill = ctx.params.f("despill") as f32 / 100.0; + let cb = ctx.params.f("clipBlack") as f32 / 100.0; + let cw = ctx.params.f("clipWhite") as f32 / 100.0; + let preblur = ctx.params.f("screenPreblur") * b.scale; + let sg = ctx.params.f("screenShrinkGrow") * b.scale; + let soft = ctx.params.f("screenSoftness") * b.scale; + let source_edges = ctx.params.e("edgeColor") == 1; + let sd = (screen[pi] - secondary(screen, o1, o2, bal)).max(1e-3); + + let blurred; + let key_src = if preblur > 0.0 { + blurred = gaussian_blur(&b.img, preblur * 0.5, preblur * 0.5, true); + &blurred + } else { + &b.img + }; + let mut m = matte_of(key_src, |c, _| { + let d = c[pi] - secondary(c, o1, o2, bal); + let t = (d / sd * gain).clamp(0.0, 1.0); + ((1.0 - t - cb) / (cw - cb).max(1e-3)).clamp(0.0, 1.0) + }); + if sg < 0.0 { + m = morph_frac(&m, -sg, false); + } else if sg > 0.0 { + m = morph_frac(&m, sg, true); + } + if soft > 0.0 { + m = gauss_plane(&m, soft * 0.5, soft * 0.5); + } + b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, &k)| { + let k = k.clamp(0.0, 1.0); + let (c, a0) = unpremul(*px); + let alpha = a0 * k; + match view { + 1 => *px = [alpha, alpha, alpha, 1.0], + 2 => { + let v = if alpha <= 1e-3 { + 0.0 + } else if alpha >= 0.999 { + 1.0 + } else { + 0.5 + }; + *px = [v, v, v, 1.0]; + } + _ => { + let mut fg = if source_edges || k <= 1e-4 { + c + } else { + let s = 1.0 - k; + [0, 1, 2].map(|i| ((c[i] - s * screen[i]).max(0.0) / k).min(c[i].max(1.0))) + }; + let spill = (fg[pi] - secondary(fg, o1, o2, bal)).max(0.0); + fg[pi] -= spill * despill; + *px = premul(fg, alpha); + } + } + }); + b +} + +// ---- Spill suppression ---- + +fn spill_suppressor(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = ctx.params.color("colorToSuppress"); + let (pi, o1, o2) = primary(k); + let amt = ctx.params.f("suppression") as f32 / 100.0; + b.img.map_straight(|mut c| { + let spill = (c[pi] - c[o1].max(c[o2])).max(0.0); + c[pi] -= spill * amt; + c + }); + b +} + +const LUMA_W: [f32; 3] = [0.2126, 0.7152, 0.0722]; + +fn advanced_spill(ctx: &EffectCtx, mut b: Buf) -> Buf { + let ultra = ctx.params.e("method") == 1; + let pi = if ultra { + primary(ctx.params.color("keyColor")).0 + } else { + // Dominant key primary: the channel with the largest total excess over the other two. + let ex = b + .img + .data + .par_iter() + .map(|px| { + let (c, a) = unpremul(*px); + [(c[0] - c[1].max(c[2])).max(0.0) * a, (c[1] - c[0].max(c[2])).max(0.0) * a, (c[2] - c[0].max(c[1])).max(0.0) * a] + }) + .reduce(|| [0.0; 3], |x, y| [x[0] + y[0], x[1] + y[1], x[2] + y[2]]); + if ex[1] >= ex[0] && ex[1] >= ex[2] { + 1 + } else if ex[2] >= ex[0] { + 2 + } else { + 0 + } + }; + let (o1, o2) = match pi { + 0 => (1, 2), + 1 => (0, 2), + _ => (0, 1), + }; + let amt = ctx.params.f("suppression") as f32 / 100.0; + let range = ctx.params.f("spillRange") as f32 / 100.0; + let luma = ctx.params.f("lumaCorrection") as f32 / 100.0; + b.img.map_straight(|mut c| { + let mx = c[o1].max(c[o2]); + let avg = 0.5 * (c[o1] + c[o2]); + let limit = mx + (avg - mx) * range; + let spill = (c[pi] - limit).max(0.0) * amt; + c[pi] -= spill; + let back = spill * LUMA_W[pi] * luma; + c.map(|v| v + back) + }); + b +} + +// ---- Key Cleaner (approximation) ---- + +fn key_cleaner(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = (ctx.params.f("additionalEdgeRadius") * b.scale).max(0.0); + let contrast = ctx.params.f("alphaContrast") as f32 / 100.0; + let strength = ctx.params.f("strength") as f32 / 100.0; + let mut a = Plane::alpha(&b.img); + if ctx.params.b("reduceChatter") { + a = gauss_plane(&a, 1.0, 1.0).map(|v| ((v - 0.5) * 1.5 + 0.5).clamp(0.0, 1.0)); + } + let est = if r > 0.0 && strength > 0.0 { Some(gaussian_blur(&b.img, r * 0.5, r * 0.5, true)) } else { None }; + b.img.data.par_iter_mut().zip(a.data.par_iter()).enumerate().for_each(|(i, (px, &na))| { + let (mut c, a0) = unpremul(*px); + if let Some(e) = &est + && a0 > 1e-4 + && a0 < 0.999 + { + let (ec, ea) = unpremul(e.data[i]); + if ea > 1e-4 { + let t = strength * (1.0 - a0); + c = [0, 1, 2].map(|k| c[k] + (ec[k] - c[k]) * t); + } + } + let na = ((na - 0.5) * contrast + 0.5).clamp(0.0, 1.0); + let na = if a0 <= 1e-6 { 0.0 } else { na }; + *px = premul(c, na); + }); + b +} + +pub fn specs() -> Vec { + let thin = || slider(-5.0, 5.0, -5.0, 5.0, 1); + let feather = || slider(0.0, 100.0, 0.0, 20.0, 1); + let b255 = || slider(0.0, 255.0, 0.0, 255.0, 0); + vec![ + spec( + "ec.key.linearcolor", + "Linear Color Key", + "Keying", + vec![ + p("keyColor", "Key Color", col(0.0, 0.0, 1.0), ParamUi::Color), + p("matchColors", "Match colors", Value::Enum(0), popup(&["Using RGB", "Using Hue", "Using Chroma"])), + p("tolerance", "Matching Tolerance", num(10.0), pct()), + p("softness", "Matching Softness", num(10.0), pct()), + p("keyOperation", "Key Operation", Value::Enum(0), popup(&["Key Colors", "Keep Colors"])), + p("view", "View", Value::Enum(0), view_popup()), + ], + linear_color_key, + ), + spec( + "ec.key.colorkey", + "Color Key", + "Obsolete", + vec![ + p("keyColor", "Key Color", col(0.0, 0.0, 1.0), ParamUi::Color), + p("colorTolerance", "Color Tolerance", num(0.0), b255()), + p("edgeThin", "Edge Thin", num(0.0), thin()), + p("edgeFeather", "Edge Feather", num(0.0), feather()), + ], + color_key, + ), + spec( + "ec.key.luma", + "Luma Key", + "Keying", + vec![ + p("keyType", "Key Type", Value::Enum(1), popup(&["Key Out Brighter", "Key Out Darker", "Key Out Similar", "Key Out Dissimilar"])), + p("threshold", "Threshold", num(0.0), b255()), + p("tolerance", "Tolerance", num(0.0), b255()), + p("edgeThin", "Edge Thin", num(0.0), thin()), + p("edgeFeather", "Edge Feather", num(0.0), feather()), + ], + luma_key, + ), + spec( + "ec.key.colordifference", + "Color Difference Key", + "Keying", + vec![ + p("keyColor", "Key Color", col(0.0, 0.0, 1.0), ParamUi::Color), + p("blackLevel", "Matte Black", num(0.0), b255()), + p("whiteLevel", "Matte White", num(255.0), b255()), + p("gamma", "Matte Gamma", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 2)), + p("view", "View", Value::Enum(0), view_popup()), + ], + color_difference_key, + ), + spec( + "ec.key.colorrange", + "Color Range", + "Keying", + vec![ + p("colorSpace", "Color Space", Value::Enum(0), popup(&["Lab", "YUV", "RGB"])), + p("fuzziness", "Fuzziness", num(0.0), b255()), + p("minL", "Min (L, Y, R)", num(0.0), b255()), + p("maxL", "Max (L, Y, R)", num(255.0), b255()), + p("minA", "Min (a, U, G)", num(0.0), b255()), + p("maxA", "Max (a, U, G)", num(100.0), b255()), + p("minB", "Min (b, V, B)", num(128.0), b255()), + p("maxB", "Max (b, V, B)", num(255.0), b255()), + ], + color_range, + ), + spec( + "ec.key.extract", + "Extract", + "Keying", + vec![ + p("channel", "Channel", Value::Enum(0), popup(&["Luminance", "Red", "Green", "Blue", "Alpha"])), + p("blackPoint", "Black Point", num(0.0), b255()), + p("whitePoint", "White Point", num(255.0), b255()), + p("blackSoftness", "Black Softness", num(0.0), b255()), + p("whiteSoftness", "White Softness", num(0.0), b255()), + p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + ], + extract, + ), + spec( + "ec.key.screen", + "Screen Key", + "Keying", + vec![ + p("view", "View", Value::Enum(0), popup(&["Final Result", "Screen Matte", "Status", "Source"])), + p("screenColor", "Screen Colour", col(0.15, 0.75, 0.25), ParamUi::Color), + p("screenGain", "Screen Gain", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("screenBalance", "Screen Balance", num(50.0), pct()), + p("despill", "Despill", num(100.0), pct()), + p("clipBlack", "Clip Black", num(0.0), pct()), + p("clipWhite", "Clip White", num(100.0), pct()), + p("screenPreblur", "Screen Pre-blur", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 1)), + p("screenShrinkGrow", "Screen Shrink/Grow", num(0.0), slider(-50.0, 50.0, -10.0, 10.0, 1)), + p("screenSoftness", "Screen Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("edgeColor", "Edge Colour", Value::Enum(0), popup(&["Screen Subtract", "Source"])), + ], + screen_key, + ), + spec( + "ec.key.spill", + "Spill Suppressor", + "Keying", + vec![ + p("colorToSuppress", "Color To Suppress", col(0.0, 0.0, 1.0), ParamUi::Color), + p("suppression", "Suppression", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 0)), + ], + spill_suppressor, + ), + spec( + "ec.key.advancedspill", + "Advanced Spill Suppressor", + "Keying", + vec![ + p("method", "Method", Value::Enum(0), popup(&["Standard", "Ultra"])), + p("suppression", "Suppression", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 0)), + p("keyColor", "Key Color", col(0.0, 1.0, 0.0), ParamUi::Color), + p("spillRange", "Spill Range", num(50.0), pct()), + p("lumaCorrection", "Luma Correction", num(0.0), pct()), + ], + advanced_spill, + ), + spec( + "ec.key.keycleaner", + "Key Cleaner", + "Keying", + vec![ + p("additionalEdgeRadius", "Additional Edge Radius", num(3.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("reduceChatter", "Reduce Chatter", Value::Bool(false), ParamUi::Checkbox), + p("alphaContrast", "Alpha Contrast", num(100.0), slider(100.0, 500.0, 100.0, 300.0, 0)), + p("strength", "Strength", num(100.0), pct()), + ], + key_cleaner, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Params, apply, find}; + + /// Left half green screen, right half red foreground. + fn green_screen() -> Image { + let mut img = Image::new(16, 8); + for y in 0..8 { + for x in 0..16 { + img.set(x, y, if x < 8 { [0.1, 0.8, 0.15, 1.0] } else { [0.9, 0.2, 0.1, 1.0] }); + } + } + img + } + + fn run(id: &str, over: &[(&str, Value)], img: Image) -> Image { + let s = find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in over { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img + } + + fn green() -> Value { + Value::Color([0.1, 0.8, 0.15, 1.0]) + } + + #[test] + fn linear_color_key_removes_green() { + let out = run("ec.key.linearcolor", &[("keyColor", green())], green_screen()); + assert!(out.get(2, 2)[3] < 0.01); + assert!(out.get(12, 2)[3] > 0.99); + } + + #[test] + fn color_difference_key_removes_green() { + let out = run("ec.key.colordifference", &[("keyColor", green())], green_screen()); + assert!(out.get(2, 2)[3] < 0.01, "{:?}", out.get(2, 2)); + assert!(out.get(12, 2)[3] > 0.99); + } + + #[test] + fn screen_key_removes_green_and_despills() { + let mut img = green_screen(); + img.set(12, 4, [0.6, 0.65, 0.5, 1.0]); + let out = run("ec.key.screen", &[("screenColor", green())], img); + assert!(out.get(2, 2)[3] < 0.01); + assert!(out.get(12, 2)[3] > 0.99); + let px = out.get(12, 4); + let (c, a) = unpremul(px); + assert!(a > 0.5 && a < 1.0, "{a}"); + assert!(c[1] <= 0.5 * (c[0] + c[2]) + 1e-3, "{c:?}"); + } + + #[test] + fn screen_key_matte_view_is_opaque() { + let out = run("ec.key.screen", &[("screenColor", green()), ("view", Value::Enum(1))], green_screen()); + assert!(out.data.iter().all(|p| p[3] == 1.0)); + assert!(out.get(2, 2)[0] < 0.01 && out.get(12, 2)[0] > 0.99); + } + + #[test] + fn color_range_keys_green_in_lab() { + let out = run("ec.key.colorrange", &[], green_screen()); + assert!(out.get(2, 2)[3] < 0.01); + assert!(out.get(12, 2)[3] > 0.99); + } + + #[test] + fn luma_key_darker() { + let mut img = Image::new(4, 1); + img.set(0, 0, [0.1, 0.1, 0.1, 1.0]); + img.set(1, 0, [0.9, 0.9, 0.9, 1.0]); + let out = run("ec.key.luma", &[("threshold", num(128.0))], img); + assert_eq!(out.get(0, 0)[3], 0.0); + assert_eq!(out.get(1, 0)[3], 1.0); + } + + #[test] + fn extract_keeps_range() { + let mut img = Image::new(3, 1); + img.set(0, 0, [0.05, 0.05, 0.05, 1.0]); + img.set(1, 0, [0.5, 0.5, 0.5, 1.0]); + img.set(2, 0, [1.0, 1.0, 1.0, 1.0]); + let out = run("ec.key.extract", &[("blackPoint", num(50.0)), ("whitePoint", num(200.0))], img); + assert_eq!(out.get(0, 0)[3], 0.0); + assert_eq!(out.get(1, 0)[3], 1.0); + assert_eq!(out.get(2, 0)[3], 0.0); + } + + #[test] + fn spill_suppressor_limits_green() { + let img = Image::filled(2, 2, [0.3, 0.8, 0.4, 1.0]); + let out = run("ec.key.spill", &[("colorToSuppress", green())], img); + let px = out.get(0, 0); + assert!((px[1] - 0.4).abs() < 1e-5 && px[0] == 0.3 && px[2] == 0.4); + } + + #[test] + fn advanced_spill_standard_detects_green() { + let img = Image::filled(2, 2, [0.3, 0.8, 0.3, 1.0]); + let out = run("ec.key.advancedspill", &[], img); + assert!((out.get(0, 0)[1] - 0.3).abs() < 1e-5); + } + + #[test] + fn key_cleaner_defaults_keep_opaque_pixels() { + let img = green_screen(); + let out = run("ec.key.keycleaner", &[], img.clone()); + assert_eq!(out.get(12, 2), img.get(12, 2)); + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index b3548294..7dc27fa2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -6,11 +6,24 @@ //! [`instantiate`]), so parameters animate, take expressions and are addressable like any other //! property. +mod blur2; +mod channel; +mod color2; mod color_fx; mod controls; mod distort; +mod distort2; mod generate; +mod generate2; +mod keying; +mod matte; mod misc; +mod noise; +mod perspective; +mod stylize2; +mod transition; +pub mod util; +mod utility; use std::collections::HashMap; use std::sync::OnceLock; @@ -77,6 +90,13 @@ impl Params { pub fn e(&self, id: &str) -> u32 { self.values.get(id).map(Value::as_enum).unwrap_or(0) } + /// String parameter (`Value::Str`), empty when missing or of another kind. + pub fn s(&self, id: &str) -> &str { + match self.values.get(id) { + Some(Value::Str(s)) => s, + _ => "", + } + } } /// A layer image in flight: layer-space point `p` sits at pixel `p * scale + offset`. @@ -155,6 +175,18 @@ pub fn registry() -> &'static [EffectSpec] { v.extend(generate::specs()); v.extend(distort::specs()); v.extend(controls::specs()); + v.extend(keying::specs()); + v.extend(matte::specs()); + v.extend(channel::specs()); + v.extend(blur2::specs()); + v.extend(color2::specs()); + v.extend(stylize2::specs()); + v.extend(noise::specs()); + v.extend(generate2::specs()); + v.extend(distort2::specs()); + v.extend(perspective::specs()); + v.extend(transition::specs()); + v.extend(utility::specs()); v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name))); v }) diff --git a/crates/effects/src/matte.rs b/crates/effects/src/matte.rs new file mode 100644 index 00000000..05dcd451 --- /dev/null +++ b/crates/effects/src/matte.rs @@ -0,0 +1,240 @@ +//! Matte effects: chokers (grey-scale morphology and blur/threshold stages) and edge-aware matte +//! refinement (guided filter, He et al. 2010). + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::gaussian_blur; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, guided_filter, morph_frac, morph_plane, premul, set_alpha, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Matte", params, render, gpu: false, float: true } +} + +fn matte_view(b: &mut Buf) { + b.img.data.par_iter_mut().for_each(|px| { + let a = px[3].clamp(0.0, 1.0); + *px = [a, a, a, 1.0]; + }); +} + +fn simple_choker(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = ctx.params.f("chokeMatte") * b.scale; + if c.abs() > 1e-6 { + if c < 0.0 && !ctx.adjustment { + b.pad((-c).ceil() as u32 + 1); + } + let a = Plane::alpha(&b.img); + let na = if c > 0.0 { morph_frac(&a, c, false) } else { morph_frac(&a, -c, true) }; + set_alpha(&mut b.img, &na); + } + if ctx.params.e("view") == 1 { + matte_view(&mut b); + } + b +} + +/// One Matte Choker stage: blur by geometric softness, then a threshold ramp. +fn choke_stage(a: Plane, geo: f64, choke: f32, gray: f32) -> Plane { + let a = if geo > 0.0 { gauss_plane(&a, geo * 0.5, geo * 0.5) } else { a }; + let t = 0.5 + choke / 255.0 * 0.5; + let w = gray.max(1e-3); + if (t - 0.5).abs() < 1e-6 && (w - 1.0).abs() < 1e-6 { + return a; + } + a.map(|v| ((v - t) / w + 0.5).clamp(0.0, 1.0)) +} + +fn matte_choker(ctx: &EffectCtx, mut b: Buf) -> Buf { + let g1 = ctx.params.f("geometricSoftness1") * b.scale; + let c1 = ctx.params.f("choke1") as f32; + let s1 = ctx.params.f("grayLevelSoftness1") as f32 / 100.0; + let g2 = ctx.params.f("geometricSoftness2") * b.scale; + let c2 = ctx.params.f("choke2") as f32; + let s2 = ctx.params.f("grayLevelSoftness2") as f32 / 100.0; + let it = ctx.params.f("iterations").round().clamp(1.0, 100.0) as usize; + if (c1 < 0.0 || c2 < 0.0 || g1 > 0.0 || g2 > 0.0) && !ctx.adjustment { + b.pad(((g1 + g2) * 1.5 * it as f64).ceil() as u32 + 1); + } + let mut a = Plane::alpha(&b.img); + for _ in 0..it { + a = choke_stage(a, g1, c1, s1); + a = choke_stage(a, g2, c2, s2); + } + set_alpha(&mut b.img, &a); + b +} + +fn refine(ctx: &EffectCtx, mut b: Buf, hard: bool) -> Buf { + let r = (ctx.params.f("radius") * b.scale).round().max(1.0) as usize; + let smooth = ctx.params.f("smooth") as f32 / 100.0; + let feather = ctx.params.f("feather") / 100.0 * r as f64 * 0.5; + let choke = ctx.params.f("choke") as f32 / 100.0 * 0.5; + let decon = ctx.params.b("decontaminate"); + let decon_amt = ctx.params.f("decontaminationAmount") as f32 / 100.0; + let invert = ctx.params.b("invert"); + let contrast = if hard { ctx.params.f("alphaContrast") as f32 / 100.0 } else { 1.0 }; + + let a = Plane::alpha(&b.img); + let guide = Plane::from_image(&b.img, |px| effectcraft_color::luminance(px[0], px[1], px[2])); + let eps = 1e-4 + smooth * smooth * 0.05; + let filtered = guided_filter(&guide, &a, r, eps); + // Only the edge band (where the matte varies within the radius) is refined. + let hi = morph_plane(&a, r, r, true); + let lo = morph_plane(&a, r, r, false); + let mut na = Plane::new(a.w, a.h); + na.data.par_iter_mut().enumerate().for_each(|(i, v)| { + *v = if hi.data[i] - lo.data[i] > 1e-3 { filtered.data[i].clamp(0.0, 1.0) } else { a.data[i] }; + }); + if feather > 0.0 { + na = gauss_plane(&na, feather, feather); + } + na = na.map(|v| { + let mut v = if choke > 0.0 { + (v - choke) / (1.0 - choke) + } else if choke < 0.0 { + v / (1.0 + choke) + } else { + v + }; + if (contrast - 1.0).abs() > 1e-6 { + v = (v - 0.5) * contrast + 0.5; + } + let v = v.clamp(0.0, 1.0); + if invert { 1.0 - v } else { v } + }); + if decon && decon_amt > 0.0 { + let est = gaussian_blur(&b.img, r as f64 * 0.5, r as f64 * 0.5, true); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a0) = unpremul(*px); + if a0 > 1e-4 && a0 < 0.999 { + let (ec, ea) = unpremul(est.data[i]); + if ea > 1e-4 { + let t = decon_amt * (1.0 - a0); + *px = premul([0, 1, 2].map(|k| c[k] + (ec[k] - c[k]) * t), a0); + } + } + }); + } + set_alpha(&mut b.img, &na); + b +} + +fn refine_soft(ctx: &EffectCtx, b: Buf) -> Buf { + refine(ctx, b, false) +} + +fn refine_hard(ctx: &EffectCtx, b: Buf) -> Buf { + refine(ctx, b, true) +} + +pub fn specs() -> Vec { + let refine_params = |hard: bool| { + let mut v = vec![ + p("radius", "Additional Edge Radius", num(if hard { 3.0 } else { 10.0 }), slider(1.0, 200.0, 1.0, 50.0, 1)), + p("smooth", "Smooth", num(if hard { 20.0 } else { 0.0 }), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("feather", "Feather", num(if hard { 0.0 } else { 10.0 }), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("choke", "Choke", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + ]; + if hard { + v.push(p("alphaContrast", "Alpha Contrast", num(300.0), slider(100.0, 1000.0, 100.0, 500.0, 0))); + } + v.push(p("decontaminate", "Decontaminate Edge Colors", Value::Bool(true), ParamUi::Checkbox)); + v.push(p("decontaminationAmount", "Decontamination Amount", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 0))); + v.push(p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox)); + v + }; + let soft = || slider(0.0, 1000.0, 0.0, 100.0, 1); + let ch = || slider(-127.0, 127.0, -127.0, 127.0, 0); + let gl = || slider(0.0, 100.0, 0.0, 100.0, 1); + vec![ + spec( + "ec.matte.simplechoker", + "Simple Choker", + vec![ + p("view", "View", Value::Enum(0), popup(&["Final Output", "Matte"])), + p("chokeMatte", "Choke Matte", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 2)), + ], + simple_choker, + ), + spec( + "ec.matte.mattechoker", + "Matte Choker", + vec![ + p("geometricSoftness1", "Geometric Softness 1", num(4.0), soft()), + p("choke1", "Choke 1", num(75.0), ch()), + p("grayLevelSoftness1", "Gray Level Softness 1", num(10.0), gl()), + p("geometricSoftness2", "Geometric Softness 2", num(0.0), soft()), + p("choke2", "Choke 2", num(0.0), ch()), + p("grayLevelSoftness2", "Gray Level Softness 2", num(100.0), gl()), + p("iterations", "Iterations", num(1.0), slider(1.0, 100.0, 1.0, 10.0, 0)), + ], + matte_choker, + ), + spec("ec.matte.refinesoft", "Refine Soft Matte", refine_params(false), refine_soft), + spec("ec.matte.refinehard", "Refine Hard Matte", refine_params(true), refine_hard), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Params, apply, find}; + use effectcraft_raster::Image; + + fn square() -> Image { + let mut img = Image::new(24, 24); + for y in 6..18 { + for x in 6..18 { + img.set(x, y, [0.8, 0.4, 0.2, 1.0]); + } + } + img + } + + fn run(id: &str, over: &[(&str, Value)], img: Image) -> Image { + let s = find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in over { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true }; + apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img + } + + fn alpha_sum(img: &Image) -> f32 { + img.data.iter().map(|p| p[3]).sum() + } + + #[test] + fn simple_choker_shrinks_and_grows() { + let img = square(); + let base = alpha_sum(&img); + let shrunk = run("ec.matte.simplechoker", &[("chokeMatte", num(2.0))], img.clone()); + assert!((alpha_sum(&shrunk) - 64.0).abs() < 1e-3, "{}", alpha_sum(&shrunk)); + let grown = run("ec.matte.simplechoker", &[("chokeMatte", num(-2.0))], img); + assert!(alpha_sum(&grown) > base); + // Grown pixels pick up the layer colour, not black. + let (c, a) = unpremul(grown.get(5, 10)); + assert!(a > 0.99 && c[0] > 0.5, "{c:?}"); + } + + #[test] + fn matte_choker_defaults_shrink() { + let img = square(); + let out = run("ec.matte.mattechoker", &[], img.clone()); + assert!(alpha_sum(&out) < alpha_sum(&img)); + assert!(out.get(12, 12)[3] > 0.99); + } + + #[test] + fn refine_soft_matte_keeps_interior_and_range() { + let img = square(); + let out = run("ec.matte.refinesoft", &[], img); + assert!(out.get(12, 12)[3] > 0.99); + assert!(out.get(0, 0)[3] < 0.01); + assert!(out.data.iter().all(|p| (0.0..=1.0).contains(&p[3]))); + } +} diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs new file mode 100644 index 00000000..759309e6 --- /dev/null +++ b/crates/effects/src/noise.rs @@ -0,0 +1,651 @@ +//! Noise & Grain effects: film grain, median-family filters, denoising and procedural noise. + +use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::generate::value_noise; +use crate::util::{Plane, gauss_plane, guided_filter, hash1, join, lerp, premul, smoothstep, split, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +/// Fractional-octave fBm of value noise in 0..1 (octave amplitude 0.5, frequency 2). +pub(crate) fn fbm(u: f32, v: f32, z: f32, seed: u32, octaves: f32) -> f32 { + let octaves = octaves.clamp(1.0, 20.0); + let n = octaves.ceil() as usize; + let frac = octaves - octaves.floor(); + let (mut sum, mut norm, mut amp, mut f) = (0.0, 0.0, 1.0, 1.0); + for o in 0..n { + let w = if o + 1 == n && frac > 0.0 { frac } else { 1.0 }; + sum += value_noise(u * f, v * f, z + o as f32 * 7.31, seed.wrapping_add(o as u32)) * amp * w; + norm += amp * w; + amp *= 0.5; + f *= 2.0; + } + sum / norm.max(1e-6) +} + +// ---- Add Grain ---- + +fn add_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { + let intensity = ctx.params.f("intensity") as f32; + if intensity <= 0.0 { + return b; + } + let size = (ctx.params.f("size") * b.scale).max(0.05) as f32; + let aspect = ctx.params.f("aspectRatio").max(0.05) as f32; + let soft = ctx.params.f("softness") * b.scale; + let mono = ctx.params.b("monochromatic"); + let sat = ctx.params.f("saturation") as f32; + let (ws, wm, wh) = (ctx.params.f("shadows") as f32, ctx.params.f("midtones") as f32, ctx.params.f("highlights") as f32); + let seed = (ctx.params.f("randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37); + let frame = (ctx.time * 24.0 * ctx.params.f("animationSpeed")).floor() as i64 as f32; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let nch = if mono { 1 } else { 3 }; + let mut planes: Vec = (0..nch) + .map(|k| { + let mut pl = Plane::new(w, h); + pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let n = value_noise(x as f32 / size, y as f32 / (size * aspect), frame, seed.wrapping_add(k as u32 * 101)); + *v = (n - 0.5) * 2.5; + } + }); + pl + }) + .collect(); + if soft > 0.05 { + planes = planes.iter().map(|pl| gauss_plane(pl, soft, soft * aspect as f64)).collect(); + } + let amp = intensity * 0.1; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + let mut g = if mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] }; + let gm = (g[0] + g[1] + g[2]) / 3.0; + g = g.map(|v| gm + (v - gm) * sat); + let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let s = 1.0 - smoothstep(0.0, 0.5, l); + let hi = smoothstep(0.5, 1.0, l); + let weight = ws * s + wm * (1.0 - s - hi) + wh * hi; + let o = [0, 1, 2].map(|k| (c[k] + g[k] * amp * weight).max(0.0)); + *px = premul(o, a); + }); + b +} + +// ---- Median (Huang sliding histogram) ---- + +const NB: usize = 512; + +#[inline] +fn quant(v: f32) -> u16 { + (v.clamp(0.0, 1.0) * (NB - 1) as f32).round() as u16 +} + +/// Per-channel median over a (2r+1)² window of the premultiplied channels (values quantised to +/// 512 levels in 0..1, edges repeated). +pub(crate) fn median_image(img: &Image, r: usize) -> Image { + let (w, h) = (img.width as usize, img.height as usize); + if r == 0 || w == 0 || h == 0 { + return img.clone(); + } + let q: Vec<[u16; 4]> = img.data.par_iter().map(|p| [quant(p[0]), quant(p[1]), quant(p[2]), quant(p[3])]).collect(); + let n = (2 * r + 1) * (2 * r + 1); + let half = n / 2; + let ri = r as i64; + let mut out = Image::new(img.width, img.height); + out.rows_mut().for_each(|(y, row)| { + let mut hist = vec![0u32; NB * 4]; + let mut med = [0usize; 4]; + let mut lt = [0usize; 4]; + let at = |x: i64, y: i64| q[y.clamp(0, h as i64 - 1) as usize * w + x.clamp(0, w as i64 - 1) as usize]; + let col = |x: i64, f: &mut dyn FnMut([u16; 4])| { + for dy in -ri..=ri { + f(at(x, y as i64 + dy)); + } + }; + for dx in -ri..=ri { + col(dx, &mut |v| { + for c in 0..4 { + hist[c * NB + v[c] as usize] += 1; + } + }); + } + let rebalance = |hist: &[u32], med: &mut [usize; 4], lt: &mut [usize; 4]| { + for c in 0..4 { + let hc = &hist[c * NB..(c + 1) * NB]; + while lt[c] > half { + med[c] -= 1; + lt[c] -= hc[med[c]] as usize; + } + while lt[c] + hc[med[c]] as usize <= half { + lt[c] += hc[med[c]] as usize; + med[c] += 1; + } + } + }; + rebalance(&hist, &mut med, &mut lt); + for x in 0..w { + row[x] = med.map(|m| m as f32 / (NB - 1) as f32); + if x + 1 == w { + break; + } + col(x as i64 - ri, &mut |v| { + for c in 0..4 { + hist[c * NB + v[c] as usize] -= 1; + if (v[c] as usize) < med[c] { + lt[c] -= 1; + } + } + }); + col(x as i64 + ri + 1, &mut |v| { + for c in 0..4 { + hist[c * NB + v[c] as usize] += 1; + if (v[c] as usize) < med[c] { + lt[c] += 1; + } + } + }); + rebalance(&hist, &mut med, &mut lt); + } + }); + out +} + +/// Median result honouring "operate on alpha": otherwise keep the original alpha and take the +/// median's straight colour. +fn median_px(orig: Px, m: Px, on_alpha: bool) -> Px { + if on_alpha { + let a = m[3].clamp(0.0, 1.0); + return [m[0].min(a), m[1].min(a), m[2].min(a), a]; + } + let a = orig[3]; + if m[3] <= 1e-4 { + return orig; + } + premul([m[0] / m[3], m[1] / m[3], m[2] / m[3]].map(|v| v.min(1.0)), a) +} + +fn median(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + if r == 0 { + return b; + } + let on_alpha = ctx.params.b("operateOnAlpha"); + let m = median_image(&b.img, r); + b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(o, &mv)| *o = median_px(*o, mv, on_alpha)); + b +} + +fn dust_scratches(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + if r == 0 { + return b; + } + let thr = ctx.params.f("threshold") as f32 / 255.0; + let on_alpha = ctx.params.b("operateOnAlpha"); + let m = median_image(&b.img, r); + b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(o, &mv)| { + let cand = median_px(*o, mv, on_alpha); + let diff = (0..4).map(|c| (cand[c] - o[c]).abs()).fold(0.0f32, f32::max); + if diff > thr { + *o = cand; + } + }); + b +} + +fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amt = ctx.params.f("noiseReduction") as f32; + if amt <= 0.0 { + return b; + } + let passes = ctx.params.f("passes").round().clamp(1.0, 4.0) as usize; + let r = (2.0 * b.scale).round().max(1.0) as usize; + let eps = (0.02 * amt).powi(2); + let mut ch = split(&b.img); + for c in ch.iter_mut().take(3) { + for _ in 0..passes { + *c = guided_filter(c, c, r, eps); + } + } + let alpha = ch[3].clone(); + let mut out = join(&ch); + out.data.par_iter_mut().zip(alpha.data.par_iter()).for_each(|(p, &a)| { + for c in 0..3 { + p[c] = p[c].max(0.0); + } + p[3] = a; + }); + b.img = out; + b +} + +// ---- Turbulent Noise ---- + +fn overflow(v: f32, mode: u32) -> f32 { + match mode { + 1 => (0.5 + 0.5 * (2.0 * (v - 0.5)).tanh() / 1f32.tanh()).clamp(0.0, 1.0), + 2 => { + let t = v.rem_euclid(2.0); + if t > 1.0 { 2.0 - t } else { t } + } + _ => v.clamp(0.0, 1.0), + } +} + +fn turbulent_noise(ctx: &EffectCtx, mut b: Buf) -> Buf { + let kind = ctx.params.e("fractalType"); + let invert = ctx.params.b("invert"); + let contrast = ctx.params.f("contrast") as f32 / 100.0; + let brightness = ctx.params.f("brightness") as f32 / 100.0; + let ov = ctx.params.e("overflow"); + let scale = (ctx.params.f("scale") * b.scale).max(1.0) as f32; + let rot = (ctx.params.f("rotation") as f32).to_radians(); + let octaves = ctx.params.f("complexity").clamp(1.0, 20.0) as f32; + let infl = (ctx.params.f("subInfluence") as f32 / 100.0).clamp(0.0, 1.0); + let sub = (ctx.params.f("subScaling") as f32 / 100.0).clamp(0.1, 1.0); + let evo = ctx.params.f("evolution") as f32 / 360.0; + let seed = (ctx.params.f("randomSeed") as i64 as u32) ^ 0x7a3d; + let opacity = ctx.params.f("opacity") as f32 / 100.0; + let blend = ctx.params.f("blend") as f32 / 100.0; + let (sr, cr) = rot.sin_cos(); + let (ox, oy) = b.to_px(ctx.params.v2("offset")); + let n_oct = octaves.ceil() as usize; + let frac = octaves - octaves.floor(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f32 + 0.5 - ox as f32, y as f32 + 0.5 - oy as f32); + let (mut u, mut v) = ((dx * cr + dy * sr) / scale, (-dx * sr + dy * cr) / scale); + if kind == 3 { + // Dynamic: domain-warped by a low-frequency field. + u += (value_noise(u * 0.5, v * 0.5, evo, seed ^ 0x55) - 0.5) * 1.5; + v += (value_noise(u * 0.5, v * 0.5, evo, seed ^ 0xaa) - 0.5) * 1.5; + } + let (mut sum, mut norm, mut amp, mut f) = (0.0, 0.0, 1.0, 1.0); + for o in 0..n_oct { + let w = if o + 1 == n_oct && frac > 0.0 { frac } else { 1.0 }; + let n = value_noise(u * f, v * f, evo + o as f32 * 5.17, seed.wrapping_add(o as u32 * 31)); + let n = match kind { + 1 => 1.0 - (n * 2.0 - 1.0).powi(2), + 2 => 1.0 - (n * 2.0 - 1.0).abs(), + _ => n, + }; + sum += n * amp * w; + norm += amp * w; + amp *= infl; + f /= sub; + } + let mut val = sum / norm.max(1e-6); + val = overflow((val - 0.5) * contrast + 0.5 + brightness, ov); + if invert { + val = 1.0 - val; + } + let g = [val * opacity, val * opacity, val * opacity, opacity]; + for c in 0..4 { + px[c] = px[c] * blend + g[c] * (1.0 - blend); + } + } + }); + b +} + +// ---- Noise Alpha / Noise HLS ---- + +/// Per-pixel noise in 0..1 that changes smoothly with `phase` (whole units = new pattern). +#[inline] +fn phased(x: u32, y: u32, seed: u32, phase: f32) -> f32 { + let k = phase.floor(); + let t = phase - k; + let k = k as i64 as u32; + let a = hash1(x, y, seed.wrapping_add(k.wrapping_mul(0x632b))); + let b = hash1(x, y, seed.wrapping_add(k.wrapping_add(1).wrapping_mul(0x632b))); + lerp(a, b, t * t * (3.0 - 2.0 * t)) +} + +fn noise_alpha(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = ctx.params.f("amount") as f32 / 100.0; + if k <= 0.0 { + return b; + } + let kind = ctx.params.e("noise"); + let orig = ctx.params.e("originalAlpha"); + let ov = ctx.params.e("overflow"); + let seed = (ctx.params.f("randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x2f1); + let phase = ctx.params.f("noisePhase") as f32 / 360.0 + if kind >= 2 { ctx.time as f32 } else { 0.0 }; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let mut n = if kind >= 2 { phased(x as u32, y as u32, seed, phase) } else { phased(x as u32, y as u32, seed, phase.floor()) }; + if kind % 2 == 1 { + n *= n; + } + let (c, a) = unpremul(*px); + let na = match orig { + 0 => a + n * k, + 1 => { + if a > 0.0 { + a + n * k + } else { + 0.0 + } + } + 2 => a * (1.0 + (2.0 * n - 1.0) * k), + _ => a + (2.0 * n - 1.0) * k * 4.0 * a * (1.0 - a), + }; + let na = match ov { + 1 => { + let t = na.rem_euclid(2.0); + if t > 1.0 { 2.0 - t } else { t } + } + 2 => { + if !(0.0..=1.0).contains(&na) { + na.rem_euclid(1.0) + } else { + na + } + } + _ => na, + } + .clamp(0.0, 1.0); + *px = premul(c, na); + } + }); + b +} + +fn noise_hls_core(ctx: &EffectCtx, mut b: Buf, phase: f32) -> Buf { + let hue = ctx.params.f("hue") as f32 / 100.0; + let light = ctx.params.f("lightness") as f32 / 100.0; + let satv = ctx.params.f("saturation") as f32 / 100.0; + if hue == 0.0 && light == 0.0 && satv == 0.0 { + return b; + } + let kind = ctx.params.e("noise"); + let gs = (ctx.params.f("grainSize") * b.scale).max(0.1) as f32; + let seed = ctx.seed.wrapping_mul(0x51f3); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (c, a) = unpremul(*px); + if a <= 0.0 { + continue; + } + let n = |k: u32| -> f32 { + let s = seed.wrapping_add(k * 7919); + match kind { + 2 => ((value_noise(x as f32 / gs, y as f32 / gs, phase, s) - 0.5) * 3.0).clamp(-1.0, 1.0), + 1 => { + let v = phased(x as u32, y as u32, s, phase) * 2.0 - 1.0; + v * v.abs() + } + _ => phased(x as u32, y as u32, s, phase) * 2.0 - 1.0, + } + }; + let (mut h, mut s, mut l) = rgb_to_hsl(c[0], c[1], c[2]); + if hue != 0.0 { + h = (h + n(0) * hue * 0.5).rem_euclid(1.0); + } + if light != 0.0 { + l = (l + n(1) * light).clamp(0.0, 1.0); + } + if satv != 0.0 { + s = (s + n(2) * satv).clamp(0.0, 1.0); + } + let (r, g, bl) = hsl_to_rgb(h, s, l); + *px = premul([r, g, bl], a); + } + }); + b +} + +fn noise_hls(ctx: &EffectCtx, b: Buf) -> Buf { + let phase = ctx.params.f("noisePhase") as f32 / 360.0; + noise_hls_core(ctx, b, phase) +} + +fn noise_hls_auto(ctx: &EffectCtx, b: Buf) -> Buf { + let phase = ctx.time as f32 * ctx.params.f("noiseAnimationSpeed") as f32; + noise_hls_core(ctx, b, phase) +} + +pub fn specs() -> Vec { + let hls_common = || { + vec![ + p("noise", "Noise", Value::Enum(0), popup(&["Uniform", "Squared", "Grain"])), + p("hue", "Hue", num(0.0), pct()), + p("lightness", "Lightness", num(0.0), pct()), + p("saturation", "Saturation", num(0.0), pct()), + p("grainSize", "Grain Size", num(1.0), slider(0.1, 100.0, 0.5, 10.0, 2)), + ] + }; + let mut hls = hls_common(); + hls.push(p("noisePhase", "Noise Phase", num(0.0), ParamUi::Angle)); + let mut hls_auto = hls_common(); + hls_auto.push(p("noiseAnimationSpeed", "Noise Animation Speed", num(10.0), slider(0.0, 1000.0, 0.0, 30.0, 1))); + vec![ + spec( + "ec.noise.addgrain", + "Add Grain", + vec![ + p("intensity", "Intensity", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("size", "Size", num(1.0), slider(0.05, 100.0, 0.1, 10.0, 2)), + p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("aspectRatio", "Aspect Ratio", num(1.0), slider(0.05, 20.0, 0.25, 4.0, 2)), + p("monochromatic", "Monochromatic", Value::Bool(false), ParamUi::Checkbox), + p("saturation", "Saturation", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("shadows", "Shadows", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("midtones", "Midtones", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("highlights", "Highlights", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), + p("animationSpeed", "Animation Speed", num(1.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + ], + add_grain, + ), + spec( + "ec.noise.median", + "Median", + vec![ + p("radius", "Radius", num(0.0), slider(0.0, 255.0, 0.0, 50.0, 0)), + p("operateOnAlpha", "Operate on Alpha Channel", Value::Bool(false), ParamUi::Checkbox), + ], + median, + ), + spec( + "ec.noise.dustscratches", + "Dust & Scratches", + vec![ + p("radius", "Radius", num(1.0), slider(0.0, 255.0, 0.0, 50.0, 0)), + p("threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)), + p("operateOnAlpha", "Operate on Alpha Channel", Value::Bool(false), ParamUi::Checkbox), + ], + dust_scratches, + ), + spec( + "ec.noise.removegrain", + "Remove Grain", + vec![ + p("noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)), + p("passes", "Passes", num(1.0), slider(1.0, 4.0, 1.0, 4.0, 0)), + ], + remove_grain, + ), + spec( + "ec.noise.turbulent", + "Turbulent Noise", + vec![ + p("fractalType", "Fractal Type", Value::Enum(1), popup(&["Basic", "Turbulent Smooth", "Turbulent Sharp", "Dynamic"])), + p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + p("contrast", "Contrast", num(100.0), slider(0.0, 10000.0, 0.0, 400.0, 1)), + p("brightness", "Brightness", num(0.0), slider(-10000.0, 10000.0, -200.0, 200.0, 1)), + p("overflow", "Overflow", Value::Enum(0), popup(&["Clip", "Soft Clamp", "Wrap Back"])), + p("scale", "Scale", num(100.0), slider(1.0, 10000.0, 20.0, 600.0, 1)), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("offset", "Offset Turbulence", Value::Vec2([0.5, 0.5]), ParamUi::Point), + p("complexity", "Complexity", num(6.0), slider(1.0, 20.0, 1.0, 10.0, 1)), + p("subInfluence", "Sub Influence (%)", num(70.0), slider(0.0, 100.0, 25.0, 100.0, 1)), + p("subScaling", "Sub Scaling", num(56.0), slider(10.0, 100.0, 25.0, 100.0, 1)), + p("evolution", "Evolution", num(0.0), ParamUi::Angle), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), + p("opacity", "Opacity", num(100.0), pct()), + p("blend", "Blend With Original", num(0.0), pct()), + ], + turbulent_noise, + ), + spec( + "ec.noise.noisealpha", + "Noise Alpha", + vec![ + p("noise", "Noise", Value::Enum(0), popup(&["Uniform Random", "Squared Random", "Uniform Animation", "Squared Animation"])), + p("amount", "Amount", num(0.0), pct()), + p("originalAlpha", "Original Alpha", Value::Enum(1), popup(&["Add", "Clamp", "Scale", "Edges"])), + p("overflow", "Overflow", Value::Enum(0), popup(&["Clip", "Wrap Back", "Wrap"])), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), + p("noisePhase", "Noise Phase", num(0.0), ParamUi::Angle), + ], + noise_alpha, + ), + spec("ec.noise.noisehls", "Noise HLS", hls, noise_hls), + spec("ec.noise.noisehlsauto", "Noise HLS Auto", hls_auto, noise_hls_auto), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Params; + + fn run(id: &str, vals: &[(&str, Value)], img: Image, time: f64) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in vals { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn gray(w: u32, h: u32, v: f32) -> Image { + Image::filled(w, h, [v, v, v, 1.0]) + } + + #[test] + fn median_removes_salt_noise() { + let mut img = gray(16, 16, 0.5); + for (x, y) in [(3, 3), (8, 2), (12, 12), (5, 10)] { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + let out = run("ec.noise.median", &[("radius", num(1.0))], img, 0.0); + for p in &out.img.data { + assert!((p[0] - 0.5).abs() < 2.0 / 511.0, "{p:?}"); + } + } + + #[test] + fn median_matches_brute_force() { + let (w, h) = (9u32, 7u32); + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let v = |k: u32| ((x * 37 + y * 101 + k * 13) % 511) as f32 / 511.0; + img.set(x, y, [v(0), v(1), v(2), v(3)]); + } + } + for r in 1..3usize { + let m = median_image(&img, r); + for y in 0..h as i64 { + for x in 0..w as i64 { + for c in 0..4 { + let mut vals: Vec = vec![]; + for dy in -(r as i64)..=r as i64 { + for dx in -(r as i64)..=r as i64 { + vals.push(img.get_clamped(x + dx, y + dy)[c]); + } + } + vals.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let want = vals[vals.len() / 2]; + let got = m.data[y as usize * w as usize + x as usize][c]; + assert!((want - got).abs() < 1e-4, "r={r} ({x},{y}) c={c}: {want} vs {got}"); + } + } + } + } + } + + #[test] + fn dust_threshold_keeps_detail() { + let mut img = gray(12, 12, 0.5); + img.set(6, 6, [0.56, 0.56, 0.56, 1.0]); + let keep = run("ec.noise.dustscratches", &[("radius", num(1.0)), ("threshold", num(40.0))], img.clone(), 0.0); + assert!((keep.img.get(6, 6)[0] - 0.56).abs() < 1e-6); + let rm = run("ec.noise.dustscratches", &[("radius", num(1.0)), ("threshold", num(5.0))], img, 0.0); + assert!((rm.img.get(6, 6)[0] - 0.5).abs() < 2.0 / 511.0); + } + + #[test] + fn turbulent_noise_seeded() { + let a = run("ec.noise.turbulent", &[("randomSeed", num(4.0))], gray(24, 16, 0.0), 0.0); + let b = run("ec.noise.turbulent", &[("randomSeed", num(4.0))], gray(24, 16, 0.0), 0.0); + let c = run("ec.noise.turbulent", &[("randomSeed", num(5.0))], gray(24, 16, 0.0), 0.0); + assert_eq!(a.img, b.img); + assert_ne!(a.img, c.img); + assert!(a.img.data.iter().all(|p| (0.0..=1.0).contains(&p[0]))); + } + + #[test] + fn noise_alpha_in_range() { + for orig in 0..4 { + for ov in 0..3 { + let mut img = gray(10, 10, 0.5); + img.data[3] = [0.0; 4]; + img.data[4] = [0.1, 0.1, 0.1, 0.2]; + let out = run("ec.noise.noisealpha", &[("amount", num(80.0)), ("originalAlpha", Value::Enum(orig)), ("overflow", Value::Enum(ov))], img, 0.3); + assert!(out.img.data.iter().all(|p| (0.0..=1.0).contains(&p[3]) && p[0] <= p[3] + 1e-6)); + } + } + } + + #[test] + fn noise_hls_changes_and_animates() { + let mut img = Image::new(8, 8); + for (i, p) in img.data.iter_mut().enumerate() { + *p = premul([0.6, 0.3, (i % 7) as f32 / 7.0], 1.0); + } + let a = run("ec.noise.noisehlsauto", &[("lightness", num(30.0))], img.clone(), 0.0); + let b = run("ec.noise.noisehlsauto", &[("lightness", num(30.0))], img.clone(), 0.55); + assert_ne!(a.img, img); + assert_ne!(a.img, b.img); + let still = run("ec.noise.noisehls", &[], img.clone(), 0.0); + assert_eq!(still.img, img); + } + + #[test] + fn add_grain_keeps_flat_mean() { + let out = run("ec.noise.addgrain", &[("intensity", num(2.0))], gray(64, 64, 0.5), 0.0); + let mean: f32 = out.img.data.iter().map(|p| p[0]).sum::() / out.img.data.len() as f32; + assert!((mean - 0.5).abs() < 0.05, "{mean}"); + assert!(out.img.data.iter().any(|p| (p[0] - 0.5).abs() > 0.01)); + } + + #[test] + fn remove_grain_smooths_noise() { + let mut img = gray(32, 32, 0.5); + for (i, p) in img.data.iter_mut().enumerate() { + let n = hash1(i as u32, 0, 9) * 0.1 - 0.05; + *p = [0.5 + n, 0.5 + n, 0.5 + n, 1.0]; + } + let var = |im: &Image| im.data.iter().map(|p| (p[0] - 0.5).powi(2)).sum::(); + let out = run("ec.noise.removegrain", &[("noiseReduction", num(3.0))], img.clone(), 0.0); + assert!(var(&out.img) < var(&img) * 0.5); + } +} diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs new file mode 100644 index 00000000..2a032eca --- /dev/null +++ b/crates/effects/src/perspective.rs @@ -0,0 +1,538 @@ +//! Perspective effects (batch 2): Bevel Alpha, Bevel Edges, Radial Shadow, CC Cylinder, +//! CC Sphere and CC Spotlight. +//! +//! Angles follow the convention used by Drop Shadow: measured clockwise from "up", so a light +//! angle `a` sits in the direction `(sin a, -cos a)` (y grows downwards). 3D vectors use x right, +//! y down, z towards the viewer. + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px, gaussian_blur}; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, layer_rect, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Perspective", params, render, gpu: false, float: true } +} + +fn norm3(v: [f64; 3]) -> [f64; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); + [v[0] / l, v[1] / l, v[2] / l] +} + +fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +/// Direction towards a light at `angle_deg` (clockwise from up) and `elevation_deg` above the plane. +fn light_dir(angle_deg: f64, elevation_deg: f64) -> [f64; 3] { + let (a, e) = (angle_deg.to_radians(), elevation_deg.to_radians()); + norm3([a.sin() * e.cos(), -a.cos() * e.cos(), e.sin()]) +} + +/// Highlight/shadow shading on a premultiplied pixel: positive `s` adds light colour, negative +/// darkens. +#[inline] +fn apply_shade(px: &mut Px, s: f32, light: [f32; 4]) { + if s > 0.0 { + for c in 0..3 { + px[c] += light[c] * s * px[3]; + } + } else if s < 0.0 { + let k = (1.0 + s).max(0.0); + for c in 0..3 { + px[c] *= k; + } + } +} + +fn bevel_alpha(ctx: &EffectCtx, mut b: Buf) -> Buf { + let t = ctx.params.f("edgeThickness") * b.scale; + let inten = ctx.params.f("lightIntensity") as f32; + if t <= 0.0 || inten == 0.0 { + return b; + } + let light = ctx.params.color("lightColor"); + let l = light_dir(ctx.params.f("lightAngle"), 45.0); + let a = Plane::alpha(&b.img); + let h = gauss_plane(&a, t * 0.5, t * 0.5); + let k = t * 1.25; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + if px[3] <= 0.0 { + continue; + } + let (xi, yi) = (x as i64, y as i64); + let dx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) as f64 * 0.5; + let dy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) as f64 * 0.5; + let n = norm3([-dx * k, -dy * k, 1.0]); + let s = (dot3(n, l) - l[2]) as f32 * inten * 2.0; + apply_shade(px, s, light); + } + }); + b +} + +fn bevel_edges(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (x0, y0, w, h) = layer_rect(ctx, &b); + let t = ctx.params.f("edgeThickness").clamp(0.0, 0.5) * w.min(h); + let inten = ctx.params.f("lightIntensity") as f32; + if t <= 0.0 || inten == 0.0 { + return b; + } + let light = ctx.params.color("lightColor"); + let l = light_dir(ctx.params.f("lightAngle"), 45.0); + // A 45° bevel: the face normal tilts towards the nearest edge. + let tilt = std::f64::consts::FRAC_1_SQRT_2; + b.img.rows_mut().for_each(|(y, row)| { + let py = y as f64 + 0.5; + for (x, px) in row.iter_mut().enumerate() { + let pxx = x as f64 + 0.5; + let d = [pxx - x0, x0 + w - pxx, py - y0, y0 + h - py]; + if d.iter().any(|v| *v < 0.0) { + continue; + } + let (mut mi, mut mv) = (0, d[0]); + for (i, v) in d.iter().enumerate() { + if *v < mv { + mv = *v; + mi = i; + } + } + if mv >= t { + continue; + } + let n2 = [[-1.0, 0.0], [1.0, 0.0], [0.0, -1.0], [0.0, 1.0]][mi]; + let n = [n2[0] * tilt, n2[1] * tilt, tilt]; + let s = (dot3(n, l) - l[2]) as f32 * inten * 2.0; + apply_shade(px, s, light); + } + }); + b +} + +fn radial_shadow(ctx: &EffectCtx, mut b: Buf) -> Buf { + let color = ctx.params.color("color"); + let opacity = ctx.params.f("opacity") as f32 / 100.0; + let dist = ctx.params.f("projectionDistance").max(0.0); + let soft = ctx.params.f("softness").max(0.0) * b.scale; + let glass = ctx.params.e("render") == 1; + let influence = ctx.params.f("colorInfluence") as f32 / 100.0; + let only = ctx.params.b("shadowOnly"); + let k = 1.0 + dist / 100.0; + let lp0 = ctx.params.v2("lightSource"); + if ctx.params.b("resizeLayer") && !ctx.adjustment { + let l = b.to_px(lp0); + let (bw, bh) = (b.img.width as f64, b.img.height as f64); + let grow = [(0.0, 0.0), (bw, 0.0), (0.0, bh), (bw, bh)].iter().map(|(cx, cy)| ((cx - l.0).hypot(cy - l.1)) * (k - 1.0)).fold(0.0, f64::max); + b.pad((grow + soft * 1.5).ceil() as u32 + 1); + } + let l = b.to_px(lp0); + let src = b.img.clone(); + let mut sh = Image::new(src.width, src.height); + sh.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (qx, qy) = (x as f64 + 0.5, y as f64 + 0.5); + let s = src.sample_bilinear(l.0 + (qx - l.0) / k, l.1 + (qy - l.1) / k); + let (sc, sa) = unpremul(s); + let mut a = if glass { (sa * (1.0 - sa) * 4.0).clamp(0.0, 1.0) } else { sa }; + a *= opacity * color[3]; + let c = [color[0] + (sc[0] - color[0]) * influence, color[1] + (sc[1] - color[1]) * influence, color[2] + (sc[2] - color[2]) * influence]; + *px = premul(c, a); + } + }); + if soft > 0.0 { + sh = gaussian_blur(&sh, soft * 0.5, soft * 0.5, false); + } + if !only { + sh.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(s, o)| { + let kk = 1.0 - o[3]; + for c in 0..4 { + s[c] = o[c] + s[c] * kk; + } + }); + } + b.img = sh; + b +} + +/// Phong-style shading shared by CC Cylinder and CC Sphere. +struct Shading { + l: [f64; 3], + color: [f32; 4], + intensity: f32, + ambient: f32, + diffuse: f32, + specular: f32, + shininess: f32, +} + +impl Shading { + fn from(ctx: &EffectCtx) -> Shading { + Shading { + l: light_dir(ctx.params.f("lightDirection"), ctx.params.f("lightHeight")), + color: ctx.params.color("lightColor"), + intensity: ctx.params.f("lightIntensity") as f32 / 100.0, + ambient: ctx.params.f("ambient") as f32 / 100.0, + diffuse: ctx.params.f("diffuse") as f32 / 100.0, + specular: ctx.params.f("specular") as f32 / 100.0, + shininess: 1.0 / (ctx.params.f("roughness") as f32).max(0.001), + } + } + /// Shade a premultiplied texel with surface normal `n`. + fn shade(&self, px: Px, n: [f64; 3]) -> Px { + let (c, a) = unpremul(px); + if a <= 0.0 { + return [0.0; 4]; + } + let nl = dot3(n, self.l).max(0.0) as f32; + let hv = norm3([self.l[0], self.l[1], self.l[2] + 1.0]); + let spec = if nl > 0.0 { (dot3(n, hv).max(0.0) as f32).powf(self.shininess) * self.specular } else { 0.0 }; + let mut o = [0.0f32; 3]; + for i in 0..3 { + let lc = self.color[i] * self.intensity; + o[i] = c[i] * (self.ambient + self.diffuse * nl * lc) + spec * lc; + } + premul(o, a) + } +} + +fn over(top: Px, bottom: Px) -> Px { + let k = 1.0 - top[3]; + [top[0] + bottom[0] * k, top[1] + bottom[1] * k, top[2] + bottom[2] * k, top[3] + bottom[3] * k] +} + +/// Sample the layer as a texture at normalised (u, v), wrapping horizontally. +fn texel(src: &Image, rect: (f64, f64, f64, f64), u: f64, v: f64) -> Px { + let (x0, y0, w, h) = rect; + let u = u.rem_euclid(1.0); + if !(0.0..=1.0).contains(&v) { + return [0.0; 4]; + } + src.sample_bilinear_clamped(x0 + u * w, y0 + v * h) +} + +fn cylinder(ctx: &EffectCtx, mut b: Buf) -> Buf { + let rect = layer_rect(ctx, &b); + let (_, _, w, h) = rect; + let r = (ctx.params.f("radius") / 100.0 * w / std::f64::consts::TAU).max(0.5); + let c = b.to_px(ctx.params.v2("position")); + let spin = ctx.params.f("rotation").to_radians(); + let mode = ctx.params.e("render"); + let sh = Shading::from(ctx); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + let v = (y as f64 + 0.5 - c.1) / h + 0.5; + for (x, px) in row.iter_mut().enumerate() { + let s = (x as f64 + 0.5 - c.0) / r; + if s.abs() >= 1.0 { + *px = [0.0; 4]; + continue; + } + let th = s.asin(); + let cz = th.cos(); + let front = || { + let u = (th + spin) / std::f64::consts::TAU + 0.5; + sh.shade(texel(&src, rect, u, v), [s, 0.0, cz]) + }; + let back = || { + let u = (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5; + sh.shade(texel(&src, rect, u, v), [-s, 0.0, cz]) + }; + *px = match mode { + 1 => front(), + 2 => back(), + _ => over(front(), back()), + }; + } + }); + b +} + +/// Rotation matrix R = Rz · Ry · Rx (degrees). +fn rot_xyz(rx: f64, ry: f64, rz: f64) -> [[f64; 3]; 3] { + let (sx, cx) = rx.to_radians().sin_cos(); + let (sy, cy) = ry.to_radians().sin_cos(); + let (sz, cz) = rz.to_radians().sin_cos(); + let mx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]]; + let my = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]]; + let mz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]]; + let mul = |a: [[f64; 3]; 3], b: [[f64; 3]; 3]| { + let mut o = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + o + }; + mul(mz, mul(my, mx)) +} + +fn sphere(ctx: &EffectCtx, mut b: Buf) -> Buf { + let rect = layer_rect(ctx, &b); + let r = (ctx.params.f("radius") * b.scale).max(0.5); + let c = b.to_px(ctx.params.v2("offset")); + let m = rot_xyz(ctx.params.f("rotationX"), ctx.params.f("rotationY"), ctx.params.f("rotationZ")); + let mode = ctx.params.e("render"); + let sh = Shading::from(ctx); + let src = b.img.clone(); + // Texture lookup of a view-space unit vector: rotate by Rᵀ (inverse) then equirectangular. + let tex = |p: [f64; 3]| { + let t = [ + m[0][0] * p[0] + m[1][0] * p[1] + m[2][0] * p[2], + m[0][1] * p[0] + m[1][1] * p[1] + m[2][1] * p[2], + m[0][2] * p[0] + m[1][2] * p[1] + m[2][2] * p[2], + ]; + let lon = t[0].atan2(t[2]); + let lat = t[1].clamp(-1.0, 1.0).asin(); + texel(&src, rect, lon / std::f64::consts::TAU + 0.5, lat / std::f64::consts::PI + 0.5) + }; + b.img.rows_mut().for_each(|(y, row)| { + let sy = (y as f64 + 0.5 - c.1) / r; + for (x, px) in row.iter_mut().enumerate() { + let sx = (x as f64 + 0.5 - c.0) / r; + let d2 = sx * sx + sy * sy; + if d2 >= 1.0 { + *px = [0.0; 4]; + continue; + } + let z = (1.0 - d2).sqrt(); + let front = || sh.shade(tex([sx, sy, z]), [sx, sy, z]); + let back = || sh.shade(tex([sx, sy, -z]), [-sx, -sy, z]); + *px = match mode { + 1 => front(), + 2 => back(), + _ => over(front(), back()), + }; + } + }); + b +} + +fn spotlight(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (_, _, w, h) = layer_rect(ctx, &b); + let from = b.to_px(ctx.params.v2("from")); + let to = b.to_px(ctx.params.v2("to")); + let height = (ctx.params.f("height") / 100.0 * w.max(h)).max(1e-3); + let cone = ctx.params.f("coneAngle").clamp(0.1, 89.9).to_radians(); + let soft = (ctx.params.f("edgeSoftness") / 100.0).clamp(0.0, 1.0); + let color = ctx.params.color("color"); + let k = ctx.params.f("intensity") as f32 / 100.0; + let light_only = ctx.params.e("render") == 1; + let axis = norm3([to.0 - from.0, to.1 - from.1, -height]); + let inner = cone * (1.0 - soft); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let v = norm3([x as f64 + 0.5 - from.0, y as f64 + 0.5 - from.1, -height]); + let ang = dot3(v, axis).clamp(-1.0, 1.0).acos(); + let i = 1.0 - smoothstep(inner as f32, cone as f32, ang as f32); + let m = i * k; + if light_only { + let a = m.clamp(0.0, 1.0); + *px = [color[0] * a, color[1] * a, color[2] * a, a]; + } else { + for c in 0..3 { + px[c] *= m * color[c]; + } + } + } + }); + b +} + +pub fn specs() -> Vec { + let pt = |x, y| Value::Vec2([x, y]); + let shading = || { + vec![ + p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightHeight", "Light Height", num(50.0), slider(-90.0, 90.0, -90.0, 90.0, 1)), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + p("ambient", "Ambient", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("diffuse", "Diffuse", num(80.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("roughness", "Roughness", num(0.05), slider(0.001, 0.5, 0.001, 0.5, 3)), + ] + }; + let render = || popup(&["Full", "Outside", "Inside"]); + let mut cyl = vec![ + p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("position", "Position", pt(0.5, 0.5), ParamUi::Point), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("render", "Render", Value::Enum(0), render()), + ]; + cyl.extend(shading()); + let mut sph = vec![ + p("radius", "Radius", num(120.0), slider(0.0, 4000.0, 0.0, 1000.0, 1)), + p("offset", "Offset", pt(0.5, 0.5), ParamUi::Point), + p("rotationX", "Rotation X", num(0.0), ParamUi::Angle), + p("rotationY", "Rotation Y", num(0.0), ParamUi::Angle), + p("rotationZ", "Rotation Z", num(0.0), ParamUi::Angle), + p("render", "Render", Value::Enum(0), render()), + ]; + sph.extend(shading()); + vec![ + spec( + "ec.perspective.bevelalpha", + "Bevel Alpha", + vec![ + p("edgeThickness", "Edge Thickness", num(2.0), slider(0.0, 200.0, 0.0, 10.0, 2)), + p("lightAngle", "Light Angle", num(-60.0), ParamUi::Angle), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightIntensity", "Light Intensity", num(0.4), slider(0.0, 1.0, 0.0, 1.0, 2)), + ], + bevel_alpha, + ), + spec( + "ec.perspective.beveledges", + "Bevel Edges", + vec![ + p("edgeThickness", "Edge Thickness", num(0.1), slider(0.0, 0.5, 0.0, 0.5, 2)), + p("lightAngle", "Light Angle", num(-60.0), ParamUi::Angle), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightIntensity", "Light Intensity", num(0.25), slider(0.0, 1.0, 0.0, 1.0, 2)), + ], + bevel_edges, + ), + spec( + "ec.perspective.radialshadow", + "Radial Shadow", + vec![ + p("color", "Shadow Color", col(0.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("lightSource", "Light Source", pt(0.3, 0.2), ParamUi::Point), + p("projectionDistance", "Projection Distance", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("softness", "Softness", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("render", "Render", Value::Enum(0), popup(&["Regular", "Glass Edge"])), + p("colorInfluence", "Color Influence", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("shadowOnly", "Shadow Only", Value::Bool(false), ParamUi::Checkbox), + p("resizeLayer", "Resize Layer", Value::Bool(false), ParamUi::Checkbox), + ], + radial_shadow, + ), + spec("ec.perspective.cccylinder", "CC Cylinder", cyl, cylinder), + spec("ec.perspective.ccsphere", "CC Sphere", sph, sphere), + spec( + "ec.perspective.ccspotlight", + "CC Spotlight", + vec![ + p("from", "From", pt(0.3, 0.2), ParamUi::Point), + p("to", "To", pt(0.5, 0.5), ParamUi::Point), + p("height", "Height", num(50.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + p("coneAngle", "Cone Angle", num(20.0), slider(0.1, 89.9, 1.0, 89.0, 1)), + p("edgeSoftness", "Edge Softness", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("intensity", "Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("render", "Render", Value::Enum(0), popup(&["Light", "Light Only"])), + ], + spotlight, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Params, find}; + + fn run(id: &str, img: &Image, set: &[(&str, Value)]) -> Buf { + let s = find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + // Points: defaults are fractions; scale to the image size like instantiate() would. + for ps in &s.params { + if let (ParamUi::Point, Value::Vec2(f)) = (&ps.ui, &ps.default) + && !set.iter().any(|(k, _)| *k == ps.id) + { + params.values.insert(ps.id.to_string(), Value::Vec2([f[0] * img.width as f64, f[1] * img.height as f64])); + } + } + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }) + } + + fn square(w: u32, h: u32, m: u32, c: [f32; 3]) -> Image { + let mut img = Image::new(w, h); + for y in m..h - m { + for x in m..w - m { + img.set(x, y, [c[0], c[1], c[2], 1.0]); + } + } + img + } + + #[test] + fn bevel_alpha_lights_one_side_and_shades_other() { + let img = square(40, 40, 10, [0.5, 0.5, 0.5]); + let out = run("ec.perspective.bevelalpha", &img, &[("lightAngle", num(-90.0)), ("edgeThickness", num(4.0))]).img; + // Light from the left: the left edge brightens, the right edge darkens, centre unchanged. + let left = out.get(10, 20)[0]; + let right = out.get(29, 20)[0]; + assert!(left > 0.55, "{left}"); + assert!(right < 0.45, "{right}"); + assert!((out.get(20, 20)[0] - 0.5).abs() < 1e-3); + assert_eq!(out.get(20, 20)[3], 1.0); + } + + #[test] + fn bevel_edges_only_touches_the_border_band() { + let img = Image::filled(40, 40, [0.5, 0.5, 0.5, 1.0]); + let out = run("ec.perspective.beveledges", &img, &[("lightAngle", num(0.0))]).img; + assert!(out.get(20, 1)[0] > 0.5, "top lit"); + assert!(out.get(20, 38)[0] < 0.5, "bottom shaded"); + assert_eq!(out.get(20, 20)[0], 0.5); + } + + #[test] + fn radial_shadow_is_behind_and_scaled() { + let img = square(60, 60, 25, [1.0, 0.0, 0.0]); + let out = run( + "ec.perspective.radialshadow", + &img, + &[("lightSource", Value::Vec2([30.0, 30.0])), ("projectionDistance", num(100.0)), ("opacity", num(100.0))], + ) + .img; + // The original stays on top. + assert_eq!(out.get(30, 30), [1.0, 0.0, 0.0, 1.0]); + // A 2× shadow around the light covers pixels the layer does not. + let s = out.get(30, 21); + assert!(s[3] > 0.9 && s[0] < 0.05, "{s:?}"); + assert_eq!(out.get(30, 5)[3], 0.0); + } + + #[test] + fn sphere_is_round_and_shaded() { + let img = Image::filled(64, 64, [0.8, 0.8, 0.8, 1.0]); + let out = run("ec.perspective.ccsphere", &img, &[("radius", num(20.0))]).img; + assert_eq!(out.get(2, 2)[3], 0.0); + assert!(out.get(32, 32)[3] > 0.99); + assert_eq!(out.get(32, 55)[3], 0.0); + // Lit from the upper left: that side is brighter than the lower right. + assert!(out.get(24, 24)[0] > out.get(40, 40)[0]); + } + + #[test] + fn cylinder_width_follows_radius() { + let img = Image::filled(100, 20, [0.5, 0.5, 0.5, 1.0]); + let out = run("ec.perspective.cccylinder", &img, &[]).img; + // radius 100% → r = W / 2π ≈ 15.9 px + assert!(out.get(50, 10)[3] > 0.99); + assert!(out.get(50 + 14, 10)[3] > 0.99); + assert_eq!(out.get(50 + 18, 10)[3], 0.0); + } + + #[test] + fn spotlight_darkens_outside_cone() { + let img = Image::filled(80, 80, [1.0, 1.0, 1.0, 1.0]); + let out = + run("ec.perspective.ccspotlight", &img, &[("from", Value::Vec2([40.0, 40.0])), ("to", Value::Vec2([40.0, 40.0])), ("edgeSoftness", num(0.0))]).img; + assert!((out.get(40, 40)[0] - 1.0).abs() < 1e-4); + assert_eq!(out.get(2, 2)[0], 0.0); + assert_eq!(out.get(2, 2)[3], 1.0); + } +} diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs new file mode 100644 index 00000000..5eee2b4f --- /dev/null +++ b/crates/effects/src/stylize2.rs @@ -0,0 +1,817 @@ +//! Stylize effects (batch 2): cartoon, roughened edges, scatter, strobe, relief, painterly +//! strokes, kaleidoscope, tiling, colour emboss, vignette and thresholds. + +use effectcraft_color::{luminance, rgb_to_hsl}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::noise::fbm; +use crate::util::{Plane, gauss_plane, guided_filter, hash1, join, layer_rect, lerp3, lerp4, morph_frac, premul, smoothstep, split, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Stylize", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---- Cartoon ---- + +fn sobel(pl: &Plane) -> Plane { + let mut out = Plane::new(pl.w, pl.h); + out.data.par_chunks_mut(pl.w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let l = |dx: i64, dy: i64| pl.get_clamped(x as i64 + dx, y as i64 + dy); + let gx = l(1, -1) + 2.0 * l(1, 0) + l(1, 1) - l(-1, -1) - 2.0 * l(-1, 0) - l(-1, 1); + let gy = l(-1, 1) + 2.0 * l(0, 1) + l(1, 1) - l(-1, -1) - 2.0 * l(0, -1) - l(1, -1); + *v = (gx * gx + gy * gy).sqrt(); + } + }); + out +} + +fn cartoon(ctx: &EffectCtx, mut b: Buf) -> Buf { + let render = ctx.params.e("render"); + let r = (ctx.params.f("detailRadius") * b.scale).round().max(0.0) as usize; + let thr = ctx.params.f("detailThreshold") as f32 / 100.0; + let steps = ctx.params.f("shadingSteps").max(1.0) as f32; + let smooth = ctx.params.f("shadingSmoothness") as f32 / 100.0; + let et = ctx.params.f("edgeThreshold") as f32; + let ew = ctx.params.f("edgeWidth") * b.scale; + let es = ctx.params.f("edgeSoftness") as f32 / 100.0; + let eo = ctx.params.f("edgeOpacity") as f32 / 100.0; + // Edge-preserving smoothing: guided filter on each premultiplied channel, guided by luma. + let smoothed = if r > 0 { + let guide = Plane::luma(&b.img); + let eps = thr * thr * 0.25 + 1e-5; + let mut ch = split(&b.img); + for c in ch.iter_mut().take(3) { + *c = guided_filter(&guide, c, r, eps); + } + join(&ch) + } else { + b.img.clone() + }; + let luma = Plane::luma(&smoothed); + let edges = if render >= 1 { + let mag = sobel(&luma); + let t1 = et * 0.2; + let t0 = t1 * (1.0 - es * 0.9); + let mut e = mag.map(|m| smoothstep(t0, t1.max(t0 + 1e-4), m)); + if ew > 1.0 { + e = morph_frac(&e, (ew - 1.0) * 0.5, true); + } else { + let k = ew.max(0.0) as f32; + e = e.map(|v| v * k); + } + Some(e) + } else { + None + }; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let a = px[3]; + if a <= 0.0 { + return; + } + let (c, _) = unpremul(smoothed.data[i]); + let fill = { + let l = luminance(c[0], c[1], c[2]); + let t = l.max(0.0) * steps; + let f = t - t.floor(); + let f2 = if smooth <= 0.0 { if f >= 0.5 { 1.0 } else { 0.0 } } else { smoothstep(0.5 - smooth * 0.5, 0.5 + smooth * 0.5, f) }; + let lq = (t.floor() + f2) / steps; + if l > 1e-4 { c.map(|v| v * lq / l) } else { [lq; 3] } + }; + let e = edges.as_ref().map(|e| e.data[i] * eo).unwrap_or(0.0); + let o = match render { + 0 => fill, + 1 => [1.0 - e; 3], + _ => fill.map(|v| v * (1.0 - e)), + }; + *px = premul(o, a); + }); + b +} + +// ---- Roughen Edges ---- + +fn roughen_edges(ctx: &EffectCtx, mut b: Buf) -> Buf { + let kind = ctx.params.e("edgeType"); + let ec = ctx.params.color("edgeColor"); + let border = ctx.params.f("border") * b.scale; + let sharp = ctx.params.f("edgeSharpness").max(0.01) as f32; + let infl = ctx.params.f("fractalInfluence") as f32; + let scale = (ctx.params.f("scale") * b.scale * 0.1).max(0.5) as f32; + let (ox, oy) = b.to_px(ctx.params.v2("offset")); + let oct = ctx.params.f("complexity") as f32; + let evo = ctx.params.f("evolution") as f32 / 360.0; + // (frequency, influence, sharpness, coloured) per edge type. + let (fm, im, sm, colored) = match kind { + 1 => (1.0, 1.0, 1.0, true), + 2 => (1.0, 1.0, 3.0, false), + 3 => (3.0, 1.0, 1.5, false), + 4 => (1.5, 1.5, 1.0, false), + 5 => (1.5, 1.5, 1.0, true), + 6 => (0.6, 1.2, 2.5, false), + 7 => (0.6, 1.2, 2.5, true), + _ => (1.0, 1.0, 1.0, false), + }; + let alpha = Plane::alpha(&b.img); + let d = if border > 0.05 { gauss_plane(&alpha, border * 0.5, border * 0.5) } else { alpha }; + let extra = if kind == 4 || kind == 5 { 1.0 } else { 0.0 }; + let seed = ctx.seed ^ 0x40f1; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + if px[3] <= 0.0 { + continue; + } + let dv = d.get(x, y); + let n = fbm((x as f32 + 0.5 - ox as f32) / scale * fm, (y as f32 + 0.5 - oy as f32) / scale * fm, evo, seed, oct + extra); + let s = dv - 0.5 + (n - 0.5) * infl * im * 0.5; + let m = (s * sharp * sm * 4.0 + 0.5).clamp(0.0, 1.0); + let (mut c, a) = unpremul(*px); + if colored { + let band = 1.0 - smoothstep(0.5, 0.9, dv + (n - 0.5) * infl * 0.5); + c = lerp3(c, [ec[0], ec[1], ec[2]], band); + } + *px = premul(c, a * m); + } + }); + b +} + +// ---- Scatter ---- + +fn scatter(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amt = ctx.params.f("amount") * b.scale; + if amt <= 0.0 { + return b; + } + let grain = ctx.params.e("grain"); + let seed = if ctx.params.b("randomizeEveryFrame") { ctx.seed ^ ((ctx.time * 1000.0) as u32).wrapping_mul(0x9e37_79b9) } else { ctx.seed }; + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let dx = if grain == 2 { 0.0 } else { (hash1(x as u32, y as u32, seed) as f64 - 0.5) * 2.0 * amt }; + let dy = if grain == 1 { 0.0 } else { (hash1(x as u32, y as u32, seed ^ 0x5bd1) as f64 - 0.5) * 2.0 * amt }; + *px = src.get((x as f64 + dx).round() as i64, (y as f64 + dy).round() as i64); + } + }); + b +} + +// ---- Strobe Light ---- + +pub(crate) fn strobe_on(time: f64, duration: f64, period: f64, prob: f64, seed: u32) -> bool { + if period <= 0.0 { + return false; + } + let k = (time / period).floor(); + let phase = time - k * period; + phase < duration || (prob > 0.0 && (hash1(k as i64 as u32, 17, seed) as f64) < prob) +} + +fn strobe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let on = strobe_on(ctx.time, ctx.params.f("strobeDuration"), ctx.params.f("strobePeriod"), ctx.params.f("randomStrobeProbability") / 100.0, ctx.seed); + if !on { + return b; + } + let blend = (ctx.params.f("blendWithOriginal") as f32 / 100.0).clamp(0.0, 1.0); + if ctx.params.e("strobe") == 1 { + b.img.scale_alpha(blend); + return b; + } + let s = ctx.params.color("strobeColor"); + let op = ctx.params.e("strobeOperator"); + b.img.map_straight(|c| { + let o: [f32; 3] = [0, 1, 2].map(|i| { + let (v, k) = (c[i], s[i]); + match op { + 1 => v + k, + 2 => (v - k).max(0.0), + 3 => v * k, + 4 => (v - k).abs(), + 5 => 1.0 - (1.0 - v) * (1.0 - k), + 6 => v.max(k), + 7 => v.min(k), + _ => k, + } + }); + lerp3(o, c, blend) + }); + b +} + +// ---- Texturize (self relief) ---- + +fn texturize(ctx: &EffectCtx, mut b: Buf) -> Buf { + let ang = ctx.params.f("lightDirection").to_radians(); + let k = ctx.params.f("textureContrast") as f32; + if k == 0.0 { + return b; + } + let (lx, ly) = (ang.cos() as f32, -ang.sin() as f32); + let h = gauss_plane(&Plane::luma(&b.img), 0.7 * b.scale, 0.7 * b.scale); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let gx = (h.get_clamped(x as i64 + 1, y as i64) - h.get_clamped(x as i64 - 1, y as i64)) * 0.5; + let gy = (h.get_clamped(x as i64, y as i64 + 1) - h.get_clamped(x as i64, y as i64 - 1)) * 0.5; + let shade = (1.0 - k * 4.0 * (gx * lx + gy * ly)).max(0.0); + for c in 0..3 { + px[c] *= shade; + } + } + }); + b +} + +// ---- Brush Strokes ---- + +fn brush_strokes(ctx: &EffectCtx, mut b: Buf) -> Buf { + let ang = ctx.params.f("strokeAngle"); + let size = (ctx.params.f("brushSize") * b.scale).max(0.5); + let len = ctx.params.f("strokeLength") * b.scale; + let density = ctx.params.f("strokeDensity") as f32; + let rnd = ctx.params.f("strokeRandomness"); + let surface = ctx.params.e("paintSurface"); + let blend = ctx.params.f("blendWithOriginal") as f32 / 100.0; + let seed = ctx.seed ^ 0xb705; + let cell = (size * 2.0).max(1.0); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (cx, cy) = ((x as f64 / cell).floor() as i64 as u32, (y as f64 / cell).floor() as i64 as u32); + let h = |k: u32| hash1(cx.wrapping_add(k.wrapping_mul(7919)), cy, seed) as f64; + let a = (ang + (h(0) - 0.5) * rnd * 60.0).to_radians(); + let l = len * (0.5 + h(1)); + let (dx, dy) = (a.sin(), -a.cos()); + let jit = (h(2) - 0.5) * size; + let (bx, by) = (x as f64 + 0.5 - dy * jit, y as f64 + 0.5 + dx * jit); + let n = l.ceil().clamp(1.0, 32.0) as usize; + let mut acc = [0.0f32; 4]; + for i in 0..n { + let t = if n == 1 { 0.0 } else { i as f64 / (n - 1) as f64 - 0.5 } * l; + let s = src.sample_bilinear_clamped(bx + dx * t, by + dy * t); + for c in 0..4 { + acc[c] += s[c]; + } + } + let stroke = acc.map(|v| v / n as f32); + let m = if (h(3) as f32) < density.min(1.0) { 1.0 } else { 0.0 }; + let orig = *px; + let base: Px = match surface { + 1 => [0.0; 4], + 2 => [1.0; 4], + 3 => [0.0, 0.0, 0.0, 1.0], + _ => orig, + }; + let painted = if surface == 2 || surface == 3 { + // Stroke over an opaque surface. + let k = 1.0 - stroke[3] * m; + [stroke[0] * m + base[0] * k, stroke[1] * m + base[1] * k, stroke[2] * m + base[2] * k, stroke[3] * m + base[3] * k] + } else { + lerp4(base, stroke, m) + }; + *px = lerp4(painted, orig, blend); + } + }); + b +} + +// ---- CC Kaleida ---- + +fn kaleida(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = b.to_px(ctx.params.v2("center")); + let size = ctx.params.f("size").max(1.0) / 100.0; + let mode = ctx.params.e("mirroring"); + let rot = ctx.params.f("rotation").to_radians(); + let n = [6.0, 8.0, 4.0, 12.0, 3.0][mode.min(4) as usize]; + let seg = std::f64::consts::TAU / n; + let mirror = mode != 3; + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let r = (dx * dx + dy * dy).sqrt() / size; + let mut a = (dy.atan2(dx) - rot).rem_euclid(seg); + if mirror && a > seg * 0.5 { + a = seg - a; + } + *px = src.sample_bilinear_clamped(c.0 + r * (a + rot).cos(), c.1 + r * (a + rot).sin()); + } + }); + b +} + +// ---- CC RepeTile ---- + +#[inline] +fn tile_coord(v: f64, size: f64) -> (i64, f64) { + let i = (v / size).floor(); + (i as i64, v - i * size) +} + +fn repetile(ctx: &EffectCtx, b: Buf) -> Buf { + let s = b.scale; + let (r, l, d, u) = ( + ctx.params.f("expandRight").max(0.0) * s, + ctx.params.f("expandLeft").max(0.0) * s, + ctx.params.f("expandDown").max(0.0) * s, + ctx.params.f("expandUp").max(0.0) * s, + ); + if ctx.adjustment || r + l + d + u < 0.5 { + return b; + } + let mode = ctx.params.e("tiling"); + let (x0, y0, w, h) = layer_rect(ctx, &b); + if w < 1.0 || h < 1.0 { + return b; + } + let (nw, nh) = ((w + l + r).round().max(1.0) as u32, (h + u + d).round().max(1.0) as u32); + let mut out = Image::new(nw, nh); + let src = &b.img; + out.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (ix, mut fx) = tile_coord(x as f64 + 0.5 - l, w); + let (iy, mut fy) = tile_coord(y as f64 + 0.5 - u, h); + let odd = (ix + iy).rem_euclid(2) == 1; + let (flip_x, flip_y) = match mode { + 1 => (ix.rem_euclid(2) == 1, iy.rem_euclid(2) == 1), + 2 => (odd, false), + 3 => (false, odd), + 4 => (odd, odd), + _ => (false, false), + }; + if flip_x { + fx = w - fx; + } + if flip_y { + fy = h - fy; + } + *px = src.sample_bilinear(x0 + fx, y0 + fy); + } + }); + Buf { img: out, offset: [l, u], scale: s } +} + +// ---- Motion Tile ---- + +fn motion_tile(ctx: &EffectCtx, b: Buf) -> Buf { + let s = b.scale; + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + if lw <= 0.0 || lh <= 0.0 { + return b; + } + let tc = ctx.params.v2("tileCenter"); + let tw = (lw * ctx.params.f("tileWidth") / 100.0).max(0.01); + let th = (lh * ctx.params.f("tileHeight") / 100.0).max(0.01); + let mirror = ctx.params.b("mirrorEdges"); + let phase = ctx.params.f("phase") / 360.0; + let hshift = ctx.params.b("horizontalPhaseShift"); + let (nw, nh, noff) = if ctx.adjustment { + (b.img.width, b.img.height, b.offset) + } else { + let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s); + let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s); + ((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s]) + }; + let mut out = Image::new(nw, nh); + let src = &b.img; + out.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let u = (x as f64 + 0.5 - noff[0]) / s; + let v = (y as f64 + 0.5 - noff[1]) / s; + let mut tx = (u - tc[0]) / tw + 0.5; + let mut ty = (v - tc[1]) / th + 0.5; + if hshift { + tx += phase * ty.floor(); + } else { + ty += phase * tx.floor(); + } + let (i, j) = (tx.floor(), ty.floor()); + let (mut fx, mut fy) = (tx - i, ty - j); + if mirror { + if (i as i64).rem_euclid(2) == 1 { + fx = 1.0 - fx; + } + if (j as i64).rem_euclid(2) == 1 { + fy = 1.0 - fy; + } + } + *px = src.sample_bilinear(fx * lw * s + b.offset[0], fy * lh * s + b.offset[1]); + } + }); + Buf { img: out, offset: noff, scale: s } +} + +// ---- Color Emboss ---- + +fn color_emboss(ctx: &EffectCtx, mut b: Buf) -> Buf { + let ang = ctx.params.f("direction").to_radians(); + let relief = ctx.params.f("relief") * b.scale; + let contrast = ctx.params.f("contrast") as f32 / 100.0; + let blend = ctx.params.f("blend") as f32 / 100.0; + let (dx, dy) = (ang.cos() * relief, -ang.sin() * relief); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (c, a) = unpremul(*px); + if a <= 0.0 { + continue; + } + let (pa, _) = unpremul(src.sample_bilinear_clamped(x as f64 + 0.5 + dx, y as f64 + 0.5 + dy)); + let (pb, _) = unpremul(src.sample_bilinear_clamped(x as f64 + 0.5 - dx, y as f64 + 0.5 - dy)); + let e = [0, 1, 2].map(|i| (c[i] + (pa[i] - pb[i]) * contrast).max(0.0)); + *px = premul(lerp3(e, c, blend), a); + } + }); + b +} + +// ---- CC Vignette ---- + +fn vignette(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amt = ctx.params.f("amount") as f32 / 100.0; + let fov = ctx.params.f("angleOfView").clamp(0.0, 179.0).to_radians(); + if amt == 0.0 || fov <= 0.0 { + return b; + } + let c = b.to_px(ctx.params.v2("center")); + let pin = ctx.params.f("pinHighlights") as f32 / 100.0; + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let half_diag = (lw * lw + lh * lh).sqrt() * 0.5; + let dist = half_diag.max(1.0) / (fov * 0.5).tan(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (c3, a) = unpremul(*px); + if a <= 0.0 { + continue; + } + let r = ((x as f64 + 0.5 - c.0).powi(2) + (y as f64 + 0.5 - c.1).powi(2)).sqrt() / dist; + let f = (1.0 / (1.0 + r * r)).powi(2) as f32; + let mut k = 1.0 - amt * (1.0 - f); + if pin > 0.0 { + let l = luminance(c3[0], c3[1], c3[2]).clamp(0.0, 1.0); + k += (1.0 - k) * pin * l; + } + *px = premul(c3.map(|v| (v * k).max(0.0)), a); + } + }); + b +} + +// ---- CC Threshold / CC Threshold RGB ---- + +fn cc_threshold(ctx: &EffectCtx, mut b: Buf) -> Buf { + let t = ctx.params.f("threshold") as f32 / 255.0; + let ch = ctx.params.e("channel"); + let inv = ctx.params.b("invert"); + let blend = ctx.params.f("blend") as f32 / 100.0; + let bin = |v: f32| if (v >= t) != inv { 1.0 } else { 0.0 }; + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + let (o, na) = match ch { + 1 => (c.map(bin), a), + 2 => ([bin(rgb_to_hsl(c[0], c[1], c[2]).1); 3], a), + 3 => (c, bin(a)), + _ => ([bin(luminance(c[0], c[1], c[2])); 3], a), + }; + let o = lerp3(o, c, blend); + let na = na + (a - na) * blend; + *px = premul(o, na); + }); + b +} + +fn cc_threshold_rgb(ctx: &EffectCtx, mut b: Buf) -> Buf { + let t = [ctx.params.f("redThreshold"), ctx.params.f("greenThreshold"), ctx.params.f("blueThreshold")].map(|v| v as f32 / 255.0); + let inv = [ctx.params.b("invertRed"), ctx.params.b("invertGreen"), ctx.params.b("invertBlue")]; + let blend = ctx.params.f("blend") as f32 / 100.0; + b.img.map_straight(|c| { + let o = [0, 1, 2].map(|i| if (c[i] >= t[i]) != inv[i] { 1.0 } else { 0.0 }); + lerp3(o, c, blend) + }); + b +} + +pub fn specs() -> Vec { + let pt = |x, y| Value::Vec2([x, y]); + let thr = || slider(0.0, 255.0, 0.0, 255.0, 0); + vec![ + spec( + "ec.stylize.cartoon", + "Cartoon", + vec![ + p("render", "Render", Value::Enum(2), popup(&["Fill", "Edges", "Fill & Edges"])), + p("detailRadius", "Detail Radius", num(8.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("detailThreshold", "Detail Threshold", num(10.0), pct()), + p("shadingSteps", "Shading Steps", num(8.0), slider(1.0, 64.0, 1.0, 64.0, 1)), + p("shadingSmoothness", "Shading Smoothness", num(70.0), pct()), + p("edgeThreshold", "Edge Threshold", num(1.5), slider(0.0, 5.0, 0.0, 5.0, 2)), + p("edgeWidth", "Edge Width", num(1.5), slider(0.0, 10.0, 0.0, 5.0, 2)), + p("edgeSoftness", "Edge Softness", num(60.0), pct()), + p("edgeOpacity", "Edge Opacity", num(100.0), pct()), + ], + cartoon, + ), + spec( + "ec.stylize.roughenedges", + "Roughen Edges", + vec![ + p( + "edgeType", + "Edge Type", + Value::Enum(0), + popup(&["Roughen", "Roughen Color", "Cut", "Spiky", "Rusty", "Rusty Color", "Photocopy", "Photocopy Color"]), + ), + p("edgeColor", "Edge Color", col(0.55, 0.3, 0.1), ParamUi::Color), + p("border", "Border", num(8.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p("edgeSharpness", "Edge Sharpness", num(1.0), slider(0.0, 10.0, 0.0, 10.0, 2)), + p("fractalInfluence", "Fractal Influence", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("scale", "Scale", num(100.0), slider(10.0, 1000.0, 10.0, 300.0, 1)), + p("offset", "Offset (Turbulence)", pt(0.0, 0.0), ParamUi::Point), + p("complexity", "Complexity", num(2.0), slider(1.0, 10.0, 1.0, 10.0, 0)), + p("evolution", "Evolution", num(0.0), ParamUi::Angle), + ], + roughen_edges, + ), + spec( + "ec.stylize.scatter", + "Scatter", + vec![ + p("amount", "Scatter Amount", num(0.0), slider(0.0, 127.0, 0.0, 127.0, 1)), + p("grain", "Grain", Value::Enum(0), popup(&["Both", "Horizontal", "Vertical"])), + p("randomizeEveryFrame", "Randomize Every Frame", Value::Bool(false), ParamUi::Checkbox), + ], + scatter, + ), + spec( + "ec.stylize.strobe", + "Strobe Light", + vec![ + p("strobeColor", "Strobe Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("blendWithOriginal", "Blend With Original", num(0.0), pct()), + p("strobeDuration", "Strobe Duration (secs)", num(0.05), slider(0.0, 30.0, 0.0, 1.0, 2)), + p("strobePeriod", "Strobe Period (secs)", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 2)), + p("randomStrobeProbability", "Random Strobe Probability", num(0.0), pct()), + p("strobe", "Strobe", Value::Enum(0), popup(&["Operates On Color Only", "Makes Layer Transparent"])), + p( + "strobeOperator", + "Strobe Operator", + Value::Enum(0), + popup(&["Copy", "Add", "Subtract", "Multiply", "Difference", "Screen", "Lighten", "Darken"]), + ), + ], + strobe, + ), + spec( + "ec.stylize.texturize", + "Texturize", + vec![ + p("lightDirection", "Light Direction", num(135.0), ParamUi::Angle), + p("textureContrast", "Texture Contrast", num(1.0), slider(0.0, 2.0, 0.0, 2.0, 2)), + ], + texturize, + ), + spec( + "ec.stylize.brushstrokes", + "Brush Strokes", + vec![ + p("strokeAngle", "Stroke Angle", num(135.0), ParamUi::Angle), + p("brushSize", "Brush Size", num(2.0), slider(0.0, 5.0, 0.0, 5.0, 1)), + p("strokeLength", "Stroke Length", num(4.0), slider(0.0, 40.0, 0.0, 40.0, 0)), + p("strokeDensity", "Stroke Density", num(1.0), slider(0.0, 2.0, 0.0, 2.0, 2)), + p("strokeRandomness", "Stroke Randomness", num(1.0), slider(0.0, 2.0, 0.0, 2.0, 2)), + p( + "paintSurface", + "Paint Surface", + Value::Enum(0), + popup(&["Paint On Original Image", "Paint On Transparent", "Paint On White", "Paint On Black"]), + ), + p("blendWithOriginal", "Blend With Original", num(0.0), pct()), + ], + brush_strokes, + ), + spec( + "ec.stylize.cckaleida", + "CC Kaleida", + vec![ + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("size", "Size", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)), + p("mirroring", "Mirroring", Value::Enum(0), popup(&["Flower", "Starlish", "Unfold", "Wheel", "Triangles"])), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + ], + kaleida, + ), + spec( + "ec.stylize.ccrepetile", + "CC RepeTile", + vec![ + p("expandRight", "Expand Right", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)), + p("expandLeft", "Expand Left", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)), + p("expandDown", "Expand Down", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)), + p("expandUp", "Expand Up", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)), + p("tiling", "Tiling", Value::Enum(0), popup(&["Repeat", "Unfold", "Checker Flip H", "Checker Flip V", "Checker Flip"])), + ], + repetile, + ), + spec( + "ec.stylize.motiontile", + "Motion Tile", + vec![ + p("tileCenter", "Tile Center", pt(0.5, 0.5), ParamUi::Point), + p("tileWidth", "Tile Width", num(100.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("tileHeight", "Tile Height", num(100.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("outputWidth", "Output Width", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)), + p("outputHeight", "Output Height", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)), + p("mirrorEdges", "Mirror Edges", Value::Bool(false), ParamUi::Checkbox), + p("phase", "Phase", num(0.0), ParamUi::Angle), + p("horizontalPhaseShift", "Horizontal Phase Shift", Value::Bool(false), ParamUi::Checkbox), + ], + motion_tile, + ), + spec( + "ec.stylize.coloremboss", + "Color Emboss", + vec![ + p("direction", "Direction", num(45.0), ParamUi::Angle), + p("relief", "Relief", num(1.5), slider(0.0, 10.0, 0.0, 10.0, 2)), + p("contrast", "Contrast", num(100.0), slider(0.0, 500.0, 0.0, 500.0, 0)), + p("blend", "Blend With Original", num(0.0), pct()), + ], + color_emboss, + ), + spec( + "ec.stylize.ccvignette", + "CC Vignette", + vec![ + p("amount", "Amount", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("angleOfView", "Angle of View", num(60.0), slider(0.0, 179.0, 0.0, 179.0, 1)), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("pinHighlights", "Pin Highlights", num(0.0), pct()), + ], + vignette, + ), + spec( + "ec.stylize.ccthreshold", + "CC Threshold", + vec![ + p("threshold", "Threshold", num(127.0), thr()), + p("channel", "Channel", Value::Enum(0), popup(&["Luminance", "RGB", "Saturation", "Alpha"])), + p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + p("blend", "Blend w. Original", num(0.0), pct()), + ], + cc_threshold, + ), + spec( + "ec.stylize.ccthresholdrgb", + "CC Threshold RGB", + vec![ + p("redThreshold", "Red Threshold", num(127.0), thr()), + p("greenThreshold", "Green Threshold", num(127.0), thr()), + p("blueThreshold", "Blue Threshold", num(127.0), thr()), + p("invertRed", "Invert Red Channel", Value::Bool(false), ParamUi::Checkbox), + p("invertGreen", "Invert Green Channel", Value::Bool(false), ParamUi::Checkbox), + p("invertBlue", "Invert Blue Channel", Value::Bool(false), ParamUi::Checkbox), + p("blend", "Blend w. Original", num(0.0), pct()), + ], + cc_threshold_rgb, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Params; + + fn run_at(id: &str, vals: &[(&str, Value)], img: Image, time: f64) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in vals { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn ramp(w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + img.set(x, y, [x as f32 / w as f32, y as f32 / h as f32, 0.3, 1.0]); + } + } + img + } + + #[test] + fn repetile_grows_and_repeats() { + let img = ramp(10, 8); + let out = run_at("ec.stylize.ccrepetile", &[("expandRight", num(10.0)), ("expandUp", num(4.0))], img.clone(), 0.0); + assert_eq!((out.img.width, out.img.height), (20, 12)); + assert_eq!(out.offset, [0.0, 4.0]); + // Repeat: pixel (x, y) of the tile to the right equals the original. + let a = out.img.get(13, 6); + let b = img.get(3, 2); + for c in 0..4 { + assert!((a[c] - b[c]).abs() < 1e-5); + } + } + + #[test] + fn motion_tile_output_size() { + let out = run_at( + "ec.stylize.motiontile", + &[("outputWidth", num(200.0)), ("outputHeight", num(150.0)), ("tileCenter", Value::Vec2([5.0, 4.0]))], + ramp(10, 8), + 0.0, + ); + assert_eq!((out.img.width, out.img.height), (20, 12)); + assert_eq!(out.offset, [5.0, 2.0]); + // Default tiles: identity inside the layer rect. + let same = run_at("ec.stylize.motiontile", &[("tileCenter", Value::Vec2([5.0, 4.0]))], ramp(10, 8), 0.0); + let img = ramp(10, 8); + for (a, b) in same.img.data.iter().zip(img.data.iter()) { + assert!((a[0] - b[0]).abs() < 1e-4); + } + } + + #[test] + fn strobe_by_time() { + assert!(strobe_on(0.02, 0.05, 1.0, 0.0, 1)); + assert!(!strobe_on(0.5, 0.05, 1.0, 0.0, 1)); + assert!(strobe_on(3.01, 0.05, 1.0, 0.0, 1)); + let img = Image::filled(4, 4, [0.2, 0.2, 0.2, 1.0]); + let on = run_at("ec.stylize.strobe", &[], img.clone(), 0.01); + assert_eq!(on.img.data[0], [1.0, 1.0, 1.0, 1.0]); + let off = run_at("ec.stylize.strobe", &[], img.clone(), 0.5); + assert_eq!(off.img, img); + let tr = run_at("ec.stylize.strobe", &[("strobe", Value::Enum(1))], img, 0.01); + assert_eq!(tr.img.data[0][3], 0.0); + } + + #[test] + fn cc_threshold_is_binary() { + let out = run_at("ec.stylize.ccthreshold", &[], ramp(16, 4), 0.0); + assert!(out.img.data.iter().all(|p| p[0] == 0.0 || p[0] == 1.0)); + assert!(out.img.data.iter().any(|p| p[0] == 0.0) && out.img.data.iter().any(|p| p[0] == 1.0)); + let rgb = run_at("ec.stylize.ccthresholdrgb", &[], ramp(16, 4), 0.0); + assert!(rgb.img.data.iter().all(|p| p[..3].iter().all(|&v| v == 0.0 || v == 1.0))); + } + + #[test] + fn roughen_only_removes_alpha() { + let mut img = Image::new(32, 32); + for y in 8..24 { + for x in 8..24 { + img.set(x, y, [0.5, 0.5, 0.5, 1.0]); + } + } + let out = run_at("ec.stylize.roughenedges", &[], img.clone(), 0.0); + assert!(out.img.data.iter().zip(img.data.iter()).all(|(o, i)| o[3] <= i[3] + 1e-6)); + assert!(out.img.data.iter().zip(img.data.iter()).any(|(o, i)| o[3] < i[3] - 0.1)); + assert!((out.img.get(16, 16)[3] - 1.0).abs() < 1e-6); + } + + #[test] + fn vignette_darkens_corners_not_center() { + let img = Image::filled(40, 40, [0.8, 0.8, 0.8, 1.0]); + let out = run_at("ec.stylize.ccvignette", &[("amount", num(100.0)), ("center", Value::Vec2([20.0, 20.0]))], img, 0.0); + assert!(out.img.get(0, 0)[0] < out.img.get(20, 20)[0] - 0.05); + assert!((out.img.get(20, 20)[0] - 0.8).abs() < 0.01); + } + + #[test] + fn kaleida_is_symmetric() { + let out = run_at("ec.stylize.cckaleida", &[("mirroring", Value::Enum(2)), ("center", Value::Vec2([16.0, 16.0]))], ramp(32, 32), 0.0); + // Unfold (4 mirrored segments): mirror across the horizontal axis through the centre. + let a = out.img.get(20, 10); + let b = out.img.get(20, 21); + assert!((a[0] - b[0]).abs() < 0.05 && (a[1] - b[1]).abs() < 0.05, "{a:?} {b:?}"); + } + + #[test] + fn cartoon_quantises_flat_regions() { + let out = run_at("ec.stylize.cartoon", &[("render", Value::Enum(0)), ("shadingSmoothness", num(0.0)), ("shadingSteps", num(4.0))], ramp(24, 8), 0.0); + let mut lv: Vec = out + .img + .data + .iter() + .map(|p| { + let l = luminance(p[0], p[1], p[2]); + (l * 4.0 * 100.0).round() as i32 + }) + .collect(); + lv.sort(); + lv.dedup(); + assert!(lv.len() <= 6, "{lv:?}"); + } +} diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs new file mode 100644 index 00000000..b1ea6074 --- /dev/null +++ b/crates/effects/src/transition.rs @@ -0,0 +1,617 @@ +//! Transition effects (batch 2): Block Dissolve, Gradient Wipe (self gradient), Iris Wipe, Card +//! Wipe (2D cards), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe. +//! +//! Every transition is the identity at 0 % completion. Angles are clockwise from "up". + +use effectcraft_color::luminance; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::Px; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, hash1, layer_rect, remap, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Transition", params, render, gpu: false, float: true } +} + +fn completion(ctx: &EffectCtx) -> f64 { + (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) +} + +#[inline] +fn scale_px(px: &mut Px, k: f32) { + for c in px.iter_mut() { + *c *= k; + } +} + +/// Largest distance from `c` to a corner of the buffer. +fn max_corner_dist(b: &Buf, c: (f64, f64), metric: impl Fn(f64, f64) -> f64) -> f64 { + let (w, h) = (b.img.width as f64, b.img.height as f64); + [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|(x, y)| metric(x - c.0, y - c.1)).fold(1e-6, f64::max) +} + +fn block_dissolve(ctx: &EffectCtx, mut b: Buf) -> Buf { + let done = completion(ctx) as f32; + if done <= 0.0 { + return b; + } + let bw = (ctx.params.f("blockWidth") * b.scale).max(1e-3); + let bh = (ctx.params.f("blockHeight") * b.scale).max(1e-3); + let feather = ctx.params.f("feather") * b.scale; + let (ox, oy) = (b.offset[0], b.offset[1]); + let seed = ctx.seed ^ 0xb10c; + let mut mask = Plane::new(b.img.width as usize, b.img.height as usize); + let w = mask.w; + mask.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + let by = ((y as f64 + 0.5 - oy) / bh).floor() as i64; + for (x, m) in row.iter_mut().enumerate() { + let bx = ((x as f64 + 0.5 - ox) / bw).floor() as i64; + let r = hash1(bx as u32, by as u32, seed); + *m = if r >= done { 1.0 } else { 0.0 }; + } + }); + if feather > 0.0 { + mask = gauss_plane(&mask, feather * 0.5, feather * 0.5); + } + b.img.mul_mask(&mask.data); + b +} + +fn gradient_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let done = completion(ctx) as f32; + if done <= 0.0 { + return b; + } + let soft = (ctx.params.f("softness") / 100.0) as f32; + let invert = ctx.params.b("invert"); + b.img.data.par_iter_mut().for_each(|px| { + let (c, _) = unpremul(*px); + let mut t = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + if invert { + t = 1.0 - t; + } + let k = if done >= 1.0 { + 0.0 + } else if soft > 0.0 { + ((t - (done * (1.0 + soft) - soft)) / soft).clamp(0.0, 1.0) + } else if t >= done { + 1.0 + } else { + 0.0 + }; + scale_px(px, k); + }); + b +} + +#[inline] +fn cross(a: (f64, f64), b: (f64, f64)) -> f64 { + a.0 * b.1 - a.1 * b.0 +} + +fn iris_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let outer = ctx.params.f("outerRadius").max(0.0) * b.scale; + if outer <= 0.0 { + return b; + } + let n = ctx.params.f("points").round().clamp(3.0, 64.0) as usize; + let inner = if ctx.params.b("useInnerRadius") { ctx.params.f("innerRadius").max(0.0) * b.scale } else { outer }; + let star = ctx.params.b("useInnerRadius"); + let rot = ctx.params.f("rotation").to_radians(); + let feather = ctx.params.f("feather") * b.scale; + let c = b.to_px(ctx.params.v2("center")); + let m = if star { n * 2 } else { n }; + let step = std::f64::consts::TAU / m as f64; + let verts: Vec<(f64, f64)> = (0..m) + .map(|i| { + let r = if star && i % 2 == 1 { inner } else { outer }; + let a = rot + i as f64 * step; + (r * a.sin(), -r * a.cos()) + }) + .collect(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let d = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let r = d.0.hypot(d.1); + let rb = if r < 1e-9 { + outer.min(inner) + } else { + let a = (d.0.atan2(-d.1) - rot).rem_euclid(std::f64::consts::TAU); + let i = ((a / step).floor() as usize).min(m - 1); + let (vi, vj) = (verts[i], verts[(i + 1) % m]); + let e = (vj.0 - vi.0, vj.1 - vi.1); + let dir = (d.0 / r, d.1 / r); + let den = cross(dir, e); + if den.abs() < 1e-12 { outer } else { cross(vi, e) / den } + }; + let k = if feather > 0.0 { + ((r - rb) / feather + 0.5).clamp(0.0, 1.0) + } else if r >= rb { + 1.0 + } else { + 0.0 + }; + scale_px(px, k as f32); + } + }); + b +} + +fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let done = completion(ctx); + if done <= 0.0 { + return b; + } + let tw = (ctx.params.f("transitionWidth") / 100.0).clamp(0.01, 1.0); + let rows = ctx.params.f("rows").round().clamp(1.0, 1000.0) as i64; + let cols = ctx.params.f("columns").round().clamp(1.0, 1000.0) as i64; + let axis = ctx.params.e("flipAxis"); + let order = ctx.params.e("flipOrder"); + let back_self = ctx.params.e("backLayer") == 1; + let seed = ctx.params.f("randomSeed") as u32 ^ 0xca7d; + let (x0, y0, w, h) = layer_rect(ctx, &b); + let (cw, ch) = (w / cols as f64, h / rows as f64); + let nx = |i: i64| if cols > 1 { i as f64 / (cols - 1) as f64 } else { 0.0 }; + let ny = |j: i64| if rows > 1 { j as f64 / (rows - 1) as f64 } else { 0.0 }; + let src = b.img.clone(); + b.img = remap(&src, false, |x, y| { + let (lx, ly) = (x - x0, y - y0); + if lx < 0.0 || ly < 0.0 || lx >= w || ly >= h { + return Some((x, y)); + } + let i = ((lx / cw).floor() as i64).min(cols - 1); + let j = ((ly / ch).floor() as i64).min(rows - 1); + let o = match order { + 1 => 1.0 - nx(i), + 2 => ny(j), + 3 => 1.0 - ny(j), + 4 => (nx(i) + ny(j)) * 0.5, + 5 => 1.0 - (nx(i) + ny(j)) * 0.5, + 6 => hash1(i as u32, j as u32, seed) as f64, + _ => nx(i), + }; + let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0); + if p <= 0.0 { + return Some((x, y)); + } + let cos = (p * std::f64::consts::PI).cos(); + if cos.abs() < 1e-4 || (cos < 0.0 && !back_self) { + return None; + } + let around_x = match axis { + 1 => false, + 2 => hash1(i as u32, j as u32, seed ^ 0x55) < 0.5, + _ => true, + }; + let (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch); + let (mut dx, mut dy) = (x - ccx, y - ccy); + if around_x { + dy /= cos; + if dy.abs() > ch * 0.5 { + return None; + } + } else { + dx /= cos; + if dx.abs() > cw * 0.5 { + return None; + } + } + Some((ccx + dx, ccy + dy)) + }); + b +} + +fn grid_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let done = completion(ctx); + if done <= 0.0 { + return b; + } + let c = b.to_px(ctx.params.v2("center")); + let rot = ctx.params.f("rotation").to_radians(); + let border = ctx.params.f("border").max(0.0) * b.scale; + let tiles = ctx.params.f("tiles").round().clamp(1.0, 500.0); + let shape = ctx.params.e("shape"); + let reverse = ctx.params.b("reverse"); + let (_, _, w, _) = layer_rect(ctx, &b); + let cs = (w / tiles).max(1.0); + let (s, co) = rot.sin_cos(); + let metric = |dx: f64, dy: f64| match shape { + 0 => dx.abs(), + 2 => dx.abs().max(dy.abs()), + _ => dx.hypot(dy), + }; + let maxd = max_corner_dist(&b, c, |dx, dy| { + let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co); + metric(qx, qy) + }); + let spread = 0.25; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co); + let (cx, cy) = (((qx / cs).floor() + 0.5) * cs, ((qy / cs).floor() + 0.5) * cs); + let mut o = (metric(cx, cy) / maxd).clamp(0.0, 1.0); + if reverse { + o = 1.0 - o; + } + let p = ((done * (1.0 + spread) - o) / spread).clamp(0.0, 1.0); + if p <= 0.0 { + continue; + } + let half = cs * 0.5 * (1.0 - p); + let m = (qx - cx).abs().max((qy - cy).abs()); + let k = if border > 0.0 { + ((half - m) / border + 0.5).clamp(0.0, 1.0) + } else if m < half { + 1.0 + } else { + 0.0 + }; + scale_px(px, k as f32); + } + }); + b +} + +fn radial_scale_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let done = completion(ctx); + if done <= 0.0 { + return b; + } + let c = b.to_px(ctx.params.v2("center")); + let reverse = ctx.params.b("reverse"); + let maxd = max_corner_dist(&b, c, f64::hypot); + let hole = done * maxd; + let src = b.img.clone(); + b.img = remap(&src, false, |x, y| { + let (dx, dy) = (x - c.0, y - c.1); + let r = dx.hypot(dy); + if reverse { + let lim = (1.0 - done) * maxd; + if r >= lim || lim <= 1e-9 { + return None; + } + let k = maxd / lim; + Some((c.0 + dx * k, c.1 + dy * k)) + } else { + if r <= hole || maxd - hole <= 1e-9 { + return None; + } + let rs = (r - hole) * maxd / (maxd - hole); + Some((c.0 + dx / r * rs, c.1 + dy / r * rs)) + } + }); + b +} + +fn scale_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let stretch = ctx.params.f("stretch").max(0.0); + if stretch <= 0.0 { + return b; + } + let c = b.to_px(ctx.params.v2("center")); + let a = ctx.params.f("direction").to_radians(); + let dir = (a.sin(), -a.cos()); + let src = b.img.clone(); + b.img = remap(&src, true, |x, y| { + let u = (x - c.0) * dir.0 + (y - c.1) * dir.1; + if u <= 0.0 { + return Some((x, y)); + } + let back = u - u / (1.0 + stretch); + Some((x - dir.0 * back, y - dir.1 * back)) + }); + b +} + +fn light_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let done = completion(ctx); + if done <= 0.0 { + return b; + } + let c = b.to_px(ctx.params.v2("center")); + let intensity = ctx.params.f("intensity") as f32 / 100.0; + let shape = ctx.params.e("shape"); + let a = ctx.params.f("direction").to_radians(); + let (s, co) = a.sin_cos(); + let from_source = ctx.params.b("colorFromSource"); + let color = ctx.params.color("color"); + let reverse = ctx.params.b("reverse"); + let metric = move |dx: f64, dy: f64| { + let (qx, qy) = (dx * co + dy * s, -dx * s + dy * co); + match shape { + 0 => qx.abs(), + 2 => qx.abs().max(qy.abs()), + _ => qx.hypot(qy), + } + }; + let maxd = max_corner_dist(&b, c, metric); + let edge = if reverse { (1.0 - done) * maxd } else { done * maxd }; + let band = (maxd * 0.06).max(2.0); + let ramp = (done * 20.0).min(1.0) as f32 * ((1.0 - done) * 20.0).min(1.0) as f32; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let m = metric(x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let visible = if reverse { m < edge } else { m > edge }; + if !visible { + *px = [0.0; 4]; + continue; + } + let g = (-((m - edge) / band).powi(2)).exp() as f32 * intensity * ramp; + if g <= 0.0 { + continue; + } + let (sc, a) = unpremul(*px); + let lc = if from_source { sc } else { [color[0], color[1], color[2]] }; + for i in 0..3 { + px[i] += lc[i] * g * a; + } + } + }); + b +} + +pub fn specs() -> Vec { + let pt = |x, y| Value::Vec2([x, y]); + let done = || p("completion", "Transition Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)); + vec![ + spec( + "ec.transition.blockdissolve", + "Block Dissolve", + vec![ + done(), + p("blockWidth", "Block Width", num(1.0), slider(1.0, 4000.0, 1.0, 100.0, 1)), + p("blockHeight", "Block Height", num(1.0), slider(1.0, 4000.0, 1.0, 100.0, 1)), + p("feather", "Feather", num(0.0), slider(0.0, 400.0, 0.0, 50.0, 1)), + ], + block_dissolve, + ), + spec( + "ec.transition.gradientwipe", + "Gradient Wipe", + vec![ + done(), + p("softness", "Transition Softness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("invert", "Invert Gradient", Value::Bool(false), ParamUi::Checkbox), + ], + gradient_wipe, + ), + spec( + "ec.transition.iriswipe", + "Iris Wipe", + vec![ + p("center", "Iris Center", pt(0.5, 0.5), ParamUi::Point), + p("points", "Iris Points", num(6.0), slider(6.0, 32.0, 6.0, 32.0, 0)), + p("outerRadius", "Outer Radius", num(0.0), slider(0.0, 32000.0, 0.0, 2000.0, 1)), + p("useInnerRadius", "Use Inner Radius", Value::Bool(false), ParamUi::Checkbox), + p("innerRadius", "Inner Radius", num(0.0), slider(0.0, 32000.0, 0.0, 2000.0, 1)), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("feather", "Feather", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + ], + iris_wipe, + ), + spec( + "ec.transition.cardwipe", + "Card Wipe", + vec![ + done(), + p("transitionWidth", "Transition Width", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 0)), + p("backLayer", "Back Layer", Value::Enum(0), popup(&["None", "Self"])), + p("rows", "Rows", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("columns", "Columns", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("flipAxis", "Flip Axis", Value::Enum(0), popup(&["X", "Y", "Random"])), + p( + "flipOrder", + "Flip Order", + Value::Enum(0), + popup(&[ + "Left to Right", + "Right to Left", + "Top to Bottom", + "Bottom to Top", + "Top Left to Bottom Right", + "Bottom Right to Top Left", + "Random", + ]), + ), + p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), + ], + card_wipe, + ), + spec( + "ec.transition.ccgridwipe", + "CC Grid Wipe", + vec![ + done(), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("border", "Border", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("tiles", "Tiles", num(10.0), slider(1.0, 500.0, 1.0, 50.0, 1)), + p("shape", "Shape", Value::Enum(1), popup(&["Doors", "Radial", "Rectangular"])), + p("reverse", "Reverse Transition", Value::Bool(false), ParamUi::Checkbox), + ], + grid_wipe, + ), + spec( + "ec.transition.ccradialscalewipe", + "CC Radial ScaleWipe", + vec![done(), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), p("reverse", "Reverse Transition", Value::Bool(false), ParamUi::Checkbox)], + radial_scale_wipe, + ), + spec( + "ec.transition.ccscalewipe", + "CC Scale Wipe", + vec![ + p("stretch", "Stretch", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("direction", "Direction", num(90.0), ParamUi::Angle), + ], + scale_wipe, + ), + spec( + "ec.transition.cclightwipe", + "CC Light Wipe", + vec![ + done(), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("intensity", "Intensity", num(100.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + p("shape", "Shape", Value::Enum(1), popup(&["Doors", "Round", "Square"])), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("colorFromSource", "Color from Source", Value::Bool(false), ParamUi::Checkbox), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("reverse", "Reverse Transition", Value::Bool(false), ParamUi::Checkbox), + ], + light_wipe, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Params, find}; + use effectcraft_raster::Image; + + fn run(id: &str, img: &Image, set: &[(&str, Value)]) -> Image { + let s = find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for ps in &s.params { + if let (ParamUi::Point, Value::Vec2(f)) = (&ps.ui, &ps.default) { + params.values.insert(ps.id.to_string(), Value::Vec2([f[0] * img.width as f64, f[1] * img.height as f64])); + } + } + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img + } + + fn ramp(w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let v = (x as f32 + 0.5) / w as f32; + img.set(x, y, [v, v, v, 1.0]); + } + } + img + } + + fn alpha_sum(img: &Image) -> f32 { + img.data.iter().map(|p| p[3]).sum() + } + + #[test] + fn transitions_are_identity_at_zero_and_clear_at_full() { + let img = ramp(40, 30); + let full = alpha_sum(&img); + for id in [ + "ec.transition.blockdissolve", + "ec.transition.gradientwipe", + "ec.transition.cardwipe", + "ec.transition.ccgridwipe", + "ec.transition.ccradialscalewipe", + "ec.transition.cclightwipe", + ] { + assert_eq!(run(id, &img, &[]), img, "{id} at 0%"); + let out = run(id, &img, &[("completion", num(100.0))]); + assert!(alpha_sum(&out) < full * 0.02, "{id} at 100%: {}", alpha_sum(&out)); + let half = alpha_sum(&run(id, &img, &[("completion", num(50.0))])); + assert!(half > 0.0 && half < full, "{id} at 50%: {half}"); + } + } + + #[test] + fn gradient_wipe_removes_dark_pixels_first() { + let img = ramp(40, 4); + let out = run("ec.transition.gradientwipe", &img, &[("completion", num(50.0))]); + assert_eq!(out.get(5, 1)[3], 0.0); + assert_eq!(out.get(35, 1)[3], 1.0); + let inv = run("ec.transition.gradientwipe", &img, &[("completion", num(50.0)), ("invert", Value::Bool(true))]); + assert_eq!(inv.get(5, 1)[3], 1.0); + assert_eq!(inv.get(35, 1)[3], 0.0); + } + + #[test] + fn iris_wipe_opens_a_polygon_hole() { + let img = Image::filled(60, 60, [1.0, 1.0, 1.0, 1.0]); + assert_eq!(run("ec.transition.iriswipe", &img, &[]), img); + let out = run("ec.transition.iriswipe", &img, &[("outerRadius", num(20.0))]); + assert_eq!(out.get(30, 30)[3], 0.0); + assert_eq!(out.get(30, 12)[3], 0.0, "vertex points up"); + assert_eq!(out.get(2, 2)[3], 1.0); + // A hexagon's flat side is closer than its vertex: cos(30°)·20 ≈ 17.3 px. + assert_eq!(out.get(30 + 18, 30)[3], 1.0); + // Star with a small inner radius leaves the space between points opaque. + let star = run("ec.transition.iriswipe", &img, &[("outerRadius", num(20.0)), ("useInnerRadius", Value::Bool(true)), ("innerRadius", num(5.0))]); + assert_eq!(star.get(30, 15)[3], 0.0); + assert_eq!(star.get(30 + 12, 30)[3], 1.0); + } + + #[test] + fn block_dissolve_is_binary_per_block() { + let img = Image::filled(40, 40, [1.0, 1.0, 1.0, 1.0]); + let out = run("ec.transition.blockdissolve", &img, &[("completion", num(50.0)), ("blockWidth", num(10.0)), ("blockHeight", num(10.0))]); + for by in 0..4 { + for bx in 0..4 { + let a = out.get(bx * 10, by * 10)[3]; + for y in 0..10 { + for x in 0..10 { + assert_eq!(out.get(bx * 10 + x, by * 10 + y)[3], a); + } + } + } + } + let n = alpha_sum(&out) / 100.0; + assert!(n > 2.0 && n < 14.0, "{n}"); + } + + #[test] + fn card_wipe_flips_cards_in_order() { + let img = Image::filled(80, 40, [1.0, 0.0, 0.0, 1.0]); + let out = run("ec.transition.cardwipe", &img, &[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0))]); + // Left cards are gone, right cards untouched. + assert_eq!(out.get(2, 20)[3], 0.0); + assert_eq!(out.get(77, 20), [1.0, 0.0, 0.0, 1.0]); + let back = run( + "ec.transition.cardwipe", + &img, + &[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backLayer", Value::Enum(1))], + ); + assert_eq!(back.get(2, 20)[3], 1.0); + } + + #[test] + fn radial_scale_wipe_pushes_content_outward() { + let img = ramp(41, 41); + let out = run("ec.transition.ccradialscalewipe", &img, &[("completion", num(30.0))]); + assert_eq!(out.get(20, 20)[3], 0.0); + assert!(out.get(0, 0)[3] > 0.0); + let rev = run("ec.transition.ccradialscalewipe", &img, &[("completion", num(30.0)), ("reverse", Value::Bool(true))]); + assert!(rev.get(20, 20)[3] > 0.0); + assert_eq!(rev.get(0, 0)[3], 0.0); + } + + #[test] + fn scale_wipe_smears_past_the_center_line() { + let img = ramp(40, 4); + assert_eq!(run("ec.transition.ccscalewipe", &img, &[]), img); + let out = run("ec.transition.ccscalewipe", &img, &[("stretch", num(1000.0))]); + // direction 90° → to the right of centre, everything samples (almost) the centre column. + assert_eq!(out.get(10, 1), img.get(10, 1)); + let centre = img.get(20, 1)[0]; + assert!((out.get(38, 1)[0] - centre).abs() < 0.03, "{}", out.get(38, 1)[0]); + } + + #[test] + fn grid_wipe_shrinks_tiles_from_center() { + let img = Image::filled(100, 100, [1.0, 1.0, 1.0, 1.0]); + let out = run("ec.transition.ccgridwipe", &img, &[("completion", num(30.0))]); + assert_eq!(out.get(50, 50)[3], 0.0, "centre tiles are gone first"); + assert_eq!(out.get(1, 1)[3], 1.0, "corners last"); + } +} diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs new file mode 100644 index 00000000..346d7619 --- /dev/null +++ b/crates/effects/src/util.rs @@ -0,0 +1,463 @@ +//! Shared helpers for effect implementations: single-channel float planes (separable box / +//! Gaussian blur, O(1)-per-pixel min/max filters, box means for guided filtering), channel +//! pickers, a small deterministic RNG and per-pixel helpers. + +use effectcraft_color::{luminance, rgb_to_hsl}; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +/// A single-channel `f32` image (row-major). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Plane { + pub w: usize, + pub h: usize, + pub data: Vec, +} + +impl Plane { + pub fn new(w: usize, h: usize) -> Plane { + Plane { w, h, data: vec![0.0; w * h] } + } + /// Build from an image by mapping every (premultiplied) pixel. + pub fn from_image(img: &Image, f: impl Fn(Px) -> f32 + Sync) -> Plane { + Plane { w: img.width as usize, h: img.height as usize, data: img.data.par_iter().map(|&p| f(p)).collect() } + } + pub fn alpha(img: &Image) -> Plane { + Plane::from_image(img, |p| p[3]) + } + /// Straight-colour Rec. 709 luminance (0 where transparent). + pub fn luma(img: &Image) -> Plane { + Plane::from_image(img, |p| { + let (c, _) = unpremul(p); + luminance(c[0], c[1], c[2]) + }) + } + #[inline] + pub fn get(&self, x: usize, y: usize) -> f32 { + self.data[y * self.w + x] + } + #[inline] + pub fn get_clamped(&self, x: i64, y: i64) -> f32 { + let x = x.clamp(0, self.w as i64 - 1) as usize; + let y = y.clamp(0, self.h as i64 - 1) as usize; + self.data[y * self.w + x] + } + /// Bilinear sample at continuous pixel coordinates (centres at +0.5), edges clamped. + pub fn sample(&self, x: f64, y: f64) -> f32 { + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = fx.floor(); + let y0 = fy.floor(); + let tx = (fx - x0) as f32; + let ty = (fy - y0) as f32; + let (x0, y0) = (x0 as i64, y0 as i64); + let a = self.get_clamped(x0, y0); + let b = self.get_clamped(x0 + 1, y0); + let c = self.get_clamped(x0, y0 + 1); + let d = self.get_clamped(x0 + 1, y0 + 1); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + top + (bot - top) * ty + } + pub fn transpose(&self) -> Plane { + let (w, h) = (self.w, self.h); + let mut out = Plane::new(h, w); + out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| { + for y in 0..h { + col[y] = self.data[y * w + x]; + } + }); + out + } + /// Apply `f(src_row, dst_row)` to every row in parallel. + pub fn map_rows(&self, f: impl Fn(&[f32], &mut [f32]) + Sync) -> Plane { + let mut out = Plane::new(self.w, self.h); + if self.w == 0 { + return out; + } + out.data.par_chunks_mut(self.w).zip(self.data.par_chunks(self.w)).for_each(|(o, s)| f(s, o)); + out + } + /// Element-wise combination with another plane of the same size. + pub fn zip_map(&self, other: &Plane, f: impl Fn(f32, f32) -> f32 + Sync) -> Plane { + Plane { w: self.w, h: self.h, data: self.data.par_iter().zip(other.data.par_iter()).map(|(&a, &b)| f(a, b)).collect() } + } + pub fn map(&self, f: impl Fn(f32) -> f32 + Sync) -> Plane { + Plane { w: self.w, h: self.h, data: self.data.par_iter().map(|&a| f(a)).collect() } + } +} + +/// Box mean of radius `r` along each row (window 2r+1, edge pixels repeated). +fn box_row(src: &[f32], dst: &mut [f32], r: usize) { + let n = src.len(); + if n == 0 { + return; + } + if r == 0 { + dst.copy_from_slice(src); + return; + } + let get = |i: isize| src[i.clamp(0, n as isize - 1) as usize]; + let norm = 1.0 / (2 * r + 1) as f32; + let mut acc = 0.0f64; + for i in -(r as isize)..=(r as isize) { + acc += get(i) as f64; + } + for x in 0..n { + dst[x] = acc as f32 * norm; + acc += get(x as isize + r as isize + 1) as f64 - get(x as isize - r as isize) as f64; + } +} + +/// Separable box mean with radii `rx`, `ry` (edges repeated). +pub fn box_plane(p: &Plane, rx: usize, ry: usize) -> Plane { + let mut cur = if rx > 0 { p.map_rows(|s, d| box_row(s, d, rx)) } else { p.clone() }; + if ry > 0 { + cur = cur.transpose().map_rows(|s, d| box_row(s, d, ry)).transpose(); + } + cur +} + +/// Box radii for `n` passes approximating a Gaussian of `sigma`. +pub fn box_radii(sigma: f64, n: usize) -> Vec { + if sigma <= 0.0 { + return vec![0; n]; + } + let w_ideal = (12.0 * sigma * sigma / n as f64 + 1.0).sqrt(); + let mut wl = w_ideal.floor() as i64; + if wl % 2 == 0 { + wl -= 1; + } + let wu = wl + 2; + let m_ideal = (12.0 * sigma * sigma - (n as i64 * wl * wl) as f64 - 4.0 * n as f64 * wl as f64 - 3.0 * n as f64) / (-4.0 * wl as f64 - 4.0); + let m = m_ideal.round() as i64; + (0..n as i64).map(|i| (((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize).collect() +} + +/// Approximate Gaussian blur of a plane (3 box passes per axis, edges repeated). +pub fn gauss_plane(p: &Plane, sigma_x: f64, sigma_y: f64) -> Plane { + let mut cur = p.clone(); + if sigma_x > 0.05 { + for r in box_radii(sigma_x, 3) { + cur = cur.map_rows(|s, d| box_row(s, d, r)); + } + } + if sigma_y > 0.05 { + let mut t = cur.transpose(); + for r in box_radii(sigma_y, 3) { + t = t.map_rows(|s, d| box_row(s, d, r)); + } + cur = t.transpose(); + } + cur +} + +/// Running min/max over a window of radius `r` (van Herk / Gil-Werman, O(1) per pixel; edges +/// repeated). +pub fn minmax_row(src: &[f32], dst: &mut [f32], r: usize, max: bool) { + let n = src.len(); + if n == 0 { + return; + } + if r == 0 { + dst.copy_from_slice(src); + return; + } + let k = 2 * r + 1; + let len = n + 2 * r; + let get = |i: usize| src[(i as isize - r as isize).clamp(0, n as isize - 1) as usize]; + let op = |a: f32, b: f32| if max { a.max(b) } else { a.min(b) }; + let mut g = vec![0.0f32; len]; + let mut hh = vec![0.0f32; len]; + for i in 0..len { + let v = get(i); + g[i] = if i % k == 0 { v } else { op(g[i - 1], v) }; + } + for i in (0..len).rev() { + let v = get(i); + hh[i] = if i == len - 1 || (i + 1) % k == 0 { v } else { op(hh[i + 1], v) }; + } + for x in 0..n { + dst[x] = op(hh[x], g[x + k - 1]); + } +} + +/// Grey-scale erosion (`max = false`) or dilation (`max = true`) with a (2rx+1)×(2ry+1) box. +pub fn morph_plane(p: &Plane, rx: usize, ry: usize, max: bool) -> Plane { + let mut cur = if rx > 0 { p.map_rows(|s, d| minmax_row(s, d, rx, max)) } else { p.clone() }; + if ry > 0 { + cur = cur.transpose().map_rows(|s, d| minmax_row(s, d, ry, max)).transpose(); + } + cur +} + +/// Morphology with a fractional radius (linear blend between the two nearest integer radii). +pub fn morph_frac(p: &Plane, radius: f64, max: bool) -> Plane { + if radius <= 0.0 { + return p.clone(); + } + let r0 = radius.floor() as usize; + let t = (radius - r0 as f64) as f32; + let a = morph_plane(p, r0, r0, max); + if t < 1e-4 { + return a; + } + let b = morph_plane(p, r0 + 1, r0 + 1, max); + a.zip_map(&b, |x, y| x + (y - x) * t) +} + +/// He et al. guided filter: smooth `p` while following edges of guide `g` (radius `r`, regulariser `eps`). +pub fn guided_filter(g: &Plane, p: &Plane, r: usize, eps: f32) -> Plane { + let mean_g = box_plane(g, r, r); + let mean_p = box_plane(p, r, r); + let gp = g.zip_map(p, |a, b| a * b); + let gg = g.map(|a| a * a); + let corr_gp = box_plane(&gp, r, r); + let corr_gg = box_plane(&gg, r, r); + let n = g.data.len(); + let mut a = Plane::new(g.w, g.h); + let mut b = Plane::new(g.w, g.h); + a.data.par_iter_mut().zip(b.data.par_iter_mut()).enumerate().for_each(|(i, (av, bv))| { + let var = corr_gg.data[i] - mean_g.data[i] * mean_g.data[i]; + let cov = corr_gp.data[i] - mean_g.data[i] * mean_p.data[i]; + *av = cov / (var + eps); + *bv = mean_p.data[i] - *av * mean_g.data[i]; + }); + debug_assert_eq!(n, a.data.len()); + let ma = box_plane(&a, r, r); + let mb = box_plane(&b, r, r); + Plane { w: g.w, h: g.h, data: (0..n).into_par_iter().map(|i| ma.data[i] * g.data[i] + mb.data[i]).collect() } +} + +/// Split an image into its four premultiplied channel planes. +pub fn split(img: &Image) -> [Plane; 4] { + [0, 1, 2, 3].map(|c| Plane::from_image(img, |p| p[c])) +} + +/// Join four planes into an image. +pub fn join(ch: &[Plane; 4]) -> Image { + let (w, h) = (ch[0].w, ch[0].h); + let mut img = Image::new(w as u32, h as u32); + img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + *px = [ch[0].data[i], ch[1].data[i], ch[2].data[i], ch[3].data[i]]; + }); + img +} + +/// Premultiplied → (straight colour, alpha). +#[inline] +pub fn unpremul(p: Px) -> ([f32; 3], f32) { + let a = p[3]; + if a > 1e-6 { ([p[0] / a, p[1] / a, p[2] / a], a) } else { ([0.0; 3], a.max(0.0)) } +} + +#[inline] +pub fn premul(c: [f32; 3], a: f32) -> Px { + [c[0] * a, c[1] * a, c[2] * a, a] +} + +#[inline] +pub fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} + +#[inline] +pub fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)] +} + +#[inline] +pub fn lerp4(a: Px, b: Px, t: f32) -> Px { + [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t), lerp(a[3], b[3], t)] +} + +#[inline] +pub fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if (e1 - e0).abs() < 1e-9 { + return if x < e0 { 0.0 } else { 1.0 }; + } + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +#[inline] +pub fn rgb3(c: [f32; 4]) -> [f32; 3] { + [c[0], c[1], c[2]] +} + +/// Channel sources used by Set Channels, Shift Channels, Set Matte, Displacement Map, … +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Src { + Red, + Green, + Blue, + Alpha, + Luminance, + Hue, + Lightness, + Saturation, + Full, + Half, + Off, +} + +/// Value of a channel source for a straight colour `c` with alpha `a`. +#[inline] +pub fn pick(src: Src, c: [f32; 3], a: f32) -> f32 { + match src { + Src::Red => c[0], + Src::Green => c[1], + Src::Blue => c[2], + Src::Alpha => a, + Src::Luminance => luminance(c[0], c[1], c[2]), + Src::Hue => rgb_to_hsl(c[0], c[1], c[2]).0, + Src::Lightness => rgb_to_hsl(c[0], c[1], c[2]).2, + Src::Saturation => rgb_to_hsl(c[0], c[1], c[2]).1, + Src::Full => 1.0, + Src::Half => 0.5, + Src::Off => 0.0, + } +} + +/// The common source list in AE order: Red, Green, Blue, Alpha, Luminance, Hue, Lightness, +/// Saturation, Full, Half, Off. +pub const SRC_ORDER: [Src; 11] = + [Src::Red, Src::Green, Src::Blue, Src::Alpha, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Half, Src::Off]; +pub const SRC_NAMES: [&str; 11] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Hue", "Lightness", "Saturation", "Full", "Half", "Off"]; + +pub fn src_at(i: u32) -> Src { + SRC_ORDER[(i as usize).min(SRC_ORDER.len() - 1)] +} + +/// Tiny deterministic RNG (xorshift*), for procedural generators. +#[derive(Clone, Debug)] +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Rng { + Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xD1B5_4A32_D192_ED03 | 1) + } + pub fn next_u64(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + /// Uniform in [0, 1). + pub fn f(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } + /// Uniform in [-1, 1). + pub fn s(&mut self) -> f64 { + self.f() * 2.0 - 1.0 + } +} + +/// Integer hash → [0, 1). +#[inline] +pub fn hash1(a: u32, b: u32, seed: u32) -> f32 { + effectcraft_raster::hash_noise(a, b, seed) +} + +/// Map every pixel with its coordinates (row-parallel). +pub fn map_xy(img: &mut Image, f: impl Fn(usize, usize, Px) -> Px + Sync) { + img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + *px = f(x, y, *px); + } + }); +} + +/// Build a new image of size `w`×`h` from `f(x, y)` (row-parallel). +pub fn gen_image(w: u32, h: u32, f: impl Fn(usize, usize) -> Px + Sync) -> Image { + let mut img = Image::new(w, h); + map_xy(&mut img, |x, y, _| f(x, y)); + img +} + +/// Replace the alpha of `img` with `new_a`, keeping straight colour. Pixels that were (nearly) +/// transparent take their colour from a small blur of the surroundings. +pub fn set_alpha(img: &mut Image, new_a: &Plane) { + let needs_fill = img.data.par_iter().zip(new_a.data.par_iter()).any(|(p, &a)| p[3] <= 1e-4 && a > 1e-4); + let fill = if needs_fill { + let blurred = effectcraft_raster::gaussian_blur(img, 3.0, 3.0, true); + Some(blurred) + } else { + None + }; + img.data.par_iter_mut().zip(new_a.data.par_iter()).enumerate().for_each(|(i, (p, &na))| { + let na = na.clamp(0.0, 1.0); + let (c, a) = unpremul(*p); + let c = if a > 1e-4 { + c + } else if let Some(f) = &fill { + unpremul(f.data[i]).0 + } else { + c + }; + *p = premul(c, na); + }); +} + +/// Normalised kernel-free resampling helper: sample an image with bilinear filtering, either +/// transparent or clamped outside. +#[inline] +pub fn sample(img: &Image, x: f64, y: f64, clamp: bool) -> Px { + if clamp { img.sample_bilinear_clamped(x, y) } else { img.sample_bilinear(x, y) } +} + +/// Inverse-mapped warp: each output pixel samples the source at `f(x, y)` (pixel centres +0.5). +pub fn remap(src: &Image, clamp: bool, f: impl Fn(f64, f64) -> Option<(f64, f64)> + Sync) -> Image { + let mut out = Image::new(src.width, src.height); + out.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + *px = match f(x as f64 + 0.5, y as f64 + 0.5) { + Some((sx, sy)) => sample(src, sx, sy, clamp), + None => [0.0; 4], + }; + } + }); + out +} + +/// Layer rectangle in buffer pixels: (x0, y0, width, height). +pub fn layer_rect(ctx: &crate::EffectCtx, b: &crate::Buf) -> (f64, f64, f64, f64) { + (b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minmax_matches_brute_force() { + let src: Vec = (0..37).map(|i| ((i * 7919) % 31) as f32).collect(); + for r in 0..5 { + let mut d = vec![0.0; src.len()]; + minmax_row(&src, &mut d, r, false); + for x in 0..src.len() { + let lo = x.saturating_sub(r); + let hi = (x + r).min(src.len() - 1); + let m = src[lo..=hi].iter().cloned().fold(f32::INFINITY, f32::min); + assert_eq!(d[x], m, "r={r} x={x}"); + } + } + } + + #[test] + fn gauss_plane_preserves_constant() { + let p = Plane { w: 20, h: 10, data: vec![0.25; 200] }; + let g = gauss_plane(&p, 3.0, 2.0); + assert!(g.data.iter().all(|v| (v - 0.25).abs() < 1e-5)); + } + + #[test] + fn guided_filter_keeps_constant() { + let g = Plane { w: 16, h: 16, data: (0..256).map(|i| (i % 16) as f32 / 16.0).collect() }; + let p = Plane { w: 16, h: 16, data: vec![0.7; 256] }; + let o = guided_filter(&g, &p, 2, 1e-3); + assert!(o.data.iter().all(|v| (v - 0.7).abs() < 1e-3)); + } +} diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs new file mode 100644 index 00000000..2c3eebd0 --- /dev/null +++ b/crates/effects/src/utility.rs @@ -0,0 +1,462 @@ +//! Utility effects: bounds growth, HDR inspection/compression, Cineon log conversion and colour +//! lookup tables (.cube). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use effectcraft_color::luminance; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use rayon::prelude::*; + +use crate::util::{premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Utility", params, render, gpu: false, float: true } +} + +fn grow_bounds(ctx: &EffectCtx, mut b: Buf) -> Buf { + let px = (ctx.params.f("pixels") * b.scale).round().max(0.0) as u32; + if !ctx.adjustment { + b.pad(px); + } + b +} + +/// Visualise out-of-range values: in-range shown as dim grey, > 1 in the highlight colour, +/// < 0 in its complement. +fn overbrights(ctx: &EffectCtx, mut b: Buf) -> Buf { + let ch = ctx.params.e("channel"); + let hc = ctx.params.color("highlightColor"); + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + let v = match ch { + 1 => c[0], + 2 => c[1], + 3 => c[2], + 4 => a, + 5 => luminance(c[0], c[1], c[2]), + _ => c[0].max(c[1]).max(c[2]), + }; + let o = if v > 1.0 { + [hc[0], hc[1], hc[2]] + } else if v < 0.0 { + [1.0 - hc[0], 1.0 - hc[1], 1.0 - hc[2]] + } else { + let g = v * 0.35; + [g, g, g] + }; + *px = premul(o, a); + }); + b +} + +/// Soft-knee, hue-preserving compression of the brightest channel into [0, 1). +fn hdr_compress_value(m: f32, knee: f32) -> f32 { + if m <= knee { + return m; + } + let w = 1.0 - knee; + knee + w * (m - knee) / ((m - knee) + w) +} + +fn hdr_compression(ctx: &EffectCtx, mut b: Buf) -> Buf { + let t = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) as f32; + if t <= 0.0 { + return b; + } + let knee = 1.0 - 0.5 * t; + b.img.map_straight(|c| { + let m = c[0].max(c[1]).max(c[2]); + if m <= knee { + return c; + } + let k = hdr_compress_value(m, knee) / m; + c.map(|v| v * k) + }); + b +} + +// ---- Cineon (Kodak printing-density log encoding: 0.002 density per code value, negative gamma 0.6) ---- + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Cineon { + pub black: f32, + pub white: f32, + pub ib: f32, + pub iw: f32, + pub gamma: f32, + pub rolloff: f32, +} + +impl Cineon { + fn offset(&self) -> f32 { + 10f32.powf((self.black - self.white) * 0.002 / 0.6) + } + fn knee(&self) -> f32 { + 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99) + } + /// Normalised code value (code / 1023) → linear. + pub fn log_to_lin(&self, v: f32) -> f32 { + let off = self.offset(); + let code = v * 1023.0; + let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); + lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01)); + let k = self.knee(); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k + w * (1.0 - (-(lin - k) / w).exp()); + } + self.ib + (self.iw - self.ib) * lin + } + /// Linear → normalised code value. + pub fn lin_to_log(&self, v: f32) -> f32 { + let off = self.offset(); + let d = self.iw - self.ib; + let mut lin = (v - self.ib) / if d.abs() < 1e-6 { 1e-6 } else { d }; + let k = self.knee(); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln(); + } + lin = lin.max(0.0).powf(self.gamma.max(0.01) / 1.7); + let code = self.white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002; + code / 1023.0 + } +} + +fn cineon(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = Cineon { + black: ctx.params.f("tenBitBlackPoint") as f32, + white: ctx.params.f("tenBitWhitePoint") as f32, + ib: ctx.params.f("internalBlackPoint") as f32, + iw: ctx.params.f("internalWhitePoint") as f32, + gamma: ctx.params.f("gamma") as f32, + rolloff: ctx.params.f("highlightRolloff") as f32, + }; + let std = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: c.rolloff }; + let kind = ctx.params.e("conversionType"); + b.img.map_straight(|v| { + v.map(|x| match kind { + 0 => c.lin_to_log(x), + 1 => c.log_to_lin(x), + _ => std.lin_to_log(c.log_to_lin(x)), + }) + }); + b +} + +// ---- Colour LUTs (.cube) ---- + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Lut { + pub n1: usize, + pub d1: Vec<[f32; 3]>, + pub n3: usize, + pub d3: Vec<[f32; 3]>, + pub dmin: [f32; 3], + pub dmax: [f32; 3], +} + +/// Parse `.cube` text (TITLE, LUT_1D_SIZE, LUT_3D_SIZE, DOMAIN_MIN/MAX, data red-fastest). +pub(crate) fn parse_cube(text: &str) -> Option { + let mut lut = Lut { n1: 0, d1: vec![], n3: 0, d3: vec![], dmin: [0.0; 3], dmax: [1.0; 3] }; + let mut rows: Vec<[f32; 3]> = Vec::new(); + let three = |it: &mut std::str::SplitWhitespace| -> Option<[f32; 3]> { + let a = it.next()?.parse().ok()?; + let b = it.next()?.parse().ok()?; + let c = it.next()?.parse().ok()?; + Some([a, b, c]) + }; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut it = line.split_whitespace(); + let key = it.clone().next()?; + match key { + "TITLE" => continue, + "LUT_1D_SIZE" => { + it.next(); + lut.n1 = it.next()?.parse().ok()?; + } + "LUT_3D_SIZE" => { + it.next(); + lut.n3 = it.next()?.parse().ok()?; + } + "DOMAIN_MIN" => { + it.next(); + lut.dmin = three(&mut it)?; + } + "DOMAIN_MAX" => { + it.next(); + lut.dmax = three(&mut it)?; + } + "LUT_1D_INPUT_RANGE" | "LUT_3D_INPUT_RANGE" => { + it.next(); + let lo: f32 = it.next()?.parse().ok()?; + let hi: f32 = it.next()?.parse().ok()?; + lut.dmin = [lo; 3]; + lut.dmax = [hi; 3]; + } + k if k.starts_with(|c: char| c.is_ascii_alphabetic()) => continue, + _ => rows.push(three(&mut it)?), + } + } + let need1 = lut.n1; + let need3 = lut.n3 * lut.n3 * lut.n3; + if (lut.n1 == 0 && lut.n3 == 0) || lut.n1 == 1 || lut.n3 == 1 || rows.len() != need1 + need3 || lut.n3 > 256 { + return None; + } + lut.d1 = rows[..need1].to_vec(); + lut.d3 = rows[need1..].to_vec(); + Some(lut) +} + +impl Lut { + fn norm(&self, c: [f32; 3]) -> [f32; 3] { + [0, 1, 2].map(|i| ((c[i] - self.dmin[i]) / (self.dmax[i] - self.dmin[i]).max(1e-9)).clamp(0.0, 1.0)) + } + pub fn apply(&self, c: [f32; 3]) -> [f32; 3] { + let mut c = c; + if self.n1 > 1 { + let t = self.norm(c); + c = [0, 1, 2].map(|i| { + let x = t[i] * (self.n1 - 1) as f32; + let i0 = (x.floor() as usize).min(self.n1 - 2); + let f = x - i0 as f32; + self.d1[i0][i] + (self.d1[i0 + 1][i] - self.d1[i0][i]) * f + }); + } + if self.n3 > 1 { + c = self.tetra(self.norm(c)); + } + c + } + fn tetra(&self, t: [f32; 3]) -> [f32; 3] { + let n = self.n3; + let s = (n - 1) as f32; + let idx = |x: f32| { + let v = x * s; + let i = (v.floor() as usize).min(n - 2); + (i, v - i as f32) + }; + let (r, fr) = idx(t[0]); + let (g, fg) = idx(t[1]); + let (b, fb) = idx(t[2]); + let at = |dr: usize, dg: usize, db: usize| self.d3[(r + dr) + (g + dg) * n + (b + db) * n * n]; + let c000 = at(0, 0, 0); + let c111 = at(1, 1, 1); + let (w1, w2, w3, p1, p2) = if fr > fg { + if fg > fb { + (fr, fg, fb, at(1, 0, 0), at(1, 1, 0)) + } else if fr > fb { + (fr, fb, fg, at(1, 0, 0), at(1, 0, 1)) + } else { + (fb, fr, fg, at(0, 0, 1), at(1, 0, 1)) + } + } else if fb > fg { + (fb, fg, fr, at(0, 0, 1), at(0, 1, 1)) + } else if fb > fr { + (fg, fb, fr, at(0, 1, 0), at(0, 1, 1)) + } else { + (fg, fr, fb, at(0, 1, 0), at(1, 1, 0)) + }; + // c = c000 + w1 (p1 - c000) + w2 (p2 - p1) + w3 (c111 - p2), w1 ≥ w2 ≥ w3. + [0, 1, 2].map(|i| c000[i] + w1 * (p1[i] - c000[i]) + w2 * (p2[i] - p1[i]) + w3 * (c111[i] - p2[i])) + } +} + +type LutCache = Mutex>>>; + +/// Load (and cache) a LUT from inline `.cube` text or a file path. +pub(crate) fn load_lut(src: &str) -> Option> { + if src.trim().is_empty() { + return None; + } + static CACHE: OnceLock = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(m) = cache.lock() + && let Some(v) = m.get(src) + { + return v.clone(); + } + let inline = src.contains('\n') || src.contains("LUT_"); + let parsed = if inline { parse_cube(src) } else { std::fs::read_to_string(src.trim()).ok().and_then(|t| parse_cube(&t)) }.map(Arc::new); + if let Ok(mut m) = cache.lock() { + m.insert(src.to_string(), parsed.clone()); + } + parsed +} + +fn apply_lut(ctx: &EffectCtx, mut b: Buf) -> Buf { + let Some(lut) = load_lut(ctx.params.s("lut")) else { + return b; + }; + b.img.map_straight(|c| lut.apply(c)); + b +} + +pub fn specs() -> Vec { + vec![ + spec("ec.utility.growbounds", "Grow Bounds", vec![p("pixels", "Pixels", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0))], grow_bounds), + spec( + "ec.utility.ccoverbrights", + "CC Overbrights", + vec![ + p("channel", "Channel", Value::Enum(0), popup(&["Max RGB", "Red", "Green", "Blue", "Alpha", "Luminance"])), + p("highlightColor", "Highlight Color", col(1.0, 0.0, 0.0), ParamUi::Color), + ], + overbrights, + ), + spec( + "ec.utility.hdrcompression", + "HDR Highlight Compression", + vec![p("amount", "Amount", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1))], + hdr_compression, + ), + spec( + "ec.utility.cineon", + "Cineon Converter", + vec![ + p("conversionType", "Conversion Type", Value::Enum(1), popup(&["Linear to Log", "Log to Linear", "Log to Log"])), + p("tenBitBlackPoint", "10 Bit Black Point", num(95.0), slider(0.0, 1023.0, 0.0, 1023.0, 0)), + p("internalBlackPoint", "Internal Black Point", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)), + p("tenBitWhitePoint", "10 Bit White Point", num(685.0), slider(0.0, 1023.0, 0.0, 1023.0, 0)), + p("internalWhitePoint", "Internal White Point", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)), + p("gamma", "Gamma", num(1.7), slider(0.1, 5.0, 0.1, 5.0, 2)), + p("highlightRolloff", "Highlight Rolloff", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + ], + cineon, + ), + // `lut`: a path to a `.cube` file, or the `.cube` text itself (set via commands/automation). + spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), ParamUi::Hidden)], apply_lut), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Image, Params}; + + fn identity_cube(n: usize) -> String { + let mut s = format!("TITLE \"id\"\n# comment\nLUT_3D_SIZE {n}\n"); + for b in 0..n { + for g in 0..n { + for r in 0..n { + let f = |v: usize| v as f32 / (n - 1) as f32; + s += &format!("{} {} {}\n", f(r), f(g), f(b)); + } + } + } + s + } + + fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in vals { + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn test_img() -> Image { + let mut img = Image::new(8, 8); + for y in 0..8 { + for x in 0..8 { + let a = 0.5 + (x as f32) / 16.0; + img.set(x, y, premul([x as f32 / 7.0, y as f32 / 7.0, ((x + y) % 5) as f32 / 4.0], a)); + } + } + img + } + + #[test] + fn identity_lut_is_identity() { + for n in [2, 17] { + let lut = parse_cube(&identity_cube(n)).unwrap(); + for c in [[0.1, 0.5, 0.9], [0.33, 0.77, 0.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [0.42, 0.42, 0.9]] { + let o = lut.apply(c); + for i in 0..3 { + assert!((o[i] - c[i]).abs() < 1e-5, "n={n} {c:?} -> {o:?}"); + } + } + let img = test_img(); + let out = run("ec.utility.applylut", &[("lut", Value::Str(identity_cube(n)))], img.clone()); + for (a, b) in out.img.data.iter().zip(img.data.iter()) { + for c in 0..4 { + assert!((a[c] - b[c]).abs() < 1e-5); + } + } + } + } + + #[test] + fn swap_lut_swaps_channels() { + let mut s = String::from("LUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + s += &format!("{b} {g} {r}\n"); + } + } + } + let lut = parse_cube(&s).unwrap(); + let o = lut.apply([0.2, 0.5, 0.8]); + assert!((o[0] - 0.8).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && (o[2] - 0.2).abs() < 1e-5, "{o:?}"); + } + + #[test] + fn bad_cube_is_passthrough() { + assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n1 1 1\n").is_none()); + assert!(parse_cube("LUT_3D_SIZE 2\n0 0 zz\n").is_none()); + let img = test_img(); + let out = run("ec.utility.applylut", &[("lut", Value::Str("LUT_3D_SIZE 3\n0 0 0\n".into()))], img.clone()); + assert_eq!(out.img, img); + let out = run("ec.utility.applylut", &[("lut", Value::Str("/nonexistent/path.cube".into()))], img.clone()); + assert_eq!(out.img, img); + } + + #[test] + fn one_d_lut_with_domain() { + let lut = parse_cube("LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 2 2 2\n1 1 1\n0 0 0\n").unwrap(); + let o = lut.apply([0.5, 1.0, 2.0]); + assert!((o[0] - 0.75).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && o[2].abs() < 1e-5, "{o:?}"); + } + + #[test] + fn cineon_round_trip() { + let c = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: 20.0 }; + for code in [100.0f32, 200.0, 350.0, 500.0, 600.0] { + let v = code / 1023.0; + let back = c.lin_to_log(c.log_to_lin(v)); + assert!((back - v).abs() < 1e-3, "{code}: {back}"); + } + assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4); + } + + #[test] + fn hdr_compression_behaviour() { + let mut img = Image::new(2, 1); + img.set(0, 0, [0.2, 0.3, 0.4, 1.0]); + img.set(1, 0, [4.0, 2.0, 1.0, 1.0]); + let out = run("ec.utility.hdrcompression", &[], img); + assert_eq!(out.img.data[0], [0.2, 0.3, 0.4, 1.0]); + let o = out.img.data[1]; + assert!(o[0] <= 1.0 && o[0] > 0.5 && (o[0] / o[1] - 2.0).abs() < 1e-4, "{o:?}"); + } + + #[test] + fn grow_bounds_pads() { + let out = run("ec.utility.growbounds", &[("pixels", num(5.0))], test_img()); + assert_eq!((out.img.width, out.img.height), (18, 18)); + assert_eq!(out.offset, [5.0, 5.0]); + } +}