Tritone: Rec. 601 luminance, as After Effects

After Effects' Tritone weighs the working-space values with Rec. 601 (0.299,
0.587, 0.114), as its Tint does; EffectCraft used Rec. 709. Case 718 (red solid,
Highlights [1,1,0.8], Midtones [0.6,0.2,0.2], Shadows [0,0,0.3], Blend 25):
AE (132, 23, 46), EffectCraft was (113, 16, 49), now (132, 23, 46). On a grey
ramp (case 815) the shadows / midtones / highlights mapping already matched and
still does within 1/255. CPU and GPU (fx_color op 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:13:41 +02:00
co-authored by Claude Opus 5.5
parent fb16d5573e
commit 960f41d50b
5 changed files with 38 additions and 10 deletions
+32 -6
View File
@@ -14,10 +14,13 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
}
/// Tint's luminance: Rec. 601 weights on the working-space values, as After Effects 2026
/// renders it (sRGB working space, not linearised: red 76/255, green 150/255, blue 29/255).
/// The GPU kernel (`pointwise` op 1) mirrors it.
fn tint_luma(c: [f32; 3]) -> f32 {
/// Rec. 601 luminance on the working-space values, as After Effects 2026 renders Tint, Tritone
/// and the other Rec. 601 effects (sRGB working space, not linearised: red 76/255, green
/// 150/255, blue 29/255). Tint and Tritone use it (Tritone, case 718: a red solid with
/// Blend With Original 25 gives (132, 23, 46), from luminance 0.299; Rec. 709's 0.213 gave
/// (113, 16, 49); on a grey ramp both weightings agree, case 815). The GPU kernels
/// (`pointwise` op 1, `fx_color` op 4) mirror it.
fn luma_601(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}
@@ -26,7 +29,7 @@ fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf {
let white = ctx.params.color("white");
let amt = ctx.params.f("amount") as f32 / 100.0;
b.img.map_straight(|c| {
let l = tint_luma(c).clamp(0.0, 1.0);
let l = luma_601(c).clamp(0.0, 1.0);
let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l];
mix(c, t, amt)
});
@@ -39,7 +42,7 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
let sh = ctx.params.color("shadows");
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let l = luma_601(c).clamp(0.0, 1.0);
let t = if l < 0.5 {
mix([sh[0], sh[1], sh[2]], [mid[0], mid[1], mid[2]], l * 2.0)
} else {
@@ -1248,6 +1251,29 @@ mod tests {
/// #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),
/// (153, 51, 51), (204, 153, 128).
#[test]
fn tritone_matches_after_effects() {
let colours = [
("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])),
("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])),
("shadows", Value::Color([0.0, 0.0, 0.3, 1.0])),
];
let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0);
let mut red = colours.to_vec();
red.push(("blend", num(25.0)));
let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]);
assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0));
for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] {
let v = level / 255.0;
let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]);
assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0));
}
}
#[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]);
+2 -1
View File
@@ -167,7 +167,8 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3<u32>) {
case 4u: {
if (a > 0.0) {
let c = px.xyz / a;
let l = clamp(luminance(c), 0.0, 1.0);
// Rec. 601 weights, as effects::color_fx::tritone.
let l = clamp(dot(c, vec3<f32>(0.299, 0.587, 0.114)), 0.0, 1.0);
var t: vec3<f32>;
if (l < 0.5) {
t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0);
+1 -1
View File
@@ -605,7 +605,7 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
case 1u: {
if (a > 0.0) {
let c = straight(px);
// Rec. 601 weights, as After Effects (effects crate tint_luma).
// Rec. 601 weights, as After Effects (effects crate luma_601).
let l = clamp(0.299 * c.x + 0.587 * c.y + 0.114 * c.z, 0.0, 1.0);
let blk = P.f[0].xyz;
let wht = P.f[1].xyz;
+1 -1
View File
@@ -852,7 +852,7 @@ fn effects_whose_kernels_failed_render_on_the_cpu() {
}
/// The GPU Tint gives After Effects 2026's greys for full-frame solids at its defaults (sRGB
/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::tint_luma`).
/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::luma_601`).
#[test]
fn gpu_tint_defaults_match_after_effects_greys() {
let Some(g) = gpu() else { return };
+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 has Swap Colors (#963) and matches After Effects 2026 renders of primary and grey solids (Rec. 601 luminance); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed |
| 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 |
| 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 |
@@ -64,6 +64,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-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 |