From e44af1f7e8aa8bcfac147b842c9a542efb1fa4b2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:15:53 -0400 Subject: [PATCH] M9.13: Keylight colour suppression / balancing and source crop methods, Scribble caps / joins / Start-End Apply To Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/catalog.rs | 2 - crates/effects/src/generate3.rs | 206 +++++++++++++++++++++++++++++++- crates/effects/src/keylight.rs | 178 ++++++++++++++++++++++++++- docs/effects.md | 8 +- 4 files changed, 380 insertions(+), 14 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 8c501a3c..b6e6b40c 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -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"), diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 658ce04c..fe0b14a2 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -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> { + 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::new(); for group in &lines { let lens: Vec = 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 { 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 { 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); + } } diff --git a/crates/effects/src/keylight.rs b/crates/effects/src/keylight.rs index 96a4483a..92a7d74f 100644 --- a/crates/effects/src/keylight.rs +++ b/crates/effects/src/keylight.rs @@ -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 { + 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 { 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 { 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"); + } } diff --git a/docs/effects.md b/docs/effects.md index 8b57bc17..7f5e0aa7 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -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..`) 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..`) 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..`) 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 |