From e559a7ce9ac93a35c655c65276ddd01684746610 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 18:38:41 +0200 Subject: [PATCH] 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) |