mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +00:00
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:
27 files changed
+510
-41
No files matched your search
@@ -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"];
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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())],
|
||||
|
||||
@@ -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))]);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", &[]);
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user