Grow the buffer to the pinned quad so Corner Pin draws outside the layer rectangle (#1938) (#1967)

* Grow the buffer to the pinned quad so Corner Pin draws outside the layer rectangle (#1938)

* Corner Pin: grow through pad_sides, skip adjustment layers, clamp to the pixel budget (#1938)

grow_sides returns the four sides for Buf::pad_sides on both paths, replacing grow_to/regrow and the inline GPU copy. Growth is skipped on adjustment layers, cut proportionally to the shared pixel budget (and 16384 per side) instead of giving up, and covered by tests for a far pin, an adjustment layer and a degenerate quad.

* Corner Pin: give the grow_sides test its own doc comment (#1938)

* effects: Corner Pin keeps a finite buffer for non-finite pins and grows without popping as a pin moves far away

* effects: rustfmt the Corner Pin non-finite pin test

---------

Co-authored-by: skylar <hi@skylar.cc>
This commit is contained in:
MarekSpodBiedryandskylar authored and GitHub committed 2026-11-05 21:52:10 +01:00
1 parent 1786c24523
commit 7ce61c68fc
4 files changed
+172 -5

No files matched your search

+80
View File
@@ -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: &params, 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<u64> = 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)
}
+80 -5
View File
@@ -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]
+11
View File
@@ -871,6 +871,17 @@ fn texturize(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
fn corner_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
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)
+1
View File
@@ -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]