M9.13: Keylight colour suppression / balancing and source crop methods, Scribble caps / joins / Start-End Apply To

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 05:15:53 -04:00
1 parent 6d5e86601f
commit e44af1f7e8
4 files changed
+380 -14

No files matched your search

-2
View File
@@ -11,8 +11,6 @@ pub const PARTIAL: &[(&str, &str)] = &[
("ec.distort.reshape", "no correspondence points"),
("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"),
("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"),
("ec.generate.scribble", "no caps, joins or Start/End Apply To"),
("ec.keying.keylight", "no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing"),
("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"),
+202 -4
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);
}
}
+174 -4
View File
@@ -126,6 +126,104 @@ 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;
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 +249,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 +347,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 +374,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 +403,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 +496,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 +509,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 +630,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");
}
}
+4 -4
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 2854 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 2872 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**: 290 implemented in full, 13 partial (what is missing is listed).
- **Status**: 292 implemented in full, 11 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`).
@@ -209,7 +209,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented |
| Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented |
| Radio Waves | `ec.generate.radiowaves` | 31 | | 32 | Implemented |
| Scribble | `ec.generate.scribble` | 21 | | 32 | Partial: no caps, joins or Start/End Apply To |
| 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 |
@@ -243,7 +243,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Extract | `ec.key.extract` | 6 | | 32 | Implemented |
| Inner/Outer Key | `ec.key.innerouter` | 88 | | 32 | Implemented |
| Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented |
| Key Light | `ec.keying.keylight` | 42 | | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing |
| Key Light | `ec.keying.keylight` | 56 | | 32 | Implemented |
| Linear Color Key | `ec.key.linearcolor` | 6 | | 32 | Implemented |
| Screen Key | `ec.key.screen` | 11 | | 32 | Implemented |
| Unmult | `ec.key.unmult` | 6 | | 32 | Implemented |