diff --git a/crates/gpu/src/tests_adjust.rs b/crates/gpu/src/tests_adjust.rs index 81bfaf10..5635e08e 100644 --- a/crates/gpu/src/tests_adjust.rs +++ b/crates/gpu/src/tests_adjust.rs @@ -3,7 +3,7 @@ use effectcraft_keyframe::{ShapePath, Value}; use effectcraft_project::build::{self, Ids}; -use effectcraft_project::{BitDepth, ColorSpace, MaskMode}; +use effectcraft_project::{BitDepth, ColorSpace, MaskMode, MatteKind, TrackMatte}; use effectcraft_render::{Backend, FxStep, FxTarget, RenderOpts, Renderer}; use effectcraft_time::Tick; @@ -72,6 +72,24 @@ fn mixed_adjustment_chains_fall_back_per_effect() { } } +/// A track matte limits where the adjustment shows, as on the CPU (#1201). +#[test] +fn track_mattes_limit_gpu_adjustments_like_the_cpu() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for kind in [MatteKind::Alpha, MatteKind::AlphaInverted, MatteKind::Luma, MatteKind::LumaInverted] { + let mut s = adjustment_scene(depth, &gpu_stack(), false); + let mut matte = s.footage(50, 30); + matte.switches.video = false; + set(&mut matte, "transform/rotation", Value::Scalar(10.0)); + let mid = s.push(matte); + let c = s.p.comp_mut(s.cid).unwrap(); + let adj = c.layers.iter_mut().find(|l| l.switches.adjustment).unwrap(); + adj.track_matte = Some(TrackMatte { layer: mid, kind }); + check(&format!("matted adjustment {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + } + } +} + /// Counts where each run of the stack goes. #[derive(Default)] struct Probe { diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 83cb8550..a5b1b4d8 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -297,12 +297,14 @@ fn draw_precomp(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: /// Adjustment layer (`Renderer::draw_adjustment`): the effect stack runs on the comp below, /// resident on the GPU (non-GPU effects read back, run on the CPU and upload), and the result -/// replaces the canvas inside the layer's footprint × opacity. `None` = draw it on the CPU. +/// replaces the canvas inside the layer's footprint × track matte × opacity. `None` = draw it +/// on the CPU. fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { let opacity = r.layer_opacity(ctx, layer); let Some(foot) = r.adjustment_footprint(ctx, layer) else { return Some(()) }; let (w, h) = (canvas.width, canvas.height); let matte = place(e, r, ctx, layer, &foot, &e.zeros(w, h), BlendMode::Normal, 1.0, None)?; + let matte = track_matte(e, r, ctx, layer, matte, None)?; let pipe = r.pipe(); // Effects run in the working space, not the linear blending space. let below = match pipe.from_blend() { @@ -353,22 +355,7 @@ fn composite_iso_with( matte_img: Option, levels: Option, ) -> Option<()> { - if let Some((m, kind)) = r.track_matte(ctx, layer) { - let mimg = match matte_img.filter(|i| (i.width, i.height) == (canvas.width, canvas.height)) { - Some(i) => i, - None => { - let mut mimg = e.zeros(canvas.width, canvas.height); - if m.is_active_at(ctx.time) - && let Some(mb) = r.layer_buf(ctx, m) - { - let mo = ctx.opacity(m) as f32; - mimg = place(e, r, ctx, m, &mb, &mimg, BlendMode::Normal, mo, None)?; - } - mimg - } - }; - iso = ops::matte(e, &iso, &mimg, kind); - } + iso = track_matte(e, r, ctx, layer, iso, matte_img)?; if layer.preserve_transparency { iso = ops::preserve(e, &iso, canvas); } @@ -376,6 +363,26 @@ fn composite_iso_with( Some(()) } +/// `Renderer::apply_track_matte`: `img` (comp-sized) times the layer's track matte, if it has +/// one; `matte_img` as in [`composite_iso_with`]. +fn track_matte(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, img: GpuImage, matte_img: Option) -> Option { + let Some((m, kind)) = r.track_matte(ctx, layer) else { return Some(img) }; + let mimg = match matte_img.filter(|i| (i.width, i.height) == (img.width, img.height)) { + Some(i) => i, + None => { + let mut mimg = e.zeros(img.width, img.height); + if m.is_active_at(ctx.time) + && let Some(mb) = r.layer_buf(ctx, m) + { + let mo = ctx.opacity(m) as f32; + mimg = place(e, r, ctx, m, &mb, &mimg, BlendMode::Normal, mo, None)?; + } + mimg + } + }; + Some(ops::matte(e, &img, &mimg, kind)) +} + /// Draw a processed layer buffer onto `target` (transform, motion blur sub-samples), quantising /// the result to `levels` when set. #[allow(clippy::too_many_arguments)] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cd56b0bc..6072851f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1945,13 +1945,26 @@ impl<'a> Renderer<'a> { /// [`Self::composite_iso`] with the track matte's pixels already drawn (`matte`, e.g. a 3D /// matte seen through the camera); otherwise the matte layer is placed in 2D. pub(crate) fn composite_iso_with(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32, matte_img: Option) { + self.apply_track_matte(ctx, layer, &mut iso, matte_img); + if layer.preserve_transparency { + iso.data.par_iter_mut().zip(canvas.data.par_iter()).for_each(|(p, q)| { + for c in p.iter_mut() { + *c *= q[3]; + } + }); + } + canvas.blend_from(&iso, layer.blend_mode, opacity, layer.id.0 as u32); + } + + /// Multiply `img` (comp-sized) by the layer's track matte, if it has one; `matte_img` as in + /// [`Self::composite_iso_with`]. + fn apply_track_matte(&self, ctx: &EvalCtx, layer: &Layer, img: &mut Image, matte_img: Option) { let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind))); - let preserve = layer.preserve_transparency; if let Some((m, kind)) = matte { let mimg = match matte_img { - Some(i) if i.width == canvas.width && i.height == canvas.height => i, + Some(i) if i.width == img.width && i.height == img.height => i, _ => { - let mut mimg = Image::new(canvas.width, canvas.height); + let mut mimg = Image::new(img.width, img.height); if m.is_active_at(ctx.time) && let Some(mb) = self.layer_buf(ctx, m) { @@ -1961,7 +1974,7 @@ impl<'a> Renderer<'a> { mimg } }; - iso.data.par_iter_mut().zip(mimg.data.par_iter()).for_each(|(p, q)| { + img.data.par_iter_mut().zip(mimg.data.par_iter()).for_each(|(p, q)| { let k = match kind { MatteKind::Alpha => q[3], MatteKind::AlphaInverted => 1.0 - q[3], @@ -1974,22 +1987,16 @@ impl<'a> Renderer<'a> { } }); } - if preserve { - iso.data.par_iter_mut().zip(canvas.data.par_iter()).for_each(|(p, q)| { - for c in p.iter_mut() { - *c *= q[3]; - } - }); - } - canvas.blend_from(&iso, layer.blend_mode, opacity, layer.id.0 as u32); } - /// Adjustment layer: effects applied to the comp below, limited to the layer's footprint. + /// Adjustment layer: effects applied to the comp below, limited to the layer's footprint + /// and its track matte. fn draw_adjustment(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, opacity: f32) { let Some(mut foot) = self.source(ctx, layer) else { return }; let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); let mut matte = Image::new(canvas.width, canvas.height); self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0); + self.apply_track_matte(ctx, layer, &mut matte, None); let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]); let mut below = Buf { img: canvas.clone(), offset: o, scale: self.opts.scale }; // Effects run in the working space, not the linear blending space. diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 45c00a64..39e022b0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -161,6 +161,32 @@ fn track_matte_kinds_keep_and_invert() { } } +/// An adjustment layer with a track matte applies its effects only where the matte allows: an +/// Invert over a 0.25 grey with an opaque white 50×50 matte at the left of the comp (#1201). +#[test] +fn track_matte_limits_an_adjustment_layer() { + for (kind, inside) in [(MatteKind::Alpha, true), (MatteKind::AlphaInverted, false), (MatteKind::Luma, true), (MatteKind::LumaInverted, false)] { + let (mut p, cid, comp) = setup(); + let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50); + matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]); + matte.switches.video = false; + let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + adj.switches.adjustment = true; + add_effect_200(&mut p, &mut adj, "ec.channel.invert", &[]); + adj.track_matte = Some(TrackMatte { layer: matte.id, kind }); + let bg = solid(&mut p, &comp, [0.25, 0.25, 0.25], 200, 100); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(matte); + c.layers.push(adj); + c.layers.push(bg); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let (g_in, g_out) = (img.get(50, 50)[0], img.get(150, 50)[0]); + let (want_in, want_out) = if inside { (0.75, 0.25) } else { (0.25, 0.75) }; + assert!((g_in - want_in).abs() < 1e-4 && (g_out - want_out).abs() < 1e-4, "{kind:?}: {g_in} inside, {g_out} outside"); + assert_eq!(img.get(150, 50)[3], 1.0, "{kind:?} keeps the comp opaque"); + } +} + #[test] fn shape_layer_draws_fill_and_stroke() { let (mut p, cid, comp) = setup();