Merge pull request #3571 from arnav-hiwarkar/fix/effect-ranges-easy-batch

Effects: 18 parameters take After Effects' valid ranges (Levels IC, 4-Color Gradient, Change Color, Noise HLS, Grid, Venetian Blinds, Radial Wipe, Scatter, Beam, Transform, Turbulent Displace)
This commit is contained in:
ash authored and GitHub committed 2026-11-12 17:19:57 +05:30
commit b9da77c63d
27 files changed
+510 -41

No files matched your search

+13
View File
@@ -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<f64> {
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"];
+96
View File
@@ -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"));
}
}
+3 -14
View File
@@ -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`).
+17 -6
View File
@@ -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<EffectSpec> {
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)));
+33
View File
@@ -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 {
+1 -1
View File
@@ -809,7 +809,7 @@ pub fn specs() -> Vec<EffectSpec> {
"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),
+2 -2
View File
@@ -506,8 +506,8 @@ pub fn specs() -> Vec<EffectSpec> {
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),
+1 -1
View File
@@ -743,7 +743,7 @@ pub fn specs() -> Vec<EffectSpec> {
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),
],
+26 -4
View File
@@ -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<EffectSpec> {
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<EffectSpec> {
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).
+2 -2
View File
@@ -1111,8 +1111,8 @@ pub fn specs() -> Vec<EffectSpec> {
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),
+1 -1
View File
@@ -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::{
+3 -3
View File
@@ -919,7 +919,7 @@ pub fn specs() -> Vec<EffectSpec> {
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<EffectSpec> {
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,
),
+32 -4
View File
@@ -766,10 +766,12 @@ pub fn specs() -> Vec<EffectSpec> {
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);
+1 -1
View File
@@ -612,7 +612,7 @@ pub fn specs() -> Vec<EffectSpec> {
"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),
],
@@ -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"),
+1
View File
@@ -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;
+170
View File
@@ -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<f64> = 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),
}
}
+26
View File
@@ -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}");
}
}
}
+1 -1
View File
@@ -385,7 +385,7 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
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)
+10 -1
View File
@@ -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).
+2
View File
@@ -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())],
+2
View File
@@ -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))]);
+9
View File
@@ -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]
+2
View File
@@ -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))]);
}
+2
View File
@@ -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", &[]);
+43
View File
@@ -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: &params, 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())));
}
+1
View File
@@ -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) |