From 0fe6db00e8952913f97811c8f5d62f23e651b4b5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 01:33:14 +0700 Subject: [PATCH 1/4] M4.9: Preserve frame rate when nested or in render queue, Preserve resolution when nested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composition Settings ▸ Advanced has both options (they were missing from the model, the renderer and the dialog): - Preserve frame rate: nested in another comp, the comp shows only its own frames (`EvalCtx::nested_time` floors the precomp layer's source time to the nested comp's frame rate; used for the nested frame, its proxy, aux channels, collapsed transformations and the viewer's internal wireframes), and the Render Queue renders it at its own rate whatever the Frame Rate override (`RenderSettings::rate`). - Preserve resolution: nested in a comp rendered at Half / Third / Quarter, it renders at full size and the buffer's scale places it. - `comp.settings {preserveFrameRate, preserveResolution}`, `comp.info` reports them, scripts read and set `CompItem.preserveNestedFrameRate` / `preserveNestedResolution`, Pre-compose copies them, and the dialog registers `dialog.comp.preserveFrameRate` / `dialog.comp.preserveResolution`. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/comp.rs | 10 +++- crates/engine/src/commands/query.rs | 1 + crates/engine/src/tests_menu_cmds.rs | 21 +++++++ crates/project/src/lib.rs | 12 ++++ crates/project/src/render_queue.rs | 4 +- crates/render/src/eval.rs | 13 +++++ crates/render/src/lib.rs | 19 ++++-- crates/render/src/tests_nested.rs | 67 ++++++++++++++++++++++ crates/script/src/model.rs | 1 + crates/script/src/prelude.js | 2 + crates/script/src/tests.rs | 18 ++++++ crates/ui-egui/src/panels/comp_settings.rs | 13 +++++ crates/ui-egui/src/panels/dialogs.rs | 4 +- crates/ui-egui/src/panels/viewer.rs | 2 +- 14 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 crates/render/src/tests_nested.rs diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index dec74406..57bd4855 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -90,6 +90,12 @@ fn apply_settings(c: &mut Comp, p: &Value) { if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) { c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256); } + if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) { + c.preserve_frame_rate = b; + } + if let Some(b) = p.get("preserveResolution").and_then(Value::as_bool) { + c.preserve_resolution = b; + } } fn new_comp(s: &mut Session, p: &Value) -> Result { @@ -252,7 +258,7 @@ pub fn specs() -> Vec { "New Composition...", ["Composition"], Some("Cmd+N"), - "{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), renderer? classic3D|advanced3D, anchor?, open?}", + "{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer? classic3D|advanced3D, anchor?, open?}", always, new_comp ), @@ -261,7 +267,7 @@ pub fn specs() -> Vec { "Composition Settings...", ["Composition"], Some("Cmd+K"), - "{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), pixelAspect?, renderer? classic3D|advanced3D}", + "{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer? classic3D|advanced3D}", has_comp, settings ), diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 267b4605..27588907 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -79,6 +79,7 @@ fn comp_info(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(), "workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background, + "preserveFrameRate": c.preserve_frame_rate, "preserveResolution": c.preserve_resolution, "layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::>(), })) } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec6e09b6..af005b50 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -811,3 +811,24 @@ fn project_files_keep_their_format_warn_about_newer_versions_and_never_save_unre let e = s.execute("file.open", json!({"path": junk})).unwrap_err().to_string(); assert!(e.contains("is not a project EffectCraft can open"), "{e}"); } + +#[test] +fn comp_settings_preserve_frame_rate_and_resolution() { + let mut s = comp(); + let info = s.execute("comp.info", json!({})).unwrap(); + assert_eq!((info["preserveFrameRate"].clone(), info["preserveResolution"].clone()), (json!(false), json!(false))); + s.execute_checked("comp.settings", json!({"preserveFrameRate": true, "preserveResolution": true})).unwrap(); + let info = s.execute("comp.info", json!({})).unwrap(); + assert_eq!((info["preserveFrameRate"].clone(), info["preserveResolution"].clone()), (json!(true), json!(true))); + // Pre-compose keeps them; one undo step clears them. + let a = solid(&mut s, "#ff0000"); + let r = s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap(); + let inner = s.project.comp(effectcraft_project::ItemId(r["comp"].as_u64().unwrap())).unwrap(); + assert!(inner.preserve_frame_rate && inner.preserve_resolution); + // Undo Pre-compose and the solid; then one more step undoes the settings. + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().preserve_frame_rate); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!s.active_comp().unwrap().preserve_frame_rate && !s.active_comp().unwrap().preserve_resolution); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 6074cb53..0e7f8c27 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -308,6 +308,14 @@ pub struct Comp { pub enable_frame_blending: bool, #[serde(default)] pub draft_3d: bool, + /// Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue: + /// nested in another comp, or rendered at another rate, it shows only its own frames. + #[serde(default)] + pub preserve_frame_rate: bool, + /// Composition Settings ▸ Advanced ▸ Preserve resolution when nested: nested in a comp + /// previewed at a lower resolution, it still renders at full size. + #[serde(default)] + pub preserve_resolution: bool, #[serde(default)] pub poster_time: Tick, /// Global Light for layer styles (Layer ▸ Layer Styles ▸ Blending Options). @@ -358,6 +366,8 @@ impl Comp { enable_motion_blur: true, enable_frame_blending: true, draft_3d: false, + preserve_frame_rate: false, + preserve_resolution: false, poster_time: Tick::ZERO, guides: vec![], global_light: styles::GlobalLight::default(), @@ -377,6 +387,8 @@ impl Comp { renderer: self.renderer, enable_motion_blur: self.enable_motion_blur, enable_frame_blending: self.enable_frame_blending, + preserve_frame_rate: self.preserve_frame_rate, + preserve_resolution: self.preserve_resolution, ..Comp::new(width, height, self.frame_rate, duration) } } diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index aca651b9..f6a46caa 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -418,8 +418,10 @@ impl RenderSettings { let b = b.clamp(a, comp.duration); (a, b) } + /// The output frame rate: the Frame Rate override, unless the comp preserves its frame rate + /// (Composition Settings ▸ Preserve frame rate when nested or in render queue). pub fn rate(&self, comp: &Comp) -> FrameRate { - self.frame_rate.unwrap_or(comp.frame_rate) + if comp.preserve_frame_rate { comp.frame_rate } else { self.frame_rate.unwrap_or(comp.frame_rate) } } /// First output-rate frame starting at or after `t`. fn ceil_frame(r: FrameRate, t: Tick) -> i64 { diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index d8d3f641..b082e5f8 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -114,6 +114,19 @@ impl<'a> EvalCtx<'a> { } layer.layer_time(self.time) } + /// The time a precomp layer's nested comp is shown at: [`Self::source_time`], floored to + /// the nested comp's own frames when it preserves its frame rate (Composition Settings ▸ + /// Preserve frame rate when nested or in render queue). + pub fn nested_time(&self, layer: &Layer) -> Tick { + let t = self.source_time(layer); + match layer.source { + LayerSource::Comp { item } => match self.project.comp(item) { + Some(nc) if nc.preserve_frame_rate => nc.frame_rate.tick_of(crate::frame_position(t, nc.frame_rate).0), + _ => t, + }, + _ => t, + } + } pub fn layer(&self, id: LayerId) -> Option<&'a Layer> { self.comp.layer(id) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 13d8fc4b..777fae25 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -157,7 +157,7 @@ impl EffectHost for FxHost<'_, '_, '_> { if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) { return None; } - self.r.nested().comp_aux(*item, self.ctx.source_time(self.layer)).map(Arc::new) + self.r.nested().comp_aux(*item, self.ctx.nested_time(self.layer)).map(Arc::new) } _ => None, } @@ -558,7 +558,7 @@ impl<'a> Renderer<'a> { /// 3D layers use this comp's camera and lights. pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> { let nc = self.project.comp(item)?; - let nctx = EvalCtx { comp_id: item, comp: nc, time: ctx.source_time(layer), ..*ctx }; + let nctx = EvalCtx { comp_id: item, comp: nc, time: ctx.nested_time(layer), ..*ctx }; let (l2c, _) = ctx.layer_to_comp(layer); let outer = Some(self.outer.map_or(l2c, |o| o * l2c)); let world = ctx.world_matrix(layer); @@ -928,19 +928,24 @@ impl<'a> Renderer<'a> { if let Some(pf) = self.proxy_for(*item) && let Some(nc) = self.project.comp(*item) { - let img = self.footage.frame(*item, pf, ctx.source_time(layer))?; + let img = self.footage.frame(*item, pf, ctx.nested_time(layer))?; return Some(self.footage_buf(&img, pf, nc.width, nc.height)); } // Essential Properties overrides render the nested comp with this instance's // values. let ov = essential_overrides(ctx, layer); let tmp = if ov.is_empty() { None } else { effectcraft_project::essential::with_overrides(self.project, *item, &ov) }; + // Preserve resolution when nested: drawn at full size even when this comp renders + // smaller (the buffer's scale places it). + let full = s < 1.0 && self.project.comp(*item).is_some_and(|nc| nc.preserve_resolution); + let bs = if full { 1.0 } else { s }; let base = self.nested_through(layer); + let base = Renderer { opts: RenderOpts { scale: bs, ..base.opts }, ..base }; let sub = match &tmp { Some(p) => Renderer { project: p, ..base }, None => base, }; - let lt = ctx.source_time(layer); + let lt = ctx.nested_time(layer); // Frame blending between the nested comp's frames (time-stretched/remapped). let mode = frame_blend_mode(ctx, layer); if mode != FrameBlend::Off @@ -950,10 +955,10 @@ impl<'a> Renderer<'a> { if w > 1e-6 { let a = sub.comp_frame(*item, nc.frame_rate.tick_of(i)); let b = sub.comp_frame(*item, nc.frame_rate.tick_of(i + 1)); - return Some(Buf { img: blend_frames(mode, &a, &b, w as f32), offset: [0.0; 2], scale: s }); + return Some(Buf { img: blend_frames(mode, &a, &b, w as f32), offset: [0.0; 2], scale: bs }); } } - Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s }) + Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs }) } LayerSource::Footage { item } => { let it = self.project.item(*item)?; @@ -1901,6 +1906,8 @@ mod tests_color; #[cfg(test)] mod tests_frame_blend; #[cfg(test)] +mod tests_nested; +#[cfg(test)] mod tests_paint; #[cfg(test)] mod tests_time; diff --git a/crates/render/src/tests_nested.rs b/crates/render/src/tests_nested.rs new file mode 100644 index 00000000..ea0e03e5 --- /dev/null +++ b/crates/render/src/tests_nested.rs @@ -0,0 +1,67 @@ +//! Nested compositions: Preserve frame rate when nested or in render queue, Preserve resolution +//! when nested. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build; +use effectcraft_project::render_queue::RenderSettings; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::render_frame; + +fn s(x: f64) -> Tick { + Tick::from_seconds_f64(x) +} + +/// A 30 fps outer comp nesting a 10 fps 100×100 comp whose 10×10 solid moves from x=10 (0 s) +/// to x=90 (2 s). Returns (project, outer, inner). +fn nested(preserve_rate: bool, preserve_res: bool) -> (Project, ItemId, ItemId) { + let mut p = Project::default(); + let outer = Comp::new(100, 100, FrameRate::FPS_30, s(4.0)); + let mut inner = Comp::new(100, 100, FrameRate::from_f64(10.0), s(4.0)); + inner.preserve_frame_rate = preserve_rate; + inner.preserve_resolution = preserve_res; + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 10, height: 10, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 10), None); + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 50.0, 0.0])), Keyframe::new(s(2.0), Value::Vec3([90.0, 50.0, 0.0]))]; + inner.layers.push(l); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into())); + let pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); + p.comp_mut(cid).unwrap().layers.push(pre); + (p, cid, iid) +} + +#[test] +fn a_comp_preserving_its_frame_rate_shows_only_its_own_frames_when_nested() { + // 2/30 s falls between the nested comp's frames 0 (0 s) and 1 (0.1 s). + let between = s(2.0 / 30.0); + let (p, cid, _) = nested(false, false); + assert_ne!(render_frame(&p, cid, between, 1.0).data, render_frame(&p, cid, Tick::ZERO, 1.0).data, "follows the outer comp's frames"); + let (p, cid, _) = nested(true, false); + assert_eq!(render_frame(&p, cid, between, 1.0).data, render_frame(&p, cid, Tick::ZERO, 1.0).data, "holds its own frame 0"); + assert_ne!(render_frame(&p, cid, s(0.1), 1.0).data, render_frame(&p, cid, Tick::ZERO, 1.0).data, "then its frame 1"); +} + +#[test] +fn the_render_queue_keeps_a_preserved_frame_rate() { + let (p, _, iid) = nested(true, false); + let settings = RenderSettings { frame_rate: Some(FrameRate::FPS_30), ..Default::default() }; + assert_eq!(settings.rate(p.comp(iid).unwrap()), FrameRate::from_f64(10.0)); + let (p, _, iid) = nested(false, false); + assert_eq!(settings.rate(p.comp(iid).unwrap()), FrameRate::FPS_30); +} + +#[test] +fn a_comp_preserving_its_resolution_renders_full_size_and_lands_in_place() { + for preserve in [false, true] { + let (p, cid, _) = nested(false, preserve); + let img = render_frame(&p, cid, Tick::ZERO, 0.5); + assert_eq!((img.width, img.height), (50, 50)); + // The solid covers x 5..15, y 45..55 in the 100×100 comp: 2.5..7.5, 22.5..27.5 at half size. + assert!(img.get(5, 25)[3] > 0.99, "preserve {preserve}: inside"); + assert!(img.get(20, 25)[3] < 0.01 && img.get(5, 40)[3] < 0.01, "preserve {preserve}: outside"); + } +} diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 18270590..4a92f396 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -131,6 +131,7 @@ fn item(s: &Session, a: &J) -> R { "draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase, "motionBlurSamplesPerFrame": c.motion_blur_samples, "motionBlurAdaptiveSampleLimit": c.motion_blur_adaptive_limit, + "preserveNestedFrameRate": c.preserve_frame_rate, "preserveNestedResolution": c.preserve_resolution, "renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" }, "open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid), "hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })), diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index c06bc9bf..423e1b89 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1409,6 +1409,8 @@ CompItem.prototype.constructor = CompItem; def("shutterAngle", function () { return this.__info().shutterAngle; }, function (v) { this.__settings({ shutterAngle: __num(v) }); }); def("shutterPhase", function () { return this.__info().shutterPhase; }, function (v) { this.__settings({ shutterPhase: __num(v) }); }); def("motionBlurSamplesPerFrame", function () { return this.__info().motionBlurSamplesPerFrame; }, function (v) { this.__settings({ motionBlurSamples: __num(v) }); }); + def("preserveNestedFrameRate", function () { return this.__info().preserveNestedFrameRate; }, function (v) { this.__settings({ preserveFrameRate: !!v }); }); + def("preserveNestedResolution", function () { return this.__info().preserveNestedResolution; }, function (v) { this.__settings({ preserveResolution: !!v }); }); def("renderer", function () { return this.__info().renderer; }, function (v) { this.__settings({ renderer: String(v).toLowerCase().indexOf("advanced") >= 0 ? "advanced3D" : "classic3D" }); }); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 4df252b6..d71d20cb 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -484,3 +484,21 @@ fn motion_graphics_template_hooks_add_mirrors_and_controllers() { let eg = c.essential.as_ref().unwrap(); assert!(matches!(eg.controls[1].kind, effectcraft_engine::project::essential::EgKind::Mirror { of } if of == eg.controls[0].id)); } + +#[test] +fn comp_preserves_nested_frame_rate_and_resolution() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var comp = app.project.items.addComp("Nested", 320, 180, 1, 2, 12); + var before = [comp.preserveNestedFrameRate, comp.preserveNestedResolution]; + comp.preserveNestedFrameRate = true; + comp.preserveNestedResolution = true; + before.concat([comp.preserveNestedFrameRate, comp.preserveNestedResolution]).join(","); + "#, + ); + assert_eq!(o.result, json!("false,false,true,true")); + let (_, c) = comp_named(&s, "Nested"); + assert!(c.preserve_frame_rate && c.preserve_resolution); +} diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 6e481c6b..2befd5e6 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -88,6 +88,8 @@ pub struct CompDraft { pub shutter_phase: f64, pub samples: u32, pub adaptive_limit: u32, + pub preserve_frame_rate: bool, + pub preserve_resolution: bool, pub advanced_3d: bool, pub tab: Tab, /// Timecode text being edited (start, duration). @@ -112,6 +114,8 @@ impl Default for CompDraft { shutter_phase: -90.0, samples: 16, adaptive_limit: 128, + preserve_frame_rate: false, + preserve_resolution: false, advanced_3d: false, tab: Tab::Basic, start_tc: None, @@ -304,6 +308,14 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr } }); ui.end_row(); + ui.label(""); + let r = ui.checkbox(&mut d.preserve_frame_rate, "Preserve frame rate when nested or in render queue"); + auto.add("dialog.comp.preserveFrameRate", r.rect, "Preserve frame rate when nested or in render queue"); + ui.end_row(); + ui.label(""); + let r = ui.checkbox(&mut d.preserve_resolution, "Preserve resolution when nested"); + auto.add("dialog.comp.preserveResolution", r.rect, "Preserve resolution when nested"); + ui.end_row(); ui.label(egui::RichText::new("Motion Blur").strong()); ui.end_row(); ui.label("Shutter Angle:"); @@ -360,6 +372,7 @@ pub fn params(d: &CompDraft) -> serde_json::Value { "name": d.name, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps, "duration": d.duration, "startTime": d.start, "background": [d.bg[0], d.bg[1], d.bg[2]], "anchor": d.anchor, "shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples, "adaptiveSampleLimit": d.adaptive_limit, + "preserveFrameRate": d.preserve_frame_rate, "preserveResolution": d.preserve_resolution, "renderer": if d.advanced_3d { "advanced3D" } else { "classic3D" }, }) } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 3820adcc..d806827c 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -83,6 +83,8 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> { shutter_phase: c.shutter_phase, samples: c.motion_blur_samples, adaptive_limit: c.motion_blur_adaptive_limit, + preserve_frame_rate: c.preserve_frame_rate, + preserve_resolution: c.preserve_resolution, advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D, ..Default::default() }; @@ -604,7 +606,7 @@ mod tests { } app.dialog_state.comp.tab = super::super::comp_settings::Tab::Advanced; frame(&mut app, &ctx); - for id in ["anchor.4", "shutterAngle", "shutterPhase", "samples", "adaptiveLimit"] { + for id in ["anchor.4", "preserveFrameRate", "preserveResolution", "shutterAngle", "shutterPhase", "samples", "adaptiveLimit"] { assert!(app.auto.find(&format!("dialog.comp.{id}")).is_some(), "dialog.comp.{id}"); } app.dialog_state.comp.tab = super::super::comp_settings::Tab::Basic; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 7a0a126f..8e9197c0 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -674,7 +674,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(nc) = app.session.project.comp(*item) { let (outer, _) = l2c(&ectx, l); - let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.source_time(l)); + let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.nested_time(l)); for nl in nc.layers.iter().filter(|nl| nl.is_active_at(nctx.time) && !nl.is_3d()) { if let Some((_, q, _)) = layer_quad(&nctx, nl) { let sq: Vec = q From 0b5cff1743f9696957f7ab14ad2d912654a005e2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 01:47:54 +0700 Subject: [PATCH 2/4] M4.10: a nested comp shows nothing before it starts or after it ends A precomp layer trimmed out (or time-remapped) past its nested comp's end kept drawing the nested layers whose bars ran on. Like After Effects, the nested comp now shows nothing outside its own span (`Comp::covers`), flattened, through a proxy or with Collapse Transformations, on the CPU and the GPU (both go through `Renderer::source` / `collapse_into`). Co-Authored-By: Claude Opus 5.5 --- crates/project/src/lib.rs | 5 +++++ crates/render/src/lib.rs | 14 +++++++++++--- crates/render/src/tests_nested.rs | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 0e7f8c27..2771c65e 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -374,6 +374,11 @@ impl Comp { essential: None, } } + /// Whether the composition has a frame at its time `t`: nested, it shows nothing before it + /// starts or after it ends (a precomp layer trimmed or remapped past it). + pub fn covers(&self, t: Tick) -> bool { + t >= Tick::ZERO && t < self.duration + } /// A new, empty composition with this one's settings (frame rate, pixel aspect, background, /// motion blur, frame blending and the 3D renderer), as Pre-compose makes. pub fn nested_like(&self, width: u32, height: u32, duration: Tick) -> Comp { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 777fae25..540ce9e9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -558,7 +558,11 @@ impl<'a> Renderer<'a> { /// 3D layers use this comp's camera and lights. pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> { let nc = self.project.comp(item)?; - let nctx = EvalCtx { comp_id: item, comp: nc, time: ctx.nested_time(layer), ..*ctx }; + let t = ctx.nested_time(layer); + if !nc.covers(t) { + return None; + } + let nctx = EvalCtx { comp_id: item, comp: nc, time: t, ..*ctx }; let (l2c, _) = ctx.layer_to_comp(layer); let outer = Some(self.outer.map_or(l2c, |o| o * l2c)); let world = ctx.world_matrix(layer); @@ -925,10 +929,15 @@ impl<'a> Renderer<'a> { return None; } // A composition proxy (a rendered still or movie) stands in for the comp. + // Nothing before the nested comp starts or after it ends. + let lt = ctx.nested_time(layer); + if !self.project.comp(*item).is_some_and(|nc| nc.covers(lt)) { + return None; + } if let Some(pf) = self.proxy_for(*item) && let Some(nc) = self.project.comp(*item) { - let img = self.footage.frame(*item, pf, ctx.nested_time(layer))?; + let img = self.footage.frame(*item, pf, lt)?; return Some(self.footage_buf(&img, pf, nc.width, nc.height)); } // Essential Properties overrides render the nested comp with this instance's @@ -945,7 +954,6 @@ impl<'a> Renderer<'a> { Some(p) => Renderer { project: p, ..base }, None => base, }; - let lt = ctx.nested_time(layer); // Frame blending between the nested comp's frames (time-stretched/remapped). let mode = frame_blend_mode(ctx, layer); if mode != FrameBlend::Off diff --git a/crates/render/src/tests_nested.rs b/crates/render/src/tests_nested.rs index ea0e03e5..6b153d32 100644 --- a/crates/render/src/tests_nested.rs +++ b/crates/render/src/tests_nested.rs @@ -65,3 +65,26 @@ fn a_comp_preserving_its_resolution_renders_full_size_and_lands_in_place() { assert!(img.get(20, 25)[3] < 0.01 && img.get(5, 40)[3] < 0.01, "preserve {preserve}: outside"); } } + +#[test] +fn a_nested_comp_shows_nothing_past_its_end() { + for collapse in [false, true] { + let mut p = Project::default(); + let outer = Comp::new(100, 100, FrameRate::FPS_30, s(4.0)); + // A 1 s comp with a solid covering it for 4 s (longer than the comp itself). + let mut inner = Comp::new(100, 100, FrameRate::FPS_30, s(1.0)); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 100, height: 100, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (100, 100), None); + l.out_point = s(4.0); + inner.layers.push(l); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into())); + // The precomp layer is trimmed out to 3 s, past the nested comp's end. + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); + pre.out_point = s(3.0); + pre.switches.collapse = collapse; + p.comp_mut(cid).unwrap().layers.push(pre); + assert!(render_frame(&p, cid, s(0.5), 1.0).get(50, 50)[3] > 0.99, "collapse {collapse}: inside its span"); + assert!(render_frame(&p, cid, s(2.0), 1.0).get(50, 50)[3] < 0.01, "collapse {collapse}: past its end"); + } +} From 995d99f93868b03bb477dac28e9b52326dabd4a1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 01:55:34 +0700 Subject: [PATCH 3/4] M4.11: one motion blur gate for layers, masks, effects and Advanced 3D MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Renderer::mb_on` (render options × the comp's switch × the layer's switch as the precomp layers above limit it) now decides everywhere: - Mask motion blur checked only the comp's switch and the layer's own switch, so Render Settings ▸ Motion Blur ▸ Off for All Layers and a precomp layer with motion blur off still blurred masks; it now takes the gate (`masks::MaskBlur`) and the comp's Samples Per Frame (four in Draft) instead of a fixed eight. - Effects that read the shutter (`EffectEnv::shutter`) get it only when the layer is motion blurred in this render. - Advanced 3D layers follow the precomp layers' Motion Blur switches too. - The layer cache key of a layer with masks or effects includes whether it is blurred and the comp's shutter and samples, so a buffer blurred for the viewer is never reused by a render with motion blur off. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/cache.rs | 26 +++++++------ crates/render/src/lib.rs | 35 ++++++++++------- crates/render/src/masks.rs | 45 +++++++++++++--------- crates/render/src/tests_mask_blur.rs | 52 ++++++++++++++++++++++++++ crates/render/src/text/tests.rs | 4 +- crates/render/src/three_d/adv/scene.rs | 5 ++- 6 files changed, 122 insertions(+), 45 deletions(-) create mode 100644 crates/render/src/tests_mask_blur.rs diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 8c85490b..e487e6ce 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -478,21 +478,21 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { /// Cache key for the processed (source → masks → effects) buffer of `layer` at the context /// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment /// layers…). -pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option { +pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { // Time effects see property values at other times. Keyframes are part of the hashed // structure (and the layer time is folded in), but expressions may read other layers at // those times, which the key cannot see: such layers are not cached. if reads_other_times(layer) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) { return None; } - key_with(ctx, layer, scale, draft, false) + key_with(ctx, layer, scale, draft, blur, false) } /// Cache key for a layer's *input* at the context time: source → masks → its first `effects` /// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here /// (keyed by item and source time), since Time effects read many neighbouring frames. -pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, effects: usize) -> Option { - let base = key_with(ctx, layer, scale, draft, true)?; +pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, effects: usize) -> Option { + let base = key_with(ctx, layer, scale, draft, blur, true)?; let mut h = KeyHasher(base ^ 0x5bd1_e995_7a3c_11d3); effects.hash(&mut h); Some(h.finish()) @@ -515,18 +515,19 @@ fn has_expression(g: &PropGroup) -> bool { /// Cache key for an adjustment layer's footprint (its source through its masks, see /// `Renderer::adjustment_footprint`): the GPU compositor then uploads it once, not every frame. -pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option { - Some(derive(key_any(ctx, layer, scale, draft, false)?, 0xf007_9417)) +pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { + Some(derive(key_any(ctx, layer, scale, draft, blur, false)?, 0xf007_9417)) } -fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option { +fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option { if layer.switches.adjustment { return None; } - key_any(ctx, layer, scale, draft, footage) + key_any(ctx, layer, scale, draft, blur, footage) } -fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option { +/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). +fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option { let mut h = KeyHasher(0xcbf2_9ce4_8422_2325); match &layer.source { LayerSource::Solid { item } => { @@ -558,11 +559,14 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) layer.id.hash(&mut h); hash_debug(&mut h, &layer.source); hash_debug(&mut h, &layer.switches); - // Mask motion blur samples the comp's shutter. - if layer.masks().is_some_and(|m| !m.children.is_empty()) { + // Mask motion blur and effects that read the shutter depend on whether the layer is motion + // blurred in this render and on the comp's shutter. + if layer.masks().is_some_and(|m| !m.children.is_empty()) || layer.effects().is_some_and(|fx| !fx.children.is_empty()) { + blur.hash(&mut h); ctx.comp.enable_motion_blur.hash(&mut h); ctx.comp.shutter_angle.to_bits().hash(&mut h); ctx.comp.shutter_phase.to_bits().hash(&mut h); + ctx.comp.motion_blur_samples.hash(&mut h); } for c in &layer.props.children { // Transform is applied later; Layer Styles are keyed separately (see [`styles_key`]). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 540ce9e9..e49ee2b8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -841,11 +841,7 @@ impl<'a> Renderer<'a> { effect_index: 0, working_space: self.pipe.space, working_linear: self.pipe.linear, - shutter: (ctx.comp.enable_motion_blur && layer.switches.motion_blur).then_some(( - ctx.comp.shutter_angle, - ctx.comp.shutter_phase, - ctx.comp.motion_blur_samples, - )), + shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)), }; // Video effects in stack order (index, group, spec); disabled and audio effects skipped. let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx @@ -1085,7 +1081,7 @@ impl<'a> Renderer<'a> { let mut buf = self.source(ctx, layer)?; // A solid is filled with a quantised colour: only masks can take it off the grid. let solid = matches!(layer.source, LayerSource::Solid { .. }); - if masks::apply(ctx, layer, &mut buf) || !solid { + if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !solid { self.pipe.quantize(&mut buf.img); } Some(buf) @@ -1152,7 +1148,7 @@ impl<'a> Renderer<'a> { } fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { - let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft)); + let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -1173,7 +1169,7 @@ impl<'a> Renderer<'a> { /// (what Time effects read at neighbouring times). Served from / stored in the layer cache /// under its own key, so scrubbing reuses frames rendered for earlier output frames. pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option> { - let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, effects)); + let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -1285,11 +1281,22 @@ impl<'a> Renderer<'a> { * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1])) } - /// Whether a layer is motion blurred. - fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool { + /// Whether a layer is motion blurred: the render options, the comp's switch and the layer's + /// switch (limited by the precomp layers above when switches affect nested comps). Layer + /// motion, mask motion blur, effects that read the shutter and Advanced 3D all ask this. + pub(crate) fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool { self.opts.motion_blur && ctx.comp.enable_motion_blur && self.layer_motion_blur(layer) } + /// What a layer's mask motion blur may do in this render (see [`masks::MaskBlur`]). + fn mask_blur(&self, ctx: &EvalCtx, layer: &Layer) -> masks::MaskBlur { + masks::MaskBlur { + allowed: self.opts.motion_blur && ctx.comp.enable_motion_blur && self.inherited.is_none_or(|(_, mb)| mb), + layer: self.layer_motion_blur(layer), + samples: if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples as usize }, + } + } + /// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D /// layer gets about one sample per output pixel its corners travel during the shutter, /// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that @@ -1545,7 +1552,7 @@ impl<'a> Renderer<'a> { /// Adjustment layer: effects applied to the comp below, limited to the layer's footprint. 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); + 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); let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]); @@ -1708,14 +1715,14 @@ impl<'a> Renderer<'a> { /// its effects show. `None` = the layer changes nothing. pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { // Cached, so an accelerator sees the same buffer (and uploads it once) every frame. - let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft)); + let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { return Some(b); } let mut foot = self.source(ctx, layer)?; - let _ = masks::apply(ctx, layer, &mut foot); + let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); let foot = Arc::new(foot); if let (Some(c), Some(k)) = (self.cache, key) { c.insert(k, foot.clone()); @@ -1914,6 +1921,8 @@ mod tests_color; #[cfg(test)] mod tests_frame_blend; #[cfg(test)] +mod tests_mask_blur; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 8fcb39b7..0cfdc0e7 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -10,29 +10,40 @@ use rayon::prelude::*; use crate::eval::EvalCtx; -/// Sub-frame samples for mask motion blur (animated mask paths only). -const MASK_BLUR_SAMPLES: usize = 8; +/// What mask motion blur may do in a render (from the renderer's motion blur gate). +#[derive(Clone, Copy, Debug, Default)] +pub struct MaskBlur { + /// Motion blur is on in this render: the render options, the comp's switch and the Motion + /// Blur switches of the precomp layers above (when switches affect nested comps). + pub allowed: bool, + /// The layer's own Motion Blur switch, as those precomp layers limit it (Same As Layer). + pub layer: bool, + /// Sub-frame samples (the comp's Samples Per Frame; fewer in Draft). + pub samples: usize, +} /// Coverage of one mask in buffer pixels (before mode/opacity/invert). With mask motion blur /// (Layer ▸ Mask ▸ Motion Blur: On, or Same As Layer with the layer's switch on) an animated /// path is averaged over the comp's shutter. -fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option { - let mb = match g.kind { - GroupKind::Mask { motion_blur, .. } => match motion_blur { - MaskMotionBlur::On => true, - MaskMotionBlur::Off => false, - MaskMotionBlur::SameAsLayer => layer.switches.motion_blur, - }, - _ => false, - }; +fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf, blur: MaskBlur) -> Option { + let mb = blur.allowed + && match g.kind { + GroupKind::Mask { motion_blur, .. } => match motion_blur { + MaskMotionBlur::On => true, + MaskMotionBlur::Off => false, + MaskMotionBlur::SameAsLayer => blur.layer, + }, + _ => false, + }; let animated = g.get("path").is_some_and(|p| p.keys.len() > 1 || p.has_expression()); - if mb && animated && ctx.comp.enable_motion_blur { + if mb && animated { let fd = ctx.comp.frame_duration().seconds(); let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0); let mut acc: Option = None; - let k = 1.0 / MASK_BLUR_SAMPLES as f32; - for i in 0..MASK_BLUR_SAMPLES { - let f = phase + angle * i as f64 / (MASK_BLUR_SAMPLES - 1) as f64; + let n = blur.samples.max(2); + let k = 1.0 / n as f32; + for i in 0..n { + let f = phase + angle * i as f64 / (n - 1) as f64; let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd)); let Some(c) = coverage_at(&sub, layer, g, buf) else { continue }; match &mut acc { @@ -121,7 +132,7 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec bool { +pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> bool { let Some(masks) = layer.masks() else { return false }; let list: Vec<_> = masks.groups().filter(|g| g.enabled && matches!(g.kind, GroupKind::Mask { mode, .. } if mode != MaskMode::None)).collect(); if list.is_empty() { @@ -133,7 +144,7 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) -> bool { let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n]; for g in list { let GroupKind::Mask { mode, inverted, .. } = g.kind else { continue }; - let Some(mut cov) = coverage(ctx, layer, g, buf) else { continue }; + let Some(mut cov) = coverage(ctx, layer, g, buf, blur) else { continue }; let op = (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32; cov.data.par_iter_mut().for_each(|c| { if inverted { diff --git a/crates/render/src/tests_mask_blur.rs b/crates/render/src/tests_mask_blur.rs new file mode 100644 index 00000000..12b88bbb --- /dev/null +++ b/crates/render/src/tests_mask_blur.rs @@ -0,0 +1,52 @@ +//! Mask motion blur follows the renderer's motion blur gate: the render options (Render +//! Settings ▸ Motion Blur, previews), the comp's switch and the precomp layers above. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, LayerSource, MaskMode, MaskMotionBlur, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +/// A white 100×100 solid whose rect mask jumps from x 10 (frame 0) to x 50 (frame 1), Mask +/// Motion Blur On, the layer's own switch off. Rendered half way, unblurred the mask covers x +/// 30..50; blurred over the shutter it reaches down to x ≈ 20. +fn project(comp_blur: bool) -> (Project, ItemId) { + let mut p = Project::default(); + let mut comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + comp.enable_motion_blur = comp_blur; + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 100, height: 100, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (100, 100), None); + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([20.0, 50.0], 20.0, 100.0), MaskMode::Add, [255, 0, 0]); + p.next_id = next; + if let GroupKind::Mask { motion_blur, .. } = &mut m.kind { + *motion_blur = MaskMotionBlur::On; + } + let path = m.get_mut("path").unwrap(); + path.keys = vec![ + Keyframe::new(Tick::ZERO, Value::Path(ShapePath::rect([20.0, 50.0], 20.0, 100.0))), + Keyframe::new(FrameRate::FPS_30.tick_of(1), Value::Path(ShapePath::rect([60.0, 50.0], 20.0, 100.0))), + ]; + l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); + l.switches.motion_blur = false; + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 { + let half = Tick::from_seconds_f64(0.5 / 30.0); + Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3] +} + +#[test] +fn mask_motion_blur_follows_the_render_and_the_comp() { + let (p, cid) = project(true); + let a = alpha_at_25(&p, cid, true); + assert!(a > 0.05 && a < 0.95, "blurred: {a}"); + assert!(alpha_at_25(&p, cid, false) < 0.01, "Render Settings ▸ Motion Blur off"); + let (p, cid) = project(false); + assert!(alpha_at_25(&p, cid, true) < 0.01, "the comp's switch off"); +} diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index b7073667..93913aa8 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -157,8 +157,8 @@ fn wiggly_selector_is_deterministic_and_time_dependent() { assert!(ys(&a).iter().all(|y| y.abs() <= 50.0 + 1e-9)); assert!(ys(&a).windows(2).any(|w| (w[0] - w[1]).abs() > 1e-6)); // The layer cache keys a wiggly text layer by time. - let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false); - let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false); + let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false, false); + let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false, false); assert_ne!(k1, k2); // Renders identically twice. let r1 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5); diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 1570c2c6..79b5dc16 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -882,9 +882,10 @@ pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32) build_at(r, ctx, None, run, out) } -/// Whether a layer's motion is blurred (Motion Blur switch, comp setting and render option). +/// Whether a layer's motion is blurred (the renderer's gate: Motion Blur switch, comp setting, +/// render option and the precomp layers above). pub(crate) fn motion_blurred(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> bool { - r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur + r.mb_on(ctx, layer) } /// [`build`] at a motion-blur sub-sample: layers with Motion Blur on, the camera and the lights From e295679b7f9442bb2687c819de9cd01039dbb693 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 01:56:17 +0700 Subject: [PATCH 4/4] M4.11: docs: architecture, parity and ROADMAP cover nested comps and motion blur Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- docs/architecture.md | 13 +++++++++++++ docs/parity.md | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 91c672a4..10ec825c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ once. | M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done | | M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done | | M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) | -| M4 | Preview: precomps, motion blur, cached playback | Mostly done | +| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle; left: motion blur of collapsed precomps and of animated layer content) | | M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done | | M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done | | M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review | diff --git a/docs/architecture.md b/docs/architecture.md index 6343ea64..a03ff587 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -299,6 +299,19 @@ Continuously Rasterize: the source is rasterised at its on-screen scale. Quality nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, dragging the source bar in the timeline) moves the source under fixed in/out points. +**Nested comps** show nothing before they start or after they end (`Comp::covers`), even when +the precomp layer is trimmed or remapped past them. A comp with **Preserve frame rate when nested +or in render queue** shows only its own frames when nested (`EvalCtx::nested_time` floors the +precomp layer's source time to its frame rate) and renders at its own rate in the Render Queue; +one with **Preserve resolution when nested** renders at full size inside a comp drawn at a lower +resolution (the buffer's scale places it). + +**Motion blur** has one gate, `Renderer::mb_on`: the render options (Render Settings ▸ Motion +Blur, previews), the comp's switch and the layer's switch, limited by the precomp layers above +when switches affect nested comps. Layer motion, mask motion blur (the comp's Samples Per Frame, +four in Draft), effects that read the shutter and Advanced 3D all follow it, and the layer cache +key of a layer with masks or effects records it. + **Motion tracking** (Animation ▸ Track Motion / Stabilize Motion, Window ▸ Tracker): a tracker is a `Tracker` group under the layer's Motion Trackers group, with Track Point groups (Feature Center, Feature Size, Search Offset, Search Size, Confidence, Attach Point, Attach Point Offset) diff --git a/docs/parity.md b/docs/parity.md index 8b131007..72879626 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -383,6 +383,22 @@ Browser in the web app (landed in M13.10: browser storage and File System Access wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. +## Update: M4.9–M4.11 nested comps and motion blur + +- **Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue** and + **Preserve resolution when nested** (model, renderer, Render Queue, dialog, `comp.settings`, + `comp.info`, `CompItem.preserveNestedFrameRate` / `preserveNestedResolution`; Pre-compose + copies them). +- **Nested comps past their end** show nothing (flattened, through a proxy, or collapsed); they + kept drawing layers whose bars ran on. +- **Motion blur gate**: mask motion blur, effects that read the shutter and Advanced 3D follow + the render options and the precomp layers' switches like layer motion does; mask motion blur + uses the comp's Samples Per Frame. + +Not yet: collapsed precomps don't blur with the precomp layer's own motion or the parent +camera's; animated content inside a layer (shape paths, text animators, nested frames) isn't +re-rendered per sub-sample; nested comp markers on the precomp layer bar. + ## Update: M4.5–M4.8 cached playback - **RAM preview keys** carry the render options (Fast Previews Draft / Fast Draft, Realtime