Black & White: Tint keeps the gray's luminosity, as After Effects

After Effects tints Black & White's gray like a Color blend: the tint colour's
hue and saturation at the gray's luminosity (weights 0.3 / 0.59 / 0.11, channels
outside 0..1 pulled back toward the gray). EffectCraft used the gray as HSL
lightness. Measured in AE 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10
(case 717) AE (43, 21, 0), EffectCraft was (51, 26, 0), now (43, 21, 0); a
yellow solid at the default sliders (gray 0.6, case 825) AE (255, 129, 2), now
(255, 129, 3); white stays white. The six sliders already matched AE on eight
colours (cases 816, 817). CPU and GPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:14:21 +02:00
co-authored by Claude Opus 5.5
parent 960f41d50b
commit 70c1349cbc
4 changed files with 80 additions and 15 deletions
+51 -10
View File
@@ -343,6 +343,33 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 {
/// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted
/// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright
/// and black blocks (#1463), and 32-bpc overbrights divided by zero.
/// Black & White's Tint: the tint colour's hue and saturation at the gray's luminosity, as a
/// Color blend does (luminosity with weights 0.3 / 0.59 / 0.11, out-of-range channels pulled
/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid
/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2),
/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first.
pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] {
let lum = |c: [f32; 3]| 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2];
if !gray.is_finite() || gray <= 0.0 {
return [0.0; 3];
}
if gray >= 1.0 {
return [gray; 3];
}
let d = gray - lum(tint);
let c = tint.map(|v| v + d);
let l = lum(c);
let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
let mut o = c;
if n < 0.0 && l - n > 1e-9 {
o = o.map(|v| l + (v - l) * l / (l - n));
}
if x > 1.0 && x - l > 1e-9 {
o = o.map(|v| l + (v - l) * (1.0 - l) / (x - l));
}
o
}
fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")]
.map(|v| v as f32 / 100.0);
@@ -357,13 +384,7 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f;
let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2]));
let gray = (min + (max - min) * wt).max(0.0);
if tint_on {
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0));
[r, g, bl]
} else {
[gray, gray, gray]
}
if tint_on { bw_tint([tc[0], tc[1], tc[2]], gray) } else { [gray, gray, gray] }
});
b
}
@@ -1248,9 +1269,6 @@ mod tests {
assert!(go(0.0, 100.0, 64) >= 30);
}
/// #1463: Black & White's sliders set the gray of each pure hue and blend between
/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
/// colour stays finite (it used to divide by zero).
/// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows
/// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46);
/// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64),
@@ -1274,6 +1292,29 @@ mod tests {
}
}
/// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow
/// and white solids at the default sliders (case 825).
#[test]
fn black_white_tint_matches_after_effects() {
let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))];
let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5);
let mut reds = tint.to_vec();
reds.push(("reds", num(10.0)));
let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]);
assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0));
let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]);
assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0));
let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]);
assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0));
// Hostile grays stay finite.
for g in [f32::NAN, -1.0, 0.0, 4.0] {
assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite()));
}
}
/// #1463: Black & White's sliders set the gray of each pure hue and blend between
/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
/// colour stays finite (it used to divide by zero).
#[test]
fn black_white_sliders_set_the_gray_of_each_hue() {
let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]);
+1 -2
View File
@@ -90,11 +90,10 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
"ec.color.blackwhite" => {
let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0);
let tc = pr.color("tintColor");
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
p.u[0] = [3, pr.b("tint") as u32, 0, 0];
p.f[0] = [w[0], w[1], w[2], w[3]];
p.f[1] = [w[4], w[5], 0.0, 0.0];
p.f[2] = [th, ts, 0.0, 0.0];
p.f[2] = [tc[0], tc[1], tc[2], 0.0];
}
"ec.color.tritone" => {
p.u[0][0] = 4;
+26 -2
View File
@@ -100,11 +100,35 @@ fn fxc_sc_weights(c0: vec3<f32>) -> array<f32, 9> {
return w;
}
// Black & White's Tint (effects::color_fx::bw_tint): the tint's hue and saturation at the
// gray's luminosity (Color blend, weights 0.3 / 0.59 / 0.11, channels clipped toward the gray).
fn fxc_bw_tint(tint: vec3<f32>, gray: f32) -> vec3<f32> {
if (!(gray > 0.0)) {
return vec3<f32>(0.0);
}
if (gray >= 1.0) {
return vec3<f32>(gray);
}
let w = vec3<f32>(0.3, 0.59, 0.11);
let c = tint + vec3<f32>(gray - dot(tint, w));
let l = dot(c, w);
let n = min(min(c.x, c.y), c.z);
let x = max(max(c.x, c.y), c.z);
var o = c;
if (n < 0.0 && l - n > 1e-9) {
o = vec3<f32>(l) + (o - vec3<f32>(l)) * l / (l - n);
}
if (x > 1.0 && x - l > 1e-9) {
o = vec3<f32>(l) + (o - vec3<f32>(l)) * (1.0 - l) / (x - l);
}
return o;
}
// u[0].x = op:
// 1 Color Balance f[0] = shadows, f[1] = midtones, f[2] = highlights; u[0].y = preserve luminosity
// 2 Vibrance f[0] = (vibrance, saturation)
// 3 Black & White f[0] = (reds, yellows, greens, cyans), f[1] = (blues, magentas);
// u[0].y = tint; f[2] = (tint hue, tint saturation)
// u[0].y = tint; f[2] = tint colour
// 4 Tritone f[0] = highlights, f[1] = midtones, f[2] = shadows; f[3].x = 1 − blend
// 5 Colorama u[0].y = phase mode, u[0].z = interpolate; f[0] = (cycles, shift, 1 − blend)
// 6 Channel Mixer f[0..3] = output rows (r, g, b, const); u[0].y = monochrome
@@ -159,7 +183,7 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3<u32>) {
let gray = max(mn + (mx - mn) * wt, 0.0);
var o = vec3<f32>(gray);
if (P.u[0].y != 0u) {
o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0));
o = fxc_bw_tint(P.f[2].xyz, gray);
}
px = vec4<f32>(o * a, a);
}
+2 -1
View File
@@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
| Audio | 13 | 0 | CPU by design (applied in the mixdown) |
| Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) |
| Channel | 13 | 13 | — |
| Color Correction | 40 | 40 | Tint and Tritone match After Effects 2026 renders (Rec. 601 luminance); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed |
| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed |
| Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed |
| Expression Controls | 9 | 9 | — |
| Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed |
@@ -65,6 +65,7 @@ the GPU is performance, not parity.
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) |
| 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-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-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending |
| 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls |