Merge main into M13.7

This commit is contained in:
Brandon Thomas 2026-10-03 07:55:24 -04:00
commit cbf9e40528
40 changed files with 7206 additions and 876 deletions

View File

@ -282,9 +282,10 @@ fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64
pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) {
let shape = ctx.params.e("irisProperties/irisShape");
let sides = if shape >= 8 { 0 } else { shape + 3 };
let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0);
let roundness = ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0;
// Aspect Ratio stretches the iris horizontally (> 1) or squeezes it (< 1).
let aspect = ctx.params.f("irisProperties/irisAspectRatio");
let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), roundness);
if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 {
for s in spans.iter_mut() {
s.1 = (s.1 as f64 * aspect).round() as i64;
@ -294,12 +295,23 @@ pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64)
(spans, r * aspect.max(1.0))
}
/// Whether Camera Lens Blur needs more than plain iris spans (Diffraction Fringe or a Blur
/// Map): the GPU kernel handles the plain blur only.
pub fn camera_lens_plain(ctx: &EffectCtx) -> bool {
ctx.params.f("irisProperties/diffractionFringe") <= 0.0 && ctx.params.get("blurMap/blurMapLayer").and_then(|v| v.as_layer()).is_none()
}
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 (spans, reach) = camera_lens_spans(ctx, r);
let shape = ctx.params.e("irisProperties/irisShape");
let sides = if shape >= 8 { 0 } else { shape + 3 };
let roundness = ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0;
// Aspect Ratio stretches the iris horizontally (> 1) or squeezes it (< 1).
let aspect = ctx.params.f("irisProperties/irisAspectRatio");
let reach = r * aspect.max(1.0);
let repeat = ctx.params.b("repeatEdge") || ctx.adjustment;
if !repeat {
b.pad(reach.ceil() as u32 + 1);
@ -348,58 +360,148 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
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);
// Sum of the source over the iris spans centred on (x, y).
let span_sum = |spans: &[(i64, i64, i64)], x: usize, y: usize| -> [f64; 4] {
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;
}
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 {
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] += pre[bnd as usize + 1][c] - pre[a as usize][c];
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);
if linear {
let (c, a) = unpremul(px);
px = premul(c.map(|v| effectcraft_color::linear_to_srgb(v.max(0.0))), a);
}
*o = px;
}
acc
};
// Diffraction Fringe: the iris edge (outer 20 % of the radius) gets more of the energy:
// 0 = an even disc, 100 = a natural halo, 500 = all of it in the ring.
let fringe = (ctx.params.f("irisProperties/diffractionFringe") / 500.0).clamp(0.0, 1.0);
let (w_in, w_ring) = (1.0 - fringe, 1.0 + 4.0 * fringe);
let iris = |radius: f64| -> (Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>) {
let stretch = |mut s: Vec<(i64, i64, i64)>| {
if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 {
for v in s.iter_mut() {
v.1 = (v.1 as f64 * aspect).round() as i64;
v.2 = (v.2 as f64 * aspect).round() as i64;
}
}
s
};
let outer = stretch(iris_spans(radius, sides, ctx.params.f("irisProperties/irisRotation"), roundness));
let inner = if fringe > 0.0 && radius * 0.8 >= 1.0 {
stretch(iris_spans(radius * 0.8, sides, ctx.params.f("irisProperties/irisRotation"), roundness))
} else {
vec![]
};
(outer, inner)
};
let count = |s: &[(i64, i64, i64)]| s.iter().map(|(_, l, r)| (r - l + 1) as f64).sum::<f64>();
// One blurred pixel with the given iris.
let blur_px = |sp: &(Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>), x: usize, y: usize| -> [f32; 4] {
let (outer, inner) = sp;
let acc = if inner.is_empty() {
let n = count(outer).max(1.0);
span_sum(outer, x, y).map(|v| v / n)
} else {
let (so, si) = (span_sum(outer, x, y), span_sum(inner, x, y));
let (no, ni) = (count(outer), count(inner));
let norm = (w_in * ni + w_ring * (no - ni)).max(1e-9);
std::array::from_fn(|c| (w_in * si[c] + w_ring * (so[c] - si[c])) / norm)
};
let mut px = acc.map(|v| v as f32);
px[3] = px[3].clamp(0.0, 1.0);
if linear {
let (c, a) = unpremul(px);
px = premul(c.map(|v| effectcraft_color::linear_to_srgb(v.max(0.0))), a);
}
px
};
// Blur Map: per-pixel radius from a map layer (no blur at Blur Focal Distance, full radius
// at the farthest value).
let map = ctx.layer_param("blurMap/blurMapLayer", true).map(|o| {
let stretch = ctx.params.e("blurMap/placement") == 1;
let img = crate::util::fit_layer(ctx, &b, &o, stretch);
let ch = ctx.params.e("blurMap/channel");
let invert = ctx.params.b("blurMap/invertBlurMap");
let focal = (ctx.params.f("blurMap/blurFocalDistance") / 255.0).clamp(0.0, 1.0) as f32;
let span = focal.max(1.0 - focal).max(1e-3);
img.data
.iter()
.map(|p| {
let (c, a) = unpremul(*p);
let mut v = match ch {
1 => a,
2 => c[0].max(c[1]).max(c[2]) * a,
_ => luminance(c[0], c[1], c[2]) * a,
};
if invert {
v = 1.0 - v;
}
((v - focal).abs() / span).clamp(0.0, 1.0)
})
.collect::<Vec<f32>>()
});
let mut out = Image::new(src.width, src.height);
match &map {
None => {
let sp = iris(r);
out.rows_mut().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
*o = blur_px(&sp, x, y);
}
});
}
Some(m) => {
// Radii quantised to levels; each pixel blends the two nearest.
let levels = (r.ceil() as usize).clamp(1, 8);
let irises: Vec<_> = (0..=levels).map(|k| iris(r * k as f64 / levels as f64)).collect();
let srcd = &b.img.data;
out.rows_mut().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let f = m[y * w + x] * levels as f32;
let k0 = (f.floor() as usize).min(levels);
let t = f - k0 as f32;
let at = |k: usize| if k == 0 || r * k as f64 / (levels as f64) < 0.5 { srcd[y * w + x] } else { blur_px(&irises[k], x, y) };
let a = at(k0);
*o = if t > 1e-4 && k0 < levels {
let bq = at(k0 + 1);
std::array::from_fn(|c| a[c] + (bq[c] - a[c]) * t)
} else {
a
};
}
});
}
}
b.img = out;
b
}
@ -604,6 +706,12 @@ pub fn specs() -> Vec<EffectSpec> {
p("irisProperties/irisRoundness", "Roundness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("irisProperties/irisAspectRatio", "Aspect Ratio", num(1.0), slider(0.01, 100.0, 0.25, 4.0, 2)),
p("irisProperties/irisRotation", "Rotation", num(0.0), ParamUi::Angle),
p("irisProperties/diffractionFringe", "Diffraction Fringe", num(0.0), slider(0.0, 500.0, 0.0, 500.0, 1)),
p("blurMap/blurMapLayer", "Layer", Value::Layer(None), ParamUi::Layer),
p("blurMap/channel", "Channel", Value::Enum(0), popup(&["Luminance", "Alpha", "Color"])),
p("blurMap/placement", "Placement", Value::Enum(1), popup(&["Center", "Stretch"])),
p("blurMap/blurFocalDistance", "Blur Focal Distance", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
p("blurMap/invertBlurMap", "Invert Blur Map", Value::Bool(false), ParamUi::Checkbox),
p("highlight/specularBrightness", "Gain", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("highlight/specularThreshold", "Threshold", num(255.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
p("highlight/highlightSaturation", "Saturation", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
@ -822,4 +930,38 @@ mod tests {
let pad = (out.img.width - 16) / 2;
assert!(out.img.get(pad as i64, pad as i64)[0] < 0.005);
}
#[test]
fn camera_lens_blur_diffraction_fringe_and_blur_map() {
use crate::{EffectEnv, EffectHost, LayerPixels};
// A single bright point spreads into a disc; with Diffraction Fringe its rim is brighter.
let mut dot = Image::filled(41, 41, [0.0, 0.0, 0.0, 1.0]);
dot.set(20, 20, [1.0, 1.0, 1.0, 1.0]);
let v = [("blurRadius", num(10.0)), ("irisProperties/irisShape", Value::Enum(8)), ("repeatEdge", Value::Bool(true))];
let plain = run("ec.blur.cameralens", &dot, &v).img;
let ring = run("ec.blur.cameralens", &dot, &[v.as_slice(), &[("irisProperties/diffractionFringe", num(300.0))]].concat()).img;
assert!((plain.get(20, 20)[0] - plain.get(29, 20)[0]).abs() < 1e-5, "even disc");
assert!(ring.get(29, 20)[0] > ring.get(20, 20)[0] * 2.0, "{:?} {:?}", ring.get(29, 20), ring.get(20, 20));
// Blur Map: black (focal distance 0) stays sharp, white gets the full radius.
struct Half;
impl EffectHost for Half {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
let mut m = Image::filled(40, 20, [0.0, 0.0, 0.0, 1.0]);
for y in 0..20 {
for x in 20..40 {
m.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
Some(LayerPixels { buf: Buf { img: m, offset: [0.0; 2], scale: 1.0 }, size: [40.0, 20.0] })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
let stripes = crate::util::gen_image(40, 20, |x, _| if x % 4 < 2 { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] });
let vals = [("blurRadius", num(6.0)), ("repeatEdge", Value::Bool(true)), ("blurMap/blurMapLayer", Value::Layer(Some(1)))];
let out = crate::run_fx("ec.blur.cameralens", &vals, stripes.clone(), 0.0, EffectEnv { host: Some(&Half), ..Default::default() }).img;
assert_eq!(out.get(4, 10), stripes.get(4, 10), "sharp where the map is at the focal distance");
assert!((out.get(30, 10)[0] - 0.5).abs() < 0.2, "{:?}", out.get(30, 10));
}
}

View File

@ -0,0 +1,413 @@
//! The 3D system shared by Card Dance, Shatter and Card Wipe: Camera System (Camera Position,
//! Corner Pins, Comp Camera), Lighting (Distant Source, Point Source, First Comp Light) and
//! Material (diffuse, specular, highlight sharpness).
//!
//! Pieces live in "buffer world" space: x, y in buffer pixels, z in buffer pixels away from the
//! viewer (the layer plane is z = 0). A [`Proj`] maps buffer-world points to buffer pixels
//! (homogeneous), so every camera system reduces to one 3×4 matrix.
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use crate::sim::pt;
use crate::{Buf, EffectCtx, ParamSpec, col, num, p, popup, slider};
pub(crate) type M3 = [[f64; 3]; 3];
/// Camera System options.
pub const CAMERA_SYSTEMS: [&str; 3] = ["Camera Position", "Corner Pins", "Comp Camera"];
/// Transform Order options (rotation order, and whether the camera position applies before or
/// after the rotation).
pub const TRANSFORM_ORDERS: [&str; 12] = [
"Rotate XYZ, Position",
"Rotate XZY, Position",
"Rotate YXZ, Position",
"Rotate YZX, Position",
"Rotate ZXY, Position",
"Rotate ZYX, Position",
"Position, Rotate XYZ",
"Position, Rotate XZY",
"Position, Rotate YXZ",
"Position, Rotate YZX",
"Position, Rotate ZXY",
"Position, Rotate ZYX",
];
/// Light Type options.
pub const LIGHT_TYPES: [&str; 3] = ["Distant Source", "Point Source", "First Comp Light"];
/// Camera System, Camera Position and Corner Pins parameters. `z` is the default camera
/// distance (Z Position, in layer sizes at the 70 mm-equivalent Focal Length).
pub(crate) fn camera_params(z: f64) -> Vec<ParamSpec> {
vec![
p("cameraSystem", "Camera System", Value::Enum(0), popup(&CAMERA_SYSTEMS)),
p("cameraPosition/xRotation", "X Rotation", num(0.0), ParamUi::Angle),
p("cameraPosition/yRotation", "Y Rotation", num(0.0), ParamUi::Angle),
p("cameraPosition/zRotation", "Z Rotation", num(0.0), ParamUi::Angle),
p("cameraPosition/xyPosition", "X, Y Position", pt(0.5, 0.5), ParamUi::Point),
p("cameraPosition/cameraZ", "Z Position", num(z), slider(0.05, 10.0, 0.5, 5.0, 2)),
p("cameraPosition/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)),
p("cameraPosition/transformOrder", "Transform Order", Value::Enum(0), popup(&TRANSFORM_ORDERS)),
p("cornerPins/upperLeftCorner", "Upper Left Corner", pt(0.0, 0.0), ParamUi::Point),
p("cornerPins/upperRightCorner", "Upper Right Corner", pt(1.0, 0.0), ParamUi::Point),
p("cornerPins/lowerLeftCorner", "Lower Left Corner", pt(0.0, 1.0), ParamUi::Point),
p("cornerPins/lowerRightCorner", "Lower Right Corner", pt(1.0, 1.0), ParamUi::Point),
p("cornerPins/autoFocalLength", "Auto Focal Length", Value::Bool(true), ParamUi::Checkbox),
p("cornerPins/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)),
]
}
/// Lighting parameters.
pub(crate) fn lighting_params(intensity: f64, ambient: f64) -> Vec<ParamSpec> {
vec![
p("lighting/lightType", "Light Type", Value::Enum(0), popup(&LIGHT_TYPES)),
p("lighting/lightIntensity", "Light Intensity", num(intensity), slider(0.0, 4.0, 0.0, 2.0, 2)),
p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("lighting/lightPosition", "Light Position", pt(0.5, 0.5), ParamUi::Point),
p("lighting/lightDepth", "Light Depth", num(1.0), slider(-10.0, 10.0, 0.0, 5.0, 3)),
p("lighting/ambientLight", "Ambient Light", num(ambient), slider(0.0, 2.0, 0.0, 1.0, 2)),
]
}
/// Material parameters. Specular defaults to 0 so pieces at rest reproduce the layer.
pub(crate) fn material_params(diffuse: f64) -> Vec<ParamSpec> {
vec![
p("material/diffuse", "Diffuse Reflection", num(diffuse), slider(0.0, 2.0, 0.0, 1.0, 2)),
p("material/specular", "Specular Reflection", num(0.0), slider(0.0, 2.0, 0.0, 1.0, 2)),
p("material/highlightSharpness", "Highlight Sharpness", num(15.0), slider(1.0, 100.0, 1.0, 100.0, 1)),
]
}
/// Whether the camera / lighting parameter `param` is shown for the current choices.
pub(crate) fn shown(param: &str, e: &dyn Fn(&str) -> u32) -> Option<bool> {
if param == "cameraPosition" || param.starts_with("cameraPosition/") {
return Some(e("cameraSystem") == 0);
}
if param == "cornerPins" || param.starts_with("cornerPins/") {
return Some(e("cameraSystem") == 1);
}
if param == "lighting/lightPosition" || param == "lighting/lightDepth" {
return Some(e("lighting/lightType") != 2);
}
None
}
pub(crate) fn mat_mul(a: &M3, b: &M3) -> M3 {
std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum()))
}
fn rx(a: f64) -> M3 {
let (s, c) = a.sin_cos();
[[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]
}
fn ry(a: f64) -> M3 {
let (s, c) = a.sin_cos();
[[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]]
}
fn rz(a: f64) -> M3 {
let (s, c) = a.sin_cos();
[[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
}
/// Rotation applying the axes in `order` (0 XYZ, 1 XZY, 2 YXZ, 3 YZX, 4 ZXY, 5 ZYX; the first
/// letter is applied first). Angles in radians.
pub(crate) fn rot_ordered(x: f64, y: f64, z: f64, order: u32) -> M3 {
let (mx, my, mz) = (rx(x), ry(y), rz(z));
let seq: [&M3; 3] = match order % 6 {
1 => [&mx, &mz, &my],
2 => [&my, &mx, &mz],
3 => [&my, &mz, &mx],
4 => [&mz, &mx, &my],
5 => [&mz, &my, &mx],
_ => [&mx, &my, &mz],
};
mat_mul(seq[2], &mat_mul(seq[1], seq[0]))
}
/// A camera: buffer-world → buffer pixels (homogeneous rows X, Y, W; W is the depth in front
/// of the camera, in the units `near` uses).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Proj {
pub m: [[f64; 4]; 3],
/// Points with W below this are behind / too close to the camera.
pub near: f64,
}
impl Proj {
/// Looking down +z at the layer plane from `d` pixels in front of buffer point `c`.
pub fn simple(c: [f64; 2], d: f64) -> Proj {
Proj { m: [[d, 0.0, c[0], 0.0], [0.0, d, c[1], 0.0], [0.0, 0.0, 1.0, d]], near: 0.05 * d }
}
/// `self` after the affine scene transform `p ↦ a·p + t`.
fn after(&self, a: &M3, t: [f64; 3]) -> Proj {
let mut m = [[0.0; 4]; 3];
for i in 0..3 {
for j in 0..3 {
m[i][j] = (0..3).map(|k| self.m[i][k] * a[k][j]).sum();
}
m[i][3] = (0..3).map(|k| self.m[i][k] * t[k]).sum::<f64>() + self.m[i][3];
}
Proj { m, near: self.near }
}
/// A screen-space homography applied after `self`.
fn then(&self, h: &M3) -> Proj {
let mut m = [[0.0; 4]; 3];
for i in 0..3 {
for j in 0..4 {
m[i][j] = (0..3).map(|k| h[i][k] * self.m[k][j]).sum();
}
}
// Keep W's scale (and so `near`) comparable when H rescales it.
Proj { m, near: self.near * h[2][2].abs().max(1e-9) }
}
/// Project a buffer-world point (None behind the camera).
#[cfg(test)]
pub fn project(&self, p: [f64; 3]) -> Option<[f64; 2]> {
let r = |i: usize| self.m[i][0] * p[0] + self.m[i][1] * p[1] + self.m[i][2] * p[2] + self.m[i][3];
let w = r(2);
(w > self.near).then(|| [r(0) / w, r(1) / w])
}
/// The camera's centre in buffer-world space (where the projection collapses), or a point
/// far in front of the layer when it has none (orthographic).
pub fn eye(&self) -> [f64; 3] {
let a: M3 = std::array::from_fn(|i| [self.m[i][0], self.m[i][1], self.m[i][2]]);
match inv3(&a) {
Some(ai) => {
let t = [-self.m[0][3], -self.m[1][3], -self.m[2][3]];
std::array::from_fn(|i| (0..3).map(|k| ai[i][k] * t[k]).sum())
}
None => [0.0, 0.0, -1e9],
}
}
}
pub(crate) fn inv3(m: &M3) -> Option<M3> {
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
if det.abs() < 1e-12 {
return None;
}
let d = 1.0 / det;
Some([
[(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * d, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * d, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * d],
[(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * d, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * d, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * d],
[(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * d, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * d, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * d],
])
}
/// Homography taking the unit square's corners (0,0) (1,0) (1,1) (0,1) to `q`.
fn square_to_quad(q: [[f64; 2]; 4]) -> M3 {
let v = |p: [f64; 2]| effectcraft_geom::Vec2 { x: p[0], y: p[1] };
effectcraft_geom::Mat3::square_to_quad([v(q[0]), v(q[1]), v(q[2]), v(q[3])]).0
}
/// The camera an effect renders through, from its Camera System parameters.
pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj {
let pr = ctx.params;
let [lw, lh] = ctx.layer_size;
let s = b.scale;
let c = [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s];
let size = lw.max(lh) * s;
let f = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
match pr.e("cameraSystem") {
1 => {
// Corner Pins: the layer's corners land on the four pins; the focal length sets how
// strongly depth (pieces leaving the plane) is foreshortened.
let focal = if pr.get("cornerPins/autoFocalLength").is_none_or(|v| v.as_bool()) { 70.0 } else { f("cornerPins/focalLength", 70.0).max(1.0) };
let base = Proj::simple(c, size * 2.0 * focal / 70.0);
let pin = |id: &str| {
let (x, y) = b.to_px(pr.v2(id));
[x, y]
};
let quad =
[pin("cornerPins/upperLeftCorner"), pin("cornerPins/upperRightCorner"), pin("cornerPins/lowerRightCorner"), pin("cornerPins/lowerLeftCorner")];
let rect = [
[b.offset[0], b.offset[1]],
[b.offset[0] + lw * s, b.offset[1]],
[b.offset[0] + lw * s, b.offset[1] + lh * s],
[b.offset[0], b.offset[1] + lh * s],
];
match inv3(&square_to_quad(rect)) {
Some(ri) => base.then(&mat_mul(&square_to_quad(quad), &ri)),
None => base,
}
}
2 => {
// Comp Camera: the comp's camera looking at this layer (layer px → layer px).
let base = Proj::simple(c, size * 2.0);
let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) else { return base };
// buffer world → layer: (p − offset) / s; layer → buffer: · s + offset.
let mut m = [[0.0; 4]; 3];
for i in 0..3 {
for j in 0..3 {
m[i][j] = cam[i][j] / s;
}
m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s;
}
let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]];
let p = Proj { m, near: 1e-6 }.then(&back);
// W of the layer centre sets the scale of `near`.
let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3];
Proj { near: (wc.abs() * 0.02).max(1e-9), ..p }
}
_ => {
let zpos = f("cameraPosition/cameraZ", 2.0).max(0.05);
let focal = f("cameraPosition/focalLength", 70.0).max(1.0);
let base = Proj::simple(c, size * zpos * focal / 140.0);
let order = pr.e("cameraPosition/transformOrder");
let r = rot_ordered(
f("cameraPosition/xRotation", 0.0).to_radians(),
f("cameraPosition/yRotation", 0.0).to_radians(),
f("cameraPosition/zRotation", 0.0).to_radians(),
order,
);
let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2()).unwrap_or([lw * 0.5, lh * 0.5]);
let (px, py) = b.to_px(xy);
let shift = [px - c[0], py - c[1], 0.0];
// Rotate about the layer centre, then move by the X, Y Position offset (or the
// other way round).
let c3 = [c[0], c[1], 0.0];
let rc: [f64; 3] = std::array::from_fn(|i| c3[i] - (0..3).map(|k| r[i][k] * c3[k]).sum::<f64>());
let t = if order >= 6 {
// p ↦ R(p + shift − c) + c
std::array::from_fn(|i| rc[i] + (0..3).map(|k| r[i][k] * shift[k]).sum::<f64>())
} else {
std::array::from_fn(|i| rc[i] + shift[i])
};
base.after(&r, t)
}
}
}
/// The comp's first light as the effect sees it (layer pixels), from [`crate::EffectHost::comp_scene`].
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CompLight {
/// Position in layer pixels (z away from the viewer).
pub pos: [f64; 3],
/// Direction the light travels (parallel / spot lights), unit, layer space.
pub dir: [f64; 3],
/// Colour × intensity (1 = 100 %).
pub color: [f32; 3],
/// 0 parallel, 1 point / spot, 2 ambient.
pub kind: u8,
}
/// The comp's camera and lights relative to the effect's layer.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CompScene {
/// Layer pixels (x, y, z) → layer pixels, homogeneous rows X, Y, W: the comp camera's view
/// of the layer brought back into the layer's own pixel grid (identity on the layer plane
/// for the default camera).
pub camera: Option<[[f64; 4]; 3]>,
pub light: Option<CompLight>,
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn norm(a: [f64; 3]) -> [f64; 3] {
let l = dot(a, a).sqrt();
if l < 1e-12 { [0.0, 0.0, -1.0] } else { [a[0] / l, a[1] / l, a[2] / l] }
}
/// Lighting and Material, ready to shade pieces.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Lighting {
/// 0 distant, 1 point, 2 ambient only.
kind: u8,
color: [f32; 3],
pos: [f64; 3],
/// Distant light: direction towards the light.
to_light: [f64; 3],
ambient: f32,
kd: f32,
ks: f32,
sharp: f32,
}
impl Lighting {
pub fn from(ctx: &EffectCtx, b: &Buf) -> Lighting {
let pr = ctx.params;
let f = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
let [lw, lh] = ctx.layer_size;
let s = b.scale;
let centre = [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s, 0.0];
let intensity = f("lighting/lightIntensity", 1.0) as f32;
let lc = pr.get("lighting/lightColor").map(|v| v.as_color()).unwrap_or([1.0; 4]);
let mut color = [lc[0] * intensity, lc[1] * intensity, lc[2] * intensity];
let lp = pr.get("lighting/lightPosition").map(|v| v.as_vec2()).unwrap_or([lw * 0.5, lh * 0.5]);
let (x, y) = b.to_px(lp);
let mut pos = [x, y, -f("lighting/lightDepth", 1.0) * lw.max(lh) * s];
let mut kind = if pr.e("lighting/lightType") == 1 { 1 } else { 0 };
let mut to_light = norm(sub(pos, centre));
if pr.e("lighting/lightType") == 2 {
match ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) {
Some(l) => {
color = [l.color[0] * intensity, l.color[1] * intensity, l.color[2] * intensity];
pos = [l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1], l.pos[2] * s];
kind = l.kind;
to_light = norm([-l.dir[0], -l.dir[1], -l.dir[2]]);
}
// No light in the comp: ambient only.
None => kind = 2,
}
}
Lighting {
kind,
color,
pos,
to_light,
ambient: f("lighting/ambientLight", 0.25) as f32,
kd: f("material/diffuse", 0.75) as f32,
ks: f("material/specular", 0.0) as f32,
sharp: f("material/highlightSharpness", 15.0).max(1.0) as f32,
}
}
/// Multiplier and additive highlight for a piece with unit normal `n` (rotated front normal)
/// at `at`, seen from `eye`. Pieces are lit on whichever side faces the viewer.
pub fn shade(&self, n: [f64; 3], at: [f64; 3], eye: [f64; 3]) -> ([f32; 3], [f32; 3]) {
let v = norm(sub(eye, at));
let n = if dot(n, v) < 0.0 { [-n[0], -n[1], -n[2]] } else { n };
if self.kind == 2 {
let a = self.ambient;
return ([a + self.kd * self.color[0], a + self.kd * self.color[1], a + self.kd * self.color[2]], [0.0; 3]);
}
let l = if self.kind == 1 { norm(sub(self.pos, at)) } else { self.to_light };
let ndl = dot(n, l).max(0.0) as f32;
let h = norm([l[0] + v[0], l[1] + v[1], l[2] + v[2]]);
let spec = if self.ks > 0.0 && ndl > 0.0 { self.ks * (dot(n, h).max(0.0) as f32).powf(self.sharp) } else { 0.0 };
(std::array::from_fn(|k| self.ambient + self.kd * ndl * self.color[k]), std::array::from_fn(|k| spec * self.color[k]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_camera_keeps_the_layer_plane() {
let p = Proj::simple([10.0, 20.0], 100.0);
assert_eq!(p.project([3.0, 4.0, 0.0]), Some([3.0, 4.0]));
let e = p.eye();
assert!((e[0] - 10.0).abs() < 1e-9 && (e[1] - 20.0).abs() < 1e-9 && (e[2] + 100.0).abs() < 1e-9);
// Further away looks smaller (towards the centre).
let q = p.project([30.0, 20.0, 100.0]).unwrap();
assert!((q[0] - 20.0).abs() < 1e-9);
}
#[test]
fn rotation_orders_differ() {
let a = rot_ordered(0.5, 0.3, 0.2, 0);
let b = rot_ordered(0.5, 0.3, 0.2, 5);
assert!((0..3).any(|i| (0..3).any(|j| (a[i][j] - b[i][j]).abs() > 1e-3)));
let z = rot_ordered(0.0, 0.0, 0.0, 3);
assert_eq!(z, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
}
}

View File

@ -7,51 +7,105 @@ use crate::{CATEGORIES, registry};
/// Effects whose implementation is known to fall short of the reference behaviour:
/// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far
/// as the public behaviour documentation describes it.
pub const PARTIAL: &[(&str, &str)] = &[
("ec.blur.cameralens", "no Diffraction Fringe or Blur Map layer"),
("ec.channel.minimax", "no Don't Shrink Edges option"),
("ec.channel.combiner", "no Saturation Multiplied target"),
("ec.3d.channelextract", "no anti-alias option"),
("ec.color.huesaturation", "no per-range Channel Control"),
("ec.color.levels", "master channel only (no Channel popup)"),
("ec.color.curves", "no Channel popup"),
("ec.color.colorama", "no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking"),
("ec.color.lumetri", "no HSL Secondary, hue/saturation curves, HDR mode or look files"),
("ec.color.selectivecolor", "simplified Colors / Details layout"),
("ec.distort.turbulentdisplace", "no locked pinning variants"),
("ec.distort.opticscompensation", "no Optimal Pixels"),
("ec.distort.transform", "no shutter-angle motion blur"),
("ec.distort.reshape", "no correspondence points"),
("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"),
("ec.generate.fill", "no Fill Mask / All Masks / feather"),
("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"),
("ec.generate.radiowaves", "no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile"),
("ec.generate.scribble", "no caps, joins or Start/End Apply To"),
("ec.generate.cellpattern", "HQ variants render like the standard ones"),
("ec.key.innerouter", "one additional mask per side; no cleanup strokes"),
("ec.key.advancedspill", "no Tolerance, Desaturate or Spill Color Correction"),
("ec.keying.keylight", "no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing"),
("ec.matte.refinesoft", "no Reduce Chatter or motion blur"),
("ec.matte.refinehard", "no Reduce Chatter or motion blur"),
("ec.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"),
("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"),
("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"),
("ec.noise.removegrain", "no preview region, sampling or temporal filtering"),
("ec.vr.digitalglitch", "reduced control set"),
("ec.vr.converter", "common layouts only"),
("ec.time.timewarp", "Pixel Motion blends frames (no optical flow); no matte, warp or crop controls"),
("ec.sim.carddance", "no camera system, corner pins or lighting/material controls"),
("ec.sim.shatter", "no custom shatter map, gradient, textures or extrusion rendering"),
("ec.sim.caustics", "no Sky group or light type"),
("ec.sim.foam", "no wobble, pop velocity, custom bubble texture, environment map or flow map"),
("ec.sim.particleplayground", "no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles"),
("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"),
("ec.stylize.glow", "no Glow Operation or arbitrary colour map"),
("ec.stylize.cartoon", "no Edge Enhancement"),
("ec.transition.cardwipe", "2D flip only; no back layer picker, camera, lighting or jitter"),
("ec.transition.blockdissolve", "no Soft Edges option"),
pub const PARTIAL: &[(&str, &str)] = &[];
/// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path,
/// `group/param`) of an instance of effect `effect`, given the instance's current values
/// (`value(spec id)`). After Effects shows only the controls a popup selects: Levels' channel,
/// Hue/Saturation's Channel Control, the camera system of the card effects, and so on. Every
/// parameter stays animatable and addressable; this is presentation only.
pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option<effectcraft_keyframe::Value>) -> bool {
let e = |id: &str| value(id).map(|v| v.as_enum()).unwrap_or(0);
let b = |id: &str| value(id).is_some_and(|v| v.as_bool());
match effect {
"ec.color.levels" => {
let ch = e("channel") as usize;
for (i, pre) in crate::color_fx::LEVELS_CHANNEL_PREFIX.iter().enumerate() {
if param.starts_with(pre) {
return ch == i + 1;
}
}
let master = ["inBlack", "inWhite", "gamma", "outBlack", "outWhite", "clipToOutputBlack", "clipToOutputWhite"];
!master.contains(&param) || ch == 0
}
"ec.color.huesaturation" => {
let ch = e("channelControl") as usize;
for (i, (pre, _, _)) in crate::color_fx::HUESAT_RANGES.iter().enumerate() {
if param.starts_with(pre) {
return ch == i + 1;
}
}
match param {
"hue" | "saturation" | "lightness" => ch == 0,
"colorizeSaturation" | "colorizeLightness" => b("colorize"),
_ => true,
}
}
"ec.color.selectivecolor" => match param.strip_prefix("details/") {
Some(rest) => {
let g = rest.split('/').next().unwrap_or("");
crate::color2::SC_GROUPS.iter().position(|x| *x == g).is_none_or(|i| i == e("colors") as usize)
}
None => true,
},
"ec.sim.carddance" | "ec.transition.cardwipe" => crate::card3d::shown(param, &e).unwrap_or(true),
"ec.sim.shatter" => match param {
"shape/customShatterMap" | "shape/whiteTilesFixed" => e("shape/pattern") == 5,
_ => crate::card3d::shown(param, &e).unwrap_or(true),
},
_ => {
let _ = b("");
true
}
}
}
/// Controls of GPU-accelerated effects that only the CPU implements: while any of them is away
/// from its default the effect renders on the CPU. (Prefixes ending in `/` cover a whole group.)
const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
(
"ec.color.lumetri",
&["highDynamicRange", "basicCorrection/inputLut", "creative/look", "curves/rgbCurves/", "curves/hueSaturationCurves/", "hslSecondary/"],
),
(
"ec.color.colorama",
&["inputPhase/addPhase", "outputCycle/usePresetPalette", "modify/", "pixelSelection/matchingMode", "masking/maskingMode", "masking/compositeOverLayer"],
),
(
"ec.keying.keylight",
&[
"foregroundColourCorrection/colourSuppression",
"foregroundColourCorrection/suppressionAmount",
"foregroundColourCorrection/colourBalanceSaturation",
"edgeColourCorrection/edgeColourSuppression",
"edgeColourCorrection/edgeSuppressionAmount",
"edgeColourCorrection/edgeColourBalanceSaturation",
"sourceCrops/xMethod",
"sourceCrops/yMethod",
"sourceCrops/edgeColourAlpha",
],
),
("ec.noise.addgrain", &["viewingMode", "preset", "color/redBalance", "color/greenBalance", "color/blueBalance", "previewRegion/"]),
("ec.blur.cameralens", &["irisProperties/diffractionFringe", "blurMap/blurMapLayer"]),
];
/// Whether the GPU kernel of effect `id` covers the instance's current settings (see
/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants and Turbulent Displace's locked
/// pinning).
pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
match id {
"ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false,
"ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false,
_ => {}
}
let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true };
let Some(spec) = crate::find(id) else { return true };
spec.params
.iter()
.filter(|ps| controls.iter().any(|c| if c.ends_with('/') { ps.id.starts_with(c) } else { ps.id == *c }))
.all(|ps| ctx.params.get(ps.id).is_none_or(|v| *v == crate::default_value(ps, ctx.layer_size)))
}
/// Implementation status of effect `id` (`Implemented` or `Partial: …`).
pub fn status(id: &str) -> String {
match PARTIAL.iter().find(|(e, _)| *e == id) {
@ -122,6 +176,60 @@ mod tests {
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
}
#[test]
fn cpu_only_controls_keep_effects_off_the_gpu() {
use effectcraft_keyframe::Value;
let ctx_with = |id: &str, set: &[(&str, Value)]| {
let s = crate::find(id).unwrap();
let mut values: std::collections::HashMap<String, Value> =
s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [64.0, 64.0]))).collect();
for (k, v) in set {
values.insert(k.to_string(), v.clone());
}
crate::Params { values }
};
let ok = |id: &str, set: &[(&str, Value)]| {
let params = ctx_with(id, set);
let ctx = crate::EffectCtx { params: &params, time: 0.0, layer_size: [64.0, 64.0], seed: 0, adjustment: false, env: Default::default() };
super::gpu_supported(id, &ctx)
};
assert!(ok("ec.color.lumetri", &[]));
assert!(!ok("ec.color.lumetri", &[("hslSecondary/correction/saturation", Value::Scalar(50.0))]));
assert!(!ok("ec.noise.addgrain", &[("preset", Value::Enum(2))]));
assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))]));
assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))]));
assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))]));
assert!(ok("ec.blur.gaussian", &[]));
}
#[test]
fn popups_choose_the_shown_controls() {
use effectcraft_keyframe::Value;
let with = |pairs: &'static [(&'static str, u32)]| move |id: &str| pairs.iter().find(|(k, _)| *k == id).map(|(_, v)| Value::Enum(*v));
let shown = |e: &str, p: &str, pairs: &'static [(&'static str, u32)]| super::param_shown(e, p, &with(pairs));
// Levels: RGB shows the master controls only; Red shows the red ones.
assert!(shown("ec.color.levels", "inBlack", &[]));
assert!(!shown("ec.color.levels", "redInBlack", &[]));
assert!(shown("ec.color.levels", "redInBlack", &[("channel", 1)]));
assert!(!shown("ec.color.levels", "inBlack", &[("channel", 1)]));
assert!(shown("ec.color.levels", "channel", &[("channel", 1)]));
// Hue/Saturation: Master or one colour range.
assert!(shown("ec.color.huesaturation", "hue", &[]));
assert!(shown("ec.color.huesaturation", "bluesHue", &[("channelControl", 5)]));
assert!(!shown("ec.color.huesaturation", "redsHue", &[("channelControl", 5)]));
// Card effects: the camera system's group.
assert!(shown("ec.sim.carddance", "cameraPosition", &[]));
assert!(!shown("ec.sim.carddance", "cornerPins", &[]));
assert!(shown("ec.transition.cardwipe", "cornerPins/upperLeftCorner", &[("cameraSystem", 1)]));
assert!(!shown("ec.sim.shatter", "shape/customShatterMap", &[]));
// Selective Color: the chosen range's Details group.
assert!(shown("ec.color.selectivecolor", "details/reds", &[]));
assert!(!shown("ec.color.selectivecolor", "details/blues/bluesCyan", &[]));
assert!(shown("ec.color.selectivecolor", "details", &[]));
// Everything else is always shown.
assert!(shown("ec.blur.gaussian", "blurriness", &[]));
}
#[test]
fn partial_notes_name_registered_effects() {
for (id, note) in super::PARTIAL {

View File

@ -90,7 +90,8 @@ const COMBINER_FROM: [&str; 14] = [
"Min RGB",
];
/// Channel Combiner's To options (a single value goes into one channel).
const COMBINER_TO: [&str; 8] = ["Red Only", "Green Only", "Blue Only", "Alpha Only", "Lightness Only", "Hue Only", "Saturation Only", "Grayscale"];
const COMBINER_TO: [&str; 9] =
["Red Only", "Green Only", "Blue Only", "Alpha Only", "Lightness Only", "Hue Only", "Saturation Only", "Grayscale", "Saturation Multiplied"];
fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf {
let from = ctx.params.e("from");
@ -160,6 +161,13 @@ fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (r, g, bb) = hsl_to_rgb(0.0, v.clamp(0.0, 1.0), 0.5);
[r, g, bb]
}
// Saturation Multiplied: the pixel keeps its hue and lightness; its saturation
// is scaled by the value.
8 => {
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let (r, g, bb) = hsl_to_rgb(h, (s * v).clamp(0.0, 1.0), l);
[r, g, bb]
}
_ => [v, v, v],
}
}
@ -175,6 +183,25 @@ fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf {
/// Minimax's Direction options.
pub(crate) const MINIMAX_DIRECTIONS: [&str; 3] = ["Horizontal & Vertical", "Just Horizontal", "Just Vertical"];
/// `p` with `rx` / `ry` zero pixels added on every side.
fn pad_plane(p: &Plane, rx: usize, ry: usize) -> Plane {
let (w, h) = (p.w + 2 * rx, p.h + 2 * ry);
let mut out = Plane::new(w, h);
for y in 0..p.h {
out.data[(y + ry) * w + rx..(y + ry) * w + rx + p.w].copy_from_slice(&p.data[y * p.w..(y + 1) * p.w]);
}
out
}
/// The `w`×`h` interior of a plane padded by [`pad_plane`].
fn crop_plane(p: &Plane, rx: usize, ry: usize, w: usize, h: usize) -> Plane {
let mut out = Plane::new(w, h);
for y in 0..h {
out.data[y * w..(y + 1) * w].copy_from_slice(&p.data[(y + ry) * p.w + rx..(y + ry) * p.w + rx + w]);
}
out
}
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 {
@ -182,6 +209,7 @@ fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
let op = ctx.params.e("operation");
let ch = ctx.params.e("channel");
let dont_shrink = ctx.params.b("dontShrinkEdges");
let (rx, ry) = match ctx.params.e("direction") {
1 => (r, 0),
2 => (0, r),
@ -204,12 +232,16 @@ fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf {
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),
// Beyond the layer edge is empty (0), so Minimum eats into the edges unless Don't
// Shrink Edges extends the edge pixels instead.
let padded = if dont_shrink { pl.clone() } else { pad_plane(pl, rx, ry) };
let out = match op {
0 => morph_plane(&padded, rx, ry, false),
1 => morph_plane(&padded, rx, ry, true),
2 => morph_plane(&morph_plane(&padded, rx, ry, false), rx, ry, true),
_ => morph_plane(&morph_plane(&padded, rx, ry, true), rx, ry, false),
};
*pl = if dont_shrink { out } else { crop_plane(&out, rx, ry, pl.w, pl.h) };
}
let mut img = join(&planes);
img.data.par_iter_mut().for_each(|px| {
@ -408,6 +440,7 @@ pub fn specs() -> Vec<EffectSpec> {
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(&MINIMAX_DIRECTIONS)),
p("dontShrinkEdges", "Don't Shrink Edges", Value::Bool(false), ParamUi::Checkbox),
],
minimax,
),
@ -658,4 +691,23 @@ mod tests {
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]));
}
#[test]
fn minimax_shrinks_layer_edges_unless_told_not_to() {
let img = Image::filled(9, 9, [1.0, 1.0, 1.0, 1.0]);
let shrunk = run("ec.channel.minimax", &[("radius", num(2.0)), ("operation", Value::Enum(0))], img.clone());
assert_eq!(shrunk.get(0, 4)[0], 0.0);
assert_eq!(shrunk.get(4, 4)[0], 1.0);
let kept = run("ec.channel.minimax", &[("radius", num(2.0)), ("operation", Value::Enum(0)), ("dontShrinkEdges", Value::Bool(true))], img);
assert_eq!(kept.get(0, 4)[0], 1.0);
}
#[test]
fn channel_combiner_saturation_multiplied() {
// From Lightness (0.5) into Saturation Multiplied halves the saturation, keeping hue.
let red = Image::filled(1, 1, [0.75, 0.25, 0.25, 1.0]);
let out = run("ec.channel.combiner", &[("from", Value::Enum(8)), ("to", Value::Enum(8))], red).get(0, 0);
let (h, s, l) = rgb_to_hsl(out[0], out[1], out[2]);
assert!((h.abs() < 1e-4 || (h - 1.0).abs() < 1e-4) && (s - 0.25).abs() < 1e-3 && (l - 0.5).abs() < 1e-3, "{h} {s} {l}");
}
}

View File

@ -31,12 +31,17 @@ fn aux(ctx: &EffectCtx) -> Option<Arc<AuxChannels>> {
/// Aux index for each buffer pixel (None outside the aux image).
fn index_map(b: &Buf, a: &AuxChannels) -> Vec<Option<usize>> {
index_map_at(b, a, [0.5, 0.5])
}
/// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1).
fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> Vec<Option<usize>> {
let (w, h) = (b.img.width as usize, b.img.height as usize);
let inv = 1.0 / b.scale.max(1e-9);
(0..w * h)
.into_par_iter()
.map(|i| {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let (x, y) = ((i % w) as f64 + at[0], (i / w) as f64 + at[1]);
a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv)
})
.collect()
@ -60,7 +65,28 @@ pub const EXTRACT_CHANNELS: [&str; 8] = ["Z-Depth", "Object ID", "Texture UV", "
fn channel_extract(ctx: &EffectCtx, mut b: Buf) -> Buf {
let Some(a) = aux(ctx) else { return b };
if ctx.params.b("antialias") {
// Anti-alias: average four sub-pixel samples of the channel.
let subs = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]];
let imgs: Vec<Image> = subs.iter().map(|s| channel_extract_at(ctx, &b, &a, &index_map_at(&b, &a, *s))).collect::<Option<Vec<_>>>().unwrap_or_default();
if imgs.len() == 4 {
let mut out = imgs[0].clone();
out.data.par_iter_mut().enumerate().for_each(|(i, p)| {
*p = std::array::from_fn(|c| imgs.iter().map(|m| m.data[i][c]).sum::<f32>() * 0.25);
});
b.img = out;
}
return b;
}
let idx = index_map(&b, &a);
if let Some(i) = channel_extract_at(ctx, &b, &a, &idx) {
b.img = i;
}
b
}
/// 3D Channel Extract through one index map (None when the channel is missing).
fn channel_extract_at(ctx: &EffectCtx, b: &Buf, a: &AuxChannels, idx: &[Option<usize>]) -> Option<Image> {
let (bp, wp) = (ctx.params.f("blackPoint") as f32, ctx.params.f("whitePoint") as f32);
// Clamp Output limits the mapped values to 0..1; Invert Depth Map flips them.
let (clamp, invert) = (ctx.params.b("clampOutput"), ctx.params.b("invertDepthMap"));
@ -69,24 +95,20 @@ fn channel_extract(ctx: &EffectCtx, mut b: Buf) -> Buf {
let lv = |v: f32| if wp >= bp { lv(v) } else { 1.0 - cl((v - wp) / (bp - wp).max(1e-9)) };
let lv = |v: f32| if invert { 1.0 - lv(v) } else { lv(v) };
let get3 = |names: [&[&str]; 3]| -> Option<[&[f32]; 3]> { Some([a.find(names[0])?, a.find(names[1])?, a.find(names[2])?]) };
let img: Option<Image> = match ctx.params.e("channel") {
0 => a.depth().map(|z| map_grey(&b, &idx, |i| lv(z[i]))),
1 => a.object_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
7 => a.material_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
4 => a.coverage().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
match ctx.params.e("channel") {
0 => a.depth().map(|z| map_grey(b, idx, |i| lv(z[i]))),
1 => a.object_id().map(|c| map_grey(b, idx, |i| lv(c[i]))),
7 => a.material_id().map(|c| map_grey(b, idx, |i| lv(c[i]))),
4 => a.coverage().map(|c| map_grey(b, idx, |i| lv(c[i]))),
2 => get3([&["UV.U", "U", "uv.x"], &["UV.V", "V", "uv.y"], &["UV.W", "W", "uv.z"]])
.or_else(|| Some([a.find(&["UV.U", "U", "uv.x"])?, a.find(&["UV.V", "V", "uv.y"])?, a.find(&["UV.V", "V", "uv.y"])?]))
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), 0.0])),
.map(|c| map_rgb(b, idx, |i| [lv(c[0][i]), lv(c[1][i]), 0.0])),
3 => get3([&["N.X", "Normal.X", "normals.x"], &["N.Y", "Normal.Y", "normals.y"], &["N.Z", "Normal.Z", "normals.z"]])
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i] * 0.5 + 0.5), lv(c[1][i] * 0.5 + 0.5), lv(c[2][i] * 0.5 + 0.5)])),
.map(|c| map_rgb(b, idx, |i| [lv(c[0][i] * 0.5 + 0.5), lv(c[1][i] * 0.5 + 0.5), lv(c[2][i] * 0.5 + 0.5)])),
5 => get3([&["BG.R", "Background.R"], &["BG.G", "Background.G"], &["BG.B", "Background.B"]])
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), lv(c[2][i])])),
_ => get3([&["R"], &["G"], &["B"]]).map(|c| map_rgb(&b, &idx, |i| [c[0][i], c[1][i], c[2][i]])),
};
if let Some(i) = img {
b.img = i;
.map(|c| map_rgb(b, idx, |i| [lv(c[0][i]), lv(c[1][i]), lv(c[2][i])])),
_ => get3([&["R"], &["G"], &["B"]]).map(|c| map_rgb(b, idx, |i| [c[0][i], c[1][i], c[2][i]])),
}
b
}
fn map_grey(b: &Buf, idx: &[Option<usize>], f: impl Fn(usize) -> f32 + Sync) -> Image {
@ -443,6 +465,7 @@ pub fn specs() -> Vec<EffectSpec> {
p("whitePoint", "White Point", num(10000.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)),
p("clampOutput", "Clamp Output", Value::Bool(true), ParamUi::Checkbox),
p("invertDepthMap", "Invert Depth Map", Value::Bool(false), ParamUi::Checkbox),
p("antialias", "Anti-alias", Value::Bool(false), ParamUi::Checkbox),
],
channel_extract,
),
@ -689,4 +712,18 @@ mod tests {
let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default());
assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
}
#[test]
fn channel_extract_antialias_averages_sub_pixels() {
// Depth at twice the layer resolution, alternating 0 / 1000 per aux column.
let mut a = AuxChannels::new(40, 20, 2.0);
a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())];
let host = AuxHost(Arc::new(a));
let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))];
let hard = run("ec.3d.channelextract", &vals, &host);
let v = hard.get(5, 5)[0];
assert!(v == 0.0 || v == 1.0, "{v}");
let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host);
assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5));
}
}

View File

@ -504,6 +504,16 @@ const SC_IDS: [[&str; 4]; 9] = [
["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"],
["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"],
];
/// Selective Color's Colors popup (the range whose controls Effect Controls shows) and the
/// Details twirl-down group of each range.
pub const SC_COLORS: [&str; 9] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks"];
pub const SC_GROUPS: [&str; 9] = ["reds", "yellows", "greens", "cyans", "blues", "magentas", "whites", "neutrals", "blacks"];
/// Spec id of range `r`'s control `leaf` (`details/reds/redsCyan`).
fn sc_param_id(r: usize, leaf: &str) -> String {
format!("details/{}/{leaf}", SC_GROUPS[r])
}
const SC_NAMES: [[&str; 4]; 9] = [
["Reds Cyan", "Reds Magenta", "Reds Yellow", "Reds Black"],
["Yellows Cyan", "Yellows Magenta", "Yellows Yellow", "Yellows Black"],
@ -534,7 +544,7 @@ fn sc_weights(c: [f32; 3]) -> [f32; 9] {
}
fn selective_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let adj = SC_IDS.map(|ids| ids.map(|id| ctx.params.f(id) as f32 / 100.0));
let adj: [[f32; 4]; 9] = std::array::from_fn(|r| SC_IDS[r].map(|id| ctx.params.f(&sc_param_id(r, id)) as f32 / 100.0));
if adj.iter().all(|r| r.iter().all(|v| *v == 0.0)) {
return b;
}
@ -882,10 +892,12 @@ pub fn specs() -> Vec<EffectSpec> {
}
lv_params.push(p("clipToOutputBlack", "Clip To Output Black", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP)));
lv_params.push(p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP)));
let mut sc_params = vec![p("method", "Method", Value::Enum(0), popup(&["Relative", "Absolute"]))];
for (ids, names) in SC_IDS.iter().zip(SC_NAMES.iter()) {
let mut sc_params = vec![p("method", "Method", Value::Enum(0), popup(&["Relative", "Absolute"])), p("colors", "Colors", Value::Enum(0), popup(&SC_COLORS))];
// Details: every range's Cyan / Magenta / Yellow / Black in its own twirl-down.
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
for (r, (ids, names)) in SC_IDS.iter().zip(SC_NAMES.iter()).enumerate() {
for k in 0..4 {
sc_params.push(p(ids[k], names[k], num(0.0), pct()));
sc_params.push(p(leak(sc_param_id(r, ids[k])), names[k], num(0.0), pct()));
}
}
let clip_params = |extra: bool| {
@ -911,7 +923,15 @@ pub fn specs() -> Vec<EffectSpec> {
spec(
"ec.color.curves",
"Curves",
vec![curve("rgb", "RGB"), curve("red", "Red"), curve("green", "Green"), curve("blue", "Blue"), curve("alpha", "Alpha")],
vec![
// The channel the graph edits (the Effect Controls graph's Channel popup).
p("channel", "Channel", Value::Enum(0), ParamUi::Hidden),
curve("rgb", "RGB"),
curve("red", "Red"),
curve("green", "Green"),
curve("blue", "Blue"),
curve("alpha", "Alpha"),
],
curves,
),
spec("ec.color.levelsic", "Levels (Individual Controls)", lv_params, levels_ic),
@ -1138,10 +1158,10 @@ mod tests {
let img = ramp();
assert_eq!(run("ec.color.selectivecolor", &img, &[]), img);
let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]);
let out = run("ec.color.selectivecolor", &red, &[("redsCyan", num(100.0)), ("method", Value::Enum(1))]);
let out = run("ec.color.selectivecolor", &red, &[("details/reds/redsCyan", num(100.0)), ("method", Value::Enum(1))]);
assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]);
let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]);
assert_eq!(run("ec.color.selectivecolor", &blue, &[("redsCyan", num(100.0))]), blue);
assert_eq!(run("ec.color.selectivecolor", &blue, &[("details/reds/redsCyan", num(100.0))]), blue);
}
#[test]

View File

@ -249,8 +249,193 @@ fn tint_offset(c: [f32; 4]) -> [f32; 3] {
[c[0] - l, c[1] - l, c[2] - l]
}
/// Control points `x,y x,y …` (0..1, whitespace or `;` separated), sorted by x.
fn parse_points(s: &str) -> Vec<(f32, f32)> {
let mut pts: Vec<(f32, f32)> = s
.split(|c: char| c.is_whitespace() || c == ';')
.filter_map(|t| {
let (x, y) = t.split_once(',')?;
let (x, y) = (x.trim().parse::<f32>().ok()?, y.trim().parse::<f32>().ok()?);
(x.is_finite() && y.is_finite()).then_some((x, y))
})
.collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
pts
}
/// A Lumetri hue / saturation curve: control points `x,y` in 0..1 where y = 0.5 is neutral,
/// interpolated with a cardinal (Catmull-Rom) spline through the points. Hue curves wrap
/// around (x = 0 and x = 1 are the same hue); the others are flat beyond their end points.
/// Tabulated as offsets `y − 0.5`.
#[derive(Clone, Debug)]
pub(crate) struct OffsetCurve {
lut: Vec<f32>,
}
impl OffsetCurve {
const N: usize = 512;
/// `None` for an empty or flat-neutral curve.
pub(crate) fn parse(s: &str, periodic: bool) -> Option<OffsetCurve> {
let pts = parse_points(s);
if pts.is_empty() || pts.iter().all(|p| (p.1 - 0.5).abs() < 1e-6) {
return None;
}
let mut ext: Vec<(f32, f32)> = vec![];
if periodic {
let wrapped: Vec<(f32, f32)> = pts.iter().map(|&(x, y)| (x.rem_euclid(1.0), y)).collect();
let mut w = wrapped;
w.sort_by(|a, b| a.0.total_cmp(&b.0));
for k in -1..=1 {
ext.extend(w.iter().map(|&(x, y)| (x + k as f32, y)));
}
} else {
let (x0, y0) = pts[0];
let (x1, y1) = pts[pts.len() - 1];
ext.push((x0.min(0.0) - 2.0, y0));
ext.push((x0.min(0.0) - 1.0, y0));
ext.extend(pts.iter().copied());
ext.push((x1.max(1.0) + 1.0, y1));
ext.push((x1.max(1.0) + 2.0, y1));
}
let n = ext.len();
// Tangents: the mean of the neighbouring slopes, flat at local extrema and next to flat
// segments so the curve never overshoots its points.
let slope = |k: usize| (ext[k + 1].1 - ext[k].1) / (ext[k + 1].0 - ext[k].0).max(1e-6);
let m: Vec<f32> = (0..n)
.map(|k| {
if k == 0 || k == n - 1 {
return 0.0;
}
let (a, b) = (slope(k - 1), slope(k));
if a * b <= 0.0 { 0.0 } else { (a + b) * 0.5 }
})
.collect();
let eval = |x: f32| -> f32 {
let k = ext.partition_point(|p| p.0 <= x).saturating_sub(1).min(n - 2);
let (xa, ya) = ext[k];
let (xb, yb) = ext[k + 1];
let h = (xb - xa).max(1e-6);
let t = ((x - xa) / h).clamp(0.0, 1.0);
let (t2, t3) = (t * t, t * t * t);
(2.0 * t3 - 3.0 * t2 + 1.0) * ya + (t3 - 2.0 * t2 + t) * h * m[k] + (-2.0 * t3 + 3.0 * t2) * yb + (t3 - t2) * h * m[k + 1]
};
let single = pts.len() == 1;
let lut = (0..=Self::N).map(|i| if single { pts[0].1 - 0.5 } else { eval(i as f32 / Self::N as f32) - 0.5 }).collect();
Some(OffsetCurve { lut })
}
/// Offset from neutral at `x` (clamped to 0..1).
pub(crate) fn at(&self, x: f32) -> f32 {
let f = x.clamp(0.0, 1.0) * Self::N as f32;
let i = (f as usize).min(Self::N - 1);
let t = f - i as f32;
self.lut[i] + (self.lut[i + 1] - self.lut[i]) * t
}
}
/// Lumetri's built-in creative looks (our own formulas; Custom loads a `.cube` file).
pub const LUMETRI_LOOKS: [&str; 8] =
["None", "Custom", "EC Warm Film", "EC Cool Night", "EC Bleach Bypass", "EC Teal & Orange", "EC Faded Matte", "EC Monochrome"];
fn builtin_look(look: u32, c: [f32; 3]) -> [f32; 3] {
let l = luminance(c[0], c[1], c[2]);
match look {
2 => [c[0] * 1.06 + 0.015, c[1] * 1.01 + 0.01, c[2] * 0.88],
3 => [c[0] * 0.82, c[1] * 0.92, c[2] * 1.08 + 0.02].map(|v| v * 0.92),
4 => {
// Silver retained: half the saturation, a luminance overlay for contrast.
let d = c.map(|v| l + (v - l) * 0.45);
let o = |v: f32| if l < 0.5 { 2.0 * v * l } else { 1.0 - 2.0 * (1.0 - v) * (1.0 - l) };
d.map(|v| v * 0.5 + o(v) * 0.5)
}
5 => {
let t = (l - 0.5).clamp(-0.5, 0.5);
[c[0] + 0.18 * t, c[1] + 0.03 * t, c[2] - 0.16 * t]
}
6 => c.map(|v| 0.08 + v * 0.84).map(|v| l + (v - l) * 0.8),
7 => [l; 3],
_ => c,
}
}
/// HSL Secondary's Show Mask styles.
const HSL_MASK_STYLES: [&str; 3] = ["Color/Gray", "Color/Black", "White/Black"];
/// HSL Secondary key: membership (0..1) of an HSL colour (h, s, l in 0..1).
struct HslKey {
hue: Option<(f32, f32, f32)>,
sat: Option<(f32, f32, f32)>,
luma: Option<(f32, f32, f32)>,
}
impl HslKey {
fn from(ctx: &EffectCtx) -> HslKey {
let pr = ctx.params;
let k = |id: &str| pr.f(&format!("hslSecondary/key/{id}")) as f32;
let on = |id: &str| pr.get(&format!("hslSecondary/key/{id}")).is_none_or(|v| v.as_bool());
let band = |lo: &str, hi: &str, soft: &str| (k(lo) / 100.0, k(hi) / 100.0, k(soft) / 100.0);
HslKey {
hue: on("hueEnabled").then(|| (k("hueCenter"), k("hueRange"), k("hueSoftness"))),
sat: on("saturationEnabled").then(|| band("saturationLow", "saturationHigh", "saturationSoftness")),
luma: on("lightnessEnabled").then(|| band("lightnessLow", "lightnessHigh", "lightnessSoftness")),
}
}
fn band(v: f32, (lo, hi, soft): (f32, f32, f32)) -> f32 {
let (lo, hi) = (lo.min(hi), lo.max(hi));
let d = if v < lo {
lo - v
} else if v > hi {
v - hi
} else {
0.0
};
if d <= 0.0 {
1.0
} else if soft > 0.0 {
(1.0 - d / soft).max(0.0)
} else {
0.0
}
}
fn weight(&self, h: f32, s: f32, l: f32) -> f32 {
let mut w = 1.0;
if let Some((centre, range, soft)) = self.hue
&& range < 180.0
{
let d = ((h * 360.0 - centre).rem_euclid(360.0)).min((centre - h * 360.0).rem_euclid(360.0));
let hw = if d <= range {
1.0
} else if soft > 0.0 {
(1.0 - (d - range) / soft).max(0.0)
} else {
0.0
};
// Greys have no hue: they belong to a hue key only as far as they are saturated.
w *= hw * (s * 20.0).min(1.0);
}
if let Some(b) = self.sat {
w *= Self::band(s, b);
}
if let Some(b) = self.luma {
w *= Self::band(l, b);
}
w
}
}
/// Lumetri Color: Basic Correction (input LUT, white balance, HDR range, tone, saturation),
/// Creative (look, adjustments), Curves (tone sliders, RGB curves, hue/saturation curves),
/// Color Wheels, HSL Secondary (key, refine, correction), Vignette, in that order.
fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let hdr = pr.b("highDynamicRange");
let hdr_white = if hdr { (pr.get("basicCorrection/tone/hdrWhite").map(Value::as_f64).unwrap_or(100.0) / 100.0).max(0.01) as f32 } else { 1.0 };
let hdr_spec = if hdr { (pr.f("basicCorrection/tone/hdrSpecular") / 100.0) as f32 } else { 0.0 };
let input_lut = if pr.e("basicCorrection/inputLut") == 1 { crate::utility::load_lut(pr.s("basicCorrection/inputLutFile")) } else { None };
let look = pr.e("creative/look");
let look_lut = if look == 1 { crate::utility::load_lut(pr.s("creative/lookFile")) } else { None };
let temp = (pr.f("basicCorrection/whiteBalance/temperature") / 100.0) as f32;
let tint = (pr.f("basicCorrection/whiteBalance/tint") / 100.0) as f32;
let expo = 2f32.powf(pr.f("basicCorrection/tone/exposure") as f32);
@ -277,6 +462,12 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
]
};
let (cm, cr, cg, cb) = (curve("curveMaster"), curve("curveRed"), curve("curveGreen"), curve("curveBlue"));
let rgb_curves =
["curves/rgbCurves/master", "curves/rgbCurves/red", "curves/rgbCurves/green", "curves/rgbCurves/blue"].map(|id| crate::color2::Curve::parse(pr.s(id)));
let hs = |id: &str, periodic: bool| OffsetCurve::parse(pr.s(&format!("curves/hueSaturationCurves/{id}")), periodic);
let (hue_sat, hue_hue, hue_luma, luma_sat, sat_sat) =
(hs("hueVsSat", true), hs("hueVsHue", true), hs("hueVsLuma", true), hs("lumaVsSat", false), hs("satVsSat", false));
let any_hs = hue_sat.is_some() || hue_hue.is_some() || hue_luma.is_some() || luma_sat.is_some() || sat_sat.is_some();
let ws = tint_offset(pr.color("colorWheels/shadowsWheel"));
let wm = tint_offset(pr.color("colorWheels/midtonesWheel"));
let wh_ = tint_offset(pr.color("colorWheels/highlightsWheel"));
@ -285,12 +476,29 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
let vround = (pr.f("vignette/vignetteRoundness") / 100.0) as f32;
let vfeather = (pr.f("vignette/vignetteFeather") / 100.0) as f32;
let sharpen = (pr.f("creative/adjustments/sharpen") / 100.0) as f32;
// HSL Secondary.
let sec = |id: &str, d: f64| pr.get(&format!("hslSecondary/{id}")).map(Value::as_f64).unwrap_or(d) as f32;
let s_temp = sec("correction/temperature", 0.0) / 100.0;
let s_tint = sec("correction/tint", 0.0) / 100.0;
let s_contrast = sec("correction/contrast", 0.0) / 100.0;
let s_sharpen = sec("correction/sharpen", 0.0) / 100.0;
let s_sat = sec("correction/saturation", 100.0) / 100.0;
let s_wheel = pr.get("hslSecondary/correction/correctionWheel").map(|v| tint_offset(v.as_color())).unwrap_or([0.0; 3]);
let show_mask = pr.b("hslSecondary/key/showMask");
let sec_active = show_mask || s_temp != 0.0 || s_tint != 0.0 || s_contrast != 0.0 || s_sharpen != 0.0 || s_sat != 1.0 || s_wheel != [0.0; 3];
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
let w = b.img.width as usize;
let (cx, cy) = (lx + lw * 0.5, ly + lh * 0.5);
map_colour(&mut b, |i, c| {
map_colour(&mut b, |_, c| {
let mut c = c;
// Basic Correction: white balance, exposure, contrast, tone, saturation.
if let Some(l) = &input_lut {
c = l.apply(c);
}
// Basic Correction: white balance, exposure, contrast, tone, saturation. With High
// Dynamic Range the tone controls work on a range whose white is HDR White.
if hdr_white != 1.0 {
c = c.map(|v| v / hdr_white);
}
if temp != 0.0 || tint != 0.0 {
c = [c[0] * (1.0 + 0.25 * temp), c[1] * (1.0 - 0.25 * tint), c[2] * (1.0 - 0.25 * temp)];
}
@ -306,13 +514,24 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
let d = 0.25 * hi * smoothstep(0.5, 1.0, lt) + 0.25 * sh * (1.0 - smoothstep(0.0, 0.5, lt)) + 0.2 * wh * lt * lt + 0.2 * bl * (1.0 - lt).powi(2);
c = c.map(|v| v + d);
}
if hdr_spec != 0.0 {
// HDR Specular scales what lies above white.
c = c.map(|v| if v > 1.0 { 1.0 + (v - 1.0) * (1.0 + hdr_spec).max(0.0) } else { v });
}
if hdr_white != 1.0 {
c = c.map(|v| v * hdr_white);
}
let l = luminance(c[0], c[1], c[2]);
if sat != 1.0 {
c = c.map(|v| l + (v - l) * sat);
}
// Creative.
if intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) {
let mut d = c;
// Creative: the look, then the adjustments, all at Intensity.
let has_look = look >= 2 || look_lut.is_some();
if intensity != 0.0 && (has_look || faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) {
let mut d = match &look_lut {
Some(lut) => lut.apply(c),
None => builtin_look(look, c),
};
if faded != 0.0 {
d = d.map(|v| v * (1.0 - 0.25 * faded) + 0.12 * faded);
}
@ -336,9 +555,40 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
c = [lerp(c[0], d[0], intensity), lerp(c[1], d[1], intensity), lerp(c[2], d[2], intensity)];
}
// Curves.
// Curves: tone sliders, RGB curves (master, then each channel), hue/saturation curves.
c = c.map(|v| tone_curve(v, cm[0], cm[1], cm[2]));
c = [tone_curve(c[0], cr[0], cr[1], cr[2]), tone_curve(c[1], cg[0], cg[1], cg[2]), tone_curve(c[2], cb[0], cb[1], cb[2])];
if let Some(m) = &rgb_curves[0] {
c = c.map(|v| m.eval(v));
}
for k in 0..3 {
if let Some(cv) = &rgb_curves[k + 1] {
c[k] = cv.eval(c[k]);
}
}
if any_hs {
let (h, s, l) = rgb_to_hsl(c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0));
let chroma = (s * 20.0).min(1.0);
let off = |cv: &Option<OffsetCurve>, x: f32| cv.as_ref().map(|cv| cv.at(x)).unwrap_or(0.0);
// y = 1 doubles saturation, y = 0 removes it.
let mut ms = (1.0 + 2.0 * off(&hue_sat, h) * chroma) * (1.0 + 2.0 * off(&luma_sat, l)) * (1.0 + 2.0 * off(&sat_sat, s));
ms = ms.max(0.0);
let dh = off(&hue_hue, h) * chroma;
let dl = off(&hue_luma, h) * chroma;
let luma = luminance(c[0], c[1], c[2]);
if dh != 0.0 {
let (r, g, b2) = hsl_to_rgb((h + dh).rem_euclid(1.0), s, l);
// Keep values outside 0..1 (HDR) by moving only the in-range part.
let base = [c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0)];
c = [c[0] + r - base[0], c[1] + g - base[1], c[2] + b2 - base[2]];
}
if ms != 1.0 {
c = c.map(|v| luma + (v - luma) * ms);
}
if dl != 0.0 {
c = c.map(|v| v + dl);
}
}
// Color wheels.
if ws != [0.0; 3] || wm != [0.0; 3] || wh_ != [0.0; 3] {
let lt = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
@ -349,6 +599,70 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
c[k] += 0.5 * (ws[k] * a + wm[k] * m + wh_[k] * z);
}
}
c
});
// HSL Secondary: key the graded colour, refine the matte, apply the correction through it.
let mask = sec_active.then(|| {
let key = HslKey::from(ctx);
let mut m = Plane::from_image(&b.img, |px| {
let (c, a) = unpremul(px);
if a <= 0.0 {
return 0.0;
}
let (h, s, l) = rgb_to_hsl(c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0));
key.weight(h, s, l)
});
let denoise = sec("refine/denoise", 0.0) / 100.0;
if denoise > 0.0 {
// Smooth speckle, then restore the matte's edge contrast.
let s = (denoise * 3.0) as f64 * b.scale;
m = gauss_plane(&m, s, s);
let k = 1.0 + denoise * 2.0;
m.data.iter_mut().for_each(|v| *v = ((*v - 0.5) * k + 0.5).clamp(0.0, 1.0));
}
let blur = sec("refine/blur", 0.0) / 100.0;
if blur > 0.0 {
let s = (blur * 10.0) as f64 * b.scale;
m = gauss_plane(&m, s, s);
}
if pr.b("hslSecondary/key/invertMask") {
m.data.iter_mut().for_each(|v| *v = 1.0 - *v);
}
m
});
let style = pr.e("hslSecondary/key/maskStyle");
map_colour(&mut b, |i, c| {
let mut c = c;
if let Some(m) = &mask {
let k = m.data[i];
if show_mask {
return match style {
0 => {
let g = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) * 0.2 + 0.4;
[lerp(g, c[0], k), lerp(g, c[1], k), lerp(g, c[2], k)]
}
1 => c.map(|v| v * k),
_ => [k; 3],
};
}
if k > 0.0 {
let mut d = c;
if s_temp != 0.0 || s_tint != 0.0 {
d = [d[0] * (1.0 + 0.25 * s_temp), d[1] * (1.0 - 0.25 * s_tint), d[2] * (1.0 - 0.25 * s_temp)];
}
if s_contrast != 0.0 {
d = d.map(|v| 0.5 + (v - 0.5) * (1.0 + s_contrast));
}
let l = luminance(d[0], d[1], d[2]);
if s_sat != 1.0 {
d = d.map(|v| l + (v - l) * s_sat);
}
for j in 0..3 {
d[j] += 0.5 * s_wheel[j];
}
c = [lerp(c[0], d[0], k), lerp(c[1], d[1], k), lerp(c[2], d[2], k)];
}
}
// Vignette.
if vig != 0.0 {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
@ -366,7 +680,8 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
c.map(|v| v.max(0.0))
});
if sharpen != 0.0 {
let sec_sharpen = if show_mask { 0.0 } else { s_sharpen };
if sharpen != 0.0 || (sec_sharpen != 0.0 && mask.is_some()) {
let luma = Plane::luma(&b.img);
let blur = gauss_plane(&luma, 1.0 * b.scale, 1.0 * b.scale);
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
@ -374,7 +689,8 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
if a <= 0.0 {
return;
}
let d = (luma.data[i] - blur.data[i]) * sharpen * 2.0;
let amount = sharpen + sec_sharpen * mask.as_ref().map(|m| m.data[i]).unwrap_or(0.0);
let d = (luma.data[i] - blur.data[i]) * amount * 2.0;
*px = premul(c.map(|v| (v + d).max(0.0)), a);
});
}
@ -384,7 +700,10 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
pub fn specs() -> Vec<EffectSpec> {
let grey = || col(0.5, 0.5, 0.5);
let mut lum = vec![
p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None"])),
p("highDynamicRange", "High Dynamic Range", Value::Bool(false), ParamUi::Checkbox),
p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None", "Custom"])),
// A .cube file path (or the file's text) for Input LUT = Custom.
p("basicCorrection/inputLutFile", "Input LUT File", Value::Str(String::new()), ParamUi::Text),
p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), bipolar()),
p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), bipolar()),
p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
@ -393,8 +712,11 @@ pub fn specs() -> Vec<EffectSpec> {
p("basicCorrection/tone/shadows", "Shadows", num(0.0), bipolar()),
p("basicCorrection/tone/whites", "Whites", num(0.0), bipolar()),
p("basicCorrection/tone/blacks", "Blacks", num(0.0), bipolar()),
p("basicCorrection/tone/hdrWhite", "HDR White", num(100.0), slider(100.0, 10000.0, 100.0, 4000.0, 0)),
p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), bipolar()),
p("basicCorrection/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("creative/look", "Look", Value::Enum(0), popup(&["None"])),
p("creative/look", "Look", Value::Enum(0), popup(&LUMETRI_LOOKS)),
p("creative/lookFile", "Look File", Value::Str(String::new()), ParamUi::Text),
p("creative/lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), pct()),
p("creative/adjustments/sharpen", "Sharpen", num(0.0), bipolar()),
@ -421,10 +743,50 @@ pub fn specs() -> Vec<EffectSpec> {
for (id, name) in CURVES {
lum.push(p(id, name, num(0.0), bipolar()));
}
// Curve graphs: control points "x,y x,y …" in 0..1 (like Curves). RGB curves default to
// the identity diagonal; hue/saturation curves are flat at y = 0.5 (no change) and empty
// means flat.
let pts = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Hidden);
lum.extend([
pts("curves/rgbCurves/master", "RGB Curve", "0,0 1,1"),
pts("curves/rgbCurves/red", "Red Curve", "0,0 1,1"),
pts("curves/rgbCurves/green", "Green Curve", "0,0 1,1"),
pts("curves/rgbCurves/blue", "Blue Curve", "0,0 1,1"),
pts("curves/hueSaturationCurves/hueVsSat", "Hue vs Sat", ""),
pts("curves/hueSaturationCurves/hueVsHue", "Hue vs Hue", ""),
pts("curves/hueSaturationCurves/hueVsLuma", "Hue vs Luma", ""),
pts("curves/hueSaturationCurves/lumaVsSat", "Luma vs Sat", ""),
pts("curves/hueSaturationCurves/satVsSat", "Sat vs Sat", ""),
]);
lum.extend([
p("colorWheels/shadowsWheel", "Shadows", grey(), ParamUi::Color),
p("colorWheels/midtonesWheel", "Midtones", grey(), ParamUi::Color),
p("colorWheels/highlightsWheel", "Highlights", grey(), ParamUi::Color),
// HSL Secondary: key (hue centre / range / softness in degrees, saturation and
// lightness bands in %), refine, correction.
p("hslSecondary/key/hueEnabled", "H", Value::Bool(true), ParamUi::Checkbox),
p("hslSecondary/key/hueCenter", "Hue Center", num(0.0), ParamUi::Angle),
p("hslSecondary/key/hueRange", "Hue Range", num(180.0), slider(0.0, 180.0, 0.0, 180.0, 1)),
p("hslSecondary/key/hueSoftness", "Hue Softness", num(0.0), slider(0.0, 180.0, 0.0, 90.0, 1)),
p("hslSecondary/key/saturationEnabled", "S", Value::Bool(true), ParamUi::Checkbox),
p("hslSecondary/key/saturationLow", "Saturation Low", num(0.0), pct()),
p("hslSecondary/key/saturationHigh", "Saturation High", num(100.0), pct()),
p("hslSecondary/key/saturationSoftness", "Saturation Softness", num(0.0), pct()),
p("hslSecondary/key/lightnessEnabled", "L", Value::Bool(true), ParamUi::Checkbox),
p("hslSecondary/key/lightnessLow", "Lightness Low", num(0.0), pct()),
p("hslSecondary/key/lightnessHigh", "Lightness High", num(100.0), pct()),
p("hslSecondary/key/lightnessSoftness", "Lightness Softness", num(0.0), pct()),
p("hslSecondary/key/showMask", "Show Mask", Value::Bool(false), ParamUi::Checkbox),
p("hslSecondary/key/maskStyle", "Mask Style", Value::Enum(0), popup(&HSL_MASK_STYLES)),
p("hslSecondary/key/invertMask", "Invert Mask", Value::Bool(false), ParamUi::Checkbox),
p("hslSecondary/refine/denoise", "Denoise", num(0.0), pct()),
p("hslSecondary/refine/blur", "Blur", num(0.0), pct()),
p("hslSecondary/correction/correctionWheel", "Correction", grey(), ParamUi::Color),
p("hslSecondary/correction/temperature", "Temperature", num(0.0), bipolar()),
p("hslSecondary/correction/tint", "Tint", num(0.0), bipolar()),
p("hslSecondary/correction/contrast", "Contrast", num(0.0), bipolar()),
p("hslSecondary/correction/sharpen", "Sharpen", num(0.0), bipolar()),
p("hslSecondary/correction/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("vignette/vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
p("vignette/vignetteMidpoint", "Midpoint", num(50.0), pct()),
p("vignette/vignetteRoundness", "Roundness", num(0.0), bipolar()),
@ -580,4 +942,133 @@ mod tests {
assert!(c.get(0, 0)[0] > 0.4);
assert_eq!(c.data, run("ec.color.lumetri", &[("curves/curveMasterMidtones", num(50.0))], g).data);
}
fn s(v: &str) -> Value {
Value::Str(v.into())
}
/// Red, green and grey swatches side by side.
fn swatches() -> Image {
let mut img = Image::new(3, 1);
img.set(0, 0, [0.8, 0.1, 0.1, 1.0]);
img.set(1, 0, [0.1, 0.8, 0.1, 1.0]);
img.set(2, 0, [0.5, 0.5, 0.5, 1.0]);
img
}
#[test]
fn lumetri_rgb_curves_per_channel() {
let g = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]);
let m = run("ec.color.lumetri", &[("curves/rgbCurves/master", s("0,0 0.5,0.75 1,1"))], g.clone()).get(0, 0);
assert!((m[0] - 0.75).abs() < 1e-3 && (m[2] - 0.75).abs() < 1e-3);
let r = run("ec.color.lumetri", &[("curves/rgbCurves/red", s("0,0 0.5,0.25 1,1"))], g).get(0, 0);
assert!((r[0] - 0.25).abs() < 1e-3 && (r[1] - 0.5).abs() < 1e-4);
}
#[test]
fn lumetri_hue_saturation_curves_target_hues() {
let img = swatches();
// Hue vs Sat: desaturate reds only (a dip to 0 at hue 0, neutral from 0.2 to 0.8).
let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsSat", s("0,0 0.2,0.5 0.8,0.5"))], img.clone());
let red = out.get(0, 0);
assert!((red[0] - red[1]).abs() < 0.02, "red desaturated: {red:?}");
assert!(close_px(out.get(1, 0), img.get(1, 0), 1e-3), "green untouched");
assert!(close_px(out.get(2, 0), img.get(2, 0), 1e-4), "grey untouched");
// Hue vs Hue: shift reds by +1/6 turn (y = 0.5 + 60/360) towards yellow.
let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsHue", s("0,0.6667 0.2,0.5 0.8,0.5"))], img.clone());
let p = out.get(0, 0);
assert!(p[1] > 0.6 && p[0] > 0.6 && p[2] < 0.2, "red became yellow: {p:?}");
// Hue vs Luma brightens greens only.
let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsLuma", s("0.333,0.7 0.1,0.5 0.6,0.5"))], img.clone());
assert!(out.get(1, 0)[1] > 0.85 && close_px(out.get(0, 0), img.get(0, 0), 1e-3));
// Luma vs Sat / Sat vs Sat: y = 0 everywhere removes all saturation.
for id in ["lumaVsSat", "satVsSat"] {
let out = run("ec.color.lumetri", &[(&format!("curves/hueSaturationCurves/{id}"), s("0,0 1,0"))], img.clone());
let p = out.get(0, 0);
assert!((p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3, "{id}: {p:?}");
}
}
fn close_px(a: [f32; 4], b: [f32; 4], eps: f32) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
}
#[test]
fn lumetri_hsl_secondary_keys_and_corrects() {
let img = swatches();
let key_red = [("hslSecondary/key/hueCenter", num(0.0)), ("hslSecondary/key/hueRange", num(20.0)), ("hslSecondary/key/hueSoftness", num(10.0))];
let mut vals: Vec<(&str, Value)> = key_red.to_vec();
vals.push(("hslSecondary/correction/saturation", num(0.0)));
let out = run("ec.color.lumetri", &vals, img.clone());
let r = out.get(0, 0);
assert!((r[0] - r[1]).abs() < 1e-3, "red desaturated through the key: {r:?}");
assert!(close_px(out.get(1, 0), img.get(1, 0), 1e-5) && close_px(out.get(2, 0), img.get(2, 0), 1e-5));
// Show Mask (White/Black) shows the key; Invert Mask flips it.
let mut vals: Vec<(&str, Value)> = key_red.to_vec();
vals.extend([("hslSecondary/key/showMask", Value::Bool(true)), ("hslSecondary/key/maskStyle", Value::Enum(2))]);
let m = run("ec.color.lumetri", &vals, img.clone());
assert_eq!([m.get(0, 0)[0], m.get(1, 0)[0], m.get(2, 0)[0]], [1.0, 0.0, 0.0]);
vals.push(("hslSecondary/key/invertMask", Value::Bool(true)));
let m = run("ec.color.lumetri", &vals, img.clone());
assert_eq!([m.get(0, 0)[0], m.get(1, 0)[0], m.get(2, 0)[0]], [0.0, 1.0, 1.0]);
// Lightness band keys only the bright grey; Blur softens the matte across pixels.
let vals = [
("hslSecondary/key/lightnessLow", num(40.0)),
("hslSecondary/key/lightnessHigh", num(60.0)),
("hslSecondary/key/showMask", Value::Bool(true)),
("hslSecondary/key/maskStyle", Value::Enum(2)),
];
let m = run("ec.color.lumetri", &vals, img.clone());
assert_eq!(m.get(2, 0)[0], 1.0);
let mut wide = Image::new(32, 1);
for x in 0..32 {
wide.set(x, 0, if x < 16 { [0.8, 0.1, 0.1, 1.0] } else { [0.1, 0.8, 0.1, 1.0] });
}
let mut vals: Vec<(&str, Value)> = key_red.to_vec();
vals.extend([
("hslSecondary/key/showMask", Value::Bool(true)),
("hslSecondary/key/maskStyle", Value::Enum(2)),
("hslSecondary/refine/blur", num(50.0)),
]);
let m = run("ec.color.lumetri", &vals, wide);
let v = m.get(16, 0)[0];
assert!(v > 0.05 && v < 0.95, "blurred matte edge {v}");
}
#[test]
fn lumetri_hdr_range_and_looks() {
// HDR White 200 nits: the tone controls see 1.6 as 0.8, so Highlights act on it.
let bright = Image::filled(2, 2, [1.6, 1.6, 1.6, 1.0]);
let sdr = run("ec.color.lumetri", &[("basicCorrection/tone/highlights", num(-100.0))], bright.clone()).get(0, 0)[0];
let hdr = run(
"ec.color.lumetri",
&[("highDynamicRange", Value::Bool(true)), ("basicCorrection/tone/hdrWhite", num(200.0)), ("basicCorrection/tone/highlights", num(-100.0))],
bright.clone(),
)
.get(0, 0)[0];
assert!(hdr < 1.6 - 0.05 && sdr < 1.6, "{sdr} {hdr}");
let spec = run("ec.color.lumetri", &[("highDynamicRange", Value::Bool(true)), ("basicCorrection/tone/hdrSpecular", num(100.0))], bright).get(0, 0)[0];
assert!((spec - 2.2).abs() < 1e-4, "{spec}");
// Built-in looks change the image at Intensity; 0 % leaves it.
let img = ramp();
let mono = run("ec.color.lumetri", &[("creative/look", Value::Enum(7))], img.clone());
let p = mono.get(9, 3);
assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5);
let none = run("ec.color.lumetri", &[("creative/look", Value::Enum(7)), ("creative/lookIntensity", num(0.0))], img.clone());
assert!(close(&none, &img, 1e-5));
// A Custom look from inline .cube text (swap red and blue).
let mut cube = String::from("LUT_3D_SIZE 2\n");
for bb in 0..2 {
for g in 0..2 {
for r in 0..2 {
cube += &format!("{bb} {g} {r}\n");
}
}
}
let g = Image::filled(1, 1, [0.9, 0.2, 0.1, 1.0]);
let o = run("ec.color.lumetri", &[("creative/look", Value::Enum(1)), ("creative/lookFile", Value::Str(cube.clone()))], g.clone()).get(0, 0);
assert!((o[0] - 0.1).abs() < 1e-3 && (o[2] - 0.9).abs() < 1e-3, "{o:?}");
let o = run("ec.color.lumetri", &[("basicCorrection/inputLut", Value::Enum(1)), ("basicCorrection/inputLutFile", Value::Str(cube))], g).get(0, 0);
assert!((o[0] - 0.1).abs() < 1e-3, "{o:?}");
}
}

View File

@ -70,6 +70,85 @@ fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Hue/Saturation's colour ranges (Channel Control entries after Master): id prefix, display
/// name and centre hue in degrees.
pub const HUESAT_RANGES: [(&str, &str, f64); 6] = [
("reds", "Reds", 0.0),
("yellows", "Yellows", 60.0),
("greens", "Greens", 120.0),
("cyans", "Cyans", 180.0),
("blues", "Blues", 240.0),
("magentas", "Magentas", 300.0),
];
/// Hue/Saturation's Channel Control popup.
pub const HUESAT_CHANNELS: [&str; 7] = ["Master", "Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas"];
/// One colour range of Hue/Saturation: the hues it covers fully (`start..end`, degrees, may
/// wrap), the fall-off widths either side, and its hue / saturation / lightness adjustments.
#[derive(Clone, Copy, Debug)]
struct HueRange {
start: f32,
end: f32,
fall_start: f32,
fall_end: f32,
hue: f32,
sat: f32,
light: f32,
}
impl HueRange {
/// Membership of hue `h` (degrees): 1 inside the range, ramping to 0 across the fall-offs.
fn weight(&self, h: f32) -> f32 {
let width = (self.end - self.start).rem_euclid(360.0);
let d = (h - self.start).rem_euclid(360.0);
if d <= width {
return 1.0;
}
let after = d - width;
let before = 360.0 - d;
let wa = if self.fall_end > 0.0 { 1.0 - after / self.fall_end } else { 0.0 };
let wb = if self.fall_start > 0.0 { 1.0 - before / self.fall_start } else { 0.0 };
wa.max(wb).clamp(0.0, 1.0)
}
}
/// The colour ranges with non-zero adjustments.
fn huesat_ranges(ctx: &EffectCtx) -> Vec<HueRange> {
let f = |id: String, d: f64| ctx.params.get(&id).map(Value::as_f64).unwrap_or(d) as f32;
HUESAT_RANGES
.iter()
.map(|(r, _, c)| HueRange {
start: f(format!("{r}RangeStart"), c - 15.0),
end: f(format!("{r}RangeEnd"), c + 15.0),
fall_start: f(format!("{r}StartFalloff"), 30.0).max(0.0),
fall_end: f(format!("{r}EndFalloff"), 30.0).max(0.0),
hue: f(format!("{r}Hue"), 0.0) / 360.0,
sat: f(format!("{r}Saturation"), 0.0) / 100.0,
light: f(format!("{r}Lightness"), 0.0) / 100.0,
})
.filter(|r| r.hue != 0.0 || r.sat != 0.0 || r.light != 0.0)
.collect()
}
/// Whether no colour range of Hue/Saturation adjusts anything (the GPU kernel does Master and
/// Colorize only).
pub fn huesat_ranges_identity(ctx: &EffectCtx) -> bool {
huesat_ranges(ctx).is_empty()
}
fn adjust_sat(s: f32, sat: f32) -> f32 {
if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) }
}
fn adjust_light(l: f32, light: f32) -> f32 {
if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) }
}
/// Hue/Saturation. Master adjusts every pixel; each colour range (Reds … Magentas, chosen in
/// Channel Control) adjusts pixels whose original hue lies in its range, fading across the
/// fall-offs (greys, having no hue, belong to no range). Colorize replaces hue and
/// saturation.
fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
let hue = ctx.params.f("hue") as f32 / 360.0;
let sat = ctx.params.f("saturation") as f32 / 100.0;
@ -78,14 +157,24 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ch = ctx.params.f("colorizeHue") as f32 / 360.0;
let cs = ctx.params.f("colorizeSaturation") as f32 / 100.0;
let cl = ctx.params.f("colorizeLightness") as f32 / 100.0;
let ranges = huesat_ranges(ctx);
b.img.map_straight(|c| {
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let (h, s, l) = if colorize {
(ch, cs, (l + cl * if cl > 0.0 { 1.0 - l } else { l }).clamp(0.0, 1.0))
} else {
let s2 = if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) };
let l2 = if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) };
((h + hue).rem_euclid(1.0), s2.clamp(0.0, 1.0), l2)
let mut s2 = adjust_sat(s, sat);
let mut l2 = adjust_light(l, light);
let mut h2 = h + hue;
for r in &ranges {
let w = r.weight(h * 360.0) * (s * 20.0).min(1.0);
if w > 0.0 {
h2 += r.hue * w;
s2 = adjust_sat(s2.clamp(0.0, 1.0), r.sat * w);
l2 = adjust_light(l2, r.light * w);
}
}
(h2.rem_euclid(1.0), s2.clamp(0.0, 1.0), l2)
};
let (r, g, bl) = hsl_to_rgb(h, s, l);
[r, g, bl]
@ -107,29 +196,87 @@ pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) {
(on("clipToOutputBlack"), on("clipToOutputWhite"))
}
/// Levels' Channel popup.
pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"];
/// Spec-id prefixes of Levels' per-channel controls (`redInBlack`, …; the RGB controls have
/// plain ids).
pub const LEVELS_CHANNEL_PREFIX: [&str; 4] = ["red", "green", "blue", "alpha"];
/// Levels' five controls (input black, input white, gamma, output black, output white) for
/// Channel popup index `ch` (0 = RGB).
pub fn levels_channel_ids(ch: usize) -> [String; 5] {
if ch == 0 || ch > 4 {
return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from);
}
let pre = LEVELS_CHANNEL_PREFIX[ch - 1];
["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| format!("{pre}{s}"))
}
fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool)) -> f32 {
let mut t = (v - ib) / (iw - ib).max(1e-6);
if clip_b {
t = t.max(0.0);
}
if clip_w {
t = t.min(1.0);
}
let t = t.max(0.0).powf(1.0 / g);
ob + (ow - ob) * t
}
const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0];
/// Levels: the RGB controls map every colour channel, then the Red / Green / Blue controls
/// their own channel and the Alpha controls alpha. All five sets are kept; the Channel popup
/// only picks the set Effect Controls shows.
fn levels(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ib = ctx.params.f("inBlack") as f32;
let iw = ctx.params.f("inWhite") as f32;
let g = ctx.params.f("gamma").max(0.01) as f32;
let ob = ctx.params.f("outBlack") as f32;
let ow = ctx.params.f("outWhite") as f32;
let (clip_b, clip_w) = levels_clip(ctx);
b.img.map_straight(|c| {
c.map(|v| {
let mut t = (v - ib) / (iw - ib).max(1e-6);
if clip_b {
t = t.max(0.0);
let clip = levels_clip(ctx);
b.img.map_straight(|c| c.map(|v| level(v, [ib, iw, g, ob, ow], clip)));
let chans = levels_channel_settings(ctx);
let ident = |l: &([f32; 5], (bool, bool))| l.0 == LEVELS_IDENTITY;
if chans.iter().all(ident) {
return b;
}
use rayon::prelude::*;
b.img.data.par_iter_mut().for_each(|px| {
let (c, a) = crate::util::unpremul(*px);
let mut o = c;
for i in 0..3 {
if !ident(&chans[i]) {
o[i] = level(c[i], chans[i].0, chans[i].1);
}
if clip_w {
t = t.min(1.0);
}
let t = t.max(0.0).powf(1.0 / g);
ob + (ow - ob) * t
})
}
let na = if ident(&chans[3]) { a } else { level(a, chans[3].0, chans[3].1).clamp(0.0, 1.0) };
*px = crate::util::premul(o, na);
});
b
}
/// Levels' Red, Green, Blue and Alpha settings and clip switches (identity for projects saved
/// before the Channel popup).
pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4] {
std::array::from_fn(|i| {
let ids = levels_channel_ids(i + 1);
let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k]));
let pre = LEVELS_CHANNEL_PREFIX[i];
let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0);
let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) };
([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip)
})
}
/// Whether Levels' per-channel controls are all at their defaults (the GPU kernel handles
/// only the RGB controls).
pub fn levels_channels_identity(ctx: &EffectCtx) -> bool {
levels_channel_settings(ctx).iter().all(|l| l.0 == LEVELS_IDENTITY)
}
/// Exposure's per-channel (gain, offset, gamma) for R, G, B: the Master group's values, or the
/// Red / Green / Blue groups' with Channels = Individual Channels.
pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] {
@ -193,10 +340,66 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Whether Fill uses masks (Fill Mask or All Masks): the GPU kernel fills alpha only.
pub fn fill_uses_masks(ctx: &EffectCtx) -> bool {
!ctx.env.masks.is_empty() && (ctx.params.b("allMasks") || ctx.params.f("fillMask") >= 1.0)
}
/// Coverage (0..1, 2×2 supersampled) of the closed polygons `polys` (buffer px) on a `w`×`h`
/// grid: the union of the shapes, each inverted when its mask is.
pub(crate) fn polys_coverage(w: usize, h: usize, polys: &[(Vec<[f64; 2]>, bool)]) -> crate::util::Plane {
use rayon::prelude::*;
let mut out = crate::util::Plane::new(w, h);
out.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, v) in row.iter_mut().enumerate() {
let mut hit = 0.0;
for (sx, sy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
let (px, py) = (x as f64 + sx, y as f64 + sy);
if polys.iter().any(|(p, inv)| crate::util::point_in_poly(p, px, py) != *inv) {
hit += 0.25;
}
}
*v = hit;
}
});
out
}
/// Fill: paints the layer's pixels with Color. With Fill Mask (or All Masks) only the pixels
/// inside that mask (or any mask) are painted, with the mask edge feathered horizontally /
/// vertically; Invert paints outside instead. Without a mask, Invert fills the transparent
/// areas.
fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = ctx.params.color("color");
let invert = ctx.params.b("invert");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
if fill_uses_masks(ctx) {
let all = ctx.params.b("allMasks");
let idx = ctx.params.f("fillMask").round() as usize;
let polys: Vec<(Vec<[f64; 2]>, bool)> = ctx
.env
.masks
.iter()
.enumerate()
.filter(|(i, m)| (all || i + 1 == idx) && m.points.len() >= 3)
.map(|(_, m)| (m.points.iter().map(|q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.inverted))
.collect();
let (w, h) = (b.img.width as usize, b.img.height as usize);
let mut cov = polys_coverage(w, h, &polys);
let (fh, fv) = (ctx.params.f("horizontalFeather").max(0.0) * b.scale, ctx.params.f("verticalFeather").max(0.0) * b.scale);
if fh > 0.0 || fv > 0.0 {
cov = crate::util::gauss_plane(&cov, fh * 0.5, fv * 0.5);
}
use rayon::prelude::*;
b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &m)| {
let m = if invert { 1.0 - m } else { m } * opacity;
let a = px[3];
for i in 0..3 {
px[i] += (c[i] * a - px[i]) * m;
}
});
return b;
}
b.img.data.iter_mut().for_each(|px| {
let a = if invert { 1.0 - px[3] } else { px[3] };
let f = [c[0] * a, c[1] * a, c[2] * a];
@ -431,32 +634,226 @@ fn colorama_phase(c: [f32; 3], a: f32, mode: u32) -> f32 {
v.clamp(0.0, 1.0)
}
/// Colorama with the built-in hue-cycle output palette: the chosen input attribute, shifted by
/// Phase Shift and repeated Cycle Repetitions times, picks a colour on the cycle (Interpolate
/// Palette off posterizes to the palette's six colours).
/// Colorama's Output Cycle presets (our own palettes; Custom uses the Palette parameter).
pub const COLORAMA_PRESETS: [&str; 9] = ["Hue Cycle", "Ramp Grayscale", "Fire", "Ice", "Ramp Red", "Ramp Green", "Ramp Blue", "Pastel Cycle", "Custom"];
/// Colorama's Modify options: which channels of the pixel take the palette colour.
const COLORAMA_MODIFY: [&str; 12] =
["All", "Red", "Green", "Blue", "Hue", "Lightness", "Saturation", "Value", "Red & Green", "Green & Blue", "Blue & Red", "None"];
/// An output cycle: colour stops around the wheel (position 0..1, straight RGBA), cyclic.
#[derive(Clone, Debug, PartialEq)]
pub struct ColoramaPalette {
pub stops: Vec<(f32, [f32; 4])>,
}
impl ColoramaPalette {
/// Built-in palette `i` of [`COLORAMA_PRESETS`].
pub fn preset(i: u32) -> ColoramaPalette {
let s = |v: &[(f32, [f32; 3])]| ColoramaPalette { stops: v.iter().map(|(p, c)| (*p, [c[0], c[1], c[2], 1.0])).collect() };
match i {
1 => s(&[(0.0, [0.0; 3]), (0.999, [1.0; 3])]),
2 => s(&[(0.0, [0.0, 0.0, 0.0]), (0.3, [0.7, 0.05, 0.0]), (0.6, [1.0, 0.55, 0.0]), (0.85, [1.0, 0.95, 0.4]), (0.999, [1.0, 1.0, 1.0])]),
3 => s(&[(0.0, [0.0, 0.0, 0.1]), (0.4, [0.1, 0.3, 0.8]), (0.75, [0.5, 0.85, 1.0]), (0.999, [1.0, 1.0, 1.0])]),
4 => s(&[(0.0, [0.0; 3]), (0.999, [1.0, 0.0, 0.0])]),
5 => s(&[(0.0, [0.0; 3]), (0.999, [0.0, 1.0, 0.0])]),
6 => s(&[(0.0, [0.0; 3]), (0.999, [0.0, 0.0, 1.0])]),
7 => s(&[(0.0, [1.0, 0.7, 0.7]), (1.0 / 3.0, [0.7, 1.0, 0.7]), (2.0 / 3.0, [0.7, 0.7, 1.0])]),
_ => s(&[
(0.0, [1.0, 0.0, 0.0]),
(1.0 / 6.0, [1.0, 1.0, 0.0]),
(2.0 / 6.0, [0.0, 1.0, 0.0]),
(3.0 / 6.0, [0.0, 1.0, 1.0]),
(4.0 / 6.0, [0.0, 0.0, 1.0]),
(5.0 / 6.0, [1.0, 0.0, 1.0]),
]),
}
}
/// Parse "pos:r,g,b[,a] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops.
pub fn parse(s: &str) -> Option<ColoramaPalette> {
let mut stops: Vec<(f32, [f32; 4])> = s
.split_whitespace()
.filter_map(|t| {
let (pos, c) = t.split_once(':')?;
let v: Vec<f32> = c.split(',').filter_map(|x| x.trim().parse().ok()).collect();
(v.len() >= 3).then(|| (pos.trim().parse::<f32>().ok().map(|p| p.rem_euclid(1.0)), [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)]))
})
.filter_map(|(p, c)| p.map(|p| (p, c)))
.collect();
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
(!stops.is_empty()).then_some(ColoramaPalette { stops })
}
/// The colour at phase `ph` (0..1). Interpolate Palette blends between neighbouring stops
/// (around the wheel); off, each phase takes the stop at or before it.
pub fn eval(&self, ph: f32, interpolate: bool) -> [f32; 4] {
let n = self.stops.len();
let ph = ph.rem_euclid(1.0);
let k = self.stops.iter().rposition(|s| s.0 <= ph).unwrap_or(n - 1);
let (p0, c0) = self.stops[k];
if !interpolate || n == 1 {
return c0;
}
let (p1, c1) = self.stops[(k + 1) % n];
let span = (p1 - p0).rem_euclid(1.0).max(1e-6);
let t = ((ph - p0).rem_euclid(1.0) / span).clamp(0.0, 1.0);
std::array::from_fn(|i| c0[i] + (c1[i] - c0[i]) * t)
}
}
/// How well colour `c` matches `key` (1 = matches) for Pixel Selection's Matching Mode
/// (1 RGB, 2 Hue, 3 Chroma), with Matching Tolerance / Softness (0..1).
fn colorama_match(c: [f32; 3], key: [f32; 3], mode: u32, tol: f32, soft: f32) -> f32 {
let d = match mode {
2 => {
let (h1, s1, _) = rgb_to_hsl(c[0], c[1], c[2]);
let (h2, _, _) = rgb_to_hsl(key[0], key[1], key[2]);
let dh = (h1 - h2).abs();
dh.min(1.0 - dh) * 2.0 + (1.0 - s1.min(1.0)) * 0.5
}
3 => {
// Chroma: the colour without its brightness.
let n = |c: [f32; 3]| {
let s = c[0] + c[1] + c[2];
if s > 1e-5 { [c[0] / s, c[1] / s, c[2] / s] } else { [1.0 / 3.0; 3] }
};
let (a, b) = (n(c), n(key));
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() * 1.5
}
_ => ((c[0] - key[0]).powi(2) + (c[1] - key[1]).powi(2) + (c[2] - key[2]).powi(2)).sqrt() / 3f32.sqrt(),
};
if d <= tol {
1.0
} else if soft > 0.0 {
(1.0 - (d - tol) / soft).clamp(0.0, 1.0)
} else {
0.0
}
}
/// Colorama: a phase taken from the pixel (Get Phase From, optionally combined with an Add
/// Phase layer), shifted and repeated around the Output Cycle, picks a palette colour, which
/// replaces the channels Modify names. Pixel Selection limits it to colours near Matching
/// Color; Masking to where a mask layer allows; Blend With Original mixes the result back.
fn colorama(ctx: &EffectCtx, mut b: Buf) -> Buf {
let shift = ctx.params.f("inputPhase/phaseShift") as f32 / 360.0;
let mode = ctx.params.e("inputPhase/mode");
let cycles = ctx.params.f("outputCycle/cycles").max(0.0) as f32;
let smooth = ctx.params.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool());
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
b.img.data.iter_mut().for_each(|px| {
let pr = ctx.params;
let shift = pr.f("inputPhase/phaseShift") as f32 / 360.0;
let mode = pr.e("inputPhase/mode");
let cycles = pr.f("outputCycle/cycles").max(0.0) as f32;
let smooth = pr.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool());
let preset = pr.e("outputCycle/usePresetPalette");
let palette = if preset as usize == COLORAMA_PRESETS.len() - 1 { ColoramaPalette::parse(pr.s("outputCycle/palette")) } else { None }
.unwrap_or_else(|| ColoramaPalette::preset(preset));
let blend = 1.0 - pr.f("blend") as f32 / 100.0;
// Add Phase.
let add = ctx.layer_param("inputPhase/addPhase", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true));
let add_from = pr.e("inputPhase/addPhaseFrom");
let add_mode = pr.e("inputPhase/addMode");
// Modify.
let modify = pr.e("modify/modify");
let modify_alpha = pr.b("modify/modifyAlpha");
let change_empty = pr.b("modify/changeEmptyPixels");
// Pixel Selection.
let match_mode = pr.e("pixelSelection/matchingMode");
let key = pr.color("pixelSelection/matchingColor");
let tol = (pr.f("pixelSelection/matchingTolerance") / 100.0) as f32;
let soft = (pr.f("pixelSelection/matchingSoftness") / 100.0) as f32;
// Masking.
let mask_mode = pr.e("masking/maskingMode");
let mask = (mask_mode != 0).then(|| ctx.layer_param("masking/maskLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true))).flatten();
let composite = pr.get("masking/compositeOverLayer").is_none_or(|v| v.as_bool());
use rayon::prelude::*;
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let a = px[3];
if a <= 0.0 {
if a <= 0.0 && !change_empty {
return;
}
let c = [px[0] / a, px[1] / a, px[2] / a];
let mut ph = (colorama_phase(c, a, mode) * cycles + shift).rem_euclid(1.0);
if !smooth {
ph = (ph * 6.0).floor() / 6.0;
let c = if a > 0.0 { [px[0] / a, px[1] / a, px[2] / a] } else { [0.0; 3] };
let mut p = colorama_phase(c, a, mode);
if let Some(img) = &add {
let (ac, aa) = crate::util::unpremul(img.data[i]);
let q = colorama_phase(ac, aa, add_from);
p = match add_mode {
1 => (p + q).min(1.0),
2 => (p + q) * 0.5,
3 => p + q - p * q,
_ => (p + q).rem_euclid(1.0),
};
}
let (r, g, bl) = effectcraft_color::hsv_to_rgb(ph, 1.0, 1.0);
let o = mix(c, [r, g, bl], blend);
*px = [o[0] * a, o[1] * a, o[2] * a, a];
let ph = (p * cycles + shift).rem_euclid(1.0);
let pc = palette.eval(ph, smooth);
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let (ph_, ps, pl) = rgb_to_hsl(pc[0], pc[1], pc[2]);
let hl = |h: f32, s: f32, l: f32| {
let (r, g, bb) = hsl_to_rgb(h, s, l);
[r, g, bb]
};
let new: [f32; 3] = match modify {
1 => [pc[0], c[1], c[2]],
2 => [c[0], pc[1], c[2]],
3 => [c[0], c[1], pc[2]],
4 => hl(ph_, s, l),
5 => hl(h, s, pl),
6 => hl(h, ps, l),
7 => {
// Value: scale the colour to the palette colour's maximum.
let (m, pm) = (c[0].max(c[1]).max(c[2]), pc[0].max(pc[1]).max(pc[2]));
if m > 1e-6 { c.map(|v| v * pm / m) } else { [pm; 3] }
}
8 => [pc[0], pc[1], c[2]],
9 => [c[0], pc[1], pc[2]],
10 => [pc[0], c[1], pc[2]],
11 => c,
_ => [pc[0], pc[1], pc[2]],
};
let new_a = if modify_alpha {
pc[3]
} else if a > 0.0 {
a
} else {
1.0
};
// Where the effect applies: Pixel Selection × Masking.
let mut w = if match_mode == 0 { 1.0 } else { colorama_match(c, [key[0], key[1], key[2]], match_mode, tol, soft) };
if let Some(m) = &mask {
let (mc, ma) = crate::util::unpremul(m.data[i]);
let lum = luminance(mc[0], mc[1], mc[2]) * ma;
w *= match mask_mode {
1 => lum,
2 => 1.0 - lum,
3 => ma,
_ => 1.0 - ma,
}
.clamp(0.0, 1.0);
}
let k = w * blend;
let o = mix(c, new, k);
let oa = a + (new_a - a) * k;
// Composite Over Layer off: only the affected pixels remain.
let oa = if composite { oa } else { oa * w };
*px = [o[0] * oa, o[1] * oa, o[2] * oa, oa.clamp(0.0, 1.0)];
});
b
}
/// Levels' Red / Green / Blue / Alpha controls ("Red Input Black", …), in that order.
fn levels_channel_params() -> Vec<crate::ParamSpec> {
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
let names = ["Input Black", "Input White", "Gamma", "Output Black", "Output White"];
let mut v = vec![];
for (i, pre) in LEVELS_CHANNEL_PREFIX.iter().enumerate() {
let label = LEVELS_CHANNELS[i + 1];
let ids = levels_channel_ids(i + 1);
for k in 0..5 {
let ui = if k == 2 { slider(0.1, 10.0, 0.1, 3.0, 2) } else { slider(-1.0, 2.0, 0.0, 1.0, 3) };
v.push(p(leak(ids[k].clone()), leak(format!("{label} {}", names[k])), num(LEVELS_IDENTITY[k] as f64), ui));
}
v.push(p(leak(format!("{pre}ClipToOutputBlack")), leak(format!("{label} Clip To Output Black")), Value::Enum(2), popup(&LEVELS_CLIP)));
v.push(p(leak(format!("{pre}ClipToOutputWhite")), leak(format!("{label} Clip To Output White")), Value::Enum(2), popup(&LEVELS_CLIP)));
}
v
}
pub fn specs() -> Vec<EffectSpec> {
let pct = || slider(-100.0, 100.0, -100.0, 100.0, 1);
let mut gpg = vec![p("blackStretch", "Black Stretch", num(0.0), slider(0.0, 4.0, 0.0, 4.0, 2))];
@ -501,21 +898,39 @@ pub fn specs() -> Vec<EffectSpec> {
spec(
"ec.color.huesaturation",
"Hue/Saturation",
vec![
p("hue", "Master Hue", num(0.0), ParamUi::Angle),
p("saturation", "Master Saturation", num(0.0), pct()),
p("lightness", "Master Lightness", num(0.0), pct()),
p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox),
p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle),
p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("colorizeLightness", "Colorize Lightness", num(0.0), pct()),
],
{
let mut v = vec![
p("channelControl", "Channel Control", Value::Enum(0), popup(&HUESAT_CHANNELS)),
p("hue", "Master Hue", num(0.0), ParamUi::Angle),
p("saturation", "Master Saturation", num(0.0), pct()),
p("lightness", "Master Lightness", num(0.0), pct()),
];
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
for (r, name, c) in HUESAT_RANGES {
let single = name.trim_end_matches('s');
v.push(p(leak(format!("{r}Hue")), leak(format!("{single} Hue")), num(0.0), ParamUi::Angle));
v.push(p(leak(format!("{r}Saturation")), leak(format!("{single} Saturation")), num(0.0), pct()));
v.push(p(leak(format!("{r}Lightness")), leak(format!("{single} Lightness")), num(0.0), pct()));
v.push(p(leak(format!("{r}RangeStart")), leak(format!("{single} Range Start")), num(c - 15.0), slider(-360.0, 720.0, -180.0, 360.0, 0)));
v.push(p(leak(format!("{r}RangeEnd")), leak(format!("{single} Range End")), num(c + 15.0), slider(-360.0, 720.0, -180.0, 360.0, 0)));
v.push(p(leak(format!("{r}StartFalloff")), leak(format!("{single} Start Falloff")), num(30.0), slider(0.0, 180.0, 0.0, 90.0, 0)));
v.push(p(leak(format!("{r}EndFalloff")), leak(format!("{single} End Falloff")), num(30.0), slider(0.0, 180.0, 0.0, 90.0, 0)));
}
v.extend([
p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox),
p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle),
p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("colorizeLightness", "Colorize Lightness", num(0.0), pct()),
]);
v
},
hue_saturation,
),
spec(
"ec.color.levels",
"Levels",
vec![
p("channel", "Channel", Value::Enum(0), popup(&LEVELS_CHANNELS)),
p("inBlack", "Input Black", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("inWhite", "Input White", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("gamma", "Gamma", num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)),
@ -525,7 +940,10 @@ pub fn specs() -> Vec<EffectSpec> {
p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&LEVELS_CLIP)),
// Projects saved before the two popups: "Don't Clip".
p("noClip", "Don't Clip", Value::Bool(false), ParamUi::Hidden),
],
]
.into_iter()
.chain(levels_channel_params())
.collect(),
levels,
),
spec(
@ -569,8 +987,12 @@ pub fn specs() -> Vec<EffectSpec> {
name: "Fill",
category: "Generate",
params: vec![
p("fillMask", "Fill Mask", num(0.0), ParamUi::Mask),
p("allMasks", "All Masks", Value::Bool(false), ParamUi::Checkbox),
p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
p("horizontalFeather", "Horizontal Feather", num(0.0), slider(0.0, 5000.0, 0.0, 100.0, 1)),
p("verticalFeather", "Vertical Feather", num(0.0), slider(0.0, 5000.0, 0.0, 100.0, 1)),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
render: fill,
@ -673,9 +1095,30 @@ pub fn specs() -> Vec<EffectSpec> {
"Colorama",
vec![
p("inputPhase/mode", "Get Phase From", Value::Enum(0), popup(&COLORAMA_PHASE)),
p("inputPhase/addPhase", "Add Phase", Value::Layer(None), ParamUi::Layer),
p("inputPhase/addPhaseFrom", "Add Phase From", Value::Enum(0), popup(&COLORAMA_PHASE)),
p("inputPhase/addMode", "Add Mode", Value::Enum(0), popup(&["Wrap", "Clamp", "Average", "Screen"])),
p("inputPhase/phaseShift", "Phase Shift", num(0.0), ParamUi::Angle),
p("outputCycle/usePresetPalette", "Use Preset Palette", Value::Enum(0), popup(&COLORAMA_PRESETS)),
// Use Preset Palette = Custom: "position:r,g,b[,a] …" around the wheel.
p(
"outputCycle/palette",
"Output Cycle",
Value::Str("0:1,0,0 0.1667:1,1,0 0.3333:0,1,0 0.5:0,1,1 0.6667:0,0,1 0.8333:1,0,1".into()),
ParamUi::Text,
),
p("outputCycle/cycles", "Cycle Repetitions", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 1)),
p("outputCycle/interpolatePalette", "Interpolate Palette", Value::Bool(true), ParamUi::Checkbox),
p("modify/modify", "Modify", Value::Enum(0), popup(&COLORAMA_MODIFY)),
p("modify/modifyAlpha", "Modify Alpha", Value::Bool(false), ParamUi::Checkbox),
p("modify/changeEmptyPixels", "Change Empty Pixels", Value::Bool(false), ParamUi::Checkbox),
p("pixelSelection/matchingColor", "Matching Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("pixelSelection/matchingTolerance", "Matching Tolerance", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("pixelSelection/matchingSoftness", "Matching Softness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("pixelSelection/matchingMode", "Matching Mode", Value::Enum(0), popup(&["Off", "RGB", "Hue", "Chroma"])),
p("masking/maskLayer", "Mask Layer", Value::Layer(None), ParamUi::Layer),
p("masking/maskingMode", "Masking Mode", Value::Enum(0), popup(&["Off", "Luminance", "Inverted Luminance", "Alpha", "Inverted Alpha"])),
p("masking/compositeOverLayer", "Composite Over Layer", Value::Bool(true), ParamUi::Checkbox),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
colorama,
@ -837,4 +1280,119 @@ mod tests {
assert!(px("ec.color.gammapedestalgain", &[("blackStretch", num(4.0))], dark)[0] > 0.15);
assert!(close(px("ec.color.gammapedestalgain", &[], dark), dark, 1e-6));
}
#[test]
fn levels_channel_controls_act_on_their_channel() {
let g = [0.5, 0.5, 0.5, 1.0];
assert!(close(px("ec.color.levels", &[("channel", Value::Enum(1))], g), g, 1e-6), "the popup alone changes nothing");
let r = px("ec.color.levels", &[("redOutWhite", num(0.5))], g);
assert!(close(r, [0.25, 0.5, 0.5, 1.0], 1e-5), "{r:?}");
let b = px("ec.color.levels", &[("blueGamma", num(2.0)), ("inWhite", num(0.5))], [0.25, 0.25, 0.25, 1.0]);
assert!(close(b, [0.5, 0.5, 0.5f32.sqrt(), 1.0], 1e-5), "RGB first, then blue: {b:?}");
let a = px("ec.color.levels", &[("alphaOutWhite", num(0.5))], [0.5, 0.5, 0.5, 1.0]);
assert!(close(a, [0.25, 0.25, 0.25, 0.5], 1e-5), "{a:?}");
// Per-channel clipping follows its own popups.
let hot = [0.9, 0.9, 0.9, 1.0];
let c = px("ec.color.levels", &[("greenInWhite", num(0.5))], hot)[1];
let nc = px("ec.color.levels", &[("greenInWhite", num(0.5)), ("greenClipToOutputWhite", Value::Enum(0))], hot)[1];
assert!((c - 1.0).abs() < 1e-6 && (nc - 1.8).abs() < 1e-5, "{c} {nc}");
}
#[test]
fn hue_saturation_ranges_adjust_their_hues_only() {
let red = [0.8, 0.1, 0.1, 1.0];
let blue = [0.1, 0.1, 0.8, 1.0];
let grey = [0.5, 0.5, 0.5, 1.0];
let v = [("redsSaturation", num(-100.0))];
let r = px("ec.color.huesaturation", &v, red);
assert!((r[0] - r[1]).abs() < 1e-4, "reds desaturated: {r:?}");
assert!(close(px("ec.color.huesaturation", &v, blue), blue, 1e-5));
assert!(close(px("ec.color.huesaturation", &v, grey), grey, 1e-5));
// Blues hue +120°: blue becomes red.
let b = px("ec.color.huesaturation", &[("bluesHue", num(120.0))], blue);
assert!(b[0] > 0.7 && b[2] < 0.2, "{b:?}");
// Fall-off: a hue 15° past the range end (Reds: −15..15, fall-off 30) gets half.
let orange_ish = {
let (r, g, b) = hsl_to_rgb(30.0 / 360.0, 0.8, 0.5);
[r, g, b, 1.0]
};
let o = px("ec.color.huesaturation", &[("redsLightness", num(100.0))], orange_ish);
let (_, _, l) = rgb_to_hsl(o[0], o[1], o[2]);
assert!((l - 0.75).abs() < 0.01, "half weight: {l}");
// Moving the range changes which hues it takes.
let o2 = px("ec.color.huesaturation", &[("redsLightness", num(100.0)), ("redsRangeEnd", num(40.0))], orange_ish);
assert!(rgb_to_hsl(o2[0], o2[1], o2[2]).2 > 0.99);
let params = crate::Params { values: [("redsHue".to_string(), num(10.0))].into_iter().collect() };
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() };
assert!(!huesat_ranges_identity(&ctx));
}
#[test]
fn fill_mask_all_masks_feather_and_invert() {
let sq = |x0: f64, x1: f64| crate::MaskShape {
name: String::new(),
points: vec![[x0, 0.0], [x1, 0.0], [x1, 10.0], [x0, 10.0]],
closed: true,
inverted: false,
};
let masks = [sq(0.0, 5.0), sq(15.0, 20.0)];
let env = EffectEnv { masks: &masks, ..Default::default() };
let grey = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]);
let run = |vals: &[(&str, Value)]| run_fx("ec.generate.fill", vals, grey.clone(), 0.0, env).img;
let one = run(&[("fillMask", num(1.0))]);
assert_eq!(one.get(2, 5), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(one.get(10, 5), [0.5, 0.5, 0.5, 1.0]);
assert_eq!(one.get(17, 5), [0.5, 0.5, 0.5, 1.0]);
let all = run(&[("allMasks", Value::Bool(true))]);
assert_eq!(all.get(17, 5), [1.0, 0.0, 0.0, 1.0]);
let inv = run(&[("fillMask", num(1.0)), ("invert", Value::Bool(true))]);
assert_eq!(inv.get(2, 5), [0.5, 0.5, 0.5, 1.0]);
assert_eq!(inv.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
let soft = run(&[("fillMask", num(1.0)), ("horizontalFeather", num(6.0))]);
let v = soft.get(6, 5)[1];
assert!(v > 0.05 && v < 0.5, "feathered edge {v}");
// Without masks, Fill fills the layer as before.
let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img;
assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn colorama_palettes_modify_selection_masking_and_add_phase() {
let grey = [0.5, 0.5, 0.5, 1.0];
let red = [1.0, 0.0, 0.0, 1.0];
// Ramp Grayscale: intensity 0.5 maps to mid grey.
let g = px("ec.color.colorama", &[("outputCycle/usePresetPalette", Value::Enum(1))], grey);
assert!((g[0] - 0.5).abs() < 0.01 && (g[0] - g[2]).abs() < 1e-5, "{g:?}");
// Custom palette: two stops, black at 0 and blue at 0.5.
let custom = [("outputCycle/usePresetPalette", Value::Enum(8)), ("outputCycle/palette", Value::Str("0:0,0,0 0.5:0,0,1".into()))];
let c = px("ec.color.colorama", &custom, grey);
assert!(c[2] > 0.99 && c[0] < 0.01, "{c:?}");
// Modify Lightness keeps the hue of the pixel (red stays red).
let l = px("ec.color.colorama", &[("modify/modify", Value::Enum(5)), ("outputCycle/usePresetPalette", Value::Enum(1))], red);
assert!(l[0] > l[1] + 0.2 && l[1] < 0.01 + l[2] + 1e-4, "{l:?}");
// Pixel Selection: only colours near Matching Color change.
let sel = [
("pixelSelection/matchingMode", Value::Enum(1)),
("pixelSelection/matchingColor", col(1.0, 0.0, 0.0)),
("pixelSelection/matchingTolerance", num(10.0)),
];
assert!(close(px("ec.color.colorama", &sel, grey), grey, 1e-6));
assert!(!close(px("ec.color.colorama", &sel, red), red, 1e-3));
// Composite Over Layer off: unselected pixels vanish.
let mut off = sel.to_vec();
off.push(("masking/compositeOverLayer", Value::Bool(false)));
assert_eq!(px("ec.color.colorama", &off, grey)[3], 0.0);
// Modify Alpha takes the palette alpha.
let alpha = [
("outputCycle/usePresetPalette", Value::Enum(8)),
("outputCycle/palette", Value::Str("0:1,1,1,0.25".into())),
("modify/modifyAlpha", Value::Bool(true)),
];
assert!((px("ec.color.colorama", &alpha, grey)[3] - 0.25).abs() < 1e-5);
// Palette parsing and evaluation.
let p = ColoramaPalette::parse("0.5:0,0,1 0:1,0,0").unwrap();
assert_eq!(p.eval(0.25, true), [0.5, 0.0, 0.5, 1.0]);
assert_eq!(p.eval(0.75, true), [0.5, 0.0, 0.5, 1.0], "wraps around");
assert_eq!(p.eval(0.25, false), [1.0, 0.0, 0.0, 1.0]);
}
}

View File

@ -257,6 +257,32 @@ fn polar(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Transform's shutter: (angle, phase) in degrees and the number of samples, or None when it
/// renders without motion blur. Use Composition's Shutter Angle takes the comp's shutter
/// (only when the comp and layer motion-blur switches are on); otherwise Shutter Angle, centred
/// on the frame.
pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> {
let (angle, phase, n) = if ctx.params.b("useCompositionShutterAngle") {
ctx.env.shutter?
} else {
let a = ctx.params.f("shutterAngle").clamp(0.0, 720.0);
(a, -a * 0.5, 16)
};
(angle > 0.0).then_some((angle, phase, n.clamp(2, 64)))
}
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
let anchor = b.to_px(pr.v2("anchor"));
let pos = b.to_px(pr.v2("position"));
let sh = pr.f("scaleHeight");
let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") };
Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(pr.f("rotation"))
* Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis"))
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1))
}
fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let pos = b.to_px(ctx.params.v2("position"));
@ -268,6 +294,34 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
let rot = ctx.params.f("rotation");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear };
// Motion blur: the transform at Samples instants across the shutter, averaged (the
// transform at other times comes from the host; without one the frame renders sharp).
if let Some((angle, phase, n)) = transform_shutter(ctx)
&& let Some(host) = ctx.env.host
{
let fd = 1.0 / ctx.fps();
let mut acc = Image::new(b.img.width, b.img.height);
let mut count = 0.0f32;
for k in 0..n {
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
let Some(pr) = host.params_at(t) else { continue };
let mut one = Image::new(b.img.width, b.img.height);
let op = pr.f("opacity") as f32 / 100.0;
effectcraft_raster::composite_warp(
&mut one,
&b.img,
&transform_matrix(&pr, &b),
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
);
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
count += 1.0;
}
if count > 0.0 {
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count));
b.img = acc;
return b;
}
}
let m = Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(rot)
* Mat3::skew_deg(skew, skew_axis)
@ -397,6 +451,8 @@ pub fn specs() -> Vec<EffectSpec> {
p("skewAxis", "Skew Axis", num(0.0), ParamUi::Angle),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("useCompositionShutterAngle", "Use Composition's Shutter Angle", Value::Bool(true), ParamUi::Checkbox),
p("shutterAngle", "Shutter Angle", num(0.0), slider(0.0, 720.0, 0.0, 360.0, 1)),
p("sampling", "Sampling", Value::Enum(0), popup(&["Bilinear", "Bicubic"])),
],
transform,
@ -510,4 +566,44 @@ mod tests {
let b = run("ec.distort.transform", &[("rotation", num(17.0)), ("sampling", Value::Enum(1))], im);
assert!(a.img.data.iter().zip(&b.img.data).any(|(p, q)| differs(*p, *q)));
}
#[test]
fn transform_shutter_angle_motion_blur() {
/// Position moves right 200 px per second.
struct Moving;
impl crate::EffectHost for Moving {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn params_at(&self, t: f64) -> Option<Params> {
let s = crate::find("ec.distort.transform").unwrap();
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [40.0, 20.0]))).collect() };
p.values.insert("position".into(), Value::Vec2([20.0 + 200.0 * t, 10.0]));
Some(p)
}
}
let mut sq = Image::new(40, 20);
for y in 5..15 {
for x in 15..25 {
sq.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
let render = |vals: &[(&str, Value)], shutter: Option<(f64, f64, u32)>| {
let env = crate::EffectEnv { host: Some(&Moving), frame_rate: 10.0, shutter, ..Default::default() };
crate::run_fx("ec.distort.transform", vals, sq.clone(), 0.0, env).img
};
let partial = |i: &Image| i.data.iter().filter(|p| p[3] > 0.02 && p[3] < 0.98).count();
// Use Composition Shutter Angle with the comp / layer motion blur off: sharp.
assert_eq!(partial(&render(&[], None)), 0);
// With the comp shutter (180°): smeared along x over 10 px.
let comp = render(&[], Some((180.0, -90.0, 16)));
assert!(partial(&comp) > 50, "{}", partial(&comp));
// Own Shutter Angle.
let own = render(&[("useCompositionShutterAngle", Value::Bool(false)), ("shutterAngle", num(360.0))], None);
assert!(partial(&own) > partial(&comp));
assert_eq!(partial(&render(&[("useCompositionShutterAngle", Value::Bool(false))], None)), 0, "0 deg: sharp");
}
}

View File

@ -62,7 +62,26 @@ const TURBULENT_KINDS: [&str; 9] = [
"Horizontal Displacement",
"Cross Displacement",
];
const TURBULENT_PINNING: [&str; 8] = ["None", "Pin All", "Pin Horizontal", "Pin Vertical", "Pin Left", "Pin Top", "Pin Right", "Pin Bottom"];
/// Turbulent Displace's Pinning options. The Locked variants also keep every displaced
/// sample inside the layer (no edge pixels pulled in from outside) and ease the pinning over a
/// wider band, so the pinned edges and their neighbourhood stay put.
const TURBULENT_PINNING: [&str; 15] = [
"None",
"Pin All",
"Pin Horizontal",
"Pin Vertical",
"Pin Left",
"Pin Top",
"Pin Right",
"Pin Bottom",
"Pin All Locked",
"Pin Horizontal Locked",
"Pin Vertical Locked",
"Pin Left Locked",
"Pin Top Locked",
"Pin Right Locked",
"Pin Bottom Locked",
];
fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amount = ctx.params.f("amount") * b.scale;
@ -80,10 +99,12 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
let evo = ctx.params.f("evolution") / 360.0;
// Evolution Options: Cycle Evolution loops the noise every `cycle` revolutions.
let cycle = (ctx.params.b("evolutionOptions/cycleEvolution")).then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0));
let pin = ctx.params.e("pinning");
let pin_raw = ctx.params.e("pinning");
let locked = pin_raw >= 8;
let pin = if locked { pin_raw - 7 } else { pin_raw };
let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 };
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
let edge = (lw.min(lh) * 0.1).max(1.0);
let edge = (lw.min(lh) * if locked { 0.25 } else { 0.1 }).max(1.0);
let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9));
let n = |u: f64, v: f64, s: u32| {
let s = seed.wrapping_add(s);
@ -128,7 +149,11 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
7 => bt,
_ => 1.0,
};
Some((x + dx * k, y + dy * k))
let (sx, sy) = (x + dx * k, y + dy * k);
if locked {
return Some((sx.clamp(lx + 0.5, lx + lw - 0.5), sy.clamp(ly + 0.5, ly + lh - 0.5)));
}
Some((sx, sy))
});
b
}
@ -223,7 +248,10 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf {
let f = r_ref / half.tan();
// Resize (with Reverse Lens Distortion): grow the layer so the stretched corners stay
// visible, up to 2× / 4× the layer size (Unlimited is capped at 8×).
let resize = ctx.params.e("resize");
// Optimal Pixels (invalidates Resize): scale the result so the whole corrected picture
// fits the layer (Reverse) or fills it with no empty corners (forward).
let optimal = ctx.params.b("optimalPixels");
let resize = if optimal { 0 } else { ctx.params.e("resize") };
if reverse && resize > 0 && !ctx.adjustment {
let c = b.to_px(ctx.params.v2("viewCenter"));
let rc =
@ -234,9 +262,21 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf {
b.pad(grow.min(cap).min(4096.0).ceil() as u32);
}
let c = b.to_px(ctx.params.v2("viewCenter"));
let fit = if optimal {
let rc =
[(lx, ly), (lx + lw, ly), (lx, ly + lh), (lx + lw, ly + lh)].iter().map(|p| ((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt()).fold(1.0, f64::max);
if reverse {
let th = (rc / r_ref * half).min(FRAC_PI_2 - 1e-3);
(f * th.tan() / rc).max(1.0)
} else {
((rc / f).atan() / half * r_ref / rc).min(1.0)
}
} else {
1.0
};
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = (x - c.0, y - c.1);
let r = (dx * dx + dy * dy).sqrt();
let r = (dx * dx + dy * dy).sqrt() * fit;
if r < 1e-9 {
return Some((x, y));
}
@ -249,7 +289,7 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
f * th.tan()
};
let k = rs / r;
let k = rs / r * fit;
Some((c.0 + dx * k, c.1 + dy * k))
});
b
@ -718,6 +758,7 @@ pub fn specs() -> Vec<EffectSpec> {
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),
p("optimalPixels", "Optimal Pixels (Invalidates Resize)", Value::Bool(false), ParamUi::Checkbox),
p("resize", "Resize", Value::Enum(0), popup(&["Off", "Max 2X", "Max 4X", "Unlimited"])),
],
optics_compensation,
@ -1052,6 +1093,15 @@ mod tests {
// Random Seed changes the pattern.
let seeded = run("ec.distort.turbulentdisplace", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], img);
assert!(max_diff(&seeded.img, &pinned.img) > 1e-3);
// Locked pinning: no sample comes from outside the layer (no transparent pixels pulled
// in) and the border still holds.
let opaque = Image::filled(40, 40, [0.5, 0.5, 0.5, 1.0]);
let strong = [("size", num(10.0)), ("amount", num(80.0))];
let left = run("ec.distort.turbulentdisplace", &[strong.as_slice(), &[("pinning", Value::Enum(4))]].concat(), opaque.clone());
let locked = run("ec.distort.turbulentdisplace", &[strong.as_slice(), &[("pinning", Value::Enum(11))]].concat(), opaque.clone());
assert!(left.img.data.iter().any(|p| p[3] < 0.99), "plain pin lets edges pull in");
assert!(locked.img.data.iter().all(|p| p[3] > 0.999), "locked keeps everything inside");
assert_eq!(TURBULENT_PINNING[11], "Pin Left Locked");
}
#[test]
@ -1078,4 +1128,20 @@ mod tests {
let max4 = run("ec.distort.opticscompensation", &set(2), img);
assert!(max4.img.width >= max2.img.width);
}
#[test]
fn optics_optimal_pixels_fills_or_fits_the_layer() {
let img = Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]);
// Forward: without Optimal Pixels the corners sample outside the layer.
let plain = run("ec.distort.opticscompensation", &[("fieldOfView", num(120.0))], img.clone());
assert!(plain.img.get(0, 0)[3] < 0.5);
let opt = run("ec.distort.opticscompensation", &[("fieldOfView", num(120.0)), ("optimalPixels", Value::Bool(true))], img.clone());
assert!(opt.img.get(0, 0)[3] > 0.9 && opt.img.get(31, 23)[3] > 0.9);
// Reverse: everything fits; Resize is ignored.
let rev = [("fieldOfView", num(120.0)), ("reverseLensDistortion", Value::Bool(true)), ("optimalPixels", Value::Bool(true)), ("resize", Value::Enum(2))];
let r = run("ec.distort.opticscompensation", &rev, img.clone());
assert_eq!(r.img.width, 32);
// The edges of the source now sit inside the layer: empty space around them.
assert!(r.img.get(0, 0)[3] < 0.5 && r.img.get(16, 12)[3] > 0.9);
}
}

View File

@ -428,6 +428,42 @@ fn smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---------------------------------------------------------------- Reshape
/// Reshape's correspondence points: "s,d s,d …" (outline fractions, sorted by destination).
fn parse_correspondence(s: &str) -> Vec<(f64, f64)> {
let mut v: Vec<(f64, f64)> = s
.split_whitespace()
.filter_map(|t| {
let (a, b) = t.split_once(',')?;
Some((a.trim().parse::<f64>().ok()?.rem_euclid(1.0), b.trim().parse::<f64>().ok()?.rem_euclid(1.0)))
})
.collect();
v.sort_by(|a, b| a.1.total_cmp(&b.1));
v
}
/// The source outline fraction matching destination fraction `u` (piecewise linear between
/// correspondence pairs; around the loop for closed shapes).
fn map_fraction(pairs: &[(f64, f64)], u: f64, closed: bool) -> f64 {
let n = pairs.len();
if n == 1 {
return (pairs[0].0 + (u - pairs[0].1)).rem_euclid(1.0);
}
if !closed {
// Open paths: clamp beyond the first / last pair (ends map to ends).
let ext: Vec<(f64, f64)> = std::iter::once((0.0, 0.0)).chain(pairs.iter().copied()).chain(std::iter::once((1.0, 1.0))).collect();
let k = ext.iter().rposition(|p| p.1 <= u).unwrap_or(0).min(ext.len() - 2);
let (a, b) = (ext[k], ext[k + 1]);
let t = if b.1 > a.1 { (u - a.1) / (b.1 - a.1) } else { 0.0 };
return (a.0 + (b.0 - a.0) * t).clamp(0.0, 1.0);
}
let k = pairs.iter().rposition(|p| p.1 <= u).unwrap_or(n - 1);
let (a, b) = (pairs[k], pairs[(k + 1) % n]);
let du = (b.1 - a.1).rem_euclid(1.0).max(1e-9);
let ds = (b.0 - a.0).rem_euclid(1.0);
let t = (u - a.1).rem_euclid(1.0) / du;
(a.0 + ds * t).rem_euclid(1.0)
}
fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (Some((sm, sc)), Some((dm, dc))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "destinationMask")) else { return b };
let pct = ctx.params.f("percent") / 100.0;
@ -450,7 +486,25 @@ fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf {
} else {
0
};
let disp: Vec<[f64; 2]> = (0..N).map(|i| [s[(i + rot) % N][0] - d[i][0], s[(i + rot) % N][1] - d[i][1]]).collect();
// Correspondence points ("source,destination" pairs of outline fractions 0..1) pin which
// point of the source outline goes to which point of the destination outline; in between
// the outlines are matched proportionally by length. Without them the starting point is
// chosen automatically (above).
let pairs = parse_correspondence(ctx.params.s("correspondencePoints"));
let src_at = |i: usize| -> [f64; 2] {
if pairs.is_empty() {
return s[(i + rot) % N];
}
let u = map_fraction(&pairs, i as f64 / N as f64, sc && dc);
let len = crate::util::poly_length(&sm, sc);
crate::util::poly_point_at(&sm, sc, u * len).0
};
let disp: Vec<[f64; 2]> = (0..N)
.map(|i| {
let q = src_at(i);
[q[0] - d[i][0], q[1] - d[i][1]]
})
.collect();
let smooth = ctx.params.e("interpolation") == 2;
b.img = remap(&b.img, false, |x, y| {
if let Some(bd) = &bound
@ -839,6 +893,8 @@ pub fn specs() -> Vec<EffectSpec> {
p("percent", "Percent", num(100.0), pct()),
p("elasticity", "Elasticity", Value::Enum(0), popup(&ELASTICITY)),
p("interpolation", "Interpolation Method", Value::Enum(1), popup(&["Discrete", "Linear", "Smooth"])),
// Correspondence points: "source,destination" outline fractions (0..1) per point.
p("correspondencePoints", "Correspondence Points", Value::Str(String::new()), ParamUi::Hidden),
],
reshape,
),
@ -1029,6 +1085,22 @@ mod tests {
assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15));
}
#[test]
fn reshape_correspondence_points_steer_the_match() {
// Matching outlines with a zero pair change nothing; a quarter-turn offset twists.
let im = img(40, 40);
let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))];
let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks);
assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change");
let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks);
assert!(maxd(&twisted.img, &im) > 0.1);
// Fraction mapping.
assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9);
assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9);
assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9);
}
#[test]
fn upscale_grows_about_centre() {
let im = img(20, 10);

View File

@ -435,12 +435,15 @@ fn median(v: &mut [f64]) -> f64 {
/// Measure motion around the current frame (`cur`) from `prev` to `next` (all in the same
/// buffer grid). `per_row`: Warp method (one velocity per scanline band).
fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool) -> Option<Flow> {
/// `detail` (Pixel Motion Detail, 0..1; 0.2 = the standard grid) scales the number of motion
/// vectors the Pixel Motion method measures: more vectors follow finer local motion.
fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool, detail: f64) -> Option<Flow> {
use effectcraft_track::klt::{GrayPyramid, LkOpts, analysis_factor, track};
let factor = analysis_factor(cur.width, cur.height, if detailed { 640 } else { 320 });
let pc = GrayPyramid::from_image(cur, [0.0; 2], factor, 4);
let (w, h) = (pc.width(), pc.height());
let n = if detailed { 24 } else { 12 };
let base = if detailed { 24.0 } else { 12.0 };
let n = if per_row { base as usize } else { (base * (0.5 + detail.clamp(0.0, 1.0) * 2.5)).round().clamp(4.0, 96.0) as usize };
let cell_w = w as f64 / n as f64;
let rows = ((n as f64) * h as f64 / w.max(1) as f64).round().max(2.0) as usize;
let cell_h = h as f64 / rows as f64;
@ -520,7 +523,17 @@ fn rolling_shutter(ctx: &EffectCtx, mut b: Buf) -> Buf {
return b;
}
let per_row = ctx.params.e("advanced/method") == 0;
let Some(flow) = measure(&b.img, prev.as_ref(), next.as_ref(), 1.0, ctx.params.b("advanced/detailedAnalysis"), per_row) else { return b };
let Some(flow) = measure(
&b.img,
prev.as_ref(),
next.as_ref(),
1.0,
ctx.params.b("advanced/detailedAnalysis"),
per_row,
ctx.params.f("advanced/pixelMotionDetail") / 100.0,
) else {
return b;
};
let dir = ctx.params.e("scanDirection");
let src = b.img.clone();
let (w, h) = (b.img.width as f64, b.img.height as f64);
@ -740,6 +753,20 @@ mod tests {
);
assert_eq!(out.img.data, again.img.data);
}
// Pixel Motion Detail changes how many vectors Pixel Motion measures (and still repairs).
let detail = |d: f64| {
run_fx(
"ec.distort.rollingshutterrepair",
&[("rollingShutterRate", num(100.0)), ("advanced/method", Value::Enum(1)), ("advanced/pixelMotionDetail", num(d))],
skewed.clone(),
t,
env,
)
.img
};
let (fine, coarse) = (detail(100.0), detail(0.0));
assert_ne!(fine.data, coarse.data);
assert!(err(&fine) < err(&skewed) * 0.5 && err(&coarse) < err(&skewed) * 0.5);
let none = run_fx("ec.distort.rollingshutterrepair", &[], skewed.clone(), t, EffectEnv::default());
assert_eq!(none.img.data, skewed.data);
}

View File

@ -43,8 +43,9 @@ fn add_light(px: Px, l: [f32; 3], la: f32) -> Px {
// ---------------------------------------------------------------- Cell Pattern
/// Cell Pattern options. The HQ variants share the shaping of their standard counterparts
/// (we always render at full precision).
/// Cell Pattern options. The HQ variants share the shaping of their standard counterparts but
/// search a wider neighbourhood of cells (no artefacts at high Disperse) and anti-alias the
/// cell edges (4×4 samples per pixel).
const CELL_PATTERNS: [&str; 12] = [
"Bubbles",
"Crystals",
@ -75,13 +76,13 @@ pub fn pattern_kind(i: u32) -> u32 {
/// Worley distances for point (u, v) in cell units: (F1, F2, hash of the nearest cell).
/// `tile` repeats the cell layout every (columns, rows) cells.
fn worley(u: f64, v: f64, disperse: f64, evo: f64, seed: u32, tile: Option<(i64, i64)>) -> (f64, f64, f32) {
fn worley_r(u: f64, v: f64, disperse: f64, evo: f64, seed: u32, tile: Option<(i64, i64)>, reach: i64) -> (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 {
for j in -reach..=reach {
for i in -reach..=reach {
let (gx, gy) = (cx + i, cy + j);
let (tx, ty) = match tile {
Some((nx, ny)) => (gx.rem_euclid(nx), gy.rem_euclid(ny)),
@ -135,6 +136,7 @@ fn overflow(v: f32, mode: u32) -> f32 {
fn cell_pattern(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pattern = pattern_kind(ctx.params.e("cellPattern"));
let hq = (6..=10).contains(&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");
@ -152,11 +154,19 @@ fn cell_pattern(ctx: &EffectCtx, mut b: Buf) -> Buf {
.params
.b("tilingOptions/enableTiling")
.then(|| (ctx.params.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64, ctx.params.f("tilingOptions/cellsVertical").round().max(1.0) as i64));
let subs: Vec<f64> = if hq { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] };
let reach = if hq { 2 } else { 1 };
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, tile);
let mut val = cell_value(pattern, f1, f2, id);
let mut val = 0.0;
for sy in &subs {
for sx in &subs {
let u = (x as f64 + sx - off.0) / size;
let v = (y as f64 + sy - off.1) / size;
let (f1, f2, id) = worley_r(u, v, disperse, evo, seed, tile, reach);
val += cell_value(pattern, f1, f2, id);
}
}
let mut val = val / (subs.len() * subs.len()) as f32;
val = overflow((val - 0.5) * contrast + 0.5, ov);
if invert {
val = 1.0 - val;
@ -349,60 +359,275 @@ fn outline_dist(pts: &[(f64, f64)], dx: f64, dy: f64) -> f64 {
best
}
/// Radio Waves' Wave Type options.
const RW_TYPES: [&str; 3] = ["Polygon", "Image Contours", "Mask"];
/// Radio Waves' Stroke Profile options.
const RW_PROFILES: [&str; 7] = ["Square", "Taper Front", "Taper Back", "Taper Both", "Sine", "Sawtooth Out", "Sawtooth In"];
/// Image Contour's Value Channel options.
const RW_CHANNELS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Hue", "Lightness", "Saturation"];
/// Stroke Profile weight across the stroke: `u` from −1 (inner edge) to 1 (outer edge).
fn rw_profile(kind: u32, u: f64) -> f64 {
let u = u.clamp(-1.0, 1.0);
match kind {
1 => (1.0 - u) * 0.5,
2 => (1.0 + u) * 0.5,
3 => 1.0 - u.abs(),
4 => (u * PI * 0.5).cos(),
5 if u < 0.0 => 1.0 + u,
6 if u > 0.0 => 1.0 - u,
_ => 1.0,
}
}
/// Fold a coordinate into `[lo, hi]` by bouncing off the ends (Reflection).
fn reflect(v: f64, lo: f64, hi: f64) -> f64 {
let span = hi - lo;
if span <= 0.0 {
return lo;
}
let t = (v - lo).rem_euclid(2.0 * span);
lo + if t > span { 2.0 * span - t } else { t }
}
/// The source shape of a contour wave: signed distance (buffer px, negative inside) on the
/// buffer grid, and the point of the shape that sits on the Producer Point.
struct Contour {
sdf: Plane,
anchor: (f64, f64),
}
/// Image Contours: the region of the source layer whose Value Channel passes Value Threshold.
fn image_contour(ctx: &EffectCtx, b: &Buf) -> Option<Contour> {
let pr = ctx.params;
let lp = ctx.layer_param("imageContour/sourceLayer", true)?;
let img = crate::util::fit_layer(ctx, b, &lp, false);
let (w, h) = (img.width as usize, img.height as usize);
let ch = pr.e("imageContour/valueChannel");
let mut v = Plane::from_image(&img, |px| {
let (c, a) = unpremul(px);
let (hh, s, l) = effectcraft_color::rgb_to_hsl(c[0], c[1], c[2]);
match ch {
0 => c[0] * a,
1 => c[1] * a,
2 => c[2] * a,
3 => a,
5 => hh,
6 => l * a,
7 => s,
_ => luminance(c[0], c[1], c[2]) * a,
}
});
let blur = pr.f("imageContour/preBlur").max(0.0) * b.scale;
if blur > 0.0 {
v = gauss_plane(&v, blur * 0.5, blur * 0.5);
}
let thr = (pr.f("imageContour/valueThreshold") / 255.0) as f32;
let invert = pr.b("imageContour/invertInput");
let mut inside: Vec<bool> = v.data.iter().map(|&x| (x >= thr) != invert).collect();
// Contour: 0 = every region; n = the n-th largest connected region.
let pick = pr.f("imageContour/contour").round() as usize;
if pick > 0 {
let mut label = vec![0u32; w * h];
let mut sizes = vec![0usize];
for start in 0..w * h {
if !inside[start] || label[start] != 0 {
continue;
}
let id = sizes.len() as u32;
let mut stack = vec![start];
label[start] = id;
let mut n = 0;
while let Some(i) = stack.pop() {
n += 1;
let (x, y) = (i % w, i / w);
let mut nb = |j: usize| {
if inside[j] && label[j] == 0 {
label[j] = id;
stack.push(j);
}
};
if x > 0 {
nb(i - 1);
}
if x + 1 < w {
nb(i + 1);
}
if y > 0 {
nb(i - w);
}
if y + 1 < h {
nb(i + w);
}
}
sizes.push(n);
}
let mut order: Vec<usize> = (1..sizes.len()).collect();
order.sort_by(|a, c| sizes[*c].cmp(&sizes[*a]).then(a.cmp(c)));
let keep = order.get(pick - 1).copied().unwrap_or(usize::MAX) as u32;
inside.iter_mut().zip(&label).for_each(|(v, l)| *v = *v && *l == keep);
}
if !inside.iter().any(|v| *v) {
return None;
}
let mut sdf = crate::util::signed_distance(&inside, w, h);
// Tolerance smooths the traced outline.
let tol = pr.f("imageContour/tolerance").max(0.0) * b.scale;
if tol > 0.0 {
sdf = gauss_plane(&sdf, tol * 0.5, tol * 0.5);
}
Some(Contour { sdf, anchor: b.to_px(pr.v2("imageContour/sourceCenter")) })
}
/// Mask: the chosen mask of this layer.
fn mask_contour(ctx: &EffectCtx, b: &Buf) -> Option<Contour> {
let idx = ctx.params.f("waveMask/mask").round() as usize;
let (pts, _, inverted) = crate::generate3::mask_px(ctx, b, idx)?;
let (w, h) = (b.img.width as usize, b.img.height as usize);
let inside: Vec<bool> = (0..w * h).map(|i| crate::util::point_in_poly(&pts, (i % w) as f64 + 0.5, (i / w) as f64 + 0.5) != inverted).collect();
if !inside.iter().any(|v| *v) {
return None;
}
// Mask waves start where the mask is (anchored at the Producer Point: no shift).
let anchor = b.to_px(ctx.params.v2("producerPoint"));
Some(Contour { sdf: crate::util::signed_distance(&inside, w, h), anchor })
}
/// One live wave.
struct Wave {
centre: (f64, f64),
radius: f64,
half_width: f64,
rotation: f64,
fade: f32,
color: [f32; 4],
opacity: f32,
/// Polygon outline (unit shape rotated and scaled), or None for a circle / contour.
outline: Option<Vec<(f64, f64)>>,
}
/// Radio Waves: waves are born at Frequency per second at the Producer Point and grow at
/// Expansion px/s for Lifespan seconds, moving at Velocity towards Direction (bouncing off the
/// layer edges with Reflection) and spinning. Polygon waves are regular polygons or stars;
/// Image Contours waves ripple outward from the outline of a source layer's thresholded
/// channel, Mask waves from a mask. With Parameters Are Set At Birth each wave keeps the values
/// it was born with; Each Frame applies the current values to every wave. Stroke draws each wave
/// with its Profile across the width.
fn radio_waves(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = b.to_px(ctx.params.v2("producerPoint"));
let sides = ctx.params.f("polygon/sides").round().clamp(3.0, 64.0);
let star = ctx.params.b("polygon/star");
let depth = ctx.params.f("polygon/starDepth").clamp(-1.0, 1.0);
let freq = ctx.params.f("waveMotion/frequency").max(0.01);
let expansion = ctx.params.f("waveMotion/expansion") * b.scale;
let orient = ctx.params.f("waveMotion/orientation").to_radians();
// Direction: 0° = up, clockwise.
let dirn = ctx.params.f("waveMotion/direction").to_radians();
let vel = ctx.params.f("waveMotion/velocity") * b.scale;
let spin = ctx.params.f("waveMotion/spin").to_radians();
let life = ctx.params.f("waveMotion/lifespan").max(0.01);
let color = ctx.params.color("waveStroke/color");
let op = (ctx.params.f("waveStroke/opacity") / 100.0) as f32;
let fin = ctx.params.f("waveStroke/fadeInTime").max(0.0);
let fout = ctx.params.f("waveStroke/fadeOutTime").max(0.0);
let sw = ctx.params.f("waveStroke/startWidth") * b.scale;
let ew = ctx.params.f("waveStroke/endWidth") * b.scale;
let cur = ctx.params;
let t = ctx.time.max(0.0);
let freq = cur.f("waveMotion/frequency").max(0.01);
let life_now = cur.f("waveMotion/lifespan").max(0.01);
let birth = cur.e("parametersAreSetAt") == 0;
let wave_type = cur.e("waveType");
let quality = cur.get("renderQuality").map(Value::as_f64).unwrap_or(4.0).clamp(1.0, 10.0);
let aa = 0.5 + 1.5 / quality;
let profile = cur.e("waveStroke/profile");
let reflection = cur.b("waveMotion/reflection");
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
let contour = match wave_type {
1 => image_contour(ctx, &b),
2 => mask_contour(ctx, &b),
_ => None,
};
if wave_type != 0 && contour.is_none() {
return b;
}
let k_hi = (t * freq).floor() as i64;
let k_lo = (((t - life) * freq).floor() as i64).max(0).max(k_hi - 256);
// (centre, radius, half width, rotation, fade) per live wave.
let waves: Vec<((f64, f64), f64, f64, f64, f32)> = (k_lo..=k_hi)
let k_lo = (((t - life_now) * freq).floor() as i64 - 1).max(0).max(k_hi - 256);
let mut cache: std::collections::HashMap<i64, crate::Params> = std::collections::HashMap::new();
let waves: Vec<Wave> = (k_lo..=k_hi)
.filter_map(|k| {
let age = t - k as f64 / freq;
let born = k as f64 / freq;
let age = t - born;
// Birth: the values the wave was born with (when the host can tell).
let pr: &crate::Params = if birth {
if let Some(h) = ctx.env.host
&& let std::collections::hash_map::Entry::Vacant(e) = cache.entry(k)
&& let Some(p) = h.params_at(born)
{
e.insert(p);
}
cache.get(&k).unwrap_or(cur)
} else {
cur
};
let life = pr.f("waveMotion/lifespan").max(0.01);
if !(0.0..life).contains(&age) {
return None;
}
let c = b.to_px(pr.v2("producerPoint"));
let fin = pr.f("waveStroke/fadeInTime").max(0.0);
let fout = pr.f("waveStroke/fadeOutTime").max(0.0);
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 };
let centre = (c.0 + dirn.sin() * vel * age, c.1 - dirn.cos() * vel * age);
Some((centre, expansion * age, ((sw + (ew - sw) * age / life) * 0.5).max(0.25), orient + spin * age, (fi * fo) as f32))
let dirn = pr.f("waveMotion/direction").to_radians();
let vel = pr.f("waveMotion/velocity") * b.scale;
let mut centre = (c.0 + dirn.sin() * vel * age, c.1 - dirn.cos() * vel * age);
if reflection {
centre = (reflect(centre.0, lx, lx + lw), reflect(centre.1, ly, ly + lh));
}
let sw = pr.f("waveStroke/startWidth") * b.scale;
let ew = pr.f("waveStroke/endWidth") * b.scale;
let radius = pr.f("waveMotion/expansion") * b.scale * age;
let rotation = pr.f("waveMotion/orientation").to_radians() + pr.f("waveMotion/spin").to_radians() * age;
let outline = (wave_type == 0).then(|| {
let sides = pr.f("polygon/sides").round().clamp(3.0, 64.0);
let star = pr.b("polygon/star");
let depth = pr.f("polygon/starDepth").clamp(-1.0, 1.0);
if sides >= 64.0 && !star {
return None;
}
let n = sides as usize;
let unit: Vec<(f64, f64)> = if star {
(0..2 * n).map(|i| (PI * i as f64 / n as f64 - PI / 2.0, if i % 2 == 0 { 1.0 } else { (1.0 + depth).max(0.0) })).collect()
} else {
(0..n).map(|i| (2.0 * PI * i as f64 / n as f64 - PI / 2.0, 1.0)).collect()
};
Some(unit.iter().map(|&(a, k)| ((a + rotation).cos() * radius * k, (a + rotation).sin() * radius * k)).collect())
});
Some(Wave {
centre,
radius,
half_width: ((sw + (ew - sw) * age / life) * 0.5).max(0.25),
rotation,
fade: (fi * fo) as f32,
color: pr.color("waveStroke/color"),
opacity: (pr.f("waveStroke/opacity") / 100.0) as f32,
outline: outline.flatten(),
})
})
.collect();
let circle = sides >= 64.0 && !star;
// Unit outline (radius 1) of the polygon or star, rotated per wave below.
let n = sides as usize;
let unit: Vec<(f64, f64)> = if star {
(0..2 * n).map(|i| (PI * i as f64 / n as f64 - PI / 2.0, if i % 2 == 0 { 1.0 } else { (1.0 + depth).max(0.0) })).collect()
} else {
(0..n).map(|i| (2.0 * PI * i as f64 / n as f64 - PI / 2.0, 1.0)).collect()
};
let outlines: Vec<Vec<(f64, f64)>> =
waves.iter().map(|&(_, rad, _, rot, _)| unit.iter().map(|&(a, k)| ((a + rot).cos() * rad * k, (a + rot).sin() * rad * k)).collect()).collect();
map_xy(&mut b.img, |x, y, px| {
let mut a = 0.0f32;
for (wi, &(cc, rad, hw, _, fade)) in waves.iter().enumerate() {
let (dx, dy) = (x as f64 + 0.5 - cc.0, y as f64 + 0.5 - cc.1);
let d = if circle { ((dx * dx + dy * dy).sqrt() - rad).abs() } else { outline_dist(&outlines[wi], dx, dy) };
a = a.max((hw - d + 0.5).clamp(0.0, 1.0) as f32 * fade);
let mut out = px;
// Older waves first, so newer ones sit on top.
for wv in &waves {
let (dx, dy) = (x as f64 + 0.5 - wv.centre.0, y as f64 + 0.5 - wv.centre.1);
// Signed distance from the wave line (negative inside the wave).
let d = match (&contour, &wv.outline) {
(Some(ct), _) => {
// The contour placed with its anchor on the wave centre, rotated.
let (s, c) = (-(wv.rotation)).sin_cos();
let (rx, ry) = (dx * c - dy * s, dx * s + dy * c);
ct.sdf.sample(ct.anchor.0 + rx, ct.anchor.1 + ry) as f64 - wv.radius
}
(None, Some(o)) => {
let ud = outline_dist(o, dx, dy);
let poly: Vec<[f64; 2]> = o.iter().map(|p| [p.0, p.1]).collect();
if crate::util::point_in_poly(&poly, dx, dy) { -ud } else { ud }
}
(None, None) => (dx * dx + dy * dy).sqrt() - wv.radius,
};
let ad = d.abs();
let cov = ((wv.half_width - ad) / aa + 0.5).clamp(0.0, 1.0);
if cov <= 0.0 {
continue;
}
let u = (d / wv.half_width).clamp(-1.0, 1.0);
let a = (cov * rw_profile(profile, u)) as f32 * wv.fade * wv.opacity * wv.color[3];
out = over(out, [wv.color[0] * a, wv.color[1] * a, wv.color[2] * a, a]);
}
let a = a * op * color[3];
over(px, [color[0] * a, color[1] * a, color[2] * a, a])
out
});
b
}
@ -414,7 +639,40 @@ type Seg = ((f64, f64), (f64, f64), f32);
/// Advanced Lightning's Lightning Type options.
const LIGHTNING_TYPES: [&str; 8] = ["Direction", "Strike", "Breaking", "Bouncy", "Omni", "Anywhere", "Vertical", "Two-Way Strike"];
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>) {
/// Advanced Lightning's Fractal Type options.
const FRACTAL_TYPES: [&str; 3] = ["Linear", "Semi-Linear", "Spline"];
/// How bolts grow: the general and Expert Settings.
struct BoltCfg<'a> {
turb: f64,
fork: f64,
decay: f32,
/// The layer's alpha (buffer grid) and Alpha Obstacle (−10..10; positive avoids opaque
/// areas, negative is drawn to them).
alpha: Option<&'a Plane>,
obstacle: f64,
main_only: bool,
/// Min Fork Distance (px): shorter stretches do not fork.
min_fork: f64,
/// Termination Threshold: branches fainter than this stop.
term: f32,
/// Zig-zag scale from Fractal Type (Linear 1, Semi-Linear 0.7, Spline 0.45 with an extra
/// subdivision level).
shape: f64,
fork_strength: f32,
fork_var: f32,
}
impl BoltCfg<'_> {
fn alpha_at(&self, p: (f64, f64)) -> f32 {
self.alpha.map_or(0.0, |a| a.sample(p.0, p.1))
}
}
/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random.
/// Segments go to `out` with their intensity; `mains` marks the main core's segments.
#[allow(clippy::too_many_arguments)]
fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec<Seg>, mains: &mut Vec<bool>) {
if out.len() > 20_000 {
return;
}
@ -422,19 +680,38 @@ fn bolt(a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, turb: f64, fork: f
let len = (dx * dx + dy * dy).sqrt();
if depth == 0 || len < 1.0 {
out.push((a, z, inten));
mains.push(main);
return;
}
let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9));
let off = rng.s() * len * 0.22 * turb;
let mut off = rng.s() * len * 0.22 * cfg.turb * cfg.shape;
// Alpha Obstacle: of the two mirror positions for the midpoint, prefer the one with less
// (positive) or more (negative) alpha, the more strongly the larger the setting.
if cfg.obstacle != 0.0 && cfg.alpha.is_some() && (main || !cfg.main_only) {
let mid = ((a.0 + z.0) * 0.5, (a.1 + z.1) * 0.5);
let p1 = (mid.0 + nx * off, mid.1 + ny * off);
let p2 = (mid.0 - nx * off, mid.1 - ny * off);
let (a1, a2) = (cfg.alpha_at(p1), cfg.alpha_at(p2));
let worse = if cfg.obstacle > 0.0 { a1 > a2 } else { a1 < a2 };
if worse && rng.f() < cfg.obstacle.abs() / 10.0 {
off = -off;
}
}
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 {
// Positive obstacles stop branches that run into opaque areas.
if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 {
return;
}
bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains);
bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains);
if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork {
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);
// Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it.
let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 };
bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains);
}
}
@ -446,26 +723,49 @@ fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
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("expertSettings/complexity").round().clamp(1.0, 12.0) as u32;
let fractal = ctx.params.e("expertSettings/fractalType");
let depth = ctx.params.f("expertSettings/complexity").round().clamp(1.0, 12.0) as u32 + u32::from(fractal == 2);
let state = ctx.params.f("conductivityState").floor() as i64 as u64;
let mut rng = Rng::new(state ^ ((ctx.seed as u64) << 32));
let obstacle = ctx.params.f("alphaObstacle").clamp(-10.0, 10.0);
let alpha_plane = (obstacle != 0.0).then(|| Plane::alpha(&b.img));
let g = |id: &str, dflt: f64| ctx.params.get(id).map(Value::as_f64).unwrap_or(dflt);
let cfg = BoltCfg {
turb,
fork,
decay,
alpha: alpha_plane.as_ref(),
obstacle,
main_only: ctx.params.b("expertSettings/mainCoreCollisionOnly"),
min_fork: g("expertSettings/minForkDistance", 0.0).max(0.0) * b.scale,
term: (g("expertSettings/terminationThreshold", 5.0) / 100.0).clamp(0.0, 1.0) as f32,
shape: match fractal {
1 => 0.7,
2 => 0.45,
_ => 1.0,
},
fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32,
fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32,
};
let mut segs = Vec::new();
let mut mains = Vec::new();
let (h, w) = (b.img.height as f64, b.img.width as f64);
let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains);
// LIGHTNING_TYPES order.
match kind {
// Direction: travels toward the Direction point and beyond.
0 => {
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);
go(o, z, depth, &cfg, &mut rng);
}
// Breaking: more branching as the points move apart.
2 => {
let dist = ((d.0 - o.0).powi(2) + (d.1 - o.1).powi(2)).sqrt();
let f = (fork * (0.5 + dist / w.max(h).max(1.0))).min(1.0);
bolt(o, d, depth, 1.0, turb, f, decay, &mut rng, &mut segs);
go(o, d, depth, &BoltCfg { fork: f, ..cfg }, &mut rng);
}
// Bouncy: a strike with a stronger zig-zag.
3 => bolt(o, d, depth, 1.0, turb * 1.6, fork, decay, &mut rng, &mut segs),
3 => go(o, d, depth, &BoltCfg { turb: turb * 1.6, ..cfg }, &mut rng),
// Omni / Anywhere: bolts in all directions out to the Outer Radius.
4 | 5 => {
let len = ((d.0 - o.0).powi(2) + (d.1 - o.1).powi(2)).sqrt().max(w.min(h) * 0.25);
@ -473,27 +773,37 @@ fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
let a = rng.f() * 2.0 * PI;
let l = if kind == 5 { len * (0.3 + 0.7 * rng.f()) } else { len };
let z = (o.0 + a.cos() * l, o.1 + a.sin() * l);
bolt(o, z, depth, 1.0, turb, fork, decay, &mut rng, &mut segs);
go(o, z, depth, &cfg, &mut rng);
}
}
6 => bolt(o, (o.0, h), depth, 1.0, turb, fork, decay, &mut rng, &mut segs),
6 => go(o, (o.0, h), depth, &cfg, &mut rng),
// Two-Way Strike: from both ends toward the middle.
7 => {
let m = ((o.0 + d.0) * 0.5, (o.1 + d.1) * 0.5);
bolt(o, m, depth.saturating_sub(1).max(1), 1.0, turb, fork, decay, &mut rng, &mut segs);
bolt(d, m, depth.saturating_sub(1).max(1), 1.0, turb, fork, decay, &mut rng, &mut segs);
go(o, m, depth.saturating_sub(1).max(1), &cfg, &mut rng);
go(d, m, depth.saturating_sub(1).max(1), &cfg, &mut rng);
}
// Strike.
_ => bolt(o, d, depth, 1.0, turb, fork, decay, &mut rng, &mut segs),
_ => go(o, d, depth, &cfg, &mut rng),
}
let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt();
let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max);
// Decay Main Core: the whole bolt fades with distance from the origin.
if decay_main {
let far = segs.iter().map(|(_, z, _)| ((z.0 - o.0).powi(2) + (z.1 - o.1).powi(2)).sqrt()).fold(1e-9, f64::max);
for (a, _, i) in &mut segs {
let t = (((a.0 - o.0).powi(2) + (a.1 - o.1).powi(2)).sqrt() / far) as f32;
let t = (dist(*a) / far) as f32;
*i *= (1.0 - decay * t).max(0.0);
}
}
// Core Drain: the main core loses intensity along its length.
let drain = (g("expertSettings/coreDrain", 0.0) / 100.0).clamp(0.0, 1.0) as f32;
if drain > 0.0 {
for ((a, _, i), m) in segs.iter_mut().zip(&mains) {
if *m {
*i *= (1.0 - drain * (dist(*a) / far) as f32).max(0.0);
}
}
}
segs
}
@ -776,9 +1086,21 @@ pub fn specs() -> Vec<EffectSpec> {
"Radio Waves",
vec![
p("producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point),
p("parametersAreSetAt", "Parameters Are Set At", Value::Enum(1), popup(&["Birth", "Each Frame"])),
p("renderQuality", "Render Quality", num(4.0), slider(1.0, 10.0, 1.0, 10.0, 0)),
p("waveType", "Wave Type", Value::Enum(0), popup(&RW_TYPES)),
p("polygon/sides", "Sides", num(64.0), slider(3.0, 64.0, 3.0, 64.0, 0)),
p("polygon/star", "Star", Value::Bool(false), ParamUi::Checkbox),
p("polygon/starDepth", "Star Depth", num(-0.3), slider(-1.0, 1.0, -1.0, 1.0, 2)),
p("imageContour/sourceLayer", "Source Layer", Value::Layer(None), ParamUi::Layer),
p("imageContour/sourceCenter", "Source Center", pt(0.5, 0.5), ParamUi::Point),
p("imageContour/valueChannel", "Value Channel", Value::Enum(4), popup(&RW_CHANNELS)),
p("imageContour/invertInput", "Invert Input", Value::Bool(false), ParamUi::Checkbox),
p("imageContour/valueThreshold", "Value Threshold", num(128.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
p("imageContour/preBlur", "Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("imageContour/tolerance", "Tolerance", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("imageContour/contour", "Contour", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 0)),
p("waveMask/mask", "Mask", num(0.0), ParamUi::Mask),
p("waveMotion/frequency", "Frequency", num(1.0), slider(0.01, 100.0, 0.01, 10.0, 2)),
p("waveMotion/expansion", "Expansion", num(100.0), slider(0.0, 10000.0, 0.0, 1000.0, 1)),
p("waveMotion/orientation", "Orientation", num(0.0), ang()),
@ -786,6 +1108,8 @@ pub fn specs() -> Vec<EffectSpec> {
p("waveMotion/velocity", "Velocity", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 1)),
p("waveMotion/spin", "Spin", num(0.0), slider(-3600.0, 3600.0, -360.0, 360.0, 1)),
p("waveMotion/lifespan", "Lifespan (sec)", num(2.0), slider(0.01, 100.0, 0.01, 10.0, 2)),
p("waveMotion/reflection", "Reflection", Value::Bool(false), ParamUi::Checkbox),
p("waveStroke/profile", "Profile", Value::Enum(0), popup(&RW_PROFILES)),
p("waveStroke/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("waveStroke/opacity", "Opacity", num(100.0), pct()),
p("waveStroke/fadeInTime", "Fade-in Time", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
@ -813,8 +1137,16 @@ pub fn specs() -> Vec<EffectSpec> {
p("forking", "Forking", num(25.0), pct()),
p("decay", "Decay", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("decayMainCore", "Decay Main Core", Value::Bool(false), ParamUi::Checkbox),
p("alphaObstacle", "Alpha Obstacle", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 2)),
p("compositeOnOriginal", "Composite On Original", Value::Bool(true), ParamUi::Checkbox),
p("expertSettings/complexity", "Complexity", num(6.0), slider(1.0, 12.0, 1.0, 12.0, 0)),
p("expertSettings/minForkDistance", "Min Fork Distance", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("expertSettings/terminationThreshold", "Termination Threshold", num(5.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("expertSettings/mainCoreCollisionOnly", "Main Core Collision Only", Value::Bool(false), ParamUi::Checkbox),
p("expertSettings/fractalType", "Fractal Type", Value::Enum(0), popup(&FRACTAL_TYPES)),
p("expertSettings/coreDrain", "Core Drain", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("expertSettings/forkStrength", "Fork Strength", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("expertSettings/forkVariation", "Fork Variation", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
advanced_lightning,
),
@ -972,6 +1304,43 @@ mod tests {
assert!(o.img.get(46, 18)[3] < 0.01, "no circle there");
}
#[test]
fn lightning_alpha_obstacle_and_expert_settings() {
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::<f32>();
// A wall of opaque pixels across the middle of the bolt's path.
let mut wall = Image::new(64, 48);
for y in 0..48 {
for x in 0..64 {
if (18..30).contains(&y) && x < 50 {
wall.set(x, y, [0.0, 0.0, 0.0, 1.0]);
}
}
}
let opts = |o: f64| {
vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))]
};
let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone());
let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone());
assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall));
// Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance.
let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
for (id, v) in [
("expertSettings/fractalType", Value::Enum(2)),
("expertSettings/coreDrain", num(100.0)),
("expertSettings/forkStrength", num(100.0)),
("expertSettings/forkVariation", num(100.0)),
] {
assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}");
}
let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48));
assert!(sum(&drained.img) < sum(&base.img));
let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48));
let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48));
assert_eq!(no_forks.img, term.img, "no forks either way");
assert!(sum(&no_forks.img) < sum(&base.img));
}
#[test]
fn lightning_types_and_decay_main_core() {
let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
@ -1014,4 +1383,130 @@ mod tests {
assert!(c > o.img.get(60, 4)[0]);
assert!(c > 0.5);
}
fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image {
let mut v: Vec<(&str, Value)> =
vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))];
v.extend_from_slice(set);
crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img
}
#[test]
fn signed_distance_of_a_square() {
let (w, h) = (20, 20);
let inside: Vec<bool> = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect();
let d = crate::util::signed_distance(&inside, w, h);
assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10));
assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10));
assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4);
}
#[test]
fn radio_waves_mask_contours_profile_reflection_and_birth() {
// Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it.
let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false };
let masks = [sq];
let env = crate::EffectEnv { masks: &masks, ..Default::default() };
let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env);
assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32));
assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01);
// Rounded corner: diagonal distance 10 from (37, 37).
assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44));
// No mask chosen: nothing drawn.
assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0));
// Image Contours: the bright square of a source layer.
struct Src;
impl crate::EffectHost for Src {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
let mut img = Image::new(64, 64);
for y in 27..37 {
for x in 27..37 {
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn params_at(&self, t: f64) -> Option<Params> {
// Colour animates from red (born at 0) to green (born later).
let s = crate::find("ec.generate.radiowaves").unwrap();
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0]));
p.values.insert("waveMotion/frequency".into(), num(1.0));
p.values.insert("waveMotion/expansion".into(), num(20.0));
p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) });
Some(p)
}
}
let env = crate::EffectEnv { host: Some(&Src), ..Default::default() };
let ic = rw(
&[
("waveType", Value::Enum(1)),
("imageContour/sourceLayer", Value::Layer(Some(1))),
("imageContour/preBlur", num(0.0)),
("imageContour/tolerance", num(0.0)),
],
0.1,
env,
);
assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32));
// Source Center off the square shifts the contour onto the producer point accordingly.
let sh = rw(
&[
("waveType", Value::Enum(1)),
("imageContour/sourceLayer", Value::Layer(Some(1))),
("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])),
("imageContour/preBlur", num(0.0)),
("imageContour/tolerance", num(0.0)),
],
0.1,
env,
);
assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32));
// Profile: Taper Both thins the stroke edges; Square is solid across.
let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))];
let sqr = rw(&wide, 0.2, crate::EffectEnv::default());
let mut tap = wide.to_vec();
tap.push(("waveStroke/profile", Value::Enum(3)));
let tap = rw(&tap, 0.2, crate::EffectEnv::default());
assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32));
assert!(tap.get(52, 32)[3] > 0.8);
// Reflection: a fast wave bounces back off the right edge.
let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))];
let gone = rw(&fast, 0.3, crate::EffectEnv::default());
let mut rf = fast.to_vec();
rf.push(("waveMotion/reflection", Value::Bool(true)));
let back = rw(&rf, 0.3, crate::EffectEnv::default());
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone));
// Parameters Are Set At Birth: the older wave keeps red, the newer one is green.
let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env);
let (old, new) = (both.get(55, 32), both.get(35, 32));
assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}");
assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}");
}
#[test]
fn cell_pattern_hq_variants_antialias() {
let s = crate::find("ec.generate.cellpattern").unwrap();
let r = |pat: u32| {
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
params.values.insert("cellPattern".into(), Value::Enum(pat));
params.values.insert("size".into(), num(12.0));
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img
};
let distinct = |img: &Image| {
let mut v: Vec<u32> = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect();
v.sort_unstable();
v.dedup();
v.len()
};
let (std, hq) = (r(4), r(9));
assert_eq!(CELL_PATTERNS[9], "Crystallize HQ");
assert_ne!(std, hq);
assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std));
}
}

View File

@ -466,6 +466,141 @@ fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64;
*out = merged;
}
/// End Cap and Join options (Scribble's Edge Options).
const END_CAPS: [&str; 3] = ["Round", "Butt", "Projecting"];
const JOINS: [&str; 3] = ["Round", "Miter", "Bevel"];
/// Convex polygons whose union is the stroke of the polylines `lines` with half width `r`:
/// one quad per segment, plus the joins (Round discs, Miter wedges up to `miter_limit` × the
/// half width, Bevel triangles) and the caps (Round discs, Butt none, Projecting square ends).
pub(crate) fn stroke_outline(lines: &[Vec<[f64; 2]>], r: f64, cap: u32, join: u32, miter_limit: f64) -> Vec<Vec<[f64; 2]>> {
let disc = |c: [f64; 2]| -> Vec<[f64; 2]> {
(0..16).map(|k| (k as f64 / 16.0 * std::f64::consts::TAU).sin_cos()).map(|(s, co)| [c[0] + co * r, c[1] + s * r]).collect()
};
let mut out = Vec::new();
for l in lines {
let pts: Vec<[f64; 2]> = l.iter().copied().fold(Vec::new(), |mut v, p| {
if v.last().is_none_or(|q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) > 1e-9) {
v.push(p);
}
v
});
if pts.len() < 2 {
if let (Some(p), 0) = (pts.first(), cap) {
out.push(disc(*p));
}
continue;
}
let dir = |a: [f64; 2], b: [f64; 2]| {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l = dx.hypot(dy).max(1e-12);
[dx / l, dy / l]
};
let n = pts.len();
for k in 0..n - 1 {
let (mut a, mut b) = (pts[k], pts[k + 1]);
let d = dir(a, b);
// Projecting caps extend the end segments by the half width.
if cap == 2 && k == 0 {
a = [a[0] - d[0] * r, a[1] - d[1] * r];
}
if cap == 2 && k == n - 2 {
b = [b[0] + d[0] * r, b[1] + d[1] * r];
}
let nn = [-d[1] * r, d[0] * r];
out.push(vec![[a[0] + nn[0], a[1] + nn[1]], [b[0] + nn[0], b[1] + nn[1]], [b[0] - nn[0], b[1] - nn[1]], [a[0] - nn[0], a[1] - nn[1]]]);
}
// Joins.
for k in 1..n - 1 {
let (p, d0, d1) = (pts[k], dir(pts[k - 1], pts[k]), dir(pts[k], pts[k + 1]));
let cross = d0[0] * d1[1] - d0[1] * d1[0];
if cross.abs() < 1e-9 && d0[0] * d1[0] + d0[1] * d1[1] > 0.0 {
continue;
}
if join == 0 {
out.push(disc(p));
continue;
}
// The outer side of the turn.
let s = if cross > 0.0 { -1.0 } else { 1.0 };
let o0 = [p[0] - d0[1] * r * s, p[1] + d0[0] * r * s];
let o1 = [p[0] - d1[1] * r * s, p[1] + d1[0] * r * s];
let bevel = vec![p, o0, o1];
if join == 1 {
// Miter point: where the two offset edges meet.
let bis = [d0[0] - d1[0], d0[1] - d1[1]];
let bl = bis[0].hypot(bis[1]);
let cos_half = ((1.0 + d0[0] * d1[0] + d0[1] * d1[1]) * 0.5).max(0.0).sqrt();
let len = if cos_half > 1e-6 { r / cos_half } else { f64::INFINITY };
if bl > 1e-9 && len <= miter_limit * r {
let m = [p[0] + (o0[0] + o1[0] - 2.0 * p[0]) * 0.5, p[1] + (o0[1] + o1[1] - 2.0 * p[1]) * 0.5];
let ml = (m[0] - p[0]).hypot(m[1] - p[1]).max(1e-12);
let tip = [p[0] + (m[0] - p[0]) / ml * len, p[1] + (m[1] - p[1]) / ml * len];
out.push(vec![p, o0, tip, o1]);
continue;
}
}
out.push(bevel);
}
if cap == 0 {
out.push(disc(pts[0]));
out.push(disc(pts[n - 1]));
}
}
out
}
/// Coverage (union, 4×4 samples per pixel) of convex polygons on a `w`×`h` grid.
pub(crate) fn raster_convex(w: usize, h: usize, polys: &[Vec<[f64; 2]>]) -> Plane {
let mut out = Plane::new(w, h);
let inside = |poly: &[[f64; 2]], x: f64, y: f64| {
let n = poly.len();
let mut sign = 0.0;
for i in 0..n {
let (a, b) = (poly[i], poly[(i + 1) % n]);
let c = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]);
if c.abs() < 1e-12 {
continue;
}
if sign == 0.0 {
sign = c.signum();
} else if c.signum() != sign {
return false;
}
}
true
};
for poly in polys {
if poly.len() < 3 {
continue;
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in poly {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
let (xa, xb) = (x0.floor().max(0.0) as usize, (x1.ceil().max(0.0) as usize).min(w));
let (ya, yb) = (y0.floor().max(0.0) as usize, (y1.ceil().max(0.0) as usize).min(h));
for y in ya..yb {
for x in xa..xb {
let mut hit = 0;
for sy in 0..4 {
for sx in 0..4 {
if inside(poly, x as f64 + (sx as f64 + 0.5) / 4.0, y as f64 + (sy as f64 + 0.5) / 4.0) {
hit += 1;
}
}
}
let v = &mut out.data[y * w + x];
*v = v.max(hit as f32 / 16.0);
}
}
}
out
}
fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let which = pr.e("scribble");
@ -585,19 +720,33 @@ fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf {
lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect());
}
let r = width * 0.5;
let mut segs = Vec::new();
// Start / End Apply To: all of a mask's strokes as one path, or each stroke on its own.
let each = pr.e("startEndApplyTo") == 1;
let mut parts: Vec<Vec<[f64; 2]>> = Vec::new();
for group in &lines {
let lens: Vec<f64> = group.iter().map(|l| poly_length(l, false)).collect();
let total: f64 = lens.iter().sum();
let (g0, g1) = (total * start, total * end);
let mut acc = 0.0;
for (l, len) in group.iter().zip(lens) {
let part = trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len));
let part =
if each { trim_poly(l, false, len * start, len * end) } else { trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) };
acc += len;
poly_segs(&part, false, r, 1.0, &mut segs);
parts.push(part);
}
}
let cov = raster_segs(w, h, &segs, 0.9);
let cap = pr.e("edgeOptions/endCap");
let join = pr.e("edgeOptions/join");
let cov = if cap == 0 && join == 0 {
let mut segs = Vec::new();
for part in &parts {
poly_segs(part, false, r, 1.0, &mut segs);
}
raster_segs(w, h, &segs, 0.9)
} else {
let polys = stroke_outline(&parts, r, cap, join, pr.f("edgeOptions/miterLimit").max(1.0));
raster_convex(w, h, &polys)
};
let color = pr.color("color");
paint(&mut b.img, &cov, color, (pr.f("opacity") / 100.0) as f32, style);
b
@ -1467,6 +1616,9 @@ pub fn specs() -> Vec<EffectSpec> {
mask_idx("mask", "Mask", 1.0),
p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])),
p("edgeOptions/edgeWidth", "Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("edgeOptions/endCap", "End Cap", Value::Enum(0), popup(&END_CAPS)),
p("edgeOptions/join", "Join", Value::Enum(0), popup(&JOINS)),
p("edgeOptions/miterLimit", "Miter Limit", num(4.0), slider(1.0, 100.0, 1.0, 20.0, 1)),
p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), pct()),
p("angle", "Angle", num(45.0), ang()),
@ -1479,6 +1631,7 @@ pub fn specs() -> Vec<EffectSpec> {
p("strokeOptions/pathOverlapVariation", "Path Overlap Variation", num(50.0), pct()),
p("start", "Start", num(0.0), pct()),
p("end", "End", num(100.0), pct()),
p("startEndApplyTo", "Start/End Apply To", Value::Enum(0), popup(&["All Strokes", "Each Stroke"])),
check("fillPathsSequentially", "Fill Paths Sequentially", true),
p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])),
p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)),
@ -1936,4 +2089,49 @@ mod tests {
let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0;
assert_eq!(peak, 8);
}
#[test]
fn stroke_outline_caps_and_joins() {
let line = vec![vec![[10.0, 10.0], [30.0, 10.0]]];
let cov = |cap: u32| raster_convex(40, 20, &stroke_outline(&line, 3.0, cap, 0, 4.0));
// Round caps reach 3 px past the ends (on the axis), Butt stops there, Projecting is square.
let (round, butt, proj) = (cov(0), cov(1), cov(2));
assert!(round.get(8, 10) > 0.9 && butt.get(8, 10) < 0.1 && proj.get(8, 10) > 0.9);
assert!(proj.get(7, 7) > 0.9 && round.get(7, 7) < 0.5, "square corner");
// A right-angle turn: Miter fills the outer corner, Bevel cuts it.
let corner = vec![vec![[5.0, 30.0], [30.0, 30.0], [30.0, 5.0]]];
let j = |join: u32| raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, join, 4.0));
let (miter, bevel) = (j(1), j(2));
assert!(miter.get(32, 32) > 0.9 && bevel.get(32, 32) < 0.1, "{} {}", miter.get(32, 32), bevel.get(32, 32));
// A tight miter limit falls back to a bevel.
let limited = raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, 1, 1.0));
assert!(limited.get(32, 32) < 0.1);
}
#[test]
fn scribble_caps_and_start_end_apply_to() {
let masks = square_mask();
let env = EffectEnv { masks: &masks, ..Default::default() };
let img = Image::new(64, 64);
let base = [("wiggleType", Value::Enum(0)), ("strokeWidth", num(3.0))];
let round = run_env("ec.generate.scribble", &base, img.clone(), 0.0, env);
let butt = run_env(
"ec.generate.scribble",
&[base.as_slice(), &[("edgeOptions/endCap", Value::Enum(1)), ("edgeOptions/join", Value::Enum(2))]].concat(),
img.clone(),
0.0,
env,
);
assert_ne!(round.img.data, butt.img.data);
// Each Stroke: every stroke is trimmed on its own, so the half-way image has ink all over.
let two = vec![
crate::MaskShape { name: String::new(), points: vec![[4.0, 4.0], [28.0, 4.0], [28.0, 60.0], [4.0, 60.0]], closed: true, inverted: false },
crate::MaskShape { name: String::new(), points: vec![[36.0, 4.0], [60.0, 4.0], [60.0, 60.0], [36.0, 60.0]], closed: true, inverted: false },
];
let env2 = EffectEnv { masks: &two, ..Default::default() };
let seq = [("scribble", Value::Enum(1)), ("end", num(50.0))];
let all = run_env("ec.generate.scribble", &[base.as_slice(), &seq].concat(), img.clone(), 0.0, env2);
let each = run_env("ec.generate.scribble", &[base.as_slice(), &seq, &[("startEndApplyTo", Value::Enum(1))]].concat(), img, 0.0, env2);
assert_ne!(all.img.data, each.img.data);
}
}

View File

@ -496,14 +496,36 @@ fn advanced_spill(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amt = ctx.params.f("suppression") as f32 / 100.0;
let range = ctx.params.f("ultraSettings/spillRange") as f32 / 100.0;
let luma = ctx.params.f("ultraSettings/lumaCorrection") as f32 / 100.0;
// Ultra: Tolerance limits suppression to hues near the key colour; Desaturate takes the
// colour out of suppressed pixels; Spill Color Correction puts the removed spill back as
// neutral light (so suppressed areas keep their brightness without the cast).
let key = ctx.params.color("ultraSettings/keyColor");
let key_hue = effectcraft_color::rgb_to_hsl(key[0], key[1], key[2]).0;
let tol = if ultra { ctx.params.get("ultraSettings/tolerance").map(Value::as_f64).unwrap_or(100.0) as f32 / 100.0 } else { 1.0 };
let desat = if ultra { ctx.params.f("ultraSettings/desaturate") as f32 / 100.0 } else { 0.0 };
let neutral = if ultra { ctx.params.f("ultraSettings/spillColorCorrection") as f32 / 100.0 } else { 0.0 };
b.img.map_straight(|mut c| {
let w = if tol < 1.0 {
let h = effectcraft_color::rgb_to_hsl(c[0], c[1], c[2]).0;
let d = (h - key_hue).abs();
let d = d.min(1.0 - d);
(1.0 - d / (tol * 0.5).max(1e-4)).clamp(0.0, 1.0)
} else {
1.0
};
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;
let spill = (c[pi] - limit).max(0.0) * amt * w;
c[pi] -= spill;
let back = spill * LUMA_W[pi] * luma;
c.map(|v| v + back)
let back = spill * LUMA_W[pi] * luma + spill * neutral / 3.0;
let mut c = c.map(|v| v + back);
if desat > 0.0 && spill > 0.0 {
let l = effectcraft_color::luminance(c[0], c[1], c[2]);
let k = (desat * (spill * 4.0).min(1.0)).min(1.0);
c = c.map(|v| v + (l - v) * k);
}
c
});
b
}
@ -675,7 +697,10 @@ pub fn specs() -> Vec<EffectSpec> {
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("ultraSettings/keyColor", "Key Color", col(0.0, 1.0, 0.0), ParamUi::Color),
p("ultraSettings/tolerance", "Tolerance", num(50.0), pct()),
p("ultraSettings/desaturate", "Desaturate", num(0.0), pct()),
p("ultraSettings/spillRange", "Spill Range", num(50.0), pct()),
p("ultraSettings/spillColorCorrection", "Spill Color Correction", num(0.0), pct()),
p("ultraSettings/lumaCorrection", "Luma Correction", num(0.0), pct()),
],
advanced_spill,
@ -846,4 +871,26 @@ mod tests {
let out = run("ec.key.keycleaner", &[], img.clone());
assert_eq!(out.get(12, 2), img.get(12, 2));
}
#[test]
fn advanced_spill_ultra_tolerance_desaturate_and_color_correction() {
let ultra = |extra: &[(&str, Value)], c: [f32; 4]| {
let mut v = vec![("method", Value::Enum(1)), ("ultraSettings/keyColor", Value::Color([0.0, 1.0, 0.0, 1.0]))];
v.extend_from_slice(extra);
run("ec.key.advancedspill", &v, Image::filled(1, 1, c)).get(0, 0)
};
// A yellow-green pixel (hue away from the key) is left alone with a narrow tolerance.
let yg = [0.6, 0.8, 0.1, 1.0];
assert!((ultra(&[("ultraSettings/tolerance", num(5.0))], yg)[1] - 0.8).abs() < 1e-5);
assert!(ultra(&[("ultraSettings/tolerance", num(100.0))], yg)[1] < 0.79);
// Desaturate greys the suppressed pixel.
let g = [0.3, 0.8, 0.3, 1.0];
let d = ultra(&[("ultraSettings/desaturate", num(100.0))], g);
assert!((d[0] - d[1]).abs() < 0.05, "{d:?}");
// Spill Color Correction keeps the brightness the suppression removed.
let plain = ultra(&[], g);
let cc = ultra(&[("ultraSettings/spillColorCorrection", num(100.0))], g);
let sum = |p: [f32; 4]| p[0] + p[1] + p[2];
assert!(sum(cc) > sum(plain) + 0.1 && (sum(cc) - sum(g)).abs() < 1e-4, "{plain:?} {cc:?}");
}
}

View File

@ -102,8 +102,11 @@ fn mask_sdf(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option<Plane> {
}
fn inner_outer_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
let fg_idx = [ctx.params.e("foreground"), ctx.params.e("additionalForeground")];
let bg_idx = [ctx.params.e("background"), ctx.params.e("additionalBackground")];
// Foreground (Inside) plus Additional Foreground 1–10; likewise for the background.
let fg_idx: Vec<u32> =
std::iter::once(ctx.params.e("foreground")).chain((1..=10).map(|k| ctx.params.e(&format!("additionalForeground/foreground{k}")))).collect();
let bg_idx: Vec<u32> =
std::iter::once(ctx.params.e("background")).chain((1..=10).map(|k| ctx.params.e(&format!("additionalBackground/background{k}")))).collect();
let radius = (ctx.params.f("singleMaskHighlightRadius") * b.scale) as f32;
let thin = ctx.params.f("edgeThin") * b.scale;
let feather = ctx.params.f("edgeFeather") * b.scale;
@ -187,6 +190,26 @@ fn inner_outer_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
.collect(),
);
}
// Cleanup Foreground / Background: brush strokes along mask paths that paint the matte
// towards opaque / transparent (Brush Radius, Brush Pressure).
for (side, target) in [("Foreground", 1.0f32), ("Background", 0.0)] {
for k in 1..=10 {
let g = format!("cleanup{side}/cleanup{side}{k}");
let idx = ctx.params.e(&format!("{g}/path"));
let Some(m) = (idx > 0).then(|| ctx.env.masks.get(idx as usize - 1)).flatten() else { continue };
let rad = (ctx.params.f(&format!("{g}/brushRadius")) * b.scale).max(0.5);
let pressure = (ctx.params.f(&format!("{g}/brushPressure")) / 100.0).clamp(0.0, 1.0) as f32;
let pts: Vec<[f64; 2]> = m.points.iter().map(|q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect();
alpha.data.par_iter_mut().enumerate().for_each(|(i, a)| {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let d = dist_to_poly(&pts, m.closed, x, y);
if d < rad + 1.0 {
let cov = ((rad - d) / rad.max(1.0) * 2.0).clamp(0.0, 1.0) as f32 * pressure;
*a += (target - *a) * cov;
}
});
}
}
if thin.abs() > 0.01 {
alpha = morph_frac(&alpha, thin.abs(), thin < 0.0);
}
@ -332,18 +355,34 @@ pub fn specs() -> Vec<EffectSpec> {
spec(
"ec.key.innerouter",
"Inner/Outer Key",
vec![
p("foreground", "Foreground (Inside)", Value::Enum(0), mask_options()),
p("additionalForeground", "Additional Foreground", Value::Enum(0), mask_options()),
p("background", "Background (Outside)", Value::Enum(0), mask_options()),
p("additionalBackground", "Additional Background", Value::Enum(0), mask_options()),
p("singleMaskHighlightRadius", "Single Mask Highlight Radius", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("edgeThin", "Edge Thin", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 1)),
p("edgeFeather", "Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 50.0, 1)),
p("edgeThreshold", "Edge Threshold", num(0.0), pct()),
p("invertExtraction", "Invert Extraction", Value::Bool(false), ParamUi::Checkbox),
p("blendWithOriginal", "Blend with Original", num(0.0), pct()),
],
{
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
let mut v = vec![p("foreground", "Foreground (Inside)", Value::Enum(0), mask_options())];
for k in 1..=10 {
v.push(p(leak(format!("additionalForeground/foreground{k}")), leak(format!("Foreground {k}")), Value::Enum(0), mask_options()));
}
v.push(p("background", "Background (Outside)", Value::Enum(0), mask_options()));
for k in 1..=10 {
v.push(p(leak(format!("additionalBackground/background{k}")), leak(format!("Background {k}")), Value::Enum(0), mask_options()));
}
for side in ["Foreground", "Background"] {
for k in 1..=10 {
let g = format!("cleanup{side}/cleanup{side}{k}");
v.push(p(leak(format!("{g}/path")), "Path", Value::Enum(0), mask_options()));
v.push(p(leak(format!("{g}/brushRadius")), "Brush Radius", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)));
v.push(p(leak(format!("{g}/brushPressure")), "Brush Pressure", num(80.0), pct()));
}
}
v.extend([
p("singleMaskHighlightRadius", "Single Mask Highlight Radius", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("edgeThin", "Edge Thin", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 1)),
p("edgeFeather", "Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 50.0, 1)),
p("edgeThreshold", "Edge Threshold", num(0.0), pct()),
p("invertExtraction", "Invert Extraction", Value::Bool(false), ParamUi::Checkbox),
p("blendWithOriginal", "Blend with Original", num(0.0), pct()),
]);
v
},
inner_outer_key,
),
spec(
@ -478,4 +517,47 @@ mod tests {
assert_eq!(out.img.get(2, 10), img.get(2, 10));
}
}
#[test]
fn inner_outer_key_more_masks_and_cleanup_strokes() {
let sq = |x0: f64, x1: f64| MaskShape { name: String::new(), points: vec![[x0, 2.0], [x1, 2.0], [x1, 8.0], [x0, 8.0]], closed: true, inverted: false };
// Masks 1..4: foreground squares; mask 5: a horizontal cleanup stroke across the middle.
let masks = vec![
sq(1.0, 5.0),
sq(7.0, 11.0),
sq(13.0, 17.0),
sq(19.0, 23.0),
MaskShape { name: String::new(), points: vec![[0.0, 5.0], [30.0, 5.0]], closed: false, inverted: false },
];
let env = EffectEnv { masks: &masks, ..Default::default() };
let img = Image::filled(30, 10, [0.5, 0.5, 0.5, 1.0]);
let vals = [
("foreground", Value::Enum(1)),
("additionalForeground/foreground1", Value::Enum(2)),
("additionalForeground/foreground2", Value::Enum(3)),
("additionalForeground/foreground5", Value::Enum(4)),
];
let out = run_fx("ec.key.innerouter", &vals, img.clone(), 0.0, env).img;
for x in [3, 9, 15, 21] {
assert_eq!(out.get(x, 3)[3], 1.0, "foreground square at {x}");
}
assert_eq!(out.get(27, 3)[3], 0.0);
// Cleanup Background along mask 5 erases a band; Cleanup Foreground restores outside.
let mut bg = vals.to_vec();
bg.extend([
("cleanupBackground/cleanupBackground1/path", Value::Enum(5)),
("cleanupBackground/cleanupBackground1/brushRadius", num(1.5)),
("cleanupBackground/cleanupBackground1/brushPressure", num(100.0)),
]);
let erased = run_fx("ec.key.innerouter", &bg, img.clone(), 0.0, env).img;
assert!(erased.get(3, 5)[3] < 0.1 && erased.get(3, 2)[3] > 0.9);
let mut fg = vals.to_vec();
fg.extend([
("cleanupForeground/cleanupForeground3/path", Value::Enum(5)),
("cleanupForeground/cleanupForeground3/brushRadius", num(1.5)),
("cleanupForeground/cleanupForeground3/brushPressure", num(100.0)),
]);
let painted = run_fx("ec.key.innerouter", &fg, img, 0.0, env).img;
assert!(painted.get(27, 5)[3] > 0.9 && painted.get(27, 2)[3] < 0.1);
}
}

View File

@ -126,6 +126,109 @@ fn correct(c: [f32; 3], sat: f32, contrast: f32, bright: f32) -> [f32; 3] {
c.map(|v| ((v - 0.5) * contrast + 0.5) * bright)
}
/// Colour Suppression choices.
const SUPPRESS: [&str; 7] = ["None", "Red", "Green", "Blue", "Cyan", "Magenta", "Yellow"];
/// Source Crops' X / Y Method choices.
const CROP_METHODS: [&str; 4] = ["Colour", "Repeat", "Reflect", "Wrap"];
/// (colour, balance 0..1, amount 0..1) of a Colour Suppression setting.
fn suppression(ctx: &EffectCtx, kind: &str, bal: &str, amt: &str) -> (u32, f32, f32) {
(ctx.params.e(kind), (ctx.params.get(bal).map(Value::as_f64).unwrap_or(50.0) / 100.0) as f32, (ctx.params.f(amt) / 100.0) as f32)
}
/// Colour Suppression: remove the excess of a primary (over a Balance-weighted mix of the other
/// two) or of a secondary (the smaller of its two primaries over the third).
fn suppress(mut c: [f32; 3], (kind, bal, amt): (u32, f32, f32)) -> [f32; 3] {
if kind == 0 || amt <= 0.0 {
return c;
}
match kind {
1..=3 => {
let k = kind as usize - 1;
let (a, b) = ((k + 1) % 3, (k + 2) % 3);
let limit = c[a] * bal + c[b] * (1.0 - bal);
c[k] -= (c[k] - limit).max(0.0) * amt;
}
_ => {
// Cyan = G + B over R, Magenta = R + B over G, Yellow = R + G over B.
let third = match kind {
4 => 0,
5 => 1,
_ => 2,
};
let (a, b) = ((third + 1) % 3, (third + 2) % 3);
let excess = (c[a].min(c[b]) - c[third]).max(0.0) * amt;
c[a] -= excess;
c[b] -= excess;
}
}
c
}
/// (tint offset) of a Colour Balancing wheel: hue (degrees) and saturation (0..1).
fn balance(ctx: &EffectCtx, hue: &str, sat: &str) -> [f32; 3] {
let s = (ctx.params.f(sat) / 100.0) as f32;
if s <= 0.0 {
return [0.0; 3];
}
let (r, g, b) = effectcraft_color::hsl_to_rgb((ctx.params.f(hue) / 360.0).rem_euclid(1.0) as f32, 1.0, 0.5);
let l = luminance(r, g, b);
[(r - l) * s * 0.5, (g - l) * s * 0.5, (b - l) * s * 0.5]
}
/// Colour Balancing: shift the colour towards the wheel's hue, keeping its luminance.
fn colour_balance(c: [f32; 3], tint: [f32; 3]) -> [f32; 3] {
if tint == [0.0; 3] {
return c;
}
[c[0] + tint[0], c[1] + tint[1], c[2] + tint[2]].map(|v| v.max(0.0))
}
/// The layer with Source Crops applied: inside the crop the picture, outside it the Edge Colour
/// (X / Y Method Colour) or the kept picture repeated, reflected or wrapped.
fn crop_source(ctx: &EffectCtx, b: &Buf) -> Image {
let pr = ctx.params;
let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0);
if crops.iter().all(|c| *c <= 0.0) {
return b.img.clone();
}
let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale);
let (x0, x1) = (b.offset[0] + crops[0] * lw, b.offset[0] + (1.0 - crops[1]) * lw);
let (y0, y1) = (b.offset[1] + crops[2] * lh, b.offset[1] + (1.0 - crops[3]) * lh);
let methods = [pr.e("sourceCrops/xMethod"), pr.e("sourceCrops/yMethod")];
let ec = pr.color("sourceCrops/edgeColour");
let ea = (pr.f("sourceCrops/edgeColourAlpha") / 100.0) as f32;
if methods == [0, 0] && ea <= 0.0 {
// Transparent edge colour: the borders are cut from the result instead (keying sees
// the whole picture).
return b.img.clone();
}
let edge = premul([ec[0], ec[1], ec[2]], ea);
// Fold a coordinate into [lo, hi) by a method; None = outside with Colour.
let fold = |v: f64, lo: f64, hi: f64, m: u32| -> Option<f64> {
let span = (hi - lo).max(1.0);
if v >= lo && v < hi {
return Some(v);
}
match m {
1 => Some(v.clamp(lo, hi - 0.5)),
2 => {
let t = (v - lo).rem_euclid(2.0 * span);
Some(lo + if t >= span { 2.0 * span - t - 0.5 } else { t })
}
3 => Some(lo + (v - lo).rem_euclid(span)),
_ => None,
}
};
crate::util::gen_image(b.img.width, b.img.height, |x, y| {
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
match (fold(fx, x0, x1, methods[0]), fold(fy, y0, y1, methods[1])) {
(Some(sx), Some(sy)) => b.img.get_clamped(sx.floor() as i64, sy.floor() as i64),
_ => edge,
}
})
}
fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let view = pr.e("view");
@ -151,7 +254,9 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
let despot_b = pr.f("screenMatte/screenDespotBlack") * b.scale;
let despot_w = pr.f("screenMatte/screenDespotWhite") * b.scale;
let src = b.img.clone();
// Source Crops: the cropped borders take the Edge Colour, or repeat / reflect / wrap the
// kept picture (X Method / Y Method), before keying.
let src = crop_source(ctx, &b);
let blurred;
let key_src: &Image = if preblur > 0.0 {
blurred = gaussian_blur(&src, preblur * 0.5, preblur * 0.5, true);
@ -247,6 +352,22 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
pr.f("edgeColourCorrection/edgeBrightness") as f32 / 100.0 + 1.0,
);
let unpremultiply = pr.b("unpremultiplyResult");
let (x_method, y_method) = (pr.e("sourceCrops/xMethod"), pr.e("sourceCrops/yMethod"));
let edge_alpha = pr.f("sourceCrops/edgeColourAlpha") as f32 / 100.0;
let fg_sup = suppression(
ctx,
"foregroundColourCorrection/colourSuppression",
"foregroundColourCorrection/suppressionBalance",
"foregroundColourCorrection/suppressionAmount",
);
let fg_bal = balance(ctx, "foregroundColourCorrection/colourBalanceHue", "foregroundColourCorrection/colourBalanceSaturation");
let edge_sup = suppression(
ctx,
"edgeColourCorrection/edgeColourSuppression",
"edgeColourCorrection/edgeSuppressionBalance",
"edgeColourCorrection/edgeSuppressionAmount",
);
let edge_bal = balance(ctx, "edgeColourCorrection/edgeColourBalanceHue", "edgeColourCorrection/edgeColourBalanceSaturation");
let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0);
let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]);
let w = b.img.width as usize;
@ -258,7 +379,7 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Source crops (layer fractions from each edge).
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let (lx, ly) = ((x - off[0]) / scale / lw.max(1e-9), (y - off[1]) / scale / lh.max(1e-9));
let cropped = lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3];
let cropped = (lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3]) && edge_alpha <= 0.0 && x_method == 0 && y_method == 0;
// Screen removal on the raw (unclipped) transparency, then despill.
let ar = raw.data[i].max(1e-4);
let mut fg = [0, 1, 2].map(|k| ((c[k] - (1.0 - ar) * screen[k]) / ar).max(0.0));
@ -287,11 +408,11 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
fg = apply_replace(fg, replace, rc, raised);
fg = apply_replace(fg, in_replace, in_rc, raised_in);
if fg_cc {
fg = correct(fg, fs, fc, fbr);
fg = colour_balance(suppress(correct(fg, fs, fc, fbr), fg_sup), fg_bal);
}
let edge = edges.as_ref().map_or(0.0, |e| e[i]);
if edge > 0.0 {
let ce = correct(fg, es, ec, ebr);
let ce = colour_balance(suppress(correct(fg, es, ec, ebr), edge_sup), edge_bal);
fg = [0, 1, 2].map(|k| fg[k] + (ce[k] - fg[k]) * edge);
}
let grey = |v: f32| [v, v, v, 1.0];
@ -380,6 +501,11 @@ pub fn specs() -> Vec<EffectSpec> {
p("foregroundColourCorrection/saturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
cc("foregroundColourCorrection/contrast", "Contrast"),
cc("foregroundColourCorrection/brightness", "Brightness"),
p("foregroundColourCorrection/colourSuppression", "Colour Suppression", Value::Enum(0), popup(&SUPPRESS)),
pp("foregroundColourCorrection/suppressionBalance", "Suppression Balance", pct(50.0)),
pp("foregroundColourCorrection/suppressionAmount", "Suppression Amount", pct(0.0)),
p("foregroundColourCorrection/colourBalanceHue", "Colour Balance Hue", num(0.0), ParamUi::Angle),
pp("foregroundColourCorrection/colourBalanceSaturation", "Colour Balance Saturation", pct(0.0)),
// Edge Colour Correction
p("edgeColourCorrection/enableEdgeColourCorrection", "Enable Edge Colour Correction", Value::Bool(false), ParamUi::Checkbox),
pp("edgeColourCorrection/edgeHardness", "Edge Hardness", pct(0.0)),
@ -388,7 +514,16 @@ pub fn specs() -> Vec<EffectSpec> {
p("edgeColourCorrection/edgeSaturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
cc("edgeColourCorrection/edgeContrast", "Contrast"),
cc("edgeColourCorrection/edgeBrightness", "Brightness"),
p("edgeColourCorrection/edgeColourSuppression", "Colour Suppression", Value::Enum(0), popup(&SUPPRESS)),
pp("edgeColourCorrection/edgeSuppressionBalance", "Suppression Balance", pct(50.0)),
pp("edgeColourCorrection/edgeSuppressionAmount", "Suppression Amount", pct(0.0)),
p("edgeColourCorrection/edgeColourBalanceHue", "Colour Balance Hue", num(0.0), ParamUi::Angle),
pp("edgeColourCorrection/edgeColourBalanceSaturation", "Colour Balance Saturation", pct(0.0)),
// Source Crops
p("sourceCrops/xMethod", "X Method", Value::Enum(0), popup(&CROP_METHODS)),
p("sourceCrops/yMethod", "Y Method", Value::Enum(0), popup(&CROP_METHODS)),
p("sourceCrops/edgeColour", "Edge Colour", col(0.0, 0.0, 0.0), ParamUi::Color),
pp("sourceCrops/edgeColourAlpha", "Edge Colour Alpha", pct(0.0)),
pp("sourceCrops/cropLeft", "Left", pct(0.0)),
pp("sourceCrops/cropRight", "Right", pct(0.0)),
pp("sourceCrops/cropTop", "Top", pct(0.0)),
@ -500,4 +635,44 @@ mod tests {
assert_eq!(g.sub("insideMask").and_then(|s| s.get("insideMask")).map(|p| p.name.as_str()), Some("Inside Mask"));
assert!(g.get("clipWhite").is_none());
}
#[test]
fn colour_suppression_balancing_and_crop_methods() {
// Suppress Red pulls red down to the balance of green and blue; Cyan the shared G+B.
assert!((suppress([0.9, 0.3, 0.1], (1, 0.5, 1.0))[0] - 0.2).abs() < 1e-6);
let c = suppress([0.1, 0.8, 0.6], (4, 0.5, 1.0));
assert!((c[1] - 0.3).abs() < 1e-6 && (c[2] - 0.1).abs() < 1e-6);
assert_eq!(suppress([0.9, 0.3, 0.1], (1, 0.5, 0.0)), [0.9, 0.3, 0.1]);
// Foreground colour correction with suppression / balancing changes the keyed colour.
let base = [("screenColour", green()), ("foregroundColourCorrection/enableColourCorrection", Value::Bool(true))];
let run = |extra: &[(&str, Value)]| {
let mut v: Vec<(&str, Value)> = base.to_vec();
v.extend_from_slice(extra);
run_fx("ec.keying.keylight", &v, plate(), 0.0, EffectEnv::default()).img.get(15, 15)
};
let plain = run(&[]);
let sup = run(&[("foregroundColourCorrection/colourSuppression", Value::Enum(1)), ("foregroundColourCorrection/suppressionAmount", num(100.0))]);
assert!(sup[0] < plain[0] - 0.1, "{sup:?} vs {plain:?}");
let bal = run(&[("foregroundColourCorrection/colourBalanceHue", num(240.0)), ("foregroundColourCorrection/colourBalanceSaturation", num(100.0))]);
assert!(bal[2] > plain[2] + 0.05);
// Source Crops: Colour fills the cropped border; Repeat copies the kept edge.
let crop = [("screenColour", green()), ("sourceCrops/cropLeft", num(10.0)), ("view", Value::Enum(1))];
let filled =
run_fx("ec.keying.keylight", &[crop.as_slice(), &[("sourceCrops/edgeColourAlpha", num(100.0))]].concat(), plate(), 0.0, EffectEnv::default()).img;
assert_eq!(filled.get(1, 15)[0], 1.0, "edge colour alpha shows in the source alpha view");
let src = crop_source(
&crate::EffectCtx {
params: &crate::Params {
values: [("sourceCrops/cropLeft".to_string(), num(25.0)), ("sourceCrops/xMethod".to_string(), Value::Enum(1))].into_iter().collect(),
},
time: 0.0,
layer_size: [40.0, 30.0],
seed: 0,
adjustment: false,
env: Default::default(),
},
&Buf { img: plate(), offset: [0.0; 2], scale: 1.0 },
);
assert_eq!(src.get(2, 15), plate().get(10, 15), "repeat");
}
}

View File

@ -10,6 +10,7 @@ pub mod audio_fx;
mod blur2;
mod blur3;
pub mod camera_tracker;
mod card3d;
pub mod catalog;
mod channel;
mod channel2;
@ -64,18 +65,22 @@ pub mod warp_stab;
use std::collections::HashMap;
use std::sync::OnceLock;
pub use color_fx::{exposure_settings, levels_clip};
pub use card3d::{CompLight, CompScene};
pub use color_fx::{
HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip,
};
pub use color2::Curve;
pub use distort::transform_shutter;
pub use distort2::parse_mesh;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
pub use effectcraft_raster::{AuxChannels, Image};
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS};
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_operation};
pub use sim::particle_state;
pub use sim3::playground_state;
// CPU helpers the GPU kernels share (effectcraft-gpu).
pub use blur2::camera_lens_spans;
pub use blur2::{camera_lens_plain, camera_lens_spans};
pub use generate::gen_mode;
pub use generate2::pattern_kind as cell_pattern_kind;
pub use noise::GrainLook;
@ -273,6 +278,16 @@ pub trait EffectHost: Sync {
fn aux(&self) -> Option<std::sync::Arc<AuxChannels>> {
None
}
/// The composition's camera and first light relative to the effect's layer (Card
/// Dance / Shatter / Card Wipe's Comp Camera and First Comp Light). `None` when unknown.
fn comp_scene(&self) -> Option<CompScene> {
None
}
/// The running effect's own parameters evaluated at another **layer time** (Radio Waves'
/// Parameters Are Set At: Birth). `None` when unavailable.
fn params_at(&self, _layer_time: f64) -> Option<Params> {
None
}
/// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the
/// CPU.
fn particles(&self) -> Option<&dyn psim::ParticleSim> {
@ -296,6 +311,10 @@ pub struct EffectEnv<'a> {
pub working_space: Option<effectcraft_color::ColorSpace>,
/// Working-space pixels are linear light (Linearize Working Space).
pub working_linear: bool,
/// The composition's shutter (angle and phase in degrees, samples per frame) when both the
/// comp's and the layer's motion blur switches are on (Transform's Use Composition's
/// Shutter Angle).
pub shutter: Option<(f64, f64, u32)>,
}
/// What an effect gets to render with.
@ -537,6 +556,19 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("toleranceGroup", "Tolerance"),
("inputPhase", "Input Phase"),
("outputCycle", "Output Cycle"),
("modify", "Modify"),
("pixelSelection", "Pixel Selection"),
("masking", "Masking"),
("details", "Details"),
("reds", "Reds"),
("yellows", "Yellows"),
("greens", "Greens"),
("cyans", "Cyans"),
("blues", "Blues"),
("magentas", "Magentas"),
("whites", "Whites"),
("neutrals", "Neutrals"),
("blacks", "Blacks"),
("moreOptions", "More Options"),
("basicCorrection", "Basic Correction"),
("whiteBalance", "White Balance"),
@ -546,9 +578,16 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("curves", "Curves"),
("colorWheels", "Color Wheels"),
("vignette", "Vignette"),
("rgbCurves", "RGB Curves"),
("hueSaturationCurves", "Hue Saturation Curves"),
("hslSecondary", "HSL Secondary"),
("key", "Key"),
("refine", "Refine"),
("correction", "Correction"),
// Camera Lens Blur.
("irisProperties", "Iris Properties"),
("highlight", "Highlight"),
("blurMap", "Blur Map"),
// Numbers.
("format", "Format"),
("fillAndStroke", "Fill and Stroke"),
@ -569,9 +608,15 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("fineTuning", "Fine Tuning"),
("unsharpMask", "Unsharp Mask"),
("noiseOptions", "Noise Options (Animation)"),
("previewRegion", "Preview Region"),
("sampling", "Sampling"),
("temporalFiltering", "Temporal Filtering"),
// Time: Timewarp.
("tuning", "Tuning"),
("motionBlur", "Motion Blur"),
("smoothing", "Smoothing"),
("weighting", "Weighting"),
("sourceCrops", "Source Crops"),
// Simulation.
("extras", "Extras"),
("wiggle", "Wiggle"),
@ -586,6 +631,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("hairColor", "Hair Color"),
("bottom", "Bottom"),
("water", "Water"),
("sky", "Sky"),
("lighting", "Lighting"),
("material", "Material"),
("shape", "Shape"),
@ -593,7 +639,12 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("force2", "Force 2"),
("bubbles", "Bubbles"),
("rendering", "Rendering"),
("flowMap", "Flow Map"),
("heightMapControls", "Height Map Controls"),
("wireframeControls", "Wireframe Controls"),
("ground", "Ground"),
("geometric", "Geometric Distortion"),
("target", "Target Point"),
("simulation", "Simulation"),
("producer1", "Producer 1"),
("producer2", "Producer 2"),
@ -606,6 +657,11 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("xScale", "X Scale"),
("yScale", "Y Scale"),
("cameraPosition", "Camera Position"),
("cornerPins", "Corner Pins"),
("positionJitter", "Position Jitter"),
("rotationJitter", "Rotation Jitter"),
("textures", "Textures"),
("gradient", "Gradient"),
("cannon", "Cannon"),
("grid", "Grid"),
("layerExploder", "Layer Exploder"),
@ -614,8 +670,36 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("repel", "Repel"),
("wall", "Wall"),
("persistentPropertyMapper", "Persistent Property Mapper"),
("ephemeralPropertyMapper", "Ephemeral Property Mapper"),
("particleExploder", "Particle Exploder"),
("affects", "Affects"),
("options", "Options"),
// Keying, Matte, Obsolete (Key Light's groups are in `keylight::GROUPS`).
("ultraSettings", "Ultra Settings"),
("additionalForeground", "Additional Foreground"),
("additionalBackground", "Additional Background"),
("cleanupForeground", "Cleanup Foreground"),
("cleanupBackground", "Cleanup Background"),
("cleanupForeground1", "Cleanup Foreground 1"),
("cleanupForeground2", "Cleanup Foreground 2"),
("cleanupForeground3", "Cleanup Foreground 3"),
("cleanupForeground4", "Cleanup Foreground 4"),
("cleanupForeground5", "Cleanup Foreground 5"),
("cleanupForeground6", "Cleanup Foreground 6"),
("cleanupForeground7", "Cleanup Foreground 7"),
("cleanupForeground8", "Cleanup Foreground 8"),
("cleanupForeground9", "Cleanup Foreground 9"),
("cleanupForeground10", "Cleanup Foreground 10"),
("cleanupBackground1", "Cleanup Background 1"),
("cleanupBackground2", "Cleanup Background 2"),
("cleanupBackground3", "Cleanup Background 3"),
("cleanupBackground4", "Cleanup Background 4"),
("cleanupBackground5", "Cleanup Background 5"),
("cleanupBackground6", "Cleanup Background 6"),
("cleanupBackground7", "Cleanup Background 7"),
("cleanupBackground8", "Cleanup Background 8"),
("cleanupBackground9", "Cleanup Background 9"),
("cleanupBackground10", "Cleanup Background 10"),
("fillAndStroke", "Fill and Stroke"),
("pathOptions", "Path Options"),
("controlPoints", "Control Points"),
@ -623,6 +707,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("orientation", "Orientation"),
("paragraph", "Paragraph"),
("preview", "Preview"),
("jitterSettings", "Jitter Settings"),
// Generate.
("positionsColors", "Positions & Colors"),
("feather", "Feather"),
@ -630,6 +715,8 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("polygon", "Polygon"),
("waveMotion", "Wave Motion"),
("waveStroke", "Stroke"),
("imageContour", "Image Contour"),
("waveMask", "Mask"),
("coreSettings", "Core Settings"),
("glowSettings", "Glow Settings"),
("expertSettings", "Expert Settings"),
@ -732,11 +819,15 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.stylize.strobe",
"ec.noise.noise",
"ec.noise.addgrain",
// Remove Grain's temporal filtering reads neighbouring frames.
"ec.noise.removegrain",
"ec.noise.noisealpha",
"ec.noise.noisehlsauto",
"ec.obsolete.lightning",
"ec.text.timecode",
"ec.text.numbers",
// Path Text's jitter changes every frame.
"ec.obsolete.pathtext",
// Time effects read neighbouring frames of the layer.
"ec.time.echo",
"ec.time.posterizetime",
@ -746,6 +837,14 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.time.ccforcemotionblur",
"ec.time.ccwidetime",
"ec.time.pixelmotionblur",
// Refine mattes' Reduce Chatter / motion blur read neighbouring frames.
"ec.matte.refinesoft",
"ec.matte.refinehard",
// Card Wipe's position / rotation jitter moves with time.
"ec.transition.cardwipe",
// Transform's motion blur and Radio Waves' birth parameters read the parameters at
// other times.
"ec.distort.transform",
// Temporal Smoothing reads neighbouring frames.
"ec.color.autolevels",
"ec.color.autocontrast",
@ -780,7 +879,8 @@ pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf {
#[cfg(test)]
pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf {
let s = find(id).unwrap_or_else(|| panic!("no effect {id}"));
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
let size = [img.width as f64, img.height as f64];
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), default_value(p, size))).collect() };
for (k, v) in vals {
assert!(params.values.contains_key(*k), "{id}: unknown param {k}");
params.values.insert(k.to_string(), v.clone());

View File

@ -93,38 +93,91 @@ fn refine(ctx: &EffectCtx, mut b: Buf, hard: bool) -> Buf {
let hi = morph_plane(&a, r, r, true);
let lo = morph_plane(&a, r, r, false);
let band = |i: usize| hi.data[i] - lo.data[i] > 1e-3;
let mut na = Plane::new(a.w, a.h);
if edge_details {
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);
na.data.par_iter_mut().enumerate().for_each(|(i, v)| {
*v = if band(i) { filtered.data[i].clamp(0.0, 1.0) } else { a.data[i] };
});
} else {
na = a.clone();
if smooth > 0.0 {
let s = smooth as f64 * r as f64 * 0.5;
na = gauss_plane(&na, s, s);
}
}
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)
// The refined matte of a frame.
let refine_alpha = |img: &effectcraft_raster::Image| -> Plane {
let a = Plane::alpha(img);
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);
if edge_details {
let guide = Plane::from_image(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);
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] };
});
} else {
v
};
if (contrast - 1.0).abs() > 1e-6 {
v = (v - 0.5) * contrast + 0.5;
na = a.clone();
if smooth > 0.0 {
let s = smooth as f64 * r as f64 * 0.5;
na = gauss_plane(&na, s, s);
}
}
let v = v.clamp(0.0, 1.0);
if invert { 1.0 - v } else { v }
});
if feather > 0.0 {
na = gauss_plane(&na, feather, feather);
}
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 }
})
};
let mut na = refine_alpha(&b.img);
// Neighbouring frames of this effect's input (same pixel grid), refined the same way.
let fps = ctx.fps();
let frame_at = |t: f64| -> Option<Plane> {
let o = ctx.env.host?.self_at(t, ctx.env.effect_index)?;
(o.img.width == b.img.width && o.img.height == b.img.height && (o.offset[0] - b.offset[0]).abs() < 1e-6 && (o.offset[1] - b.offset[1]).abs() < 1e-6)
.then(|| refine_alpha(&o.img))
};
// Reduce Chatter: where the previous and next frames agree but this one differs, the matte
// is chattering; average the three there. Where they disagree (motion) it is left alone.
let chatter = (pr.f("reduceChatter") / 100.0).clamp(0.0, 1.0) as f32;
if chatter > 0.0 {
let prev = frame_at(ctx.time - 1.0 / fps);
let next = frame_at(ctx.time + 1.0 / fps);
if let (Some(p), Some(n)) = (&prev, &next) {
na.data.par_iter_mut().enumerate().for_each(|(i, v)| {
let (c, a, z) = (*v, p.data[i], n.data[i]);
let steady = (1.0 - (a - z).abs() / 0.5).clamp(0.0, 1.0);
*v = c + ((a + c + z) / 3.0 - c) * chatter * steady;
});
}
}
// Motion blur: the matte averaged over the shutter (centred on the frame).
if pr.b("useMotionBlur") {
let g = |id: &str| pr.get(&format!("motionBlur/{id}")).map(Value::as_f64);
let samples = g("motionBlurSamples").unwrap_or(16.0).clamp(1.0, 64.0) as usize;
let samples = if pr.b("motionBlur/higherQuality") { samples * 2 } else { samples }.min(64);
let span = g("shutterAngle").unwrap_or(180.0).clamp(0.0, 720.0) / 360.0 / fps;
if samples > 1 && span > 0.0 {
let mut acc = na.clone();
let mut n = 1.0f32;
for k in 0..samples {
let t = ctx.time - span * 0.5 + span * k as f64 / (samples - 1) as f64;
if (t - ctx.time).abs() < 1e-9 {
continue;
}
if let Some(p) = frame_at(t) {
acc.data.iter_mut().zip(&p.data).for_each(|(a, q)| *a += q);
n += 1.0;
}
}
if n > 1.0 {
acc.data.iter_mut().for_each(|a| *a /= n);
na = acc;
}
}
}
if view_edges {
// The refined matte in grey with the analysed edge band tinted.
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
@ -190,6 +243,16 @@ pub fn specs() -> Vec<EffectSpec> {
check(pre[3], "View Decontamination Map", false),
]
};
// Reduce Chatter and the motion-blur settings.
let temporal = || {
vec![
p("reduceChatter", "Reduce Chatter", num(0.0), pct0()),
check("useMotionBlur", "Use Motion Blur", false),
p("motionBlur/motionBlurSamples", "Motion Blur Samples", num(16.0), slider(1.0, 64.0, 1.0, 64.0, 0)),
p("motionBlur/shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)),
check("motionBlur/higherQuality", "Higher Quality", false),
]
};
let radius = |d: f64| p("radius", "Additional Edge Radius", num(d), slider(1.0, 200.0, 1.0, 50.0, 1));
let mut soft_params = vec![
check("calculateEdgeDetails", "Calculate Edge Details", true),
@ -199,8 +262,9 @@ pub fn specs() -> Vec<EffectSpec> {
p("feather", "Feather", num(10.0), pct0()),
p("contrast", "Contrast", num(0.0), pct0()),
p("shiftEdge", "Shift Edge", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)),
check("decontaminate", "Decontaminate Edge Colors", true),
];
soft_params.extend(temporal());
soft_params.push(check("decontaminate", "Decontaminate Edge Colors", true));
soft_params.extend(decon(&[
"decontamination/decontaminationAmount",
"decontamination/extendWhereSmoothed",
@ -214,8 +278,9 @@ pub fn specs() -> Vec<EffectSpec> {
p("smooth", "Smooth", num(20.0), pct0()),
p("feather", "Feather", num(0.0), pct0()),
p("choke", "Choke", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)),
check("decontaminate", "Decontaminate Edge Colors", true),
];
hard_params.extend(temporal());
hard_params.push(check("decontaminate", "Decontaminate Edge Colors", true));
hard_params.extend(decon(&["decontaminationAmount", "extendWhereSmoothed", "increaseDecontaminationRadius", "viewDecontaminationMap"]));
// Our extras: the analysed edge band's radius, alpha contrast and inversion.
hard_params.push(radius(3.0));
@ -345,4 +410,63 @@ mod tests {
assert!(out.get(0, 0)[3] < 0.01);
assert!(out.data.iter().all(|p| (0.0..=1.0).contains(&p[3])));
}
/// A white square whose left edge sits at x = 10 + `jitter` × (frame parity) + `speed` × frame.
struct Edge {
jitter: i64,
speed: i64,
}
fn edge(x0: i64) -> Image {
let mut img = Image::new(40, 20);
for y in 4..16 {
for x in x0.max(0)..(x0 + 16).min(40) {
img.set(x as u32, y, [1.0, 1.0, 1.0, 1.0]);
}
}
img
}
impl crate::EffectHost for Edge {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
let f = (t * 10.0).round() as i64;
Some(Buf { img: edge(10 + self.jitter * (f & 1) + self.speed * f), offset: [0.0; 2], scale: 1.0 })
}
}
fn run_t(id: &str, over: &[(&str, Value)], t: f64, host: &Edge) -> Image {
let f = (t * 10.0).round() as i64;
let img = edge(10 + host.jitter * (f & 1) + host.speed * f);
let env = crate::EffectEnv { host: Some(host), frame_rate: 10.0, ..Default::default() };
crate::run_fx(id, over, img, t, env).img
}
#[test]
fn refine_mattes_reduce_chatter_and_motion_blur() {
for id in ["ec.matte.refinesoft", "ec.matte.refinehard"] {
let jitter = Edge { jitter: 1, speed: 0 };
let base = [("feather", num(0.0)), ("smooth", num(0.0)), ("decontaminate", Value::Bool(false))];
let with = |extra: &[(&str, Value)], t: f64, h: &Edge| {
let mut v: Vec<(&str, Value)> = base.to_vec();
v.extend_from_slice(extra);
run_t(id, &v, t, h)
};
let diff = |a: &Image, b: &Image| a.data.iter().zip(&b.data).map(|(p, q)| (p[3] - q[3]).abs()).sum::<f32>();
let (a0, a1) = (with(&[], 1.0, &jitter), with(&[], 1.1, &jitter));
let (c0, c1) = (with(&[("reduceChatter", num(100.0))], 1.0, &jitter), with(&[("reduceChatter", num(100.0))], 1.1, &jitter));
assert!(diff(&c0, &c1) < diff(&a0, &a1) * 0.7, "{id}: chatter {} vs {}", diff(&c0, &c1), diff(&a0, &a1));
// Motion blur smears a moving matte edge along the motion.
let moving = Edge { jitter: 0, speed: 4 };
let mb = with(&[("useMotionBlur", Value::Bool(true)), ("motionBlur/shutterAngle", num(360.0))], 0.3, &moving);
let sharp = with(&[], 0.3, &moving);
let partial = |img: &Image| img.data.iter().filter(|p| p[3] > 0.05 && p[3] < 0.95).count();
assert!(partial(&mb) > partial(&sharp) + 10, "{id}: {} vs {}", partial(&mb), partial(&sharp));
// Without a host both are inert.
assert_eq!(run(id, &[("reduceChatter", num(100.0)), ("useMotionBlur", Value::Bool(true))], edge(10)), run(id, &[], edge(10)));
}
}
}

View File

@ -38,6 +38,10 @@ pub const PARAM_ID_ALIASES: &[(&str, &str, &str)] = &[
("ec.matte.rotobrush", "refineEdgeMatte/extendWhereSmoothed", "refineEdgeMatte/decontamination/extendWhereSmoothed"),
("ec.matte.rotobrush", "refineEdgeMatte/increaseDecontaminationRadius", "refineEdgeMatte/decontamination/increaseDecontaminationRadius"),
("ec.matte.rotobrush", "refineEdgeMatte/viewDecontaminationMap", "refineEdgeMatte/decontamination/viewDecontaminationMap"),
// Keying: Inner/Outer Key's single additional masks became Additional Foreground /
// Background 1 (of 10).
("ec.key.innerouter", "additionalForeground", "additionalForeground/foreground1"),
("ec.key.innerouter", "additionalBackground", "additionalBackground/background1"),
// Generate: Radio Waves' growth speed was called Velocity; Velocity now moves the wave.
("ec.generate.radiowaves", "velocity", "waveMotion/expansion"),
];
@ -153,6 +157,10 @@ pub fn legacy_default(effect: &str, param: &str) -> Option<Value> {
("ec.noise.removegrain", "noiseReductionSettings/mode") => Value::Enum(1),
// Audio: the Compressor's release was always manual.
("ec.audio.compressor", "autoRelease") => Value::Bool(false),
// Keying: Advanced Spill Suppressor's Ultra mode had no hue tolerance.
("ec.key.advancedspill", "ultraSettings/tolerance") => Value::Scalar(100.0),
// Transition: Block Dissolve's blocks had hard edges.
("ec.transition.blockdissolve", "softEdges") => Value::Bool(false),
_ => return None,
})
}
@ -242,6 +250,18 @@ fn is_numeric_ui(u: &ParamUi) -> bool {
/// point defaults of added parameters. Returns whether anything changed.
pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2]) -> bool {
let before = g.clone();
// Card Wipe's Back Layer was a None / Self popup; it is a layer parameter now, with "Self"
// kept in the hidden Back Layer Is Self switch.
let mut back_self = None;
if spec.id == "ec.transition.cardwipe"
&& let Some(pr) = g.get_mut("backLayer")
&& matches!(pr.ui, ParamUi::Popup { .. })
{
back_self = Some(pr.value.as_enum() == 1);
pr.value = Value::Layer(None);
pr.keys.clear();
pr.ui = ParamUi::Layer;
}
for ps in &spec.params {
// Locate the stored property: at its spec path, under an old id, or at top level
// before it moved into a twirl-down group.
@ -296,6 +316,11 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
}
}
}
if let Some(on) = back_self
&& let Some(pr) = g.get_mut("backSelf")
{
pr.value = Value::Bool(on);
}
// Refresh twirl-down group names.
fn names(g: &mut PropGroup) {
for c in &mut g.children {
@ -368,4 +393,78 @@ mod tests {
assert!(g.get("smoothness").is_none());
assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0));
}
#[test]
fn card_wipe_back_layer_popup_becomes_a_layer_parameter() {
let spec = find("ec.transition.cardwipe").unwrap();
let mut next = 1;
for (old, self_) in [(1, true), (0, false)] {
let mut g = instantiate(spec, &mut Ids(&mut next), "Card Wipe", [100.0, 50.0]);
take_prop(&mut g, "backSelf");
let pr = g.get_mut("backLayer").unwrap();
pr.ui = ParamUi::Popup { options: vec!["None".into(), "Self".into()] };
pr.value = Value::Enum(old);
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert_eq!(g.get("backLayer").unwrap().value, Value::Layer(None));
assert_eq!(g.get("backLayer").unwrap().ui, ParamUi::Layer);
assert_eq!(g.get("backSelf").unwrap().value, Value::Bool(self_));
}
}
#[test]
fn particle_playground_mapper_targets_remap_by_label() {
let spec = find("ec.sim.particleplayground").unwrap();
let mut next = 1;
let mut g = instantiate(spec, &mut Ids(&mut next), "Particle Playground", [100.0, 50.0]);
let old: Vec<String> =
["None", "Red", "Green", "Blue", "Kinetic Friction", "Scale", "X", "Y", "X Speed", "Y Speed", "X Force", "Y Force", "Opacity", "Mass"]
.iter()
.map(|s| s.to_string())
.collect();
let pr = prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap();
pr.ui = ParamUi::Popup { options: old };
pr.value = Value::Enum(8);
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert_eq!(prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap().value, Value::Enum(15), "X Speed");
}
#[test]
fn selective_color_ranges_move_into_details() {
let spec = find("ec.color.selectivecolor").unwrap();
let mut next = 1;
let mut g = instantiate(spec, &mut Ids(&mut next), "Selective Color", [100.0, 50.0]);
let mut pr = take_at(&mut g, "details/reds/redsCyan").unwrap();
pr.value = Value::Scalar(40.0);
g.children.push(pr.into());
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert!(g.get("redsCyan").is_none());
assert_eq!(prop_at(&mut g, "details/reds/redsCyan").unwrap().value, Value::Scalar(40.0));
assert_eq!(g.sub("details").unwrap().sub("reds").unwrap().name, "Reds");
}
#[test]
fn inner_outer_key_additional_masks_move_into_their_groups() {
let spec = find("ec.key.innerouter").unwrap();
let mut next = 1;
let mut g = instantiate(spec, &mut Ids(&mut next), "Inner/Outer Key", [100.0, 50.0]);
let mut pr = take_at(&mut g, "additionalForeground/foreground1").unwrap();
pr.match_id = "additionalForeground".into();
pr.value = Value::Enum(3);
g.children.push(pr.into());
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert!(g.get("additionalForeground").is_none());
assert_eq!(prop_at(&mut g, "additionalForeground/foreground1").unwrap().value, Value::Enum(3));
}
#[test]
fn match_grain_viewing_modes_remap_by_label() {
let spec = find("ec.noise.matchgrain").unwrap();
let mut next = 1;
let mut g = instantiate(spec, &mut Ids(&mut next), "Match Grain", [100.0, 50.0]);
let pr = g.get_mut("viewingMode").unwrap();
pr.ui = ParamUi::Popup { options: vec!["Final Output".into(), "Noise Samples".into(), "Blending Matte".into()] };
pr.value = Value::Enum(0);
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert_eq!(g.get("viewingMode").unwrap().value, Value::Enum(4), "Final Output");
}
}

View File

@ -120,12 +120,48 @@ pub(crate) fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32)
t.max(0.0).powf(0.5f32.ln() / m.ln())
}
/// Glow Operation options (blend modes the glow is applied with).
pub const GLOW_OPERATIONS: [&str; 17] = [
"Normal",
"Add",
"Multiply",
"Screen",
"Overlay",
"Soft Light",
"Hard Light",
"Color Dodge",
"Color Burn",
"Darken",
"Lighten",
"Difference",
"Exclusion",
"Hue",
"Saturation",
"Color",
"Luminosity",
];
/// Glow's Glow Operation as a blend mode (Add for projects saved before it existed).
pub fn glow_operation(ctx: &EffectCtx) -> effectcraft_color::BlendMode {
let name = GLOW_OPERATIONS.get(ctx.params.get("glowOperation").map(|v| v.as_enum()).unwrap_or(1) as usize).copied().unwrap_or("Add");
effectcraft_color::BlendMode::ALL.iter().copied().find(|m| m.label() == name).unwrap_or(effectcraft_color::BlendMode::Add)
}
/// Glow's Arbitrary Map: red, green and blue curves of the glow brightness, as Curves point
/// lists `x,y …` separated by `|` (an empty or missing curve is the identity).
fn glow_map(s: &str) -> Vec<Option<crate::Curve>> {
let mut parts = s.split('|');
(0..3).map(|_| parts.next().and_then(crate::Curve::parse)).collect()
}
fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
let thr = ctx.params.f("threshold") as f32 / 100.0;
let radius = ctx.params.f("radius") * b.scale;
let intensity = ctx.params.f("intensity") as f32;
let alpha_based = ctx.params.e("based") == 0;
let use_colors = ctx.params.e("colors") == 1;
let arbitrary = (ctx.params.e("colors") == 2).then(|| glow_map(ctx.params.s("arbitraryMap")));
let glow_op = glow_operation(ctx);
let looping = ctx.params.e("colorLooping");
let loops = ctx.params.f("colorLoops") as f32;
let phase = (ctx.params.f("colorPhase") / 360.0) as f32;
@ -147,7 +183,11 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Glow Based On: brightness from the colours, or from the alpha channel.
let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) };
let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0);
if use_colors {
if let Some(map) = &arbitrary {
let t = glow_ab_t(l, looping, loops, phase, mid);
let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::<Vec<f32>>();
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
} else if use_colors {
let t = glow_ab_t(l, looping, loops, phase, mid);
let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t];
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
@ -170,6 +210,8 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
// None: the glow alone.
2 => *o = [g[0], g[1], g[2], g[3]],
// On Top with another Glow Operation: that blend mode.
_ if glow_op != effectcraft_color::BlendMode::Add => *o = effectcraft_color::blend_pixel(glow_op, *o, g, 0.0),
// On Top: add (screen-like add on premultiplied colour; alpha grows by the glow's alpha).
_ => {
for c in 0..3 {
@ -571,7 +613,10 @@ pub fn specs() -> Vec<EffectSpec> {
p("radius", "Glow Radius", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("intensity", "Glow Intensity", num(1.0), slider(0.0, 255.0, 0.0, 4.0, 1)),
p("operation", "Composite Original", Value::Enum(0), popup(&["On Top", "Behind", "None"])),
p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors"])),
p("glowOperation", "Glow Operation", Value::Enum(1), popup(&GLOW_OPERATIONS)),
p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors", "Arbitrary Map"])),
// Arbitrary Map: "red points | green points | blue points" (Curves format).
p("arbitraryMap", "Arbitrary Map", Value::Str(String::new()), ParamUi::Hidden),
p("colorLooping", "Color Looping", Value::Enum(2), popup(&["Sawtooth A>B", "Sawtooth B>A", "Triangle A>B>A", "Triangle B>A>B"])),
p("colorLoops", "Color Loops", num(1.0), slider(1.0, 127.0, 1.0, 10.0, 1)),
p("colorPhase", "Color Phase", num(0.0), ParamUi::Angle),
@ -687,3 +732,52 @@ pub fn specs() -> Vec<EffectSpec> {
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
/// A bright square on mid grey.
fn scene() -> effectcraft_raster::Image {
let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]);
for y in 8..16 {
for x in 8..16 {
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
img
}
#[test]
fn glow_operation_and_arbitrary_map() {
// Pixels addressed in layer space (Glow pads its buffer).
struct Out(Buf);
impl Out {
fn get(&self, x: i64, y: i64) -> [f32; 4] {
self.0.img.get(x + self.0.offset[0] as i64, y + self.0.offset[1] as i64)
}
}
let run = |vals: &[(&str, Value)]| Out(run_fx("ec.stylize.glow", vals, scene(), 0.0, EffectEnv::default()));
let add = run(&[]);
assert_eq!(GLOW_OPERATIONS[1], "Add");
// Multiply darkens around the square where Add brightens.
let mul = run(&[("glowOperation", Value::Enum(2))]);
assert!(add.get(6, 12)[0] > 0.31 && mul.get(6, 12)[0] <= 0.3 + 1e-4, "{:?} {:?}", add.get(6, 12), mul.get(6, 12));
assert_eq!(
glow_operation(&EffectCtx {
params: &crate::Params::default(),
time: 0.0,
layer_size: [1.0; 2],
seed: 0,
adjustment: false,
env: Default::default()
}),
effectcraft_color::BlendMode::Add
);
// Arbitrary Map: the glow takes the mapped colour (here pure red for every brightness).
let red = run(&[("colors", Value::Enum(2)), ("arbitraryMap", Value::Str("0,1 1,1|0,0 1,0|0,0 1,0".into())), ("operation", Value::Enum(2))]);
let p = red.get(6, 12);
assert!(p[0] > 0.05 && p[1] < 1e-4 && p[2] < 1e-4, "{p:?}");
}
}

View File

@ -59,6 +59,8 @@ pub struct GrainLook {
/// Grain time (frames of grain at the animation speed) and whether it interpolates.
pub frame: f32,
pub seed: u32,
/// Color ▸ Red / Green / Blue Balance.
pub balance: [f32; 3],
}
impl GrainLook {
@ -84,7 +86,28 @@ impl GrainLook {
midpoint: pr.get("application/midpoint").map(Value::as_f64).unwrap_or(0.5).clamp(0.01, 0.99) as f32,
frame: if smooth && ft.fract() != 0.0 { ft as f32 } else { ft.floor() as f32 },
seed: (pr.f("animation/randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37),
balance: ch("color/", "redBalance", "greenBalance", "blueBalance"),
}
.with_preset(pr.e("preset"))
}
/// Apply Add Grain's Preset (a film-stock look on top of the controls).
fn with_preset(mut self, preset: u32) -> GrainLook {
// (intensity ×, size ×, softness +, saturation ×, monochrome)
let (ki, ks, soft, ksat, mono) = match preset {
1 => (0.6, 0.7, 0.2, 0.6, false),
2 => (1.3, 1.8, 0.4, 0.8, false),
3 => (1.8, 2.5, 0.8, 0.5, false),
4 => (1.0, 0.6, 0.0, 1.0, false),
5 => (1.6, 1.4, 0.3, 1.0, true),
_ => return self,
};
self.intensity *= ki;
self.size *= ks;
self.softness += soft;
self.saturation *= ksat;
self.mono |= mono;
self
}
/// Zero-mean noise planes (one, or one per channel) of grain `size` pixels (times each
@ -129,7 +152,7 @@ impl GrainLook {
let l = luminance(self.tint[0], self.tint[1], self.tint[2]).max(1e-3);
g = [0, 1, 2].map(|k| g[k] * ((1.0 - self.tint_amount) + self.tint_amount * self.tint[k] / l));
}
g
[g[0] * self.balance[0], g[1] * self.balance[1], g[2] * self.balance[2]]
}
/// Combine source channel `c` with signed grain `n` by the Blending Mode.
@ -171,6 +194,9 @@ pub(crate) fn grain_params() -> (Vec<crate::ParamSpec>, Vec<crate::ParamSpec>, V
p("color/saturation", "Saturation", num(1.0), mult()),
p("color/tintAmount", "Tint Amount", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("color/tintColor", "Tint Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color),
p("color/redBalance", "Red Balance", num(1.0), mult()),
p("color/greenBalance", "Green Balance", num(1.0), mult()),
p("color/blueBalance", "Blue Balance", num(1.0), mult()),
];
let application = vec![
p("application/blendingMode", "Blending Mode", Value::Enum(0), popup(&GRAIN_BLEND_MODES)),
@ -187,27 +213,124 @@ pub(crate) fn grain_params() -> (Vec<crate::ParamSpec>, Vec<crate::ParamSpec>, V
(tweaking, color, application, animation)
}
/// Add Grain's Viewing Mode options.
pub(crate) const ADD_GRAIN_VIEWS: [&str; 3] = ["Preview", "Blending Matte", "Final Output"];
/// Add Grain's Preset options (our own film-stock looks).
pub(crate) const GRAIN_PRESETS: [&str; 6] = ["None", "Fine 35mm", "Coarse 16mm", "Vintage 8mm", "Video Noise", "Monochrome High Speed"];
/// The Preview Region controls of the grain effects (centre, size, box display).
pub(crate) fn preview_params() -> Vec<crate::ParamSpec> {
vec![
p("previewRegion/center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
p("previewRegion/width", "Width", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)),
p("previewRegion/height", "Height", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)),
p("previewRegion/showBox", "Show Box", Value::Bool(true), ParamUi::Checkbox),
p("previewRegion/boxColor", "Box Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color),
]
}
/// Preview viewing mode: the processed pixels inside the Preview Region, the original
/// outside, and (Show Box) the region's outline.
pub(crate) fn preview_compose(ctx: &EffectCtx, b: &mut Buf, orig: &Image) {
let c = b.to_px(ctx.params.v2("previewRegion/center"));
let (hw, hh) = (ctx.params.f("previewRegion/width") * b.scale * 0.5, ctx.params.f("previewRegion/height") * b.scale * 0.5);
let (x0, x1, y0, y1) = (c.0 - hw, c.0 + hw, c.1 - hh, c.1 + hh);
let show = ctx.params.b("previewRegion/showBox");
let col = ctx.params.color("previewRegion/boxColor");
let w = b.img.width as usize;
b.img.data.par_iter_mut().zip(orig.data.par_iter()).enumerate().for_each(|(i, (px, o))| {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let inside = x >= x0 && x < x1 && y >= y0 && y < y1;
if !inside {
*px = *o;
}
if show {
let near = |v: f64, e: f64| (v - e).abs() < 0.75;
let on = ((near(x, x0) || near(x, x1)) && y >= y0 - 0.75 && y < y1 + 0.75) || ((near(y, y0) || near(y, y1)) && x >= x0 - 0.75 && x < x1 + 0.75);
if on {
*px = [col[0], col[1], col[2], 1.0];
}
}
});
}
/// Boxes (x, y, size in buffer px) where grain is sampled: the flattest of a grid of
/// candidate boxes (real grain shows best where the picture has no detail).
pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize, usize, usize)> {
let (w, h) = (img.width as usize, img.height as usize);
let size = size.clamp(3, w.min(h).max(3));
if w < size || h < size || count == 0 {
return vec![];
}
let low = Plane::luma(img);
let step = (size / 2).max(1);
let mut cands: Vec<(f32, usize, usize)> = Vec::new();
for y in (0..=h - size).step_by(step) {
for x in (0..=w - size).step_by(step) {
// Variance of the picture inside the box (the flattest boxes hold the least detail).
let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true);
for yy in y..y + size {
for xx in x..x + size {
let v = low.data[yy * w + xx];
s += v;
sq += v * v;
n += 1.0;
opaque &= img.data[yy * w + xx][3] > 0.5;
}
}
if opaque {
cands.push((sq / n - (s / n).powi(2), x, y));
}
}
}
cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1)));
cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect()
}
/// Draw sample boxes' outlines (Noise Samples viewing mode).
pub(crate) fn draw_boxes(img: &mut Image, boxes: &[(usize, usize, usize)], c: [f32; 4]) {
for &(x, y, s) in boxes {
for k in 0..s {
for (px, py) in [(x + k, y), (x + k, y + s - 1), (x, y + k), (x + s - 1, y + k)] {
img.set(px as u32, py as u32, [c[0], c[1], c[2], 1.0]);
}
}
}
}
// ---- Add Grain ----
fn add_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
let look = GrainLook::from_params(ctx);
if look.intensity <= 0.0 {
// Viewing Mode (Final Output for projects saved before it existed).
let view = ctx.params.get("viewingMode").map(|v| v.as_enum()).unwrap_or(2);
if look.intensity <= 0.0 && view != 1 {
return b;
}
let orig = b.img.clone();
let (w, h) = (b.img.width as usize, b.img.height as usize);
let planes = look.planes(w, h, look.size * b.scale as f32, b.scale, 101);
let amp = look.intensity * 0.25;
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (c, a) = unpremul(*px);
let weight = look.weight(c);
if view == 1 {
// Blending Matte: where (and how strongly) the grain applies.
let v = weight.clamp(0.0, 1.0);
*px = [v, v, v, 1.0];
return;
}
if a <= 0.0 {
return;
}
let raw = if look.mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] };
let g = look.color(raw);
let weight = look.weight(c);
let o = [0, 1, 2].map(|k| look.blend(c[k], g[k] * amp * look.channel_intensity[k] * weight));
*px = premul(o, a);
});
if view == 0 {
preview_compose(ctx, &mut b, &orig);
}
b
}
@ -333,18 +456,104 @@ fn dust_scratches(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Remove Grain's Viewing Mode options.
pub(crate) const REMOVE_GRAIN_VIEWS: [&str; 4] = ["Preview", "Noise Samples", "Blending Matte", "Final Output"];
/// Grain sampling controls (Match Grain, Remove Grain): how many flat boxes of what size the
/// grain is measured in.
pub(crate) fn sampling_params() -> Vec<crate::ParamSpec> {
vec![
p("sampling/samplingProcess", "Sampling Process", Value::Enum(0), popup(&["Automatic"])),
p("sampling/numberOfSamples", "Number of Samples", num(80.0), slider(1.0, 500.0, 1.0, 200.0, 0)),
p("sampling/sampleSize", "Sample Size", num(9.0), slider(3.0, 64.0, 3.0, 32.0, 0)),
p("sampling/sampleBoxColor", "Sample Box Color", crate::col(1.0, 1.0, 0.0), ParamUi::Color),
]
}
/// The sample boxes for the effect's Sampling settings.
pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(usize, usize, usize)> {
let n = ctx.params.get("sampling/numberOfSamples").map(Value::as_f64).unwrap_or(80.0).clamp(1.0, 500.0) as usize;
let s = (ctx.params.get("sampling/sampleSize").map(Value::as_f64).unwrap_or(9.0) * scale).round().max(3.0) as usize;
sample_boxes(img, n, s)
}
fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let amt = pr.f("noiseReductionSettings/noiseReduction") as f32;
let um_amount = pr.f("unsharpMask/amount") as f32 / 100.0;
if amt <= 0.0 && um_amount <= 0.0 {
let view = pr.get("viewingMode").map(|v| v.as_enum()).unwrap_or(3);
let boxes = sampling_boxes(ctx, &b.img, b.scale);
if view == 1 {
let c = pr.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]);
draw_boxes(&mut b.img, &boxes, c);
return b;
}
if view == 2 {
b.img.data.iter_mut().for_each(|p| *p = [1.0, 1.0, 1.0, 1.0]);
return b;
}
let temporal = pr.b("temporalFiltering/enabled");
if amt <= 0.0 && um_amount <= 0.0 && !temporal {
return b;
}
let before = b.img.clone();
// Temporal Filtering: steady pixels average with the neighbouring frames (grain changes
// every frame, the picture does not); Motion Sensitivity keeps moving areas out of it.
if temporal {
let fps = ctx.fps();
let host = ctx.env.host;
let at = |t: f64| host.and_then(|h| h.self_at(t, ctx.env.effect_index)).filter(|o| o.img.width == b.img.width && o.img.height == b.img.height);
let (prev, next) = (at(ctx.time - 1.0 / fps), at(ctx.time + 1.0 / fps));
let k = (pr.get("temporalFiltering/amount").map(Value::as_f64).unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
let sens = pr.get("temporalFiltering/motionSensitivity").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0) as f32;
let thr = 0.02 + (1.0 - sens) * 0.2;
if prev.is_some() || next.is_some() {
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let mut acc = *px;
let mut n = 1.0;
for o in [&prev, &next].into_iter().flatten() {
let q = o.img.data[i];
let d = (0..3).map(|c| (q[c] - px[c]).abs()).fold(0.0f32, f32::max);
if d < thr {
let wgt = 1.0 - d / thr;
for c in 0..4 {
acc[c] += q[c] * wgt;
}
n += wgt;
}
}
for c in 0..4 {
px[c] += (acc[c] / n - px[c]) * k;
}
});
}
if amt <= 0.0 && um_amount <= 0.0 {
if view == 0 {
preview_compose(ctx, &mut b, &before);
}
return b;
}
}
// The grain level measured in the sample boxes scales the smoothing.
let level = {
let mut m = vec![false; b.img.data.len()];
let w = b.img.width as usize;
for &(x, y, s) in &boxes {
for yy in y..y + s {
for xx in x..x + s {
m[yy * w + xx] = true;
}
}
}
let st = crate::noise2::grain_stats_masked(&b.img, Some(&m));
let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0;
if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) }
};
let passes = pr.f("noiseReductionSettings/passes").round().clamp(1.0, 8.0) as usize;
let multichannel = pr.e("noiseReductionSettings/mode") == 0;
let texture = (pr.f("fineTuning/texture") as f32).clamp(0.0, 1.0);
let r = (2.0 * b.scale).round().max(1.0) as usize;
let eps = (0.02 * amt).powi(2);
let eps = (0.02 * amt * level).powi(2);
let orig = split(&b.img);
let mut ch = orig.clone();
if amt > 0.0 {
@ -387,6 +596,9 @@ fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
p[3] = a;
});
b.img = out;
if view == 0 {
preview_compose(ctx, &mut b, &before);
}
b
}
@ -533,7 +745,9 @@ pub fn specs() -> Vec<EffectSpec> {
vec![
{
let (tweaking, color, application, animation) = grain_params();
spec("ec.noise.addgrain", "Add Grain", [tweaking, color, application, animation].into_iter().flatten().collect(), add_grain)
let head =
vec![p("viewingMode", "Viewing Mode", Value::Enum(2), popup(&ADD_GRAIN_VIEWS)), p("preset", "Preset", Value::Enum(0), popup(&GRAIN_PRESETS))];
spec("ec.noise.addgrain", "Add Grain", [head, preview_params(), tweaking, color, application, animation].into_iter().flatten().collect(), add_grain)
},
spec(
"ec.noise.median",
@ -557,15 +771,26 @@ pub fn specs() -> Vec<EffectSpec> {
spec(
"ec.noise.removegrain",
"Remove Grain",
vec![
p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)),
p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)),
p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])),
p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)),
p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)),
p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
],
[
vec![p("viewingMode", "Viewing Mode", Value::Enum(3), popup(&REMOVE_GRAIN_VIEWS))],
preview_params(),
vec![
p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)),
p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)),
p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])),
p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)),
p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)),
p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
p("temporalFiltering/enabled", "Enabled", Value::Bool(false), ParamUi::Checkbox),
p("temporalFiltering/amount", "Amount", num(100.0), pct()),
p("temporalFiltering/motionSensitivity", "Motion Sensitivity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
],
sampling_params(),
]
.into_iter()
.flatten()
.collect(),
remove_grain,
),
spec(
@ -768,6 +993,71 @@ mod tests {
assert!(tinted.img.data.iter().any(|p| (p[0] - 0.4).abs() > 1e-3));
}
#[test]
fn grain_preview_presets_balance_and_views() {
let base = gray(64, 64, 0.5);
let grain = [("tweaking/intensity", num(3.0))];
let with = |extra: &[(&str, Value)]| run("ec.noise.addgrain", &[grain.as_slice(), extra].concat(), base.clone(), 0.0).img;
// Preview: grain only inside the region; its box outline drawn.
let prev = with(&[
("viewingMode", Value::Enum(0)),
("previewRegion/center", Value::Vec2([32.0, 32.0])),
("previewRegion/width", num(20.0)),
("previewRegion/height", num(20.0)),
]);
assert_eq!(prev.get(2, 2), base.get(2, 2));
assert_ne!(prev.get(32, 32), base.get(32, 32));
assert_eq!(prev.get(22, 32), [1.0, 1.0, 1.0, 1.0], "box outline");
// Blending Matte shows the tonal weight (1 at mid grey with the defaults).
assert_eq!(with(&[("viewingMode", Value::Enum(1))]).get(5, 5), [1.0, 1.0, 1.0, 1.0]);
// Presets and channel balance change the grain.
let plain = with(&[]);
assert_ne!(with(&[("preset", Value::Enum(3))]), plain);
let no_red = with(&[("color/redBalance", num(0.0))]);
assert!(no_red.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-6));
// Sample boxes sit in the flat part of a picture.
let mut half = gray(64, 64, 0.5);
for y in 0..64 {
for x in 32..64 {
half.set(x, y, if (x + y) % 2 == 0 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] });
}
}
let boxes = sample_boxes(&half, 4, 8);
assert_eq!(boxes.len(), 4);
assert!(boxes.iter().all(|(x, _, s)| x + s <= 32), "{boxes:?}");
// Remove Grain's Noise Samples view draws them.
let shown = run("ec.noise.removegrain", &[("viewingMode", Value::Enum(1))], gray(64, 64, 0.5), 0.0).img;
assert!(shown.data.iter().any(|p| p == &[1.0, 1.0, 0.0, 1.0]));
}
#[test]
fn remove_grain_temporal_filtering_averages_steady_frames() {
/// Flat grey with per-frame grain.
struct Grainy;
impl crate::EffectHost for Grainy {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
Some(Buf { img: frame((t * 10.0).round() as u32), offset: [0.0; 2], scale: 1.0 })
}
}
fn frame(f: u32) -> Image {
crate::util::gen_image(32, 32, |x, y| {
let n = (hash1(x as u32, y as u32, f.wrapping_mul(977)) - 0.5) * 0.06;
[0.5 + n, 0.5 + n, 0.5 + n, 1.0]
})
}
let env = crate::EffectEnv { host: Some(&Grainy), frame_rate: 10.0, ..Default::default() };
let vals = [("noiseReductionSettings/noiseReduction", num(0.0)), ("temporalFiltering/enabled", Value::Bool(true))];
let out = crate::run_fx("ec.noise.removegrain", &vals, frame(5), 0.5, env).img;
let dev = |i: &Image| i.data.iter().map(|p| (p[0] - 0.5).abs()).sum::<f32>();
assert!(dev(&out) < dev(&frame(5)) * 0.75, "{} vs {}", dev(&out), dev(&frame(5)));
}
#[test]
fn noise_alpha_cycle_repeats() {
let at = |phase: f64| {

View File

@ -7,7 +7,7 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use rayon::prelude::*;
use crate::noise::{GrainLook, fbm, grain_params, median_image};
use crate::noise::{GrainLook, draw_boxes, fbm, grain_params, median_image, preview_params, sampling_boxes, sampling_params};
use crate::util::{Plane, gauss_plane, layer_or_self, premul, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
@ -29,7 +29,14 @@ pub(crate) struct GrainStats {
pub size: f32,
}
#[cfg(test)]
pub(crate) fn grain_stats(img: &Image) -> GrainStats {
grain_stats_masked(img, None)
}
/// [`grain_stats`] over the pixels `mask` selects (all opaque pixels without one).
pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainStats {
let sel = |i: usize| mask.is_none_or(|m| m[i]);
let (w, h) = (img.width as usize, img.height as usize);
if w < 3 || h < 3 {
return GrainStats::default();
@ -44,7 +51,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats {
let hp: Vec<f32> = pl.data.iter().zip(low.data.iter()).map(|(a, b)| a - b).collect();
let (mut sum, mut sq, mut n) = (0.0f64, 0.0f64, 0.0f64);
for (i, v) in hp.iter().enumerate() {
if straight[i].1 > 0.5 {
if straight[i].1 > 0.5 && sel(i) {
sum += *v as f64;
sq += (*v as f64) * (*v as f64);
n += 1.0;
@ -57,7 +64,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats {
for y in 0..h {
for x in 0..w - 1 {
let i = y * w + x;
if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 {
if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 && sel(i) && sel(i + 1) {
rho_num += (hp[i] * hp[i + 1]) as f64;
rho_den += (hp[i] * hp[i]) as f64;
}
@ -68,15 +75,45 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats {
GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 }
}
/// Match Grain's Viewing Mode options.
const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"];
fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
if ctx.params.get("noiseSourceLayer").and_then(Value::as_layer).is_none() {
return b;
}
// MATCH_GRAIN_VIEWS order.
let view = ctx.params.e("viewingMode");
let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false);
let src_stats = grain_stats(&source);
let src_boxes = sampling_boxes(ctx, &source, b.scale);
let own_boxes = sampling_boxes(ctx, &b.img, b.scale);
let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]);
if view == 1 || view == 2 {
// Noise Samples (on the source) / Compensation Samples (on this layer).
let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) };
draw_boxes(&mut img, &boxes, box_col);
b.img = img;
return b;
}
let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option<Vec<bool>> {
if boxes.is_empty() {
return None;
}
let w = img.width as usize;
let mut m = vec![false; img.data.len()];
for &(x, y, s) in boxes {
for yy in y..y + s {
for xx in x..x + s {
m[yy * w + xx] = true;
}
}
}
Some(m)
};
let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref());
let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32;
let own = if comp > 0.0 { grain_stats(&b.img) } else { GrainStats::default() };
let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() };
let orig = b.img.clone();
let look = GrainLook::from_params(ctx);
let amp: [f32; 3] = [0, 1, 2].map(|k| {
let s = src_stats.std[k];
@ -100,11 +137,7 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
let g = look.color(raw);
let weight = look.weight(c);
match view {
1 => {
let o = [0, 1, 2].map(|k| (0.5 + g[k] * amp[k]).clamp(0.0, 1.0));
*px = [o[0], o[1], o[2], 1.0];
}
2 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0],
3 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0],
_ => {
if a <= 0.0 {
return;
@ -114,6 +147,9 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
}
});
if view == 0 {
crate::noise::preview_compose(ctx, &mut b, &orig);
}
b
}
@ -214,13 +250,13 @@ pub fn specs() -> Vec<EffectSpec> {
"ec.noise.matchgrain",
"Match Grain",
vec![
p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])),
p("viewingMode", "Viewing Mode", Value::Enum(4), popup(&MATCH_GRAIN_VIEWS)),
p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer),
p("noiseSourceLayerSource", "Noise Source Layer Source", Value::Enum(0), ParamUi::Hidden),
p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()),
]
.into_iter()
.chain([tweaking, color, application, animation].into_iter().flatten())
.chain([preview_params(), sampling_params(), tweaking, color, application, animation].into_iter().flatten())
.collect(),
match_grain,
),

View File

@ -431,7 +431,7 @@ mod tests {
fn noise_types_differ_and_block_is_piecewise_constant() {
let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]);
// Block noise: neighbours inside one 10 px cell share a value.
assert_eq!(a.get(1, 1), a.get(2, 2));
assert_eq!(a.get(4, 4), a.get(5, 5));
let others: Vec<Image> = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect();
assert_ne!(others[0], others[1]);
assert_ne!(others[1], others[2]);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -61,6 +61,41 @@ fn cartoon(ctx: &EffectCtx, mut b: Buf) -> Buf {
} else {
b.img.clone()
};
// Advanced ▸ Edge Enhancement: one shock-filter step. Each pixel samples from the side of
// the nearest edge it belongs to (along the luma gradient, by the sign of the Laplacian):
// positive values sharpen the transitions, negative ones soften them.
let enh = ctx.params.f("advanced/edgeEnhancement") / 100.0;
let smoothed = if enh < 0.0 {
// Softening: blend towards a blur of the picture.
let s = 1.5 * b.scale;
let blur = effectcraft_raster::gaussian_blur(&smoothed, s, s, true);
let k = (-enh) as f32;
let mut out = smoothed;
out.data.par_iter_mut().zip(blur.data.par_iter()).for_each(|(p, q)| (0..4).for_each(|c| p[c] += (q[c] - p[c]) * k));
out
} else if enh > 0.0 {
let l = Plane::luma(&smoothed);
let (w, h) = (l.w as i64, l.h as i64);
let s = enh * 2.0 * b.scale;
let src = smoothed.clone();
let mut out = smoothed;
out.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (x, y) = ((i as i64) % w, (i as i64) / w);
let g = |dx: i64, dy: i64| l.get_clamped(x + dx, y + dy);
let (gx, gy) = ((g(1, 0) - g(-1, 0)) * 0.5, (g(0, 1) - g(0, -1)) * 0.5);
let lap = g(1, 0) + g(-1, 0) + g(0, 1) + g(0, -1) - 4.0 * g(0, 0);
let m = (gx * gx + gy * gy).sqrt();
if m < 1e-4 || lap.abs() < 1e-5 || y >= h {
return;
}
let k = -lap.signum() as f64 * s / m as f64;
let (sx, sy) = (x as f64 + 0.5 + gx as f64 * k, y as f64 + 0.5 + gy as f64 * k);
*px = src.sample_bilinear_clamped(sx, sy);
});
out
} else {
smoothed
};
let luma = Plane::luma(&smoothed);
let edges = if render >= 1 {
let mag = sobel(&luma);
@ -543,6 +578,7 @@ pub fn specs() -> Vec<EffectSpec> {
p("edge/edgeWidth", "Width", num(1.5), slider(0.0, 10.0, 0.0, 5.0, 2)),
p("edge/edgeSoftness", "Softness", num(60.0), pct()),
p("edge/edgeOpacity", "Opacity", num(100.0), pct()),
p("advanced/edgeEnhancement", "Edge Enhancement", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("advanced/edgeBlackLevel", "Edge Black Level", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("advanced/edgeContrast", "Edge Contrast", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
],
@ -993,4 +1029,30 @@ mod tests {
assert_eq!(cyc(0.0), cyc(360.0));
assert_ne!(cyc(0.0), cyc(180.0));
}
#[test]
fn cartoon_edge_enhancement_sharpens_or_softens() {
// A soft 8-pixel ramp from black to white.
let mut img = Image::new(32, 4);
for y in 0..4 {
for x in 0..32 {
let v = 0.5 + 0.5 * ((x as f32 - 15.5) / 4.0).tanh();
img.set(x, y, [v, v, v, 1.0]);
}
}
let fill = |e: f64| {
let v = [
("render", Value::Enum(0)),
("detailRadius", num(0.0)),
("fill/shadingSteps", num(64.0)),
("fill/shadingSmoothness", num(100.0)),
("advanced/edgeEnhancement", num(e)),
];
run_at("ec.stylize.cartoon", &v, img.clone(), 0.0).img
};
let steep = |o: &Image| (0..31).map(|x| (o.get(x + 1, 2)[0] - o.get(x, 2)[0]).abs()).fold(0.0f32, f32::max);
let (base, sharp, soft) = (fill(0.0), fill(100.0), fill(-100.0));
assert!(steep(&sharp) > steep(&base) + 0.02, "{} vs {}", steep(&sharp), steep(&base));
assert!(steep(&soft) < steep(&base) + 1e-4, "{} vs {}", steep(&soft), steep(&base));
}
}

View File

@ -94,6 +94,11 @@ fn glyph_src(c: char) -> &'static str {
}
}
/// A glyph's strokes on the 4 × 6 grid (Particle Playground's text particles).
pub(crate) fn glyph_strokes(c: char) -> Vec<Vec<[f64; 2]>> {
parse_glyph(c)
}
fn parse_glyph(c: char) -> Vec<Vec<[f64; 2]>> {
glyph_src(c)
.split(';')
@ -503,49 +508,112 @@ fn path_text(ctx: &EffectCtx, mut b: Buf) -> Buf {
closed = false;
}
let len = poly_length(&path, closed);
let text: String = pr.s("text").chars().take(g("advanced", "visibleCharacters").as_f64().max(0.0) as usize).collect();
let visible = g("advanced", "visibleCharacters").as_f64().max(0.0);
let all: Vec<char> = pr.s("text").chars().collect();
let n_all = all.len();
let size = (g("character", "size").as_f64() * b.scale).max(0.5);
let u = unit(size);
let tracking = g("character", "tracking").as_f64() * b.scale;
let lm = g("paragraph", "leftMargin").as_f64() * b.scale;
let rm = g("paragraph", "rightMargin").as_f64() * b.scale;
let shift = g("paragraph", "baselineShift").as_f64() * b.scale;
let line_spacing = pr.get("paragraph/lineSpacing").map(Value::as_f64).unwrap_or(size / b.scale * 1.2) * b.scale;
let rot = g("character/orientation", "characterRotation").as_f64().to_radians();
let n = text.chars().count();
let advs: Vec<f64> = text.chars().map(|c| advance(c, true) * u).collect();
let natural: f64 = advs.iter().sum::<f64>() + tracking * (n.max(1) as f64 - 1.0);
let avail = (len - lm - rm).max(0.0);
let (mut cursor, extra) = match g("paragraph", "alignment").as_enum() {
1 => (len - rm - natural, 0.0),
2 => (lm + (avail - natural) * 0.5, 0.0),
3 => (lm, if n > 1 { (avail - natural) / (n as f64 - 1.0) } else { 0.0 }),
_ => (lm, 0.0),
let shear = pr.f("character/orientation/characterShear").to_radians().tan();
let hshear = pr.f("character/horizontalShear").to_radians().tan();
let sx = pr.get("character/horizontalScale").map(Value::as_f64).unwrap_or(100.0) / 100.0;
let sy = pr.get("character/verticalScale").map(Value::as_f64).unwrap_or(100.0) / 100.0;
// Kerning: "index:value …" — the gap after character `index` (0-based) in 1/1000 em.
let kerning: Vec<(usize, f64)> = pr
.s("character/kerning")
.split_whitespace()
.filter_map(|t| {
let (i, v) = t.split_once(':')?;
Some((i.trim().parse().ok()?, v.trim().parse().ok()?))
})
.collect();
let kern = |i: usize| kerning.iter().filter(|(k, _)| *k == i).map(|(_, v)| v / 1000.0 * size).sum::<f64>();
// Jitter: per character, re-randomised every frame.
let jit = |id: &str| pr.f(&format!("advanced/jitterSettings/{id}"));
let (j_base, j_kern, j_rot, j_scale) =
(jit("baselineJitterMax") * b.scale, jit("kerningJitterMax") * b.scale, jit("rotationJitterMax").to_radians(), jit("scaleJitterMax") / 100.0);
let frame = (ctx.time * ctx.fps()).floor() as i64 as u32;
let rnd = |i: usize, k: u32| hash1(i as u32, k.wrapping_add(frame.wrapping_mul(7919)), 0x7e17) as f64 * 2.0 - 1.0;
// Fade Time: characters fade in over this share of the visible characters.
let fade = pr.f("advanced/fadeTime").clamp(0.0, 100.0) / 100.0 * n_all.max(1) as f64;
let char_alpha = |i: usize| -> f32 {
if fade <= 0.0 { if (i as f64) < visible.floor() { 1.0 } else { 0.0 } } else { ((visible - i as f64) / fade).clamp(0.0, 1.0) as f32 }
};
let mut polys = Vec::new();
for (c, adv) in text.chars().zip(advs) {
let mid = cursor + adv * 0.5;
cursor += adv + tracking + extra;
if !closed && (mid < 0.0 || mid > len) {
continue;
}
let (q, d) = poly_point_at(&path, closed, if closed { mid.rem_euclid(len.max(1e-9)) } else { mid });
let ang = d[1].atan2(d[0]) + rot;
let (s, co) = ang.sin_cos();
let left = ink_left(c, true);
for st in parse_glyph(c) {
polys.push(
st.iter()
.map(|g| {
let lx = (g[0] - left - 0.8) * u - adv * 0.5 + 0.8 * u;
let ly = (g[1] - 6.0) * u - shift;
[q[0] + lx * co - ly * s, q[1] + lx * s + ly * co]
})
.collect(),
);
// Lines (separated by line breaks) stack along the path's normal by Line Spacing.
let mut polys_by_alpha: Vec<(f32, Vec<Vec<[f64; 2]>>)> = Vec::new();
let mut gi = 0usize;
for (li, line) in all.split(|c| *c == '\n' || *c == '\r').enumerate() {
let idx0 = gi;
gi += line.len() + 1;
let n = line.len();
let advs: Vec<f64> = line.iter().map(|&c| advance(c, true) * u * sx).collect();
let kerns: Vec<f64> = (0..n).map(|k| kern(idx0 + k) + if j_kern != 0.0 { rnd(idx0 + k, 2) * j_kern } else { 0.0 }).collect();
let natural: f64 = advs.iter().sum::<f64>() + tracking * (n.max(1) as f64 - 1.0) + kerns.iter().take(n.saturating_sub(1)).sum::<f64>();
let avail = (len - lm - rm).max(0.0);
let (mut cursor, extra) = match g("paragraph", "alignment").as_enum() {
1 => (len - rm - natural, 0.0),
2 => (lm + (avail - natural) * 0.5, 0.0),
3 => (lm, if n > 1 { (avail - natural) / (n as f64 - 1.0) } else { 0.0 }),
_ => (lm, 0.0),
};
let line_off = li as f64 * line_spacing;
for (k, (&c, adv)) in line.iter().zip(advs).enumerate() {
let i = idx0 + k;
let mid = cursor + adv * 0.5;
cursor += adv + tracking + extra + kerns[k];
let alpha = char_alpha(i);
if alpha <= 0.0 || (!closed && (mid < 0.0 || mid > len)) {
continue;
}
let (q, d) = poly_point_at(&path, closed, if closed { mid.rem_euclid(len.max(1e-9)) } else { mid });
let ang = d[1].atan2(d[0]) + rot + if j_rot != 0.0 { rnd(i, 3) * j_rot } else { 0.0 };
let (s, co) = ang.sin_cos();
let js = if j_scale != 0.0 { (1.0 + rnd(i, 4) * j_scale).max(0.05) } else { 1.0 };
let jb = if j_base != 0.0 { rnd(i, 1) * j_base } else { 0.0 };
let left = ink_left(c, true);
let set = match polys_by_alpha.iter_mut().find(|(a, _)| (*a - alpha).abs() < 1e-4) {
Some((_, v)) => v,
None => {
polys_by_alpha.push((alpha, Vec::new()));
&mut polys_by_alpha.last_mut().expect("pushed").1
}
};
for st in parse_glyph(c) {
set.push(
st.iter()
.map(|gl| {
// Glyph space (baseline at 0, y down), then scale, shear and lift.
let gx = ((gl[0] - left - 0.8) * u + 0.8 * u) * sx * js - adv * 0.5;
let gy = (gl[1] - 6.0) * u * sy * js;
let gx = gx - gy * (shear + hshear);
let gy = gy - shift - jb + line_off;
[q[0] + gx * co - gy * s, q[1] + gx * s + gy * co]
})
.collect(),
);
}
}
}
let look = look_from(ctx, &b, "compositeOnOriginal");
draw_text(&mut b, &polys, weight(size), &look);
let mut look = look_from(ctx, &b, "compositeOnOriginal");
let base_op = look.opacity;
let mut first = true;
for (alpha, polys) in &polys_by_alpha {
look.opacity = base_op * alpha;
draw_text(&mut b, polys, weight(size), &look);
if first {
// Later passes go over the first one.
look.on_original = true;
first = false;
}
}
if polys_by_alpha.is_empty() {
draw_text(&mut b, &[], weight(size), &look);
}
b
}
@ -806,12 +874,24 @@ pub fn specs() -> Vec<EffectSpec> {
p("fillAndStroke/strokeWidth", "Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)),
p("character/size", "Size", num(36.0), size()),
p("character/tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)),
// Kerning: "index:value" pairs, the gap after character `index` in 1/1000 em.
p("character/kerning", "Kerning", Value::Str(String::new()), ParamUi::Text),
p("character/orientation/characterRotation", "Character Rotation", num(0.0), ang()),
p("character/orientation/characterShear", "Character Shear", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)),
p("character/horizontalShear", "Horizontal Shear", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)),
p("character/horizontalScale", "Horizontal Scale", num(100.0), slider(1.0, 1000.0, 10.0, 400.0, 1)),
p("character/verticalScale", "Vertical Scale", num(100.0), slider(1.0, 1000.0, 10.0, 400.0, 1)),
p("paragraph/alignment", "Alignment", Value::Enum(0), popup(&["Left", "Right", "Center", "Force"])),
p("paragraph/leftMargin", "Left Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)),
p("paragraph/rightMargin", "Right Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)),
p("paragraph/lineSpacing", "Line Spacing", num(43.0), slider(-1000.0, 1000.0, 0.0, 200.0, 1)),
p("paragraph/baselineShift", "Baseline Shift", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
p("advanced/visibleCharacters", "Visible Characters", num(1024.0), slider(0.0, 1024.0, 0.0, 1024.0, 0)),
p("advanced/fadeTime", "Fade Time", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("advanced/jitterSettings/baselineJitterMax", "Baseline Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("advanced/jitterSettings/kerningJitterMax", "Kerning Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("advanced/jitterSettings/rotationJitterMax", "Rotation Jitter Max", num(0.0), slider(0.0, 360.0, 0.0, 360.0, 1)),
p("advanced/jitterSettings/scaleJitterMax", "Scale Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
check("compositeOnOriginal", "Composite On Original", true),
],
path_text,
@ -995,6 +1075,39 @@ mod tests {
assert!(ink(&circle.img) > 10.0);
}
#[test]
fn path_text_kerning_scale_lines_fade_and_jitter() {
let img = Image::new(160, 100);
let line = [
("pathOptions/shapeType", Value::Enum(3)),
("pathOptions/controlPoints/vertex1", Value::Vec2([5.0, 40.0])),
("pathOptions/controlPoints/vertex2", Value::Vec2([155.0, 40.0])),
("character/size", num(20.0)),
("text", Value::Str("AB".into())),
];
let with = |extra: &[(&str, Value)], t: f64| run("ec.obsolete.pathtext", &[line.as_slice(), extra].concat(), img.clone(), t).img;
let right_edge = |i: &Image| (0..160).rev().find(|&x| (0..100).any(|y| i.get(x, y)[3] > 0.3)).unwrap_or(0);
let base = with(&[], 0.0);
// Kerning after the first character pushes the second one along.
assert!(right_edge(&with(&[("character/kerning", Value::Str("0:500".into()))], 0.0)) >= right_edge(&base) + 8);
// Horizontal Scale widens, shear changes the shapes.
assert!(right_edge(&with(&[("character/horizontalScale", num(200.0))], 0.0)) > right_edge(&base) + 5);
assert_ne!(with(&[("character/horizontalShear", num(30.0))], 0.0), base);
// A second line sits Line Spacing below the first.
let two = with(&[("text", Value::Str("AB\rCD".into())), ("paragraph/lineSpacing", num(30.0))], 0.0);
let ink_rows = |i: &Image, y0: i64, y1: i64| (y0..y1).flat_map(|y| (0..160).map(move |x| (x, y))).map(|(x, y)| i.get(x, y)[3]).sum::<f32>();
assert!(ink_rows(&two, 52, 72) > 5.0 && ink_rows(&base, 52, 72) < 0.5);
// Fade Time: the character being revealed is partly transparent.
let fading = with(&[("advanced/visibleCharacters", num(1.5)), ("advanced/fadeTime", num(50.0))], 0.0);
let total = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
let (one, both) = (with(&[("advanced/visibleCharacters", num(1.0))], 0.0), with(&[("advanced/visibleCharacters", num(2.0))], 0.0));
assert!(total(&fading) > total(&one) + 1.0 && total(&fading) < total(&both) - 1.0, "{} {} {}", total(&one), total(&fading), total(&both));
// Jitter differs between frames but is repeatable.
let j = [("advanced/jitterSettings/baselineJitterMax", num(10.0))];
assert_ne!(with(&j, 0.0), with(&j, 0.5));
assert_eq!(with(&j, 0.5), with(&j, 0.5));
}
#[test]
fn lightning_deterministic_and_animates() {
let img = Image::new(96, 64);

View File

@ -6,8 +6,8 @@
//! masks and ignore effects applied before them (precompose to include those). Without a host
//! (tests, thumbnails) they pass their input through.
//!
//! Pixel-motion based methods (Timewarp's Pixel Motion, Pixel Motion Blur) are approximated by
//! frame mixing / sub-frame sampling; there is no optical-flow estimation yet.
//! Timewarp's Pixel Motion estimates block-matching optical flow between neighbouring frames
//! ([`effectcraft_raster::flow`]); Pixel Motion Blur averages sub-frame samples.
//!
//! [`EffectHost::self_at`]: crate::EffectHost::self_at
@ -16,6 +16,8 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use effectcraft_raster::flow::Flow;
use crate::util::{fit_layer, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
@ -90,11 +92,29 @@ pub fn specs() -> Vec<EffectSpec> {
p("speed", "Speed", num(50.0), slider(-10000.0, 10000.0, 0.0, 200.0, 1)),
p("sourceFrame", "Source Frame", num(0.0), slider(-100000.0, 100000.0, 0.0, 300.0, 1)),
p("tuning/vectorDetail", "Vector Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("tuning/smoothing/globalSmoothness", "Global Smoothness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("tuning/smoothing/localSmoothness", "Local Smoothness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("tuning/smoothing/smoothingIterations", "Smoothing Iterations", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 0)),
p("tuning/buildFromOneImage", "Build From One Image", Value::Bool(false), ParamUi::Checkbox),
p("tuning/correctLuminanceChanges", "Correct Luminance Changes", Value::Bool(false), ParamUi::Checkbox),
p("tuning/filtering", "Filtering", Value::Enum(0), popup(&["Normal", "Extreme"])),
p("tuning/errorThreshold", "Error Threshold", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("tuning/blockSize", "Block Size", num(8.0), slider(4.0, 64.0, 4.0, 32.0, 0)),
p("tuning/weighting/redWeighting", "Red Weighting", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("tuning/weighting/greenWeighting", "Green Weighting", num(0.59), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("tuning/weighting/blueWeighting", "Blue Weighting", num(0.11), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("motionBlur/enableMotionBlur", "Enable Motion Blur", Value::Bool(false), ParamUi::Checkbox),
p("motionBlur/shutterControl", "Shutter Control", Value::Enum(0), popup(&["Automatic", "Manual"])),
p("motionBlur/shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)),
p("motionBlur/shutterSamples", "Shutter Samples", num(5.0), slider(1.0, 64.0, 1.0, 32.0, 0)),
p("matteLayer", "Matte Layer", Value::Layer(None), ParamUi::Layer),
p("matteChannel", "Matte Channel", Value::Enum(0), popup(&TW_MATTE_CHANNELS)),
p("warpLayer", "Warp Layer", Value::Layer(None), ParamUi::Layer),
p("show", "Show", Value::Enum(0), popup(&TW_SHOW)),
p("sourceCrops/leftCrop", "Left Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
p("sourceCrops/rightCrop", "Right Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
p("sourceCrops/bottomCrop", "Bottom Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
p("sourceCrops/topCrop", "Top Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
],
timewarp,
),
@ -428,6 +448,238 @@ pub fn timewarp_source_time(t: f64, by_frame: bool, speed: f64, source_frame: f6
if by_frame { source_frame / fps } else { t * speed / 100.0 }
}
/// Timewarp's Pixel Motion settings (the Tuning group and Source Crops).
#[derive(Clone, Copy, Debug)]
pub struct PixelMotion {
/// Vector spacing in pixels (from Vector Detail).
pub tile: u32,
/// Matching block size in pixels.
pub window: u32,
pub global_smoothness: f32,
pub local_smoothness: f32,
pub iterations: u32,
pub build_from_one: bool,
pub correct_luminance: bool,
pub extreme: bool,
/// Error Threshold (0..1): pixels whose two warped frames disagree by more fall back to a
/// frame mix.
pub error_threshold: f32,
pub weights: [f32; 3],
/// Left, right, top, bottom crops in pixels.
pub crops: [u32; 4],
}
impl PixelMotion {
fn from(ctx: &EffectCtx, scale: f64) -> PixelMotion {
let pr = ctx.params;
let g = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
let detail = g("tuning/vectorDetail", 20.0).clamp(0.0, 100.0) / 100.0;
// Vector Detail 0 → one vector per 32 px, 100 → one per 2 px (exponential).
let tile = (32.0 * (1.0f64 / 16.0).powf(detail) * scale).round().max(2.0) as u32;
let px = |id: &str| (g(id, 0.0).max(0.0) * scale).round() as u32;
PixelMotion {
tile,
window: ((g("tuning/blockSize", 8.0).max(4.0)) * scale).round().max(4.0) as u32,
global_smoothness: g("tuning/smoothing/globalSmoothness", 0.5).clamp(0.0, 1.0) as f32,
local_smoothness: g("tuning/smoothing/localSmoothness", 0.5).clamp(0.0, 1.0) as f32,
iterations: g("tuning/smoothing/smoothingIterations", 20.0).clamp(0.0, 200.0) as u32,
build_from_one: pr.b("tuning/buildFromOneImage"),
correct_luminance: pr.b("tuning/correctLuminanceChanges"),
extreme: pr.e("tuning/filtering") == 1,
error_threshold: (g("tuning/errorThreshold", 10.0) / 100.0).clamp(0.0, 1.0) as f32,
weights: [g("tuning/weighting/redWeighting", 0.3), g("tuning/weighting/greenWeighting", 0.59), g("tuning/weighting/blueWeighting", 0.11)]
.map(|v| v.max(0.0) as f32),
crops: [px("sourceCrops/leftCrop"), px("sourceCrops/rightCrop"), px("sourceCrops/topCrop"), px("sourceCrops/bottomCrop")],
}
}
}
/// Replace the cropped edges of `img` with the nearest kept pixel (Source Crops: edge garbage
/// must not drive or smear the motion).
fn crop_edges(img: &Image, [l, r, t, b]: [u32; 4]) -> Image {
if l == 0 && r == 0 && t == 0 && b == 0 {
return img.clone();
}
let (w, h) = (img.width as i64, img.height as i64);
let (x0, x1) = ((l as i64).min(w - 1), (w - 1 - r as i64).max(0));
let (y0, y1) = ((t as i64).min(h - 1), (h - 1 - b as i64).max(0));
let mut out = img.clone();
out.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
*px = img.get((x as i64).clamp(x0, x1.max(x0)), (y as i64).clamp(y0, y1.max(y0)));
}
});
out
}
/// Weighted grey (R·wr + G·wg + B·wb in every colour channel) that the motion is estimated on.
fn motion_grey(img: &Image, wt: [f32; 3], gain: f32) -> Image {
let sum = (wt[0] + wt[1] + wt[2]).max(1e-6);
let mut out = img.clone();
out.data.par_iter_mut().for_each(|p| {
let g = (p[0] * wt[0] + p[1] * wt[1] + p[2] * wt[2]) / sum * gain;
*p = [g, g, g, p[3]];
});
out
}
fn mean_grey(img: &Image) -> f32 {
let n = img.data.len().max(1) as f32;
img.data.par_iter().map(|p| p[0]).sum::<f32>() / n
}
/// Smooth a motion field: `iterations` passes pulling each vector towards its 3×3 mean by
/// Local Smoothness, then a blend towards a wide average by Global Smoothness.
fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) {
let (c, r) = (flow.cols, flow.rows);
if c * r <= 1 {
return;
}
let mean = |v: &[[f32; 2]], rad: i64| -> Vec<[f32; 2]> {
(0..c * r)
.into_par_iter()
.map(|i| {
let (x, y) = ((i % c) as i64, (i / c) as i64);
let mut s = [0.0f32; 2];
let mut n = 0.0;
for dy in -rad..=rad {
for dx in -rad..=rad {
let (nx, ny) = (x + dx, y + dy);
if nx >= 0 && ny >= 0 && (nx as usize) < c && (ny as usize) < r {
let q = v[ny as usize * c + nx as usize];
s[0] += q[0];
s[1] += q[1];
n += 1.0;
}
}
}
[s[0] / n, s[1] / n]
})
.collect()
};
if pm.local_smoothness > 0.0 {
for _ in 0..pm.iterations {
let m = mean(&flow.v, 1);
for (v, q) in flow.v.iter_mut().zip(m) {
v[0] += (q[0] - v[0]) * pm.local_smoothness * 0.5;
v[1] += (q[1] - v[1]) * pm.local_smoothness * 0.5;
}
}
}
if pm.global_smoothness > 0.0 {
let m = mean(&flow.v, 3);
for (v, q) in flow.v.iter_mut().zip(m) {
v[0] += (q[0] - v[0]) * pm.global_smoothness * 0.5;
v[1] += (q[1] - v[1]) * pm.global_smoothness * 0.5;
}
}
}
/// The motion field from frame `a` to frame `b` (or from the warp layer's frames).
pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0);
let gain = if pm.correct_luminance {
let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0)));
if mb > 1e-4 { ma / mb } else { 1.0 }
} else {
1.0
};
let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain);
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4);
smooth_flow(&mut flow, pm);
flow
}
/// Pixel Motion: the frame at fraction `w` between `a` and `b` built by warping along `flow`
/// (Build From One Image: from the nearer frame only), falling back to a mix where the two
/// warped frames disagree by more than the Error Threshold.
pub fn timewarp_interpolate(a: &Image, b: &Image, w: f32, flow: &Flow, pm: &PixelMotion) -> Image {
let (a, b) = (crop_edges(a, pm.crops), crop_edges(b, pm.crops));
let mut out = Image::new(a.width, a.height);
let wd = w as f64;
let sample = |img: &Image, x: f64, y: f64| if pm.extreme { img.sample_bicubic(x, y) } else { img.sample_bilinear_clamped(x, y) };
out.rows_mut().for_each(|(y, row)| {
let cy = y as f64 + 0.5;
for (x, px) in row.iter_mut().enumerate() {
let cx = x as f64 + 0.5;
let f = flow.at(cx, cy);
let (fx, fy) = (f[0] as f64, f[1] as f64);
let pa = sample(&a, cx - wd * fx, cy - wd * fy);
let pb = sample(&b, cx + (1.0 - wd) * fx, cy + (1.0 - wd) * fy);
if pm.build_from_one {
*px = if w < 0.5 { pa } else { pb };
continue;
}
let err = (0..4).map(|c| (pa[c] - pb[c]).abs()).fold(0.0f32, f32::max);
let (pa, pb) = if pm.error_threshold > 0.0 && err > pm.error_threshold && pm.error_threshold < 1.0 {
(a.get(x as i64, y as i64), b.get(x as i64, y as i64))
} else {
(pa, pb)
};
for c in 0..4 {
px[c] = pa[c] + (pb[c] - pa[c]) * w;
}
}
});
out
}
/// Timewarp's Matte Channel options.
const TW_MATTE_CHANNELS: [&str; 4] = ["Alpha", "Inverted Alpha", "Luminance", "Inverted Luminance"];
/// Timewarp's Show options.
const TW_SHOW: [&str; 4] = ["Normal", "Foreground Only", "Background Only", "Matte"];
/// A matte plane (foreground = 1) from a matte layer frame.
fn matte_of(img: &Image, channel: u32) -> Vec<f32> {
img.data
.par_iter()
.map(|p| {
let (c, a) = unpremul(*p);
let l = (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) * a;
match channel {
1 => 1.0 - a,
2 => l,
3 => 1.0 - l,
_ => a,
}
.clamp(0.0, 1.0)
})
.collect()
}
fn times_mask(img: &Image, m: &[f32], invert: bool) -> Image {
let mut out = img.clone();
out.data.par_iter_mut().zip(m.par_iter()).for_each(|(p, &k)| {
let k = if invert { 1.0 - k } else { k };
p.iter_mut().for_each(|v| *v *= k);
});
out
}
/// Another layer (layer parameter `id`) at the comp times matching source layer times `times`,
/// fitted onto `grid`. `None` when no layer is chosen.
fn fetch_layer(ctx: &EffectCtx, id: &str, times: &[f64], grid: &Buf) -> Option<Vec<Image>> {
let lid = ctx.params.get(id)?.as_layer()?;
let host = ctx.env.host?;
let me = ctx.params.get(&crate::layer_source_id(id)).is_none_or(|v| v.as_enum() != 0);
let shift = ctx.env.comp_time - ctx.time;
Some(
times
.iter()
.map(|t| match host.layer_at(lid, t + shift, me) {
Some(lp) => fit_layer(ctx, grid, &lp, false),
None => Image::new(grid.img.width, grid.img.height),
})
.collect(),
)
}
/// Timewarp. Whole Frames shows the nearest source frame, Frame Mix cross-fades the two
/// neighbouring frames and Pixel Motion builds the in-between frame along estimated motion
/// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning
/// group. A Matte Layer splits foreground and background so each moves on its own vectors; a
/// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter
/// Samples instants across the shutter.
fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
if ctx.env.host.is_none() {
return b;
@ -437,36 +689,126 @@ fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
let speed = ctx.params.f("speed");
let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps);
let method = ctx.params.e("method");
// Build From One Image: interpolated frames come from the nearest source frame alone.
let one = method > 0 && ctx.params.b("tuning/buildFromOneImage");
// Sample times (with weights) for one instant of source time.
let instant = |s: f64| -> Vec<(f64, f32)> {
let f = s * fps;
let f0 = (f + 1e-6).floor();
let frac = (f - f0) as f32;
if one {
return vec![((f + 1e-6).round() / fps, 1.0)];
}
if method == 0 || frac < 1e-4 { vec![(f0 / fps, 1.0)] } else { vec![(f0 / fps, 1.0 - frac), ((f0 + 1.0) / fps, frac)] }
};
let mut samples: Vec<(f64, f32)> = Vec::new();
// Instants of source time (with weights) across the shutter.
let mut instants: Vec<(f64, f32)> = vec![];
if ctx.params.b("motionBlur/enableMotionBlur") {
let angle = if ctx.params.e("motionBlur/shutterControl") == 1 { ctx.params.f("motionBlur/shutterAngle") } else { 180.0 };
let n = ctx.params.f("motionBlur/shutterSamples").round().clamp(1.0, 64.0) as usize;
let rate = if by_frame { 1.0 } else { speed / 100.0 };
let span = angle / 360.0 * rate / fps;
for i in 0..n {
let s = src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 };
samples.extend(instant(s).into_iter().map(|(t, w)| (t, w / n as f32)));
instants.push((src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 }, 1.0 / n as f32));
}
} else {
samples = instant(src);
instants.push((src, 1.0));
}
// Each instant needs the frame at or before it and the next one.
let split = |s: f64| {
let f = s * fps;
let f0 = (f + 1e-6).floor();
(f0, ((f - f0) as f32).max(0.0))
};
let mut times: Vec<f64> = vec![];
for &(s, _) in &instants {
let (f0, _) = split(s);
times.push(f0 / fps);
times.push((f0 + 1.0) / fps);
}
let times: Vec<f64> = samples.iter().map(|s| s.0).collect();
let weights: Vec<f32> = samples.iter().map(|s| s.1).collect();
let Some(fr) = fetch(ctx, &times) else { return b };
let img = average(&fr.imgs, &weights);
with_img(fr.grid, img)
let grid = fr.grid.clone();
let frame = |t: f64| -> &Image {
let k = times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
&fr.imgs[k]
};
let pm = PixelMotion::from(ctx, grid.scale);
let mattes = fetch_layer(ctx, "matteLayer", &times, &grid);
let warps = fetch_layer(ctx, "warpLayer", &times, &grid);
let matte_ch = ctx.params.e("matteChannel");
let show = ctx.params.e("show");
let mut flows: Vec<((i64, bool), Flow)> = vec![];
let mut out_frames = vec![];
for &(s, wgt) in &instants {
let (f0, frac) = split(s);
let (t0, t1) = (f0 / fps, (f0 + 1.0) / fps);
let (a, bb) = (frame(t0), frame(t1));
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
let ma = mattes.as_ref().map(|m| matte_of(&m[idx(t0)], matte_ch));
let mb = mattes.as_ref().map(|m| matte_of(&m[idx(t1)], matte_ch));
let img = if method == 0 || frac < 1e-4 || (one && method == 1) {
// Whole frames (Build From One Image with Frame Mix: the nearest frame).
let pick = if method == 0 || frac < 1e-4 { 0.0 } else { frac.round() };
let base = if pick < 0.5 { a } else { bb };
match (show, &ma, &mb) {
(1, Some(m), _) if pick < 0.5 => times_mask(base, m, false),
(2, Some(m), _) if pick < 0.5 => times_mask(base, m, true),
(1, _, Some(m)) => times_mask(base, m, false),
(2, _, Some(m)) => times_mask(base, m, true),
(3, Some(m), _) => matte_image(if pick < 0.5 { m } else { mb.as_ref().unwrap_or(m) }, base),
_ => base.clone(),
}
} else if method == 1 {
match (&ma, &mb, show) {
(Some(m0), Some(m1), 3) => effectcraft_raster::flow::mix(&matte_image(m0, a), &matte_image(m1, bb), frac),
(Some(m0), Some(m1), 1 | 2) => effectcraft_raster::flow::mix(&times_mask(a, m0, show == 2), &times_mask(bb, m1, show == 2), frac),
_ => effectcraft_raster::flow::mix(a, bb, frac),
}
} else {
// Pixel Motion: vectors from the warp layer if any, else from the layer.
let key = ((f0 as i64), false);
let flow_ab = |fa: &Image, fb: &Image| -> Flow { timewarp_flow(fa, fb, &pm) };
let flow = match flows.iter().find(|(k, _)| *k == key) {
Some((_, f)) => f.clone(),
None => {
let f = match &warps {
Some(w) => flow_ab(&w[idx(t0)], &w[idx(t1)]),
None => flow_ab(a, bb),
};
flows.push((key, f.clone()));
f
}
};
match (&ma, &mb) {
(Some(m0), Some(m1)) => {
// Foreground and background each move on their own vectors.
let (fa, fb) = (times_mask(a, m0, false), times_mask(bb, m1, false));
let (ga, gb) = (times_mask(a, m0, true), times_mask(bb, m1, true));
let fg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&fa, &fb) };
let bg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&ga, &gb) };
let fg = timewarp_interpolate(&fa, &fb, frac, &fg_flow, &pm);
let bg = timewarp_interpolate(&ga, &gb, frac, &bg_flow, &pm);
match show {
1 => fg,
2 => bg,
3 => matte_image(&fg.data.iter().map(|p| p[3]).collect::<Vec<_>>(), &fg),
_ => {
let mut o = bg;
o.data.par_iter_mut().zip(fg.data.par_iter()).for_each(|(p, q)| {
let k = 1.0 - q[3];
for c in 0..4 {
p[c] = q[c] + p[c] * k;
}
});
o
}
}
}
_ => timewarp_interpolate(a, bb, frac, &flow, &pm),
}
};
out_frames.push((img, wgt));
}
let imgs: Vec<Image> = out_frames.iter().map(|(i, _)| i.clone()).collect();
let weights: Vec<f32> = out_frames.iter().map(|(_, w)| *w).collect();
let img = if imgs.len() == 1 { imgs.into_iter().next().unwrap_or_default() } else { average(&imgs, &weights) };
with_img(grid, img)
}
/// A matte shown as opaque grey.
fn matte_image(m: &[f32], like: &Image) -> Image {
let mut out = Image::new(like.width, like.height);
out.data.par_iter_mut().zip(m.par_iter()).for_each(|(p, &k)| *p = [k, k, k, 1.0]);
out
}
// ---------------------------------------------------------------- CC Force Motion Blur / CC Wide Time / Pixel Motion Blur
@ -563,6 +905,97 @@ mod tests {
assert!((out.img.data[0][0] - 0.5).abs() < 1e-5, "{}", out.img.data[0][0]);
}
/// Texture `x − dx` (smooth, non-repeating).
fn texture(w: u32, h: u32, dx: f64) -> Image {
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
let (u, v) = (x as f64 - dx, y as f64);
let s = (0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos() + 0.1 * (u * 0.031 + v * 0.017).sin()) as f32;
img.set(x, y, [s, s * 0.8, 1.0 - s, 1.0]);
}
}
img
}
/// A textured layer moving right 3 px per frame (10 fps); layer 7 (a "warp" / matte layer)
/// is the same texture standing still, layer 8 a matte that is white on the left half.
struct Moving;
impl EffectHost for Moving {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
Some(Buf { img: texture(96, 64, (t * 10.0).round() * 3.0), offset: [0.0; 2], scale: 1.0 })
}
fn layer_at(&self, id: u64, _: f64, _: bool) -> Option<LayerPixels> {
let img = match id {
7 => texture(96, 64, 0.0),
_ => {
let mut m = Image::new(96, 64);
for y in 0..64 {
for x in 0..48 {
m.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
m
}
};
Some(LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [96.0, 64.0] })
}
}
fn mae(a: &Image, b: &Image, margin: i64) -> f32 {
let (w, h) = (a.width as i64, a.height as i64);
let mut s = 0.0;
let mut n = 0.0;
for y in margin..h - margin {
for x in margin..w - margin {
let (p, q) = (a.get(x, y), b.get(x, y));
s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::<f32>();
n += 3.0;
}
}
s / n
}
fn run_moving(vals: &[(&str, Value)], t: f64) -> Image {
let env = EffectEnv { host: Some(&Moving), frame_rate: 10.0, comp_time: t, ..Default::default() };
run_fx("ec.time.timewarp", vals, Image::new(96, 64), t, env).img
}
#[test]
fn timewarp_pixel_motion_follows_the_motion() {
// Speed 50 % at layer time 1.1 s: source 0.55 s, halfway between frames 5 and 6 (15 and
// 18 px), so the true in-between frame is the texture at 16.5 px.
let want = texture(96, 64, 16.5);
let pm = run_moving(&[("method", Value::Enum(2)), ("tuning/vectorDetail", num(60.0))], 1.1);
let mix = run_moving(&[("method", Value::Enum(1))], 1.1);
let (e_pm, e_mix) = (mae(&pm, &want, 8), mae(&mix, &want, 8));
assert!(e_pm < e_mix * 0.5, "pixel motion {e_pm} vs frame mix {e_mix}");
// Deterministic and seek-consistent (same time → same pixels).
assert_eq!(pm.data, run_moving(&[("method", Value::Enum(2)), ("tuning/vectorDetail", num(60.0))], 1.1).data);
// Build From One Image warps the nearer frame alone: still close to the truth.
let one = run_moving(&[("method", Value::Enum(2)), ("tuning/buildFromOneImage", Value::Bool(true)), ("tuning/vectorDetail", num(60.0))], 1.1);
assert!(mae(&one, &want, 8) < e_mix * 0.5);
// A Warp Layer standing still supplies zero motion: the result is a plain mix.
let warped = run_moving(&[("method", Value::Enum(2)), ("warpLayer", Value::Layer(Some(7))), ("tuning/errorThreshold", num(100.0))], 1.1);
assert!(mae(&warped, &mix, 0) < 1e-3, "{}", mae(&warped, &mix, 0));
// Show Matte displays the matte layer; Foreground Only keeps the matte's left half.
let m = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("show", Value::Enum(3))], 1.1);
assert!(m.get(10, 10)[0] > 0.99 && m.get(80, 10)[0] < 0.01);
let fg = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("show", Value::Enum(1))], 1.1);
assert!(fg.get(10, 30)[3] > 0.99 && fg.get(85, 30)[3] < 0.01);
let normal = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("tuning/vectorDetail", num(60.0))], 1.1);
assert!(mae(&normal, &want, 12) < e_mix, "{} {e_mix}", mae(&normal, &want, 12));
// Source Crops: a crop replicates the kept edge into the cropped band.
let c = run_moving(&[("method", Value::Enum(2)), ("sourceCrops/leftCrop", num(10.0))], 1.1);
assert_eq!(c.get(0, 20), c.get(5, 20));
}
#[test]
fn timewarp_and_wide_time() {
let h = Clock { static_layer: false };

View File

@ -1,5 +1,5 @@
//! Transition effects (batch 2): Block Dissolve, Gradient Wipe (gradient layer or self), Iris Wipe, Card
//! Wipe (2D cards), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe.
//! Wipe (3D cards with camera, lighting and jitter), 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".
@ -9,6 +9,7 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::Px;
use rayon::prelude::*;
use crate::sim2::{Piece, draw_pieces, rot_axis, sort_far_first};
use crate::util::{Plane, gauss_plane, hash1, layer_rect, remap, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, col, num, p, popup, slider};
@ -61,14 +62,21 @@ fn block_dissolve(ctx: &EffectCtx, mut b: Buf) -> Buf {
let feather = ctx.params.f("feather") * b.scale;
let (ox, oy) = (b.offset[0], b.offset[1]);
let seed = ctx.seed ^ 0xb10c;
// Soft Edges (Best Quality): block edges falling between pixels are anti-aliased (4×4
// sub-pixel samples); off, each pixel takes its centre's block.
let soft = ctx.params.b("softEdges");
let subs: Vec<f64> = if soft { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] };
let kept = |x: f64, y: f64| {
let (bx, by) = (((x - ox) / bw).floor() as i64, ((y - oy) / bh).floor() as i64);
hash1(bx as u32, by as u32, seed) >= done
};
let mut mask = Plane::new(b.img.width as usize, b.img.height as usize);
let w = mask.w;
let n = (subs.len() * subs.len()) as f32;
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 };
let hits = subs.iter().flat_map(|sy| subs.iter().map(move |sx| (*sx, *sy))).filter(|(sx, sy)| kept(x as f64 + sx, y as f64 + sy)).count();
*m = hits as f32 / n;
}
});
if feather > 0.0 {
@ -163,21 +171,47 @@ fn iris_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Card Wipe: the layer is cut into Rows × Columns cards that flip over in Flip Order, each
/// through a 180° turn about Flip Axis, as textured planes in 3D seen through the Camera
/// System and lit by Lighting / Material. The flipped side shows Back Layer (none: the cards
/// vanish as they turn away). Position / Rotation Jitter wobble the cards over time.
fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let done = completion(ctx);
if done <= 0.0 {
return b;
let pr = ctx.params;
let f = |id: &str| pr.get(id).map(Value::as_f64).unwrap_or(0.0);
let jitter_on = [
"positionJitter/xJitterAmount",
"positionJitter/yJitterAmount",
"positionJitter/zJitterAmount",
"rotationJitter/xRotJitterAmount",
"rotationJitter/yRotJitterAmount",
"rotationJitter/zRotJitterAmount",
]
.iter()
.any(|id| f(id) != 0.0);
if done <= 0.0
&& !jitter_on
&& pr.e("cameraSystem") == 0
&& f("cameraPosition/xRotation") == 0.0
&& f("cameraPosition/yRotation") == 0.0
&& f("cameraPosition/zRotation") == 0.0
{
// Nothing has moved: the layer as is (exactly).
let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2());
if xy.is_none_or(|p| (p[0] - ctx.layer_size[0] * 0.5).abs() < 1e-9 && (p[1] - ctx.layer_size[1] * 0.5).abs() < 1e-9) {
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 tw = (pr.f("transitionWidth") / 100.0).clamp(0.01, 1.0);
let rows = pr.f("rows").round().clamp(1.0, 1000.0) as i64;
// Rows & Columns: Independent, or Columns Follows Rows.
let cols = if ctx.params.e("rowsAndColumns") == 1 { rows } else { ctx.params.f("columns").round().clamp(1.0, 1000.0) as i64 };
let card_scale = ctx.params.f("cardScale").max(0.01);
let axis = ctx.params.e("flipAxis");
let order = ctx.params.e("flipOrder");
let back_self = ctx.params.e("backLayer") == 1;
let jitter = ctx.params.f("timingRandomness").clamp(0.0, 1.0);
let seed = ctx.params.f("randomSeed") as u32 ^ 0xca7d;
let cols = if pr.e("rowsAndColumns") == 1 { rows } else { pr.f("columns").round().clamp(1.0, 1000.0) as i64 };
let card_scale = pr.f("cardScale").max(0.01);
let axis = pr.e("flipAxis");
let direction = pr.e("flipDirection");
let order = pr.e("flipOrder");
let jitter = pr.f("timingRandomness").clamp(0.0, 1.0);
let seed = pr.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 };
@ -187,64 +221,91 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
Some(o) => place_layer(ctx, &b, &o, 2),
None => b.img.clone(),
});
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 (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch);
let mut 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 => (nx(i) + 1.0 - ny(j)) * 0.5,
7 => 1.0 - (nx(i) + ny(j)) * 0.5,
8 => {
let g = grad.as_ref().map(|g| g.get_clamped(ccx as i64, ccy as i64)).unwrap_or([0.0; 4]);
let (c, _) = unpremul(g);
luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64
// Back Layer: a chosen layer (stretched to fit), or this layer for projects saved with the
// old "Self" choice; none hides the cards' backs.
let back = match ctx.layer_param("backLayer", true) {
Some(o) => Some(crate::util::fit_layer(ctx, &b, &o, true)),
None if pr.b("backSelf") => Some(b.img.clone()),
None => None,
};
let cam = crate::card3d::projection(ctx, &b);
let eye = cam.eye();
let light = crate::card3d::Lighting::from(ctx, &b);
let t = ctx.time;
let jit = |i: i64, j: i64, k: u32, speed: f64| -> f64 {
let ph = hash1(i as u32, j as u32, seed ^ k) as f64 * std::f64::consts::TAU;
let rate = 0.5 + hash1(i as u32, j as u32, seed ^ (k + 101)) as f64;
(t * speed * rate * std::f64::consts::TAU + ph).sin()
};
let (jx, jy, jz, js) =
(f("positionJitter/xJitterAmount"), f("positionJitter/yJitterAmount"), f("positionJitter/zJitterAmount"), f("positionJitter/jitterSpeed"));
let (rjx, rjy, rjz, rjs) =
(f("rotationJitter/xRotJitterAmount"), f("rotationJitter/yRotJitterAmount"), f("rotationJitter/zRotJitterAmount"), f("rotationJitter/rotJitterSpeed"));
let mut pieces: Vec<Piece> = (0..rows * cols)
.into_par_iter()
.filter_map(|k| {
let (i, j) = (k % cols, k / cols);
let (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch);
let mut 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 => (nx(i) + 1.0 - ny(j)) * 0.5,
7 => 1.0 - (nx(i) + ny(j)) * 0.5,
8 => {
let g = grad.as_ref().map(|g| g.get_clamped(ccx as i64, ccy as i64)).unwrap_or([0.0; 4]);
let (c, _) = unpremul(g);
luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64
}
_ => nx(i),
};
if jitter > 0.0 {
o += (hash1(i as u32, j as u32, seed ^ 0x7177) as f64 - o) * jitter;
}
_ => nx(i),
};
if jitter > 0.0 {
o += (hash1(i as u32, j as u32, seed ^ 0x7177) as f64 - o) * jitter;
}
let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0);
// Card Scale: each card is scaled about its centre (gaps below 1, cropped above 1).
let (mut dx, mut dy) = ((x - ccx) / card_scale, (y - ccy) / card_scale);
if dx.abs() > cw * 0.5 + 1e-9 || dy.abs() > ch * 0.5 + 1e-9 {
return None;
}
if p <= 0.0 {
return Some((ccx + dx, ccy + dy));
}
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,
};
if around_x {
dy /= cos;
if dy.abs() > ch * 0.5 {
let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0);
let around_x = match axis {
1 => false,
2 => hash1(i as u32, j as u32, seed ^ 0x55) < 0.5,
_ => true,
};
let sign = match direction {
1 => -1.0,
2 if hash1(i as u32, j as u32, seed ^ 0x99) < 0.5 => -1.0,
_ => 1.0,
};
let a = p * std::f64::consts::PI * sign;
let flip = if around_x { rot_axis([1.0, 0.0, 0.0], a) } else { rot_axis([0.0, 1.0, 0.0], a) };
let jr = crate::card3d::rot_ordered(
(rjx * jit(i, j, 11, rjs)).to_radians(),
(rjy * jit(i, j, 12, rjs)).to_radians(),
(rjz * jit(i, j, 13, rjs)).to_radians(),
0,
);
let r = crate::card3d::mat_mul(&jr, &flip);
let pos = [ccx + jx * jit(i, j, 21, js) * cw, ccy + jy * jit(i, j, 22, js) * ch, jz * jit(i, j, 23, js) * cw.max(ch)];
// Card Scale: each card is scaled about its centre (gaps below 1, cropped above 1).
let (sw, sh) = ((cw * 0.5 * card_scale.min(1.0)) as f32, (ch * 0.5 * card_scale.min(1.0)) as f32);
let (cx, cy) = (ccx as f32, ccy as f32);
let poly = [[cx - sw, cy - sh], [cx + sw, cy - sh], [cx + sw, cy + sh], [cx - sw, cy + sh]];
// Above 1 the card's picture is magnified (cropped to the card).
let k = card_scale.max(1.0);
let rs: crate::card3d::M3 = std::array::from_fn(|q| [r[q][0] * k, r[q][1] * k, r[q][2]]);
let poly = poly.map(|v| [cx + (v[0] - cx) / k as f32, cy + (v[1] - cy) / k as f32]);
let mut pc = Piece::new(&poly, [cx, cy], &rs, pos, cam)?;
if pc.back && back.is_none() {
return None;
}
} else {
dx /= cos;
if dx.abs() > cw * 0.5 {
return None;
}
}
Some((ccx + dx, ccy + dy))
});
pc.back_mirror = if around_x { 1 } else { 2 };
pc.light(&light, &r, pos, eye);
Some(pc)
})
.collect();
sort_far_first(&mut pieces);
let mut out = effectcraft_raster::Image::new(b.img.width, b.img.height);
draw_pieces(&mut out, &pieces, &b.img, back.as_ref());
b.img = out;
b
}
@ -412,6 +473,7 @@ pub fn specs() -> Vec<EffectSpec> {
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)),
p("softEdges", "Soft Edges (Best Quality)", Value::Bool(true), ParamUi::Checkbox),
],
block_dissolve,
),
@ -447,12 +509,15 @@ pub fn specs() -> Vec<EffectSpec> {
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("backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer),
// Projects saved when Back Layer was a None / Self popup: Self.
p("backSelf", "Back Layer Is Self", Value::Bool(false), ParamUi::Hidden),
p("rowsAndColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follows Rows"])),
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("cardScale", "Card Scale", num(1.0), slider(0.01, 10.0, 0.01, 2.0, 2)),
p("flipAxis", "Flip Axis", Value::Enum(0), popup(&["X", "Y", "Random"])),
p("flipDirection", "Flip Direction", Value::Enum(0), popup(&["Positive", "Negative", "Random"])),
p(
"flipOrder",
"Flip Order",
@ -472,7 +537,22 @@ pub fn specs() -> Vec<EffectSpec> {
p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer),
p("timingRandomness", "Timing Randomness", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)),
],
]
.into_iter()
.chain(crate::card3d::camera_params(2.0))
.chain(crate::card3d::lighting_params(1.0, 0.25))
.chain(crate::card3d::material_params(0.75))
.chain([
p("positionJitter/xJitterAmount", "X Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
p("positionJitter/jitterSpeed", "Jitter Speed", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("positionJitter/yJitterAmount", "Y Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
p("positionJitter/zJitterAmount", "Z Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
p("rotationJitter/xRotJitterAmount", "X Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)),
p("rotationJitter/rotJitterSpeed", "Rot Jitter Speed", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("rotationJitter/yRotJitterAmount", "Y Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)),
p("rotationJitter/zRotJitterAmount", "Z Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)),
])
.collect(),
card_wipe,
),
spec(
@ -635,7 +715,7 @@ mod tests {
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))],
&[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backSelf", Value::Bool(true))],
);
assert_eq!(back.get(2, 20)[3], 1.0);
}
@ -746,4 +826,63 @@ mod tests {
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");
}
fn run_t(img: &Image, set: &[(&str, Value)], time: f64, host: Option<&dyn crate::EffectHost>) -> Image {
let s = find("ec.transition.cardwipe").unwrap();
let size = [img.width as f64, img.height as f64];
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, size))).collect() };
for (k, v) in set {
params.values.insert(k.to_string(), v.clone());
}
let env = crate::EffectEnv { host, ..Default::default() };
let ctx = EffectCtx { params: &params, time, layer_size: size, seed: 7, adjustment: false, env };
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
}
#[test]
fn card_wipe_back_layer_direction_jitter_and_camera() {
struct Green;
impl crate::EffectHost for Green {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
Some(crate::LayerPixels { buf: Buf { img: Image::filled(80, 40, [0.0, 1.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [80.0, 40.0] })
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
let img = Image::filled(80, 40, [1.0, 0.0, 0.0, 1.0]);
let v = [("completion", num(100.0)), ("rows", num(1.0)), ("columns", num(8.0))];
// Fully flipped cards show the Back Layer.
let mut b: Vec<(&str, Value)> = v.to_vec();
b.push(("backLayer", Value::Layer(Some(3))));
let out = run_t(&img, &b, 0.0, Some(&Green));
let p = out.get(45, 20);
assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}");
// Half way, Flip Direction decides which way the cards turn (the views differ).
let half = [("completion", num(30.0)), ("rows", num(1.0)), ("columns", num(1.0)), ("transitionWidth", num(100.0)), ("flipAxis", Value::Enum(1))];
let pos = run_t(&img, &[half.as_slice(), &[("cameraPosition/xRotation", num(20.0))]].concat(), 0.0, None);
let neg = run_t(&img, &[half.as_slice(), &[("cameraPosition/xRotation", num(20.0)), ("flipDirection", Value::Enum(1))]].concat(), 0.0, None);
assert_ne!(pos, neg);
// Position Jitter moves the cards over time (and only then).
let j = [("completion", num(0.0)), ("positionJitter/xJitterAmount", num(0.3))];
let a = run_t(&img, &j, 0.0, None);
let c = run_t(&img, &j, 0.37, None);
assert_ne!(a, c);
assert_eq!(c, run_t(&img, &j, 0.37, None), "deterministic");
assert_eq!(run_t(&img, &[("completion", num(0.0))], 0.37, None), img, "no jitter, no change");
// Camera Position rotation tilts the whole wipe in perspective.
let tilt = run_t(&img, &[("completion", num(0.0)), ("cameraPosition/yRotation", num(40.0))], 0.0, None);
assert_eq!(tilt.get(0, 0)[3], 0.0);
}
#[test]
fn block_dissolve_soft_edges_antialias_blocks() {
let img = Image::filled(40, 4, [1.0, 1.0, 1.0, 1.0]);
let v = [("completion", num(50.0)), ("blockWidth", num(2.5)), ("blockHeight", num(4.0))];
let frac = |o: &Image| o.data.iter().filter(|p| p[3] > 0.01 && p[3] < 0.99).count();
let hard = run("ec.transition.blockdissolve", &img, &[v.as_slice(), &[("softEdges", Value::Bool(false))]].concat());
let soft = run("ec.transition.blockdissolve", &img, &v);
assert_eq!(frac(&hard), 0);
assert!(frac(&soft) > 0);
}
}

View File

@ -563,6 +563,78 @@ impl<S: Clone> SimCache<S> {
}
}
/// 1D squared-distance transform of sampled function `f` (Felzenszwalb & Huttenlocher 2012;
/// `f` is 0 at sites and +∞ elsewhere).
fn sq_dt_1d(f: &[f64], out: &mut [f64]) {
let n = f.len();
let sites: Vec<usize> = (0..n).filter(|&i| f[i].is_finite()).collect();
if sites.is_empty() {
out.iter_mut().for_each(|o| *o = f64::INFINITY);
return;
}
let mut v = vec![0usize; sites.len()];
let mut z = vec![0f64; sites.len() + 1];
let mut k = 0usize;
v[0] = sites[0];
z[0] = f64::NEG_INFINITY;
z[1] = f64::INFINITY;
let s_of = |q: usize, p: usize| ((f[q] + (q * q) as f64) - (f[p] + (p * p) as f64)) / (2.0 * q as f64 - 2.0 * p as f64);
for &q in &sites[1..] {
let mut s = s_of(q, v[k]);
while s <= z[k] {
k -= 1;
s = s_of(q, v[k]);
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f64::INFINITY;
}
k = 0;
for (q, o) in out.iter_mut().enumerate() {
while z[k + 1] < q as f64 {
k += 1;
}
let d = q as f64 - v[k] as f64;
*o = d * d + f[v[k]];
}
}
/// Euclidean distance from every pixel to the nearest `true` pixel of `mask` (`w`×`h`),
/// infinite when there is none.
pub fn distance_transform(mask: &[bool], w: usize, h: usize) -> Vec<f32> {
let mut g: Vec<f64> = mask.iter().map(|&m| if m { 0.0 } else { f64::INFINITY }).collect();
// Columns, then rows.
let cols: Vec<Vec<f64>> = (0..w)
.into_par_iter()
.map(|x| {
let f: Vec<f64> = (0..h).map(|y| g[y * w + x]).collect();
let mut o = vec![0.0; h];
sq_dt_1d(&f, &mut o);
o
})
.collect();
for (x, col) in cols.iter().enumerate() {
for y in 0..h {
g[y * w + x] = col[y];
}
}
g.par_chunks_mut(w.max(1)).for_each(|row| {
let f = row.to_vec();
sq_dt_1d(&f, row);
});
g.iter().map(|&d| d.sqrt() as f32).collect()
}
/// Signed distance to the boundary of the `inside` region: negative inside, positive outside
/// (pixel centres; ±0.5 at the boundary).
pub fn signed_distance(inside: &[bool], w: usize, h: usize) -> Plane {
let outside: Vec<bool> = inside.iter().map(|v| !v).collect();
let to_in = distance_transform(inside, w, h);
let to_out = distance_transform(&outside, w, h);
Plane { w, h, data: to_in.iter().zip(&to_out).map(|(a, b)| if *a > 0.0 { a - 0.5 } else { -(b - 0.5) }).collect() }
}
/// Point-in-polygon (even-odd) for a closed polyline.
pub fn point_in_poly(pts: &[[f64; 2]], x: f64, y: f64) -> bool {
let n = pts.len();

View File

@ -154,7 +154,20 @@ fn mtv(m: &[[f64; 3]; 3], v: V3) -> V3 {
// ---------------------------------------------------------------- projections (VR Converter)
pub const PROJECTIONS: [&str; 6] = ["Equirectangular 2:1", "Cube-map 4:3", "Cube-map Pano2VR 3:2", "Cube-map GearVR 6:1", "Sphere-map", "2D Source"];
/// VR Converter's projections / layouts. Cube-map Facebook 3:2 and Cube-map EAC 3:2 put
/// left, front, right over bottom, back, top (EAC with equi-angular faces); Fisheye
/// (FullDome) is an angular fisheye looking up at the zenith with the front at the bottom.
pub const PROJECTIONS: [&str; 9] = [
"Equirectangular 2:1",
"Cube-map 4:3",
"Cube-map Pano2VR 3:2",
"Cube-map GearVR 6:1",
"Sphere-map",
"2D Source",
"Cube-map Facebook 3:2",
"Cube-map EAC 3:2",
"Fisheye (FullDome)",
];
/// Cube faces: (forward, right, up).
const FACES: [(V3, V3, V3); 6] = [
@ -173,6 +186,8 @@ fn cube_grid(kind: u32) -> ((u32, u32), [(u32, u32); 6]) {
1 => ((4, 3), [(2, 1), (0, 1), (1, 0), (1, 2), (1, 1), (3, 1)]),
// 3 × 2: right, left, top / bottom, front, back.
2 => ((3, 2), [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]),
// 3 × 2: left, front, right / bottom, back, top (Facebook, EAC).
6 | 7 => ((3, 2), [(2, 0), (0, 0), (2, 1), (0, 1), (1, 0), (1, 1)]),
// 6 × 1 strip in face order.
_ => ((6, 1), [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]),
}
@ -191,16 +206,34 @@ impl Proj {
pub fn dir(&self, x: f64, y: f64, w: f64, h: f64) -> Option<V3> {
match self.kind {
0 => Some(equi_dir(x, y, w, h)),
1..=3 => {
1..=3 | 6 | 7 => {
let ((gw, gh), cells) = cube_grid(self.kind);
let (cw, ch) = (w / gw as f64, h / gh as f64);
let (cx, cy) = ((x / cw).floor(), (y / ch).floor());
let f = cells.iter().position(|&(c, r)| c as f64 == cx && r as f64 == cy)?;
let a = (x - cx * cw) / cw * 2.0 - 1.0;
let b = (y - cy * ch) / ch * 2.0 - 1.0;
let mut a = (x - cx * cw) / cw * 2.0 - 1.0;
let mut b = (y - cy * ch) / ch * 2.0 - 1.0;
if self.kind == 7 {
// Equi-angular faces: positions are linear in angle.
a = (a * std::f64::consts::FRAC_PI_4).tan();
b = (b * std::f64::consts::FRAC_PI_4).tan();
}
let (fw, r, u) = FACES[f];
Some(norm([fw[0] + a * r[0] - b * u[0], fw[1] + a * r[1] - b * u[1], fw[2] + a * r[2] - b * u[2]]))
}
8 => {
// FullDome: 180° angular fisheye centred on the zenith (+Y), front at the bottom.
let s = w.min(h) / 2.0;
let (u, v) = ((x - w / 2.0) / s, (y - h / 2.0) / s);
let r = (u * u + v * v).sqrt();
if r > 1.0 {
return None;
}
let th = r * std::f64::consts::FRAC_PI_2;
let (cu, cv) = if r > 1e-12 { (u / r, v / r) } else { (0.0, 0.0) };
// Local: forward = +Y, image right = +X, image down = +Z.
Some([th.sin() * cu, th.cos(), th.sin() * cv])
}
4 => {
// Angular fisheye centred on +Z; radius ∝ angle, `fov` across the image width.
let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians();
@ -231,17 +264,32 @@ impl Proj {
let (x, y) = equi_px(d, w, h);
Some((x, y, None))
}
1..=3 => {
1..=3 | 6 | 7 => {
let f = (0..6).max_by(|&i, &j| dot(d, FACES[i].0).total_cmp(&dot(d, FACES[j].0)))?;
let (fw, r, u) = FACES[f];
let k = dot(d, fw);
let (a, b) = (dot(d, r) / k, -dot(d, u) / k);
let (mut a, mut b) = (dot(d, r) / k, -dot(d, u) / k);
if self.kind == 7 {
a = a.atan() / std::f64::consts::FRAC_PI_4;
b = b.atan() / std::f64::consts::FRAC_PI_4;
}
let ((gw, gh), cells) = cube_grid(self.kind);
let (cw, ch) = (w / gw as f64, h / gh as f64);
let (c, rr) = cells[f];
let (x0, y0) = (c as f64 * cw, rr as f64 * ch);
Some((x0 + (a + 1.0) * 0.5 * cw, y0 + (b + 1.0) * 0.5 * ch, Some([x0, y0, x0 + cw, y0 + ch])))
}
8 => {
let th = d[1].clamp(-1.0, 1.0).acos();
if th > std::f64::consts::FRAC_PI_2 + 1e-9 {
return None;
}
let s = w.min(h) / 2.0;
let r = th / std::f64::consts::FRAC_PI_2;
let l = (d[0] * d[0] + d[2] * d[2]).sqrt();
let (cu, cv) = if l > 1e-12 { (d[0] / l, d[2] / l) } else { (0.0, 0.0) };
Some((w / 2.0 + cu * r * s, h / 2.0 + cv * r * s, None))
}
4 => {
let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians();
let th = d[2].clamp(-1.0, 1.0).acos();
@ -578,7 +626,7 @@ fn vr_converter(ctx: &EffectCtx, mut b: Buf) -> Buf {
let from = Proj { kind: ctx.params.e("sourceProjection"), fov: ctx.params.f("sourceHorizontalFov") };
let to = Proj { kind: ctx.params.e("targetProjection"), fov: ctx.params.f("targetHorizontalFov") };
let (t, pn, r) = (ctx.params.f("tilt"), ctx.params.f("pan"), ctx.params.f("roll"));
if from.kind == to.kind && (from.kind <= 3 || from.fov == to.fov) && t == 0.0 && pn == 0.0 && r == 0.0 {
if from.kind == to.kind && (!matches!(from.kind, 4 | 5) || from.fov == to.fov) && t == 0.0 && pn == 0.0 && r == 0.0 {
return b;
}
let m = rot_params(ctx);
@ -825,39 +873,86 @@ fn vr_fractal(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// VR Digital Glitch: horizontal bands of the panorama jump sideways (Geometric Distortion,
/// split into Horizontal / Vertical displacement), their colour channels split apart (Color
/// Distortion with per-channel offsets) and scan lines darken alternate rows (Scanline
/// Spacing apart). The pattern re-randomises Distortion Rate times a second, and Distortion
/// Evolution (optionally cycling) moves it smoothly in between. Target Point limits the glitch
/// to a region of the sphere (Radius, Feather; radius 0 = everywhere).
fn vr_glitch(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let amp = (pr.f("masterAmplitude") / 100.0).max(0.0);
if amp <= 0.0 {
return b;
}
let g = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
let rate = pr.f("distortionRate").max(0.0);
let geo = pr.f("geometricDistortion") / 100.0 * amp;
let (hdisp, vdisp) = (g("geometric/horizontalDisplacement", 100.0) / 100.0, g("geometric/verticalDisplacement", 0.0) / 100.0);
let complexity = pr.f("distortionComplexity").clamp(1.0, 100.0);
let rgb = pr.f("colorDistortion") / 100.0 * amp;
let offs = [g("color/redOffset", -1.0), g("color/greenOffset", 0.0), g("color/blueOffset", 1.0)];
let scan = (pr.f("scanlines") / 100.0 * amp) as f32;
let spacing = g("scanlineSpacing", 2.0).round().max(2.0) as usize;
let seed = (pr.f("randomSeed") as u32) ^ ctx.seed;
// The glitch pattern changes `rate` times a second (held between changes).
let slot = if rate > 0.0 { (ctx.time * rate).floor() as i64 } else { 0 } as u32;
// Evolution: a fraction of a slot that blends towards the next pattern.
let mut evo = g("distortionEvolution", 0.0) / 360.0;
if pr.b("evolutionOptions/cycleEvolution") {
evo = evo.rem_euclid(g("evolutionOptions/cycle", 1.0).round().max(1.0));
}
let base = if rate > 0.0 { ctx.time * rate } else { 0.0 } + evo;
let slot = base.floor() as i64 as u32;
let frac = base - base.floor();
// Target Point.
let target = mv(&rotation(g("target/tilt", 0.0), g("target/pan", 0.0), 0.0), [0.0, 0.0, 1.0]);
let radius = g("target/radius", 0.0).to_radians();
let feather = g("target/feather", 0.0).to_radians();
b.img = per_eye(&b.img, layout(ctx), |e, eye| {
let (w, h) = (e.width as f64, e.height as f64);
let bands = complexity.round() as u32;
let mut o = Image::new(e.width, e.height);
o.rows_mut().for_each(|(y, row)| {
let band = ((y as f64 / h) * bands as f64) as u32;
let r0 = hash1(band, slot, seed ^ (eye as u32 * 977));
let on = r0 < 0.35;
let shift = if on { (hash1(band, slot + 1, seed) as f64 * 2.0 - 1.0) * geo * w * 0.15 } else { 0.0 };
let split = if on { rgb * w * 0.01 * (1.0 + hash1(band, slot + 2, seed) as f64) } else { 0.0 };
let dark = if y % 2 == 0 { 1.0 - scan * 0.5 } else { 1.0 };
let at = |s: u32| {
let r0 = hash1(band, s, seed ^ (eye as u32 * 977));
let on = r0 < 0.35;
let shift = if on { (hash1(band, s + 1, seed) as f64 * 2.0 - 1.0) * geo } else { 0.0 };
let split = if on { rgb * w * 0.01 * (1.0 + hash1(band, s + 2, seed) as f64) } else { 0.0 };
(shift, split)
};
let (s0, p0) = at(slot);
let (shift, split) = if frac > 0.0 {
let (s1, p1) = at(slot + 1);
(s0 + (s1 - s0) * frac, p0 + (p1 - p0) * frac)
} else {
(s0, p0)
};
let (dx, dy) = (shift * w * 0.15 * hdisp, shift * h * 0.15 * vdisp);
let dark = if y % spacing == 0 { 1.0 - scan * 0.5 } else { 1.0 };
for (x, px) in row.iter_mut().enumerate() {
let sx = x as f64 + 0.5 - shift;
let sy = y as f64 + 0.5;
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
let k = if radius > 0.0 {
let a = angle(equi_dir(fx, fy, w, h), target);
if a <= radius {
1.0
} else if feather > 0.0 {
(1.0 - (a - radius) / feather).clamp(0.0, 1.0)
} else {
0.0
}
} else {
1.0
};
let orig = e.data[y * e.width as usize + x];
if k <= 0.0 {
*px = orig;
continue;
}
let (sx, sy) = (fx - dx * k, (fy - dy * k).clamp(0.5, h - 0.5));
// Horizontal shifts wrap around the seam (the frame is a full turn).
let g = sample_equi(e, sx, sy);
let r = sample_equi(e, sx - split, sy);
let bb = sample_equi(e, sx + split, sy);
*px = [r[0] * dark, g[1] * dark, bb[2] * dark, g[3].max(r[3]).max(bb[3])];
let c: [Px; 3] = std::array::from_fn(|i| sample_equi(e, sx + offs[i] * split * k, sy));
let d = 1.0 - (1.0 - dark) * k as f32;
*px = [c[0][0] * d, c[1][1] * d, c[2][2] * d, c[0][3].max(c[1][3]).max(c[2][3])];
}
});
o
@ -947,9 +1042,22 @@ pub fn specs() -> Vec<EffectSpec> {
p("masterAmplitude", "Master Amplitude", num(100.0), slider(0.0, 500.0, 0.0, 200.0, 1)),
p("distortionRate", "Distortion Rate", num(5.0), slider(0.0, 60.0, 0.0, 30.0, 2)),
p("distortionComplexity", "Distortion Complexity", num(16.0), slider(1.0, 100.0, 1.0, 64.0, 0)),
ang("distortionEvolution", "Distortion Evolution"),
p("evolutionOptions/cycleEvolution", "Cycle Evolution", Value::Bool(false), ParamUi::Checkbox),
p("evolutionOptions/cycle", "Cycle (in Revolutions)", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)),
p("geometricDistortion", "Geometric Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("geometric/horizontalDisplacement", "Horizontal Displacement", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("geometric/verticalDisplacement", "Vertical Displacement", num(0.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("colorDistortion", "Color Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("color/redOffset", "Red Offset", num(-1.0), slider(-10.0, 10.0, -2.0, 2.0, 2)),
p("color/greenOffset", "Green Offset", num(0.0), slider(-10.0, 10.0, -2.0, 2.0, 2)),
p("color/blueOffset", "Blue Offset", num(1.0), slider(-10.0, 10.0, -2.0, 2.0, 2)),
p("scanlines", "Scanlines", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("scanlineSpacing", "Scanline Spacing", num(2.0), slider(2.0, 100.0, 2.0, 20.0, 0)),
ang("target/tilt", "Target Tilt"),
ang("target/pan", "Target Pan"),
p("target/radius", "Target Radius", num(0.0), slider(0.0, 180.0, 0.0, 180.0, 1)),
p("target/feather", "Target Feather", num(0.0), slider(0.0, 180.0, 0.0, 90.0, 1)),
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)),
],
vr_glitch,
@ -1056,7 +1164,7 @@ mod tests {
#[test]
fn projections_are_inverse() {
for kind in 0..6u32 {
for kind in 0..9u32 {
let pr = Proj { kind, fov: if kind == 4 { 360.0 } else { 100.0 } };
let (w, h) = (240.0, 120.0);
for (x, y) in [(10.5, 20.5), (100.5, 60.5), (230.5, 110.5), (130.5, 33.5)] {
@ -1070,7 +1178,7 @@ mod tests {
#[test]
fn converter_round_trips_equirect_through_cube_maps() {
let img = sphere_img(128, 64);
for target in [1u32, 2, 3] {
for target in [1u32, 2, 3, 6, 7] {
let cube = run_fx("ec.vr.converter", &[("targetProjection", Value::Enum(target))], img.clone(), 0.0, EffectEnv::default());
let back = run_fx(
"ec.vr.converter",
@ -1098,6 +1206,41 @@ mod tests {
}
}
#[test]
fn digital_glitch_full_controls() {
let img = sphere_img(128, 64);
let run = |vals: &[(&str, Value)], t: f64| run_fx("ec.vr.digitalglitch", vals, img.clone(), t, EffectEnv::default()).img;
let base = run(&[], 0.0);
for (id, v) in
[("distortionEvolution", num(90.0)), ("geometric/verticalDisplacement", num(100.0)), ("color/redOffset", num(3.0)), ("scanlineSpacing", num(5.0))]
{
assert_ne!(run(&[(id, v.clone())], 0.0), base, "{id}");
}
// Cycle Evolution: one full cycle is the start again.
let cyc =
|e: f64| run(&[("distortionEvolution", num(e)), ("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))], 0.0);
assert_eq!(cyc(0.0), cyc(360.0));
// Target Point: a small region around the front; the back stays untouched.
let t = run(&[("target/radius", num(30.0))], 0.0);
assert_eq!(t.get(0, 32), img.get(0, 32), "behind the viewer");
}
#[test]
fn converter_fulldome_and_eac() {
let img = sphere_img(128, 64);
let dome = run_fx("ec.vr.converter", &[("targetProjection", Value::Enum(8))], img.clone(), 0.0, EffectEnv::default()).img;
// The centre looks at the zenith (+Y); the bottom edge at the front (+Z).
let c = dome.get(64, 32);
assert!((c[1] - 0.9).abs() < 0.03, "{c:?}");
let f = dome.get(64, 62);
assert!(f[2] > 0.8, "{f:?}");
// EAC faces are equi-angular: a quarter of the front face sits at 22.5 degrees.
let eac = Proj { kind: 7, fov: 90.0 };
let cw = 128.0 / 3.0;
let d = eac.dir(cw + 0.25 * cw, 16.0, 128.0, 64.0).unwrap();
assert!((d[0].atan2(d[2]).to_degrees() + 22.5).abs() < 0.5, "{d:?}");
}
#[test]
fn rotate_sphere_pans_and_round_trips() {
let img = sphere_img(96, 48);

View File

@ -105,6 +105,10 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> {
}
fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
// Controls the kernels do not implement render on the CPU.
if !effectcraft_effects::catalog::gpu_supported(id, ctx) {
return None;
}
match id {
"ec.blur.gaussian" => gaussian(e, ctx, b),
"ec.blur.fastbox" => box_blur(e, ctx, b),
@ -218,6 +222,10 @@ fn directional(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
}
fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
// Other Glow Operations and the Arbitrary Map render on the CPU.
if ctx.params.e("colors") == 2 || effectcraft_effects::glow_operation(ctx) != effectcraft_color::BlendMode::Add {
return None;
}
let thr = ctx.params.f("threshold") as f32 / 100.0;
let radius = ctx.params.f("radius") * b.scale;
let intensity = ctx.params.f("intensity") as f32;
@ -276,6 +284,10 @@ fn drop_shadow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
}
fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
// Motion blur (shutter angle) renders on the CPU.
if ctx.env.host.is_some() && effectcraft_effects::transform_shutter(ctx).is_some() {
return None;
}
let anchor = b.to_px(ctx.params.v2("anchor"));
let pos = b.to_px(ctx.params.v2("position"));
let sh = ctx.params.f("scaleHeight");
@ -352,11 +364,19 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
p.f[0] = [br, k, ct, 0.0];
}
"ec.color.huesaturation" => {
// Colour ranges (Channel Control) render on the CPU.
if !effectcraft_effects::huesat_ranges_identity(ctx) {
return None;
}
p.u[0] = [3, ctx.params.b("colorize") as u32, 0, 0];
p.f[0] = [f("hue") as f32 / 360.0, f("saturation") as f32 / 100.0, f("lightness") as f32 / 100.0, 0.0];
p.f[1] = [f("colorizeHue") as f32 / 360.0, f("colorizeSaturation") as f32 / 100.0, f("colorizeLightness") as f32 / 100.0, 0.0];
}
"ec.color.levels" => {
// Red / Green / Blue / Alpha controls render on the CPU.
if !effectcraft_effects::levels_channels_identity(ctx) {
return None;
}
let (clip_b, clip_w) = effectcraft_effects::levels_clip(ctx);
p.u[0] = [4, clip_b as u32, clip_w as u32, 0];
p.f[0] = [f("inBlack") as f32, f("inWhite") as f32, f("gamma").max(0.01) as f32, f("outBlack") as f32];
@ -382,6 +402,10 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
p.f[0][0] = 1.0 - f("blend") as f32 / 100.0;
}
"ec.generate.fill" => {
// Fill Mask / All Masks render on the CPU.
if effectcraft_effects::fill_uses_masks(ctx) {
return None;
}
p.u[0] = [7, ctx.params.b("invert") as u32, 0, 0];
p.f[0] = ctx.params.color("color");
p.f[1][0] = f("opacity") as f32 / 100.0;

View File

@ -59,15 +59,15 @@ fn rgb(c: [f32; 4]) -> [f32; 4] {
/// Same selective colour ids as `color2::SC_IDS`.
const SC_IDS: [[&str; 4]; 9] = [
["redsCyan", "redsMagenta", "redsYellow", "redsBlack"],
["yellowsCyan", "yellowsMagenta", "yellowsYellow", "yellowsBlack"],
["greensCyan", "greensMagenta", "greensYellow", "greensBlack"],
["cyansCyan", "cyansMagenta", "cyansYellow", "cyansBlack"],
["bluesCyan", "bluesMagenta", "bluesYellow", "bluesBlack"],
["magentasCyan", "magentasMagenta", "magentasYellow", "magentasBlack"],
["whitesCyan", "whitesMagenta", "whitesYellow", "whitesBlack"],
["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"],
["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"],
["details/reds/redsCyan", "details/reds/redsMagenta", "details/reds/redsYellow", "details/reds/redsBlack"],
["details/yellows/yellowsCyan", "details/yellows/yellowsMagenta", "details/yellows/yellowsYellow", "details/yellows/yellowsBlack"],
["details/greens/greensCyan", "details/greens/greensMagenta", "details/greens/greensYellow", "details/greens/greensBlack"],
["details/cyans/cyansCyan", "details/cyans/cyansMagenta", "details/cyans/cyansYellow", "details/cyans/cyansBlack"],
["details/blues/bluesCyan", "details/blues/bluesMagenta", "details/blues/bluesYellow", "details/blues/bluesBlack"],
["details/magentas/magentasCyan", "details/magentas/magentasMagenta", "details/magentas/magentasYellow", "details/magentas/magentasBlack"],
["details/whites/whitesCyan", "details/whites/whitesMagenta", "details/whites/whitesYellow", "details/whites/whitesBlack"],
["details/neutrals/neutralsCyan", "details/neutrals/neutralsMagenta", "details/neutrals/neutralsYellow", "details/neutrals/neutralsBlack"],
["details/blacks/blacksCyan", "details/blacks/blacksMagenta", "details/blacks/blacksYellow", "details/blacks/blacksBlack"],
];
fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {

View File

@ -32,10 +32,24 @@ fn black_white_colorama_selective_color() {
effect_case("ec.color.colorama", &[]);
effect_case("ec.color.colorama", &[("inputPhase/mode", e(4)), ("inputPhase/phaseShift", n(75.0)), ("outputCycle/cycles", n(2.5)), ("blend", n(40.0))]);
effect_case("ec.color.colorama", &[("inputPhase/mode", e(8)), ("outputCycle/cycles", n(1.3))]);
effect_case("ec.color.selectivecolor", &[("redsCyan", n(60.0)), ("bluesYellow", n(-40.0)), ("neutralsBlack", n(20.0)), ("whitesMagenta", n(30.0))]);
effect_case(
"ec.color.selectivecolor",
&[("method", e(1)), ("greensMagenta", n(50.0)), ("yellowsCyan", n(-30.0)), ("blacksYellow", n(40.0)), ("magentasBlack", n(25.0))],
&[
("details/reds/redsCyan", n(60.0)),
("details/blues/bluesYellow", n(-40.0)),
("details/neutrals/neutralsBlack", n(20.0)),
("details/whites/whitesMagenta", n(30.0)),
],
);
effect_case(
"ec.color.selectivecolor",
&[
("method", e(1)),
("details/greens/greensMagenta", n(50.0)),
("details/yellows/yellowsCyan", n(-30.0)),
("details/blacks/blacksYellow", n(40.0)),
("details/magentas/magentasBlack", n(25.0)),
],
);
}

View File

@ -95,18 +95,28 @@ fn sad(a: &Plane, b: &Plane, bx: i64, by: i64, bs: i64, dx: i64, dy: i64) -> f32
/// Block-matching flow from `a` to `b` (same size). `block` is the tile size at full resolution;
/// `radius` the search range at the coarsest level (motion up to about `radius · 2^levels`).
pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow {
let bs = block.max(4) as usize;
block_flow_window(a, b, block, block, radius)
}
/// [`block_flow`] with separate vector spacing and matching window: one vector per
/// `tile × tile` tile, each matched with the `window × window` block centred on its tile
/// (Timewarp's Vector Detail and Block Size).
pub fn block_flow_window(a: &Image, b: &Image, tile: u32, window: u32, radius: i32) -> Flow {
let bs = tile.max(2) as usize;
let win = window.max(4) as i64;
// Window origin relative to the tile origin.
let wo = bs as i64 / 2 - win / 2;
let (w, h) = (a.width as usize, a.height as usize);
let cols = w.div_ceil(bs).max(1);
let rows = h.div_ceil(bs).max(1);
let mut flow = Flow { width: a.width, height: a.height, block: bs as u32, cols, rows, v: vec![[0.0; 2]; cols * rows] };
if a.width != b.width || a.height != b.height || w < bs || h < bs {
if a.width != b.width || a.height != b.height || w < bs || h < bs || (w as i64) < win || (h as i64) < win {
return flow;
}
// Pyramids (level 0 = full resolution); stop while blocks still fit.
let mut pa = vec![Plane::luma(a)];
let mut pb = vec![Plane::luma(b)];
while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= bs * 2 && p.h / 2 >= bs * 2) {
while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= (bs as i64).max(win) as usize * 2 && p.h / 2 >= (bs as i64).max(win) as usize * 2) {
let (na, nb) = (pa.last().map(Plane::half), pb.last().map(Plane::half));
pa.extend(na);
pb.extend(nb);
@ -118,15 +128,15 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow {
let (la, lb) = (&pa[level], &pb[level]);
let k = 1i64 << level;
// The block covers the tile's footprint at this level (at least 4 pixels).
let lbs = ((bs as i64) / k).max(4);
let lbs = (win / k).max(4);
let r = if level == top { radius as i64 } else { 1 };
let prev = cur.clone();
cur = (0..cols * rows)
.into_par_iter()
.map(|i| {
let (c, rr) = ((i % cols) as i64, (i / cols) as i64);
let bx = c * bs as i64 / k;
let by = rr * bs as i64 / k;
let bx = (c * bs as i64 + wo) / k;
let by = (rr * bs as i64 + wo) / k;
// Candidates: this tile's and its neighbours' vectors from the coarser level.
let mut seeds: Vec<[i64; 2]> = vec![[0, 0]];
if level != top {
@ -160,18 +170,19 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow {
// Sub-pixel refinement (parabola through the SAD of the neighbours) at full resolution.
let (la, lb) = (&pa[0], &pb[0]);
let bsi = bs as i64;
let wsi = win;
let fine: Vec<[f32; 2]> = (0..cols * rows)
.into_par_iter()
.map(|i| {
let (bx, by) = ((i % cols) as i64 * bsi, (i / cols) as i64 * bsi);
let (bx, by) = ((i % cols) as i64 * bsi + wo, (i / cols) as i64 * bsi + wo);
let d = cur[i];
let e0 = sad(la, lb, bx, by, bsi, d[0], d[1]);
let e0 = sad(la, lb, bx, by, wsi, d[0], d[1]);
let fit = |em: f32, ep: f32| {
let den = em - 2.0 * e0 + ep;
if den > 1e-6 { (0.5 * (em - ep) / den).clamp(-0.5, 0.5) } else { 0.0 }
};
let sx = fit(sad(la, lb, bx, by, bsi, d[0] - 1, d[1]), sad(la, lb, bx, by, bsi, d[0] + 1, d[1]));
let sy = fit(sad(la, lb, bx, by, bsi, d[0], d[1] - 1), sad(la, lb, bx, by, bsi, d[0], d[1] + 1));
let sx = fit(sad(la, lb, bx, by, wsi, d[0] - 1, d[1]), sad(la, lb, bx, by, wsi, d[0] + 1, d[1]));
let sy = fit(sad(la, lb, bx, by, wsi, d[0], d[1] - 1), sad(la, lb, bx, by, wsi, d[0], d[1] + 1));
[d[0] as f32 + sx, d[1] as f32 + sy]
})
.collect();

View File

@ -157,6 +157,38 @@ impl EffectHost for FxHost<'_, '_, '_> {
}
}
fn params_at(&self, layer_time: f64) -> Option<Params> {
let i = self.index.load(std::sync::atomic::Ordering::Relaxed);
let g = self.layer.effects()?.groups().nth(i)?;
let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time)));
Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr)))
}
fn comp_scene(&self) -> Option<effectcraft_effects::CompScene> {
// The comp camera's view of the layer (as if 3D at its transform), brought back into the
// layer's own pixel grid through the inverse of how the layer itself composites.
let world = self.ctx.world_matrix(self.layer);
let (cam, _, _) = self.ctx.camera();
let p = (cam * world).0;
let back = self.ctx.layer_to_comp(self.layer).0.inverse()?.0;
let rows = [p[0], p[1], p[3]];
let camera: [[f64; 4]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| back[i][k] * rows[k][j]).sum()));
let inv = world.inverse();
let light = three_d::light::lights_at(self.ctx).first().and_then(|l| {
let inv = inv.as_ref()?;
let pos = inv.apply(l.pos);
let dir = inv.apply_vec(l.dir);
let len = (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z).sqrt().max(1e-12);
let kind = match l.kind {
effectcraft_project::LightKind::Parallel => 0,
effectcraft_project::LightKind::Ambient => 2,
_ => 1,
};
Some(effectcraft_effects::CompLight { pos: [pos.x, pos.y, pos.z], dir: [dir.x / len, dir.y / len, dir.z / len], color: l.color, kind })
});
Some(effectcraft_effects::CompScene { camera: Some(camera), light })
}
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
@ -741,6 +773,11 @@ impl<'a> Renderer<'a> {
effect_index: 0,
working_space: self.pipe.space,
working_linear: self.pipe.linear,
shutter: (ctx.comp.enable_motion_blur && layer.switches.motion_blur).then_some((
ctx.comp.shutter_angle,
ctx.comp.shutter_phase,
ctx.comp.motion_blur_samples,
)),
};
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx

View File

@ -163,6 +163,19 @@ fn effects_run_in_pipeline() {
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
}
/// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place.
#[test]
fn card_dance_comp_camera_matches_the_default_view() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.2, 0.6, 1.0], 50, 50);
add_effect(&mut p, &mut l, "ec.sim.carddance", [50.0, 50.0], &[("cameraSystem", Value::Enum(2))]);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let c = img.get(100, 50);
assert!((c[0] - 0.2).abs() < 0.02 && (c[2] - 1.0).abs() < 0.02 && c[3] > 0.99, "{c:?}");
assert!(img.get(10, 50)[3] < 0.01);
}
// ------------------------------------------------------------------------------ layer cache
fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {

View File

@ -441,6 +441,37 @@ fn dial_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, layer: &Layer, prop: &P
}
}
/// The effect instance a row belongs to, for conditional visibility
/// ([`effectcraft_engine::effects::catalog::param_shown`]): effect id, the instance's root
/// group and the spec-id path of the group being drawn (`""` or `cameraPosition/`).
struct FxScope<'a> {
effect: &'a str,
root: &'a PropGroup,
path: String,
}
impl<'a> FxScope<'a> {
fn sub(&self, m: &str) -> FxScope<'a> {
FxScope { effect: self.effect, root: self.root, path: format!("{}{m}/", self.path) }
}
/// Whether child `m` of the current group is shown.
fn shown(&self, layer: &Layer, ectx: &EvalCtx, m: &str) -> bool {
let root = self.root;
let value = |id: &str| -> Option<Value> {
let mut g = root;
let mut parts = id.split('/').peekable();
while let Some(part) = parts.next() {
if parts.peek().is_none() {
return g.get(part).map(|p| ectx.value(layer, p));
}
g = g.sub(part)?;
}
None
};
effectcraft_engine::effects::catalog::param_shown(self.effect, &format!("{}{m}", self.path), &value)
}
}
#[allow(clippy::too_many_arguments)]
fn group_rows(
app: &mut EffectcraftApp,
@ -452,6 +483,7 @@ fn group_rows(
rect: Rect,
y: &mut f32,
depth: usize,
fx: &FxScope,
actions: &mut Actions,
) {
let t = app.tokens;
@ -459,7 +491,7 @@ fn group_rows(
let r = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), ROW));
match c {
Node::Prop(pr) => {
if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() {
if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() || !fx.shown(layer, ectx, &pr.match_id) {
continue;
}
*y += ROW;
@ -486,7 +518,7 @@ fn group_rows(
}
Node::Group(sg) => {
// Paint strokes live in the Timeline only (AE's Paint shows Paint on Transparent).
if effectcraft_engine::effects::paint::is_paint(g) {
if effectcraft_engine::effects::paint::is_paint(g) || !fx.shown(layer, ectx, &sg.match_id) {
continue;
}
*y += ROW;
@ -503,7 +535,8 @@ fn group_rows(
p.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &sg.name, Tokens::ui(12.0), t.text);
}
if open {
group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, actions);
let sub = fx.sub(&sg.match_id);
group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, &sub, actions);
}
}
}
@ -642,12 +675,24 @@ fn channel_popup(
fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, ectx: &EvalCtx, r: Rect, actions: &mut Actions) {
let t = app.tokens;
let euid = g.uid;
let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(4);
// The channel lives in the effect's hidden Channel parameter (UI state for instances
// saved before it existed).
let chan_prop = g.get("channel");
let ch = match chan_prop {
Some(pr) => ectx.value(layer, pr).as_enum() as usize,
None => app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0),
}
.min(4);
let x0 = r.min.x + 30.0;
let cy = r.min.y + 17.0;
let names: Vec<&str> = fw::CURVE_CHANNELS.iter().map(|c| c.1).collect();
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel")) {
app.ui.fx_curve_channel.insert(euid, n);
match chan_prop {
Some(pr) => actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": n}))),
None => {
app.ui.fx_curve_channel.insert(euid, n);
}
}
}
let pencil = app.ui.fx_curve_pencil.contains(&euid);
let mr = Rect::from_min_size(pos2(x0 + 146.0, cy - 9.0), vec2(56.0, 18.0));
@ -696,7 +741,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
/// Levels parameter ids for a channel: input black, input white, gamma, output black, output white.
fn levels_ids(effect: &str, ch: usize) -> [String; 5] {
if effect == "ec.color.levels" {
return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from);
return effectcraft_engine::effects::levels_channel_ids(ch);
}
// Levels (Individual Controls) nests each channel's controls in its twirl-down group.
let pre = fw::LEVELS_CHANNELS[ch.min(4)].0;
@ -721,7 +766,13 @@ fn levels_editor(
let euid = g.uid;
let x0 = r.min.x + 30.0;
let cy = r.min.y + 17.0;
let ch = if effect == "ec.color.levelsic" { app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4) } else { 0 };
// Levels keeps its channel in the Channel parameter; Levels (Individual Controls) shows
// every channel and the popup only picks the histogram.
let ch = if effect == "ec.color.levelsic" {
app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4)
} else {
g.get("channel").map(|pr| ectx.value(layer, pr).as_enum() as usize).unwrap_or(0).min(4)
};
if effect == "ec.color.levelsic" {
let names: Vec<&str> = fw::LEVELS_CHANNELS.iter().map(|c| c.1).collect();
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel"))
@ -959,7 +1010,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &mut actions);
let scope = FxScope { effect: effect.as_str(), root: g, path: String::new() };
group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &scope, &mut actions);
}
y += 4.0;
}

View File

@ -3,11 +3,11 @@
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
EffectCraft ships 306 effects with 2488 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
EffectCraft ships 306 effects with 3009 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
- **GPU**: 59 effects also run on the GPU compositor with identical results.
- **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping.
- **Status**: 264 implemented in full, 42 partial (what is missing is listed).
- **Status**: 306 implemented in full, 0 partial (what is missing is listed).
Effects are addressed by id (`ec.<category>.<name>`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`).
@ -15,7 +15,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| 3D Channel Extract | `ec.3d.channelextract` | 5 | | 32 | Partial: no anti-alias option |
| 3D Channel Extract | `ec.3d.channelextract` | 6 | | 32 | Implemented |
| Cryptomatte | `ec.3d.cryptomatte` | 4 | | 32 | Implemented |
| Depth Matte | `ec.3d.depthmatte` | 3 | | 32 | Implemented |
| Depth of Field | `ec.3d.depthoffield` | 4 | | 32 | Implemented |
@ -51,7 +51,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Radial Blur | `ec.blur.ccradial` | 4 | | 32 | Implemented |
| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented |
| CC Vector Blur | `ec.blur.ccvector` | 4 | | 32 | Implemented |
| Camera Lens Blur | `ec.blur.cameralens` | 10 | GPU | 32 | Partial: no Diffraction Fringe or Blur Map layer |
| Camera Lens Blur | `ec.blur.cameralens` | 16 | GPU | 32 | Implemented |
| Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented |
| Channel Blur | `ec.blur.channel` | 6 | | 32 | Implemented |
| Compound Blur | `ec.blur.compound` | 4 | | 32 | Implemented |
@ -71,10 +71,10 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Blend | `ec.channel.blend` | 4 | | 32 | Implemented |
| CC Composite | `ec.channel.cccomposite` | 3 | | 32 | Implemented |
| Calculations | `ec.channel.calculations` | 9 | | 32 | Implemented |
| Channel Combiner | `ec.channel.combiner` | 6 | | 32 | Partial: no Saturation Multiplied target |
| Channel Combiner | `ec.channel.combiner` | 6 | | 32 | Implemented |
| Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | | 32 | Implemented |
| Invert | `ec.channel.invert` | 2 | GPU | 32 | Implemented |
| Minimax | `ec.channel.minimax` | 4 | | 32 | Partial: no Don't Shrink Edges option |
| Minimax | `ec.channel.minimax` | 5 | | 32 | Implemented |
| Remove Color Matting | `ec.channel.removecolormatting` | 2 | | 32 | Implemented |
| Set Channels | `ec.channel.setchannels` | 9 | | 32 | Implemented |
| Set Matte | `ec.channel.setmatte` | 6 | | 32 | Implemented |
@ -102,16 +102,16 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | | 32 | Implemented |
| Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented |
| Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented |
| Colorama | `ec.color.colorama` | 5 | GPU | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking |
| Curves | `ec.color.curves` | 0 | GPU | 32 | Partial: no Channel popup |
| Colorama | `ec.color.colorama` | 20 | GPU | 32 | Implemented |
| Curves | `ec.color.curves` | 0 | GPU | 32 | Implemented |
| Equalize | `ec.color.equalize` | 2 | | 32 | Implemented |
| Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented |
| Gamma/Pedestal/Gain | `ec.color.gammapedestalgain` | 10 | | 32 | Implemented |
| Hue/Saturation | `ec.color.huesaturation` | 7 | GPU | 32 | Partial: no per-range Channel Control |
| Hue/Saturation | `ec.color.huesaturation` | 50 | GPU | 32 | Implemented |
| Leave Color | `ec.color.leavecolor` | 5 | | 32 | Implemented |
| Levels | `ec.color.levels` | 7 | GPU | 32 | Partial: master channel only (no Channel popup) |
| Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented |
| Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented |
| Lumetri Color | `ec.color.lumetri` | 38 | GPU | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files |
| Lumetri Color | `ec.color.lumetri` | 66 | GPU | 32 | Implemented |
| OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented |
| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | | 32 | Implemented |
| OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 32 | Implemented |
@ -119,7 +119,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented |
| PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented |
| Photo Filter | `ec.color.photofilter` | 4 | | 32 | Implemented |
| Selective Color | `ec.color.selectivecolor` | 37 | GPU | 32 | Partial: simplified Colors / Details layout |
| Selective Color | `ec.color.selectivecolor` | 38 | GPU | 32 | Implemented |
| Shadow/Highlight | `ec.color.shadowhighlight` | 14 | | 32 | Implemented |
| Tint | `ec.color.tint` | 3 | GPU | 32 | Implemented |
| Tritone | `ec.color.tritone` | 4 | GPU | 32 | Implemented |
@ -154,16 +154,16 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented |
| Mirror | `ec.distort.mirror` | 2 | | 32 | Implemented |
| Offset | `ec.distort.offset` | 2 | | 32 | Implemented |
| Optics Compensation | `ec.distort.opticscompensation` | 5 | | 32 | Partial: no Optimal Pixels |
| Optics Compensation | `ec.distort.opticscompensation` | 6 | | 32 | Implemented |
| Polar Coordinates | `ec.distort.polar` | 2 | | 32 | Implemented |
| Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented |
| Reshape | `ec.distort.reshape` | 6 | | 32 | Partial: no correspondence points |
| Reshape | `ec.distort.reshape` | 6 | | 32 | Implemented |
| Ripple | `ec.distort.ripple` | 7 | GPU | 32 | Implemented |
| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Partial: Pixel Motion Detail has no effect |
| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Implemented |
| Smear | `ec.distort.smear` | 8 | | 32 | Implemented |
| Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented |
| Transform | `ec.distort.transform` | 10 | GPU | 32 | Partial: no shutter-angle motion blur |
| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Partial: no locked pinning variants |
| Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented |
| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Implemented |
| Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented |
| Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented |
| Warp | `ec.distort.warp` | 5 | | 32 | Implemented |
@ -189,7 +189,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| 4-Color Gradient | `ec.generate.fourcolor` | 12 | GPU | 32 | Implemented |
| Advanced Lightning | `ec.generate.advancedlightning` | 16 | | 32 | Partial: no Alpha Obstacle; most Expert Settings missing |
| Advanced Lightning | `ec.generate.advancedlightning` | 24 | | 32 | Implemented |
| Audio Spectrum | `ec.generate.audiospectrum` | 23 | | 32 | Implemented |
| Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented |
| Beam | `ec.generate.beam` | 11 | | 32 | Implemented |
@ -198,19 +198,19 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Light Rays | `ec.generate.cclightrays` | 8 | | 32 | Implemented |
| CC Light Sweep | `ec.generate.cclightsweep` | 9 | | 32 | Implemented |
| CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented |
| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Partial: HQ variants render like the standard ones |
| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Implemented |
| Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented |
| Circle | `ec.generate.circle` | 11 | | 32 | Implemented |
| Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented |
| Eyedropper Fill | `ec.generate.eyedropperfill` | 5 | | 32 | Implemented |
| Fill | `ec.generate.fill` | 3 | GPU | 32 | Partial: no Fill Mask / All Masks / feather |
| Fill | `ec.generate.fill` | 7 | GPU | 32 | Implemented |
| Fractal | `ec.generate.fractal` | 21 | | 32 | Implemented |
| Gradient Ramp | `ec.generate.gradientramp` | 7 | GPU | 32 | Implemented |
| Grid | `ec.generate.grid` | 12 | GPU | 32 | Implemented |
| Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented |
| Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented |
| Radio Waves | `ec.generate.radiowaves` | 17 | | 32 | Partial: no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile |
| Scribble | `ec.generate.scribble` | 21 | | 32 | Partial: no caps, joins or Start/End Apply To |
| Radio Waves | `ec.generate.radiowaves` | 31 | | 32 | Implemented |
| Scribble | `ec.generate.scribble` | 25 | | 32 | Implemented |
| Stroke | `ec.generate.stroke` | 11 | | 32 | Implemented |
| Vegas | `ec.generate.vegas` | 26 | | 32 | Implemented |
| Write-on | `ec.generate.writeon` | 10 | | 32 | Implemented |
@ -222,9 +222,9 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| VR Blur | `ec.vr.blur` | 2 | | 32 | Implemented |
| VR Chromatic Aberrations | `ec.vr.chromaticaberrations` | 8 | | 32 | Implemented |
| VR Color Gradients | `ec.vr.colorgradients` | 19 | | 32 | Implemented |
| VR Converter | `ec.vr.converter` | 8 | | 32 | Partial: common layouts only |
| VR Converter | `ec.vr.converter` | 8 | | 32 | Implemented |
| VR De-Noise | `ec.vr.denoise` | 4 | | 32 | Implemented |
| VR Digital Glitch | `ec.vr.digitalglitch` | 8 | | 32 | Partial: reduced control set |
| VR Digital Glitch | `ec.vr.digitalglitch` | 21 | | 32 | Implemented |
| VR Fractal Noise | `ec.vr.fractalnoise` | 16 | | 32 | Implemented |
| VR Glow | `ec.vr.glow` | 7 | | 32 | Implemented |
| VR Plane to Sphere | `ec.vr.planetosphere` | 6 | | 32 | Implemented |
@ -236,15 +236,15 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Advanced Spill Suppressor | `ec.key.advancedspill` | 5 | | 32 | Partial: no Tolerance, Desaturate or Spill Color Correction |
| Advanced Spill Suppressor | `ec.key.advancedspill` | 8 | | 32 | Implemented |
| CC Simple Wire Removal | `ec.key.ccsimplewireremoval` | 7 | | 32 | Implemented |
| Color Difference Key | `ec.key.colordifference` | 16 | | 32 | Implemented |
| Color Range | `ec.key.colorrange` | 8 | | 32 | Implemented |
| Difference Matte | `ec.key.differencematte` | 6 | | 32 | Implemented |
| Extract | `ec.key.extract` | 6 | | 32 | Implemented |
| Inner/Outer Key | `ec.key.innerouter` | 10 | | 32 | Partial: one additional mask per side; no cleanup strokes |
| Inner/Outer Key | `ec.key.innerouter` | 88 | | 32 | Implemented |
| Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented |
| Key Light | `ec.keying.keylight` | 42 | GPU | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing |
| Key Light | `ec.keying.keylight` | 56 | GPU | 32 | Implemented |
| Linear Color Key | `ec.key.linearcolor` | 6 | GPU | 32 | Implemented |
| Screen Key | `ec.key.screen` | 11 | | 32 | Implemented |
| Unmult | `ec.key.unmult` | 6 | | 32 | Implemented |
@ -255,8 +255,8 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|---|---|---:|:-:|:-:|---|
| Matte Choker | `ec.matte.mattechoker` | 7 | | 32 | Implemented |
| Mocha shape | `ec.obsolete.mochashape` | 6 | | 32 | Implemented |
| Refine Hard Matte | `ec.matte.refinehard` | 11 | | 32 | Partial: no Reduce Chatter or motion blur |
| Refine Soft Matte | `ec.matte.refinesoft` | 13 | | 32 | Partial: no Reduce Chatter or motion blur |
| Refine Hard Matte | `ec.matte.refinehard` | 16 | | 32 | Implemented |
| Refine Soft Matte | `ec.matte.refinesoft` | 18 | | 32 | Implemented |
| Roto Brush & Refine Edge | `ec.matte.rotobrush` | 24 | | 32 | Implemented |
| Simple Choker | `ec.matte.simplechoker` | 2 | | 32 | Implemented |
@ -264,18 +264,18 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Add Grain | `ec.noise.addgrain` | 22 | GPU | 32 | Partial: no preview region, presets, channel balance or temporal controls |
| Add Grain | `ec.noise.addgrain` | 32 | GPU | 32 | Implemented |
| Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented |
| Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented |
| Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented |
| Match Grain | `ec.noise.matchgrain` | 25 | | 32 | Partial: no preview region, presets, sampling or temporal controls |
| Match Grain | `ec.noise.matchgrain` | 37 | | 32 | Implemented |
| Median | `ec.noise.median` | 2 | | 32 | Implemented |
| Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented |
| Noise | `ec.noise.noise` | 3 | GPU | 32 | Implemented |
| Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented |
| Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented |
| Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented |
| Remove Grain | `ec.noise.removegrain` | 7 | | 32 | Partial: no preview region, sampling or temporal filtering |
| Remove Grain | `ec.noise.removegrain` | 20 | | 32 | Implemented |
| Turbulent Noise | `ec.noise.turbulent` | 22 | | 32 | Implemented |
## Obsolete
@ -288,7 +288,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Gaussian Blur (Legacy) | `ec.obsolete.gaussianlegacy` | 2 | | 32 | Implemented |
| Lightning | `ec.obsolete.lightning` | 25 | | 32 | Implemented |
| Luma Key | `ec.key.luma` | 5 | | 32 | Implemented |
| Path Text | `ec.obsolete.pathtext` | 21 | | 32 | Partial: no kerning, shear/scale, line spacing, fade time or jitter |
| Path Text | `ec.obsolete.pathtext` | 32 | | 32 | Implemented |
| Reduce Interlace Flicker | `ec.blur.reduceflicker` | 1 | | 32 | Implemented |
| Spill Suppressor | `ec.key.spill` | 3 | | 32 | Implemented |
@ -329,12 +329,12 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Scatterize | `ec.sim.ccscatterize` | 4 | | 32 | Implemented |
| CC Snowfall | `ec.sim.ccsnowfall` | 19 | | 32 | Implemented |
| CC Star Burst | `ec.sim.ccstarburst` | 6 | | 32 | Implemented |
| Card Dance | `ec.sim.carddance` | 35 | | 32 | Partial: no camera system, corner pins or lighting/material controls |
| Caustics | `ec.sim.caustics` | 21 | | 32 | Partial: no Sky group or light type |
| Foam | `ec.sim.foam` | 24 | | 32 | Partial: no wobble, pop velocity, custom bubble texture, environment map or flow map |
| Particle Playground | `ec.sim.particleplayground` | 37 | | 32 | Partial: no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles |
| Shatter | `ec.sim.shatter` | 26 | | 32 | Partial: no custom shatter map, gradient, textures or extrusion rendering |
| Wave World | `ec.sim.waveworld` | 27 | | 32 | Partial: no wireframe controls, dry-area rendering or ground group |
| Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented |
| Caustics | `ec.sim.caustics` | 28 | | 32 | Implemented |
| Foam | `ec.sim.foam` | 36 | | 32 | Implemented |
| Particle Playground | `ec.sim.particleplayground` | 92 | | 32 | Implemented |
| Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented |
| Wave World | `ec.sim.waveworld` | 35 | | 32 | Implemented |
## Stylize
@ -352,11 +352,11 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Threshold | `ec.stylize.ccthreshold` | 4 | | 32 | Implemented |
| CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | | 32 | Implemented |
| CC Vignette | `ec.stylize.ccvignette` | 4 | | 32 | Implemented |
| Cartoon | `ec.stylize.cartoon` | 11 | | 32 | Partial: no Edge Enhancement |
| Cartoon | `ec.stylize.cartoon` | 12 | | 32 | Implemented |
| Color Emboss | `ec.stylize.coloremboss` | 4 | | 32 | Implemented |
| Emboss | `ec.stylize.emboss` | 4 | GPU | 32 | Implemented |
| Find Edges | `ec.stylize.findedges` | 2 | GPU | 32 | Implemented |
| Glow | `ec.stylize.glow` | 13 | GPU | 32 | Partial: no Glow Operation or arbitrary colour map |
| Glow | `ec.stylize.glow` | 14 | GPU | 32 | Implemented |
| Mosaic | `ec.stylize.mosaic` | 3 | GPU | 32 | Implemented |
| Motion Tile | `ec.stylize.motiontile` | 8 | | 32 | Implemented |
| Posterize | `ec.stylize.posterize` | 1 | | 32 | Implemented |
@ -384,13 +384,13 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Posterize Time | `ec.time.posterizetime` | 1 | | 32 | Implemented |
| Time Difference | `ec.time.timedifference` | 5 | | 32 | Implemented |
| Time Displacement | `ec.time.timedisplacement` | 4 | | 32 | Implemented |
| Timewarp | `ec.time.timewarp` | 10 | | 32 | Partial: Pixel Motion blends frames (no optical flow); no matte, warp or crop controls |
| Timewarp | `ec.time.timewarp` | 28 | | 32 | Implemented |
## Transition
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Block Dissolve | `ec.transition.blockdissolve` | 4 | | 32 | Partial: no Soft Edges option |
| Block Dissolve | `ec.transition.blockdissolve` | 5 | | 32 | Implemented |
| CC Glass Wipe | `ec.transition.ccglasswipe` | 5 | | 32 | Implemented |
| CC Grid Wipe | `ec.transition.ccgridwipe` | 7 | | 32 | Implemented |
| CC Image Wipe | `ec.transition.ccimagewipe` | 7 | | 32 | Implemented |
@ -401,7 +401,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | | 32 | Implemented |
| CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented |
| CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented |
| Card Wipe | `ec.transition.cardwipe` | 12 | | 32 | Partial: 2D flip only; no back layer picker, camera, lighting or jitter |
| Card Wipe | `ec.transition.cardwipe` | 44 | | 32 | Implemented |
| Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented |
| Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented |
| Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented |