From fb16d5573e87b150730d94f8794c5fd5e37a4bd8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 04:59:38 +0200 Subject: [PATCH 01/13] Tint: Rec. 601 luminance weights, as After Effects After Effects 2026 (sRGB working space, not linearised) maps red, green, blue and 50% grey solids at Tint's defaults to 76, 150, 29 and 128/255: Rec. 601 weights on the working-space values, not Rec. 709. CPU and GPU kernel changed; tests pin the four greys within 1/255. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 20 +++++++++++++++++++- crates/gpu/src/shaders/kernels.wgsl | 3 ++- crates/gpu/src/tests.rs | 19 +++++++++++++++++++ crates/render/src/tests.rs | 16 ++++++++++++++++ docs/effects-parity.md | 5 +++-- 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index cf6698fe..b9ec5f5f 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -14,12 +14,19 @@ 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 { + 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] +} + fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf { let black = ctx.params.color("black"); let white = ctx.params.color("white"); let amt = ctx.params.f("amount") 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 = tint_luma(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) }); @@ -1173,6 +1180,17 @@ mod tests { } } + /// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working + /// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values. + #[test] + fn tint_defaults_match_after_effects_greys() { + for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]); + let want = want / 255.0; + assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}"); + } + } + #[test] fn colorama_reads_get_phase_from() { // Same intensity, different red: Intensity gives one colour, Red gives two. diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index defbb5bf..ce23927a 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -605,7 +605,8 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { case 1u: { if (a > 0.0) { let c = straight(px); - let l = clamp(luminance(c), 0.0, 1.0); + // Rec. 601 weights, as After Effects (effects crate tint_luma). + 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; let t = blk + (wht - blk) * l; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 24f8c961..a0abcd22 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -851,6 +851,25 @@ 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`). +#[test] +fn gpu_tint_defaults_match_after_effects_greys() { + let Some(g) = gpu() else { return }; + for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let mut s = Scene::new(BitDepth::Bpc32); + s.p.settings.working_space = Some(ColorSpace::Srgb); + let mut l = s.solid(col, 97, 61); + s.effect(&mut l, "ec.color.tint", &[]); + s.push(l); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + let o = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame").get(48, 30); + let want = want / 255.0; + assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}"); + } +} + /// A layer whose bounds change every frame reads back at a new size each time: the staging /// buffers kept for reuse stay within budget instead of growing for the whole render (#2081). #[test] diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 72dd2650..14c110bf 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -254,6 +254,22 @@ fn effects_run_in_pipeline() { assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}"); } +/// After Effects 2026, sRGB working space (not linearised), Tint at its defaults on full-frame +/// solids: the effect sees the encoded solid colour and weights it Rec. 601. +#[test] +fn tint_in_an_srgb_working_space_matches_after_effects_greys() { + for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let (mut p, cid, comp) = setup(); + p.settings.working_space = Some(effectcraft_color::ColorSpace::Srgb); + let mut l = solid(&mut p, &comp, col, 200, 100); + add_effect(&mut p, &mut l, "ec.color.tint", [200.0, 100.0], &[]); + p.comp_mut(cid).unwrap().layers.push(l); + let o = render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 50); + let want = want / 255.0; + assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}"); + } +} + /// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place. #[test] fn card_dance_comp_camera_matches_the_default_view() { diff --git a/docs/effects-parity.md b/docs/effects-parity.md index c5fbc5ba..cf2c6bb7 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -15,7 +15,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump | | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | -| **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | +| **Rendered output matches After Effects** | **unmeasured** | Only Tint is checked against After Effects renders (four solids, within 1/255). `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | | Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes | @@ -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); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| 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 | | 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-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 | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 960f41d50b30447ee786df2e0b4b09fe811ca0b7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:33:09 +0200 Subject: [PATCH 02/13] 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 --- crates/effects/src/color_fx.rs | 38 +++++++++++++++++++++++----- crates/gpu/src/shaders/fx_color.wgsl | 3 ++- crates/gpu/src/shaders/kernels.wgsl | 2 +- crates/gpu/src/tests.rs | 2 +- docs/effects-parity.md | 3 ++- 5 files changed, 38 insertions(+), 10 deletions(-) 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 | From 70c1349cbcc36a4bada8d0f37c1b9bae794ba086 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:40:14 +0200 Subject: [PATCH 03/13] 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 --- crates/effects/src/color_fx.rs | 61 +++++++++++++++++++++++----- crates/gpu/src/fx_color.rs | 3 +- crates/gpu/src/shaders/fx_color.wgsl | 28 ++++++++++++- docs/effects-parity.md | 3 +- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 0040293e..e93026df 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -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]); diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index b9ae5282..18bd8409 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -90,11 +90,10 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "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; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 38325a94..daac9fb5 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -100,11 +100,35 @@ fn fxc_sc_weights(c0: vec3) -> array { 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, gray: f32) -> vec3 { + if (!(gray > 0.0)) { + return vec3(0.0); + } + if (gray >= 1.0) { + return vec3(gray); + } + let w = vec3(0.3, 0.59, 0.11); + let c = tint + vec3(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(l) + (o - vec3(l)) * l / (l - n); + } + if (x > 1.0 && x - l > 1e-9) { + o = vec3(l) + (o - vec3(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) { let gray = max(mn + (mx - mn) * wt, 0.0); var o = vec3(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(o * a, a); } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 9fce1066..2b8b72e1 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 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 | From c7060e2d759cdab8436d1e2806472f292a61b0ad Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 06:15:06 +0200 Subject: [PATCH 04/13] Exposure: gamma 2.2 linear-light conversion, as After Effects With Bypass Linear Light Conversion off, Exposure decoded and re-encoded with the piecewise sRGB curve. After Effects 2026 renders (sRGB working space, 32 bpc) of Offset 0.0005 / 0.002 / 0.02 / 0.1 on black measure 8 / 15 / 43 / 90, which a pure 2.2 power predicts (8.06 / 15.13 / 43.08 / 89.54) and the sRGB curve does not (1.65 / 6.59 / 38.68 / 89.04). The effect now uses its own 2.2 power curve on the CPU and in the GPU kernel; the shared colour functions and other effects are unchanged. As before, the conversion applies whatever the working space, also a linearised one. Negative values go through both curves mirrored (input was clamped at 0 before), so defaults are an identity for 32-bpc values and a negative Offset darkens past black without NaN. New test against the AE values; existing Exposure tests still hold. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 43 +++++++++++++++++++++++++++-- crates/gpu/src/shaders/kernels.wgsl | 7 +++-- docs/effects-parity.md | 1 + 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index e93026df..3556fbe5 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -314,6 +314,17 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] { } } +/// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion +/// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005 +/// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65). +pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2; + +/// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless +/// Bypass Linear Light Conversion is on. The conversion is the effect's own pure 2.2 power curve +/// (see [`EXPOSURE_LINEAR_GAMMA`]), applied whatever the project's working space, also when it +/// is already linear (Linearize Working Space), as before. Negative values (32 bpc, or a +/// negative Offset) go through the curves mirrored, so a negative Offset darkens smoothly past +/// black and the default settings leave every value unchanged. fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = exposure_settings(ctx); let bypass = ctx.params.b("bypassLinearLight"); @@ -323,9 +334,9 @@ fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf { if bypass { return mirror_pow(c[i] * e + off, 1.0 / g); } - let lin = effectcraft_color::srgb_to_linear(c[i].max(0.0)); + let lin = mirror_pow(c[i], EXPOSURE_LINEAR_GAMMA); let o = mirror_pow(lin * e + off, 1.0 / g); - effectcraft_color::linear_to_srgb(o.abs()).copysign(o) + mirror_pow(o, 1.0 / EXPOSURE_LINEAR_GAMMA) }) }); b @@ -1406,6 +1417,34 @@ mod tests { assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}"); } + /// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space, + /// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power. + /// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets. + #[test] + fn exposure_matches_after_effects_gamma_2_2() { + let red = [1.0, 0.0, 0.0, 1.0]; + let code = |o: [f32; 4], i: usize| o[i] * 255.0; + // Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture). + for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] { + let o = px("ec.color.exposure", &[("master/offset", num(offset))], red); + for i in [1, 2] { + assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i)); + } + assert!(o[0] >= 1.0, "{o:?}"); + } + // Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54. + let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red); + assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); + let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red); + assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); + // Default settings leave 32-bpc values alone, negative and over-range ones included. + let hdr = [-0.25, 0.5, 2.0, 1.0]; + assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5)); + // A negative Offset pushes black below zero, mirrored, rather than clipping or NaN. + let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]); + assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}"); + } + #[test] fn change_to_color_change_modes_tolerances_and_matte() { let red = [0.8, 0.1, 0.1, 1.0]; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 00d0e8bf..870430a5 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -714,14 +714,15 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { let c = straight(px); var o = vec3(0.0); for (var i = 0; i < 3; i++) { - // Per-channel gain, offset, gamma; negative values are mirrored. + // Per-channel gain, offset, gamma; negative values are mirrored. The + // linear-light conversion is a pure 2.2 power (effects crate exposure). if (P.u[0].y != 0u) { let x = c[i] * P.f[0][i] + P.f[1][i]; o[i] = sign(x) * powz(abs(x), 1.0 / P.f[2][i]); } else { - let lin = srgb_to_linear(max(c[i], 0.0)); + let lin = sign(c[i]) * powz(abs(c[i]), 2.2); let x = lin * P.f[0][i] + P.f[1][i]; - o[i] = sign(x) * linear_to_srgb(powz(abs(x), 1.0 / P.f[2][i])); + o[i] = sign(x) * powz(powz(abs(x), 1.0 / P.f[2][i]), 1.0 / 2.2); } } px = vec4(o * a, a); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2b8b72e1..88b42d62 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -67,6 +67,7 @@ the GPU is performance, not parity. | 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-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 | | 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 | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 617b0a578d9b16333483cf82c834d9294352404e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 06:22:38 +0200 Subject: [PATCH 05/13] Exposure: After Effects parameter ranges All twelve sliders (Master, Red, Green, Blue) take After Effects' ranges: Exposure valid -100..100, slider -4..4, 2 decimals (was -20..20, slider -5..5); Offset -2..2, slider -0.5..0.5, 4 decimals (unchanged); Gamma Correction valid and slider 0.1..10, 2 decimals (was 0.01..9.99, slider 0.1..3). Every value inside the old Exposure and Offset ranges stays valid. Saved values are not clamped on load, so a gamma below 0.1 from an older project or a Photoshop import (0.01..9.99) is kept and still renders (the renderer's 0.01 floor is unchanged); only new edits clamp to 0.1. Test of the ranges and of an old gamma surviving the load refresh. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 38 ++++++++++++++++++++++++++++++++-- docs/effects-parity.md | 1 + 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 3556fbe5..5a00114f 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -1011,11 +1011,13 @@ pub fn specs() -> Vec { "Exposure", { let mut v = vec![p("channels", "Channels", Value::Enum(0), popup(&["Master", "Individual Channels"]))]; + // After Effects' ranges (valid, slider, decimals). Gamma values below 0.1 from + // older projects or Photoshop imports (0.01..9.99) are kept and still render. let group = |ids: [&'static str; 3]| { [ - p(ids[0], "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p(ids[0], "Exposure", num(0.0), slider(-100.0, 100.0, -4.0, 4.0, 2)), p(ids[1], "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)), - p(ids[2], "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)), + p(ids[2], "Gamma Correction", num(1.0), slider(0.1, 10.0, 0.1, 10.0, 2)), ] }; v.extend(group(["master/exposure", "master/offset", "master/gamma"])); @@ -1417,6 +1419,38 @@ mod tests { assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}"); } + /// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma + /// of 0.05, valid before, as in Photoshop) survives loading and still renders. + #[test] + fn exposure_has_after_effects_ranges_and_keeps_old_values() { + use effectcraft_project::build::Ids; + let spec = crate::find("ec.color.exposure").unwrap(); + let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)]; + let sliders: Vec<_> = spec + .params + .iter() + .filter_map(|p| match p.ui { + ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))), + _ => None, + }) + .collect(); + assert_eq!(sliders.len(), 12); + for (i, (id, r)) in sliders.iter().enumerate() { + assert_eq!(*r, want[i % 3], "{id}"); + } + let mut next = 1; + let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]); + let gamma = g.prop_mut("master/gamma").unwrap(); + gamma.value = num(0.05); + gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2); + crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]); + let gamma = g.prop_mut("master/gamma").unwrap(); + assert_eq!(gamma.value, num(0.05)); + assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1)); + let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]); + assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}"); + } + /// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space, /// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power. /// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 88b42d62..f8325875 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -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-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 | +| 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 | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 515cc3d5f711805982e4fd9dbe1586cd1dfc42eb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 11:26:09 +0200 Subject: [PATCH 06/13] Levels: channel controls before RGB, "Off for 32 bpc Color" unclipped in 32 bpc, as After Effects After Effects 2026 in a 32 bpc project, Levels (Individual Controls) with RGB (0.1, 0.9, gamma 1.5, 0.05, 0.95), Red Gamma 0.8, Green Input White 0.8 and Blue Output Black 0.2 on grey steps 0..255 (case 904 E07): every step matches "each channel's controls, then the RGB controls" with nothing clipped (values below Input Black end below Output Black, above Input White above Output White). EffectCraft ran RGB first and clipped "Off for 32 bpc Color" (it could not see the bit depth): up to 27/255 off on 95 % of the grid; now 0 grid points off, max 1/255, CPU and GPU. EffectEnv gains float_depth (the renderer sets it for 32 bpc projects), so "Off for 32 bpc Color" clips in 8 / 16 bpc projects as before. Unclipped values below 0 take the gamma mirrored through 0. Plain Levels keeps its order (its channel controls were not measured); its clipping follows the same rule. EffectCraft's own projects default to 8 bpc and are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color2.rs | 64 ++++++++++++++++++++++++++++- crates/effects/src/color_fx.rs | 27 +++++++++--- crates/effects/src/lib.rs | 2 + crates/gpu/src/deferred.rs | 1 + crates/gpu/src/shaders/fx_tone.wgsl | 15 +++++-- crates/gpu/src/shaders/kernels.wgsl | 7 +++- crates/render/src/lib.rs | 1 + docs/effects-parity.md | 3 +- 8 files changed, 106 insertions(+), 14 deletions(-) diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 71dee87b..d78b0fb0 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -201,7 +201,7 @@ impl Lv { if clip_w { t = t.min(1.0); } - self.ob + (self.ow - self.ob) * t.max(0.0).powf(1.0 / self.g.max(0.01)) + self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01)) } } @@ -217,7 +217,10 @@ fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf { return b; } let clip = crate::color_fx::levels_clip(ctx); - map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[i + 1].apply(lv[0].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0))); + // Each channel's own controls first, then the RGB controls: After Effects 2026 with RGB + // (0.1, 0.9, gamma 1.5, 0.05, 0.95) and Green Input White 0.8 maps grey 32/255 to green + // 52/255 (RGB first would give 45). + map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[0].apply(lv[i + 1].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0))); b } @@ -1139,6 +1142,63 @@ mod tests { assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6); } + /// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1, + /// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green + /// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps: + /// the channel controls run first, nothing clips (8-bit captures clamp the results). + #[test] + fn levels_ic_matches_after_effects_in_32_bpc() { + let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + let ae = [ + [0, 0, 70], + [0, 0, 88], + [0, 52, 104], + [61, 108, 132], + [102, 150, 158], + [138, 187, 181], + [171, 220, 203], + [203, 250, 223], + [233, 255, 243], + [255, 255, 255], + ]; + let mut img = Image::new(10, 1); + for (x, v) in steps.iter().enumerate() { + let v = *v as f32 / 255.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let vals = [ + ("rgb/rgbInBlack", Value::Scalar(0.1)), + ("rgb/rgbInWhite", Value::Scalar(0.9)), + ("rgb/rgbGamma", Value::Scalar(1.5)), + ("rgb/rgbOutBlack", Value::Scalar(0.05)), + ("rgb/rgbOutWhite", Value::Scalar(0.95)), + ("red/redGamma", Value::Scalar(0.8)), + ("green/greenInWhite", Value::Scalar(0.8)), + ("blue/blueOutBlack", Value::Scalar(0.2)), + ]; + let s = find("ec.color.levelsic").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in &vals { + params.values.insert(k.to_string(), v.clone()); + } + let render = |float_depth: bool| { + let env = crate::EffectEnv { float_depth, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img + }; + let out = render(true); + for (x, want) in ae.iter().enumerate() { + let p = out.get(x as i64, 0); + for c in 0..3 { + let got = p[c].clamp(0.0, 1.0) * 255.0; + assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]); + } + } + // In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black. + let clipped = render(false); + assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0)); + } + #[test] fn levels_ic_identity_and_red_only() { let img = ramp(); diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 5a00114f..705cc3c7 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -207,16 +207,32 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf { pub const LEVELS_CLIP: [&str; 3] = ["Off", "On", "Off for 32 bpc Color"]; /// Whether Levels clips below Input Black / above Input White. "Off for 32 bpc Color" clips -/// (effects can't see the project's bit depth; 8/16 bpc behaviour). The hidden `noClip` switch -/// of projects saved before the two popups existed turns both off. +/// in 8 and 16 bpc projects and not in 32 bpc ones (After Effects 2026, Levels (Individual +/// Controls) in a 32 bpc project: values below Input Black end below Output Black). The hidden +/// `noClip` switch of projects saved before the two popups existed turns both off. pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) { if ctx.params.b("noClip") { return (false, false); } - let on = |id: &str| ctx.params.get(id).is_none_or(|v| v.as_enum() != 0); + let on = |id: &str| levels_clip_on(ctx, id); (on("clipToOutputBlack"), on("clipToOutputWhite")) } +/// One Clip To Output popup: 0 Off, 1 On, 2 Off for 32 bpc Color (missing: clip). +pub fn levels_clip_on(ctx: &EffectCtx, id: &str) -> bool { + match ctx.params.get(id).map(|v| v.as_enum()) { + Some(0) => false, + Some(2) => !ctx.env.float_depth, + _ => true, + } +} + +/// Levels' gamma on the normalised input `t`: unclipped values below Input Black extrapolate +/// (the curve mirrored through 0) instead of stopping at Output Black. +pub fn levels_gamma(t: f32, g: f32) -> f32 { + if t >= 0.0 { t.powf(1.0 / g) } else { -(-t).powf(1.0 / g) } +} + /// Levels' Channel popup. pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"]; @@ -242,8 +258,7 @@ fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool)) if clip_w { t = t.min(1.0); } - let t = t.max(0.0).powf(1.0 / g); - ob + (ow - ob) * t + ob + (ow - ob) * levels_gamma(t, g) } const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0]; @@ -286,7 +301,7 @@ pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4] let ids = levels_channel_ids(i + 1); let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k])); let pre = LEVELS_CHANNEL_PREFIX[i]; - let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0); + let on = |id: String| levels_clip_on(ctx, &id); let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) }; ([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip) }) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7..f157e74c 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -406,6 +406,8 @@ pub struct EffectEnv<'a> { pub working_space: Option, /// Working-space pixels are linear light (Linearize Working Space). pub working_linear: bool, + /// The project works in 32 bpc float (Levels' "Off for 32 bpc Color" clips only below it). + pub float_depth: bool, /// The composition's shutter (angle and phase in degrees, samples per frame) when both the /// comp's and the layer's motion blur switches are on (Transform's Use Composition's /// Shutter Angle). diff --git a/crates/gpu/src/deferred.rs b/crates/gpu/src/deferred.rs index 3ac2c67e..e96b54a1 100644 --- a/crates/gpu/src/deferred.rs +++ b/crates/gpu/src/deferred.rs @@ -252,6 +252,7 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option) -> u12 k.u64(e.effect_index as u64); k.debug(&e.working_space); k.u64(e.working_linear as u64); + k.u64(e.float_depth as u64); k.debug(&e.shutter); } k.finish() diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index 39db3458..c99d199c 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -20,6 +20,14 @@ fn fxt_rem360(x: f32) -> f32 { } // Levels (Individual Controls) channel `i` (data: 5 × (ib, iw, g, ob, ow, identity)). +// color_fx::levels_gamma: unclipped values below 0 mirror the curve. +fn fxt_gamma(t: f32, g: f32) -> f32 { + if (t >= 0.0) { + return powz(t, 1.0 / g); + } + return -powz(-t, 1.0 / g); +} + fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { let o = i * 6u; if (data[o + 5u] != 0.0) { @@ -32,7 +40,7 @@ fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { if (clip_w) { t = min(t, 1.0); } - return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * powz(max(t, 0.0), 1.0 / max(data[o + 2u], 0.01)); + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, max(data[o + 2u], 0.01)); } // color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white). @@ -44,8 +52,7 @@ fn fxt_level(o: u32, v: f32) -> f32 { if (data[o + 6u] != 0.0) { t = min(t, 1.0); } - t = powz(max(t, 0.0), 1.0 / data[o + 2u]); - return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * t; + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, data[o + 2u]); } fn fxt_level_ident(o: u32) -> bool { @@ -177,7 +184,7 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3) { let aa = max(a, 0.0); var nc: vec3; for (var i = 0u; i < 3u; i++) { - nc[i] = fxt_lv(i + 1u, fxt_lv(0u, c[i], cb, cw), cb, cw); + nc[i] = fxt_lv(0u, fxt_lv(i + 1u, c[i], cb, cw), cb, cw); } let na = clamp(clamp(fxt_lv(4u, aa, cb, cw), 0.0, 1.0), 0.0, 1.0); o = vec4(nc * na, na); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 870430a5..00d75fe2 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -703,7 +703,12 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { if (P.u[0].z != 0u) { t = min(t, 1.0); } - t = powz(max(t, 0.0), 1.0 / g); + // color_fx::levels_gamma: unclipped values below 0 mirror the curve. + if (t >= 0.0) { + t = powz(t, 1.0 / g); + } else { + t = -powz(-t, 1.0 / g); + } o[i] = ob + (ow - ob) * t; } px = vec4(o * a, a); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f..dff28f22 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1184,6 +1184,7 @@ impl<'a> Renderer<'a> { effect_index: 0, working_space: self.pipe.space, working_linear: self.pipe.linear, + float_depth: self.pipe.levels.is_none(), shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)), }; // Video effects in stack order (index, group, spec); disabled and audio effects skipped. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index f8325875..2d59c6b4 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, 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 | +| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); 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 | @@ -66,6 +66,7 @@ the GPU is performance, not parity. |---|---|---| | 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-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-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 | | 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) | From dc9f15ada114bc3be659650db08cac4e584f44cb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 14:41:24 +0200 Subject: [PATCH 07/13] Brightness & Contrast: negative settings invert the positive curves, as After Effects In After Effects 2026 the non-legacy curve of Brightness -40 is the inverse of Brightness +40 (within 2.6/255 on a full black-to-white ramp) and Contrast -50 the inverse of Contrast +50. EffectCraft fitted the negative brightness with its own power (2^(1.32 |b|) on v), 20/255 off AE at -40; the negatives now use the reciprocal exponents of the positive curves: Brightness -40 max 6.2/255 (was 19.8), Contrast -50 4.1 (was 7.0); positives unchanged (40: 5.5, 80: 7.8, Contrast 50: 2.7, 90: 5.0; B20 C30: 6.6, which confirms brightness before contrast). The #681 oracle samples still pass. CPU and GPU. The positive curves' remaining 5-8/255 needs a better curve family (a two-point spline fits each capture within 1.6/255), left open. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 40 +++++++++++++++++++++++++---- crates/gpu/src/shaders/kernels.wgsl | 16 ++++++++---- docs/effects-parity.md | 4 +-- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 705cc3c7..4dd800fa 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -59,18 +59,22 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf { /// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of /// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1 /// pass through. The GPU kernel mirrors it. +/// +/// Negative Brightness and Contrast are the inverse curves of the positive ones (After Effects +/// 2026 ramps: Brightness −40 is Brightness +40 inverted within 2.6/255, Contrast −50 likewise), +/// so they use the reciprocal exponents: Brightness −40 was 20/255 off AE, now 6.2. pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 { if v >= 1.0 { return v; } let mut v = v.max(0.0); - if b > 0.0 { - v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b)); - } else if b < 0.0 { - v = v.powf(2f32.powf(-1.32 * b)); + if b != 0.0 { + let e = 2f32.powf(1.24 * b.abs()); + v = 1.0 - (1.0 - v).powf(if b > 0.0 { e } else { 1.0 / e }); } if c != 0.0 { - let g = (1.0 + 0.53 * c).max(0.05); + let g = (1.0 + 0.53 * c.abs()).max(0.05); + let g = if c > 0.0 { g } else { 1.0 / g }; v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) }; } v @@ -1340,6 +1344,32 @@ mod tests { } } + /// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast + /// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit) + #[test] + fn negative_brightness_and_contrast_invert_the_positive_curves() { + let go = |b: f64, c: f64, x: u8| { + let v = x as f32 / 255.0; + (px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32 + }; + let samples: &[(f64, f64, u8, u8)] = &[ + (-40.0, 0.0, 64, 50), + (-40.0, 0.0, 128, 99), + (-40.0, 0.0, 191, 155), + (-40.0, 0.0, 234, 207), + (0.0, -50.0, 21, 28), + (0.0, -50.0, 64, 76), + (0.0, -50.0, 191, 179), + (0.0, -50.0, 234, 227), + (40.0, 0.0, 128, 162), + (0.0, 50.0, 64, 52), + ]; + for &(b, c, x, ae) in samples { + let got = go(b, c, x); + assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}"); + } + } + /// #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). diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 00d75fe2..45cb9efe 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -624,14 +624,20 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { } else if (P.u[0].y == 2u) { // Brightness & Contrast tone curves (effects crate bc_modern, AE-fitted #681). var o = c; - let g = max(1.0 + 0.53 * ct, 0.05); + // Negative settings invert the positive curves (reciprocal exponents). + var g = max(1.0 + 0.53 * abs(ct), 0.05); + if (ct < 0.0) { + g = 1.0 / g; + } + var e = exp2(1.24 * abs(br)); + if (br < 0.0) { + e = 1.0 / e; + } for (var i = 0; i < 3; i++) { if (c[i] < 1.0) { var v = max(c[i], 0.0); - if (br > 0.0) { - v = 1.0 - powz(1.0 - v, exp2(1.24 * br)); - } else if (br < 0.0) { - v = powz(v, exp2(-1.32 * br)); + if (br != 0.0) { + v = 1.0 - powz(1.0 - v, e); } if (ct != 0.0) { if (v < 0.5) { diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2d59c6b4..d072c49c 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, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); 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); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); 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 | @@ -67,8 +67,8 @@ the GPU is performance, not parity. | 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-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-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 | | 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 | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | From 39bd0efac29b5e656c59ca969b5abcddb6538b5e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:26:04 +0200 Subject: [PATCH 08/13] 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 --- crates/effects/src/keying.rs | 93 +++++++++++++++++++----------- crates/gpu/src/shaders/fx_key.wgsl | 18 ++---- docs/effects-parity.md | 3 +- 3 files changed, 65 insertions(+), 49 deletions(-) diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 9d61ff8c..c4f216aa 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -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::>() + }; + // 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); diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index a28ecc65..b3865472 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -199,23 +199,13 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { 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(m); } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index d072c49c..1fcbc4a6 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -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 | From 75271bfe6c9fb21b44de1cf0c4c66974639be8bb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 12:20:28 +0200 Subject: [PATCH 09/13] Luma Key and Extract: Rec. 601 luminance, as After Effects After Effects 2026 keys Luma Key and Extract (Channel Luminance) on Rec. 601 weights of the working-space values: Key Out Darker / Extract Black Point at 65, 166 and 24 on pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green (150 / 182) - Rec. 709, which EffectCraft used, decides all three the other way. Case 904 E18 (Extract 30 / 220 / softness 10 on grey and colour steps): 513 of 5184 grid points off (pure blue kept at 231 in AE, keyed out in EC), now 0, max 0. CPU and GPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 15 ++++++++------- crates/effects/src/keying.rs | 21 ++++++++++++++++++--- crates/gpu/src/shaders/fx_key.wgsl | 9 +++++++-- docs/effects-parity.md | 3 ++- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 4dd800fa..ddbb61b2 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -14,13 +14,14 @@ 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] } -/// 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 { +/// Rec. 601 luminance on the working-space values (sRGB working space, not linearised: red +/// 76/255, green 150/255, blue 29/255), as After Effects 2026 renders Tint, Tritone, Luma Key and +/// Extract. 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). Luma Key and Extract: Key Out Darker / Extract Black Point at 65, 166 and 24 on +/// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green +/// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it. +pub(crate) fn luma_601(c: [f32; 3]) -> f32 { 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] } diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index c4f216aa..4953d482 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -2,12 +2,13 @@ //! matte clean-up. Written from public behaviour descriptions and the standard colour-difference / //! blue-screen matting literature (Vlahos; Smith & Blinn, "Blue Screen Matting"). -use effectcraft_color::{luminance, rgb_to_hsl, srgb_to_linear}; +use effectcraft_color::{rgb_to_hsl, srgb_to_linear}; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use effectcraft_raster::{Image, gaussian_blur}; use rayon::prelude::*; +use crate::color_fx::luma_601; use crate::util::{Plane, gauss_plane, morph_frac, premul, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -144,7 +145,7 @@ fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf { 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]); + let l = luma_601([c[0], c[1], c[2]]); let keep = match kind { 0 => l <= thr, 2 => (l - thr).abs() > tol, @@ -311,7 +312,7 @@ fn extract(ctx: &EffectCtx, mut b: Buf) -> Buf { 2 => c[1], 3 => c[2], 4 => a, - _ => luminance(c[0], c[1], c[2]), + _ => luma_601([c[0], c[1], c[2]]), }; let mut k = 1.0f32; if v < bp { @@ -827,6 +828,20 @@ mod tests { } } + /// After Effects 2026: Key Out Darker and Extract's Black Point at 65 / 166 / 24 on pure + /// red / green / blue keep red and blue and key out green: Rec. 601 luminance (Rec. 709 + /// would key out red and blue and keep green). + #[test] + fn luma_key_and_extract_use_rec601_as_after_effects() { + for (c, thr, kept) in [([1.0, 0.0, 0.0], 65.0, true), ([0.0, 1.0, 0.0], 166.0, false), ([0.0, 0.0, 1.0], 24.0, true)] { + let img = Image::filled(2, 1, [c[0], c[1], c[2], 1.0]); + let key = run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(thr))], img.clone()); + let ext = run("ec.key.extract", &[("blackPoint", num(thr))], img); + let want = if kept { 1.0 } else { 0.0 }; + assert_eq!((key.get(0, 0)[3], ext.get(0, 0)[3]), (want, want), "{c:?} at {thr}"); + } + } + #[test] fn extract_keeps_range() { let mut img = Image::new(3, 1); diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index b3865472..37146c32 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -62,6 +62,11 @@ fn fxk_range_components(space: u32, c: vec3) -> vec3 { } // keying::matte_levels. +// Luma Key / Extract luminance: Rec. 601 (effects::color_fx::luma_601). +fn fxk_key_luma(c: vec3) -> f32 { + return dot(c, vec3(0.299, 0.587, 0.114)); +} + fn fxk_matte_levels(v: f32, in_b: f32, in_w: f32, gamma: f32, out_b: f32, out_w: f32) -> f32 { var t = clamp((v - in_b / 255.0) / max((in_w - in_b) / 255.0, 1e-4), 0.0, 1.0); t = powz(t, 1.0 / max(gamma, 0.01)); @@ -195,7 +200,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { o = vec4(select(1.0, 0.0, d <= f0.w)); } case 2u: { - let l = luminance(c); + let l = fxk_key_luma(c); let thr = f0.x; let tol = f0.y; var m: f32; @@ -231,7 +236,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { case 2u: { v = c.y; } case 3u: { v = c.z; } case 4u: { v = a; } - default: { v = luminance(c); } + default: { v = fxk_key_luma(c); } } v = 255.0 * v; let bp = f0.x; diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 1fcbc4a6..92f4d419 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -35,7 +35,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | | Immersive Video | 12 | 12 | — | -| Keying | 12 | 12 | Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | +| Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; 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 | Luma Key matches After Effects 2026 on a grey ramp (hard cut, Tolerance only for Similar / Dissimilar) | @@ -69,6 +69,7 @@ the GPU is performance, not parity. | 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-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (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 | From d7854b84a31ff3be6cd3f57291a68ac44f289695 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 12:37:28 +0200 Subject: [PATCH 10/13] =?UTF-8?q?4-Color=20Gradient:=20weights=201=20/=20(?= =?UTF-8?q?d=C2=B2=20+=20Blend=C2=B2),=20as=20After=20Effects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Effects weighs each of the four points by its inverse squared distance softened by Blend squared, 1 / (d^2 + Blend^2) in pixels: fitted to AE 2026 renders of the default gradient at Blend 10, 100 and 300 and case 904 E16 at Blend 50 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 = about Blend^2; mean error 0.27/255, max 0.9). EffectCraft used 1 / (d^2 / (100 Blend))^1.5, in which Blend cancels out: E16 was 34/255 off on 99 % of the grid, now 0 grid points off, max 1/255, CPU and GPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/generate.rs | 66 ++++++++++++++++++++++++- crates/effects/src/lib.rs | 2 +- crates/gpu/src/fx_generate.rs | 4 +- crates/gpu/src/shaders/fx_generate.wgsl | 2 +- docs/effects-parity.md | 3 +- 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index fd5a5412..637cc428 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -112,10 +112,19 @@ pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px { } } +/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point +/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100 +/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error +/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0. +pub fn four_color_softening(blend: f64, scale: f64) -> f64 { + let b = blend.max(0.0) * scale; + (b * b).max(1e-6) +} + fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let pts: Vec<((f64, f64), [f32; 4])> = (1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect(); - let blend = ctx.params.f("blend").max(1.0); + let k = four_color_softening(ctx.params.f("blend"), b.scale); let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; let op = ctx.params.f("opacity") as f32 / 100.0; let mode = ctx.params.e("blendingMode"); @@ -126,7 +135,7 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let mut wsum = 0.0; for ((qx, qy), c) in &pts { let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2); - let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5); + let w = 1.0 / (d2 + k); for i in 0..3 { acc[i] += c[i] as f64 * w; } @@ -423,6 +432,59 @@ mod generator_tests { } } + /// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 % + /// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10 + /// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5). + #[test] + fn four_color_blend_matches_after_effects() { + let pts = [ + ("positionsColors/point1", [19.2, 10.8]), + ("positionsColors/point2", [172.8, 10.8]), + ("positionsColors/point3", [19.2, 97.2]), + ("positionsColors/point4", [172.8, 97.2]), + ]; + let cols = [ + ("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]), + ("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]), + ("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]), + ("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]), + ]; + let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [ + ( + 10.0, + [ + (30, 20, [249.0, 240.0, 15.0]), + (150, 30, [20.0, 208.0, 47.0]), + (60, 90, [213.0, 56.0, 199.0]), + (100, 10, [115.0, 177.0, 78.0]), + (10, 50, [238.0, 145.0, 110.0]), + ], + ), + ( + 30.0, + [ + (30, 20, [236.0, 214.0, 41.0]), + (150, 30, [30.0, 189.0, 66.0]), + (60, 90, [203.0, 67.0, 188.0]), + (100, 10, [117.0, 173.0, 82.0]), + (10, 50, [233.0, 140.0, 115.0]), + ], + ), + ]; + for (blend, samples) in ae { + let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect(); + vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v)))); + vals.push(("blend", num(blend))); + let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default()); + for (x, y, want) in samples { + let p = o.img.get(x, y); + for c in 0..3 { + assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]); + } + } + } + } + #[test] fn four_color_none_stays_inside_the_layer_alpha() { // #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index f157e74c..13a4130d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -91,7 +91,7 @@ pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; // CPU helpers the GPU kernels share (effectcraft-gpu). pub use blur2::{camera_lens_plain, camera_lens_spans}; -pub use generate::gen_mode; +pub use generate::{four_color_softening, gen_mode}; pub use generate2::pattern_kind as cell_pattern_kind; pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan}; pub use generate3::{ diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 3744ec4f..0216d390 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -353,10 +353,10 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0]; p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1)); } - let blend = ctx.params.f("blend").max(1.0); + let k = effectcraft_effects::four_color_softening(ctx.params.f("blend"), b.scale); let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0]; - p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + p.f[8] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; run(e, "gen_fourcolor", &p, b, None, None) } diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index f46b2f0b..bca00ce1 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -500,7 +500,7 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3) { let dx = f32(p.x) + 0.5 - q.x; let dy = f32(p.y) + 0.5 - q.y; let d2 = dx * dx + dy * dy; - let w = 1.0 / pow(d2 / bl + 1e-6, 1.5); + let w = 1.0 / (d2 + bl); acc += P.f[4 + i].xyz * w; wsum += w; } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 92f4d419..97c43bc4 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -33,7 +33,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); 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 | +| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | | Immersive Video | 12 | 12 | — | | Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | | Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | @@ -70,6 +70,7 @@ the GPU is performance, not parity. | 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-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | +| 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (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 | From 84ffc8453eedbc509d226e483e63221efb9f7324 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 10:42:33 +0200 Subject: [PATCH 11/13] Fill: Opacity is the filled layer's opacity, as After Effects Without a Fill Mask, After Effects turns the layer into the fill colour and applies Opacity to its alpha; nothing of the original shows through. AE 2026 renders Fill (0, 0.5, 1) at Opacity 50 % on a Gradient Ramp over a red solid as premultiplied (0, 0.251, 0.502) at alpha 0.502 at every pixel; EffectCraft mixed the fill with the ramp (mean difference 95.5/255), now within 1/255, CPU and GPU. Invert fills the transparent part the same way. With a Fill Mask the mix with the original is unchanged (not measured). Lottie's fill effect (colour, alpha x opacity) now plays the same in EffectCraft. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 37 ++++++++++++++++++++++------- crates/gpu/src/shaders/kernels.wgsl | 10 +++----- docs/effects-parity.md | 3 ++- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index ddbb61b2..8e451f4e 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -480,15 +480,12 @@ fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf { }); return b; } + // Without masks the layer becomes the fill colour, and Opacity is the layer's opacity: After + // Effects 2026 renders Fill (0, 0.5, 1) at Opacity 50 % as that colour at half alpha, with + // nothing of the original showing through (it is not a mix with the original). b.img.data.iter_mut().for_each(|px| { - let a = if invert { 1.0 - px[3] } else { px[3] }; - let f = [c[0] * a, c[1] * a, c[2] * a]; - for i in 0..3 { - px[i] += (f[i] - px[i]) * opacity; - } - if invert { - px[3] += (a - px[3]) * opacity; - } + let a = if invert { 1.0 - px[3] } else { px[3] } * opacity; + *px = [c[0] * a, c[1] * a, c[2] * a, a]; }); b } @@ -1666,6 +1663,30 @@ mod tests { assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]); } + /// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp + /// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the + /// fill colour at half the layer's alpha, nothing of the ramp left. + #[test] + fn fill_opacity_is_the_layers_opacity_as_in_after_effects() { + let mut img = Image::new(16, 4); + for x in 0..16 { + let v = x as f32 / 15.0; + for y in 0..4 { + img.set(x, y, [v, 0.0, 0.0, 1.0]); + } + } + let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))]; + let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img; + for x in [0, 7, 15] { + let p = out.get(x, 2); + let ae = [0.0, 0.251, 0.502, 0.502]; + assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}"); + } + // A half-transparent pixel ends at a quarter alpha, still the fill colour. + let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img; + assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]); + } + #[test] fn colorama_palettes_modify_selection_masking_and_add_phase() { let grey = [0.5, 0.5, 0.5, 1.0]; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 45cb9efe..2d00ccfa 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -766,17 +766,13 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { let c = P.f[0]; let invert = P.u[0].y != 0u; let opacity = P.f[1].x; + // The fill colour at the layer's (or inverted) alpha times Opacity (color_fx::fill). var fa = px.w; if (invert) { fa = 1.0 - px.w; } - let f = c.xyz * fa; - let rgb = px.xyz + (f - px.xyz) * opacity; - var alpha = px.w; - if (invert) { - alpha = px.w + (fa - px.w) * opacity; - } - px = vec4(rgb, alpha); + fa = fa * opacity; + px = vec4(c.xyz * fa, fa); } case 8u: { // effects::color2::map_ca: transparent pixels only change through the alpha curve. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 97c43bc4..4e9a2ddb 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -33,7 +33,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); 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 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | +| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | | Immersive Video | 12 | 12 | — | | Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | | Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | @@ -71,6 +71,7 @@ the GPU is performance, not parity. | 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-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) | +| 2026-10-19 | minor | Fill without a mask: the layer becomes the fill colour at its alpha × Opacity (was a mix with the original), measured against After Effects 2026 (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 | From d9205fa5f499a4df02c0696db46bff7072523cb0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 15:36:29 +0200 Subject: [PATCH 12/13] Curves: a natural cubic spline through the points, as After Effects After Effects' Curves effect interpolates its points with a natural cubic spline (zero curvature at the ends), clamped to 0..1: AE 2026 renders of an RGB curve (0,0) (125,199) (255,255) and a Red curve (0,0) (128,44) (255,255) (cases 962 / 963, ROUND4) give exactly the natural spline's values on every grey step (0, 30, 59, 115, 164, 202, 227, 242, 250, 255 and 0, 0, 1, 6, 19, 44, 83, 135, 194, 255). EffectCraft used a monotone (Fritsch-Carlson) spline: 34 / 67 / 125 / 171 ... on the RGB curve, up to 10/255 off (ROUND4 measured up to 6 levels on real projects); now within 1/255, CPU and GPU (the same Hermite tables carry the spline's knot slopes). Lumetri's curves and Glow's Arbitrary Map keep the monotone spline (not measured). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color2.rs | 76 +++++++++++++++++++++++++++++++++++- crates/gpu/src/effects.rs | 2 +- docs/effects-parity.md | 3 +- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index d78b0fb0..9547fd77 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -55,6 +55,17 @@ impl Curve { /// Parse "x,y x,y …" (separators: whitespace or ';'). Returns None for an identity curve or /// fewer than two valid points. pub fn parse(s: &str) -> Option { + Self::parse_with(s, false) + } + + /// [`Curve::parse`] interpolated as After Effects' Curves effect does: a natural cubic spline + /// through the points, clamped to 0..1 (AE 2026: RGB points (0,0) (125,199) (255,255) and + /// Red (0,0) (128,44) (255,255) map every grey step exactly as the natural spline does). + pub fn parse_natural(s: &str) -> Option { + Self::parse_with(s, true) + } + + fn parse_with(s: &str, natural: bool) -> Option { let mut pts: Vec<(f32, f32)> = s .split(|c: char| c.is_whitespace() || c == ';') .filter(|t| !t.is_empty()) @@ -74,6 +85,12 @@ impl Curve { let ys: Vec = pts.iter().map(|p| p.1).collect(); let n = xs.len(); let d: Vec = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect(); + if natural { + let ms = natural_slopes(&xs, &d); + let mut c = Curve { xs, ys, ms, lut: Vec::new() }; + c.lut = (0..=LUT_N).map(|i| c.eval_exact(i as f32 / LUT_N as f32).clamp(0.0, 1.0)).collect(); + return Some(c); + } let mut ms = vec![0.0f32; n]; ms[0] = d[0]; ms[n - 1] = d[n - 2]; @@ -136,6 +153,40 @@ impl Curve { } } +/// Knot slopes of the natural cubic spline (zero curvature at both ends) through points with +/// abscissae `xs` and segment slopes `d`; as Hermite tangents they reproduce the spline exactly. +fn natural_slopes(xs: &[f32], d: &[f32]) -> Vec { + let n = xs.len(); + if n < 3 { + return vec![d.first().copied().unwrap_or(0.0); n]; + } + let h: Vec = (0..n - 1).map(|k| f64::from(xs[k + 1] - xs[k])).collect(); + let dd: Vec = d.iter().map(|&v| f64::from(v)).collect(); + // Second derivatives M[1..n-1] (M[0] = M[n-1] = 0): tridiagonal system, Thomas algorithm. + let m = n - 2; + let (mut a, mut b, mut c, mut r) = (vec![0.0; m], vec![0.0; m], vec![0.0; m], vec![0.0; m]); + for i in 0..m { + let k = i + 1; + a[i] = h[k - 1]; + b[i] = 2.0 * (h[k - 1] + h[k]); + c[i] = h[k]; + r[i] = 6.0 * (dd[k] - dd[k - 1]); + } + for i in 1..m { + let w = a[i] / b[i - 1]; + b[i] -= w * c[i - 1]; + r[i] -= w * r[i - 1]; + } + let mut sec = vec![0.0f64; n]; + for i in (0..m).rev() { + let next = if i + 1 < m { sec[i + 2] } else { 0.0 }; + sec[i + 1] = (r[i] - c[i] * next) / b[i]; + } + let mut ms: Vec = (0..n - 1).map(|k| (dd[k] - h[k] * (2.0 * sec[k] + sec[k + 1]) / 6.0) as f32).collect(); + ms.push((dd[n - 2] + h[n - 2] * (sec[n - 2] + 2.0 * sec[n - 1]) / 6.0) as f32); + ms +} + fn ev(c: &Option, v: f32) -> f32 { c.as_ref().map(|c| c.eval(v)).unwrap_or(v) } @@ -145,7 +196,7 @@ fn ev(c: &Option, v: f32) -> f32 { /// sorted on load); the default `"0,0 1,1"` is the identity. Colour channels go through the /// master RGB curve first, then their own curve. fn curves(ctx: &EffectCtx, mut b: Buf) -> Buf { - let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse(ctx.params.s(id))); + let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse_natural(ctx.params.s(id))); if m.is_none() && r.is_none() && g.is_none() && bl.is_none() && a.is_none() { return b; } @@ -1199,6 +1250,29 @@ mod tests { assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0)); } + /// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199) + /// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results. + #[test] + fn curves_follow_a_natural_spline_as_in_after_effects() { + let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + let mut img = Image::new(10, 1); + for (x, v) in steps.iter().enumerate() { + let v = *v as f32 / 255.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::>().join(" ")); + let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]); + let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]); + let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255]; + let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255]; + for x in 0..10 { + let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32; + let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32; + assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]); + assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]); + } + } + #[test] fn levels_ic_identity_and_red_only() { let img = ramp(); diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab7..33deb36a 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -388,7 +388,7 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { // ---------------------------------------------------------------- per-pixel fn curves(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { - let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse(ctx.params.s(id))); + let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse_natural(ctx.params.s(id))); if parsed.iter().all(Option::is_none) { return Some(b); } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 4e9a2ddb..099e783e 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, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); 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); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); 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 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -72,6 +72,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) | | 2026-10-19 | minor | Fill without a mask: the layer becomes the fill colour at its alpha × Opacity (was a mix with the original), measured against After Effects 2026 (CPU and GPU) | +| 2026-10-19 | minor | Curves: natural cubic spline through the points, clamped to 0..1 (was a monotone spline), measured against After Effects 2026 (CPU and GPU); Lumetri's curves and Glow's Arbitrary Map keep the monotone spline | | 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 | From e559a7ce9ac93a35c655c65276ddd01684746610 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 18:38:41 +0200 Subject: [PATCH 13/13] Review: tie the colour kernels' literals to the CPU constants; visible GPU skip; B&C wording WGSL cannot read Rust constants, so the Rec. 601 luma (Tint, Tritone, Luma Key / Extract), Black & White's tint weights and Exposure's 2.2 power stay literals in the kernels; they now name their Rust source (effects::LUMA_601, BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, newly exported) and the test shader_constants_match_the_cpu (no GPU needed) fails if a literal drifts. gpu_tint_defaults_match_after_effects_greys says "skipped: no GPU adapter" when it skips (EFFECTCRAFT_REQUIRE_GPU=1 still makes it fail). docs/effects-parity.md: Brightness & Contrast is close to, not the same as, After Effects (up to 6.2/255), and the summary row lists what is measured. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 13 +++++++++++-- crates/effects/src/lib.rs | 6 +++--- crates/gpu/src/shaders/fx_color.wgsl | 4 +++- crates/gpu/src/shaders/fx_key.wgsl | 3 ++- crates/gpu/src/shaders/kernels.wgsl | 6 ++++-- crates/gpu/src/tests.rs | 29 +++++++++++++++++++++++++++- docs/effects-parity.md | 6 +++--- 7 files changed, 54 insertions(+), 13 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 8e451f4e..402d0ebe 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -22,9 +22,13 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { /// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green /// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it. pub(crate) fn luma_601(c: [f32; 3]) -> f32 { - 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] + LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2] } +/// The Rec. 601 luma weights of [`luma_601`]. The WGSL kernels repeat them as literals; the GPU +/// test `shader_constants_match_the_cpu` checks that they are these. +pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114]; + fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf { let black = ctx.params.color("black"); let white = ctx.params.color("white"); @@ -337,6 +341,7 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] { /// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion /// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005 /// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65). +/// `kernels.wgsl` repeats it as a literal; the GPU test `shader_constants_match_the_cpu` checks it. pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2; /// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless @@ -379,8 +384,12 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { /// 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. +/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU +/// test `shader_constants_match_the_cpu` checks that they are these. +pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11]; + 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]; + let lum = |c: [f32; 3]| BW_TINT_LUMA[0] * c[0] + BW_TINT_LUMA[1] * c[1] + BW_TINT_LUMA[2] * c[2]; if !gray.is_finite() || gray <= 0.0 { return [0.0; 3]; } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 13a4130d..a2497fcf 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -70,11 +70,11 @@ use std::collections::HashMap; use std::sync::OnceLock; pub use card3d::{CompLight, CompScene}; -pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color_fx::{ - HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity, - levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, + BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, LUMA_601, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, + fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, }; +pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use color3::OffsetCurve; pub use distort::transform_shutter; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index daac9fb5..f616a2d9 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -102,6 +102,7 @@ fn fxc_sc_weights(c0: vec3) -> array { // 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). +// The weights are effects::BW_TINT_LUMA (checked by the test shader_constants_match_the_cpu). fn fxc_bw_tint(tint: vec3, gray: f32) -> vec3 { if (!(gray > 0.0)) { return vec3(0.0); @@ -191,7 +192,8 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { case 4u: { if (a > 0.0) { let c = px.xyz / a; - // Rec. 601 weights, as effects::color_fx::tritone. + // Rec. 601 weights, as effects::color_fx::tritone: effects::LUMA_601 (checked by + // the test shader_constants_match_the_cpu). let l = clamp(dot(c, vec3(0.299, 0.587, 0.114)), 0.0, 1.0); var t: vec3; if (l < 0.5) { diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index 37146c32..bee93ea6 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -62,7 +62,8 @@ fn fxk_range_components(space: u32, c: vec3) -> vec3 { } // keying::matte_levels. -// Luma Key / Extract luminance: Rec. 601 (effects::color_fx::luma_601). +// Luma Key / Extract luminance: Rec. 601, effects::LUMA_601 (checked by the test +// shader_constants_match_the_cpu). fn fxk_key_luma(c: vec3) -> f32 { return dot(c, vec3(0.299, 0.587, 0.114)); } diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 2d00ccfa..6960da85 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -605,7 +605,8 @@ 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 luma_601). + // Rec. 601 weights, as After Effects: effects::LUMA_601 (checked by the test + // shader_constants_match_the_cpu). 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; @@ -726,7 +727,8 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { var o = vec3(0.0); for (var i = 0; i < 3; i++) { // Per-channel gain, offset, gamma; negative values are mirrored. The - // linear-light conversion is a pure 2.2 power (effects crate exposure). + // linear-light conversion is a pure 2.2 power: effects::EXPOSURE_LINEAR_GAMMA + // (checked by the test shader_constants_match_the_cpu). if (P.u[0].y != 0u) { let x = c[i] * P.f[0][i] + P.f[1][i]; o[i] = sign(x) * powz(abs(x), 1.0 / P.f[2][i]); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 11dc81a9..a481ff73 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -855,7 +855,11 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { /// 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 }; + let Some(g) = gpu() else { + // Visible skip (EFFECTCRAFT_REQUIRE_GPU=1 makes a missing adapter fail instead). + eprintln!("gpu_tint_defaults_match_after_effects_greys: skipped: no GPU adapter"); + return; + }; for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { let mut s = Scene::new(BitDepth::Bpc32); s.p.settings.working_space = Some(ColorSpace::Srgb); @@ -870,6 +874,29 @@ fn gpu_tint_defaults_match_after_effects_greys() { } } +/// WGSL has no way to read the CPU's constants, so the kernels repeat them as literals; this +/// ties each literal to its Rust source (runs without a GPU): Rec. 601 luma for Tint (`pointwise` +/// op 1), Tritone and Luma Key / Extract, Black & White's tint weights and Exposure's 2.2 power. +#[test] +fn shader_constants_match_the_cpu() { + use effectcraft_effects::{BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, LUMA_601}; + let kernels = include_str!("shaders/kernels.wgsl"); + let color = include_str!("shaders/fx_color.wgsl"); + let key = include_str!("shaders/fx_key.wgsl"); + let [r, g, b] = LUMA_601; + let dot = format!("dot(c, vec3({r}, {g}, {b}))"); + assert!(kernels.contains(&format!("clamp({r} * c.x + {g} * c.y + {b} * c.z, 0.0, 1.0)")), "Tint luma in kernels.wgsl is not {LUMA_601:?}"); + assert!(color.contains(&format!("clamp({dot}, 0.0, 1.0)")), "Tritone luma in fx_color.wgsl is not {LUMA_601:?}"); + assert!(key.contains(&format!("return {dot};")), "fxk_key_luma in fx_key.wgsl is not {LUMA_601:?}"); + let [r, g, b] = BW_TINT_LUMA; + assert!(color.contains(&format!("let w = vec3({r}, {g}, {b});")), "fxc_bw_tint weights are not {BW_TINT_LUMA:?}"); + let gamma = EXPOSURE_LINEAR_GAMMA; + assert!( + kernels.contains(&format!("powz(abs(c[i]), {gamma})")) && kernels.contains(&format!("1.0 / {gamma})")), + "Exposure's power in kernels.wgsl is not {gamma}" + ); +} + /// A layer whose bounds change every frame reads back at a new size each time: the staging /// buffers kept for reuse stay within budget instead of growing for the whole render (#2081). #[test] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 099e783e..6defcba8 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -15,7 +15,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump | | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | -| **Rendered output matches After Effects** | **unmeasured** | Only Tint is checked against After Effects renders (four solids, within 1/255). `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | +| **Rendered output matches After Effects** | **partly measured** | Some colour effects are checked against After Effects 2026 renders of our own test projects: Tint, Tritone, Black & White, Exposure, Levels, Curves, Luma Key, Extract, 4-Color Gradient and Fill within 1–3/255, Brightness & Contrast up to 6.2/255 (Color Correction, Generate and Keying rows below); every other effect is unmeasured. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | | Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes | @@ -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, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); 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); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) close to After Effects 2026 ramps, not identical (up to 6.2/255 off; the test allows 7/255) (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); 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 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -67,7 +67,7 @@ the GPU is performance, not parity. | 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-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 | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026; still up to 6.2/255 off, not the same pixels), 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-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) |