From fa733bfb043c18c1cee94d0496643db09aa4ec62 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 12:25:29 +1100 Subject: [PATCH] Effects: text and shape layers run their stack over the whole layer region (#659). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An authored layer's effect buffer spanned only its painted content — the glyph bounding box for text, the drawn bounds for shapes — while the size the stack measures from (effect_bounds) is the whole comp-sized layer rectangle. Tiling and displacement effects computed correct output for pixels outside the painted rectangle and then wrote them into the buffer that was too small, so CC Tiler repeated only within the text box and CC Smear lost its liquid trail at the glyph edge (#658). Buf::cover now grows the stack's input to the layer's rectangle with transparent margins — what a sized source's buffer already is — applied by run_effect_stack whenever an enabled video effect will run, and natively on the GPU by reallocating and copying the texture. The placement and the sized-source paths are unchanged; two regression tests (CC Tiler spans the region, CC Smear trails past the text bounds) fail without the growth. --- crates/effects/src/lib.rs | 36 ++++++++++++++++++++++++++ crates/gpu/src/effects.rs | 15 +++++++++++ crates/render/src/lib.rs | 17 ++++++++++++ crates/render/src/tests.rs | 53 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7e968297..4e0af6f5 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -264,6 +264,21 @@ impl Buf { pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } + /// Grow the buffer so it covers a `size`-layer-pixel region starting at its offset (see + /// [`cover_plan`]): the whole layer rectangle a sized source's buffer already is. Content + /// keeps its place; the new area is transparent. No-op when the buffer already covers it. + pub fn cover(&mut self, size: [f64; 2]) { + let Some((dx, dy, w, h)) = cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return }; + let src = std::mem::replace(&mut self.img, Image::new(w, h)); + let (sw, sh) = (src.width as usize, src.height as usize); + for y in 0..sh { + let s = y * sw; + let d = (y + dy as usize) * w as usize + dx as usize; + self.img.data[d..d + sw].copy_from_slice(&src.data[s..s + sw]); + } + self.offset[0] += dx as f64; + self.offset[1] += dy as f64; + } /// Measure from `origin` (in the current coordinates): a point `p` becomes `p − origin`. /// Moves between layer space and effect space (the renderer's `effect_bounds`). pub fn rebase(&mut self, origin: [f64; 2]) { @@ -281,6 +296,27 @@ impl Buf { } } +/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to +/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the +/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer +/// already covers that region unchanged (footage, solids, the comp below an adjustment). +pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { + if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() { + return None; + } + // Region and content, both as pixel ranges in the buffer's own coordinates. + let (rw, rh) = (offset[0] + (size[0] * scale).ceil(), offset[1] + (size[1] * scale).ceil()); + let (lo_x, lo_y) = (offset[0].min(0.0).floor(), offset[1].min(0.0).floor()); + let (hi_x, hi_y) = (rw.max(width as f64).ceil(), rh.max(height as f64).ceil()); + let (nw_f, nh_f) = (hi_x - lo_x, hi_y - lo_y); + if !(nw_f <= 16384.0 && nh_f <= 16384.0) { + return None; + } + let (dx, dy) = ((-lo_x) as u32, (-lo_y) as u32); + let (nw, nh) = (nw_f.round().max(1.0) as u32, nh_f.round().max(1.0) as u32); + (dx != 0 || dy != 0 || nw != width || nh != height).then_some((dx, dy, nw, nh)) +} + /// A layer mask at the current time, flattened to a polyline in layer coordinates. #[derive(Clone, Debug, Default, PartialEq)] pub struct MaskShape { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 426931d7..42d2bab7 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -145,6 +145,21 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { let out = f(Buf { img, offset: b.offset, scale: b.scale }); self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); } + + /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no + /// readback. A region the device can't allocate leaves the buffer as it is. + fn cover(&mut self, size: [f64; 2]) { + let Some(b) = self.b.take() else { return }; + let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) { + Some((dx, dy, w, h)) if self.e.g.fits(w, h) => { + let img = self.e.image(w, h); + self.e.copy_into(&b.img, &img, dx, dy); + GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale } + } + _ => b, + }; + self.b = Some(out); + } } fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f4a5a98a..58f6c362 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -377,6 +377,16 @@ pub trait FxTarget { fn gpu(&mut self, steps: &[FxStep]) -> bool; /// Run one CPU effect: `f` maps the buffer. fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); + /// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own + /// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`]. + /// Authored buffers (text, shapes) only span their painted content; this gives the stack + /// the room a sized source's buffer already has (#659). + fn cover(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.cover(size); + buf + }); + } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -991,6 +1001,13 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; + // #659: an authored layer's buffer spans only its painted content while the stack works + // on the layer's whole region (`layer_size`): give the buffer the room a sized source + // already has, so tiling or displacement can place pixels outside the content instead + // of losing them at its edge. Only when something will actually run. + if fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { + target.cover(layer_size); + } let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7e065391..eee105fc 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -597,6 +597,59 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() { } } +/// #659: a tiling effect on a text layer tiles across the layer's whole region. The glyphs +/// were cut off at their own bounding box: the effect computed over the comp-sized layer rect +/// but wrote into the painted (glyph-bounds) buffer alone. Tiling shrinks each copy, so the +/// probe is the x-span of the painted pixels, not their count. +#[test] +fn effects_on_text_reach_past_the_text_bounds() { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() })); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 60.0, 0.0]); + p.comp_mut(cid).unwrap().layers.push(l.clone()); + let plain = render_frame(&p, cid, Tick::ZERO, 1.0); + let painted = plain.data.iter().filter(|px| px[3] > 0.5).count(); + add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]); + p.comp_mut(cid).unwrap().layers = vec![l]; + let tiled = render_frame(&p, cid, Tick::ZERO, 1.0); + let (mut lo, mut hi) = (u32::MAX, 0u32); + for y in 0..100 { + for x in 0..200 { + if tiled.get(x as i64, y as i64)[3] > 0.5 { + lo = lo.min(x); + hi = hi.max(x); + } + } + } + assert!( + painted > 50 && hi.saturating_sub(lo) > 100, + "CC Tiler tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}" + ); +} + +/// #658: CC Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past +/// the painted rectangle used to fall off its edge, so the liquid trail never appeared. +#[test] +fn cc_smear_trails_text_past_its_bounds() { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() })); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([60.0, 60.0, 0.0]); + p.comp_mut(cid).unwrap().layers.push(l.clone()); + let right = |img: &effectcraft_raster::Image| (0..100).map(|y| (0..200).filter(|x| img.get(*x, y)[3] > 0.5).max().unwrap_or(0)).max().unwrap_or(0); + let plain = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); + add_effect_200( + &mut p, + &mut l, + "ec.distort.ccsmear", + &[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))], + ); + p.comp_mut(cid).unwrap().layers = vec![l]; + let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); + assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}"); +} + /// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the /// effect's own masks are in effect space too: Set Matte with a centred shape layer as the /// matte keeps the centre of a full-comp solid (#227).