mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #686 from vbasky/fix/659-text-effect-bounds
Effects: text and shape layers run their stack over the whole layer region (#659, #658)
This commit is contained in:
commit
6b69881826
4 files changed
+121
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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<GBuf> {
|
||||
|
||||
@@ -484,6 +484,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).
|
||||
@@ -1103,6 +1113,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()) {
|
||||
|
||||
@@ -640,6 +640,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).
|
||||
|
||||
Reference in new issue
Block a user