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))]);