diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index b9ec5f5f..0040293e 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -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]); diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 6b8641dd..38325a94 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -167,7 +167,8 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { 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(0.299, 0.587, 0.114)), 0.0, 1.0); var t: vec3; if (l < 0.5) { t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index ce23927a..00d0e8bf 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -605,7 +605,7 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { 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; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index a0abcd22..11dc81a9 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -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 }; diff --git a/docs/effects-parity.md b/docs/effects-parity.md index cf2c6bb7..9fce1066 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -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 |