From c6ab02b83aeb7fa7aba0a4e3fbcd0ad5e7751d9f Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 22:49:52 -0300 Subject: [PATCH 1/2] Fix #2919: Match Grain takes no sample box beside transparent pixels A box's own pixels had to be opaque, but the high-pass that measures its grain reads 3 px around it, and a transparent pixel there is black once unpremultiplied. A blur earlier in the stack pads the layer with transparent pixels, so boxes along that border measured the edge as strong, smooth colour grain, and Match Grain drew 12 px colour cells from a clean source on the frames that picked them. Boxes now need their 3 px surround opaque too (a summed-area table keeps the check O(1) per candidate). Remove Grain picks its boxes the same way. --- crates/effects/src/noise.rs | 32 +++++++++++++++++++++++++++----- crates/effects/src/noise2.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 9d21e77a7..b6f76a268 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -254,8 +254,14 @@ pub(crate) fn preview_compose(ctx: &EffectCtx, b: &mut Buf, orig: &Image) { }); } +/// How far the grain statistics read around a sample box: their high-pass subtracts a Gaussian +/// of sigma 1.5, three box passes of radius 1 (`noise2::grain_stats_boxes`). +const SAMPLE_REACH: usize = 3; + /// Boxes (x, y, size in buffer px) where grain is sampled: the flattest of a grid of -/// candidate boxes (real grain shows best where the picture has no detail). +/// candidate boxes (real grain shows best where the picture has no detail). A box and the +/// [`SAMPLE_REACH`] px around it are opaque: a transparent pixel there is black once +/// unpremultiplied, and the high-pass would measure that edge as grain. pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize, usize, usize)> { let (w, h) = (img.width as usize, img.height as usize); let size = size.clamp(3, w.min(h).max(3)); @@ -264,6 +270,19 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize } let low = Plane::luma(img); let step = (size / 2).max(1); + // Transparent pixels (alpha 0.5 or less) in a summed-area table: `sat[y * (w + 1) + x]` + // counts them above and left of (x, y). + let sw = w + 1; + let mut sat = vec![0usize; sw * (h + 1)]; + for y in 0..h { + let mut run = 0; + for x in 0..w { + let opaque = img.data[y * w + x][3] > 0.5; + run += usize::from(!opaque); + sat[(y + 1) * sw + x + 1] = sat[y * sw + x + 1] + run; + } + } + let sat = sat.as_slice(); // Candidate rows in parallel; the sort below is a total order (variance, y, x), so the // result does not depend on the collection order. let rows: Vec = (0..=h - size).step_by(step).collect(); @@ -271,20 +290,23 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize .par_iter() .flat_map_iter(|&y| { let data = low.data.as_slice(); - let alpha = img.data.as_slice(); (0..=w - size).step_by(step).filter_map(move |x| { + let (x0, y0) = (x.saturating_sub(SAMPLE_REACH), y.saturating_sub(SAMPLE_REACH)); + let (x1, y1) = ((x + size + SAMPLE_REACH).min(w), (y + size + SAMPLE_REACH).min(h)); + if sat[y1 * sw + x1] + sat[y0 * sw + x0] != sat[y0 * sw + x1] + sat[y1 * sw + x0] { + return None; + } // Variance of the picture inside the box (the flattest boxes hold the least detail). - let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); + let (mut s, mut sq, mut n) = (0.0f32, 0.0f32, 0.0f32); for yy in y..y + size { for xx in x..x + size { let v = data[yy * w + xx]; s += v; sq += v * v; n += 1.0; - opaque &= alpha[yy * w + xx][3] > 0.5; } } - opaque.then(|| (sq / n - (s / n).powi(2), x, y)) + Some((sq / n - (s / n).powi(2), x, y)) }) }) .collect(); diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index 48d840416..5ffdce5f5 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -464,6 +464,36 @@ mod tests { assert_eq!(out.img.data, again.img.data); } + /// #2919: a sample box beside transparent pixels measured the edge as grain. Its own + /// pixels are opaque, but the high-pass blur reads 3 px around it, where transparent pixels + /// are black once unpremultiplied. An earlier blur pads the layer with transparent pixels, + /// so a clean plate got blocky colour grain on the frames whose flattest boxes sat at the + /// edge, and fine grain on the others. + #[test] + fn match_grain_ignores_the_edge_of_a_transparent_border() { + // A flat, noiseless sky inside the 2 px transparent frame of a padded buffer. + let (w, h) = (64u32, 48u32); + let mut sky = Image::filled(w, h, [0.4, 0.6, 0.9, 1.0]); + for y in 0..h { + for x in 0..w { + if x < 2 || y < 2 || x >= w - 2 || y >= h - 2 { + sky.set(x, y, [0.0; 4]); + } + } + } + let boxes = crate::noise::sample_boxes(&sky, 80, 9); + assert!(!boxes.is_empty()); + let st = grain_stats_boxes(&sky, &boxes); + assert!(st.std.iter().all(|s| *s < 1e-5), "{st:?} from {boxes:?}"); + // So Match Grain finds no grain to add. + let host = FakeHost(sky); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let target = Image::filled(w, h, [0.2, 0.3, 0.5, 1.0]); + let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], target.clone(), 0.5, env); + let most = out.img.data.iter().zip(&target.data).flat_map(|(a, b)| (0..3).map(move |k| (a[k] - b[k]).abs())).fold(0.0f32, f32::max); + assert!(most < 1e-5, "Match Grain added {most}"); + } + #[test] fn median_legacy_radius_zero_identity_and_removes_speck() { let mut img = Image::filled(9, 9, [0.2, 0.2, 0.2, 1.0]); From 1b5c28ee1d07fb6a506490793cd756640464b5aa Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 23:17:04 -0300 Subject: [PATCH 2/2] Follow-up to #2919: Match Grain matches no grain without a clean sample box A source with no box-sized area clear of transparent pixels (a small layer inside a blur's padding) got no sample boxes, and grain_stats_boxes then measured the whole image, edge included. With no clean box Match Grain now matches no grain, as After Effects applies none when its samples hold transparent areas. Compensate for Existing Noise took the same route through the layer's own boxes, so a layer with no clean box now has nothing taken off. grain_stats_boxes keeps its empty-list fallback. --- crates/effects/src/noise2.rs | 65 +++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index 5ffdce5f5..56de9943e 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -190,15 +190,14 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { b.img = img; return b; } - let src_stats = grain_stats_boxes(&source, &src_boxes); + // No box with an opaque surround (a small layer inside transparent pixels) means no clean + // sample: no grain to match, as After Effects applies none when its samples hold transparent + // areas. The whole-image fallback of `grain_stats_boxes` would measure the edge again. + let clean_stats = |img: &Image, boxes: &[(usize, usize, usize)]| if boxes.is_empty() { GrainStats::default() } else { grain_stats_boxes(img, boxes) }; + let src_stats = clean_stats(&source, &src_boxes); let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; // The own-layer boxes only matter for the compensation term; skip them when it is off. - let own = if comp > 0.0 { - let own_boxes = sampling_boxes(ctx, &b.img, b.scale); - grain_stats_boxes(&b.img, &own_boxes) - } else { - GrainStats::default() - }; + let own = if comp > 0.0 { clean_stats(&b.img, &sampling_boxes(ctx, &b.img, b.scale)) } else { GrainStats::default() }; let orig = (view == 0).then(|| b.img.clone()); let look = GrainLook::from_params(ctx); let amp: [f32; 3] = [0, 1, 2].map(|k| { @@ -471,27 +470,45 @@ mod tests { /// edge, and fine grain on the others. #[test] fn match_grain_ignores_the_edge_of_a_transparent_border() { - // A flat, noiseless sky inside the 2 px transparent frame of a padded buffer. - let (w, h) = (64u32, 48u32); - let mut sky = Image::filled(w, h, [0.4, 0.6, 0.9, 1.0]); - for y in 0..h { - for x in 0..w { - if x < 2 || y < 2 || x >= w - 2 || y >= h - 2 { - sky.set(x, y, [0.0; 4]); + // A flat colour inside the 2 px transparent frame of a padded buffer. + let framed = |w: u32, h: u32, c: [f32; 4]| { + let mut img = Image::filled(w, h, c); + for y in 0..h { + for x in 0..w { + if x < 2 || y < 2 || x >= w - 2 || y >= h - 2 { + img.set(x, y, [0.0; 4]); + } } } + img + }; + let most_change = |a: &Image, b: &Image| a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |k| (p[k] - q[k]).abs())).fold(0.0f32, f32::max); + // A noiseless sky. At 16 px no 9 px box has an opaque 3 px surround, so there is no + // clean sample at all (the whole-image fallback would measure the edge). + for (w, h, clean_boxes) in [(64u32, 48u32, true), (16, 16, false)] { + let sky = framed(w, h, [0.4, 0.6, 0.9, 1.0]); + let boxes = crate::noise::sample_boxes(&sky, 80, 9); + assert_eq!(!boxes.is_empty(), clean_boxes, "{w}x{h}: {boxes:?}"); + if clean_boxes { + let st = grain_stats_boxes(&sky, &boxes); + assert!(st.std.iter().all(|s| *s < 1e-5), "{w}x{h}: {st:?} from {boxes:?}"); + } + // So Match Grain finds no grain to add. + let host = FakeHost(sky); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let target = Image::filled(w, h, [0.2, 0.3, 0.5, 1.0]); + let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], target.clone(), 0.5, env); + let most = most_change(&out.img, &target); + assert!(most < 1e-5, "{w}x{h}: Match Grain added {most}"); } - let boxes = crate::noise::sample_boxes(&sky, 80, 9); - assert!(!boxes.is_empty()); - let st = grain_stats_boxes(&sky, &boxes); - assert!(st.std.iter().all(|s| *s < 1e-5), "{st:?} from {boxes:?}"); - // So Match Grain finds no grain to add. - let host = FakeHost(sky); + // Compensate for Existing Noise has no clean sample of such a layer either, so its edge + // doesn't cancel the source's grain. + let host = FakeHost(noisy(16, 16, 0.1, 3)); let env = EffectEnv { host: Some(&host), ..Default::default() }; - let target = Image::filled(w, h, [0.2, 0.3, 0.5, 1.0]); - let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], target.clone(), 0.5, env); - let most = out.img.data.iter().zip(&target.data).flat_map(|(a, b)| (0..3).map(move |k| (a[k] - b[k]).abs())).fold(0.0f32, f32::max); - assert!(most < 1e-5, "Match Grain added {most}"); + let target = framed(16, 16, [0.9, 0.9, 0.9, 1.0]); + let params = [("noiseSourceLayer", Value::Layer(Some(7))), ("compensateForExistingNoise", crate::num(100.0))]; + let out = run_fx("ec.noise.matchgrain", ¶ms, target.clone(), 0.5, env); + assert!(most_change(&out.img, &target) > 0.01, "the layer's own edge cancelled the grain"); } #[test]