From 8bb7e270c390622c311d66274fdd225cf22354da Mon Sep 17 00:00:00 2001 From: ded Date: Wed, 21 Oct 2026 11:38:18 +0300 Subject: [PATCH] A region of interest over an adjustment layer shows the full frame's pixels With a Region of Interest or the Extended Viewer, the canvas holds only the region, so an adjustment layer's stack saw just that part of the comp below: a Transform scaling it down, a blur near the region's edge or Auto Levels gave pixels the full frame doesn't. Past the frame, the stack also took in layers lying off the frame and a Transform pulled them into it. - comp_frame_cpu: a region over an adjustment layer with effects renders the frame with the region around it and crops (inside the frame: the whole frame, on any backend, as 3D comps already do). - draw_adjustment: under a region holding the whole frame the stack gets the comp frame below and nothing past it, as in a render, and its result goes back onto the canvas by offset. Tests: region and Extended Viewer over Transform, Gaussian Blur, Auto Levels and Invert adjustments equal the full frame; past the frame the layers show unadjusted. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 51 +++++++++++++++++++++- crates/render/src/tests_roi.rs | 77 ++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f40..8bc77eac39 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1028,6 +1028,27 @@ impl<'a> Renderer<'a> { } return out; } + // Region of interest over an adjustment layer with effects: the stack reads the whole + // comp below (a Transform scaling it down, a blur, Auto Levels), not just the region. + // Render the frame with the region around it and crop: inside the frame that is the + // whole frame (on any backend), past it the region grown to hold the frame. + if self.depth == 0 + && let Some(r) = self.opts.roi + && !roi_holds_frame(r, comp.width, comp.height) + && comp.layers.iter().any(adjusts_below) + { + let (cw, ch) = (comp.width as f64, comp.height as f64); + let (x0, y0) = (r[0].min(0.0), r[1].min(0.0)); + let u = [x0, y0, (r[0] + r[2]).max(cw) - x0, (r[1] + r[3]).max(ch) - y0]; + let frame = if roi_inside(r, comp.width, comp.height) { + Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t) + } else { + Renderer { opts: RenderOpts { roi: Some(u), ..self.opts }, ..*self }.comp_frame_cpu(comp_id, t) + }; + let px = |v: f64| (v * s).round(); + let (rw, rh) = ((px(r[2]) as u32).max(1), (px(r[3]) as u32).max(1)); + return frame.crop((px(r[0]) - px(x0)) as i64, (px(r[1]) - px(y0)) as i64, rw, rh); + } let (w, h) = match self.roi_offset() { Some((_, _, rw, rh)) => (rw, rh), None => (w, h), @@ -2036,13 +2057,29 @@ impl<'a> Renderer<'a> { 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 }; + let (x, y) = self.out_offset(); + // A region holding the whole frame (the Extended Viewer): the stack gets the comp frame + // below and nothing past it, as in a render, so a Transform scaling it down doesn't pull + // in layers lying off the frame. + let s = self.opts.scale; + let frame = self.opts.roi.filter(|r| self.top && roi_holds_frame(*r, ctx.comp.width, ctx.comp.height)); + let mut below = match frame { + Some(_) => { + let (w, h) = (((ctx.comp.width as f64 * s).round() as u32).max(1), ((ctx.comp.height as f64 * s).round() as u32).max(1)); + Buf { img: canvas.crop(-x as i64, -y as i64, w, h), offset: [0.0; 2], scale: s } + } + None => Buf { img: canvas.clone(), offset: [-x, -y], scale: s }, + }; // Effects run in the working space, not the linear blending space. if let Some(c) = self.pipe.from_blend() { color::convert(&mut below.img, &c); } let mut adjusted = self.apply_effects(ctx, layer, below, true); + // Back onto the canvas: the result pixel at canvas pixel (0, 0). + let (dx, dy) = ((adjusted.offset[0] + x).round(), (adjusted.offset[1] + y).round()); + if dx != 0.0 || dy != 0.0 || adjusted.img.width != canvas.width || adjusted.img.height != canvas.height { + adjusted.img = adjusted.img.crop(dx as i64, dy as i64, canvas.width, canvas.height); + } if let Some(c) = self.pipe.to_blend() { color::convert(&mut adjusted.img, &c); } @@ -2344,6 +2381,16 @@ pub fn roi_inside(r: [f64; 4], w: u32, h: u32) -> bool { r[0] >= 0.0 && r[1] >= 0.0 && r[0] + r[2] <= w as f64 + 1e-6 && r[1] + r[3] <= h as f64 + 1e-6 } +/// Whether region of interest `r` holds the whole `w` × `h` comp frame. +fn roi_holds_frame(r: [f64; 4], w: u32, h: u32) -> bool { + r[0] <= 0.0 && r[1] <= 0.0 && r[0] + r[2] >= w as f64 - 1e-6 && r[1] + r[3] >= h as f64 - 1e-6 +} + +/// An adjustment layer with an enabled effect: its stack runs on the comp below. +fn adjusts_below(l: &Layer) -> bool { + l.switches.adjustment && l.switches.effects && l.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled && matches!(g.kind, GroupKind::Effect { .. }))) +} + /// The Extended Viewer's region of interest: the comp frame grown by `margin` × its size on every /// side (`margin` = 1 shows a pasteboard one comp wide around the frame), intersected with the /// `visible` comp-space rectangle `[x0, y0, x1, y1]` and snapped outward to whole 16 px steps from the frame edges diff --git a/crates/render/src/tests_roi.rs b/crates/render/src/tests_roi.rs index 9182817cf2..e7c8e00bd6 100644 --- a/crates/render/src/tests_roi.rs +++ b/crates/render/src/tests_roi.rs @@ -101,3 +101,80 @@ fn extended_region_is_none_inside_the_frame() { // Clamped to the margin. assert_eq!(crate::extended_region(100, 100, [-1000.0, 0.0, 50.0, 100.0], 1.0), Some([-112.0, 0.0, 212.0, 100.0])); } + +/// `project(false)` under an adjustment layer carrying `effect` (`vals` set), with a yellow solid +/// out of the frame on the right (comp x 170..210) that only the Extended Viewer shows. +fn adjusted(effect: &str, vals: &[(&str, Value)]) -> (Project, effectcraft_project::ItemId) { + let (mut p, cid) = project(false); + let comp = p.comp(cid).unwrap().clone(); + let sid = p.add_item("Adjust", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 160, height: 120, pixel_aspect: 1.0 })); + let mut adj = build::layer(&mut p, &comp, "Adjust", LayerSource::Solid { item: sid }, (160, 120), None); + adj.switches.adjustment = true; + let spec = effectcraft_effects::find(effect).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut build::Ids(&mut next), spec.name, [160.0, 120.0]); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v.clone(); + } + adj.props.sub_mut("effects").unwrap().children.push(g.into()); + let yid = p.add_item("Out", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 0.0], width: 40, height: 40, pixel_aspect: 1.0 })); + let mut out = build::layer(&mut p, &comp, "Out", LayerSource::Solid { item: yid }, (40, 40), None); + out.props.prop_mut("transform/position").unwrap().value = Value::Vec3([190.0, 60.0, 0.0]); + let layers = &mut p.comp_mut(cid).unwrap().layers; + layers.insert(0, adj); + layers.push(out); + (p, cid) +} + +fn adjustment_cases() -> Vec<(&'static str, Vec<(&'static str, Value)>)> { + vec![ + // Reads pixels far from where it writes them (scaled down about the centre). + ("ec.distort.transform", vec![("scaleHeight", Value::Scalar(50.0)), ("scaleWidth", Value::Scalar(50.0))]), + ("ec.blur.gaussian", vec![("blurriness", Value::Scalar(12.0))]), + // Statistics over the whole frame. + ("ec.color.autolevels", vec![]), + // Per pixel. + ("ec.channel.invert", vec![]), + ] +} + +/// A region of interest over an adjustment layer whose effects read outside the region shows +/// the full frame's pixels (the stack runs on the whole comp below, as in the full frame). +#[test] +fn roi_over_an_adjustment_layer_equals_the_full_frame() { + for (effect, vals) in adjustment_cases() { + let (p, cid) = adjusted(effect, &vals); + for (scale, r) in [(1.0, [100.0, 70.0, 50.0, 40.0]), (1.0, [0.0, 0.0, 30.0, 20.0]), (0.5, [40.0, 20.0, 60.0, 40.0])] { + let full = Renderer::new(&p, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(cid, Tick::ZERO); + let roi = Renderer::new(&p, &NoFootage, RenderOpts { scale, roi: Some(r), ..Default::default() }).comp_frame(cid, Tick::ZERO); + let px = |v: f64| (v * scale).round() as u32; + assert_eq!((roi.width, roi.height), (px(r[2]), px(r[3])), "{effect}"); + let want = crop(&full, px(r[0]), px(r[1]), px(r[2]), px(r[3])); + for (i, (a, b)) in roi.data.iter().zip(&want.data).enumerate() { + assert!(a.iter().zip(b).all(|(p, q)| (p - q).abs() < 1e-4), "{effect} region {r:?} at {scale}: pixel {i} is {a:?}, the frame has {b:?}"); + } + } + } +} + +/// The Extended Viewer over an adjustment layer: the frame part equals the normal frame (what +/// lies past the frame stays out of the stack, as in a render), and past the frame the layers +/// show unadjusted. +#[test] +fn extended_region_over_an_adjustment_layer() { + for (effect, vals) in adjustment_cases() { + let (p, cid) = adjusted(effect, &vals); + let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + let region = [-80.0, -64.0, 320.0, 248.0]; + let ext = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some(region), ..Default::default() }).comp_frame(cid, Tick::ZERO); + assert_eq!((ext.width, ext.height), (320, 248), "{effect}"); + let inner = crop(&ext, 80, 64, 160, 120); + for (i, (a, b)) in inner.data.iter().zip(&full.data).enumerate() { + assert!(a.iter().zip(b).all(|(p, q)| (p - q).abs() < 1e-4), "{effect}: frame pixel {i} is {a:?}, the frame has {b:?}"); + } + // Comp (190, 60): the yellow solid past the frame, untouched by the adjustment. + assert_eq!(ext.get(190 + 80, 60 + 64), [1.0, 1.0, 0.0, 1.0], "{effect}: pasteboard pixel"); + assert_eq!(ext.get(5, 5), [0.0; 4], "{effect}: empty pasteboard"); + } +}