From 7051fc1f588ba98394545d875cb065fd0620bf7c Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Fri, 6 Nov 2026 17:36:48 +0100 Subject: [PATCH] Color Stabilizer and Linear Wipe: match After Effects (sample positions, feather) (#3135) * Color Stabilizer: sample the reference where the points were at the reference time (#3125) After Effects 2026 samples the reference frame at the positions the Black / Mid / White Points had at the reference time. With keyframed points it therefore corrects even a static picture: our importer sample (colstab-43-keys, Levels, Black Point (64, 90) -> (151, 99) over 1 s) renders at 0.5 s as the Levels map out = a + b*v of the ramp with b 1.2935..1.2965 and a -0.2365..-0.2350, which is the Black Point's current sample (107.5, 94.5) sent to the reference sample at (64, 90). EffectCraft sampled both frames at the current points, so the card stayed unchanged (slope 1). The samples now read the points at the reference time through the host (params_at), falling back to the current points without one. CPU and GPU share the sampler (color_stabilizer_maps). Sample Size's unit is not pinned by the samples (every size 1..40 leaves the card unchanged in After Effects; that only bounds the sampled area) and is left as it is. Part of #3125 Co-Authored-By: Claude Opus 5.5 * Linear Wipe: After Effects' edge position and feather profile (#3125) Measured on our importer samples (linwipe-*, 2026-11-05, After Effects 2026, 320 x 180): - The edge sits at lo + completion * (hi - lo) whatever the feather (the set-all render's 50 % line is at -5.25, predicted -5.266); 0 % keeps the layer and 100 % clears it even with Feather 32000. EffectCraft extended the travel by the feather, 13 px off at Feather 16. - The feathered edge is the hard matte blurred with the layer's edges repeated: three box passes of radius 6 per axis fit Feather 16 (sigma 6.35..6.6, taken as Feather / 2.5), including the extra opacity where the edge meets the layer's left side. EffectCraft drew a linear ramp Feather px wide. - Without feather the edge pixel is anti-aliased (143/255; coverage gives 0.6). EffectCraft drew a hard step. set-all alpha: before max 195, 4646 px (8.1 %) off by more than 2/255; after max 15, 529 px (0.9 %), all along the layer's left side (at most 2 elsewhere). Feather 0 renders: max 112 -> 10 on the edge column. CPU: coverage matte, raster::gaussian_blur (3 box passes, edges repeated); GPU: the same matte from gen_linear_wipe (new modes), its gaussian_blur, then a multiply, so both match. Sigma is capped at twice the layer's longer side; NaN values leave the layer as it is. Projects saved before keep the old ramp: a hidden legacyFeather parameter that migrate::legacy_default sets on upgrade (as for Ripple and Block Dissolve). Part of #3125 Co-Authored-By: Claude Opus 5.5 * Linear Wipe on the GPU: 100 % clears the layer before the angle is read A NaN angle left the layer unchanged on the GPU while the CPU cleared it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/effects/src/lib.rs | 4 +- crates/effects/src/migrate.rs | 14 +++ crates/effects/src/misc.rs | 151 ++++++++++++++++++++++-- crates/effects/src/ocio/effects.rs | 40 +++++-- crates/effects/src/ocio/tests.rs | 77 ++++++++++++ crates/gpu/src/fx_generate.rs | 64 +++++++--- crates/gpu/src/shaders/fx_generate.wgsl | 13 +- crates/gpu/src/tests/fx_generate.rs | 7 ++ 8 files changed, 331 insertions(+), 39 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e2987197d..d732f160b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -88,8 +88,8 @@ use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; pub use misc::{ - GLOW_GAIN, GLOW_KNEE, INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, radial_wipe_sigma, radial_wipe_wedge, venetian_cover, - venetian_origin, + GLOW_GAIN, GLOW_KNEE, INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, linear_wipe_axis, linear_wipe_sigma, radial_wipe_sigma, + radial_wipe_wedge, venetian_cover, venetian_origin, }; pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 171e5249e..59daca49d 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -161,6 +161,8 @@ pub fn legacy_default(effect: &str, param: &str) -> Option { ("ec.key.advancedspill", "ultraSettings/tolerance") => Value::Scalar(100.0), // Transition: Block Dissolve's blocks had hard edges. ("ec.transition.blockdissolve", "softEdges") => Value::Bool(false), + // Linear Wipe's feather was a linear ramp with the travel extended by it (#3125). + ("ec.transition.linearwipe", "legacyFeather") => Value::Bool(true), // 3D Channel: Cryptomatte's Matte Only put the matte in alpha too (#3127). ("ec.3d.cryptomatte", crate::channel3d::MATTE_ONLY_IN_ALPHA) => Value::Bool(true), _ => return None, @@ -608,6 +610,18 @@ mod tests { assert_eq!(current_param_id(stab, "other/smoothness"), None); } + /// #3125: a Linear Wipe saved before its feather followed After Effects keeps the old ramp; + /// a new one doesn't. + #[test] + fn old_linear_wipes_keep_the_legacy_feather() { + let mut next = 1; + let (spec, mut g) = instance("ec.transition.linearwipe", &mut next); + assert_eq!(g.get("legacyFeather").unwrap().value, Value::Bool(false)); + take_prop(&mut g, "legacyFeather"); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), SIZE)); + assert_eq!(g.get("legacyFeather").unwrap().value, Value::Bool(true)); + } + #[test] fn card_wipe_back_layer_popup_becomes_a_layer_parameter() { let spec = find("ec.transition.cardwipe").unwrap(); diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 15d5d0aac..65951ad97 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -538,16 +538,83 @@ fn noise(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -fn linear_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { - let done = ctx.params.f("completion") / 100.0; - let ang = ctx.params.f("angle").to_radians(); - let feather = (ctx.params.f("feather") * b.scale).max(0.001); - let (w, h) = (b.img.width as f64, b.img.height as f64); - // Direction the wipe travels: angle 90° wipes left→right. +/// Linear Wipe's geometry: the direction the wipe travels (angle 90° wipes left → right) and the +/// range of `x·dx + y·dy` over a `w` × `h` buffer's corners. +pub fn linear_wipe_axis(angle_deg: f64, w: f64, h: f64) -> ([f64; 2], f64, f64) { + let ang = angle_deg.to_radians(); let (dx, dy) = (ang.sin(), -ang.cos()); let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; - let proj: Vec = corners.iter().map(|(x, y)| x * dx + y * dy).collect(); - let (lo, hi) = (proj.iter().cloned().fold(f64::INFINITY, f64::min), proj.iter().cloned().fold(f64::NEG_INFINITY, f64::max)); + let proj = corners.map(|(x, y)| x * dx + y * dy); + let (lo, hi) = (proj.iter().copied().fold(f64::INFINITY, f64::min), proj.iter().copied().fold(f64::NEG_INFINITY, f64::max)); + ([dx, dy], lo, hi) +} + +/// Linear Wipe's Feather (`feather` pixels at preview `scale`) as the Gaussian sigma that +/// softens the edge of a `w` × `h` buffer. After Effects' feathered edge is the hard wipe +/// blurred with the layer's edges repeated; our importer sample (Feather 16 at 77°, 2026-11-05) +/// matches three box passes of radius 6 per axis, sigma 6.35..6.6, so sigma = Feather / 2.5 +/// (pinned at Feather 16 only, #3125). Capped at twice the buffer's longer side, past which +/// a blur with repeated edges no longer changes and the running sums only cost time. +pub fn linear_wipe_sigma(feather: f64, scale: f64, w: f64, h: f64) -> f64 { + let s = feather * scale / 2.5; + if s.is_finite() && s > 0.0 { s.min(2.0 * w.max(h).max(1.0)) } else { 0.0 } +} + +fn linear_wipe(ctx: &EffectCtx, b: Buf) -> Buf { + if ctx.params.b("legacyFeather") { + return linear_wipe_legacy(ctx, b); + } + linear_wipe_blurred(ctx, b) +} + +/// After Effects' Linear Wipe (#3125): the edge sits at `lo + completion·(hi − lo)` whatever the +/// feather (0 % keeps the layer, 100 % clears it), anti-aliased by pixel coverage, and Feather +/// blurs that hard matte with the layer's edges repeated (so the feather leans opaque where it +/// meets the layer's sides, as After Effects' does). +fn linear_wipe_blurred(ctx: &EffectCtx, mut b: Buf) -> Buf { + let done = ctx.params.f("completion") / 100.0; + if done.is_nan() || done <= 0.0 { + return b; + } + if done >= 1.0 { + b.img.data.iter_mut().for_each(|px| *px = [0.0; 4]); + return b; + } + let (w, h) = (b.img.width as f64, b.img.height as f64); + let ([dx, dy], lo, hi) = linear_wipe_axis(ctx.params.f("angle"), w, h); + let edge = lo + (hi - lo) * done; + if !(edge.is_finite() && dx.is_finite() && dy.is_finite()) { + return b; + } + let mut mask = Image::new(b.img.width, b.img.height); + mask.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let d = (x as f64 + 0.5) * dx + (y as f64 + 0.5) * dy; + *px = [((d - edge) + 0.5).clamp(0.0, 1.0) as f32; 4]; + } + }); + let sigma = linear_wipe_sigma(ctx.params.f("feather"), b.scale, w, h); + // Three box passes per axis (the measured shape; the GPU runs the same passes). Below + // Feather ~2 they leave the anti-aliased edge alone. + if sigma > 1e-3 { + mask = gaussian_blur(&mask, sigma, sigma, true); + } + for (px, m) in b.img.data.iter_mut().zip(mask.data.iter()) { + let k = m[3].clamp(0.0, 1.0); + for c in px.iter_mut() { + *c *= k; + } + } + b +} + +/// Linear Wipe as EffectCraft drew it before #3125 (projects saved then keep it): a linear ramp +/// Feather pixels wide, the travel extended by the feather at both ends. +fn linear_wipe_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf { + let done = ctx.params.f("completion") / 100.0; + let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let (w, h) = (b.img.width as f64, b.img.height as f64); + let ([dx, dy], lo, hi) = linear_wipe_axis(ctx.params.f("angle"), w, h); let edge = lo - feather + (hi - lo + 2.0 * feather) * done; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { @@ -838,6 +905,8 @@ pub fn specs() -> Vec { p("completion", "Transition Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)), p("angle", "Wipe Angle", num(90.0), ParamUi::Angle), p("feather", "Feather", num(0.0), slider(0.0, 32000.0, 0.0, 500.0, 1)), + // Projects saved before #3125 keep the earlier linear feather ramp. + p("legacyFeather", "Legacy Feather", Value::Bool(false), ParamUi::Hidden), ], linear_wipe, ), @@ -890,6 +959,72 @@ mod tests { (16..24).map(|x| out.img.get(x, 4)[0]).collect() } + fn wipe(vals: &[(&str, Value)]) -> effectcraft_raster::Image { + let card = effectcraft_raster::Image::filled(320, 180, [1.0, 1.0, 1.0, 1.0]); + run_fx("ec.transition.linearwipe", vals, card, 0.0, EffectEnv::default()).img + } + + /// #3125: After Effects 2026's Linear Wipe on our 320 × 180 importer sample (2026-11-05, + /// `linwipe-01-set-all`): Completion 10, Wipe Angle 77, Feather 16. Alpha (8-bit) of rows + /// 150 and 120 from x = 8, where the feathered edge crosses the layer away from its sides. + #[test] + fn linear_wipe_feather_matches_after_effects() { + const ROW150: [u8; 48] = [ + 0, 0, 0, 0, 0, 1, 2, 3, 6, 9, 12, 17, 23, 31, 39, 49, 61, 73, 86, 100, 115, 129, 144, 158, 172, 185, 198, 208, 218, 226, 233, 239, 244, 247, 250, + 252, 254, 254, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + ]; + const ROW120: [u8; 48] = [ + 3, 5, 8, 12, 17, 23, 30, 39, 49, 60, 72, 85, 99, 114, 128, 143, 157, 171, 184, 197, 208, 217, 226, 233, 239, 243, 247, 250, 252, 253, 254, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + ]; + let vals = [("completion", num(10.0)), ("angle", num(77.0)), ("feather", num(16.0))]; + let out = wipe(&vals); + for (y, row) in [(150, &ROW150), (120, &ROW120)] { + for (i, &ae) in row.iter().enumerate() { + let x = 8 + i as i64; + let a = out.get(x, y)[3] * 255.0; + assert!((a - ae as f32).abs() <= 2.5, "({x}, {y}): {a} vs After Effects {ae}"); + } + } + // Where the edge meets the layer's left side the feather leans opaque, as in After + // Effects (179 at (0, 30); a profile across the edge alone gives about 110). + assert!(out.get(0, 30)[3] * 255.0 > 160.0, "{}", out.get(0, 30)[3] * 255.0); + // Projects saved before keep the old linear ramp, which put the edge 13 px further left. + let legacy = wipe(&[vals.as_slice(), &[("legacyFeather", Value::Bool(true))]].concat()); + assert!((legacy.get(23, 150)[3] * 255.0 - 129.0).abs() > 100.0); + } + + /// #3125: without feather After Effects' edge sits at Completion × the layer's extent along + /// the wipe (9.5 % of 320 px = 30.4, `linwipe-04-keys`; 22 % = 70.4, `linwipe-05-expr`); the + /// edge pixel is anti-aliased (After Effects 143 / 255, coverage gives 0.6). 0 % keeps the + /// layer and 100 % clears it whatever the feather (`linwipe-02-min`, `linwipe-03-max`). + #[test] + fn linear_wipe_edge_follows_completion() { + for (c, col) in [(9.5, 30), (22.0, 70)] { + let out = wipe(&[("completion", num(c)), ("angle", num(90.0))]); + for y in [0, 90, 179] { + assert_eq!(out.get(col - 1, y)[3], 0.0, "{c}"); + assert_eq!(out.get(col + 1, y)[3], 1.0, "{c}"); + assert!((out.get(col, y)[3] - 143.0 / 255.0).abs() < 0.05, "{c}: {}", out.get(col, y)[3]); + } + } + let max = wipe(&[("completion", num(100.0)), ("feather", num(32000.0))]); + assert!(max.data.iter().all(|p| p[3] == 0.0)); + let min = wipe(&[("completion", num(0.0)), ("feather", num(32000.0))]); + assert!(min.data.iter().all(|p| p[3] == 1.0)); + } + + /// Hostile values: a huge feather is capped (no slow or oversized blur), NaN does not spread. + #[test] + fn linear_wipe_survives_hostile_values() { + let out = wipe(&[("completion", num(50.0)), ("feather", num(1e12))]); + assert!(out.data.iter().all(|p| p[3].is_finite())); + let out = wipe(&[("completion", num(50.0)), ("angle", num(f64::NAN)), ("feather", num(f64::NAN))]); + assert!(out.data.iter().all(|p| p[3].is_finite())); + let out = wipe(&[("completion", num(f64::NAN))]); + assert!(out.data.iter().all(|p| p[3] == 1.0)); + } + #[test] fn small_gaussian_blurriness_is_continuous() { // #1939: 0.9 used to be identical to 0, and 1.2 / 1.6 / 2.0 identical to each other. diff --git a/crates/effects/src/ocio/effects.rs b/crates/effects/src/ocio/effects.rs index 5df24096d..0eb7bd330 100644 --- a/crates/effects/src/ocio/effects.rs +++ b/crates/effects/src/ocio/effects.rs @@ -398,15 +398,38 @@ fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 { a.1 + (c.1 - a.1) * (v - a.0) / (c.0 - a.0).max(1e-9) } +/// The Black, Mid and White Point parameters. +const STABILIZER_POINTS: [&str; 3] = ["blackPoint", "midPoint", "whitePoint"]; + +/// Color Stabilizer's samples: the current frame's means at the points' current positions and +/// the reference frame's means at the positions the points had at the reference time (After +/// Effects samples the reference where the points were when it was set: with keyframed points a +/// static picture is corrected, #3125). The points fall back to their current positions when +/// the host can't evaluate them at the reference time. `None` without a host or reference frame. +fn stabilizer_samples(ctx: &EffectCtx, b: &Buf) -> Option<([[f64; 3]; 3], [[f64; 3]; 3])> { + let host = ctx.env.host?; + let t_ref = ctx.params.f("referenceFrame"); + let reference = host.self_at(t_ref, ctx.env.effect_index)?; + let r = ctx.params.f("sampleSize").max(0.5); + let pts = STABILIZER_POINTS.map(|id| ctx.params.v2(id)); + let ref_params = host.params_at(t_ref); + let ref_pts = match &ref_params { + Some(p) => STABILIZER_POINTS.map(|id| p.v2(id)), + None => pts, + }; + let cur = pts.map(|pt| sample_mean(b, pt, r)); + let mut refv = [[0.0; 3]; 3]; + for (o, pt) in refv.iter_mut().zip(ref_pts) { + *o = sample_mean(&reference, pt, r); + } + Some((cur, refv)) +} + /// Color Stabilizer's correction of buffer `b` as one piecewise-linear map per channel (points /// with increasing x; the end segments extend), the same maps `color_stabilizer` applies. /// `None` without a host or reference frame (the layer passes through). (The GPU effect.) pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64)>; 3]> { - let reference = ctx.env.host?.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index)?; - let r = ctx.params.f("sampleSize").max(0.5); - let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")]; - let cur = pts.map(|pt| sample_mean(b, pt, r)); - let refv = pts.map(|pt| sample_mean(&reference, pt, r)); + let (cur, refv) = stabilizer_samples(ctx, b)?; let mode = ctx.params.e("stabilize"); Some([0, 1, 2].map(|k| { let offset = |c0: f64, r0: f64| vec![(0.0, r0 - c0), (1.0, 1.0 + r0 - c0)]; @@ -439,12 +462,7 @@ pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64) } pub(super) fn color_stabilizer(ctx: &EffectCtx, mut b: Buf) -> Buf { - let Some(host) = ctx.env.host else { return b }; - let Some(reference) = host.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index) else { return b }; - let r = ctx.params.f("sampleSize").max(0.5); - let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")]; - let cur = pts.map(|pt| sample_mean(&b, pt, r)); - let refv = pts.map(|pt| sample_mean(&reference, pt, r)); + let Some((cur, refv)) = stabilizer_samples(ctx, &b) else { return b }; let mode = ctx.params.e("stabilize"); map_px(&mut b, |c| { [0, 1, 2].map(|k| { diff --git a/crates/effects/src/ocio/tests.rs b/crates/effects/src/ocio/tests.rs index 2df2320e6..fe93dd25a 100644 --- a/crates/effects/src/ocio/tests.rs +++ b/crates/effects/src/ocio/tests.rs @@ -319,3 +319,80 @@ fn color_stabilizer_matches_the_reference_frame() { let b = run_fx("ec.color.colorstabilizer", &vals2, lifted, 0.0, env); assert!((b.img.get(5, 20)[0] - reference.get(5, 20)[0]).abs() < 0.01); } + +/// After Effects 2026's 320 × 180 test card from our importer samples (2026-11-05): a black to +/// white ramp across, six 40 px colour squares at y 45 and a white 30 px square moving from +/// x 20 to 300 at y 130 over 2 s. +fn stabilizer_card(t: f64) -> Image { + let mut img = Image::new(320, 180); + let cols = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 1.0, 0.0], [0.0, 1.0, 1.0], [1.0, 0.0, 1.0]]; + let mover = 20.0 + 140.0 * t; + for y in 0..180 { + for x in 0..320 { + let (cx, cy) = (x as f64 + 0.5, y as f64 + 0.5); + let mut c = [(cx / 320.0) as f32; 3]; + for (i, col) in cols.iter().enumerate() { + if (cx - (30.0 + 52.0 * i as f64)).abs() < 20.0 && (cy - 45.0).abs() < 20.0 { + c = col.map(|v| v as f32); + } + } + if (cx - mover).abs() < 15.0 && (cy - 130.0).abs() < 15.0 { + c = [1.0; 3]; + } + img.set(x, y, [c[0], c[1], c[2], 1.0]); + } + } + img +} + +/// The sample's keyframed points: Black Point (64, 90) → (151, 99) and Mid Point (160, 90) → +/// (188, 122) over the first second, White Point at (256, 90), Stabilize Levels. +struct KeyedCard; +impl EffectHost for KeyedCard { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + Some(Buf { img: stabilizer_card(t), offset: [0.0; 2], scale: 1.0 }) + } + fn params_at(&self, t: f64) -> Option { + let mut p = crate::Params { values: Default::default() }; + let k = t.clamp(0.0, 1.0); + let lerp = |a: [f64; 2], b: [f64; 2]| Value::Vec2([a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]); + p.values.insert("blackPoint".into(), lerp([64.0, 90.0], [151.0, 99.0])); + p.values.insert("midPoint".into(), lerp([160.0, 90.0], [188.0, 122.0])); + p.values.insert("whitePoint".into(), Value::Vec2([256.0, 90.0])); + Some(p) + } +} + +/// #3125: with keyframed sample points After Effects samples the reference frame where the +/// points were at the reference time, so a static card is corrected. Its render at 0.5 s (Black +/// Point at (107.5, 94.5)) is a Levels map of the ramp: `out = a + b·v` with b 1.2935..1.2965 +/// and a −0.2365..−0.2350 (fitted on our 26-level export). EffectCraft sampled both frames at +/// the current points and left the card unchanged. +#[test] +fn color_stabilizer_samples_the_reference_at_the_points_reference_positions() { + let host = KeyedCard; + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let p = host.params_at(0.5).unwrap(); + let mut vals: Vec<(&str, Value)> = ["blackPoint", "midPoint", "whitePoint"].iter().map(|id| (*id, p.values[*id].clone())).collect(); + vals.push(("stabilize", Value::Enum(1))); + vals.push(("sampleSize", num(3.0))); + vals.push(("referenceFrame", num(0.0))); + let out = run_fx("ec.color.colorstabilizer", &vals, stabilizer_card(0.5), 0.5, env); + // The ramp row between the squares: fit out = a + b·v from two far-apart pixels. + let v = |x: i64| (x as f64 + 0.5) / 320.0; + let o = |x: i64| out.img.get(x, 90)[0] as f64; + let b = (o(240) - o(80)) / (v(240) - v(80)); + let a = o(80) - b * v(80); + assert!((b - 1.295).abs() < 0.005, "slope {b}"); + assert!((a + 0.2357).abs() < 0.005, "offset {a}"); + // Every ramp pixel of that row follows the line (no clamping in 32 bpc). + for x in (0..320).step_by(7) { + assert!((o(x) - (a + b * v(x))).abs() < 1e-3, "x {x}"); + } +} diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index d0c96a029..c6f2d1def 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -183,27 +183,57 @@ fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { // ---------------------------------------------------------------- transitions -fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { +/// Linear Wipe (`effects::misc::linear_wipe`): the hard matte at `lo + completion·(hi − lo)` +/// blurred by Feather with the layer's edges repeated, or the legacy linear ramp. +fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let done = ctx.params.f("completion") / 100.0; - let ang = ctx.params.f("angle").to_radians(); - let feather = (ctx.params.f("feather") * b.scale).max(0.001); let (w, h) = (b.img.width as f64, b.img.height as f64); - let (dx, dy) = (ang.sin(), -ang.cos()); - let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; - let proj: Vec = corners.iter().map(|(x, y)| x * dx + y * dy).collect(); - let (lo, hi) = (proj.iter().cloned().fold(f64::INFINITY, f64::min), proj.iter().cloned().fold(f64::NEG_INFINITY, f64::max)); - let edge = lo - feather + (hi - lo + 2.0 * feather) * done; - let mut data = Vec::with_capacity(2 * (b.img.width + b.img.height) as usize); - for x in 0..b.img.width { - data.extend(hl((x as f64 + 0.5) * dx - edge)); + let ([dx, dy], lo, hi) = effectcraft_effects::linear_wipe_axis(ctx.params.f("angle"), w, h); + // Per column ((x + 0.5)·dx − edge), per row ((y + 0.5)·dy), as (hi, lo) pairs. + let table = |edge: f64| { + let mut data = Vec::with_capacity(2 * (b.img.width + b.img.height) as usize); + for x in 0..b.img.width { + data.extend(hl((x as f64 + 0.5) * dx - edge)); + } + for y in 0..b.img.height { + data.extend(hl((y as f64 + 0.5) * dy)); + } + data + }; + let params = |width: f64, mode: u32| { + let mut p = Params::default(); + p.u[0][0] = 2 * b.img.width; + p.u[0][1] = mode; + p.f[0][0] = width as f32; + p + }; + if ctx.params.b("legacyFeather") { + let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let data = table(lo - feather + (hi - lo + 2.0 * feather) * done); + return run(e, "gen_linear_wipe", ¶ms(feather, 0), b, None, Some(&data)); } - for y in 0..b.img.height { - data.extend(hl((y as f64 + 0.5) * dy)); + if done.is_nan() || done <= 0.0 { + return Some(b); } - let mut p = Params::default(); - p.u[0][0] = 2 * b.img.width; - p.f[0][0] = feather as f32; - run(e, "gen_linear_wipe", &p, b, None, Some(&data)) + // 100 % clears the layer whatever the angle (the CPU clears before it reads it). + if done >= 1.0 { + b.img = e.image(b.img.width, b.img.height); + return Some(b); + } + let edge = lo + (hi - lo) * done; + if !(edge.is_finite() && dx.is_finite() && dy.is_finite()) { + return Some(b); + } + let data = table(edge); + let sigma = effectcraft_effects::linear_wipe_sigma(ctx.params.f("feather"), b.scale, w, h); + if sigma <= 1e-3 { + return run(e, "gen_linear_wipe", ¶ms(1.0, 0), b, None, Some(&data)); + } + let buf = e.data(&data); + let matte = e.scratch(b.img.width, b.img.height); + e.pixels("gen_linear_wipe", ¶ms(1.0, 1), &b.img, None, &matte, Some(&buf)); + let blurred = gaussian_blur(e, &matte, sigma, sigma, true); + run(e, "gen_linear_wipe", ¶ms(1.0, 2), b, Some(&blurred), Some(&data)) } fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index 57d1c89e3..7a2a026bd 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -234,7 +234,9 @@ fn gen_lens_blur(@builtin(global_invocation_id) gid: vec3) { // ---------------------------------------------------------------- wipes // Linear Wipe. data = per column ((x + 0.5)·dx − edge) (hi, lo), then per row ((y + 0.5)·dy) -// (hi, lo); u[0].x = row table offset; f[0].x = feather. +// (hi, lo); u[0] = (row table offset, mode); f[0].x = ramp width (the legacy feather, 1 = pixel +// coverage). Mode 0 multiplies the layer by the ramp, 1 writes the ramp itself (the hard matte +// Feather blurs), 2 multiplies the layer by aux's alpha (the blurred matte). @compute @workgroup_size(16, 16) fn gen_linear_wipe(@builtin(global_invocation_id) gid: vec3) { let dims = out_dims(); @@ -242,8 +244,17 @@ fn gen_linear_wipe(@builtin(global_invocation_id) gid: vec3) { if (p.x >= dims.x || p.y >= dims.y) { return; } + if (P.u[0].y == 2u) { + let m = clamp(textureLoad(aux, p, 0).w, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * m); + return; + } let d = gen_sum(gen_hl(2u * u32(p.x)), gen_hl(P.u[0].x + 2u * u32(p.y))); let k = clamp(d / P.f[0].x + 0.5, 0.0, 1.0); + if (P.u[0].y == 1u) { + textureStore(out, p, vec4(k)); + return; + } textureStore(out, p, textureLoad(src, p, 0) * k); } diff --git a/crates/gpu/src/tests/fx_generate.rs b/crates/gpu/src/tests/fx_generate.rs index ebe2f2bf0..d12920797 100644 --- a/crates/gpu/src/tests/fx_generate.rs +++ b/crates/gpu/src/tests/fx_generate.rs @@ -69,6 +69,13 @@ fn wipes() { effect_case("ec.transition.linearwipe", &[("completion", n(40.0)), ("angle", n(30.0)), ("feather", n(12.0))]); effect_case("ec.transition.linearwipe", &[("completion", n(55.0)), ("angle", n(-120.0))]); effect_case("ec.transition.linearwipe", &[("completion", n(20.0)), ("angle", n(90.0)), ("feather", n(3.0))]); + // #3125: After Effects' feather (blurred matte, edges repeated), 0 % and 100 %, and the legacy ramp. + effect_case("ec.transition.linearwipe", &[("completion", n(10.0)), ("angle", n(77.0)), ("feather", n(16.0))]); + effect_case("ec.transition.linearwipe", &[("completion", n(100.0)), ("feather", n(32000.0))]); + effect_case("ec.transition.linearwipe", &[("completion", n(0.0)), ("feather", n(40.0))]); + // 100 % clears the layer also when the angle is not a number (the CPU clears before reading it). + effect_case("ec.transition.linearwipe", &[("completion", n(100.0)), ("angle", n(f64::NAN))]); + effect_case("ec.transition.linearwipe", &[("completion", n(40.0)), ("angle", n(30.0)), ("feather", n(12.0)), ("legacyFeather", b(true))]); effect_case("ec.transition.venetian", &[("completion", n(35.0)), ("direction", n(30.0)), ("width", n(11.0)), ("feather", n(2.0))]); effect_case("ec.transition.venetian", &[("completion", n(60.0)), ("width", n(17.0))]); effect_case("ec.transition.venetian", &[("completion", n(25.0)), ("direction", n(-70.0)), ("width", n(9.0)), ("feather", n(0.5))]);