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 |