Merge branch 'worktree-agent-a23886a31ccc16878'

This commit is contained in:
Brandon Thomas 2026-10-01 14:32:21 -04:00
commit 989c4ba531
14 changed files with 8436 additions and 0 deletions

712
crates/effects/src/blur2.rs Normal file
View File

@ -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<crate::ParamSpec>, 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<Plane> = 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<Image> = (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<Vec<[f64; 4]>> = 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<EffectSpec> {
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: &params, 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);
}
}

View File

@ -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<crate::ParamSpec>, 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<EffectSpec> {
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: &params, 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<f32> = (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]));
}
}

1062
crates/effects/src/color2.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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<crate::ParamSpec>, 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<usize>> = 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<Pt> {
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::<f64>().ok()?;
let y = it.next()?.trim().parse::<f64>().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<EffectSpec> {
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: &params, 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);
}
}

View File

@ -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<crate::ParamSpec>, 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<Ghost> {
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<Seg>) {
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<Seg> {
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<usize>> = 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<EffectSpec> {
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: &params, 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);
}
}

View File

@ -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<crate::ParamSpec>, 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<EffectSpec> {
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: &params, 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));
}
}

View File

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

240
crates/effects/src/matte.rs Normal file
View File

@ -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<crate::ParamSpec>, 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<EffectSpec> {
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: &params, 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])));
}
}

651
crates/effects/src/noise.rs Normal file
View File

@ -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<crate::ParamSpec>, 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<Plane> = (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<EffectSpec> {
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: &params, 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<f32> = 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::<f32>() / 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::<f32>();
let out = run("ec.noise.removegrain", &[("noiseReduction", num(3.0))], img.clone(), 0.0);
assert!(var(&out.img) < var(&img) * 0.5);
}
}

View File

@ -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<ParamSpec>, 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<EffectSpec> {
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: &params, 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);
}
}

View File

@ -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<crate::ParamSpec>, 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<EffectSpec> {
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: &params, 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<i32> = 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:?}");
}
}

View File

@ -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<ParamSpec>, 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<EffectSpec> {
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: &params, 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");
}
}

463
crates/effects/src/util.rs Normal file
View File

@ -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<f32>,
}
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<usize> {
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<f32> = (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));
}
}

View File

@ -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<crate::ParamSpec>, 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<Lut> {
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<HashMap<String, Option<Arc<Lut>>>>;
/// Load (and cache) a LUT from inline `.cube` text or a file path.
pub(crate) fn load_lut(src: &str) -> Option<Arc<Lut>> {
if src.trim().is_empty() {
return None;
}
static CACHE: OnceLock<LutCache> = 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<EffectSpec> {
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: &params, 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]);
}
}