diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 916e162f6..610f73f4c 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -384,6 +384,18 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf { fn corner_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { let (w, h) = (b.layer_w(ctx), b.layer_h(ctx)); + // The warped layer is drawn wherever the corners are, so grow the buffer to hold the quad; + // an adjustment layer keeps the size of the comp below it. + if !ctx.adjustment { + let pts = ["ul", "ur", "lr", "ll"].map(|k| { + let p = b.to_px(ctx.params.v2(k)); + [p.0, p.1] + }); + let (lo, hi) = crate::quad_bounds(&pts); + if let Some(sides) = crate::grow_sides(lo, hi, b.img.width, b.img.height) { + b.pad_sides(sides); + } + } let q = ["ul", "ur", "lr", "ll"].map(|k| { let p = b.to_px(ctx.params.v2(k)); vec2(p.0, p.1) @@ -542,6 +554,74 @@ mod tests { crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 }) } + #[test] + fn corner_pin_draws_outside_the_layer_rectangle() { + // A 20×10 layer pinned to a quad that lies entirely right of and below it. + let im = crate::util::gen_image(20, 10, |_, _| [1.0, 0.0, 0.0, 1.0]); + let set = [("ul", Value::Vec2([40.0, 30.0])), ("ur", Value::Vec2([60.0, 30.0])), ("lr", Value::Vec2([60.0, 40.0])), ("ll", Value::Vec2([40.0, 40.0]))]; + let o = run("ec.distort.cornerpin", &set, im); + assert!(o.img.width >= 60 && o.img.height >= 40, "{}x{}", o.img.width, o.img.height); + let (cx, cy) = (o.offset[0] as i64 + 50, o.offset[1] as i64 + 35); + assert!(o.img.get(cx, cy)[0] > 0.9); + } + + #[test] + fn corner_pin_keeps_the_size_on_an_adjustment_layer() { + let s = crate::find("ec.distort.cornerpin").unwrap(); + let im = crate::util::gen_image(20, 10, |_, _| [1.0, 0.0, 0.0, 1.0]); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [20.0, 10.0]))).collect() }; + params.values.insert("lr".to_string(), Value::Vec2([400.0, 300.0])); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [20.0, 10.0], seed: 3, adjustment: true, env: Default::default() }; + let o = crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 }); + assert_eq!((o.img.width, o.img.height, o.offset), (20, 10, [0.0, 0.0])); + } + + #[test] + fn corner_pin_with_a_far_pin_grows_to_it() { + let im = crate::util::gen_image(20, 10, |_, _| [1.0, 0.0, 0.0, 1.0]); + let o = run("ec.distort.cornerpin", &[("lr", Value::Vec2([1500.0, 1000.0]))], im); + assert!(o.img.width >= 1500 && o.img.height >= 1000, "{}x{}", o.img.width, o.img.height); + } + + #[test] + fn corner_pin_with_a_non_finite_pin_returns_a_buffer_without_panicking() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for corner in ["ul", "ur", "lr", "ll"] { + let im = crate::util::gen_image(20, 10, |_, _| [1.0, 0.0, 0.0, 1.0]); + let o = run("ec.distort.cornerpin", &[(corner, Value::Vec2([bad, 5.0]))], im); + assert!( + o.img.width >= 1 && o.img.height >= 1 && o.img.width <= 16384 && o.img.height <= 16384, + "{corner} {bad}: {}x{}", + o.img.width, + o.img.height + ); + assert!(o.offset.iter().all(|v| v.is_finite()), "{corner} {bad}: offset {:?}", o.offset); + } + } + } + + #[test] + fn corner_pin_growth_is_continuous_as_a_pin_moves_far_away() { + let mut last: Option = None; + for k in 0..60 { + let far = 100.0 * 1.4_f64.powi(k); + let im = crate::util::gen_image(20, 10, |_, _| [1.0, 0.0, 0.0, 1.0]); + let o = run("ec.distort.cornerpin", &[("lr", Value::Vec2([far, far]))], im); + let size = (o.img.width, o.img.height); + assert!(size.0 >= 20 && size.1 >= 10 && u64::from(size.0) * u64::from(size.1) <= 1 << 24, "pin {far}: {size:?}"); + let area = u64::from(size.0) * u64::from(size.1); + if let Some(prev) = last { + // The shared cut factor may trade width for height as the pin direction changes, + // but the buffer as a whole does not shrink (beyond whole-pixel flooring at the budget) + // as the pin moves further away. + assert!(area * 10_000 >= prev * 9_999, "pin {far}: the buffer shrank from {prev} to {area} pixels ({size:?})"); + } + let opaque = (0..o.img.height as i64).any(|y| (0..o.img.width as i64).any(|x| o.img.get(x, y)[3] > 0.0)); + assert!(opaque, "pin {far}: the pinned layer vanished"); + last = Some(area); + } + } + fn differs(a: Px4, b: Px4) -> bool { (0..4).any(|i| (a[i] - b[i]).abs() > 1e-4) } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 0138cd9ca..da46836af 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -338,13 +338,19 @@ impl Buf { } } -/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within -/// 16 Mpx (256 MiB of f32 RGBA) or four times its own area, whichever is larger: a small layer -/// far from an effect's centre can't ask for gigabytes (#2261). Shared with the GPU path. -pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 { +/// The most pixels a `w` × `h` effect buffer may grow to: 16 Mpx (256 MiB of f32 RGBA) or four +/// times its own area, whichever is larger. Shared by every effect that grows its buffer. +fn pixel_budget(w: f64, h: f64) -> f64 { const MIN_BUDGET: f64 = 16_777_216.0; + MIN_BUDGET.max(4.0 * w * h) +} + +/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within +/// [`pixel_budget`]: a small layer far from an effect's centre can't ask for gigabytes (#2261). +/// Shared with the GPU path. +pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 { let (w, h) = (f64::from(w), f64::from(h)); - let budget = MIN_BUDGET.max(4.0 * w * h); + let budget = pixel_budget(w, h); if (w + 2.0 * f64::from(pad)) * (h + 2.0 * f64::from(pad)) <= budget { return pad; } @@ -353,6 +359,51 @@ pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 { (p.floor().max(0.0) as u32).min(pad) } +/// The most pixels on a side that [`grow_sides`] grows a buffer to. +const MAX_GROWN_SIDE: f64 = 16384.0; + +/// The transparent pixels to add (left, top, right, bottom; for [`Buf::pad_sides`]) so a +/// `width` × `height` buffer also spans the pixel box `lo`..`hi`, in the buffer's own coordinates. +/// When the full growth would pass [`MAX_GROWN_SIDE`] or the [`pixel_budget`], every side is cut +/// by the same factor instead of giving up, so the picture shrinks smoothly rather than popping. +/// `None` when the buffer already spans the box, or the box is not finite. +pub fn grow_sides(lo: [f64; 2], hi: [f64; 2], width: u32, height: u32) -> Option<[u32; 4]> { + if !lo.iter().chain(hi.iter()).all(|v| v.is_finite()) { + return None; + } + let (w, h) = (f64::from(width), f64::from(height)); + let want = |v: f64| v.clamp(0.0, MAX_GROWN_SIDE); + let [l, t] = [want(-lo[0].floor()), want(-lo[1].floor())]; + let [r, b] = [want(hi[0].ceil() - w), want(hi[1].ceil() - h)]; + let (sx, sy) = (l + r, t + b); + if sx == 0.0 && sy == 0.0 { + return None; + } + let mut f = 1.0_f64; + if sx > 0.0 { + f = f.min((MAX_GROWN_SIDE - w).max(0.0) / sx); + } + if sy > 0.0 { + f = f.min((MAX_GROWN_SIDE - h).max(0.0) / sy); + } + let budget = pixel_budget(w, h); + if (w + sx * f) * (h + sy * f) > budget { + // (w + sx·f)(h + sy·f) = budget, solved for f. + let (a, bq, c) = (sx * sy, w * sy + h * sx, w * h - budget); + let q = if a > 0.0 { (-bq + (bq * bq - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a) } else { -c / bq }; + f = f.min(q); + } + let f = if f.is_finite() { f.clamp(0.0, 1.0) } else { 0.0 }; + let sides = [l, t, r, b].map(|v| (v * f).floor() as u32); + sides.iter().any(|&v| v != 0).then_some(sides) +} + +/// The pixel box (min, max) enclosing the four points of a quad. +pub fn quad_bounds(q: &[[f64; 2]; 4]) -> ([f64; 2], [f64; 2]) { + let f = |i: usize, init: f64, g: fn(f64, f64) -> f64| q.iter().fold(init, |a, p| g(a, p[i])); + ([f(0, f64::INFINITY, f64::min), f(1, f64::INFINITY, f64::min)], [f(0, f64::NEG_INFINITY, f64::max), f(1, f64::NEG_INFINITY, f64::max)]) +} + /// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`: /// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its /// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp @@ -1416,6 +1467,30 @@ pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, en mod tests { use super::*; + /// #1938: Corner Pin's growth spans the pinned quad, and past the side cap or the pixel + /// budget it is cut by one factor instead of being dropped. + #[test] + fn grow_sides_spans_the_box_and_clamps_instead_of_giving_up() { + let area = |w: u32, h: u32, s: [u32; 4]| (u64::from(w) + u64::from(s[0]) + u64::from(s[2])) * (u64::from(h) + u64::from(s[1]) + u64::from(s[3])); + assert_eq!(grow_sides([-3.5, 2.0], [30.0, 40.2], 20, 10), Some([4, 0, 10, 31])); + assert_eq!(grow_sides([1.0, 1.0], [19.0, 9.0], 20, 10), None, "already inside"); + assert_eq!(grow_sides([f64::NAN, 0.0], [30.0, 40.0], 20, 10), None); + assert_eq!(grow_sides([0.0, 0.0], [f64::INFINITY, 40.0], 20, 10), None); + // A pin near (16000, 16000) on a small layer: cut to the budget, still growing, in the same ratio. + let s = grow_sides([0.0, 0.0], [16000.0, 16000.0], 20, 10).unwrap(); + assert!(s[2] > 1000 && s[3] > 1000 && area(20, 10, s) <= 1 << 24, "{s:?}"); + // An absurdly far pin is still bounded and still grows. + let s = grow_sides([-1e12, -1e12], [1e12, 1e12], 1920, 1080).unwrap(); + assert!(s.iter().all(|&v| v > 0) && area(1920, 1080, s) <= 1 << 24, "{s:?}"); + assert!(1920 + u64::from(s[0]) + u64::from(s[2]) <= 16384); + // A layer already past the side cap does not grow. + assert_eq!(grow_sides([0.0, 0.0], [20000.0, 100.0], 17000, 50), None); + // A degenerate quad (all four points equal) grows to its point only. + let q = [[50.0, 30.0]; 4]; + let (lo, hi) = quad_bounds(&q); + assert_eq!(grow_sides(lo, hi, 20, 10), Some([0, 0, 30, 20])); + } + /// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx; /// big layers may still grow to four times their area, and small pads are untouched. #[test] diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index cc9045a41..34d434307 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -871,6 +871,17 @@ fn texturize(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { fn corner_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let (w, h) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + // Grow to hold the whole quad, like the CPU `corner_pin`. + if !ctx.adjustment { + let pts = ["ul", "ur", "lr", "ll"].map(|k| { + let p = b.to_px(ctx.params.v2(k)); + [p.0, p.1] + }); + let (lo, hi) = effectcraft_effects::quad_bounds(&pts); + if let Some(sides) = effectcraft_effects::grow_sides(lo, hi, b.img.width, b.img.height) { + b.pad_sides(e, sides)?; + } + } let q = ["ul", "ur", "lr", "ll"].map(|k| { let p = b.to_px(ctx.params.v2(k)); vec2(p.0, p.1) diff --git a/crates/gpu/src/tests/fx_stylize.rs b/crates/gpu/src/tests/fx_stylize.rs index d9ca95712..63d93f207 100644 --- a/crates/gpu/src/tests/fx_stylize.rs +++ b/crates/gpu/src/tests/fx_stylize.rs @@ -174,6 +174,7 @@ fn mirror_offset_polar_spherize_corner_pin() { effect_case("ec.distort.spherize", &[("radius", n(35.0)), ("center", pt(30.0, 25.0))]); effect_case("ec.distort.cornerpin", &[("ul", pt(5.0, 3.0)), ("ur", pt(60.0, 8.0)), ("lr", pt(66.0, 40.0)), ("ll", pt(2.0, 44.0))]); effect_case("ec.distort.cornerpin", &[("ul", pt(-8.0, 6.0)), ("ur", pt(75.0, -4.0)), ("lr", pt(50.0, 30.0)), ("ll", pt(12.0, 50.0))]); + effect_case("ec.distort.cornerpin", &[("ul", pt(70.0, 55.0)), ("ur", pt(120.0, 55.0)), ("lr", pt(120.0, 90.0)), ("ll", pt(70.0, 90.0))]); } #[test]