mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 14:38:21 -06:00
refactor(effects): move src/distort3.rs tests to src/distort3/tests.rs
This commit is contained in:
@@ -1084,337 +1084,4 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, MaskShape, Params};
|
||||
|
||||
fn img(w: u32, h: u32) -> Image {
|
||||
crate::util::gen_image(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, ((x * 7 + y * 3) % 11) as f32 / 11.0, 1.0])
|
||||
}
|
||||
|
||||
/// Run with Point defaults scaled to the layer (like `instantiate`).
|
||||
fn run(id: &str, set: &[(&str, Value)], im: Image, masks: &[MaskShape]) -> Buf {
|
||||
let s = crate::find(id).unwrap();
|
||||
let ls = [im.width as f64, im.height as f64];
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for ps in &s.params {
|
||||
if let (ParamUi::Point, Value::Vec2(v)) = (&ps.ui, &ps.default) {
|
||||
params.values.insert(ps.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]]));
|
||||
}
|
||||
}
|
||||
for (k, v) in set {
|
||||
assert!(params.values.contains_key(*k), "{id}: {k}");
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let env = EffectEnv { masks, ..Default::default() };
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 1, adjustment: false, env };
|
||||
crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
fn maxd(a: &Image, b: &Image) -> f32 {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
fn rect_mask(x0: f64, y0: f64, x1: f64, y1: f64) -> MaskShape {
|
||||
MaskShape { name: "m".into(), points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]], closed: true, inverted: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_at_neutral_settings() {
|
||||
let im = img(40, 30);
|
||||
let cases: &[(&str, Vec<(&str, Value)>)] = &[
|
||||
("ec.distort.ccbender", vec![]),
|
||||
("ec.distort.ccpowerpin", vec![]),
|
||||
("ec.distort.ccripplepulse", vec![]),
|
||||
("ec.distort.ccslant", vec![]),
|
||||
("ec.distort.ccsmear", vec![("reach", num(0.0))]),
|
||||
("ec.distort.ccsplit", vec![("split", num(0.0))]),
|
||||
("ec.distort.ccsplit2", vec![("split1", num(0.0)), ("split2", num(0.0))]),
|
||||
("ec.distort.smear", vec![]),
|
||||
("ec.distort.reshape", vec![]),
|
||||
("ec.distort.twirllegacy", vec![]),
|
||||
("ec.distort.warp", vec![("warpStyle", Value::Enum(5))]),
|
||||
("ec.distort.upscale", vec![]),
|
||||
];
|
||||
for (id, set) in cases {
|
||||
let o = run(id, set, im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) < 1e-5, "{id} not identity");
|
||||
assert_eq!(o.offset, [0.0, 0.0], "{id}");
|
||||
}
|
||||
// Power pin with perspective 0 at default corners is also identity.
|
||||
let o = run("ec.distort.ccpowerpin", &[("perspective", num(0.0))], im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_deterministic() {
|
||||
let im = img(36, 28);
|
||||
let masks = [rect_mask(8.0, 8.0, 16.0, 16.0), rect_mask(12.0, 10.0, 22.0, 20.0), rect_mask(2.0, 2.0, 34.0, 26.0)];
|
||||
let cases: &[(&str, Vec<(&str, Value)>)] = &[
|
||||
("ec.distort.ccbender", vec![("amount", num(40.0)), ("style", Value::Enum(3))]),
|
||||
("ec.distort.ccblobbylize", vec![]),
|
||||
("ec.distort.ccpowerpin", vec![("topLeft", pt(5.0, 3.0)), ("perspective", num(40.0))]),
|
||||
("ec.distort.ccripplepulse", vec![("pulseLevel", num(80.0))]),
|
||||
("ec.distort.ccslant", vec![("slant", num(30.0)), ("stretching", Value::Bool(true))]),
|
||||
("ec.distort.ccsmear", vec![]),
|
||||
("ec.distort.ccsplit", vec![]),
|
||||
("ec.distort.ccsplit2", vec![("split2", num(10.0))]),
|
||||
("ec.distort.smear", vec![("sourceMask", Value::Enum(1)), ("boundaryMask", Value::Enum(3)), ("maskOffset", pt(4.0, 2.0))]),
|
||||
("ec.distort.reshape", vec![("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]),
|
||||
("ec.distort.twirllegacy", vec![("angle", num(90.0))]),
|
||||
("ec.distort.warp", vec![("bend", num(50.0)), ("horizontalDistortion", num(20.0))]),
|
||||
("ec.distort.upscale", vec![("scale", num(200.0))]),
|
||||
];
|
||||
for (id, set) in cases {
|
||||
let a = run(id, set, im.clone(), &masks);
|
||||
let b = run(id, set, im.clone(), &masks);
|
||||
assert_eq!(a.img, b.img, "{id}");
|
||||
assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}");
|
||||
assert!(a.img.width != im.width || a.img.height != im.height || maxd(&a.img, &im) > 1e-3, "{id} did nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_defaults_match_ae() {
|
||||
let s = crate::find("ec.distort.ccblobbylize").unwrap();
|
||||
let default = |id: &str| s.params.iter().find(|p| p.id == id).unwrap().default.clone();
|
||||
assert_eq!(default("property"), Value::Enum(0), "Property defaults to Red");
|
||||
assert_eq!(default("softness"), num(20.0));
|
||||
assert_eq!(default("cutAway"), num(25.0));
|
||||
assert_eq!(default("lightHeight"), num(65.0));
|
||||
assert_eq!(default("lightPosition"), pt(0.5, 0.5));
|
||||
assert_eq!(default("ambient"), num(75.0));
|
||||
assert_eq!(default("diffuse"), num(25.0));
|
||||
assert_eq!(default("specular"), num(100.0));
|
||||
assert_eq!(default("roughness"), num(0.025));
|
||||
assert_eq!(default("metal"), num(100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_cut_away_thresholds_a_height_ramp() {
|
||||
// AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge.
|
||||
let im = crate::util::gen_image(1920, 8, |x, _| {
|
||||
let h = x as f32 / 1920.0;
|
||||
[h, h, h, 1.0]
|
||||
});
|
||||
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]);
|
||||
assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent");
|
||||
assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque");
|
||||
let edge = o.img.get(480, 4)[3];
|
||||
assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold");
|
||||
let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count();
|
||||
assert!(partial <= 2, "{partial} antialiased columns instead of one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_cut_away_100_is_empty() {
|
||||
// Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100.
|
||||
let im = crate::util::gen_image(1920, 8, |x, _| {
|
||||
let h = x as f32 / 1920.0;
|
||||
[h, h, h, 1.0]
|
||||
});
|
||||
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
|
||||
assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_cut_away_100_clears_an_opaque_edge() {
|
||||
// An opaque source reaches h = 1 on the high side of an edge; the gradient antialias
|
||||
// used to leave m = 0.5 there, so Cut Away 100 still produced visible pixels.
|
||||
let im = crate::util::gen_image(64, 8, |x, _| if x < 32 { [1.0; 4] } else { [0.0; 4] });
|
||||
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(3)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
|
||||
assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left an opaque edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_softness_warps_a_step_matte_inward() {
|
||||
// AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte
|
||||
// edge at about x = 946, not at the source edge. The PR's matte-preserving version
|
||||
// left it at 960; the effect must warp the source matte.
|
||||
let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] });
|
||||
let o = run(
|
||||
"ec.distort.ccblobbylize",
|
||||
&[
|
||||
("property", Value::Enum(3)),
|
||||
("softness", num(20.0)),
|
||||
("cutAway", num(0.0)),
|
||||
("ambient", num(100.0)),
|
||||
("diffuse", num(0.0)),
|
||||
("specular", num(0.0)),
|
||||
("metal", num(0.0)),
|
||||
],
|
||||
im,
|
||||
&[],
|
||||
);
|
||||
assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]);
|
||||
assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge");
|
||||
assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped");
|
||||
}
|
||||
|
||||
/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized
|
||||
/// (200 × 100) layer bounds, after `id` with `set`.
|
||||
fn on_text(id: &str, set: &[(&str, Value)]) -> Buf {
|
||||
let s = crate::find(id).unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for (k, v) in set {
|
||||
assert!(params.values.contains_key(*k), "{id}: {k}");
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let mut im = Image::new(20, 10);
|
||||
im.data.iter_mut().for_each(|p| *p = [1.0; 4]);
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() };
|
||||
crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
/// Alpha at layer point `p` (0 outside the buffer).
|
||||
fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 {
|
||||
let (x, y) = b.to_px(p);
|
||||
b.img.get(x.floor() as i64, y.floor() as i64)[3]
|
||||
}
|
||||
|
||||
/// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds
|
||||
/// (comp-sized, as in After Effects), not cut off where the glyphs end.
|
||||
#[test]
|
||||
fn effects_on_text_reach_past_its_box() {
|
||||
let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]);
|
||||
assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box");
|
||||
assert!(smear.img.width < 200, "it grows only as far as pixels move");
|
||||
let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]);
|
||||
assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9);
|
||||
assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds");
|
||||
assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1);
|
||||
// A footage layer's buffer is its bounds already: no change.
|
||||
let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]);
|
||||
assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_warp_style_runs_and_bends() {
|
||||
let im = img(40, 30);
|
||||
for s in 0..WARP_STYLES.len() as u32 {
|
||||
for axis in 0..2 {
|
||||
let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(s)), ("warpAxis", Value::Enum(axis)), ("bend", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[0] as u32;
|
||||
// The layer centre's neighbourhood is still made of source pixels.
|
||||
assert!(o.img.get(pad as i64 + 20, pad as i64 + 15)[3] > 0.5, "style {s}");
|
||||
assert!(o.img.width > im.width);
|
||||
}
|
||||
}
|
||||
// Arc with positive bend raises the middle of the layer: the top edge in the middle
|
||||
// moves up (opaque pixels above the original top).
|
||||
let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(3)), ("bend", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[1] as i64;
|
||||
assert!(o.img.get(o.offset[0] as i64 + 20, pad - 3)[3] > 0.5);
|
||||
assert!(o.img.get(o.offset[0] as i64 + 1, pad - 3)[3] < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_pin_moves_corners() {
|
||||
let im = img(20, 20);
|
||||
// Pin to the right half: output x in 10..20 shows the whole layer squeezed.
|
||||
let set = [("topLeft", pt(10.0, 0.0)), ("bottomLeft", pt(10.0, 20.0))];
|
||||
let o = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
|
||||
assert_eq!(o.img.get(3, 10)[3], 0.0);
|
||||
let px = o.img.get(15, 10);
|
||||
assert!((px[0] - 0.5).abs() < 0.1, "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_pin_unstretch_expands_pinned_region() {
|
||||
let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]);
|
||||
let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))];
|
||||
let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count();
|
||||
let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]);
|
||||
assert_eq!(opaque(&off), 96);
|
||||
let mut set = inset.to_vec();
|
||||
set.push(("unstretch", Value::Bool(true)));
|
||||
let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
|
||||
assert_eq!(opaque(&on), 24 * 16);
|
||||
assert!(on.img.get(0, 0)[3] > 0.99);
|
||||
// Default corners are the identity either way.
|
||||
let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]);
|
||||
assert!(maxd(&id.img, &im) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_opens_a_gap() {
|
||||
let im = img(40, 30);
|
||||
let o = run("ec.distort.ccsplit", &[("split", num(40.0))], im.clone(), &[]);
|
||||
assert_eq!(o.img.get(20, 15)[3], 0.0);
|
||||
assert_eq!(o.img.get(2, 2), im.get(2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slant_shears_from_floor() {
|
||||
let im = img(20, 20);
|
||||
let o = run("ec.distort.ccslant", &[("slant", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[0] as i64;
|
||||
// Bottom row stays put; the top row shifts right by ~10 px.
|
||||
assert!((o.img.get(pad + 5, pad + 19)[0] - im.get(5, 19)[0]).abs() < 0.05);
|
||||
assert!((o.img.get(pad + 15, pad)[0] - im.get(5, 0)[0]).abs() < 0.08);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_moves_source_to_destination() {
|
||||
// A bright square at the source mask; reshape to a shifted destination mask.
|
||||
let im = crate::util::gen_image(40, 40, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] });
|
||||
let masks = [rect_mask(10.0, 10.0, 20.0, 20.0), rect_mask(16.0, 10.0, 26.0, 20.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
|
||||
let o = run("ec.distort.reshape", &[("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))], im, &masks);
|
||||
assert!(o.img.get(24, 15)[0] > 0.8, "{:?}", o.img.get(24, 15));
|
||||
assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_correspondence_points_steer_the_match() {
|
||||
// Matching outlines with a zero pair change nothing; a quarter-turn offset twists.
|
||||
let im = img(40, 40);
|
||||
let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
|
||||
let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))];
|
||||
let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks);
|
||||
assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change");
|
||||
let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks);
|
||||
assert!(maxd(&twisted.img, &im) > 0.1);
|
||||
// Fraction mapping.
|
||||
assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9);
|
||||
assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9);
|
||||
assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscale_grows_about_centre() {
|
||||
let im = img(20, 10);
|
||||
let o = run("ec.distort.upscale", &[("scale", num(200.0)), ("detail", num(0.0))], im.clone(), &[]);
|
||||
assert_eq!((o.img.width, o.img.height), (40, 20));
|
||||
// Grown about the layer centre: layer point (0,0) now sits at buffer pixel (10,5), so the
|
||||
// centre (10,5) lands in the middle of the 40×20 buffer.
|
||||
assert_eq!(o.offset, [10.0, 5.0]);
|
||||
let c = o.img.sample_bilinear(20.0, 10.0);
|
||||
assert!((c[0] - im.sample_bilinear(10.0, 5.0)[0]).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twirl_legacy_rotates_inside_radius() {
|
||||
let im = img(40, 40);
|
||||
let o = run("ec.distort.twirllegacy", &[("angle", num(180.0)), ("radius", num(100.0))], im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) > 0.1);
|
||||
// Corners lie outside the radius.
|
||||
assert_eq!(o.img.get(0, 0), im.get(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_resolution_matches_scaled_geometry() {
|
||||
// Bender at scale 0.5: the buffer is half size, parameters in layer pixels.
|
||||
let s = crate::find("ec.distort.ccbender").unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
params.values.insert("amount".into(), num(30.0));
|
||||
params.values.insert("top".into(), pt(20.0, 0.0));
|
||||
params.values.insert("base".into(), pt(20.0, 40.0));
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: Default::default() };
|
||||
let full = crate::apply(s, &ctx, Buf { img: img(40, 40), offset: [0.0; 2], scale: 1.0 });
|
||||
let half = crate::apply(s, &ctx, Buf { img: effectcraft_raster::resample(&img(40, 40), 20, 20), offset: [0.0; 2], scale: 0.5 });
|
||||
let a = full.img.sample_bilinear(20.0, 4.0)[3];
|
||||
let b = half.img.sample_bilinear(10.0, 2.0)[3];
|
||||
assert!((a - b).abs() < 0.3);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
use super::*;
|
||||
use crate::{EffectEnv, MaskShape, Params};
|
||||
|
||||
fn img(w: u32, h: u32) -> Image {
|
||||
crate::util::gen_image(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, ((x * 7 + y * 3) % 11) as f32 / 11.0, 1.0])
|
||||
}
|
||||
|
||||
/// Run with Point defaults scaled to the layer (like `instantiate`).
|
||||
fn run(id: &str, set: &[(&str, Value)], im: Image, masks: &[MaskShape]) -> Buf {
|
||||
let s = crate::find(id).unwrap();
|
||||
let ls = [im.width as f64, im.height as f64];
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for ps in &s.params {
|
||||
if let (ParamUi::Point, Value::Vec2(v)) = (&ps.ui, &ps.default) {
|
||||
params.values.insert(ps.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]]));
|
||||
}
|
||||
}
|
||||
for (k, v) in set {
|
||||
assert!(params.values.contains_key(*k), "{id}: {k}");
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let env = EffectEnv { masks, ..Default::default() };
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 1, adjustment: false, env };
|
||||
crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
fn maxd(a: &Image, b: &Image) -> f32 {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
fn rect_mask(x0: f64, y0: f64, x1: f64, y1: f64) -> MaskShape {
|
||||
MaskShape { name: "m".into(), points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]], closed: true, inverted: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_at_neutral_settings() {
|
||||
let im = img(40, 30);
|
||||
let cases: &[(&str, Vec<(&str, Value)>)] = &[
|
||||
("ec.distort.ccbender", vec![]),
|
||||
("ec.distort.ccpowerpin", vec![]),
|
||||
("ec.distort.ccripplepulse", vec![]),
|
||||
("ec.distort.ccslant", vec![]),
|
||||
("ec.distort.ccsmear", vec![("reach", num(0.0))]),
|
||||
("ec.distort.ccsplit", vec![("split", num(0.0))]),
|
||||
("ec.distort.ccsplit2", vec![("split1", num(0.0)), ("split2", num(0.0))]),
|
||||
("ec.distort.smear", vec![]),
|
||||
("ec.distort.reshape", vec![]),
|
||||
("ec.distort.twirllegacy", vec![]),
|
||||
("ec.distort.warp", vec![("warpStyle", Value::Enum(5))]),
|
||||
("ec.distort.upscale", vec![]),
|
||||
];
|
||||
for (id, set) in cases {
|
||||
let o = run(id, set, im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) < 1e-5, "{id} not identity");
|
||||
assert_eq!(o.offset, [0.0, 0.0], "{id}");
|
||||
}
|
||||
// Power pin with perspective 0 at default corners is also identity.
|
||||
let o = run("ec.distort.ccpowerpin", &[("perspective", num(0.0))], im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_deterministic() {
|
||||
let im = img(36, 28);
|
||||
let masks = [rect_mask(8.0, 8.0, 16.0, 16.0), rect_mask(12.0, 10.0, 22.0, 20.0), rect_mask(2.0, 2.0, 34.0, 26.0)];
|
||||
let cases: &[(&str, Vec<(&str, Value)>)] = &[
|
||||
("ec.distort.ccbender", vec![("amount", num(40.0)), ("style", Value::Enum(3))]),
|
||||
("ec.distort.ccblobbylize", vec![]),
|
||||
("ec.distort.ccpowerpin", vec![("topLeft", pt(5.0, 3.0)), ("perspective", num(40.0))]),
|
||||
("ec.distort.ccripplepulse", vec![("pulseLevel", num(80.0))]),
|
||||
("ec.distort.ccslant", vec![("slant", num(30.0)), ("stretching", Value::Bool(true))]),
|
||||
("ec.distort.ccsmear", vec![]),
|
||||
("ec.distort.ccsplit", vec![]),
|
||||
("ec.distort.ccsplit2", vec![("split2", num(10.0))]),
|
||||
("ec.distort.smear", vec![("sourceMask", Value::Enum(1)), ("boundaryMask", Value::Enum(3)), ("maskOffset", pt(4.0, 2.0))]),
|
||||
("ec.distort.reshape", vec![("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]),
|
||||
("ec.distort.twirllegacy", vec![("angle", num(90.0))]),
|
||||
("ec.distort.warp", vec![("bend", num(50.0)), ("horizontalDistortion", num(20.0))]),
|
||||
("ec.distort.upscale", vec![("scale", num(200.0))]),
|
||||
];
|
||||
for (id, set) in cases {
|
||||
let a = run(id, set, im.clone(), &masks);
|
||||
let b = run(id, set, im.clone(), &masks);
|
||||
assert_eq!(a.img, b.img, "{id}");
|
||||
assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}");
|
||||
assert!(a.img.width != im.width || a.img.height != im.height || maxd(&a.img, &im) > 1e-3, "{id} did nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_defaults_match_ae() {
|
||||
let s = crate::find("ec.distort.ccblobbylize").unwrap();
|
||||
let default = |id: &str| s.params.iter().find(|p| p.id == id).unwrap().default.clone();
|
||||
assert_eq!(default("property"), Value::Enum(0), "Property defaults to Red");
|
||||
assert_eq!(default("softness"), num(20.0));
|
||||
assert_eq!(default("cutAway"), num(25.0));
|
||||
assert_eq!(default("lightHeight"), num(65.0));
|
||||
assert_eq!(default("lightPosition"), pt(0.5, 0.5));
|
||||
assert_eq!(default("ambient"), num(75.0));
|
||||
assert_eq!(default("diffuse"), num(25.0));
|
||||
assert_eq!(default("specular"), num(100.0));
|
||||
assert_eq!(default("roughness"), num(0.025));
|
||||
assert_eq!(default("metal"), num(100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_cut_away_thresholds_a_height_ramp() {
|
||||
// AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge.
|
||||
let im = crate::util::gen_image(1920, 8, |x, _| {
|
||||
let h = x as f32 / 1920.0;
|
||||
[h, h, h, 1.0]
|
||||
});
|
||||
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]);
|
||||
assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent");
|
||||
assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque");
|
||||
let edge = o.img.get(480, 4)[3];
|
||||
assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold");
|
||||
let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count();
|
||||
assert!(partial <= 2, "{partial} antialiased columns instead of one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_cut_away_100_is_empty() {
|
||||
// Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100.
|
||||
let im = crate::util::gen_image(1920, 8, |x, _| {
|
||||
let h = x as f32 / 1920.0;
|
||||
[h, h, h, 1.0]
|
||||
});
|
||||
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
|
||||
assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_cut_away_100_clears_an_opaque_edge() {
|
||||
// An opaque source reaches h = 1 on the high side of an edge; the gradient antialias
|
||||
// used to leave m = 0.5 there, so Cut Away 100 still produced visible pixels.
|
||||
let im = crate::util::gen_image(64, 8, |x, _| if x < 32 { [1.0; 4] } else { [0.0; 4] });
|
||||
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(3)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
|
||||
assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left an opaque edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_softness_warps_a_step_matte_inward() {
|
||||
// AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte
|
||||
// edge at about x = 946, not at the source edge. The PR's matte-preserving version
|
||||
// left it at 960; the effect must warp the source matte.
|
||||
let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] });
|
||||
let o = run(
|
||||
"ec.distort.ccblobbylize",
|
||||
&[
|
||||
("property", Value::Enum(3)),
|
||||
("softness", num(20.0)),
|
||||
("cutAway", num(0.0)),
|
||||
("ambient", num(100.0)),
|
||||
("diffuse", num(0.0)),
|
||||
("specular", num(0.0)),
|
||||
("metal", num(0.0)),
|
||||
],
|
||||
im,
|
||||
&[],
|
||||
);
|
||||
assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]);
|
||||
assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge");
|
||||
assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped");
|
||||
}
|
||||
|
||||
/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized
|
||||
/// (200 × 100) layer bounds, after `id` with `set`.
|
||||
fn on_text(id: &str, set: &[(&str, Value)]) -> Buf {
|
||||
let s = crate::find(id).unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for (k, v) in set {
|
||||
assert!(params.values.contains_key(*k), "{id}: {k}");
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let mut im = Image::new(20, 10);
|
||||
im.data.iter_mut().for_each(|p| *p = [1.0; 4]);
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() };
|
||||
crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
/// Alpha at layer point `p` (0 outside the buffer).
|
||||
fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 {
|
||||
let (x, y) = b.to_px(p);
|
||||
b.img.get(x.floor() as i64, y.floor() as i64)[3]
|
||||
}
|
||||
|
||||
/// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds
|
||||
/// (comp-sized, as in After Effects), not cut off where the glyphs end.
|
||||
#[test]
|
||||
fn effects_on_text_reach_past_its_box() {
|
||||
let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]);
|
||||
assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box");
|
||||
assert!(smear.img.width < 200, "it grows only as far as pixels move");
|
||||
let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]);
|
||||
assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9);
|
||||
assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds");
|
||||
assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1);
|
||||
// A footage layer's buffer is its bounds already: no change.
|
||||
let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]);
|
||||
assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_warp_style_runs_and_bends() {
|
||||
let im = img(40, 30);
|
||||
for s in 0..WARP_STYLES.len() as u32 {
|
||||
for axis in 0..2 {
|
||||
let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(s)), ("warpAxis", Value::Enum(axis)), ("bend", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[0] as u32;
|
||||
// The layer centre's neighbourhood is still made of source pixels.
|
||||
assert!(o.img.get(pad as i64 + 20, pad as i64 + 15)[3] > 0.5, "style {s}");
|
||||
assert!(o.img.width > im.width);
|
||||
}
|
||||
}
|
||||
// Arc with positive bend raises the middle of the layer: the top edge in the middle
|
||||
// moves up (opaque pixels above the original top).
|
||||
let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(3)), ("bend", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[1] as i64;
|
||||
assert!(o.img.get(o.offset[0] as i64 + 20, pad - 3)[3] > 0.5);
|
||||
assert!(o.img.get(o.offset[0] as i64 + 1, pad - 3)[3] < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_pin_moves_corners() {
|
||||
let im = img(20, 20);
|
||||
// Pin to the right half: output x in 10..20 shows the whole layer squeezed.
|
||||
let set = [("topLeft", pt(10.0, 0.0)), ("bottomLeft", pt(10.0, 20.0))];
|
||||
let o = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
|
||||
assert_eq!(o.img.get(3, 10)[3], 0.0);
|
||||
let px = o.img.get(15, 10);
|
||||
assert!((px[0] - 0.5).abs() < 0.1, "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_pin_unstretch_expands_pinned_region() {
|
||||
let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]);
|
||||
let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))];
|
||||
let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count();
|
||||
let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]);
|
||||
assert_eq!(opaque(&off), 96);
|
||||
let mut set = inset.to_vec();
|
||||
set.push(("unstretch", Value::Bool(true)));
|
||||
let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
|
||||
assert_eq!(opaque(&on), 24 * 16);
|
||||
assert!(on.img.get(0, 0)[3] > 0.99);
|
||||
// Default corners are the identity either way.
|
||||
let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]);
|
||||
assert!(maxd(&id.img, &im) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_opens_a_gap() {
|
||||
let im = img(40, 30);
|
||||
let o = run("ec.distort.ccsplit", &[("split", num(40.0))], im.clone(), &[]);
|
||||
assert_eq!(o.img.get(20, 15)[3], 0.0);
|
||||
assert_eq!(o.img.get(2, 2), im.get(2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slant_shears_from_floor() {
|
||||
let im = img(20, 20);
|
||||
let o = run("ec.distort.ccslant", &[("slant", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[0] as i64;
|
||||
// Bottom row stays put; the top row shifts right by ~10 px.
|
||||
assert!((o.img.get(pad + 5, pad + 19)[0] - im.get(5, 19)[0]).abs() < 0.05);
|
||||
assert!((o.img.get(pad + 15, pad)[0] - im.get(5, 0)[0]).abs() < 0.08);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_moves_source_to_destination() {
|
||||
// A bright square at the source mask; reshape to a shifted destination mask.
|
||||
let im = crate::util::gen_image(40, 40, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] });
|
||||
let masks = [rect_mask(10.0, 10.0, 20.0, 20.0), rect_mask(16.0, 10.0, 26.0, 20.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
|
||||
let o = run("ec.distort.reshape", &[("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))], im, &masks);
|
||||
assert!(o.img.get(24, 15)[0] > 0.8, "{:?}", o.img.get(24, 15));
|
||||
assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_correspondence_points_steer_the_match() {
|
||||
// Matching outlines with a zero pair change nothing; a quarter-turn offset twists.
|
||||
let im = img(40, 40);
|
||||
let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
|
||||
let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))];
|
||||
let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks);
|
||||
assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change");
|
||||
let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks);
|
||||
assert!(maxd(&twisted.img, &im) > 0.1);
|
||||
// Fraction mapping.
|
||||
assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9);
|
||||
assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9);
|
||||
assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscale_grows_about_centre() {
|
||||
let im = img(20, 10);
|
||||
let o = run("ec.distort.upscale", &[("scale", num(200.0)), ("detail", num(0.0))], im.clone(), &[]);
|
||||
assert_eq!((o.img.width, o.img.height), (40, 20));
|
||||
// Grown about the layer centre: layer point (0,0) now sits at buffer pixel (10,5), so the
|
||||
// centre (10,5) lands in the middle of the 40×20 buffer.
|
||||
assert_eq!(o.offset, [10.0, 5.0]);
|
||||
let c = o.img.sample_bilinear(20.0, 10.0);
|
||||
assert!((c[0] - im.sample_bilinear(10.0, 5.0)[0]).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twirl_legacy_rotates_inside_radius() {
|
||||
let im = img(40, 40);
|
||||
let o = run("ec.distort.twirllegacy", &[("angle", num(180.0)), ("radius", num(100.0))], im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) > 0.1);
|
||||
// Corners lie outside the radius.
|
||||
assert_eq!(o.img.get(0, 0), im.get(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_resolution_matches_scaled_geometry() {
|
||||
// Bender at scale 0.5: the buffer is half size, parameters in layer pixels.
|
||||
let s = crate::find("ec.distort.ccbender").unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
params.values.insert("amount".into(), num(30.0));
|
||||
params.values.insert("top".into(), pt(20.0, 0.0));
|
||||
params.values.insert("base".into(), pt(20.0, 40.0));
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: Default::default() };
|
||||
let full = crate::apply(s, &ctx, Buf { img: img(40, 40), offset: [0.0; 2], scale: 1.0 });
|
||||
let half = crate::apply(s, &ctx, Buf { img: effectcraft_raster::resample(&img(40, 40), 20, 20), offset: [0.0; 2], scale: 0.5 });
|
||||
let a = full.img.sample_bilinear(20.0, 4.0)[3];
|
||||
let b = half.img.sample_bilinear(10.0, 2.0)[3];
|
||||
assert!((a - b).abs() < 0.3);
|
||||
}
|
||||
Reference in New Issue
Block a user