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/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..2771c65e 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,12 +366,19 @@ 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(), 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 { @@ -377,6 +392,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/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/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..e49ee2b8 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,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.source_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); @@ -837,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 @@ -925,22 +925,31 @@ 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.source_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 // 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); // Frame blending between the nested comp's frames (time-stretched/remapped). let mode = frame_blend_mode(ctx, layer); if mode != FrameBlend::Off @@ -950,10 +959,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)?; @@ -1072,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) @@ -1139,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) { @@ -1160,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) { @@ -1272,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 @@ -1532,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]); @@ -1695,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()); @@ -1901,6 +1921,10 @@ 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; #[cfg(test)] mod tests_time; 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/tests_nested.rs b/crates/render/src/tests_nested.rs new file mode 100644 index 00000000..6b153d32 --- /dev/null +++ b/crates/render/src/tests_nested.rs @@ -0,0 +1,90 @@ +//! 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"); + } +} + +#[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"); + } +} 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 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 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