//! Keying effects, batch 2: Difference Matte (against another layer), Inner/Outer Key (mattes //! from masks with colour-based edge estimation and decontamination), Unmult and CC Simple //! Wire Removal. use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use effectcraft_raster::gaussian_blur; use rayon::prelude::*; use crate::util::{Plane, dist_to_poly, gauss_plane, layer_or_self, lerp4, morph_frac, point_in_poly, premul, smoothstep, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Keying", params, render, gpu: false, float: true } } fn pct() -> ParamUi { slider(0.0, 100.0, 0.0, 100.0, 1) } // ---------------------------------------------------------------- Difference Matte fn difference_matte(ctx: &EffectCtx, mut b: Buf) -> Buf { let view = ctx.params.e("view"); if view == 1 { return b; } let tol = (ctx.params.f("matchingTolerance") / 100.0) as f32; let soft = (ctx.params.f("matchingSoftness") / 100.0) as f32; let blur = ctx.params.f("blurBeforeDifference") * b.scale; let other = layer_or_self(ctx, &b, "differenceLayer", true, ctx.params.e("ifLayerSizesDiffer") == 1); let (src, other) = if blur > 0.05 { (gaussian_blur(&b.img, blur * 0.5, blur * 0.5, true), gaussian_blur(&other, blur * 0.5, blur * 0.5, true)) } else { (b.img.clone(), other) }; let matte_only = view == 2; b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { let (c, a) = unpremul(src.data[i]); let (oc, oa) = unpremul(other.data[i]); let d = (c[0] - oc[0]).abs().max((c[1] - oc[1]).abs()).max((c[2] - oc[2]).abs()).max((a - oa).abs()); let m = if d <= tol { 0.0 } else if soft <= 1e-6 { 1.0 } else { ((d - tol) / soft).clamp(0.0, 1.0) }; if matte_only { *px = [m * px[3], m * px[3], m * px[3], px[3]]; } else { *px = px.map(|v| v * m); } }); b } // ---------------------------------------------------------------- Inner/Outer Key fn mask_options() -> ParamUi { popup(&["None", "Mask 1", "Mask 2", "Mask 3", "Mask 4", "Mask 5", "Mask 6", "Mask 7", "Mask 8", "Mask 9", "Mask 10"]) } /// Coverage plane (1 inside) of the selected closed masks, in buffer pixels. fn mask_plane(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option { let shapes: Vec<&crate::MaskShape> = idx.iter().filter(|&&i| i > 0).filter_map(|&i| ctx.env.masks.get(i as usize - 1)).collect(); if shapes.is_empty() { return None; } let (w, h) = (b.img.width as usize, b.img.height as usize); let mut pl = Plane::new(w, h); let inv = 1.0 / b.scale.max(1e-9); pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { for (x, v) in row.iter_mut().enumerate() { let lx = (x as f64 + 0.5 - b.offset[0]) * inv; let ly = (y as f64 + 0.5 - b.offset[1]) * inv; *v = if shapes.iter().any(|s| point_in_poly(&s.points, lx, ly)) { 1.0 } else { 0.0 }; } }); Some(pl) } /// Signed distance (pixels, positive inside) to the selected mask outlines. fn mask_sdf(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option { let shapes: Vec<&crate::MaskShape> = idx.iter().filter(|&&i| i > 0).filter_map(|&i| ctx.env.masks.get(i as usize - 1)).collect(); if shapes.is_empty() { return None; } let (w, h) = (b.img.width as usize, b.img.height as usize); let mut pl = Plane::new(w, h); let inv = 1.0 / b.scale.max(1e-9); pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { for (x, v) in row.iter_mut().enumerate() { let lx = (x as f64 + 0.5 - b.offset[0]) * inv; let ly = (y as f64 + 0.5 - b.offset[1]) * inv; let inside = shapes.iter().any(|s| point_in_poly(&s.points, lx, ly)); let d = shapes.iter().map(|s| dist_to_poly(&s.points, true, lx, ly)).fold(f64::INFINITY, f64::min) * b.scale; *v = if inside { d as f32 } else { -d as f32 }; } }); Some(pl) } /// Inner/Outer Key's geometry (shared with the GPU compositor, effectcraft-gpu `fx_pixel2`): /// the trimap of the selected masks and the Cleanup brush strokes, in buffer pixels. pub struct InnerOuterPlan { /// 1 = known foreground, 0 = known background, 0.5 = unknown. pub tri: Plane, /// Some pixel is unknown (edge colours are estimated and decontaminated). pub unknown: bool, /// Blur radius of the local foreground / background colour estimates. pub sigma: f64, /// Cleanup strokes in order: (matte target, coverage per pixel; negative = beyond the brush). pub strokes: Vec<(f32, Vec)>, /// Edge Thin, Edge Feather (buffer px), Edge Threshold, Invert Extraction, Blend with Original. pub thin: f64, pub feather: f64, pub threshold: f32, pub invert: bool, pub original: f32, } /// [`InnerOuterPlan`] of an Inner/Outer Key instance on `b`'s geometry (`None` = no foreground /// mask: the layer passes through). pub fn inner_outer_plan(ctx: &EffectCtx, b: &Buf) -> Option { // Foreground (Inside) plus Additional Foreground 1–10; likewise for the background. let fg_idx: Vec = std::iter::once(ctx.params.e("foreground")).chain((1..=10).map(|k| ctx.params.e(&format!("additionalForeground/foreground{k}")))).collect(); let bg_idx: Vec = 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 (w, h) = (b.img.width as usize, b.img.height as usize); // Trimap: 1 = known foreground, 0 = known background, 0.5 = unknown. let fg = mask_plane(ctx, b, &fg_idx); let bg = mask_plane(ctx, b, &bg_idx); let tri: Plane = match (fg, bg) { (None, _) => return None, (Some(f), Some(bk)) => f.zip_map(&bk, |f, bk| { if f > 0.5 { 1.0 } else if bk > 0.5 { 0.5 } else { 0.0 } }), (Some(f), None) => { if radius <= 0.0 { f } else { let sdf = mask_sdf(ctx, b, &fg_idx).unwrap_or(f); sdf.map(|d| { if d > radius { 1.0 } else if d < -radius { 0.0 } else { 0.5 } }) } } }; let unknown = tri.data.par_iter().any(|&t| t > 0.25 && t < 0.75); let sigma = (radius.max(4.0) as f64).max(w.min(h) as f64 * 0.02); // Cleanup Foreground / Background: brush strokes along mask paths that paint the matte // towards opaque / transparent (Brush Radius, Brush Pressure). let mut strokes = Vec::new(); 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(); let cov: Vec = (0..w * h) .into_par_iter() .map(|i| { 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 { ((rad - d) / rad.max(1.0) * 2.0).clamp(0.0, 1.0) as f32 * pressure } else { -1.0 } }) .collect(); strokes.push((target, cov)); } } Some(InnerOuterPlan { tri, unknown, sigma, strokes, thin: ctx.params.f("edgeThin") * b.scale, feather: ctx.params.f("edgeFeather") * b.scale, threshold: (ctx.params.f("edgeThreshold") / 100.0) as f32, invert: ctx.params.b("invertExtraction"), original: (ctx.params.f("blendWithOriginal") / 100.0) as f32, }) } fn inner_outer_key(ctx: &EffectCtx, mut b: Buf) -> Buf { let Some(plan) = inner_outer_plan(ctx, &b) else { return b }; let InnerOuterPlan { tri, unknown, sigma, strokes, thin, feather, threshold: thr, invert, original: orig } = plan; let (w, h) = (b.img.width as usize, b.img.height as usize); let mut alpha = tri.clone(); let mut colours: Option> = None; if unknown { // Local foreground / background colour estimates: normalised blurs over known regions. let known_f = tri.map(|t| if t > 0.75 { 1.0 } else { 0.0 }); let known_b = tri.map(|t| if t < 0.25 { 1.0 } else { 0.0 }); let straight: Vec<[f32; 3]> = b.img.data.par_iter().map(|&px| unpremul(px).0).collect(); let est = |known: &Plane| -> Vec<[f32; 3]> { let wsum = gauss_plane(known, sigma, sigma); let chans: Vec = (0..3) .map(|k| { let pl = Plane { w, h, data: straight.par_iter().zip(known.data.par_iter()).map(|(c, &m)| c[k] * m).collect() }; gauss_plane(&pl, sigma, sigma) }) .collect(); (0..w * h).into_par_iter().map(|i| [0, 1, 2].map(|k| chans[k].data[i] / wsum.data[i].max(1e-6))).collect() }; let fc = est(&known_f); let bc = est(&known_b); alpha.data.par_iter_mut().enumerate().for_each(|(i, a)| { if *a > 0.25 && *a < 0.75 { let c = straight[i]; let d = [fc[i][0] - bc[i][0], fc[i][1] - bc[i][1], fc[i][2] - bc[i][2]]; let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; *a = if dd < 1e-6 { 0.5 } else { (((c[0] - bc[i][0]) * d[0] + (c[1] - bc[i][1]) * d[1] + (c[2] - bc[i][2]) * d[2]) / dd).clamp(0.0, 1.0) }; } }); // Decontaminate edge colours: remove the background contribution. colours = Some( (0..w * h) .into_par_iter() .map(|i| { let a = alpha.data[i]; let c = straight[i]; if tri.data[i] > 0.25 && tri.data[i] < 0.75 && a > 0.02 { [0, 1, 2].map(|k| ((c[k] - (1.0 - a) * bc[i][k]) / a).clamp(0.0, 1.0)) } else { c } }) .collect(), ); } for (target, cov) in &strokes { alpha.data.par_iter_mut().zip(cov.par_iter()).for_each(|(a, &c)| { if c >= 0.0 { *a += (target - *a) * c; } }); } if thin.abs() > 0.01 { alpha = morph_frac(&alpha, thin.abs(), thin < 0.0); } if feather > 0.05 { alpha = gauss_plane(&alpha, feather * 0.5, feather * 0.5); } let src = b.img.clone(); b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { let mut m = alpha.data[i]; if thr > 0.0 { m = if m < thr { 0.0 } else { m }; } if invert { m = 1.0 - m; } let (c0, a) = unpremul(src.data[i]); let c = colours.as_ref().map(|v| v[i]).unwrap_or(c0); let keyed = premul(c, a * m); *px = lerp4(keyed, src.data[i], orig); }); b } // ---------------------------------------------------------------- Unmult /// Turns a black (or white) background into transparency. Alpha ramps up from the level (Black /// Level: on the brightest channel; White Level: on the darkest channel, mirrored) over a /// transition whose width is Softness (0 % = hard cutoff at the level, 100 % = the whole /// remaining range, which with the default levels is the classic unmultiply: alpha = brightest /// channel). Remove Color Matting takes the background colour back out of semi-transparent /// pixels; Clip HDR Results keeps the recovered colours in 0..1. fn unmult(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let white = pr.e("backgroundColor") == 1; let level = if white { pr.f("whiteLevel") } else { pr.f("blackLevel") }.clamp(0.0, 100.0) as f32 / 100.0; let soft = pr.f("softness").clamp(0.0, 100.0) as f32 / 100.0; let decon = pr.b("removeColorMatting"); let clip = pr.b("clipHdrResults"); b.img.data.par_iter_mut().for_each(|px| { let (c, a0) = unpremul(*px); // Distance from the background colour, and where the ramp starts on that scale. let (d, start) = if white { (1.0 - c[0].min(c[1]).min(c[2]), 1.0 - level) } else { (c[0].max(c[1]).max(c[2]), level) }; let d = d.clamp(0.0, 1.0); let width = soft * (1.0 - start); let m = if width > 1e-6 { ((d - start) / width).clamp(0.0, 1.0) } else if d > start { 1.0 } else { 0.0 }; if m <= 1e-6 { *px = [0.0; 4]; return; } let bg = if white { 1.0 } else { 0.0 }; let mut out = if decon { c.map(|v| (v - bg * (1.0 - m)) / m) } else { c }; if clip { out = out.map(|v| v.clamp(0.0, 1.0)); } *px = premul(out, a0 * m); }); b } // ---------------------------------------------------------------- CC Simple Wire Removal fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf { let pa = ctx.params.v2("pointA"); let pb = ctx.params.v2("pointB"); let style = ctx.params.e("removalStyle"); let half = (ctx.params.f("thickness") * b.scale * 0.5).max(0.0); if half <= 0.0 { return b; } let slope = (ctx.params.f("slope") / 100.0).clamp(0.0, 1.0) as f32; let mirror = (ctx.params.f("mirrorBlend") / 100.0).clamp(0.0, 1.0) as f32; let (ax, ay) = b.to_px(pa); let (bx, by) = b.to_px(pb); let (dx, dy) = (bx - ax, by - ay); let len = (dx * dx + dy * dy).sqrt(); if len < 1e-6 { return b; } let (tx, ty) = (dx / len, dy / len); let (nx, ny) = (-ty, tx); let src = b.img.clone(); let soft_w = half * slope as f64; // Frame Offset: the same pixels from the previous or next frame, resampled into this grid. let offset = ctx.params.f("frameOffset").round(); let neighbour = if style == 1 && offset != 0.0 { ctx.env.host.and_then(|h| h.self_at(ctx.time + offset / ctx.fps(), ctx.env.effect_index)).map(|nb| { if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale { nb.img } else { let k = nb.scale / b.scale.max(1e-9); crate::util::gen_image(b.img.width, b.img.height, |x, y| { let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0]; let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1]; nb.img.sample_bilinear(lx, ly) }) } }) } else { None }; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay); let d = qx * nx + qy * ny; let ad = d.abs(); if ad > half + soft_w { continue; } // Positions just outside the wire on both sides. let base = (x as f64 + 0.5 - d * nx, y as f64 + 0.5 - d * ny); let edge = half + 1.0; let pos = |s: f64| src.sample_bilinear_clamped(base.0 + nx * s, base.1 + ny * s); let repl = match style { 0 => { let t = ((d + edge) / (2.0 * edge)) as f32; lerp4(pos(-edge), pos(edge), t.clamp(0.0, 1.0)) } 1 if neighbour.is_some() => neighbour.as_ref().map_or(*px, |n| n.get(x as i64, y as i64)), // Displace: shift the image across the wire from the nearer side (also Frame Offset // when no other frame is available). 2 | 1 => { let s = if d >= 0.0 { d + 2.0 * (half - ad) + 1.0 } else { d - 2.0 * (half - ad) - 1.0 }; let sh = pos(s); if mirror > 0.0 { lerp4(sh, pos(-s), mirror * 0.5) } else { sh } } _ => { // Displace Horizontal: sample left/right of the wire along x. let sx = if nx.abs() > 1e-6 { (half - ad + 1.0) / nx.abs() } else { 0.0 }; let dir = if d * nx >= 0.0 { 1.0 } else { -1.0 }; let sh = src.sample_bilinear_clamped(x as f64 + 0.5 + dir * sx, y as f64 + 0.5); let mi = src.sample_bilinear_clamped(x as f64 + 0.5 - dir * sx, y as f64 + 0.5); if mirror > 0.0 { lerp4(sh, mi, mirror * 0.5) } else { sh } } }; let k = if ad <= half { 1.0 } else { 1.0 - smoothstep(half as f32, (half + soft_w) as f32, ad as f32) }; *px = lerp4(*px, repl, k); } }); b } pub fn specs() -> Vec { vec![ spec( "ec.key.differencematte", "Difference Matte", vec![ p("view", "View", Value::Enum(0), popup(&["Final Output", "Source Only", "Matte Only"])), p("differenceLayer", "Difference Layer", Value::Layer(None), ParamUi::Layer), p("ifLayerSizesDiffer", "If Layer Sizes Differ", Value::Enum(0), popup(&["Center", "Stretch to Fit"])), p("matchingTolerance", "Matching Tolerance", num(15.0), pct()), p("matchingSoftness", "Matching Softness", num(0.0), pct()), p("blurBeforeDifference", "Blur Before Difference", num(0.0), slider(0.0, 1000.0, 0.0, 20.0, 1)), ], difference_matte, ), spec( "ec.key.innerouter", "Inner/Outer Key", { 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( "ec.key.unmult", "Unmult", vec![ p("backgroundColor", "Background Color", Value::Enum(0), popup(&["Black", "White"])), p("blackLevel", "Black Level", num(0.0), pct()), p("whiteLevel", "White Level", num(100.0), pct()), p("softness", "Softness", num(100.0), pct()), p("removeColorMatting", "Remove Color Matting", Value::Bool(true), ParamUi::Checkbox), p("clipHdrResults", "Clip HDR Results", Value::Bool(true), ParamUi::Checkbox), ], unmult, ), spec( "ec.key.ccsimplewireremoval", "CC Simple Wire Removal", vec![ p("pointA", "Point A", Value::Vec2([0.25, 0.25]), ParamUi::Point), p("pointB", "Point B", Value::Vec2([0.75, 0.75]), ParamUi::Point), p("removalStyle", "Removal Style", Value::Enum(2), popup(&["Fade", "Frame Offset", "Displace", "Displace Horizontal"])), p("thickness", "Thickness", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 1)), p("slope", "Slope", num(0.0), pct()), p("mirrorBlend", "Mirror Blend", num(0.0), pct()), p("frameOffset", "Frame Offset", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 0)), ], wire_removal, ), ] } #[cfg(test)] mod tests { use super::*; use crate::channel2::tests::{FakeHost, sample}; use crate::{EffectEnv, MaskShape, run_fx}; use effectcraft_raster::Image; #[test] fn difference_matte_self_is_transparent_and_other_keeps() { let img = sample(); let out = run_fx("ec.key.differencematte", &[], img.clone(), 0.0, EffectEnv::default()); assert!(out.img.data.iter().all(|p| p[3] == 0.0)); let host = FakeHost(Image::filled(8, 6, [0.0, 0.0, 0.4, 1.0])); let env = EffectEnv { host: Some(&host), ..Default::default() }; let out = run_fx("ec.key.differencematte", &[("differenceLayer", Value::Layer(Some(7)))], img.clone(), 0.0, env); assert_eq!(out.img.get(5, 5), img.get(5, 5)); assert_eq!(out.img.get(0, 0)[3], 0.0, "matching pixel keyed out"); let again = run_fx("ec.key.differencematte", &[("differenceLayer", Value::Layer(Some(7)))], img.clone(), 0.0, env); assert_eq!(out.img.data, again.img.data); } #[test] fn inner_outer_key_inside_mask() { let img = Image::filled(20, 20, [0.8, 0.2, 0.2, 1.0]); let masks = vec![MaskShape { name: "Mask 1".into(), points: vec![[5.0, 5.0], [15.0, 5.0], [15.0, 15.0], [5.0, 15.0]], closed: true, inverted: false }]; let env = EffectEnv { masks: &masks, ..Default::default() }; let out = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1))], img.clone(), 0.0, env); assert!((out.img.get(10, 10)[3] - 1.0).abs() < 1e-6); assert_eq!(out.img.get(1, 1)[3], 0.0); // Two masks: unknown band estimated from colour. let mut img2 = Image::filled(20, 20, [0.0, 0.0, 1.0, 1.0]); for y in 0..20 { for x in 7..13 { img2.set(x, y, [1.0, 0.0, 0.0, 1.0]); } } let masks2 = vec![ MaskShape { name: "in".into(), points: vec![[8.0, 0.0], [12.0, 0.0], [12.0, 20.0], [8.0, 20.0]], closed: true, inverted: false }, MaskShape { name: "out".into(), points: vec![[4.0, 0.0], [16.0, 0.0], [16.0, 20.0], [4.0, 20.0]], closed: true, inverted: false }, ]; let env2 = EffectEnv { masks: &masks2, ..Default::default() }; let o2 = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1)), ("background", Value::Enum(2))], img2.clone(), 0.0, env2); assert!(o2.img.get(7, 10)[3] > 0.8, "{:?}", o2.img.get(7, 10)); assert!(o2.img.get(5, 10)[3] < 0.2, "{:?}", o2.img.get(5, 10)); assert_eq!(o2.img.get(1, 10)[3], 0.0); let o3 = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1)), ("background", Value::Enum(2))], img2, 0.0, env2); assert_eq!(o2.img.data, o3.img.data); } #[test] fn unmult_black_becomes_transparent() { let mut img = Image::filled(4, 4, [0.0, 0.0, 0.0, 1.0]); img.set(1, 1, [0.5, 0.25, 0.0, 1.0]); let out = run_fx("ec.key.unmult", &[], img, 0.0, EffectEnv::default()); assert_eq!(out.img.get(0, 0), [0.0; 4]); let p = out.img.get(1, 1); assert!((p[3] - 0.5).abs() < 1e-6 && (p[0] - 0.5).abs() < 1e-6); } #[test] fn unmult_white_background_levels_and_softness() { // White background: white goes transparent, a half-grey pixel half transparent with its // colour recovered as black. let mut img = Image::filled(3, 1, [1.0, 1.0, 1.0, 1.0]); img.set(1, 0, [0.5, 0.5, 0.5, 1.0]); img.set(2, 0, [0.0, 0.0, 0.0, 1.0]); let out = run_fx("ec.key.unmult", &[("backgroundColor", Value::Enum(1))], img.clone(), 0.0, EffectEnv::default()); assert_eq!(out.img.get(0, 0), [0.0; 4]); let p = out.img.get(1, 0); assert!((p[3] - 0.5).abs() < 1e-5 && p[0].abs() < 1e-5, "{p:?}"); assert_eq!(out.img.get(2, 0), [0.0, 0.0, 0.0, 1.0]); // Hard cutoff: Softness 0 with Black Level 40 % keeps a 50 % grey fully opaque, and // without Remove Color Matting the colour is untouched. let hard = run_fx( "ec.key.unmult", &[("blackLevel", num(40.0)), ("softness", num(0.0)), ("removeColorMatting", Value::Bool(false))], img, 0.0, EffectEnv::default(), ); assert_eq!(hard.img.get(1, 0), [0.5, 0.5, 0.5, 1.0]); assert_eq!(hard.img.get(2, 0), [0.0; 4]); } #[test] fn wire_removal_hides_a_wire() { let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]); for y in 0..20 { img.set(10, y, [0.0, 0.0, 0.0, 1.0]); } for style in 0..4u32 { let out = run_fx( "ec.key.ccsimplewireremoval", &[("pointA", Value::Vec2([10.5, 0.0])), ("pointB", Value::Vec2([10.5, 20.0])), ("removalStyle", Value::Enum(style)), ("thickness", num(3.0))], img.clone(), 0.0, EffectEnv::default(), ); assert!((out.img.get(10, 10)[0] - 0.5).abs() < 0.05, "style {style}: {:?}", out.img.get(10, 10)); assert_eq!(out.img.get(2, 10), img.get(2, 10)); } } #[test] fn wire_removal_frame_offset_uses_neighbour_frame() { use crate::{EffectHost, LayerPixels}; struct Frames; impl EffectHost for Frames { fn layer(&self, _: u64, _: bool) -> Option { None } fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { None } fn self_at(&self, t: f64, _: usize) -> Option { // Frame n (at 10 fps) is a flat grey of n / 10. let v = (t * 10.0).round() as f32 / 10.0; Some(Buf { img: Image::filled(20, 20, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 }) } } let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]); for y in 0..20 { img.set(10, y, [0.0, 0.0, 0.0, 1.0]); } let host = Frames; let run = |style: u32, off: f64| { let env = EffectEnv { host: Some(&host), frame_rate: 10.0, ..Default::default() }; let vals = [ ("pointA", Value::Vec2([10.5, 0.0])), ("pointB", Value::Vec2([10.5, 20.0])), ("removalStyle", Value::Enum(style)), ("thickness", num(3.0)), ("frameOffset", num(off)), ]; run_fx("ec.key.ccsimplewireremoval", &vals, img.clone(), 0.5, env).img }; // At 0.5 s (frame 5) offset -1 shows frame 4 and +1 shows frame 6 inside the strip. let (prev, next) = (run(1, -1.0), run(1, 1.0)); assert!((prev.get(10, 10)[0] - 0.4).abs() < 1e-4, "{:?}", prev.get(10, 10)); assert!((next.get(10, 10)[0] - 0.6).abs() < 1e-4, "{:?}", next.get(10, 10)); // Outside the strip the current frame stays. assert_eq!(prev.get(2, 10), img.get(2, 10)); // Displace ignores Frame Offset. assert_eq!(run(2, -1.0).data, run(2, 1.0).data); } #[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); } }