From e14cdd6009796c7ee56e82e8373c1d0ef0c896d8 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 04:21:14 +0300 Subject: [PATCH] Layer cache: animated masks with mask motion blur key the layer time Review: mask motion blur draws an animated mask at the shutter's other times, but the layer key hashed its value at this time only. Two frames with the same mask value (a hold before a move) shared a cached layer, with the wrong blur. This applied to solids, text and shapes already, and to stills since this PR. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/cache.rs | 26 ++++++++++++++++++++++++++ crates/render/src/tests_mask_blur.rs | 25 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 3fabe473..17794d46 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -521,6 +521,27 @@ fn still_footage(ctx: &EvalCtx, item: effectcraft_project::ItemId) -> bool { ctx.project.item(item).is_some_and(|it| it.proxy.is_none() && matches!(&it.kind, ItemKind::Footage(f) if f.kind == FootageKind::Still)) } +/// Does the layer have an animated mask that motion blur may draw at other times (Layer ▸ Mask ▸ +/// Motion Blur On, or Same As Layer with the layer blurred)? +fn mask_motion_blur(ctx: &EvalCtx, layer: &Layer, blur: bool) -> bool { + use effectcraft_project::{GroupKind, MaskMotionBlur}; + fn animated(g: &PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => !p.keys.is_empty() || p.has_expression(), + Node::Group(sub) => animated(sub), + }) + } + ctx.comp.enable_motion_blur + && layer.masks().is_some_and(|m| { + m.groups().any(|g| match g.kind { + GroupKind::Mask { motion_blur, .. } => { + (motion_blur == MaskMotionBlur::On || (motion_blur == MaskMotionBlur::SameAsLayer && blur)) && animated(g) + } + _ => false, + }) + }) +} + fn has_expression(g: &PropGroup) -> bool { g.children.iter().any(|c| match c { Node::Prop(p) => p.has_expression(), @@ -635,6 +656,11 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h); } } + // Mask motion blur draws animated masks at the shutter's other times too: their values at + // this time don't tell two frames apart (a hold before a move). + if mask_motion_blur(ctx, layer, blur) { + layer.layer_time(ctx.time).0.hash(&mut h); + } if time_dependent(layer, effects != Some(0)) { layer.layer_time(ctx.time).0.hash(&mut h); // Effects also see the layer's time mapping. diff --git a/crates/render/src/tests_mask_blur.rs b/crates/render/src/tests_mask_blur.rs index 12b88bbb..07bc1e95 100644 --- a/crates/render/src/tests_mask_blur.rs +++ b/crates/render/src/tests_mask_blur.rs @@ -41,6 +41,31 @@ fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 { Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3] } +/// A mask that holds from frame 0 to 2, then moves: frames 1 and 2 have the same mask value, but at +/// frame 2 the shutter already sees it move. The layer cache must not serve frame 1's layer there. +#[test] +fn mask_motion_blur_keys_the_layer_cache_by_time() { + let (mut p, cid) = project(true); + let rect = |x: f64| Value::Path(ShapePath::rect([x, 50.0], 20.0, 100.0)); + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + let Some(Node::Group(m)) = l.props.sub_mut("masks").unwrap().children.first_mut() else { panic!("mask") }; + m.get_mut("path").unwrap().keys = vec![ + Keyframe::new(Tick::ZERO, rect(20.0)), + Keyframe::new(FrameRate::FPS_30.tick_of(2), rect(20.0)), + Keyframe::new(FrameRate::FPS_30.tick_of(3), rect(60.0)), + ]; + let cache = crate::LayerCache::default(); + let render = |t: Tick, cached: bool| { + let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: true, ..Default::default() }); + r.cache = cached.then_some(&cache); + r.comp_frame(cid, t) + }; + let (f1, f2) = (FrameRate::FPS_30.tick_of(1), FrameRate::FPS_30.tick_of(2)); + assert_ne!(render(f1, false), render(f2, false), "the shutter at frame 2 sees the move"); + render(f1, true); + assert_eq!(render(f2, true), render(f2, false)); +} + #[test] fn mask_motion_blur_follows_the_render_and_the_comp() { let (p, cid) = project(true);