Files
effectcraft/crates/effects/src/keylight.rs
T
bflatasticandClaude Opus 5.5 73b2607c9a M5.16: Key Light: keyer eyedroppers pick from the effect input (effect.pickColor); "keylight" search
- effect.pickColor {effect, param|prop, x, y (layer px), average?}: the colour of the effect's
  input at a point, set on the parameter (one undo step). Keying effects' Effect Controls
  eyedroppers use it, since the shown frame is already keyed; Ctrl/Cmd+click averages 5x5.
- effects::aliases / name_matches: Effects & Presets and effect.list (MCP list_effects) find
  Key Light by After Effects' name ("Keylight (1.2)").
- End-to-end test: a green-screen plate with an uneven screen, spill and soft edges keyed via
  effect.apply "Keylight (1.2)", pick, Clip Black / White and a render; UI eyedropper test.
- Docs: parity, agents (green-screen recipe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:04:10 +07:00

682 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Key Light — a full screen-colour keyer with the controls of After Effects' bundled
//! "Keylight (1.2)" (that name is a trademark of its vendor, so the effect is registered as
//! **Key Light**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]).
//!
//! The method is the textbook *screen difference* key, written from the public description of
//! the controls:
//!
//! * colours are first neutralised by the **Alpha Bias** / **Despill Bias** colours (each channel
//! scaled so the bias colour becomes grey — mid grey = no change);
//! * the **screen difference** of a pixel is its screen-primary channel minus a balance-weighted
//! mix of the other two (**Screen Balance**); the raw matte is
//! `1 − Screen Gain × diff(pixel) / diff(screen colour)`;
//! * the **Screen Matte** is then clipped (Clip Black / White, with Clip Rollback restoring edge
//! detail), despotted (morphological open/close: Despot Black / White), shrunk or grown and
//! softened, combined with the **Inside** / **Outside** masks and the source alpha;
//! * the foreground is the source with the screen colour removed in proportion to the
//! transparency (`(c − (1 − α)·S) / α`), then **despilled** (the primary is limited to the
//! balanced secondary), with **Replace Method** colouring areas whose alpha was raised by
//! clipping or the inside mask, and optional foreground and edge colour correction.
//!
//! Views: Source, Source Alpha, Corrected Source, Colour Correction Edges, Screen Matte, Inside
//! Mask, Outside Mask, Combined Matte, Status, Intermediate Result and Final Result.
use effectcraft_color::luminance;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, gaussian_blur};
use rayon::prelude::*;
use crate::util::{Plane, gauss_plane, morph_frac, point_in_poly, premul, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
/// Other names the effect answers to in [`crate::lookup`].
pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight"];
pub const VIEWS: [&str; 11] = [
"Source",
"Source Alpha",
"Corrected Source",
"Colour Correction Edges",
"Screen Matte",
"Inside Mask",
"Outside Mask",
"Combined Matte",
"Status",
"Intermediate Result",
"Final Result",
];
/// Display names of the effect's twirl-down groups (by match id).
pub const GROUPS: &[(&str, &str)] = &[
("screenMatte", "Screen Matte"),
("insideMask", "Inside Mask"),
("outsideMask", "Outside Mask"),
("foregroundColourCorrection", "Foreground Colour Correction"),
("edgeColourCorrection", "Edge Colour Correction"),
("sourceCrops", "Source Crops"),
];
const REPLACE: [&str; 4] = ["None", "Source", "Hard Colour", "Soft Colour"];
fn pct(d: f64) -> (Value, ParamUi) {
(num(d), slider(0.0, 100.0, 0.0, 100.0, 1))
}
fn mask_popup() -> ParamUi {
popup(&["None", "Mask 1", "Mask 2", "Mask 3", "Mask 4", "Mask 5", "Mask 6", "Mask 7", "Mask 8", "Mask 9", "Mask 10"])
}
/// Index of the screen's primary channel and the two others (larger-in-screen first).
fn primary(s: [f32; 3]) -> (usize, usize, usize) {
let pi = if s[1] >= s[0] && s[1] >= s[2] {
1
} else if s[2] >= s[0] {
2
} else {
0
};
let (a, b) = match pi {
0 => (1, 2),
1 => (0, 2),
_ => (0, 1),
};
if s[a] >= s[b] { (pi, a, b) } else { (pi, b, a) }
}
/// Screen difference: primary minus the balance-weighted secondaries.
#[inline]
pub fn screen_diff(c: [f32; 3], pi: usize, o1: usize, o2: usize, bal: f32) -> f32 {
c[pi] - (bal * c[o1] + (1.0 - bal) * c[o2])
}
/// Scale channels so `bias` becomes neutral (grey keeps everything as is).
#[inline]
fn neutralise(c: [f32; 3], bias: [f32; 3]) -> [f32; 3] {
let l = luminance(bias[0], bias[1], bias[2]).max(1e-4);
[0, 1, 2].map(|i| c[i] * l / bias[i].max(1e-4))
}
fn mask_coverage(ctx: &EffectCtx, b: &Buf, idx: u32, softness: f64, invert: bool) -> Option<Plane> {
let shape = ctx.env.masks.get((idx as usize).checked_sub(1)?)?;
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 point_in_poly(&shape.points, lx, ly) != shape.inverted { 1.0 } else { 0.0 };
}
});
let s = softness * b.scale;
if s > 0.0 {
pl = gauss_plane(&pl, s * 0.5, s * 0.5);
}
if invert {
pl = pl.map(|v| 1.0 - v);
}
Some(pl)
}
/// Saturation / contrast / brightness correction of a straight colour.
fn correct(c: [f32; 3], sat: f32, contrast: f32, bright: f32) -> [f32; 3] {
let l = luminance(c[0], c[1], c[2]);
let c = c.map(|v| l + (v - l) * sat);
c.map(|v| ((v - 0.5) * contrast + 0.5) * bright)
}
/// Colour Suppression choices.
const SUPPRESS: [&str; 7] = ["None", "Red", "Green", "Blue", "Cyan", "Magenta", "Yellow"];
/// Source Crops' X / Y Method choices.
const CROP_METHODS: [&str; 4] = ["Colour", "Repeat", "Reflect", "Wrap"];
/// (colour, balance 0..1, amount 0..1) of a Colour Suppression setting.
fn suppression(ctx: &EffectCtx, kind: &str, bal: &str, amt: &str) -> (u32, f32, f32) {
(ctx.params.e(kind), (ctx.params.get(bal).map(Value::as_f64).unwrap_or(50.0) / 100.0) as f32, (ctx.params.f(amt) / 100.0) as f32)
}
/// Colour Suppression: remove the excess of a primary (over a Balance-weighted mix of the other
/// two) or of a secondary (the smaller of its two primaries over the third).
fn suppress(mut c: [f32; 3], (kind, bal, amt): (u32, f32, f32)) -> [f32; 3] {
if kind == 0 || amt <= 0.0 {
return c;
}
match kind {
1..=3 => {
let k = kind as usize - 1;
let (a, b) = ((k + 1) % 3, (k + 2) % 3);
let limit = c[a] * bal + c[b] * (1.0 - bal);
c[k] -= (c[k] - limit).max(0.0) * amt;
}
_ => {
// Cyan = G + B over R, Magenta = R + B over G, Yellow = R + G over B.
let third = match kind {
4 => 0,
5 => 1,
_ => 2,
};
let (a, b) = ((third + 1) % 3, (third + 2) % 3);
let excess = (c[a].min(c[b]) - c[third]).max(0.0) * amt;
c[a] -= excess;
c[b] -= excess;
}
}
c
}
/// (tint offset) of a Colour Balancing wheel: hue (degrees) and saturation (0..1).
fn balance(ctx: &EffectCtx, hue: &str, sat: &str) -> [f32; 3] {
let s = (ctx.params.f(sat) / 100.0) as f32;
if s <= 0.0 {
return [0.0; 3];
}
let (r, g, b) = effectcraft_color::hsl_to_rgb((ctx.params.f(hue) / 360.0).rem_euclid(1.0) as f32, 1.0, 0.5);
let l = luminance(r, g, b);
[(r - l) * s * 0.5, (g - l) * s * 0.5, (b - l) * s * 0.5]
}
/// Colour Balancing: shift the colour towards the wheel's hue, keeping its luminance.
fn colour_balance(c: [f32; 3], tint: [f32; 3]) -> [f32; 3] {
if tint == [0.0; 3] {
return c;
}
[c[0] + tint[0], c[1] + tint[1], c[2] + tint[2]].map(|v| v.max(0.0))
}
/// The layer with Source Crops applied: inside the crop the picture, outside it the Edge Colour
/// (X / Y Method Colour) or the kept picture repeated, reflected or wrapped.
fn crop_source(ctx: &EffectCtx, b: &Buf) -> Image {
let pr = ctx.params;
let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0);
if crops.iter().all(|c| *c <= 0.0) {
return b.img.clone();
}
let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale);
let (x0, x1) = (b.offset[0] + crops[0] * lw, b.offset[0] + (1.0 - crops[1]) * lw);
let (y0, y1) = (b.offset[1] + crops[2] * lh, b.offset[1] + (1.0 - crops[3]) * lh);
let methods = [pr.e("sourceCrops/xMethod"), pr.e("sourceCrops/yMethod")];
let ec = pr.color("sourceCrops/edgeColour");
let ea = (pr.f("sourceCrops/edgeColourAlpha") / 100.0) as f32;
if methods == [0, 0] && ea <= 0.0 {
// Transparent edge colour: the borders are cut from the result instead (keying sees
// the whole picture).
return b.img.clone();
}
let edge = premul([ec[0], ec[1], ec[2]], ea);
// Fold a coordinate into [lo, hi) by a method; None = outside with Colour.
let fold = |v: f64, lo: f64, hi: f64, m: u32| -> Option<f64> {
let span = (hi - lo).max(1.0);
if v >= lo && v < hi {
return Some(v);
}
match m {
1 => Some(v.clamp(lo, hi - 0.5)),
2 => {
let t = (v - lo).rem_euclid(2.0 * span);
Some(lo + if t >= span { 2.0 * span - t - 0.5 } else { t })
}
3 => Some(lo + (v - lo).rem_euclid(span)),
_ => None,
}
};
crate::util::gen_image(b.img.width, b.img.height, |x, y| {
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
match (fold(fx, x0, x1, methods[0]), fold(fy, y0, y1, methods[1])) {
(Some(sx), Some(sy)) => b.img.get_clamped(sx.floor() as i64, sy.floor() as i64),
_ => edge,
}
})
}
fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let view = pr.e("view");
if view == 0 {
return b;
}
let sc = pr.color("screenColour");
let screen = [sc[0], sc[1], sc[2]];
let (pi, o1, o2) = primary(screen);
let gain = pr.f("screenGain") as f32 / 100.0;
let bal = pr.f("screenBalance") as f32 / 100.0;
let dbias = pr.color("despillBias");
let abias = if pr.b("lockBiasesTogether") { dbias } else { pr.color("alphaBias") };
let (dbias, abias) = ([dbias[0], dbias[1], dbias[2]], [abias[0], abias[1], abias[2]]);
let s_alpha = neutralise(screen, abias);
let sd = screen_diff(s_alpha, pi, o1, o2, bal).max(1e-3);
let cb = pr.f("screenMatte/clipBlack") as f32 / 100.0;
let cw = pr.f("screenMatte/clipWhite") as f32 / 100.0;
let rollback = pr.f("screenMatte/clipRollback") * b.scale;
let preblur = pr.f("screenPreblur") * b.scale;
let sg = pr.f("screenMatte/screenShrinkGrow") * b.scale;
let soft = pr.f("screenMatte/screenSoftness") * b.scale;
let despot_b = pr.f("screenMatte/screenDespotBlack") * b.scale;
let despot_w = pr.f("screenMatte/screenDespotWhite") * b.scale;
// 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);
&blurred
} else {
&src
};
// Raw screen matte (opacity of the foreground).
let raw = Plane::from_image(key_src, |px| {
let (c, _) = unpremul(px);
let d = screen_diff(neutralise(c, abias), pi, o1, o2, bal);
(1.0 - gain * d / sd).clamp(0.0, 1.0)
});
let clip = |v: f32| ((v - cb) / (cw - cb).max(1e-4)).clamp(0.0, 1.0);
let mut m = raw.map(clip);
if rollback > 0.0 {
// Restore edge detail lost by clipping within `rollback` pixels of the clipped matte.
let grown = morph_frac(&m, rollback, true);
let shrunk = morph_frac(&m, rollback, false);
m = Plane {
w: m.w,
h: m.h,
data: (0..m.data.len())
.into_par_iter()
.map(|i| if grown.data[i] > shrunk.data[i] + 1e-4 { m.data[i].max(raw.data[i]).min(grown.data[i]) } else { m.data[i] })
.collect(),
};
}
if despot_b > 0.0 {
// Fill small holes: close (dilate then erode).
m = morph_frac(&morph_frac(&m, despot_b, true), despot_b, false);
}
if despot_w > 0.0 {
// Remove small specks: open (erode then dilate).
m = morph_frac(&morph_frac(&m, despot_w, false), despot_w, true);
}
if sg < 0.0 {
m = morph_frac(&m, -sg, false);
} else if sg > 0.0 {
m = morph_frac(&m, sg, true);
}
if soft > 0.0 {
m = gauss_plane(&m, soft * 0.5, soft * 0.5);
}
let screen_matte = m.clone();
let inside = mask_coverage(ctx, &b, pr.e("insideMask/insideMask"), pr.f("insideMask/insideMaskSoftness"), pr.b("insideMask/invertInsideMask"));
let outside = mask_coverage(ctx, &b, pr.e("outsideMask/outsideMask"), pr.f("outsideMask/outsideMaskSoftness"), pr.b("outsideMask/invertOutsideMask"));
let src_alpha_mode = pr.e("insideMask/sourceAlpha");
let n = src.data.len();
let combined: Vec<f32> = (0..n)
.into_par_iter()
.map(|i| {
let mut a = screen_matte.data[i];
let sa = src.data[i][3].clamp(0.0, 1.0);
let ins = inside.as_ref().map_or(0.0, |p| p.data[i]);
let ins = if src_alpha_mode == 1 { ins.max(sa) } else { ins };
a = a.max(ins);
if let Some(o) = &outside {
a *= 1.0 - o.data[i];
}
if src_alpha_mode == 2 {
a *= sa;
}
a.clamp(0.0, 1.0)
})
.collect();
let edges: Option<Vec<f32>> = pr.b("edgeColourCorrection/enableEdgeColourCorrection").then(|| {
let grow = pr.f("edgeColourCorrection/edgeGrow") * b.scale;
let hard = (pr.f("edgeColourCorrection/edgeHardness") / 100.0) as f32;
let esoft = pr.f("edgeColourCorrection/edgeSoftness") * b.scale;
// Edge band: where the (grown) combined matte is partial.
let cm = Plane { w: m.w, h: m.h, data: combined.clone() };
let band = cm.map(|a| if a > 1e-3 && a < 0.999 { 1.0 } else { 0.0 });
let mut band = if grow > 0.0 { morph_frac(&band, grow, true) } else { band };
if esoft > 0.0 {
band = gauss_plane(&band, esoft * 0.5, esoft * 0.5);
}
band.data.iter().map(|v| (v * (1.0 + hard * 4.0)).min(1.0)).collect()
});
let replace = pr.e("screenMatte/replaceMethod");
let rc = pr.color("screenMatte/replaceColour");
let in_replace = pr.e("insideMask/insideReplaceMethod");
let in_rc = pr.color("insideMask/insideReplaceColour");
let fg_cc = pr.b("foregroundColourCorrection/enableColourCorrection");
let (fs, fc, fbr) = (
pr.f("foregroundColourCorrection/saturation") as f32 / 100.0,
pr.f("foregroundColourCorrection/contrast") as f32 / 100.0 + 1.0,
pr.f("foregroundColourCorrection/brightness") as f32 / 100.0 + 1.0,
);
let (es, ec, ebr) = (
pr.f("edgeColourCorrection/edgeSaturation") as f32 / 100.0,
pr.f("edgeColourCorrection/edgeContrast") as f32 / 100.0 + 1.0,
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;
let (scale, off) = (b.scale, b.offset);
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let (c, _) = unpremul(src.data[i]);
let a = combined[i];
let sm = screen_matte.data[i];
// 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]) && 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));
let nfg = neutralise(fg, dbias);
let spill = screen_diff(nfg, pi, o1, o2, bal).max(0.0);
let l0 = (luminance(dbias[0], dbias[1], dbias[2]) / dbias[pi].max(1e-4)).max(1e-4);
fg[pi] -= spill / l0;
let intermediate = fg;
// Replace colour where the alpha was raised beyond the raw screen matte.
let raised = (sm - raw.data[i]).max(0.0);
let raised_in = inside.as_ref().map_or(0.0, |p| (p.data[i] - sm).max(0.0));
let apply_replace = |fg: [f32; 3], method: u32, rc: [f32; 4], amt: f32| -> [f32; 3] {
if amt <= 0.0 {
return fg;
}
match method {
1 => [0, 1, 2].map(|k| fg[k] + (c[k] - fg[k]) * amt),
2 => [rc[0], rc[1], rc[2]],
3 => {
let l = luminance(c[0], c[1], c[2]) / luminance(rc[0], rc[1], rc[2]).max(1e-4);
[0, 1, 2].map(|k| fg[k] + (rc[k] * l - fg[k]) * amt)
}
_ => fg,
}
};
fg = apply_replace(fg, replace, rc, raised);
fg = apply_replace(fg, in_replace, in_rc, raised_in);
if fg_cc {
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 = 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];
*px = match view {
1 => grey(src.data[i][3]),
2 => {
let mut cs = neutralise(c, dbias);
let s2 = screen_diff(cs, pi, o1, o2, bal).max(0.0);
cs[pi] -= s2;
if fg_cc {
cs = correct(cs, fs, fc, fbr);
}
[cs[0], cs[1], cs[2], 1.0]
}
3 => grey(edge),
4 => grey(sm),
5 => grey(inside.as_ref().map_or(0.0, |p| p.data[i])),
6 => grey(outside.as_ref().map_or(0.0, |p| p.data[i])),
7 => grey(a),
8 => {
let v = if a <= 1e-3 {
0.0
} else if a >= 0.999 {
1.0
} else {
0.5
};
grey(v)
}
9 => premul(intermediate, sm),
_ => {
if cropped {
[0.0; 4]
} else if unpremultiply {
premul(fg, a)
} else {
// Keylight's premultiplied output read as straight colour.
premul(fg.map(|v| v * a), a)
}
}
};
});
b
}
pub fn specs() -> Vec<EffectSpec> {
let pp = |id: &'static str, name: &'static str, v: (Value, ParamUi)| p(id, name, v.0, v.1);
let cc = |id: &'static str, name: &'static str| p(id, name, num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1));
vec![EffectSpec {
id: "ec.keying.keylight",
name: "Key Light",
category: "Keying",
params: vec![
p("view", "View", Value::Enum(10), popup(&VIEWS)),
p("unpremultiplyResult", "Unpremultiply Result", Value::Bool(true), ParamUi::Checkbox),
p("screenColour", "Screen Colour", col(0.0, 1.0, 0.0), ParamUi::Color),
p("screenGain", "Screen Gain", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
p("screenBalance", "Screen Balance", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("despillBias", "Despill Bias", col(0.5, 0.5, 0.5), ParamUi::Color),
p("alphaBias", "Alpha Bias", col(0.5, 0.5, 0.5), ParamUi::Color),
p("lockBiasesTogether", "Lock Biases Together", Value::Bool(true), ParamUi::Checkbox),
p("screenPreblur", "Screen Pre-blur", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 1)),
// Screen Matte
pp("screenMatte/clipBlack", "Clip Black", pct(0.0)),
pp("screenMatte/clipWhite", "Clip White", pct(100.0)),
p("screenMatte/clipRollback", "Clip Rollback", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("screenMatte/screenShrinkGrow", "Screen Shrink/Grow", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 1)),
p("screenMatte/screenSoftness", "Screen Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("screenMatte/screenDespotBlack", "Screen Despot Black", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("screenMatte/screenDespotWhite", "Screen Despot White", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("screenMatte/replaceMethod", "Replace Method", Value::Enum(3), popup(&REPLACE)),
p("screenMatte/replaceColour", "Replace Colour", col(0.5, 0.5, 0.5), ParamUi::Color),
// Inside Mask
p("insideMask/insideMask", "Inside Mask", Value::Enum(0), mask_popup()),
p("insideMask/insideMaskSoftness", "Inside Mask Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("insideMask/invertInsideMask", "Invert", Value::Bool(false), ParamUi::Checkbox),
p("insideMask/insideReplaceMethod", "Replace Method", Value::Enum(1), popup(&REPLACE)),
p("insideMask/insideReplaceColour", "Replace Colour", col(0.5, 0.5, 0.5), ParamUi::Color),
p("insideMask/sourceAlpha", "Source Alpha", Value::Enum(2), popup(&["Ignore", "Add to Inside Mask", "Normal"])),
// Outside Mask
p("outsideMask/outsideMask", "Outside Mask", Value::Enum(0), mask_popup()),
p("outsideMask/outsideMaskSoftness", "Outside Mask Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("outsideMask/invertOutsideMask", "Invert", Value::Bool(false), ParamUi::Checkbox),
// Foreground Colour Correction
p("foregroundColourCorrection/enableColourCorrection", "Enable Colour Correction", Value::Bool(false), ParamUi::Checkbox),
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)),
p("edgeColourCorrection/edgeSoftness", "Edge Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
p("edgeColourCorrection/edgeGrow", "Edge Grow", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
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)),
pp("sourceCrops/cropBottom", "Bottom", pct(0.0)),
],
render: key_light,
gpu: false,
float: true,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, MaskShape, run_fx};
/// Green screen with a red square (fg) and a half-transparent mix strip.
fn plate() -> Image {
crate::util::gen_image(40, 30, |x, y| {
if (10..20).contains(&x) && (10..20).contains(&y) {
[0.8, 0.2, 0.1, 1.0]
} else if (25..30).contains(&x) {
// 50 % red over green.
[0.4, 0.6, 0.05, 1.0]
} else {
[0.1, 0.9, 0.1, 1.0]
}
})
}
fn green() -> Value {
col(0.1, 0.9, 0.1)
}
#[test]
fn keys_the_screen_and_keeps_the_foreground() {
let out = run_fx("ec.keying.keylight", &[("screenColour", green())], plate(), 0.0, EffectEnv::default());
let bg = out.img.get(2, 2);
let fg = out.img.get(15, 15);
let mix = out.img.get(27, 5);
assert!(bg[3] < 0.02, "{bg:?}");
assert!(fg[3] > 0.98 && (fg[0] - 0.8).abs() < 0.05, "{fg:?}");
assert!(mix[3] > 0.3 && mix[3] < 0.7, "{mix:?}");
// The recovered edge colour has the green removed (no spill).
let (c, _) = unpremul(mix);
assert!(c[1] <= c[0] + 0.05, "{c:?}");
// Deterministic.
let again = run_fx("ec.keying.keylight", &[("screenColour", green())], plate(), 0.0, EffectEnv::default());
assert_eq!(out.img.data, again.img.data);
}
#[test]
fn clip_gain_views_and_masks() {
// Clip White 60 %: the half-transparent strip becomes solid.
let out = run_fx("ec.keying.keylight", &[("screenColour", green()), ("screenMatte/clipWhite", num(40.0))], plate(), 0.0, EffectEnv::default());
assert!(out.img.get(27, 5)[3] > 0.99);
// Screen Matte and Status views are grey-scale and opaque.
let sm = run_fx("ec.keying.keylight", &[("screenColour", green()), ("view", Value::Enum(4))], plate(), 0.0, EffectEnv::default());
assert!(sm.img.get(2, 2)[0] < 0.02 && sm.img.get(15, 15)[0] > 0.98 && sm.img.get(2, 2)[3] == 1.0);
let st = run_fx("ec.keying.keylight", &[("screenColour", green()), ("view", Value::Enum(8))], plate(), 0.0, EffectEnv::default());
assert_eq!(st.img.get(27, 5)[0], 0.5);
// Source view is the identity.
let src = run_fx("ec.keying.keylight", &[("view", Value::Enum(0))], plate(), 0.0, EffectEnv::default());
assert_eq!(src.img.data, plate().data);
// Inside mask forces opacity; outside mask forces transparency.
let sq = |x0: f64, y0: f64, x1: f64, y1: f64| MaskShape {
name: "m".into(),
points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]],
closed: true,
inverted: false,
};
let masks = [sq(0.0, 0.0, 6.0, 6.0), sq(12.0, 12.0, 18.0, 18.0)];
let env = EffectEnv { masks: &masks, ..Default::default() };
let m = run_fx(
"ec.keying.keylight",
&[("screenColour", green()), ("insideMask/insideMask", Value::Enum(1)), ("outsideMask/outsideMask", Value::Enum(2))],
plate(),
0.0,
env,
);
assert!(m.img.get(2, 2)[3] > 0.99, "{:?}", m.img.get(2, 2));
assert!(m.img.get(15, 15)[3] < 0.01);
// Screen gain above 100 % keys more of the strip.
let g = run_fx("ec.keying.keylight", &[("screenColour", green()), ("screenGain", num(150.0))], plate(), 0.0, EffectEnv::default());
assert!(g.img.get(27, 5)[3] < out.img.get(27, 5)[3]);
assert_eq!(crate::lookup("Keylight (1.2)").map(|s| s.id), Some("ec.keying.keylight"));
// Searches find it by After Effects' name as well as its own.
let spec = crate::find("ec.keying.keylight").unwrap();
assert!(crate::name_matches(spec, "keylight") && crate::name_matches(spec, "key light") && !crate::name_matches(spec, "blur"));
}
/// Instances saved with the flat layout get their parameters moved into the twirl-downs.
#[test]
fn flat_instances_move_into_twirl_downs() {
use effectcraft_project::build::Ids;
let spec = crate::find("ec.keying.keylight").unwrap();
let mut next = 1;
let mut ids = Ids(&mut next);
let mut g = crate::instantiate(spec, &mut ids, "Key Light", [10.0, 10.0]);
// Flatten it the way older versions saved it.
let mut flat = vec![];
g.walk_mut(&mut |p| flat.push(p.clone()));
g.children = flat.into_iter().map(Into::into).collect();
let clip = g.children.iter_mut().find_map(|c| match c {
effectcraft_project::Node::Prop(p) if p.match_id == "clipWhite" => Some(p),
_ => None,
});
clip.unwrap().value = num(42.0);
assert!(crate::migrate::upgrade_instance(spec, &mut g, &mut ids, [10.0, 10.0]));
assert_eq!(g.sub("screenMatte").map(|s| s.name.as_str()), Some("Screen Matte"));
assert_eq!(g.sub("screenMatte").and_then(|s| s.get("clipWhite")).map(|p| p.value.clone()), Some(num(42.0)));
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");
}
}