Merge branch 'worktree-agent-ad1b692cafaa1598a'

# Conflicts:
#	crates/render/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-02 10:32:51 -04:00
31 changed files with 3789 additions and 319 deletions
+52 -3
View File
@@ -139,11 +139,14 @@ pub struct RenderOpts {
pub draft: bool,
/// 3D view camera override (viewer Front/Top/Custom views); None = the comp's active camera.
pub view: Option<three_d::CameraState>,
/// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers
/// just this rectangle, and only its pixels are composited.
pub roi: Option<[f64; 4]>,
}
impl Default for RenderOpts {
fn default() -> Self {
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None }
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None }
}
}
@@ -342,6 +345,26 @@ impl<'a> Renderer<'a> {
let s = self.opts.scale;
let w = ((comp.width as f64 * s).round() as u32).max(1);
let h = ((comp.height as f64 * s).round() as u32).max(1);
// Region of interest on a comp with 3D layers: render the frame and crop it.
if self.depth == 0
&& let Some(r) = self.opts.roi
&& comp.has_3d()
{
let full = Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t);
let (x0, y0) = ((r[0] * s).round().max(0.0) as u32, (r[1] * s).round().max(0.0) as u32);
let (rw, rh) = (((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1));
let mut out = Image::new(rw, rh);
for y in 0..rh.min(full.height.saturating_sub(y0)) {
for x in 0..rw.min(full.width.saturating_sub(x0)) {
out.data[(y * rw + x) as usize] = full.data[((y + y0) * full.width + x + x0) as usize];
}
}
return out;
}
let (w, h) = match self.roi_offset() {
Some((_, _, rw, rh)) => (rw, rh),
None => (w, h),
};
let mut canvas = Image::new(w, h);
if self.depth > 16 {
return canvas;
@@ -725,12 +748,35 @@ impl<'a> Renderer<'a> {
}
}
/// The region of interest of a top-level 2D frame: (x, y) offset of the output in output
/// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp).
fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
let r = self.opts.roi.filter(|_| self.depth == 0)?;
let s = self.opts.scale;
if r[2] <= 0.0 || r[3] <= 0.0 {
return None;
}
Some(((r[0] * s).round(), (r[1] * s).round(), ((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1)))
}
/// Scaled comp pixel → output pixel: the region of interest's offset.
fn out_matrix(&self) -> Mat3 {
match self.roi_offset() {
Some((x, y, _, _)) => Mat3::translate(vec2(-x, -y)),
None => Mat3::IDENTITY,
}
}
/// Buffer pixel → output pixel matrix for the layer at the context time.
fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 {
let s = self.opts.scale;
let (l2c, _) = ctx.layer_to_comp(layer);
let l2c = self.outer.map_or(l2c, |o| o * l2c);
Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
self.out_matrix()
* Mat3::scale(vec2(s, s))
* l2c
* Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale))
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
}
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
@@ -885,7 +931,8 @@ impl<'a> Renderer<'a> {
masks::apply(ctx, layer, &mut foot);
let mut matte = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0);
let mut below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale };
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.
if let Some(c) = self.pipe.from_blend() {
color::convert(&mut below.img, &c);
@@ -953,6 +1000,8 @@ mod tests;
#[cfg(test)]
mod tests_remap;
#[cfg(test)]
mod tests_roi;
#[cfg(test)]
mod tests_styles;
/// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size
+22
View File
@@ -72,6 +72,28 @@ fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) {
(m, (ctx.f(layer, tr, "opacity", 100.0) / 100.0) as f32)
}
/// Shape item (a Path, Rectangle… inside nested Shape groups) → layer space: the product of the
/// enclosing groups' transforms. `None` when `uid` is not in the layer's contents.
pub fn item_matrix(ctx: &EvalCtx, layer: &Layer, uid: u64) -> Option<Mat3> {
fn walk(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, uid: u64, m: Mat3) -> Option<Mat3> {
for sub in g.groups() {
if sub.uid == uid {
return Some(m);
}
if sub.match_id == "group"
&& let Some(inner) = sub.sub("contents")
{
let gm = sub.sub("transform").map(|t| group_matrix(ctx, layer, t).0).unwrap_or(Mat3::IDENTITY);
if let Some(r) = walk(ctx, layer, inner, uid, m * gm) {
return Some(r);
}
}
}
None
}
walk(ctx, layer, layer.props.sub("contents")?, uid, Mat3::IDENTITY)
}
fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
}
+73
View File
@@ -0,0 +1,73 @@
//! Region of interest: a ROI render equals the matching crop of the full render.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::Value;
use effectcraft_project::build;
use effectcraft_project::{Comp, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Image, NoFootage, RenderOpts, Renderer};
fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) {
let mut p = Project::default();
let comp = Comp::new(160, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let mut layers = vec![];
for (i, (c, w, h, pos, rot)) in
[([1.0, 0.2, 0.1], 90, 50, [60.0, 50.0], 17.0), ([0.1, 0.4, 1.0], 70, 70, [100.0, 75.0], -30.0), ([0.9, 0.9, 0.2], 40, 30, [30.0, 95.0], 0.0)]
.into_iter()
.enumerate()
{
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: c, width: w, height: h, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]);
l.props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(rot);
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(80.0);
if i == 1 {
l.blend_mode = BlendMode::Screen;
l.switches.three_d = three_d;
}
layers.push(l);
}
p.comp_mut(cid).unwrap().layers = layers;
(p, cid)
}
fn crop(img: &Image, x: u32, y: u32, w: u32, h: u32) -> Image {
let mut out = Image::new(w, h);
for yy in 0..h {
for xx in 0..w {
out.data[(yy * w + xx) as usize] = img.data[((yy + y) * img.width + xx + x) as usize];
}
}
out
}
fn assert_same(a: &Image, b: &Image) {
assert_eq!((a.width, a.height), (b.width, b.height));
for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() {
for c in 0..4 {
assert!((p[c] - q[c]).abs() < 1e-4, "pixel {i} channel {c}: {} vs {}", p[c], q[c]);
}
}
}
#[test]
fn roi_render_equals_cropped_full_render() {
for three_d in [false, true] {
let (p, cid) = project(three_d);
let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
let roi = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some([30.0, 20.0, 80.0, 60.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert_eq!((roi.width, roi.height), (80, 60));
assert_same(&roi, &crop(&full, 30, 20, 80, 60));
}
}
#[test]
fn roi_at_half_resolution() {
let (p, cid) = project(false);
let full = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
let roi = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, roi: Some([40.0, 20.0, 60.0, 40.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert_eq!((roi.width, roi.height), (30, 20));
assert_same(&roi, &crop(&full, 20, 10, 30, 20));
}