Luma Key: Key Out Brighter and Darker cut hard at Threshold, as After Effects

After Effects' Luma Key removes everything darker (or brighter) than Threshold
and ignores Tolerance for those two key types; Tolerance only sets the band of
Key Out Similar / Dissimilar. EffectCraft ramped the matte over Tolerance, so
case 719 (a 50 % grey, Key Out Darker, Threshold 140, Tolerance 20) kept about
80 % of the layer where AE keys it out completely.

Measured in AE 2026 on a vertical grey ramp (cases 812-814): Darker keys up to
level 139.9 and keeps 140.1 (EffectCraft now the same row); Similar keys levels
120.3-160.0; with Edge Thin 1 / Edge Feather 2.5 the matte rises over rows
591-596 (EffectCraft within 0.2 of AE's alpha there). CPU and GPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:15:02 +02:00
co-authored by Claude Opus 5.5
parent dc9f15ada1
commit 39bd0efac2
3 changed files with 65 additions and 49 deletions
+59 -34
View File
@@ -135,46 +135,23 @@ fn color_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---- Luma Key ----
/// Luma Key as After Effects 2026 keys a grey ramp (cases 812-814, 719): Key Out Brighter and
/// Darker cut hard at Threshold and ignore Tolerance; Key Out Similar removes, and Dissimilar
/// keeps, a hard band of Threshold ± Tolerance (levels of 255). Edge Thin and Edge Feather
/// then soften the cut.
fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
let kind = ctx.params.e("keyType");
let thr = ctx.params.f("threshold") as f32 / 255.0;
let tol = ctx.params.f("tolerance") as f32 / 255.0;
let m = matte_of(&b.img, |c, _| {
let l = luminance(c[0], c[1], c[2]);
match kind {
0 => {
if tol > 0.0 {
((thr + tol - l) / tol).clamp(0.0, 1.0)
} else if l > thr {
0.0
} else {
1.0
}
}
2 => {
if (l - thr).abs() <= tol {
0.0
} else {
1.0
}
}
3 => {
if (l - thr).abs() <= tol {
1.0
} else {
0.0
}
}
_ => {
if tol > 0.0 {
((l - (thr - tol)) / tol).clamp(0.0, 1.0)
} else if l < thr {
0.0
} else {
1.0
}
}
}
let keep = match kind {
0 => l <= thr,
2 => (l - thr).abs() > tol,
3 => (l - thr).abs() <= tol,
_ => l >= thr,
};
if keep { 1.0 } else { 0.0 }
});
let m = thin_feather(m, ctx.params.f("edgeThin") * b.scale, ctx.params.f("edgeFeather") * b.scale);
apply_matte(&mut b.img, &m, 0);
@@ -802,6 +779,54 @@ mod tests {
assert_eq!(out.get(1, 0)[3], 1.0);
}
/// After Effects 2026 (cases 812-814): Luma Key on a vertical black-to-white ramp (row y
/// has level (y + 0.5) / 1080 × 255), Threshold 140, Tolerance 20. EffectCraft ramped the
/// matte over the tolerance (case 719: a 50 % grey kept 80 % of its alpha where AE keys it
/// out).
#[test]
fn luma_key_matches_after_effects() {
let ramp = || {
let mut img = Image::new(1, 1080);
for y in 0..1080 {
let v = (y as f32 + 0.5) / 1080.0;
img.set(0, y, [v, v, v, 1.0]);
}
img
};
let key = |kind: u32, thin: f64, feather: f64| {
let set =
[("keyType", Value::Enum(kind)), ("threshold", num(140.0)), ("tolerance", num(20.0)), ("edgeThin", num(thin)), ("edgeFeather", num(feather))];
let o = run("ec.key.luma", &set, ramp());
(0..1080).map(|y| o.get(0, y)[3]).collect::<Vec<f32>>()
};
// Key Out Darker (AE 2 = EffectCraft 1): keyed up to row 592, kept from 593.
let d = key(1, 0.0, 0.0);
assert!(d[..593].iter().all(|a| *a == 0.0) && d[593..].iter().all(|a| *a == 1.0), "{:?}", &d[588..598]);
// Key Out Similar: AE keys rows 509..=677 (levels 120.3..160.0) and keeps the rest; the
// rows at the band's ends sit within 0.1 level of 120 / 160, so they may go either way.
let s = key(2, 0.0, 0.0);
assert!(
(0..1080).filter(|y| !(507..=510).contains(y) && !(676..=679).contains(y)).all(|y| (s[y] == 0.0) == (509..=677).contains(&y)),
"{:?} {:?}",
&s[505..512],
&s[674..681]
);
// Key Out Brighter and Dissimilar are the complements.
let b = key(0, 0.0, 0.0);
assert!(b[595] == 0.0 && b[590] == 1.0);
let ds = key(3, 0.0, 0.0);
assert!(ds[600] == 1.0 && ds[400] == 0.0 && ds[700] == 0.0);
// A 50 % grey at Darker 140 / 20 is keyed out entirely (case 719).
let o =
run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(140.0)), ("tolerance", num(20.0))], Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]));
assert_eq!(o.get(0, 0)[3], 0.0);
// Edge Thin 1, Edge Feather 2.5 (case 814): AE's alpha rises over rows 591..596.
let f = key(1, 1.0, 2.5);
for (y, ae) in [(590, 0.0), (591, 0.026), (592, 0.139), (593, 0.357), (594, 0.635), (595, 0.868), (596, 0.97), (597, 1.0)] {
assert!((f[y] - ae).abs() < 0.2, "row {y}: {} vs AE {ae}", f[y]);
}
}
#[test]
fn extract_keeps_range() {
let mut img = Image::new(3, 1);
+4 -14
View File
@@ -199,23 +199,13 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3<u32>) {
let thr = f0.x;
let tol = f0.y;
var m: f32;
// As effects::keying::luma_key: Brighter / Darker cut hard at the threshold
// (Tolerance only shapes Similar / Dissimilar), as After Effects.
switch u0.y {
case 0u: {
if (tol > 0.0) {
m = clamp((thr + tol - l) / tol, 0.0, 1.0);
} else {
m = select(1.0, 0.0, l > thr);
}
}
case 0u: { m = select(1.0, 0.0, l > thr); }
case 2u: { m = select(1.0, 0.0, abs(l - thr) <= tol); }
case 3u: { m = select(0.0, 1.0, abs(l - thr) <= tol); }
default: {
if (tol > 0.0) {
m = clamp((l - (thr - tol)) / tol, 0.0, 1.0);
} else {
m = select(1.0, 0.0, l < thr);
}
}
default: { m = select(1.0, 0.0, l < thr); }
}
o = vec4<f32>(m);
}
+2 -1
View File
@@ -38,7 +38,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
| Keying | 12 | 12 | Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed |
| Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format |
| Noise & Grain | 13 | 12 | Curl Noise (#463); Match Grain on the CPU |
| Obsolete | 9 | 9 | — |
| Obsolete | 9 | 9 | Luma Key matches After Effects 2026 on a grey ramp (hard cut, Tolerance only for Similar / Dissimilar) |
| Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) |
| Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) |
| Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) |
@@ -68,6 +68,7 @@ the GPU is performance, not parity.
| 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) |
| 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) |
| 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU |
| 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) |
| 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) |
| 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) |
| 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending |