M9.13: Fill masks/feather, Refine mattes Reduce Chatter and motion blur

- Fill: Fill Mask, All Masks, Horizontal / Vertical Feather, Invert outside the mask
  (GPU falls back to the CPU when masks are used).
- Refine Soft / Hard Matte: Reduce Chatter (temporal steadying of oscillating edges) and
  Use Motion Blur (samples, shutter angle, higher quality) from neighbouring frames.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 03:30:33 -04:00
1 parent ba5e2061fe
commit 3103e7e80f
6 files changed
+260 -41

No files matched your search

-3
View File
@@ -19,7 +19,6 @@ pub const PARTIAL: &[(&str, &str)] = &[
("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"),
@@ -27,8 +26,6 @@ pub const PARTIAL: &[(&str, &str)] = &[
("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"),
+89
View File
@@ -340,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];
@@ -755,8 +811,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,
@@ -1069,4 +1129,33 @@ mod tests {
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]);
}
}
+6 -1
View File
@@ -62,7 +62,9 @@ use std::collections::HashMap;
use std::sync::OnceLock;
pub use card3d::{CompLight, CompScene};
pub use color_fx::{HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip};
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;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
@@ -693,6 +695,9 @@ 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",
// Temporal Smoothing reads neighbouring frames.
+156 -32
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)));
}
}
}
+4
View File
@@ -345,6 +345,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;
+5 -5
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 303 effects with 2702 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 303 effects with 2716 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**: 24 effects also run on the GPU compositor with identical results.
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
- **Status**: 270 implemented in full, 33 partial (what is missing is listed).
- **Status**: 273 implemented in full, 30 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`).
@@ -202,7 +202,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| 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 | | 32 | Implemented |
@@ -254,8 +254,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 |