#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.
This commit is contained in:
arnav-hiwarkar committed 2026-11-12 15:05:48 +05:30
1 parent 41585f2aec
commit 216fc6052d
3 files changed
+154 -14

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"];
+138
View File
@@ -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"));
}
}
+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`).