From 2aefe3e6445879a1d1856aecd43ee375c8fda57e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:35:47 +0000 Subject: [PATCH] Effects: CC Smear and CC Tiler on text and shape layers reach past the content's box (#659) A text or shape layer's effect bounds are comp-sized (as in After Effects), but its buffer surrounds only its content, and each effect grows the buffer itself when it needs room. CC Smear and CC Tiler never did, so a smear was cut off where the glyphs end and tiles filled only the text's box. CC Tiler now covers the whole layer rectangle; CC Smear grows by how far it moves pixels (Reach x From->To), at most to the layer bounds, so footage layers, whose buffer is their bounds already, are unchanged. CPU and GPU alike. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/effects/src/distort2.rs | 5 ++- crates/effects/src/distort3.rs | 57 +++++++++++++++++++++++++++++++++- crates/effects/src/lib.rs | 2 +- crates/effects/src/util.rs | 11 +++++++ crates/gpu/src/fx_stylize.rs | 12 +++++-- 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index f672d149..10ec5973 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -20,7 +20,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::generate::value_noise; -use crate::util::{SRC_NAMES, Src, fit_layer, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; +use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { @@ -678,6 +678,9 @@ fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf { fn cc_tiler(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = (ctx.params.f("scale") / 100.0).max(0.001); + if !ctx.adjustment { + b.pad(layer_cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale)); + } let c = b.to_px(ctx.params.v2("center")); let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; let (x0, y0, w, h) = layer_rect(ctx, &b); diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 27da970f..99139483 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -330,9 +330,12 @@ fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---------------------------------------------------------------- CC Smear fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { + let reach = ctx.params.f("reach") / 100.0; + if !ctx.adjustment { + b.pad(cc_smear_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale)); + } let from = b.to_px(ctx.params.v2("from")); let to = b.to_px(ctx.params.v2("to")); - let reach = ctx.params.f("reach") / 100.0; let radius = (ctx.params.f("radius") * b.scale).max(0.5); let (dx, dy) = (to.0 - from.0, to.1 - from.1); let l2 = dx * dx + dy * dy; @@ -351,6 +354,21 @@ fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// The transparent pixels to add on every side of a buffer `size` pixels big (with `offset` and +/// `scale`) for CC Smear: it moves pixels by up to Reach × From→To, and they may move out to the +/// layer bounds. A text or shape layer's buffer ends at its content while its bounds are +/// comp-sized, so the smear was cut off at the content's box (#659). +pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 { + let reach = ctx.params.f("reach") / 100.0; + let (from, to) = (ctx.params.v2("from"), ctx.params.v2("to")); + let len = ((to[0] - from[0]).powi(2) + (to[1] - from[1]).powi(2)).sqrt() * scale; + let moved = (reach.abs() * len).ceil(); + if !moved.is_finite() || moved <= 0.0 { + return 0; + } + (moved.min(4096.0) as u32).min(crate::util::layer_cover_pad(ctx, size, offset, scale)) +} + // ---------------------------------------------------------------- CC Split / CC Split 2 fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf { @@ -1109,6 +1127,43 @@ mod tests { } } + /// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized + /// (200 × 100) layer bounds, after `id` with `set`. + fn on_text(id: &str, set: &[(&str, Value)]) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let mut im = Image::new(20, 10); + im.data.iter_mut().for_each(|p| *p = [1.0; 4]); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 }) + } + + /// Alpha at layer point `p` (0 outside the buffer). + fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 { + let (x, y) = b.to_px(p); + b.img.get(x.floor() as i64, y.floor() as i64)[3] + } + + /// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds + /// (comp-sized, as in After Effects), not cut off where the glyphs end. + #[test] + fn effects_on_text_reach_past_its_box() { + let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]); + assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box"); + assert!(smear.img.width < 200, "it grows only as far as pixels move"); + let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]); + assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9); + assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds"); + assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1); + // A footage layer's buffer is its bounds already: no change. + let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]); + assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0])); + } + #[test] fn every_warp_style_runs_and_bends() { let im = img(40, 30); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 45e2be1c..7e968297 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sampl pub use color3::OffsetCurve; pub use distort::transform_shutter; pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh}; -pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, reshape_setup, smear_setup, warp_inverse_map}; +pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map}; pub use distort4::liquify_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index c3416618..1527e221 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -433,6 +433,17 @@ pub fn layer_rect_at(ctx: &crate::EffectCtx, offset: [f64; 2], scale: f64) -> (f (offset[0], offset[1], ctx.layer_size[0] * scale, ctx.layer_size[1] * scale) } +/// Transparent pixels to add on every side ([`crate::Buf::pad`]) of a buffer `size` pixels big, +/// with `offset` and `scale`, so it covers the whole layer rectangle (at most 4096). A text or +/// shape layer's buffer only surrounds its content, but its layer bounds are comp-sized (see +/// the renderer's `effect_bounds`): an effect that fills the layer, such as CC Tiler, fills +/// those as in After Effects (#659). +pub fn layer_cover_pad(ctx: &crate::EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 { + let (x0, y0, w, h) = layer_rect_at(ctx, offset, scale); + let need = [-x0, -y0, x0 + w - f64::from(size[0]), y0 + h - f64::from(size[1])].into_iter().fold(0.0, f64::max); + need.ceil().min(4096.0) as u32 +} + /// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other /// layer is scaled to this layer's size ("Stretch to Fit"); otherwise it is centred. pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPixels, stretch: bool) -> Image { diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index 8aee71ed..f5b7708d 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -299,10 +299,13 @@ fn cc_slant(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { ) } -fn cc_smear(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { +fn cc_smear(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let reach = ctx.params.f("reach") / 100.0; + if !ctx.adjustment { + b.pad(e, effectcraft_effects::cc_smear_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale))?; + } let from = b.to_px(ctx.params.v2("from")); let to = b.to_px(ctx.params.v2("to")); - let reach = ctx.params.f("reach") / 100.0; let radius = (ctx.params.f("radius") * b.scale).max(0.5); let (dx, dy) = (to.0 - from.0, to.1 - from.1); let l2 = dx * dx + dy * dy; @@ -585,8 +588,11 @@ fn offset(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { table(e, b, &data, false, blend.max(0.0)) } -fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { +fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let s = (ctx.params.f("scale") / 100.0).max(0.001); + if !ctx.adjustment { + b.pad(e, effectcraft_effects::util::layer_cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale))?; + } let c = b.to_px(ctx.params.v2("center")); let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; let (x0, y0, w, h) = layer_rect(ctx, &b);