From 216fc6052d0b238ad0db14a0b0e7a4a48558d757 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 14:52:53 +0530 Subject: [PATCH 01/17] #3470, #3471, #3472, #3473, #3474, #3475, #3476, #3477, #3478, #3479, #3480, #3483, #3484, #3487, #3488, #3489, #3490, #3491: guard effect hard ranges against After Effects' recorded ranges A test walks every effect parameter the .aep table maps to a recorded After Effects definition (fx_defs) and checks that the effect's hard range (ParamUi::Slider min..max, what prop.set and the value editors clamp to) covers After Effects' valid range, converted to EffectCraft's units the way the importer converts values. Conv::number_scale is that conversion, now shared by the importer and the test. The parameters still narrower are allow-listed by issue (#3460-#3492); the test fails on a new narrower parameter and on a row that is no longer needed, so the list shrinks as the issues are fixed. --- crates/aep/src/container/effect_map.rs | 13 +++ crates/aep/src/export/fx_defs.rs | 138 +++++++++++++++++++++++++ crates/aep/src/import/effects.rs | 17 +-- 3 files changed, 154 insertions(+), 14 deletions(-) diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index 49a53d2e7..29274fc68 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -38,6 +38,19 @@ pub enum Conv { Skip, } +impl Conv { + /// For a number (`Percent`, `Plain`, `Factor`): what a stored value is multiplied by to give + /// the value here. `None` for every other conversion. + pub fn number_scale(self) -> Option { + match self { + Conv::Percent { valid_max } => Some(100.0 / valid_max), + Conv::Plain { .. } => Some(1.0), + Conv::Factor { factor, .. } => Some(factor), + _ => None, + } + } +} + // After Effects' popup items as the corpus stores them (`pdnm`). /// 368. pub const RAMP_SHAPE: &[&str] = &["Linear Ramp", "Radial Ramp"]; diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index e8464bada..346f20462 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -707,3 +707,141 @@ pub(crate) static FX_DEFS: &[FxDef] = &[ p(BUILT_IN, 9, &[], None, None, None, 0x00000000), ]), ]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::container::effect_map::by_match_name; + use effectcraft_project::ParamUi; + + /// Effect parameters whose hard range (`ParamUi::Slider` min..max, which `prop.set` and the + /// value editors clamp to) is still narrower than After Effects' recorded valid range, each + /// with the open issue that tracks it: (effect, parameter, issue). Remove a row when its + /// issue is fixed; the test fails on a row that is no longer needed. + #[rustfmt::skip] + const KNOWN_NARROWER: &[(&str, &str, u32)] = &[ + ("ec.blur.gaussian", "blurriness", 3460), + ("ec.blur.fastbox", "radius", 3461), + ("ec.blur.channel", "redBlurriness", 3462), + ("ec.blur.channel", "greenBlurriness", 3462), + ("ec.blur.channel", "blueBlurriness", 3462), + ("ec.blur.channel", "alphaBlurriness", 3462), + ("ec.obsolete.gaussianlegacy", "blurriness", 3463), + ("ec.perspective.dropshadow", "softness", 3464), + ("ec.blur.compound", "maximumBlur", 3465), + ("ec.blur.sharpen", "amount", 3466), + ("ec.blur.radial", "amount", 3467), + ("ec.blur.ccradial", "amount", 3468), + ("ec.distort.turbulentdisplace", "amount", 3469), + ("ec.distort.turbulentdisplace", "evolutionOptions/randomSeed", 3470), + ("ec.distort.transform", "scaleHeight", 3471), + ("ec.distort.transform", "scaleWidth", 3471), + ("ec.transition.venetian", "width", 3472), + ("ec.transition.venetian", "feather", 3473), + ("ec.transition.radialwipe", "feather", 3474), + ("ec.generate.beam", "startThickness", 3475), + ("ec.generate.beam", "endThickness", 3475), + ("ec.generate.grid", "border", 3476), + ("ec.stylize.scatter", "amount", 3477), + ("ec.generate.fourcolor", "blend", 3478), + ("ec.generate.fourcolor", "jitter", 3479), + ("ec.color.changecolor", "hueTransform", 3480), + ("ec.time.pixelmotionblur", "shutterAngle", 3481), + ("ec.time.pixelmotionblur", "shutterSamples", 3482), + ("ec.noise.noisehls", "hue", 3483), + ("ec.noise.noisehls", "lightness", 3483), + ("ec.noise.noisehls", "saturation", 3483), + ("ec.noise.noisehls", "grainSize", 3484), + ("ec.noise.fractal", "subSettings/subInfluence", 3485), + ("ec.noise.fractal", "subSettings/subScaling", 3486), + ("ec.color.levelsic", "rgb/rgbInBlack", 3487), + ("ec.color.levelsic", "red/redInBlack", 3487), + ("ec.color.levelsic", "green/greenInBlack", 3487), + ("ec.color.levelsic", "blue/blueInBlack", 3487), + ("ec.color.levelsic", "alpha/alphaInBlack", 3487), + ("ec.color.levelsic", "rgb/rgbInWhite", 3488), + ("ec.color.levelsic", "red/redInWhite", 3488), + ("ec.color.levelsic", "green/greenInWhite", 3488), + ("ec.color.levelsic", "blue/blueInWhite", 3488), + ("ec.color.levelsic", "alpha/alphaInWhite", 3488), + ("ec.color.levelsic", "rgb/rgbOutBlack", 3489), + ("ec.color.levelsic", "red/redOutBlack", 3489), + ("ec.color.levelsic", "green/greenOutBlack", 3489), + ("ec.color.levelsic", "blue/blueOutBlack", 3489), + ("ec.color.levelsic", "alpha/alphaOutBlack", 3489), + ("ec.color.levelsic", "rgb/rgbOutWhite", 3490), + ("ec.color.levelsic", "red/redOutWhite", 3490), + ("ec.color.levelsic", "green/greenOutWhite", 3490), + ("ec.color.levelsic", "blue/blueOutWhite", 3490), + ("ec.color.levelsic", "alpha/alphaOutWhite", 3490), + ("ec.color.levelsic", "rgb/rgbGamma", 3491), + ("ec.color.levelsic", "red/redGamma", 3491), + ("ec.color.levelsic", "green/greenGamma", 3491), + ("ec.color.levelsic", "blue/blueGamma", 3491), + ("ec.color.levelsic", "alpha/alphaGamma", 3491), + ("ec.distort.ccpowerpin", "expandTop", 3492), + ("ec.distort.ccpowerpin", "expandLeft", 3492), + ("ec.distort.ccpowerpin", "expandRight", 3492), + ("ec.distort.ccpowerpin", "expandBottom", 3492), + ]; + + /// `a` is at most `b`, allowing for the `f32` the recorded bounds were read from. + fn at_most(a: f64, b: f64) -> bool { + a <= b + 1e-6 * a.abs().max(b.abs()).max(1.0) + } + + /// Every effect parameter the .aep table maps to a recorded After Effects definition accepts + /// at least After Effects' valid range, converted to EffectCraft's units the way the importer + /// converts values (`Conv::number_scale`). Only `ParamUi::Slider` limits values (`prop.set`, + /// the Effect Controls and Timeline editors); other controls take any number. A mapping with + /// a declared narrower range (`ParamMap::limit`) must match the effect's range instead. + #[test] + fn effect_hard_ranges_cover_recorded_after_effects_ranges() { + let mut problems = Vec::new(); + let mut narrower = Vec::new(); + for fx in FX_DEFS { + let Some(map) = by_match_name(fx.match_name) else { continue }; + let Some(spec) = effectcraft_effects::find(map.effect) else { + problems.push(format!("{}: no effect {}", fx.match_name, map.effect)); + continue; + }; + for pm in map.params { + let Some(scale) = pm.conv.number_scale() else { continue }; + let at = format!("{} {}", map.effect, pm.target); + let Some(ps) = spec.params.iter().find(|p| p.id == pm.target) else { + problems.push(format!("{at}: not a parameter of the effect")); + continue; + }; + // Other controls take any number. + let ParamUi::Slider { min, max, .. } = &ps.ui else { continue }; + let Some(def) = fx.params.get(pm.index) else { + problems.push(format!("{at}: no recorded definition at parT {}", pm.index)); + continue; + }; + let (Some(lo), Some(hi)) = (def.valid_min, def.valid_max) else { + problems.push(format!("{at}: no recorded valid range")); + continue; + }; + let (lo, hi) = (lo * scale, hi * scale); + if let Some(limit) = pm.limit { + if limit != (*min, *max) { + problems.push(format!("{at}: declared limit {limit:?} is not the effect's range {min}..{max}")); + } + } else if !(at_most(*min, lo) && at_most(hi, *max)) { + narrower.push((map.effect, pm.target, format!("{at}: {min}..{max} excludes After Effects' {lo}..{hi}"))); + } + } + } + for (effect, param, why) in &narrower { + if !KNOWN_NARROWER.iter().any(|(e, p, _)| e == effect && p == param) { + problems.push(why.clone()); + } + } + for (effect, param, issue) in KNOWN_NARROWER { + if !narrower.iter().any(|(e, p, _)| e == effect && p == param) { + problems.push(format!("{effect} {param}: covers After Effects' range now (or is not mapped); remove it from KNOWN_NARROWER (#{issue})")); + } + } + assert!(problems.is_empty(), "{}", problems.join("\n")); + } +} diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 45ea70840..11d975cd8 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -259,23 +259,12 @@ pub(crate) fn import_mapped(p: &EffectParam<'_, '_>, m: &ParamMap, cx: &LayerCtx Conv::Checkbox => Kind::EffectBool, // Handled above. Conv::LayerRef { .. } | Conv::Skip => return, - Conv::Percent { valid_max } => { + Conv::Percent { valid_max } | Conv::Plain { valid_max } | Conv::Factor { valid_max, .. } => { if !checked(valid_max, col) { return; } - Kind::EffectNumber(100.0 / valid_max) - } - Conv::Plain { valid_max } => { - if !checked(valid_max, col) { - return; - } - Kind::EffectNumber(1.0) - } - Conv::Factor { valid_max, factor } => { - if !checked(valid_max, col) { - return; - } - Kind::EffectNumber(factor) + let Some(scale) = m.conv.number_scale() else { return }; + Kind::EffectNumber(scale) } }; // Grouped parameters are addressed by path (`master/exposure`). From 39a3b565d81fdda97ade8babfa62f23ee8ac4374 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:11:21 +0530 Subject: [PATCH 02/17] #3487, #3488, #3489, #3490, #3491: Levels (Individual Controls): levels and gamma take After Effects' ranges Input/Output Black and White of every channel take -10000..10000 and Gamma goes down to 0 (it keeps its wider maximum, 10), After Effects' recorded valid ranges in the same units (the .aep mapping is Conv::Plain). Defaults and the 0..1 / 0.1..3 sliders are unchanged. Gamma 0 renders as the existing 0.01 floor, an exponent of 100, so an HDR value a few times Input White would overflow f32 and an infinite curve times an empty output range is NaN. The curve is now bounded at +-1e6 on the CPU (Lv::apply) and the GPU (fxt_lv), so the output stays finite over the whole range; values below the bound are unchanged. The engine regression (tests/effect_ranges.rs) sets both ends of each row through prop.set, checks undo/redo, save and reopen, and that a project saved with the narrower ranges opens with the new ones and its values. --- crates/aep/src/export/fx_defs.rs | 25 ---- crates/effects/src/color2.rs | 22 +++- crates/effects/src/color2/tests.rs | 29 +++++ crates/engine/src/tests.rs | 1 + crates/engine/src/tests/effect_ranges.rs | 148 +++++++++++++++++++++++ crates/gpu/src/shaders/fx_tone.wgsl | 4 +- crates/gpu/src/tests/fx_tone.rs | 11 ++ 7 files changed, 208 insertions(+), 32 deletions(-) create mode 100644 crates/engine/src/tests/effect_ranges.rs diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index 346f20462..92ff7c20c 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -754,31 +754,6 @@ mod tests { ("ec.noise.noisehls", "grainSize", 3484), ("ec.noise.fractal", "subSettings/subInfluence", 3485), ("ec.noise.fractal", "subSettings/subScaling", 3486), - ("ec.color.levelsic", "rgb/rgbInBlack", 3487), - ("ec.color.levelsic", "red/redInBlack", 3487), - ("ec.color.levelsic", "green/greenInBlack", 3487), - ("ec.color.levelsic", "blue/blueInBlack", 3487), - ("ec.color.levelsic", "alpha/alphaInBlack", 3487), - ("ec.color.levelsic", "rgb/rgbInWhite", 3488), - ("ec.color.levelsic", "red/redInWhite", 3488), - ("ec.color.levelsic", "green/greenInWhite", 3488), - ("ec.color.levelsic", "blue/blueInWhite", 3488), - ("ec.color.levelsic", "alpha/alphaInWhite", 3488), - ("ec.color.levelsic", "rgb/rgbOutBlack", 3489), - ("ec.color.levelsic", "red/redOutBlack", 3489), - ("ec.color.levelsic", "green/greenOutBlack", 3489), - ("ec.color.levelsic", "blue/blueOutBlack", 3489), - ("ec.color.levelsic", "alpha/alphaOutBlack", 3489), - ("ec.color.levelsic", "rgb/rgbOutWhite", 3490), - ("ec.color.levelsic", "red/redOutWhite", 3490), - ("ec.color.levelsic", "green/greenOutWhite", 3490), - ("ec.color.levelsic", "blue/blueOutWhite", 3490), - ("ec.color.levelsic", "alpha/alphaOutWhite", 3490), - ("ec.color.levelsic", "rgb/rgbGamma", 3491), - ("ec.color.levelsic", "red/redGamma", 3491), - ("ec.color.levelsic", "green/greenGamma", 3491), - ("ec.color.levelsic", "blue/blueGamma", 3491), - ("ec.color.levelsic", "alpha/alphaGamma", 3491), ("ec.distort.ccpowerpin", "expandTop", 3492), ("ec.distort.ccpowerpin", "expandLeft", 3492), ("ec.distort.ccpowerpin", "expandRight", 3492), diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 6639e649e..ad370ea1d 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -252,10 +252,17 @@ impl Lv { if clip_w { t = t.min(1.0); } - self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01)) + let curve = crate::color_fx::levels_gamma(t, self.g.max(0.01)).clamp(-LEVELS_IC_CURVE_LIMIT, LEVELS_IC_CURVE_LIMIT); + self.ob + (self.ow - self.ob) * curve } } +/// How far Levels (Individual Controls)' curve may reach, far beyond any image value: with gamma +/// down to 0 (rendered as 0.01, an exponent of 100) an HDR value a few times Input White would +/// overflow `f32`, and an infinite curve times an empty output range is NaN. Bounded, the output +/// stays finite over the whole valid range (levels ±10000). The GPU (`fxt_lv`) uses the same. +const LEVELS_IC_CURVE_LIMIT: f32 = 1e6; + fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf { let lv = LV_IDS.map(|ids| Lv { ib: ctx.params.f(ids[0]) as f32, @@ -978,11 +985,14 @@ fn cc_kernel(ctx: &EffectCtx, mut b: Buf) -> Buf { pub fn specs() -> Vec { let mut lv_params = Vec::new(); for (ids, names) in LV_IDS.iter().zip(LV_NAMES.iter()) { - lv_params.push(p(ids[0], names[0], num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); - lv_params.push(p(ids[1], names[1], num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); - lv_params.push(p(ids[2], names[2], num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2))); - lv_params.push(p(ids[3], names[3], num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); - lv_params.push(p(ids[4], names[4], num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3))); + // After Effects' valid ranges (#3487-#3491): levels -10000..10000 and gamma from 0 (the + // renderer floors it at 0.01); gamma keeps its wider maximum. The sliders stay 0..1. + let level = || slider(-10000.0, 10000.0, 0.0, 1.0, 3); + lv_params.push(p(ids[0], names[0], num(0.0), level())); + lv_params.push(p(ids[1], names[1], num(1.0), level())); + lv_params.push(p(ids[2], names[2], num(1.0), slider(0.0, 10.0, 0.1, 3.0, 2))); + lv_params.push(p(ids[3], names[3], num(0.0), level())); + lv_params.push(p(ids[4], names[4], num(1.0), level())); } lv_params.push(p("clipToOutputBlack", "Clip To Output Black", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP))); lv_params.push(p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP))); diff --git a/crates/effects/src/color2/tests.rs b/crates/effects/src/color2/tests.rs index 5f57692a0..069d4c9f7 100644 --- a/crates/effects/src/color2/tests.rs +++ b/crates/effects/src/color2/tests.rs @@ -270,6 +270,35 @@ fn levels_ic_clip_popups() { assert!(run("ec.color.levelsic", &img, &off).data[0][0] > 1.5); } +/// #3487-#3491: over After Effects' valid ranges (levels ±10000, gamma from 0) the output stays +/// finite: gamma 0 renders as 0.01, whose curve on an HDR value overflows `f32` unbounded. +#[test] +fn levels_ic_stays_finite_over_the_whole_range() { + let img = Image::filled(2, 2, [3.0, 0.5, -2.0, 1.0]); + for (ib, iw, ob, ow) in [ + (0.0, 1.0, 0.0, 1.0), + (0.0, 1.0, 1.0, 1.0), + (-10000.0, 10000.0, -10000.0, 10000.0), + (10000.0, -10000.0, 10000.0, -10000.0), + (0.0, 1e-3, -10000.0, 10000.0), + ] { + let lv = [ + ("rgb/rgbInBlack", num(ib)), + ("rgb/rgbInWhite", num(iw)), + ("rgb/rgbGamma", num(0.0)), + ("rgb/rgbOutBlack", num(ob)), + ("rgb/rgbOutWhite", num(ow)), + ("clipToOutputBlack", Value::Enum(0)), + ("clipToOutputWhite", Value::Enum(0)), + ]; + let out = run("ec.color.levelsic", &img, &lv); + assert!(out.data.iter().flatten().all(|v| v.is_finite()), "{ib} {iw} {ob} {ow}: {:?}", out.data[0]); + } + // Gamma 0 is the limit of a small gamma: below Input White the curve goes to 0. + let dark = run("ec.color.levelsic", &Image::filled(1, 1, [0.5, 0.5, 0.5, 1.0]), &[("rgb/rgbGamma", num(0.0))]); + assert!(dark.data[0][0] < 1e-6, "{:?}", dark.data[0]); +} + /// A host whose other layers and other frames are a fixed image. struct ImgHost(Image); impl crate::EffectHost for ImgHost { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ab4b5b8da..054ece456 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -12,6 +12,7 @@ mod crypto_pick; mod delete_keys; mod disk_cache; mod display_pass; +mod effect_ranges; mod effects; mod essential; mod essential_more; diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs new file mode 100644 index 000000000..3581f4411 --- /dev/null +++ b/crates/engine/src/tests/effect_ranges.rs @@ -0,0 +1,148 @@ +//! Effect parameters take the whole valid range After Effects records for them (fx_defs in the +//! aep crate), end to end: `prop.set` keeps both ends, undo and redo restore them, and a saved +//! project reopens with them. The aep crate's +//! `effect_hard_ranges_cover_recorded_after_effects_ranges` checks the ranges themselves +//! against that table. + +use effectcraft_project::{LayerId, Node, ParamUi, PropGroup}; +use serde_json::{Value, json}; + +use crate::Session; + +/// (issue, effect, parameter path, lowest, highest): the ends of After Effects' valid range, +/// in EffectCraft's units, each outside the range the effect accepted before the issue. +#[rustfmt::skip] +const ROWS: &[(u32, &str, &str, f64, f64)] = &[ + (3487, "ec.color.levelsic", "rgb/rgbInBlack", -10000.0, 10000.0), + (3488, "ec.color.levelsic", "rgb/rgbInWhite", -10000.0, 10000.0), + (3489, "ec.color.levelsic", "rgb/rgbOutBlack", -10000.0, 10000.0), + (3490, "ec.color.levelsic", "rgb/rgbOutWhite", -10000.0, 10000.0), + (3491, "ec.color.levelsic", "rgb/rgbGamma", 0.0, 10.0), + (3487, "ec.color.levelsic", "red/redInBlack", -10000.0, 10000.0), + (3488, "ec.color.levelsic", "red/redInWhite", -10000.0, 10000.0), + (3489, "ec.color.levelsic", "red/redOutBlack", -10000.0, 10000.0), + (3490, "ec.color.levelsic", "red/redOutWhite", -10000.0, 10000.0), + (3491, "ec.color.levelsic", "red/redGamma", 0.0, 10.0), + (3487, "ec.color.levelsic", "green/greenInBlack", -10000.0, 10000.0), + (3488, "ec.color.levelsic", "green/greenInWhite", -10000.0, 10000.0), + (3489, "ec.color.levelsic", "green/greenOutBlack", -10000.0, 10000.0), + (3490, "ec.color.levelsic", "green/greenOutWhite", -10000.0, 10000.0), + (3491, "ec.color.levelsic", "green/greenGamma", 0.0, 10.0), + (3487, "ec.color.levelsic", "blue/blueInBlack", -10000.0, 10000.0), + (3488, "ec.color.levelsic", "blue/blueInWhite", -10000.0, 10000.0), + (3489, "ec.color.levelsic", "blue/blueOutBlack", -10000.0, 10000.0), + (3490, "ec.color.levelsic", "blue/blueOutWhite", -10000.0, 10000.0), + (3491, "ec.color.levelsic", "blue/blueGamma", 0.0, 10.0), + (3487, "ec.color.levelsic", "alpha/alphaInBlack", -10000.0, 10000.0), + (3488, "ec.color.levelsic", "alpha/alphaInWhite", -10000.0, 10000.0), + (3489, "ec.color.levelsic", "alpha/alphaOutBlack", -10000.0, 10000.0), + (3490, "ec.color.levelsic", "alpha/alphaOutWhite", -10000.0, 10000.0), + (3491, "ec.color.levelsic", "alpha/alphaGamma", 0.0, 10.0), +]; + +fn path(effect: &str, param: &str) -> String { + format!("effects/{effect}/{param}") +} + +fn read(s: &mut Session, layer: u64, effect: &str, param: &str) -> f64 { + let got = s.execute("prop.get", json!({"layer": layer, "path": path(effect, param)})).unwrap(); + got["value"].as_f64().unwrap_or_else(|| panic!("{effect} {param}: {got}")) +} + +fn set(s: &mut Session, layer: u64, effect: &str, param: &str, v: f64) -> Value { + s.execute("prop.set", json!({"layer": layer, "path": path(effect, param), "value": v})).unwrap() +} + +/// A composition with two solids, each with one instance of every effect in [`ROWS`]. +fn session() -> (Session, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 2})).unwrap(); + let mut layers = [0; 2]; + for l in &mut layers { + *l = s.execute("layer.newSolid", json!({"color": "#808080", "width": 64, "height": 36})).unwrap()["layer"].as_u64().unwrap(); + let mut effects: Vec<&str> = ROWS.iter().map(|r| r.1).collect(); + effects.dedup(); + for e in effects { + s.execute("effect.apply", json!({"layers": [*l], "effect": e})).unwrap(); + } + } + (s, layers[0], layers[1]) +} + +/// Both ends of every row survive `prop.set`, undo / redo, and a save and reopen. +#[test] +fn effect_parameters_keep_both_ends_of_the_after_effects_range() { + let (mut s, low, high) = session(); + for &(issue, e, p, lo, hi) in ROWS { + assert_eq!(set(&mut s, high, e, p, lo), json!(lo), "#{issue} {e} {p}: prop.set reply"); + assert_eq!(read(&mut s, high, e, p), lo, "#{issue} {e} {p}"); + set(&mut s, high, e, p, hi); + assert_eq!(read(&mut s, high, e, p), hi, "#{issue} {e} {p}"); + assert!(s.undo()); + assert_eq!(read(&mut s, high, e, p), lo, "#{issue} {e} {p}: undo"); + assert!(s.redo()); + assert_eq!(read(&mut s, high, e, p), hi, "#{issue} {e} {p}: redo"); + set(&mut s, low, e, p, lo); + } + let dir = std::env::temp_dir().join(format!("ec-effect-ranges-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ranges.ecproj").to_string_lossy().to_string(); + s.execute("file.save", json!({"path": file})).unwrap(); + let mut s2 = Session::default(); + s2.execute("file.open", json!({"path": file})).unwrap(); + let _ = std::fs::remove_dir_all(dir); + for &(issue, e, p, lo, hi) in ROWS { + assert_eq!(read(&mut s2, low, e, p), lo, "#{issue} {e} {p}: reopened"); + assert_eq!(read(&mut s2, high, e, p), hi, "#{issue} {e} {p}: reopened"); + } +} + +/// A project saved while these ranges were narrower stored that range with each instance +/// (`ParamUi`) and values clamped to it: opening it takes the effect's current range and keeps +/// the stored values, so the new ends can be set. +#[test] +fn projects_saved_with_the_narrower_ranges_open_with_the_new_ones() { + let (mut s, low, _) = session(); + let cid = s.active_comp_id().unwrap(); + let mut old = (*s.project).clone(); + let stored: Vec = ROWS.iter().map(|&(_, e, p, ..)| read(&mut s, low, e, p)).collect(); + { + let effectcraft_project::ItemKind::Comp(comp) = &mut old.items.get_mut(&cid).unwrap().kind else { panic!("not a composition") }; + let l = std::sync::Arc::make_mut(comp).layers.find_mut(|l| l.id == LayerId(low)).unwrap(); + for (&(_, e, p, ..), v) in ROWS.iter().zip(&stored) { + let g = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|n| match n { + Node::Group(g) if g.match_id == e => Some(g), + _ => None, + }); + let pr = prop_mut(g.unwrap(), p).unwrap_or_else(|| panic!("{e} {p}")); + // The narrowest stored range that still holds the stored value. + pr.ui = ParamUi::Slider { min: *v, max: *v, slider_min: *v, slider_max: *v, decimals: 1 }; + } + } + let dir = std::env::temp_dir().join(format!("ec-effect-ranges-old-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("old.ecproj").to_string_lossy().to_string(); + std::fs::write(&file, old.to_json()).unwrap(); + let mut s2 = Session::default(); + s2.execute("file.open", json!({"path": file})).unwrap(); + let _ = std::fs::remove_dir_all(dir); + for (&(issue, e, p, lo, hi), v) in ROWS.iter().zip(&stored) { + assert_eq!(read(&mut s2, low, e, p), *v, "#{issue} {e} {p}: the stored value is kept"); + let spec = effectcraft_effects::find(e).unwrap().params.iter().find(|q| q.id == p).unwrap(); + let uid = s2.execute("prop.get", json!({"layer": low, "path": path(e, p)})).unwrap()["uid"].as_u64().unwrap(); + let layer = s2.project.comp(cid).unwrap().layer(LayerId(low)).unwrap(); + assert_eq!(layer.props.find(uid).unwrap().ui, spec.ui, "#{issue} {e} {p}: the range is refreshed"); + set(&mut s2, low, e, p, lo); + assert_eq!(read(&mut s2, low, e, p), lo, "#{issue} {e} {p}"); + set(&mut s2, low, e, p, hi); + assert_eq!(read(&mut s2, low, e, p), hi, "#{issue} {e} {p}"); + } +} + +/// The property at `path` (`group/param`) in an effect instance. +fn prop_mut<'a>(g: &'a mut PropGroup, path: &str) -> Option<&'a mut effectcraft_project::Property> { + match path.split_once('/') { + None => g.get_mut(path), + Some((first, rest)) => prop_mut(g.sub_mut(first)?, rest), + } +} diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index bb206adbd..fb7dfe42b 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -40,7 +40,9 @@ 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]) * fxt_gamma(t, max(data[o + 2u], 0.01)); + // color2::LEVELS_IC_CURVE_LIMIT keeps the curve finite. + let curve = clamp(fxt_gamma(t, max(data[o + 2u], 0.01)), -1e6, 1e6); + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * curve; } // color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white). diff --git a/crates/gpu/src/tests/fx_tone.rs b/crates/gpu/src/tests/fx_tone.rs index 40bc9483f..ca80f3836 100644 --- a/crates/gpu/src/tests/fx_tone.rs +++ b/crates/gpu/src/tests/fx_tone.rs @@ -29,6 +29,17 @@ fn levels_and_gamma() { ); effect_case("ec.color.levelsic", &[("green/greenInBlack", n(0.2)), ("green/greenInWhite", n(0.6)), ("clipToOutputWhite", e(0))]); effect_case("ec.color.levelsic", &[("alpha/alphaOutBlack", n(0.3))]); + // #3487-#3491: the ends of the valid ranges, the curve bounded where gamma 0 would overflow. + effect_case( + "ec.color.levelsic", + &[ + ("rgb/rgbInWhite", n(0.2)), + ("rgb/rgbGamma", n(0.0)), + ("red/redOutBlack", n(-10000.0)), + ("blue/blueOutWhite", n(10000.0)), + ("clipToOutputWhite", e(0)), + ], + ); effect_case("ec.color.levels", &[("redInBlack", n(0.1)), ("greenGamma", n(1.5)), ("blueOutWhite", n(0.7)), ("inWhite", n(0.9))]); effect_case("ec.color.levels", &[("alphaInWhite", n(0.6)), ("redInWhite", n(0.5)), ("redClipToOutputWhite", e(0))]); effect_case("ec.color.gammapedestalgain", &[("redGamma", n(1.4)), ("greenPedestal", n(0.1)), ("blueGain", n(0.8))]); From 363a800d3f65a113aae7d442b47583b39b60cf47 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:14:34 +0530 Subject: [PATCH 03/17] #3478, #3479: 4-Color Gradient: Blend and Jitter take After Effects' ranges Blend goes up to 10000 (it keeps its minimum of 1, below After Effects' 5) and Jitter up to 500 %, After Effects' recorded valid ranges in the same units. Defaults and sliders are unchanged. The CPU and GPU renderers capped Jitter's dither at 100 %, so higher values would be stored but render as 100. Both now take it from one function, four_color_jitter, which keeps the dither linear up to 500 %. Blend 10000 is a finite softening (Blend squared) at the same cost. Jitter was written as "percent" in keyframe text because its range was 0..100; a UNITS row keeps that unit. --- crates/aep/src/export/fx_defs.rs | 2 -- crates/effects/src/generate.rs | 28 +++++++++++++++++-- crates/effects/src/lib.rs | 2 +- .../src/interchange/keyframe_text_effects.rs | 1 + crates/engine/src/tests/effect_ranges.rs | 7 +++-- crates/engine/src/tests/keyframe_text.rs | 18 ++++++++++++ crates/gpu/src/fx_generate.rs | 2 +- crates/gpu/src/tests/fx_generate.rs | 2 ++ 8 files changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index 92ff7c20c..cc80eaaaa 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -743,8 +743,6 @@ mod tests { ("ec.generate.beam", "endThickness", 3475), ("ec.generate.grid", "border", 3476), ("ec.stylize.scatter", "amount", 3477), - ("ec.generate.fourcolor", "blend", 3478), - ("ec.generate.fourcolor", "jitter", 3479), ("ec.color.changecolor", "hueTransform", 3480), ("ec.time.pixelmotionblur", "shutterAngle", 3481), ("ec.time.pixelmotionblur", "shutterSamples", 3482), diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index ae0d61861..47a6d6314 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -121,11 +121,17 @@ pub fn four_color_softening(blend: f64, scale: f64) -> f64 { (b * b).max(1e-6) } +/// 4-Color Gradient's Jitter (percent, up to After Effects' 500 %) as the dither's strength: 1 at +/// 100 %, a few code values of noise per unit. +pub fn four_color_jitter(jitter: f64) -> f32 { + (jitter / 100.0).clamp(0.0, 5.0) as f32 +} + 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 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 jitter = four_color_jitter(ctx.params.f("jitter")); let op = ctx.params.f("opacity") as f32 / 100.0; let mode = ctx.params.e("blendingMode"); let seed = ctx.seed; @@ -349,8 +355,9 @@ pub fn specs() -> Vec { p("positionsColors/color3", "Color 3", col(1.0, 0.0, 1.0), ParamUi::Color), p("positionsColors/point4", "Point 4", pt(0.9, 0.9), ParamUi::Point), p("positionsColors/color4", "Color 4", col(0.0, 0.0, 1.0), ParamUi::Color), - p("blend", "Blend", num(100.0), slider(1.0, 1000.0, 1.0, 1000.0, 1)), - p("jitter", "Jitter", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + // After Effects' valid ranges (#3478, #3479); Blend keeps its lower minimum (1, not 5). + p("blend", "Blend", num(100.0), slider(1.0, 10000.0, 1.0, 1000.0, 1)), + p("jitter", "Jitter", num(0.0), slider(0.0, 500.0, 0.0, 100.0, 1)), p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), p("blendingMode", "Blending Mode", Value::Enum(0), popup(&GEN_BLEND_MODES[..18])), ], @@ -465,6 +472,21 @@ mod generator_tests { } } + /// #3479: Jitter goes up to After Effects' 500 %, and more jitter dithers more (the dither + /// stopped growing at 100 %). + #[test] + fn four_color_jitter_grows_up_to_five_hundred_percent() { + let img = Image::filled(32, 32, [0.0, 0.0, 0.0, 1.0]); + let spread = |jitter: f64| { + let o = run_fx("ec.generate.fourcolor", &[("jitter", num(jitter))], img.clone(), 0.0, EffectEnv::default()).img; + let flat = run_fx("ec.generate.fourcolor", &[], img.clone(), 0.0, EffectEnv::default()).img; + o.data.iter().zip(&flat.data).map(|(a, b)| (a[0] - b[0]).abs()).fold(0.0f32, f32::max) + }; + let (at100, at500) = (spread(100.0), spread(500.0)); + assert!(at100 > 0.0 && at500 > 4.0 * at100, "{at100} {at500}"); + assert!(at500 < 0.2, "a few code values: {at500}"); + } + /// 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). diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 9105f4587..e030c6748 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -99,7 +99,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_MAX_RADIUS, camera_lens_plain, camera_lens_radius, camera_lens_spans, radial_fast_pad}; -pub use generate::{four_color_softening, gen_mode}; +pub use generate::{four_color_jitter, 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/engine/src/interchange/keyframe_text_effects.rs b/crates/engine/src/interchange/keyframe_text_effects.rs index 83f5b6c20..94d81afcc 100644 --- a/crates/engine/src/interchange/keyframe_text_effects.rs +++ b/crates/engine/src/interchange/keyframe_text_effects.rs @@ -273,6 +273,7 @@ pub(crate) const UNITS: &[(&str, &str, &str)] = &[ ("ec.distort.wavewarp", "width", "percent"), ("ec.distort.wavewarp", "speed", "pixels"), ("ec.generate.fourcolor", "blend", "percent"), + ("ec.generate.fourcolor", "jitter", "percent"), ("ec.generate.advancedlightning", "coreSettings/coreRadius", ""), ("ec.generate.advancedlightning", "glowSettings/glowRadius", "percent"), ("ec.generate.advancedlightning", "alphaObstacle", "pixels"), diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs index 3581f4411..cb4d4ea24 100644 --- a/crates/engine/src/tests/effect_ranges.rs +++ b/crates/engine/src/tests/effect_ranges.rs @@ -9,8 +9,9 @@ use serde_json::{Value, json}; use crate::Session; -/// (issue, effect, parameter path, lowest, highest): the ends of After Effects' valid range, -/// in EffectCraft's units, each outside the range the effect accepted before the issue. +/// (issue, effect, parameter path, lowest, highest): the ends of the range the effect now accepts, +/// in EffectCraft's units: After Effects' valid range, or wider where EffectCraft already was. +/// At least one end of each is outside the range accepted before the issue. #[rustfmt::skip] const ROWS: &[(u32, &str, &str, f64, f64)] = &[ (3487, "ec.color.levelsic", "rgb/rgbInBlack", -10000.0, 10000.0), @@ -38,6 +39,8 @@ const ROWS: &[(u32, &str, &str, f64, f64)] = &[ (3489, "ec.color.levelsic", "alpha/alphaOutBlack", -10000.0, 10000.0), (3490, "ec.color.levelsic", "alpha/alphaOutWhite", -10000.0, 10000.0), (3491, "ec.color.levelsic", "alpha/alphaGamma", 0.0, 10.0), + (3478, "ec.generate.fourcolor", "blend", 1.0, 10000.0), + (3479, "ec.generate.fourcolor", "jitter", 0.0, 500.0), ]; fn path(effect: &str, param: &str) -> String { diff --git a/crates/engine/src/tests/keyframe_text.rs b/crates/engine/src/tests/keyframe_text.rs index 30d3b436e..81d9487d9 100644 --- a/crates/engine/src/tests/keyframe_text.rs +++ b/crates/engine/src/tests/keyframe_text.rs @@ -480,3 +480,21 @@ fn camera_light_and_material_options_are_written_as_after_effects_does() { assert!(text.contains(block), "{block}in\n{text}"); } } + +/// The unit column of `name`'s block in copied keyframe text. +fn unit_of<'a>(text: &'a str, name: &str) -> &'a str { + let mut lines = text.lines().skip_while(|l| !l.contains(&format!("\t{name} #"))); + lines.next().unwrap_or_else(|| panic!("no {name} in\n{text}")); + lines.next().and_then(|l| l.strip_prefix("\tFrame\t")).and_then(|l| l.strip_suffix('\t')).unwrap_or_else(|| panic!("{name} in\n{text}")) +} + +/// Widening a 0..100 slider to After Effects' range (#3479, #3483, #3484) keeps the unit the +/// keyframe text writes for it. +#[test] +fn widened_effect_ranges_keep_their_keyframe_text_units() { + let (mut s, l) = session(); + s.execute("effect.apply", json!({"layer": l.0, "effect": "ec.generate.fourcolor"})).unwrap(); + let text = copy_props(&mut s, l, &["effects/#1/blend", "effects/#1/jitter"]); + assert_eq!(unit_of(&text, "Blend"), "percent"); + assert_eq!(unit_of(&text, "Jitter"), "percent"); +} diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 893208a15..2dda38f45 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -385,7 +385,7 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1)); } 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; + let jitter = effectcraft_effects::four_color_jitter(ctx.params.f("jitter")); p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 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/tests/fx_generate.rs b/crates/gpu/src/tests/fx_generate.rs index d12920797..d7e05e047 100644 --- a/crates/gpu/src/tests/fx_generate.rs +++ b/crates/gpu/src/tests/fx_generate.rs @@ -167,6 +167,8 @@ fn generators() { effect_case("ec.generate.fourcolor", &[]); effect_case("ec.generate.fourcolor", &[("blend", n(40.0)), ("jitter", n(50.0)), ("opacity", n(80.0)), ("blendingMode", e(3))]); effect_case("ec.generate.fourcolor", &[("blend", n(600.0)), ("blendingMode", e(16)), ("positionsColors/point2", pt(30.0, 20.0))]); + // #3478, #3479: the top of After Effects' ranges. + effect_case("ec.generate.fourcolor", &[("blend", n(10000.0)), ("jitter", n(500.0))]); } #[test] From 244f9bd846d1b94f47a392e4f46618b6db3f85c1 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:46:48 +0530 Subject: [PATCH 04/17] #3483, #3484: Noise HLS: amounts and Grain Size take After Effects' ranges Hue, Lightness and Saturation go up to 1000 % and Grain Size down to 0 (it keeps its wider maximum, 100), After Effects' recorded valid ranges in the same units. Noise HLS Auto shares the controls and widens with them. Defaults and sliders are unchanged. The renderers need no change: hue wraps, lightness and saturation clamp per pixel after the noise is added, and Grain Size is floored at 0.1 px on the CPU and the GPU, so the new values cost the same and stay finite. The amounts were written as "percent" in keyframe text because their range was 0..100, and Grain Size would become "percent" at 0..100; UNITS rows keep the units they had. --- crates/aep/src/export/fx_defs.rs | 4 --- crates/effects/src/noise.rs | 32 ++++++++++++++++--- .../src/interchange/keyframe_text_effects.rs | 9 ++++++ crates/engine/src/tests/effect_ranges.rs | 8 +++++ crates/engine/src/tests/keyframe_text.rs | 8 +++++ crates/gpu/src/tests/fx_noise.rs | 2 ++ 6 files changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index cc80eaaaa..90db58758 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -746,10 +746,6 @@ mod tests { ("ec.color.changecolor", "hueTransform", 3480), ("ec.time.pixelmotionblur", "shutterAngle", 3481), ("ec.time.pixelmotionblur", "shutterSamples", 3482), - ("ec.noise.noisehls", "hue", 3483), - ("ec.noise.noisehls", "lightness", 3483), - ("ec.noise.noisehls", "saturation", 3483), - ("ec.noise.noisehls", "grainSize", 3484), ("ec.noise.fractal", "subSettings/subInfluence", 3485), ("ec.noise.fractal", "subSettings/subScaling", 3486), ("ec.distort.ccpowerpin", "expandTop", 3492), diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 09217f831..246910cd5 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -766,10 +766,12 @@ pub fn specs() -> Vec { let hls_common = || { vec![ p("noise", "Noise", Value::Enum(0), popup(&["Uniform", "Squared", "Grain"])), - p("hue", "Hue", num(0.0), pct()), - p("lightness", "Lightness", num(0.0), pct()), - p("saturation", "Saturation", num(0.0), pct()), - p("grainSize", "Grain Size", num(1.0), slider(0.1, 100.0, 0.5, 10.0, 2)), + // After Effects' valid ranges (#3483, #3484): the amounts go to 1000 %, Grain Size + // down to 0 (the renderer floors it at 0.1 px) and keeps its wider maximum. + p("hue", "Hue", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("lightness", "Lightness", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("saturation", "Saturation", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("grainSize", "Grain Size", num(1.0), slider(0.0, 100.0, 0.5, 10.0, 2)), ] }; let mut hls = hls_common(); @@ -967,6 +969,28 @@ mod tests { assert_eq!(still.img, img); } + /// #3483, #3484: amounts above 100 % keep adding noise, and Grain Size 0 renders (floored at + /// 0.1 px) with finite output, for both effects. + #[test] + fn noise_hls_takes_after_effects_ranges() { + let mut img = Image::new(16, 16); + for (i, p) in img.data.iter_mut().enumerate() { + *p = premul([0.6, 0.3, (i % 7) as f32 / 7.0], 1.0); + } + for id in ["ec.noise.noisehls", "ec.noise.noisehlsauto"] { + let at = |amount: f64, grain: f64| { + let vals = + [("noise", Value::Enum(2)), ("hue", num(amount)), ("lightness", num(amount)), ("saturation", num(amount)), ("grainSize", num(grain))]; + run(id, &vals, img.clone(), 0.0).img + }; + let (low, high) = (at(100.0, 1.0), at(1000.0, 1.0)); + assert_ne!(low, high, "{id}"); + for out in [high, at(1000.0, 0.0)] { + assert!(out.data.iter().flatten().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "{id}"); + } + } + } + #[test] fn add_grain_keeps_flat_mean() { let out = run("ec.noise.addgrain", &[("tweaking/intensity", num(2.0)), ("application/blendingMode", Value::Enum(2))], gray(64, 64, 0.5), 0.0); diff --git a/crates/engine/src/interchange/keyframe_text_effects.rs b/crates/engine/src/interchange/keyframe_text_effects.rs index 94d81afcc..7b0658f61 100644 --- a/crates/engine/src/interchange/keyframe_text_effects.rs +++ b/crates/engine/src/interchange/keyframe_text_effects.rs @@ -318,6 +318,15 @@ pub(crate) const UNITS: &[(&str, &str, &str)] = &[ ("ec.matte.rotobrush", "refineEdgeMatte/shiftEdge", "percent"), ("ec.matte.rotobrush", "refineEdgeMatte/decontamination/increaseDecontaminationRadius", ""), ("ec.matte.simplechoker", "chokeMatte", "pixels"), + // Their ranges were 0..100 (#3483, #3484). + ("ec.noise.noisehls", "hue", "percent"), + ("ec.noise.noisehls", "lightness", "percent"), + ("ec.noise.noisehls", "saturation", "percent"), + ("ec.noise.noisehls", "grainSize", ""), + ("ec.noise.noisehlsauto", "hue", "percent"), + ("ec.noise.noisehlsauto", "lightness", "percent"), + ("ec.noise.noisehlsauto", "saturation", "percent"), + ("ec.noise.noisehlsauto", "grainSize", ""), ("ec.noise.fractal", "transform/scale", "percent"), ("ec.noise.fractal", "transform/scaleWidth", "percent"), ("ec.noise.fractal", "transform/scaleHeight", "percent"), diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs index cb4d4ea24..9c0c336ec 100644 --- a/crates/engine/src/tests/effect_ranges.rs +++ b/crates/engine/src/tests/effect_ranges.rs @@ -41,6 +41,14 @@ const ROWS: &[(u32, &str, &str, f64, f64)] = &[ (3491, "ec.color.levelsic", "alpha/alphaGamma", 0.0, 10.0), (3478, "ec.generate.fourcolor", "blend", 1.0, 10000.0), (3479, "ec.generate.fourcolor", "jitter", 0.0, 500.0), + (3483, "ec.noise.noisehls", "hue", 0.0, 1000.0), + (3483, "ec.noise.noisehls", "lightness", 0.0, 1000.0), + (3483, "ec.noise.noisehls", "saturation", 0.0, 1000.0), + (3484, "ec.noise.noisehls", "grainSize", 0.0, 100.0), + (3483, "ec.noise.noisehlsauto", "hue", 0.0, 1000.0), + (3483, "ec.noise.noisehlsauto", "lightness", 0.0, 1000.0), + (3483, "ec.noise.noisehlsauto", "saturation", 0.0, 1000.0), + (3484, "ec.noise.noisehlsauto", "grainSize", 0.0, 100.0), ]; fn path(effect: &str, param: &str) -> String { diff --git a/crates/engine/src/tests/keyframe_text.rs b/crates/engine/src/tests/keyframe_text.rs index 81d9487d9..a299899eb 100644 --- a/crates/engine/src/tests/keyframe_text.rs +++ b/crates/engine/src/tests/keyframe_text.rs @@ -497,4 +497,12 @@ fn widened_effect_ranges_keep_their_keyframe_text_units() { let text = copy_props(&mut s, l, &["effects/#1/blend", "effects/#1/jitter"]); assert_eq!(unit_of(&text, "Blend"), "percent"); assert_eq!(unit_of(&text, "Jitter"), "percent"); + for fx in ["ec.noise.noisehls", "ec.noise.noisehlsauto"] { + let (mut s, l) = session(); + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + let text = copy_props(&mut s, l, &["effects/#1/hue", "effects/#1/lightness", "effects/#1/saturation", "effects/#1/grainSize"]); + for (name, unit) in [("Hue", "percent"), ("Lightness", "percent"), ("Saturation", "percent"), ("Grain Size", "")] { + assert_eq!(unit_of(&text, name), unit, "{fx} {name}"); + } + } } diff --git a/crates/gpu/src/tests/fx_noise.rs b/crates/gpu/src/tests/fx_noise.rs index 7d13004ee..006128c73 100644 --- a/crates/gpu/src/tests/fx_noise.rs +++ b/crates/gpu/src/tests/fx_noise.rs @@ -164,6 +164,8 @@ fn noise_alpha_and_hls() { effect_case("ec.noise.noisehls", &[("hue", n(30.0)), ("lightness", n(20.0)), ("saturation", n(40.0))]); effect_case("ec.noise.noisehls", &[("noise", e(1)), ("lightness", n(50.0)), ("noisePhase", n(200.0))]); effect_case("ec.noise.noisehls", &[("noise", e(2)), ("hue", n(60.0)), ("saturation", n(25.0)), ("grainSize", n(3.0))]); + // #3483, #3484: the ends of After Effects' ranges. + effect_case("ec.noise.noisehls", &[("noise", e(2)), ("hue", n(1000.0)), ("lightness", n(1000.0)), ("saturation", n(1000.0)), ("grainSize", n(0.0))]); effect_case("ec.noise.noisehlsauto", &[("noise", e(0)), ("hue", n(20.0)), ("lightness", n(30.0)), ("noiseAnimationSpeed", n(7.0))]); } From ce4db788960c51317ea5b26e0a8ca06d66c0f5f3 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:47:14 +0530 Subject: [PATCH 05/17] #3480: Change Color: Hue Transform takes After Effects' range Hue Transform takes -1800..1800 degrees, After Effects' recorded valid range in the same units. The CPU and GPU renderers wrap the hue (rem_euclid / fract_euclid), so five turns render like the hue they end on, at the same cost. Default and slider are unchanged. --- crates/aep/src/export/fx_defs.rs | 1 - crates/effects/src/color3.rs | 2 +- crates/engine/src/tests/effect_ranges.rs | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index 90db58758..865d1fef2 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -743,7 +743,6 @@ mod tests { ("ec.generate.beam", "endThickness", 3475), ("ec.generate.grid", "border", 3476), ("ec.stylize.scatter", "amount", 3477), - ("ec.color.changecolor", "hueTransform", 3480), ("ec.time.pixelmotionblur", "shutterAngle", 3481), ("ec.time.pixelmotionblur", "shutterSamples", 3482), ("ec.noise.fractal", "subSettings/subInfluence", 3485), diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index d0d398100..6548fdbdb 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -809,7 +809,7 @@ pub fn specs() -> Vec { "Change Color", vec![ p("view", "View", Value::Enum(0), popup(&["Corrected Layer", "Color Correction Mask"])), - p("hueTransform", "Hue Transform", num(0.0), slider(-360.0, 360.0, -180.0, 180.0, 1)), + p("hueTransform", "Hue Transform", num(0.0), slider(-1800.0, 1800.0, -180.0, 180.0, 1)), p("lightnessTransform", "Lightness Transform", num(0.0), bipolar()), p("saturationTransform", "Saturation Transform", num(0.0), bipolar()), p("colorToChange", "Color To Change", col(0.8, 0.2, 0.2), ParamUi::Color), diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs index 9c0c336ec..08a57fa3b 100644 --- a/crates/engine/src/tests/effect_ranges.rs +++ b/crates/engine/src/tests/effect_ranges.rs @@ -49,6 +49,7 @@ const ROWS: &[(u32, &str, &str, f64, f64)] = &[ (3483, "ec.noise.noisehlsauto", "lightness", 0.0, 1000.0), (3483, "ec.noise.noisehlsauto", "saturation", 0.0, 1000.0), (3484, "ec.noise.noisehlsauto", "grainSize", 0.0, 100.0), + (3480, "ec.color.changecolor", "hueTransform", -1800.0, 1800.0), ]; fn path(effect: &str, param: &str) -> String { From 85f47ec072ce70bc5958d42929f313587d394e2c Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:47:14 +0530 Subject: [PATCH 06/17] #3476: Grid: Border takes After Effects' range Border takes 0..2000, After Effects' recorded valid range in the same units (pixels). The CPU and GPU renderers compute the border per pixel in closed form, so a wider border costs the same; one wider than the cells covers them. Default and slider are unchanged. --- crates/aep/src/export/fx_defs.rs | 1 - crates/effects/src/generate.rs | 2 +- crates/engine/src/tests/effect_ranges.rs | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index 865d1fef2..d65448651 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -741,7 +741,6 @@ mod tests { ("ec.transition.radialwipe", "feather", 3474), ("ec.generate.beam", "startThickness", 3475), ("ec.generate.beam", "endThickness", 3475), - ("ec.generate.grid", "border", 3476), ("ec.stylize.scatter", "amount", 3477), ("ec.time.pixelmotionblur", "shutterAngle", 3481), ("ec.time.pixelmotionblur", "shutterSamples", 3482), diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index 47a6d6314..d8d47a7c7 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -391,7 +391,7 @@ pub fn specs() -> Vec { p("corner", "Corner", pt(0.1, 0.1), ParamUi::Point), p("width", "Width", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)), p("height", "Height", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)), - p("border", "Border", num(2.0), slider(0.0, 400.0, 0.0, 40.0, 1)), + p("border", "Border", num(2.0), slider(0.0, 2000.0, 0.0, 40.0, 1)), p("feather/featherWidth", "Width", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)), p("feather/featherHeight", "Height", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)), p("invertGrid", "Invert Grid", Value::Bool(false), ParamUi::Checkbox), diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs index 08a57fa3b..76d7610d3 100644 --- a/crates/engine/src/tests/effect_ranges.rs +++ b/crates/engine/src/tests/effect_ranges.rs @@ -50,6 +50,7 @@ const ROWS: &[(u32, &str, &str, f64, f64)] = &[ (3483, "ec.noise.noisehlsauto", "saturation", 0.0, 1000.0), (3484, "ec.noise.noisehlsauto", "grainSize", 0.0, 100.0), (3480, "ec.color.changecolor", "hueTransform", -1800.0, 1800.0), + (3476, "ec.generate.grid", "border", 0.0, 2000.0), ]; fn path(effect: &str, param: &str) -> String { From 29c76bb6b4104a9275d22ec9bd14d5e081317f5d Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:47:14 +0530 Subject: [PATCH 07/17] #3472, #3473: Venetian Blinds: Width and Feather take After Effects' ranges Width takes 1..32000 and Feather 0..32000, After Effects' recorded valid ranges in the same units (pixels). The work stays bounded: the renderers floor the width at 1 buffer pixel, sum at most 40 Gaussian terms per pixel, and above 8 widths the feather is the even veil (venetian_cover; the GPU hands that case to the CPU, as before). Default and slider are unchanged. --- crates/aep/src/export/fx_defs.rs | 2 -- crates/effects/src/misc.rs | 4 ++-- crates/engine/src/tests/effect_ranges.rs | 2 ++ 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index d65448651..7f1bd8c21 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -736,8 +736,6 @@ mod tests { ("ec.distort.turbulentdisplace", "evolutionOptions/randomSeed", 3470), ("ec.distort.transform", "scaleHeight", 3471), ("ec.distort.transform", "scaleWidth", 3471), - ("ec.transition.venetian", "width", 3472), - ("ec.transition.venetian", "feather", 3473), ("ec.transition.radialwipe", "feather", 3474), ("ec.generate.beam", "startThickness", 3475), ("ec.generate.beam", "endThickness", 3475), diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 65951ad97..a1037c693 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -930,8 +930,8 @@ pub fn specs() -> Vec { vec![ p("completion", "Transition Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)), p("direction", "Direction", num(90.0), ParamUi::Angle), - p("width", "Width", num(30.0), slider(2.0, 1000.0, 2.0, 200.0, 0)), - p("feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("width", "Width", num(30.0), slider(1.0, 32000.0, 2.0, 200.0, 0)), + p("feather", "Feather", num(0.0), slider(0.0, 32000.0, 0.0, 100.0, 1)), ], venetian, ), diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs index 76d7610d3..73cb0e5db 100644 --- a/crates/engine/src/tests/effect_ranges.rs +++ b/crates/engine/src/tests/effect_ranges.rs @@ -51,6 +51,8 @@ const ROWS: &[(u32, &str, &str, f64, f64)] = &[ (3484, "ec.noise.noisehlsauto", "grainSize", 0.0, 100.0), (3480, "ec.color.changecolor", "hueTransform", -1800.0, 1800.0), (3476, "ec.generate.grid", "border", 0.0, 2000.0), + (3472, "ec.transition.venetian", "width", 1.0, 32000.0), + (3473, "ec.transition.venetian", "feather", 0.0, 32000.0), ]; fn path(effect: &str, param: &str) -> String { From a08240155ccec5977a384af0c6dd735d97783860 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:47:14 +0530 Subject: [PATCH 08/17] #3474: Radial Wipe: Feather takes After Effects' range Feather takes 0..32000, After Effects' recorded valid range in the same units. The CPU and GPU renderers evaluate the feather per pixel in closed form (a Gaussian of the distance to the wedge, radial_wipe_alpha), so a wide feather costs the same and stays finite. Default and slider are unchanged. --- crates/aep/src/export/fx_defs.rs | 1 - crates/effects/src/misc.rs | 2 +- crates/engine/src/tests/effect_ranges.rs | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index 7f1bd8c21..a741397ff 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -736,7 +736,6 @@ mod tests { ("ec.distort.turbulentdisplace", "evolutionOptions/randomSeed", 3470), ("ec.distort.transform", "scaleHeight", 3471), ("ec.distort.transform", "scaleWidth", 3471), - ("ec.transition.radialwipe", "feather", 3474), ("ec.generate.beam", "startThickness", 3475), ("ec.generate.beam", "endThickness", 3475), ("ec.stylize.scatter", "amount", 3477), diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index a1037c693..9c7131657 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -919,7 +919,7 @@ pub fn specs() -> Vec { p("startAngle", "Start Angle", num(0.0), ParamUi::Angle), p("center", "Wipe Center", Value::Vec2([0.5, 0.5]), ParamUi::Point), p("wipe", "Wipe", Value::Enum(0), popup(&["Clockwise", "Counterclockwise", "Both"])), - p("feather", "Feather", num(0.0), slider(0.0, 360.0, 0.0, 50.0, 1)), + p("feather", "Feather", num(0.0), slider(0.0, 32000.0, 0.0, 50.0, 1)), ], radial_wipe, ), diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs index 73cb0e5db..3ba91dc5f 100644 --- a/crates/engine/src/tests/effect_ranges.rs +++ b/crates/engine/src/tests/effect_ranges.rs @@ -53,6 +53,7 @@ const ROWS: &[(u32, &str, &str, f64, f64)] = &[ (3476, "ec.generate.grid", "border", 0.0, 2000.0), (3472, "ec.transition.venetian", "width", 1.0, 32000.0), (3473, "ec.transition.venetian", "feather", 0.0, 32000.0), + (3474, "ec.transition.radialwipe", "feather", 0.0, 32000.0), ]; fn path(effect: &str, param: &str) -> String { From c4cd9f1c2eff1e19d0848c6114da2f97450de93a Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:47:15 +0530 Subject: [PATCH 09/17] #3477: Scatter: Scatter Amount takes After Effects' range Scatter Amount takes 0..1000, After Effects' recorded valid range in the same units (pixels); the slider stays 0..127. The CPU and GPU renderers move each pixel by one random offset of up to the amount, so a larger amount costs the same; samples outside the layer are transparent. Default and slider are unchanged. --- crates/aep/src/export/fx_defs.rs | 1 - crates/effects/src/stylize2.rs | 2 +- crates/engine/src/tests/effect_ranges.rs | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index a741397ff..3c7464009 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -738,7 +738,6 @@ mod tests { ("ec.distort.transform", "scaleWidth", 3471), ("ec.generate.beam", "startThickness", 3475), ("ec.generate.beam", "endThickness", 3475), - ("ec.stylize.scatter", "amount", 3477), ("ec.time.pixelmotionblur", "shutterAngle", 3481), ("ec.time.pixelmotionblur", "shutterSamples", 3482), ("ec.noise.fractal", "subSettings/subInfluence", 3485), diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index 247854352..a2de2c35e 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -612,7 +612,7 @@ pub fn specs() -> Vec { "ec.stylize.scatter", "Scatter", vec![ - p("amount", "Scatter Amount", num(0.0), slider(0.0, 127.0, 0.0, 127.0, 1)), + p("amount", "Scatter Amount", num(0.0), slider(0.0, 1000.0, 0.0, 127.0, 1)), p("grain", "Grain", Value::Enum(0), popup(&["Both", "Horizontal", "Vertical"])), p("randomizeEveryFrame", "Scatter Randomness", Value::Bool(false), ParamUi::Checkbox), ], diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs index 3ba91dc5f..c783478fb 100644 --- a/crates/engine/src/tests/effect_ranges.rs +++ b/crates/engine/src/tests/effect_ranges.rs @@ -54,6 +54,7 @@ const ROWS: &[(u32, &str, &str, f64, f64)] = &[ (3472, "ec.transition.venetian", "width", 1.0, 32000.0), (3473, "ec.transition.venetian", "feather", 0.0, 32000.0), (3474, "ec.transition.radialwipe", "feather", 0.0, 32000.0), + (3477, "ec.stylize.scatter", "amount", 0.0, 1000.0), ]; fn path(effect: &str, param: &str) -> String { From 3bd7de6ad8b7ff4903f03d3810ad32a0d2ba9a72 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:47:15 +0530 Subject: [PATCH 10/17] #3475: Beam: Starting and Ending Thickness take After Effects' range Starting and Ending Thickness take 0..4000, After Effects' recorded valid range in the same units (pixels). The CPU and GPU renderers draw the beam per pixel from its distance to the segment, within the layer's buffer, so a thicker beam costs the same. Default and slider are unchanged. --- crates/aep/src/export/fx_defs.rs | 2 -- crates/effects/src/generate2.rs | 4 ++-- crates/engine/src/tests/effect_ranges.rs | 2 ++ 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index 3c7464009..e35f7c6fc 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -736,8 +736,6 @@ mod tests { ("ec.distort.turbulentdisplace", "evolutionOptions/randomSeed", 3470), ("ec.distort.transform", "scaleHeight", 3471), ("ec.distort.transform", "scaleWidth", 3471), - ("ec.generate.beam", "startThickness", 3475), - ("ec.generate.beam", "endThickness", 3475), ("ec.time.pixelmotionblur", "shutterAngle", 3481), ("ec.time.pixelmotionblur", "shutterSamples", 3482), ("ec.noise.fractal", "subSettings/subInfluence", 3485), diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index ad80cb9cd..6bf5443fc 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -1111,8 +1111,8 @@ pub fn specs() -> Vec { p("endPoint", "Ending Point", pt(0.75, 0.5), ParamUi::Point), p("length", "Length", num(25.0), pct()), p("time", "Time", num(0.0), pct()), - p("startThickness", "Starting Thickness", num(8.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), - p("endThickness", "Ending Thickness", num(8.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("startThickness", "Starting Thickness", num(8.0), slider(0.0, 4000.0, 0.0, 100.0, 1)), + p("endThickness", "Ending Thickness", num(8.0), slider(0.0, 4000.0, 0.0, 100.0, 1)), p("softness", "Softness", num(20.0), pct()), p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), p("outsideColor", "Outside Color", col(0.4, 0.4, 1.0), ParamUi::Color), diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs index c783478fb..320e1378f 100644 --- a/crates/engine/src/tests/effect_ranges.rs +++ b/crates/engine/src/tests/effect_ranges.rs @@ -55,6 +55,8 @@ const ROWS: &[(u32, &str, &str, f64, f64)] = &[ (3473, "ec.transition.venetian", "feather", 0.0, 32000.0), (3474, "ec.transition.radialwipe", "feather", 0.0, 32000.0), (3477, "ec.stylize.scatter", "amount", 0.0, 1000.0), + (3475, "ec.generate.beam", "startThickness", 0.0, 4000.0), + (3475, "ec.generate.beam", "endThickness", 0.0, 4000.0), ]; fn path(effect: &str, param: &str) -> String { From 74bcb30540c770670c982de1cbab554f3b408d0a Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:47:15 +0530 Subject: [PATCH 11/17] #3471: Transform: Scale Height and Scale Width take After Effects' range Scale Height and Scale Width take -30000..30000 %, After Effects' recorded valid range in the same units. The CPU and GPU share the matrix (transform_matrix_px) and the buffer growth, which is capped at 16 Mpx (transform_grow; the CPU test already renders 100000 %), so larger scales stay bounded. Default and slider are unchanged. --- crates/aep/src/export/fx_defs.rs | 2 -- crates/effects/src/distort.rs | 4 ++-- crates/engine/src/tests/effect_ranges.rs | 2 ++ 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index e35f7c6fc..82d21666a 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -734,8 +734,6 @@ mod tests { ("ec.blur.ccradial", "amount", 3468), ("ec.distort.turbulentdisplace", "amount", 3469), ("ec.distort.turbulentdisplace", "evolutionOptions/randomSeed", 3470), - ("ec.distort.transform", "scaleHeight", 3471), - ("ec.distort.transform", "scaleWidth", 3471), ("ec.time.pixelmotionblur", "shutterAngle", 3481), ("ec.time.pixelmotionblur", "shutterSamples", 3482), ("ec.noise.fractal", "subSettings/subInfluence", 3485), diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 610f73f4c..59d4aad01 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -506,8 +506,8 @@ pub fn specs() -> Vec { p("anchor", "Anchor Point", pt(0.5, 0.5), ParamUi::Point), p("position", "Position", pt(0.5, 0.5), ParamUi::Point), p("uniform", "Uniform Scale", Value::Bool(true), ParamUi::Checkbox), - p("scaleHeight", "Scale Height", num(100.0), slider(-10000.0, 10000.0, 0.0, 600.0, 1)), - p("scaleWidth", "Scale Width", num(100.0), slider(-10000.0, 10000.0, 0.0, 600.0, 1)), + p("scaleHeight", "Scale Height", num(100.0), slider(-30000.0, 30000.0, 0.0, 600.0, 1)), + p("scaleWidth", "Scale Width", num(100.0), slider(-30000.0, 30000.0, 0.0, 600.0, 1)), p("skew", "Skew", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)), p("skewAxis", "Skew Axis", num(0.0), ParamUi::Angle), p("rotation", "Rotation", num(0.0), ParamUi::Angle), diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs index 320e1378f..9b3eb1414 100644 --- a/crates/engine/src/tests/effect_ranges.rs +++ b/crates/engine/src/tests/effect_ranges.rs @@ -57,6 +57,8 @@ const ROWS: &[(u32, &str, &str, f64, f64)] = &[ (3477, "ec.stylize.scatter", "amount", 0.0, 1000.0), (3475, "ec.generate.beam", "startThickness", 0.0, 4000.0), (3475, "ec.generate.beam", "endThickness", 0.0, 4000.0), + (3471, "ec.distort.transform", "scaleHeight", -30000.0, 30000.0), + (3471, "ec.distort.transform", "scaleWidth", -30000.0, 30000.0), ]; fn path(effect: &str, param: &str) -> String { From 98d82ae761bd465f7797fcbc20b0a0509f9dcd8c Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 15:47:15 +0530 Subject: [PATCH 12/17] #3470: Turbulent Displace: Random Seed takes After Effects' range Random Seed takes 0..100000, After Effects' recorded valid range (and what the other effects' Random Seed already takes). The CPU and GPU hash it the same way with wrapping u32 arithmetic, so any seed costs the same. Default and slider are unchanged. --- crates/aep/src/export/fx_defs.rs | 1 - crates/effects/src/distort2.rs | 2 +- crates/engine/src/tests/effect_ranges.rs | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index 82d21666a..338e00fc5 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -733,7 +733,6 @@ mod tests { ("ec.blur.radial", "amount", 3467), ("ec.blur.ccradial", "amount", 3468), ("ec.distort.turbulentdisplace", "amount", 3469), - ("ec.distort.turbulentdisplace", "evolutionOptions/randomSeed", 3470), ("ec.time.pixelmotionblur", "shutterAngle", 3481), ("ec.time.pixelmotionblur", "shutterSamples", 3482), ("ec.noise.fractal", "subSettings/subInfluence", 3485), diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index bbd8738a8..611cb6dcf 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -743,7 +743,7 @@ pub fn specs() -> Vec { p("evolution", "Evolution", num(0.0), ParamUi::Angle), p("evolutionOptions/cycleEvolution", "Cycle Evolution", Value::Bool(false), ParamUi::Checkbox), p("evolutionOptions/cycle", "Cycle (in Revolutions)", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)), - p("evolutionOptions/randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)), + p("evolutionOptions/randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), p("pinning", "Pinning", Value::Enum(1), popup(&TURBULENT_PINNING)), p("resizeLayer", "Resize Layer", Value::Bool(false), ParamUi::Checkbox), ], diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs index 9b3eb1414..7deaaa911 100644 --- a/crates/engine/src/tests/effect_ranges.rs +++ b/crates/engine/src/tests/effect_ranges.rs @@ -59,6 +59,7 @@ const ROWS: &[(u32, &str, &str, f64, f64)] = &[ (3475, "ec.generate.beam", "endThickness", 0.0, 4000.0), (3471, "ec.distort.transform", "scaleHeight", -30000.0, 30000.0), (3471, "ec.distort.transform", "scaleWidth", -30000.0, 30000.0), + (3470, "ec.distort.turbulentdisplace", "evolutionOptions/randomSeed", 0.0, 100000.0), ]; fn path(effect: &str, param: &str) -> String { From c41d889686e898980a55d7b4e8cce9235a803729 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 16:14:55 +0530 Subject: [PATCH 13/17] #3470, #3471, #3472, #3473, #3474, #3475, #3476, #3477, #3478, #3479, #3480, #3483, #3484, #3487, #3488, #3489, #3490, #3491: docs: effect ranges in the parity revision history --- docs/effects-parity.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/effects-parity.md b/docs/effects-parity.md index c7f850834..93bdf4792 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -64,6 +64,7 @@ the GPU is performance, not parity. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Parameters take After Effects' recorded valid ranges (hard range; defaults and sliders unchanged): Levels (Individual Controls) levels −10000..10000 and Gamma from 0; 4-Color Gradient Blend to 10000 and Jitter to 500 %; Change Color Hue Transform ±1800°; Noise HLS (and Auto) amounts to 1000 % and Grain Size from 0; Grid Border to 2000; Venetian Blinds Width 1..32000 and Feather to 32000; Radial Wipe Feather to 32000; Scatter Amount to 1000; Beam thicknesses to 4000; Transform Scale ±30000 %; Turbulent Displace Random Seed to 100000. A test checks every mapped parameter's range against the recorded table (CPU and GPU) | | 2026-10-19 | minor | Glow (original colours): bright pass per channel, 8 × (value − threshold + 0.061), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Glow (colour channels): the glow covers the layer's blurred alpha, blur σ = 0.384 × radius, measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Radial Blur repeats the edge instead of sampling transparency outside the layer, measured against After Effects 2026 renders (CPU; Radial Blur has no GPU path) | From acf60eeaf3b05be46a354a18b1704a31ca024e7c Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 16:24:17 +0530 Subject: [PATCH 14/17] #3487, #3488, #3489, #3490, #3491: Levels (Individual Controls): bound the curve only where it would overflow The 1e6 bound also capped curves that were finite before (gamma 0.1 on an HDR value 4 x Input White is 4^10, about 1.05e6). The bound is now 1e30: 1e30 times the widest output range (2e4) is still finite, and every curve below it renders as before. A test pins 4^10. --- crates/effects/src/color2.rs | 11 ++++++----- crates/effects/src/color2/tests.rs | 4 ++++ crates/gpu/src/shaders/fx_tone.wgsl | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index ad370ea1d..9df44d76f 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -257,11 +257,12 @@ impl Lv { } } -/// How far Levels (Individual Controls)' curve may reach, far beyond any image value: with gamma -/// down to 0 (rendered as 0.01, an exponent of 100) an HDR value a few times Input White would -/// overflow `f32`, and an infinite curve times an empty output range is NaN. Bounded, the output -/// stays finite over the whole valid range (levels ±10000). The GPU (`fxt_lv`) uses the same. -const LEVELS_IC_CURVE_LIMIT: f32 = 1e6; +/// How far Levels (Individual Controls)' curve may reach: with gamma down to 0 (rendered as 0.01, +/// an exponent of 100) an HDR value a few times Input White overflows `f32`, and an infinite curve +/// times an empty output range is NaN. 1e30 times the widest output range (2e4) is still finite +/// (`f32::MAX` is 3.4e38), and every curve that was finite before stays as it was below it. The +/// GPU (`fxt_lv`) uses the same. +const LEVELS_IC_CURVE_LIMIT: f32 = 1e30; fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf { let lv = LV_IDS.map(|ids| Lv { diff --git a/crates/effects/src/color2/tests.rs b/crates/effects/src/color2/tests.rs index 069d4c9f7..76338dd98 100644 --- a/crates/effects/src/color2/tests.rs +++ b/crates/effects/src/color2/tests.rs @@ -294,6 +294,10 @@ fn levels_ic_stays_finite_over_the_whole_range() { let out = run("ec.color.levelsic", &img, &lv); assert!(out.data.iter().flatten().all(|v| v.is_finite()), "{ib} {iw} {ob} {ow}: {:?}", out.data[0]); } + // The bound only stops the overflow: an HDR value that was finite before keeps its level + // (gamma 0.1 at 4 x Input White: 4^10). + let bright = run("ec.color.levelsic", &Image::filled(1, 1, [4.0, 4.0, 4.0, 1.0]), &[("rgb/rgbGamma", num(0.1)), ("clipToOutputWhite", Value::Enum(0))]); + assert_eq!(bright.data[0][0], 4.0f32.powf(10.0)); // Gamma 0 is the limit of a small gamma: below Input White the curve goes to 0. let dark = run("ec.color.levelsic", &Image::filled(1, 1, [0.5, 0.5, 0.5, 1.0]), &[("rgb/rgbGamma", num(0.0))]); assert!(dark.data[0][0] < 1e-6, "{:?}", dark.data[0]); diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index fb7dfe42b..bcd23baa7 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -41,7 +41,7 @@ fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { t = min(t, 1.0); } // color2::LEVELS_IC_CURVE_LIMIT keeps the curve finite. - let curve = clamp(fxt_gamma(t, max(data[o + 2u], 0.01)), -1e6, 1e6); + let curve = clamp(fxt_gamma(t, max(data[o + 2u], 0.01)), -1e30, 1e30); return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * curve; } From c0387ea18ada7a31870705a583c9f12aa991ab88 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 16:24:17 +0530 Subject: [PATCH 15/17] #3470, #3472, #3473, #3474, #3475, #3476, #3477, #3480, #3483, #3484, #3487-#3491: test the ends of the new ranges on the GPU CPU / GPU comparisons at the new ends for Venetian Blinds (width 1, width and feather 32000), Radial Wipe (feather 32000), Grid (border 2000), Scatter (1000), Beam (4000), Change Color (1710 degrees) and Turbulent Displace (seed 100000). The comparison counts infinity on both sides as equal, so Levels (Individual Controls) gets a direct check that the GPU's output is finite at the ends of its ranges. The Noise HLS test asserts the spec's ranges, so it fails without them. --- crates/effects/src/noise.rs | 4 ++++ crates/gpu/src/tests/fx_distort.rs | 2 ++ crates/gpu/src/tests/fx_gen2.rs | 2 ++ crates/gpu/src/tests/fx_generate.rs | 7 +++++++ crates/gpu/src/tests/fx_stylize.rs | 2 ++ crates/gpu/src/tests/fx_tone.rs | 32 +++++++++++++++++++++++++++++ 6 files changed, 49 insertions(+) diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 246910cd5..fde24b537 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -978,6 +978,10 @@ mod tests { *p = premul([0.6, 0.3, (i % 7) as f32 / 7.0], 1.0); } for id in ["ec.noise.noisehls", "ec.noise.noisehlsauto"] { + for (param, lo, hi) in [("hue", 0.0, 1000.0), ("lightness", 0.0, 1000.0), ("saturation", 0.0, 1000.0), ("grainSize", 0.0, 100.0)] { + let ui = &crate::find(id).unwrap().params.iter().find(|p| p.id == param).unwrap().ui; + assert!(matches!(ui, ParamUi::Slider { min, max, .. } if *min == lo && *max == hi), "{id} {param}: {ui:?}"); + } let at = |amount: f64, grain: f64| { let vals = [("noise", Value::Enum(2)), ("hue", num(amount)), ("lightness", num(amount)), ("saturation", num(amount)), ("grainSize", num(grain))]; diff --git a/crates/gpu/src/tests/fx_distort.rs b/crates/gpu/src/tests/fx_distort.rs index 1b8621408..6fc18f3c9 100644 --- a/crates/gpu/src/tests/fx_distort.rs +++ b/crates/gpu/src/tests/fx_distort.rs @@ -38,6 +38,8 @@ fn twirl_bulge_ripple_wave_warp() { #[test] fn turbulent_displace_and_cc_lens() { effect_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]); + // #3470: After Effects' maximum Random Seed. + effect_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0)), ("evolutionOptions/randomSeed", n(100000.0))]); effect_case( "ec.distort.turbulentdisplace", &[("displacement", e(1)), ("complexity", n(3.5)), ("evolution", n(90.0)), ("size", n(25.0)), ("pinning", e(2)), ("resizeLayer", on())], diff --git a/crates/gpu/src/tests/fx_gen2.rs b/crates/gpu/src/tests/fx_gen2.rs index 620cced13..1c19725e1 100644 --- a/crates/gpu/src/tests/fx_gen2.rs +++ b/crates/gpu/src/tests/fx_gen2.rs @@ -40,6 +40,8 @@ fn closed_form() { env_case("ec.generate.beam", &[]); env_case("ec.generate.beam", &[("length", n(70.0)), ("time", n(40.0)), ("softness", n(0.0)), ("perspective3d", off()), ("compositeOnOriginal", off())]); env_case("ec.generate.beam", &[("startThickness", n(3.0)), ("endThickness", n(20.0)), ("insideColor", c(0.3, 0.9, 1.0))]); + // #3475: After Effects' maximum thickness. + env_case("ec.generate.beam", &[("startThickness", n(0.0)), ("endThickness", n(4000.0))]); env_case("ec.generate.lensflare", &[]); env_case("ec.generate.lensflare", &[("lensType", e(1)), ("flareCenter", pt(50.0, 30.0)), ("blendWithOriginal", n(30.0))]); env_case("ec.generate.lensflare", &[("lensType", e(2)), ("flareBrightness", n(160.0))]); diff --git a/crates/gpu/src/tests/fx_generate.rs b/crates/gpu/src/tests/fx_generate.rs index d7e05e047..f8482b5ab 100644 --- a/crates/gpu/src/tests/fx_generate.rs +++ b/crates/gpu/src/tests/fx_generate.rs @@ -83,6 +83,11 @@ fn wipes() { effect_case("ec.transition.radialwipe", &[("completion", n(45.0)), ("wipe", e(1)), ("center", pt(30.2, 19.6))]); effect_case("ec.transition.radialwipe", &[("completion", n(60.0)), ("wipe", e(2)), ("startAngle", n(-40.0)), ("feather", n(3.0))]); effect_case("ec.transition.radialwipe", &[("completion", n(0.0)), ("feather", n(20.0))]); + // #3472-#3474: the ends of After Effects' ranges (a Feather wider than 8 blinds is the CPU's + // even veil). + effect_case("ec.transition.venetian", &[("completion", n(40.0)), ("width", n(1.0)), ("feather", n(6.0))]); + effect_case("ec.transition.venetian", &[("completion", n(40.0)), ("width", n(32000.0)), ("feather", n(32000.0))]); + effect_case("ec.transition.radialwipe", &[("completion", n(40.0)), ("feather", n(32000.0))]); effect_case("ec.transition.gradientwipe", &[("completion", n(40.0))]); effect_case("ec.transition.gradientwipe", &[("completion", n(55.0)), ("softness", n(30.0)), ("invert", b(true))]); effect_case("ec.transition.gradientwipe", &[("completion", n(100.0))]); @@ -147,6 +152,8 @@ fn generators() { ); effect_case("ec.generate.checkerboard", &[("sizeFrom", e(0)), ("corner", pt(16.0, 11.0)), ("color", c(0.9, 0.3, 0.1)), ("blendingMode", e(1))]); effect_case("ec.generate.grid", &[("width", n(12.0)), ("border", n(3.0))]); + // #3476: a border wider than the cells (After Effects' maximum). + effect_case("ec.generate.grid", &[("width", n(12.0)), ("border", n(2000.0))]); effect_case( "ec.generate.grid", &[ diff --git a/crates/gpu/src/tests/fx_stylize.rs b/crates/gpu/src/tests/fx_stylize.rs index 63d93f207..8c6c7be33 100644 --- a/crates/gpu/src/tests/fx_stylize.rs +++ b/crates/gpu/src/tests/fx_stylize.rs @@ -109,6 +109,8 @@ fn arbitrary_map_signed_fractional_and_near_zero_wraps() { #[test] fn scatter_brush_strokes() { effect_case("ec.stylize.scatter", &[("amount", n(5.0))]); + // #3477: After Effects' maximum, far beyond the layer. + effect_case("ec.stylize.scatter", &[("amount", n(1000.0))]); effect_case("ec.stylize.scatter", &[("amount", n(3.0)), ("grain", e(1))]); effect_case("ec.stylize.scatter", &[("amount", n(7.0)), ("grain", e(2)), ("randomizeEveryFrame", on())]); effect_case("ec.stylize.brushstrokes", &[]); diff --git a/crates/gpu/src/tests/fx_tone.rs b/crates/gpu/src/tests/fx_tone.rs index ca80f3836..3083f5949 100644 --- a/crates/gpu/src/tests/fx_tone.rs +++ b/crates/gpu/src/tests/fx_tone.rs @@ -81,6 +81,8 @@ fn colour_replacement() { &[("amount", n(100.0)), ("color", c(0.8, 0.4, 0.3)), ("matchColors", e(1)), ("tolerance", n(5.0)), ("edgeSoftness", n(30.0))], ); effect_case("ec.color.changecolor", &[("hueTransform", n(90.0)), ("colorToChange", c(0.7, 0.5, 0.4)), ("matchingSoftness", n(20.0))]); + // #3480: near the top of After Effects' range (four and three quarter turns). + effect_case("ec.color.changecolor", &[("hueTransform", n(1710.0)), ("colorToChange", c(0.7, 0.5, 0.4)), ("matchingSoftness", n(20.0))]); effect_case( "ec.color.changecolor", &[ @@ -199,3 +201,33 @@ fn color_stabilizer() { fn gradient_ramp_matches_the_cpu() { effect_case("ec.generate.gradientramp", &[]); } + +/// #3487-#3491: at the ends of the valid ranges the GPU's Levels (Individual Controls) output is +/// finite (the CPU / GPU comparison counts infinity on both sides as equal). +#[test] +fn levels_ic_is_finite_at_the_ends_of_its_ranges() { + let Some(g) = crate::tests::gpu() else { return }; + let spec = effectcraft_effects::find("ec.color.levelsic").unwrap(); + let size = [16.0, 8.0]; + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in [ + ("rgb/rgbInWhite", n(0.2)), + ("rgb/rgbGamma", n(0.0)), + ("red/redOutBlack", n(-10000.0)), + ("blue/blueOutWhite", n(10000.0)), + ("green/greenOutWhite", n(0.0)), + ("clipToOutputWhite", e(0)), + ] { + params.values.insert(k.to_string(), v); + } + let mut img = crate::tests::pattern(7, 16, 8); + for p in &mut img.data { + *p = p.map(|v| v * 3.0); + } + let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale: 1.0 }; + let ctx = effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: size, seed: 1, adjustment: false, env: Default::default() }; + let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx }], &buf, None) + .expect("the GPU runs Levels (Individual Controls)"); + assert!(out.img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", out.img.data.iter().find(|p| p.iter().any(|v| !v.is_finite()))); +} From 047b912fc58ecface93af11ce75411b2eda6122a Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 16:35:08 +0530 Subject: [PATCH 16/17] #3487, #3488, #3489, #3490, #3491: Levels (Individual Controls): GPU saturates the curve before pow can overflow WGSL leaves the result of an overflowing pow indeterminate, so clamping after it did not guarantee the CPU's 1e30 bound on every backend. A curve past the bound (log2|t| / gamma > 99.7) now saturates to +-1e30 without calling pow; between 1e30 and 2^99.7 the clamp gives the same 1e30, so the GPU matches the CPU everywhere. --- crates/gpu/src/shaders/fx_tone.wgsl | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index bcd23baa7..a47d5df59 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -40,8 +40,12 @@ fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { if (clip_w) { t = min(t, 1.0); } - // color2::LEVELS_IC_CURVE_LIMIT keeps the curve finite. - let curve = clamp(fxt_gamma(t, max(data[o + 2u], 0.01)), -1e30, 1e30); + // color2::LEVELS_IC_CURVE_LIMIT keeps the curve finite. WGSL leaves an overflowing pow + // indeterminate, so a curve past the bound saturates before pow: |t|^(1/g) > 1e30 once + // log2|t| / g > 99.66; between 1e30 and 2^99.7 the clamp gives the same 1e30. + let g = max(data[o + 2u], 0.01); + let saturate = abs(t) > 1.0 && log2(abs(t)) / g > 99.7; + let curve = select(clamp(fxt_gamma(t, g), -1e30, 1e30), sign(t) * 1e30, saturate); return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * curve; } From f88d0d0a8ddc9ae0feea24af8082381b6423a8fd Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 12 Nov 2026 16:37:09 +0530 Subject: [PATCH 17/17] #3487, #3488, #3489, #3490, #3491: Levels (Individual Controls): skip the overflowing pow with a branch WGSL's select evaluates both of its arguments, so the previous commit still ran the overflowing pow and only discarded it. An explicit branch now skips the power for a curve past the bound. --- crates/gpu/src/shaders/fx_tone.wgsl | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index a47d5df59..9472d63f0 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -43,9 +43,12 @@ fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { // color2::LEVELS_IC_CURVE_LIMIT keeps the curve finite. WGSL leaves an overflowing pow // indeterminate, so a curve past the bound saturates before pow: |t|^(1/g) > 1e30 once // log2|t| / g > 99.66; between 1e30 and 2^99.7 the clamp gives the same 1e30. + // A branch, not select: select evaluates both sides, so pow would still run. let g = max(data[o + 2u], 0.01); - let saturate = abs(t) > 1.0 && log2(abs(t)) / g > 99.7; - let curve = select(clamp(fxt_gamma(t, g), -1e30, 1e30), sign(t) * 1e30, saturate); + var curve = sign(t) * 1e30; + if (!(abs(t) > 1.0 && log2(abs(t)) / g > 99.7)) { + curve = clamp(fxt_gamma(t, g), -1e30, 1e30); + } return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * curve; }