From 515cc3d5f711805982e4fd9dbe1586cd1dfc42eb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 11:26:09 +0200 Subject: [PATCH] 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) |