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..338e00fc5 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -707,3 +707,99 @@ 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.time.pixelmotionblur", "shutterAngle", 3481), + ("ec.time.pixelmotionblur", "shutterSamples", 3482), + ("ec.noise.fractal", "subSettings/subInfluence", 3485), + ("ec.noise.fractal", "subSettings/subScaling", 3486), + ("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`). diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 6639e649e..9df44d76f 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -252,10 +252,18 @@ 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: 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 { ib: ctx.params.f(ids[0]) as f32, @@ -978,11 +986,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..76338dd98 100644 --- a/crates/effects/src/color2/tests.rs +++ b/crates/effects/src/color2/tests.rs @@ -270,6 +270,39 @@ 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]); + } + // 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]); +} + /// A host whose other layers and other frames are a fixed image. struct ImgHost(Image); impl crate::EffectHost for ImgHost { 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/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/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/effects/src/generate.rs b/crates/effects/src/generate.rs index ae0d61861..d8d47a7c7 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])), ], @@ -384,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), @@ -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/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/effects/src/lib.rs b/crates/effects/src/lib.rs index 26b0008ef..f9d8f97e8 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -100,7 +100,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/effects/src/misc.rs b/crates/effects/src/misc.rs index 65951ad97..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, ), @@ -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/effects/src/noise.rs b/crates/effects/src/noise.rs index 09217f831..fde24b537 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,32 @@ 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"] { + 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))]; + 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/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/interchange/keyframe_text_effects.rs b/crates/engine/src/interchange/keyframe_text_effects.rs index 83f5b6c20..7b0658f61 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"), @@ -317,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.rs b/crates/engine/src/tests.rs index b2964916f..df0853398 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -14,6 +14,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..7deaaa911 --- /dev/null +++ b/crates/engine/src/tests/effect_ranges.rs @@ -0,0 +1,170 @@ +//! 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 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), + (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), + (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), + (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), + (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), + (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 { + 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/engine/src/tests/keyframe_text.rs b/crates/engine/src/tests/keyframe_text.rs index 30d3b436e..a299899eb 100644 --- a/crates/engine/src/tests/keyframe_text.rs +++ b/crates/engine/src/tests/keyframe_text.rs @@ -480,3 +480,29 @@ 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"); + 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/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/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index bb206adbd..9472d63f0 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -40,7 +40,16 @@ 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. 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); + 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; } // color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white). 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 d12920797..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", &[ @@ -167,6 +174,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] 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))]); } 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 40bc9483f..3083f5949 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))]); @@ -70,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", &[ @@ -188,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()))); +} 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) |