M9.2: effects batch 2 (keying, matte, channel, stylize, noise, perspective, transitions, utility)
Adds 105 effects in new modules (keying, matte, channel, blur2, color2, stylize2, noise, generate2, distort2, perspective, transition, utility) plus shared plane/morphology/guided-filter helpers in util.rs, with 96 new unit tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
808cd490cd
commit
4c3b92723b
|
|
@ -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: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
|
||||
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
fn edge_img() -> Image {
|
||||
let mut img = Image::new(32, 16);
|
||||
for y in 0..16 {
|
||||
for x in 0..32 {
|
||||
let v = if x < 16 { 0.1 } else { 0.9 };
|
||||
img.set(x, y, [v, v, v, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bilateral_keeps_edge_sharper_than_gaussian() {
|
||||
let img = edge_img();
|
||||
let bl = run("ec.blur.bilateral", &img, &[("radius", num(4.0))]).img;
|
||||
let g = gaussian_blur(&img, 2.0, 2.0, true);
|
||||
let jump_b = bl.get(16, 8)[0] - bl.get(15, 8)[0];
|
||||
let jump_g = g.get(16, 8)[0] - g.get(15, 8)[0];
|
||||
assert!(jump_b > 0.7, "{jump_b}");
|
||||
assert!(jump_b > jump_g + 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_blur_zero_is_identity() {
|
||||
let img = edge_img();
|
||||
let out = run("ec.blur.channel", &img, &[]);
|
||||
assert_eq!(out.img, img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_blur_only_touches_selected_channel() {
|
||||
let img = edge_img();
|
||||
let out = run("ec.blur.channel", &img, &[("redBlurriness", num(6.0)), ("repeatEdge", Value::Bool(true))]).img;
|
||||
let (l, r) = (out.get(15, 8), out.get(16, 8));
|
||||
assert!(r[0] - l[0] < 0.6);
|
||||
assert!((l[1] - 0.1).abs() < 1e-5 && (r[1] - 0.9).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lens_blur_preserves_constant_and_energy() {
|
||||
let img = Image::filled(24, 20, [0.4, 0.3, 0.2, 1.0]);
|
||||
let out = run("ec.blur.cameralens", &img, &[("repeatEdge", Value::Bool(true))]).img;
|
||||
assert!(out.data.iter().all(|p| (p[0] - 0.4).abs() < 1e-4 && (p[3] - 1.0).abs() < 1e-4));
|
||||
let mut dot = Image::new(9, 9);
|
||||
dot.set(4, 4, [1.0, 1.0, 1.0, 1.0]);
|
||||
let out = run("ec.blur.cameralens", &dot, &[("blurRadius", num(5.0))]);
|
||||
let total: f32 = out.img.data.iter().map(|p| p[3]).sum();
|
||||
assert!((total - 1.0).abs() < 1e-3, "{total}");
|
||||
assert!(out.img.width > 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iris_spans_hexagon_is_symmetric() {
|
||||
let s = iris_spans(10.0, 6, 0.0, 0.0);
|
||||
let s0 = s.iter().find(|t| t.0 == 0).unwrap();
|
||||
assert_eq!(s0.1, -s0.2);
|
||||
let circle = iris_spans(10.0, 0, 0.0, 0.0);
|
||||
assert_eq!(circle.len(), 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduce_flicker_blurs_vertically_only() {
|
||||
let mut img = Image::filled(8, 8, [0.0, 0.0, 0.0, 1.0]);
|
||||
for x in 0..8 {
|
||||
img.set(x, 4, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
let out = run("ec.blur.reduceflicker", &img, &[("softness", num(1.0))]).img;
|
||||
assert!(out.get(3, 4)[0] < 0.9 && out.get(3, 3)[0] > 0.05);
|
||||
assert!((out.get(0, 4)[0] - out.get(7, 4)[0]).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_blur_dark_pixels_stay_sharp() {
|
||||
let mut img = Image::new(16, 16);
|
||||
for y in 0..16 {
|
||||
for x in 0..16 {
|
||||
img.set(x, y, if (x + y) % 2 == 0 { [0.0, 0.0, 0.0, 1.0] } else { [0.02, 0.02, 0.02, 1.0] });
|
||||
}
|
||||
}
|
||||
let out = run("ec.blur.compound", &img, &[("maximumBlur", num(10.0))]);
|
||||
// Luminance ≈ 0 → (almost) no blur.
|
||||
let pad = (out.img.width - 16) / 2;
|
||||
assert!(out.img.get(pad as i64, pad as i64)[0] < 0.005);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true };
|
||||
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img
|
||||
}
|
||||
|
||||
fn sample() -> Image {
|
||||
let mut img = Image::new(8, 8);
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
let a = if x < 6 { 1.0 } else { 0.5 };
|
||||
img.set(x, y, [0.2 * a, 0.5 * a, 0.9 * a, a]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_channels_defaults_are_identity_and_swap_works() {
|
||||
let img = sample();
|
||||
assert_eq!(run("ec.channel.setchannels", &[], img.clone()), img);
|
||||
let out = run("ec.channel.setchannels", &[("setRedTo", Value::Enum(2)), ("setBlueTo", Value::Enum(0))], img);
|
||||
assert!(close(out.get(0, 0), [0.9, 0.5, 0.2, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_channels_takes_red_from_green() {
|
||||
let out = run("ec.channel.shiftchannels", &[("takeRedFrom", Value::Enum(2))], sample());
|
||||
assert!(close(out.get(7, 0), [0.25, 0.25, 0.45, 0.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_combiner_hls_round_trip() {
|
||||
let img = sample();
|
||||
let hls = run("ec.channel.combiner", &[("from", Value::Enum(0))], img.clone());
|
||||
let back = run("ec.channel.combiner", &[("from", Value::Enum(1))], hls);
|
||||
assert!(close(back.get(0, 0), img.get(0, 0)), "{:?}", back.get(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_maximum_grows_bright_point() {
|
||||
let mut img = Image::filled(9, 9, [0.0, 0.0, 0.0, 1.0]);
|
||||
img.set(4, 4, [1.0, 1.0, 1.0, 1.0]);
|
||||
let out = run("ec.channel.minimax", &[("radius", num(2.0))], img);
|
||||
assert_eq!(out.get(2, 2)[0], 1.0);
|
||||
assert_eq!(out.get(1, 4)[0], 0.0);
|
||||
let n: usize = out.data.iter().filter(|p| p[0] > 0.5).count();
|
||||
assert_eq!(n, 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_add_and_slice() {
|
||||
let img = Image::filled(1, 1, [0.4, 0.4, 0.4, 1.0]);
|
||||
let out = run("ec.channel.arithmetic", &[("redValue", num(51.0))], img.clone());
|
||||
assert!((out.get(0, 0)[0] - 0.6).abs() < 1e-5 && (out.get(0, 0)[1] - 0.4).abs() < 1e-6);
|
||||
let out = run("ec.channel.arithmetic", &[("operator", Value::Enum(10)), ("redValue", num(200.0)), ("greenValue", num(10.0))], img);
|
||||
assert_eq!(&out.get(0, 0)[..3], &[0.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_color_matting_inverts_matting() {
|
||||
// Foreground colour f, alpha a, matted against bg: stored straight value = f·a + bg·(1−a).
|
||||
let (f, a, bg) = ([0.8f32, 0.3, 0.1], 0.4f32, [0.2f32, 0.6, 0.9]);
|
||||
let s: Vec<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]));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false };
|
||||
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
fn max_diff(a: &Image, b: &Image) -> f32 {
|
||||
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn displacement_map_half_is_identity() {
|
||||
let img = test_img(32, 24);
|
||||
let o = run("ec.distort.displacementmap", &[("useForHorizontal", Value::Enum(9)), ("useForVertical", Value::Enum(9))], img.clone());
|
||||
assert_eq!(o.img, img);
|
||||
let o = run(
|
||||
"ec.distort.displacementmap",
|
||||
&[("useForHorizontal", Value::Enum(8)), ("useForVertical", Value::Enum(10)), ("maxHorizontal", num(2.0))],
|
||||
img.clone(),
|
||||
);
|
||||
// Full → +2 px horizontally: pixel x shows source x + 2.
|
||||
assert!((o.img.get(5, 5)[0] - img.get(7, 5)[0]).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_warp_empty_and_zero_are_identity() {
|
||||
let img = test_img(32, 24);
|
||||
assert_eq!(run("ec.distort.meshwarp", &[], img.clone()).img, img);
|
||||
let zeros = vec!["0,0"; 64].join(" ");
|
||||
assert_eq!(run("ec.distort.meshwarp", &[("mesh", Value::Str(zeros))], img.clone()).img, img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_warp_uniform_offset_translates() {
|
||||
let img = test_img(32, 24);
|
||||
let shift = vec!["4,0"; 64].join(" ");
|
||||
let o = run("ec.distort.meshwarp", &[("mesh", Value::Str(shift))], img.clone());
|
||||
for y in 2..22 {
|
||||
for x in 6..30 {
|
||||
let (a, b2) = (o.img.get(x, y), img.get(x - 4, y));
|
||||
assert!((0..4).all(|i| (a[i] - b2[i]).abs() < 1e-4), "({x},{y}) {a:?} vs {b2:?}");
|
||||
}
|
||||
}
|
||||
assert!(o.img.get(1, 10)[3] < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bezier_warp_default_is_identity() {
|
||||
let img = test_img(32, 24);
|
||||
let o = run("ec.distort.bezierwarp", &[], img.clone());
|
||||
assert!(max_diff(&o.img, &img) < 1e-3, "{}", max_diff(&o.img, &img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optics_fov_zero_is_identity_and_nonzero_warps() {
|
||||
let img = test_img(32, 24);
|
||||
assert_eq!(run("ec.distort.opticscompensation", &[], img.clone()).img, img);
|
||||
assert!(max_diff(&run("ec.distort.opticscompensation", &[("fieldOfView", num(90.0))], img.clone()).img, &img) > 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_it_zero_is_identity() {
|
||||
let img = test_img(32, 24);
|
||||
assert_eq!(run("ec.distort.ccbendit", &[], img.clone()).img, img);
|
||||
assert!(max_diff(&run("ec.distort.ccbendit", &[("bend", num(45.0))], img.clone()).img, &img) > 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magnify_100_is_identity() {
|
||||
let img = test_img(32, 24);
|
||||
assert!(max_diff(&run("ec.distort.magnify", &[("magnification", num(100.0))], img.clone()).img, &img) < 1e-6);
|
||||
let o = run("ec.distort.magnify", &[("size", num(8.0))], img.clone());
|
||||
// Centre pixel unchanged; slightly off-centre samples move toward the centre.
|
||||
assert!(max_diff(&o.img, &img) > 1e-3);
|
||||
assert_eq!(o.img.get(0, 0), img.get(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiler_100_is_identity_and_50_tiles() {
|
||||
let img = test_img(32, 24);
|
||||
assert!(max_diff(&run("ec.distort.cctiler", &[("scale", num(100.0))], img.clone()).img, &img) < 1e-6);
|
||||
let o = run("ec.distort.cctiler", &[], img.clone());
|
||||
assert!(o.img.data.iter().all(|p| p[3] > 0.99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turbulent_displace_zero_amount_is_identity() {
|
||||
let img = test_img(32, 24);
|
||||
assert_eq!(run("ec.distort.turbulentdisplace", &[("amount", num(0.0))], img.clone()).img, img);
|
||||
assert!(max_diff(&run("ec.distort.turbulentdisplace", &[], img.clone()).img, &img) > 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn griddler_default_is_identity() {
|
||||
let img = test_img(32, 24);
|
||||
assert_eq!(run("ec.distort.ccgriddler", &[], img.clone()).img, img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_turn_removes_corner_and_keeps_far_side() {
|
||||
let img = test_img(64, 64);
|
||||
let o = run("ec.distort.ccpageturn", &[("foldRadius", num(4.0))], img.clone());
|
||||
// Bottom-right corner is lifted away; top-left untouched.
|
||||
assert!(o.img.get(63, 63)[3] < 1e-6);
|
||||
assert_eq!(o.img.get(2, 2), img.get(2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_lens_clears_outside() {
|
||||
let img = test_img(32, 32);
|
||||
let o = run("ec.distort.cclens", &[], img);
|
||||
assert!(o.img.get(0, 0)[3] < 1e-6);
|
||||
assert!(o.img.get(16, 16)[3] > 0.99);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false };
|
||||
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_pattern_is_deterministic_and_seeded() {
|
||||
let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30));
|
||||
let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30));
|
||||
assert_eq!(a.img, b2.img);
|
||||
let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("randomSeed", num(5.0))], Image::new(40, 30));
|
||||
assert_ne!(a.img, c.img);
|
||||
assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_ring_coverage() {
|
||||
let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64));
|
||||
// Ring passes through (32 + 20, 32); centre is empty.
|
||||
assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32));
|
||||
assert!(o.img.get(32, 32)[3] < 1e-3);
|
||||
assert!(o.img.get(2, 2)[3] < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_is_deterministic_per_conductivity() {
|
||||
let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
assert_eq!(a.img, b2.img);
|
||||
assert!(a.img.data.iter().any(|p| p[3] > 0.5));
|
||||
let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48));
|
||||
assert_ne!(a.img, c.img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beam_draws_between_points() {
|
||||
let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32));
|
||||
assert!(o.img.get(32, 16)[3] > 0.99);
|
||||
assert!(o.img.get(32, 2)[3] < 1e-3);
|
||||
assert!(o.img.get(2, 16)[3] < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lens_flare_brightest_at_center() {
|
||||
let o = run("ec.generate.lensflare", &[], Image::new(64, 64));
|
||||
let c = o.img.get(16, 16)[0];
|
||||
assert!(c > o.img.get(60, 4)[0]);
|
||||
assert!(c > 0.5);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
|
||||
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img
|
||||
}
|
||||
|
||||
fn green() -> Value {
|
||||
Value::Color([0.1, 0.8, 0.15, 1.0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_color_key_removes_green() {
|
||||
let out = run("ec.key.linearcolor", &[("keyColor", green())], green_screen());
|
||||
assert!(out.get(2, 2)[3] < 0.01);
|
||||
assert!(out.get(12, 2)[3] > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_difference_key_removes_green() {
|
||||
let out = run("ec.key.colordifference", &[("keyColor", green())], green_screen());
|
||||
assert!(out.get(2, 2)[3] < 0.01, "{:?}", out.get(2, 2));
|
||||
assert!(out.get(12, 2)[3] > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_key_removes_green_and_despills() {
|
||||
let mut img = green_screen();
|
||||
img.set(12, 4, [0.6, 0.65, 0.5, 1.0]);
|
||||
let out = run("ec.key.screen", &[("screenColor", green())], img);
|
||||
assert!(out.get(2, 2)[3] < 0.01);
|
||||
assert!(out.get(12, 2)[3] > 0.99);
|
||||
let px = out.get(12, 4);
|
||||
let (c, a) = unpremul(px);
|
||||
assert!(a > 0.5 && a < 1.0, "{a}");
|
||||
assert!(c[1] <= 0.5 * (c[0] + c[2]) + 1e-3, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_key_matte_view_is_opaque() {
|
||||
let out = run("ec.key.screen", &[("screenColor", green()), ("view", Value::Enum(1))], green_screen());
|
||||
assert!(out.data.iter().all(|p| p[3] == 1.0));
|
||||
assert!(out.get(2, 2)[0] < 0.01 && out.get(12, 2)[0] > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_range_keys_green_in_lab() {
|
||||
let out = run("ec.key.colorrange", &[], green_screen());
|
||||
assert!(out.get(2, 2)[3] < 0.01);
|
||||
assert!(out.get(12, 2)[3] > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn luma_key_darker() {
|
||||
let mut img = Image::new(4, 1);
|
||||
img.set(0, 0, [0.1, 0.1, 0.1, 1.0]);
|
||||
img.set(1, 0, [0.9, 0.9, 0.9, 1.0]);
|
||||
let out = run("ec.key.luma", &[("threshold", num(128.0))], img);
|
||||
assert_eq!(out.get(0, 0)[3], 0.0);
|
||||
assert_eq!(out.get(1, 0)[3], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_keeps_range() {
|
||||
let mut img = Image::new(3, 1);
|
||||
img.set(0, 0, [0.05, 0.05, 0.05, 1.0]);
|
||||
img.set(1, 0, [0.5, 0.5, 0.5, 1.0]);
|
||||
img.set(2, 0, [1.0, 1.0, 1.0, 1.0]);
|
||||
let out = run("ec.key.extract", &[("blackPoint", num(50.0)), ("whitePoint", num(200.0))], img);
|
||||
assert_eq!(out.get(0, 0)[3], 0.0);
|
||||
assert_eq!(out.get(1, 0)[3], 1.0);
|
||||
assert_eq!(out.get(2, 0)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_suppressor_limits_green() {
|
||||
let img = Image::filled(2, 2, [0.3, 0.8, 0.4, 1.0]);
|
||||
let out = run("ec.key.spill", &[("colorToSuppress", green())], img);
|
||||
let px = out.get(0, 0);
|
||||
assert!((px[1] - 0.4).abs() < 1e-5 && px[0] == 0.3 && px[2] == 0.4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_spill_standard_detects_green() {
|
||||
let img = Image::filled(2, 2, [0.3, 0.8, 0.3, 1.0]);
|
||||
let out = run("ec.key.advancedspill", &[], img);
|
||||
assert!((out.get(0, 0)[1] - 0.3).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_cleaner_defaults_keep_opaque_pixels() {
|
||||
let img = green_screen();
|
||||
let out = run("ec.key.keycleaner", &[], img.clone());
|
||||
assert_eq!(out.get(12, 2), img.get(12, 2));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true };
|
||||
apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img
|
||||
}
|
||||
|
||||
fn alpha_sum(img: &Image) -> f32 {
|
||||
img.data.iter().map(|p| p[3]).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_choker_shrinks_and_grows() {
|
||||
let img = square();
|
||||
let base = alpha_sum(&img);
|
||||
let shrunk = run("ec.matte.simplechoker", &[("chokeMatte", num(2.0))], img.clone());
|
||||
assert!((alpha_sum(&shrunk) - 64.0).abs() < 1e-3, "{}", alpha_sum(&shrunk));
|
||||
let grown = run("ec.matte.simplechoker", &[("chokeMatte", num(-2.0))], img);
|
||||
assert!(alpha_sum(&grown) > base);
|
||||
// Grown pixels pick up the layer colour, not black.
|
||||
let (c, a) = unpremul(grown.get(5, 10));
|
||||
assert!(a > 0.99 && c[0] > 0.5, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matte_choker_defaults_shrink() {
|
||||
let img = square();
|
||||
let out = run("ec.matte.mattechoker", &[], img.clone());
|
||||
assert!(alpha_sum(&out) < alpha_sum(&img));
|
||||
assert!(out.get(12, 12)[3] > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_soft_matte_keeps_interior_and_range() {
|
||||
let img = square();
|
||||
let out = run("ec.matte.refinesoft", &[], img);
|
||||
assert!(out.get(12, 12)[3] > 0.99);
|
||||
assert!(out.get(0, 0)[3] < 0.01);
|
||||
assert!(out.data.iter().all(|p| (0.0..=1.0).contains(&p[3])));
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false };
|
||||
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
fn gray(w: u32, h: u32, v: f32) -> Image {
|
||||
Image::filled(w, h, [v, v, v, 1.0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_removes_salt_noise() {
|
||||
let mut img = gray(16, 16, 0.5);
|
||||
for (x, y) in [(3, 3), (8, 2), (12, 12), (5, 10)] {
|
||||
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
let out = run("ec.noise.median", &[("radius", num(1.0))], img, 0.0);
|
||||
for p in &out.img.data {
|
||||
assert!((p[0] - 0.5).abs() < 2.0 / 511.0, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_matches_brute_force() {
|
||||
let (w, h) = (9u32, 7u32);
|
||||
let mut img = Image::new(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let v = |k: u32| ((x * 37 + y * 101 + k * 13) % 511) as f32 / 511.0;
|
||||
img.set(x, y, [v(0), v(1), v(2), v(3)]);
|
||||
}
|
||||
}
|
||||
for r in 1..3usize {
|
||||
let m = median_image(&img, r);
|
||||
for y in 0..h as i64 {
|
||||
for x in 0..w as i64 {
|
||||
for c in 0..4 {
|
||||
let mut vals: Vec<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
|
||||
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
fn square(w: u32, h: u32, m: u32, c: [f32; 3]) -> Image {
|
||||
let mut img = Image::new(w, h);
|
||||
for y in m..h - m {
|
||||
for x in m..w - m {
|
||||
img.set(x, y, [c[0], c[1], c[2], 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bevel_alpha_lights_one_side_and_shades_other() {
|
||||
let img = square(40, 40, 10, [0.5, 0.5, 0.5]);
|
||||
let out = run("ec.perspective.bevelalpha", &img, &[("lightAngle", num(-90.0)), ("edgeThickness", num(4.0))]).img;
|
||||
// Light from the left: the left edge brightens, the right edge darkens, centre unchanged.
|
||||
let left = out.get(10, 20)[0];
|
||||
let right = out.get(29, 20)[0];
|
||||
assert!(left > 0.55, "{left}");
|
||||
assert!(right < 0.45, "{right}");
|
||||
assert!((out.get(20, 20)[0] - 0.5).abs() < 1e-3);
|
||||
assert_eq!(out.get(20, 20)[3], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bevel_edges_only_touches_the_border_band() {
|
||||
let img = Image::filled(40, 40, [0.5, 0.5, 0.5, 1.0]);
|
||||
let out = run("ec.perspective.beveledges", &img, &[("lightAngle", num(0.0))]).img;
|
||||
assert!(out.get(20, 1)[0] > 0.5, "top lit");
|
||||
assert!(out.get(20, 38)[0] < 0.5, "bottom shaded");
|
||||
assert_eq!(out.get(20, 20)[0], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_shadow_is_behind_and_scaled() {
|
||||
let img = square(60, 60, 25, [1.0, 0.0, 0.0]);
|
||||
let out = run(
|
||||
"ec.perspective.radialshadow",
|
||||
&img,
|
||||
&[("lightSource", Value::Vec2([30.0, 30.0])), ("projectionDistance", num(100.0)), ("opacity", num(100.0))],
|
||||
)
|
||||
.img;
|
||||
// The original stays on top.
|
||||
assert_eq!(out.get(30, 30), [1.0, 0.0, 0.0, 1.0]);
|
||||
// A 2× shadow around the light covers pixels the layer does not.
|
||||
let s = out.get(30, 21);
|
||||
assert!(s[3] > 0.9 && s[0] < 0.05, "{s:?}");
|
||||
assert_eq!(out.get(30, 5)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sphere_is_round_and_shaded() {
|
||||
let img = Image::filled(64, 64, [0.8, 0.8, 0.8, 1.0]);
|
||||
let out = run("ec.perspective.ccsphere", &img, &[("radius", num(20.0))]).img;
|
||||
assert_eq!(out.get(2, 2)[3], 0.0);
|
||||
assert!(out.get(32, 32)[3] > 0.99);
|
||||
assert_eq!(out.get(32, 55)[3], 0.0);
|
||||
// Lit from the upper left: that side is brighter than the lower right.
|
||||
assert!(out.get(24, 24)[0] > out.get(40, 40)[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_width_follows_radius() {
|
||||
let img = Image::filled(100, 20, [0.5, 0.5, 0.5, 1.0]);
|
||||
let out = run("ec.perspective.cccylinder", &img, &[]).img;
|
||||
// radius 100% → r = W / 2π ≈ 15.9 px
|
||||
assert!(out.get(50, 10)[3] > 0.99);
|
||||
assert!(out.get(50 + 14, 10)[3] > 0.99);
|
||||
assert_eq!(out.get(50 + 18, 10)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spotlight_darkens_outside_cone() {
|
||||
let img = Image::filled(80, 80, [1.0, 1.0, 1.0, 1.0]);
|
||||
let out =
|
||||
run("ec.perspective.ccspotlight", &img, &[("from", Value::Vec2([40.0, 40.0])), ("to", Value::Vec2([40.0, 40.0])), ("edgeSoftness", num(0.0))]).img;
|
||||
assert!((out.get(40, 40)[0] - 1.0).abs() < 1e-4);
|
||||
assert_eq!(out.get(2, 2)[0], 0.0);
|
||||
assert_eq!(out.get(2, 2)[3], 1.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
|
||||
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
fn ramp(w: u32, h: u32) -> Image {
|
||||
let mut img = Image::new(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
img.set(x, y, [x as f32 / w as f32, y as f32 / h as f32, 0.3, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repetile_grows_and_repeats() {
|
||||
let img = ramp(10, 8);
|
||||
let out = run_at("ec.stylize.ccrepetile", &[("expandRight", num(10.0)), ("expandUp", num(4.0))], img.clone(), 0.0);
|
||||
assert_eq!((out.img.width, out.img.height), (20, 12));
|
||||
assert_eq!(out.offset, [0.0, 4.0]);
|
||||
// Repeat: pixel (x, y) of the tile to the right equals the original.
|
||||
let a = out.img.get(13, 6);
|
||||
let b = img.get(3, 2);
|
||||
for c in 0..4 {
|
||||
assert!((a[c] - b[c]).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_tile_output_size() {
|
||||
let out = run_at(
|
||||
"ec.stylize.motiontile",
|
||||
&[("outputWidth", num(200.0)), ("outputHeight", num(150.0)), ("tileCenter", Value::Vec2([5.0, 4.0]))],
|
||||
ramp(10, 8),
|
||||
0.0,
|
||||
);
|
||||
assert_eq!((out.img.width, out.img.height), (20, 12));
|
||||
assert_eq!(out.offset, [5.0, 2.0]);
|
||||
// Default tiles: identity inside the layer rect.
|
||||
let same = run_at("ec.stylize.motiontile", &[("tileCenter", Value::Vec2([5.0, 4.0]))], ramp(10, 8), 0.0);
|
||||
let img = ramp(10, 8);
|
||||
for (a, b) in same.img.data.iter().zip(img.data.iter()) {
|
||||
assert!((a[0] - b[0]).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strobe_by_time() {
|
||||
assert!(strobe_on(0.02, 0.05, 1.0, 0.0, 1));
|
||||
assert!(!strobe_on(0.5, 0.05, 1.0, 0.0, 1));
|
||||
assert!(strobe_on(3.01, 0.05, 1.0, 0.0, 1));
|
||||
let img = Image::filled(4, 4, [0.2, 0.2, 0.2, 1.0]);
|
||||
let on = run_at("ec.stylize.strobe", &[], img.clone(), 0.01);
|
||||
assert_eq!(on.img.data[0], [1.0, 1.0, 1.0, 1.0]);
|
||||
let off = run_at("ec.stylize.strobe", &[], img.clone(), 0.5);
|
||||
assert_eq!(off.img, img);
|
||||
let tr = run_at("ec.stylize.strobe", &[("strobe", Value::Enum(1))], img, 0.01);
|
||||
assert_eq!(tr.img.data[0][3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_threshold_is_binary() {
|
||||
let out = run_at("ec.stylize.ccthreshold", &[], ramp(16, 4), 0.0);
|
||||
assert!(out.img.data.iter().all(|p| p[0] == 0.0 || p[0] == 1.0));
|
||||
assert!(out.img.data.iter().any(|p| p[0] == 0.0) && out.img.data.iter().any(|p| p[0] == 1.0));
|
||||
let rgb = run_at("ec.stylize.ccthresholdrgb", &[], ramp(16, 4), 0.0);
|
||||
assert!(rgb.img.data.iter().all(|p| p[..3].iter().all(|&v| v == 0.0 || v == 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roughen_only_removes_alpha() {
|
||||
let mut img = Image::new(32, 32);
|
||||
for y in 8..24 {
|
||||
for x in 8..24 {
|
||||
img.set(x, y, [0.5, 0.5, 0.5, 1.0]);
|
||||
}
|
||||
}
|
||||
let out = run_at("ec.stylize.roughenedges", &[], img.clone(), 0.0);
|
||||
assert!(out.img.data.iter().zip(img.data.iter()).all(|(o, i)| o[3] <= i[3] + 1e-6));
|
||||
assert!(out.img.data.iter().zip(img.data.iter()).any(|(o, i)| o[3] < i[3] - 0.1));
|
||||
assert!((out.img.get(16, 16)[3] - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vignette_darkens_corners_not_center() {
|
||||
let img = Image::filled(40, 40, [0.8, 0.8, 0.8, 1.0]);
|
||||
let out = run_at("ec.stylize.ccvignette", &[("amount", num(100.0)), ("center", Value::Vec2([20.0, 20.0]))], img, 0.0);
|
||||
assert!(out.img.get(0, 0)[0] < out.img.get(20, 20)[0] - 0.05);
|
||||
assert!((out.img.get(20, 20)[0] - 0.8).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kaleida_is_symmetric() {
|
||||
let out = run_at("ec.stylize.cckaleida", &[("mirroring", Value::Enum(2)), ("center", Value::Vec2([16.0, 16.0]))], ramp(32, 32), 0.0);
|
||||
// Unfold (4 mirrored segments): mirror across the horizontal axis through the centre.
|
||||
let a = out.img.get(20, 10);
|
||||
let b = out.img.get(20, 21);
|
||||
assert!((a[0] - b[0]).abs() < 0.05 && (a[1] - b[1]).abs() < 0.05, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cartoon_quantises_flat_regions() {
|
||||
let out = run_at("ec.stylize.cartoon", &[("render", Value::Enum(0)), ("shadingSmoothness", num(0.0)), ("shadingSteps", num(4.0))], ramp(24, 8), 0.0);
|
||||
let mut lv: Vec<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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false };
|
||||
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
|
||||
}
|
||||
|
||||
fn ramp(w: u32, h: u32) -> Image {
|
||||
let mut img = Image::new(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let v = (x as f32 + 0.5) / w as f32;
|
||||
img.set(x, y, [v, v, v, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn alpha_sum(img: &Image) -> f32 {
|
||||
img.data.iter().map(|p| p[3]).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_are_identity_at_zero_and_clear_at_full() {
|
||||
let img = ramp(40, 30);
|
||||
let full = alpha_sum(&img);
|
||||
for id in [
|
||||
"ec.transition.blockdissolve",
|
||||
"ec.transition.gradientwipe",
|
||||
"ec.transition.cardwipe",
|
||||
"ec.transition.ccgridwipe",
|
||||
"ec.transition.ccradialscalewipe",
|
||||
"ec.transition.cclightwipe",
|
||||
] {
|
||||
assert_eq!(run(id, &img, &[]), img, "{id} at 0%");
|
||||
let out = run(id, &img, &[("completion", num(100.0))]);
|
||||
assert!(alpha_sum(&out) < full * 0.02, "{id} at 100%: {}", alpha_sum(&out));
|
||||
let half = alpha_sum(&run(id, &img, &[("completion", num(50.0))]));
|
||||
assert!(half > 0.0 && half < full, "{id} at 50%: {half}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_wipe_removes_dark_pixels_first() {
|
||||
let img = ramp(40, 4);
|
||||
let out = run("ec.transition.gradientwipe", &img, &[("completion", num(50.0))]);
|
||||
assert_eq!(out.get(5, 1)[3], 0.0);
|
||||
assert_eq!(out.get(35, 1)[3], 1.0);
|
||||
let inv = run("ec.transition.gradientwipe", &img, &[("completion", num(50.0)), ("invert", Value::Bool(true))]);
|
||||
assert_eq!(inv.get(5, 1)[3], 1.0);
|
||||
assert_eq!(inv.get(35, 1)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iris_wipe_opens_a_polygon_hole() {
|
||||
let img = Image::filled(60, 60, [1.0, 1.0, 1.0, 1.0]);
|
||||
assert_eq!(run("ec.transition.iriswipe", &img, &[]), img);
|
||||
let out = run("ec.transition.iriswipe", &img, &[("outerRadius", num(20.0))]);
|
||||
assert_eq!(out.get(30, 30)[3], 0.0);
|
||||
assert_eq!(out.get(30, 12)[3], 0.0, "vertex points up");
|
||||
assert_eq!(out.get(2, 2)[3], 1.0);
|
||||
// A hexagon's flat side is closer than its vertex: cos(30°)·20 ≈ 17.3 px.
|
||||
assert_eq!(out.get(30 + 18, 30)[3], 1.0);
|
||||
// Star with a small inner radius leaves the space between points opaque.
|
||||
let star = run("ec.transition.iriswipe", &img, &[("outerRadius", num(20.0)), ("useInnerRadius", Value::Bool(true)), ("innerRadius", num(5.0))]);
|
||||
assert_eq!(star.get(30, 15)[3], 0.0);
|
||||
assert_eq!(star.get(30 + 12, 30)[3], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_dissolve_is_binary_per_block() {
|
||||
let img = Image::filled(40, 40, [1.0, 1.0, 1.0, 1.0]);
|
||||
let out = run("ec.transition.blockdissolve", &img, &[("completion", num(50.0)), ("blockWidth", num(10.0)), ("blockHeight", num(10.0))]);
|
||||
for by in 0..4 {
|
||||
for bx in 0..4 {
|
||||
let a = out.get(bx * 10, by * 10)[3];
|
||||
for y in 0..10 {
|
||||
for x in 0..10 {
|
||||
assert_eq!(out.get(bx * 10 + x, by * 10 + y)[3], a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = alpha_sum(&out) / 100.0;
|
||||
assert!(n > 2.0 && n < 14.0, "{n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_wipe_flips_cards_in_order() {
|
||||
let img = Image::filled(80, 40, [1.0, 0.0, 0.0, 1.0]);
|
||||
let out = run("ec.transition.cardwipe", &img, &[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0))]);
|
||||
// Left cards are gone, right cards untouched.
|
||||
assert_eq!(out.get(2, 20)[3], 0.0);
|
||||
assert_eq!(out.get(77, 20), [1.0, 0.0, 0.0, 1.0]);
|
||||
let back = run(
|
||||
"ec.transition.cardwipe",
|
||||
&img,
|
||||
&[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backLayer", Value::Enum(1))],
|
||||
);
|
||||
assert_eq!(back.get(2, 20)[3], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_scale_wipe_pushes_content_outward() {
|
||||
let img = ramp(41, 41);
|
||||
let out = run("ec.transition.ccradialscalewipe", &img, &[("completion", num(30.0))]);
|
||||
assert_eq!(out.get(20, 20)[3], 0.0);
|
||||
assert!(out.get(0, 0)[3] > 0.0);
|
||||
let rev = run("ec.transition.ccradialscalewipe", &img, &[("completion", num(30.0)), ("reverse", Value::Bool(true))]);
|
||||
assert!(rev.get(20, 20)[3] > 0.0);
|
||||
assert_eq!(rev.get(0, 0)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_wipe_smears_past_the_center_line() {
|
||||
let img = ramp(40, 4);
|
||||
assert_eq!(run("ec.transition.ccscalewipe", &img, &[]), img);
|
||||
let out = run("ec.transition.ccscalewipe", &img, &[("stretch", num(1000.0))]);
|
||||
// direction 90° → to the right of centre, everything samples (almost) the centre column.
|
||||
assert_eq!(out.get(10, 1), img.get(10, 1));
|
||||
let centre = img.get(20, 1)[0];
|
||||
assert!((out.get(38, 1)[0] - centre).abs() < 0.03, "{}", out.get(38, 1)[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_wipe_shrinks_tiles_from_center() {
|
||||
let img = Image::filled(100, 100, [1.0, 1.0, 1.0, 1.0]);
|
||||
let out = run("ec.transition.ccgridwipe", &img, &[("completion", num(30.0))]);
|
||||
assert_eq!(out.get(50, 50)[3], 0.0, "centre tiles are gone first");
|
||||
assert_eq!(out.get(1, 1)[3], 1.0, "corners last");
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false };
|
||||
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
fn test_img() -> Image {
|
||||
let mut img = Image::new(8, 8);
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
let a = 0.5 + (x as f32) / 16.0;
|
||||
img.set(x, y, premul([x as f32 / 7.0, y as f32 / 7.0, ((x + y) % 5) as f32 / 4.0], a));
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_lut_is_identity() {
|
||||
for n in [2, 17] {
|
||||
let lut = parse_cube(&identity_cube(n)).unwrap();
|
||||
for c in [[0.1, 0.5, 0.9], [0.33, 0.77, 0.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [0.42, 0.42, 0.9]] {
|
||||
let o = lut.apply(c);
|
||||
for i in 0..3 {
|
||||
assert!((o[i] - c[i]).abs() < 1e-5, "n={n} {c:?} -> {o:?}");
|
||||
}
|
||||
}
|
||||
let img = test_img();
|
||||
let out = run("ec.utility.applylut", &[("lut", Value::Str(identity_cube(n)))], img.clone());
|
||||
for (a, b) in out.img.data.iter().zip(img.data.iter()) {
|
||||
for c in 0..4 {
|
||||
assert!((a[c] - b[c]).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_lut_swaps_channels() {
|
||||
let mut s = String::from("LUT_3D_SIZE 2\n");
|
||||
for b in 0..2 {
|
||||
for g in 0..2 {
|
||||
for r in 0..2 {
|
||||
s += &format!("{b} {g} {r}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
let lut = parse_cube(&s).unwrap();
|
||||
let o = lut.apply([0.2, 0.5, 0.8]);
|
||||
assert!((o[0] - 0.8).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && (o[2] - 0.2).abs() < 1e-5, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_cube_is_passthrough() {
|
||||
assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n1 1 1\n").is_none());
|
||||
assert!(parse_cube("LUT_3D_SIZE 2\n0 0 zz\n").is_none());
|
||||
let img = test_img();
|
||||
let out = run("ec.utility.applylut", &[("lut", Value::Str("LUT_3D_SIZE 3\n0 0 0\n".into()))], img.clone());
|
||||
assert_eq!(out.img, img);
|
||||
let out = run("ec.utility.applylut", &[("lut", Value::Str("/nonexistent/path.cube".into()))], img.clone());
|
||||
assert_eq!(out.img, img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_d_lut_with_domain() {
|
||||
let lut = parse_cube("LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 2 2 2\n1 1 1\n0 0 0\n").unwrap();
|
||||
let o = lut.apply([0.5, 1.0, 2.0]);
|
||||
assert!((o[0] - 0.75).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && o[2].abs() < 1e-5, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cineon_round_trip() {
|
||||
let c = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: 20.0 };
|
||||
for code in [100.0f32, 200.0, 350.0, 500.0, 600.0] {
|
||||
let v = code / 1023.0;
|
||||
let back = c.lin_to_log(c.log_to_lin(v));
|
||||
assert!((back - v).abs() < 1e-3, "{code}: {back}");
|
||||
}
|
||||
assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_compression_behaviour() {
|
||||
let mut img = Image::new(2, 1);
|
||||
img.set(0, 0, [0.2, 0.3, 0.4, 1.0]);
|
||||
img.set(1, 0, [4.0, 2.0, 1.0, 1.0]);
|
||||
let out = run("ec.utility.hdrcompression", &[], img);
|
||||
assert_eq!(out.img.data[0], [0.2, 0.3, 0.4, 1.0]);
|
||||
let o = out.img.data[1];
|
||||
assert!(o[0] <= 1.0 && o[0] > 0.5 && (o[0] / o[1] - 2.0).abs() < 1e-4, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grow_bounds_pads() {
|
||||
let out = run("ec.utility.growbounds", &[("pixels", num(5.0))], test_img());
|
||||
assert_eq!((out.img.width, out.img.height), (18, 18));
|
||||
assert_eq!(out.offset, [5.0, 5.0]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue