diff --git a/ROADMAP.md b/ROADMAP.md index fcf6d797..8147cc2b 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 (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) | +| 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, motion blur of collapsed precomps and of animated shape / text content; left: content-keyed RAM preview frames) | | 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 | @@ -33,7 +33,6 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026) - **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking. - What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto - Brush and face tracking; in previews, motion blur of collapsed precomps and of animated layer - content, and content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)). + Brush and face tracking; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)). Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f14b505b..d0fdfc44 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -970,6 +970,8 @@ mod tests_menu_cmds; #[cfg(test)] mod tests_model3d; #[cfg(test)] +mod tests_motion_blur_depth; +#[cfg(test)] mod tests_project_items; #[cfg(test)] mod tests_proxy; diff --git a/crates/engine/src/tests_motion_blur_depth.rs b/crates/engine/src/tests_motion_blur_depth.rs new file mode 100644 index 00000000..46312a08 --- /dev/null +++ b/crates/engine/src/tests_motion_blur_depth.rs @@ -0,0 +1,122 @@ +//! Motion blur of collapsed precomps (the precomp layer's own motion; the containing comp's +//! shutter) and of animated shape content, rendered through the commands. + +use effectcraft_raster::Image; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::Session; +use crate::render::RenderOpts; + +const W: u32 = 160; +const H: u32 = 90; + +fn id(v: &Value, k: &str) -> u64 { + v[k].as_u64().unwrap() +} + +fn render(s: &Session, comp: u64) -> Image { + s.render(effectcraft_project::ItemId(comp), Tick::from_seconds_f64(0.5), RenderOpts::default()) +} + +/// Pixels with partial alpha (motion-blur smear). +fn partial(img: &Image) -> usize { + img.data.iter().filter(|p| p[3] > 0.05 && p[3] < 0.95).count() +} + +fn max_diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max) +} + +/// "Main" holding "Pre" (a 20×20 white square in the middle), the precomp layer moving 480 px/s +/// (20 px a frame at 24 fps) with motion blur on. `inner_move`: the square moves inside Pre too +/// (with its own motion blur switch), and Pre's shutter is closed (0°). +fn scene(collapse: bool, inner_move: bool) -> (Session, u64) { + let mut s = Session::default(); + let pre = id(&s.execute("comp.new", json!({"name": "Pre", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp"); + let sq = id(&s.execute("layer.newSolid", json!({"name": "Square", "color": "#ffffff", "width": 20, "height": 20})).unwrap(), "layer"); + if inner_move { + s.execute("comp.settings", json!({"shutterAngle": 0})).unwrap(); + s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [sq], "switch": "motionBlur", "value": true})).unwrap(); + for (t, x) in [(0.0, -160.0), (1.0, 320.0)] { + s.execute("prop.addKey", json!({"layer": sq, "path": "transform/position", "time": t, "value": [x, 45.0]})).unwrap(); + } + } + let main = id(&s.execute("comp.new", json!({"name": "Main", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp"); + s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap(); + let l = id(&s.execute("layer.addItem", json!({"item": pre})).unwrap(), "layer"); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "collapse", "value": collapse})).unwrap(); + if !inner_move { + for (t, x) in [(0.0, -160.0), (1.0, 320.0)] { + s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": t, "value": [x, 45.0]})).unwrap(); + } + } + (s, main) +} + +#[test] +fn a_collapsed_precomp_layer_blurs_with_its_own_motion() { + let (s, main) = scene(true, false); + let collapsed = render(&s, main); + // The square sits at x = 80 at 0.5 s and smears over 10 px (180° of 20 px). + assert!(partial(&collapsed) > 100, "smeared: {}", partial(&collapsed)); + // As blurred as the same precomp rendered flat. + let (s, main) = scene(false, false); + let flat = render(&s, main); + assert!(max_diff(&collapsed, &flat) < 0.08, "{}", max_diff(&collapsed, &flat)); + // Motion blur off for the render: sharp. + let sharp = s.render(effectcraft_project::ItemId(main), Tick::from_seconds_f64(0.5), RenderOpts { motion_blur: false, ..Default::default() }); + assert!(partial(&sharp) < 10, "{}", partial(&sharp)); +} + +#[test] +fn collapsed_layers_use_the_containing_comps_shutter() { + // Pre's own shutter is closed; drawn into Main (180°) the square's own motion blurs. + let (s, main) = scene(true, true); + assert!(partial(&render(&s, main)) > 100, "{}", partial(&render(&s, main))); + // Rendered flat, Pre uses its own (closed) shutter. + let (s, main) = scene(false, true); + assert!(partial(&render(&s, main)) < 10, "{}", partial(&render(&s, main))); +} + +/// A shape layer whose rectangle moves inside its contents (not its transform): motion blur +/// smears it like the same move made with the layer's Position. +#[test] +fn animated_shape_content_is_motion_blurred() { + let shape = |content: bool, blur: bool| { + let mut s = Session::default(); + let c = id(&s.execute("comp.new", json!({"name": "S", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp"); + s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap(); + let l = id(&s.execute("layer.newShape", json!({"kind": "rect", "size": [20, 20], "fill": "#ffffff", "position": [80, 45]})).unwrap(), "layer"); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": blur})).unwrap(); + let (target, base) = if content { + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().clone(); + let mut found = None; + layer.props.group("contents").unwrap().walk("contents", &mut |_, p| { + if found.is_none() && p.match_id == "position" { + found = Some((json!({"prop": p.uid}), p.value.as_vec2())); + } + }); + found.unwrap() + } else { + (json!({"path": "transform/position"}), [80.0, 45.0]) + }; + for (t, dx) in [(0.0, -240.0), (1.0, 240.0)] { + let mut p = target.clone(); + p["layer"] = json!(l); + p["time"] = json!(t); + p["value"] = json!([base[0] + dx, base[1]]); + s.execute("prop.addKey", p).unwrap(); + } + render(&s, c) + }; + let content = shape(true, true); + assert!(partial(&content) > 100, "smeared: {}", partial(&content)); + // Like the layer moved by its transform… + let moved = shape(false, true); + assert!(max_diff(&content, &moved) < 0.15, "{}", max_diff(&content, &moved)); + // …and sharp with the layer's motion blur switch off. + assert!(partial(&shape(true, false)) < 40, "{}", partial(&shape(true, false))); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e07e45be..5c1a7e74 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -444,6 +444,49 @@ pub struct Renderer<'a> { /// This renderer draws into the top-level frame's canvas (the top comp, or a collapsed /// precomp drawn straight into it): the region of interest's offset applies. top: bool, + /// The collapsed precomp layers this comp is drawn through, outermost first: motion blur + /// re-evaluates their transforms at each sub-sample (see [`Renderer::buf_matrix_at`]). + through: [Option>; MAX_THROUGH], + /// More collapsed levels than [`MAX_THROUGH`]: their motion is not blurred. + through_lost: bool, +} + +/// One collapsed precomp layer a nested comp is drawn through. +#[derive(Clone, Copy)] +struct Through<'a> { + comp_id: ItemId, + comp: &'a Comp, + /// The containing comp's time at this frame. + time: Tick, + layer: &'a Layer, + /// The precomp layer is motion blurred: its own motion blurs the nested layers. + blur: bool, +} + +/// Collapsed precomps within collapsed precomps that motion blur follows (more than +/// [`Renderer::collapsed`]'s nesting limit). +const MAX_THROUGH: usize = 16; + +/// A motion-blur shutter: the comp's, or for a collapsed precomp's layers the outermost +/// containing comp's (they are drawn into its frames). +#[derive(Clone, Copy)] +struct Shutter { + /// Shutter angle and phase as fractions of a frame. + angle: f64, + phase: f64, + /// Frame duration (seconds). + frame: f64, + /// Samples Per Frame and Adaptive Sample Limit. + samples: u32, + limit: u32, +} + +impl Shutter { + /// Seconds from the frame time to sub-sample `i` of `n`, spread over the open shutter. + fn offset(&self, i: usize, n: usize) -> f64 { + let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; + (self.phase + self.angle * f) * self.frame + } } /// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`). @@ -490,6 +533,8 @@ impl<'a> Renderer<'a> { inherited: None, defer_dof: false, top: true, + through: [None; MAX_THROUGH], + through_lost: false, } } @@ -524,7 +569,17 @@ impl<'a> Renderer<'a> { /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { - Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, defer_dof: false, top: false, ..*self } + Renderer { + depth: self.depth + 1, + outer: None, + opacity_mul: 1.0, + collapse3d: None, + defer_dof: false, + top: false, + through: [None; MAX_THROUGH], + through_lost: false, + ..*self + } } /// The nested comp of a precomp layer whose transformations collapse into this comp: the @@ -558,7 +613,7 @@ impl<'a> Renderer<'a> { /// their transforms are concatenated with the precomp layer's (one resample, no /// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their /// 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>)> { + pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> { let nc = self.project.comp(item)?; let t = ctx.nested_time(layer); if !nc.covers(t) { @@ -573,7 +628,15 @@ impl<'a> Renderer<'a> { cam: three_d::compose::camera_for(self, ctx), parent: self.collapse3d.map_or(*ctx, |c| c.parent), }; - Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), ..*self }, nctx)) + // Motion blur follows the precomp layer's own motion too. + let link = Through { comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, layer, blur: self.mb_on(ctx, layer) }; + let mut through = self.through; + let slot = through.iter_mut().find(|t| t.is_none()); + let through_lost = self.through_lost || slot.is_none(); + if let Some(slot) = slot { + *slot = Some(link); + } + Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, nctx)) } pub(crate) fn opacity_mul(&self) -> f32 { @@ -1080,7 +1143,10 @@ impl<'a> Renderer<'a> { /// Source → masks, clamped/quantised to the project depth. fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { - let mut buf = self.source(ctx, layer)?; + let mut buf = match self.content_times(ctx, layer) { + Some(times) => self.blurred_source(ctx, layer, ×)?, + None => 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, self.mask_blur(ctx, layer)) || !solid { @@ -1151,6 +1217,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, self.mb_on(ctx, layer))); + let key = self.content_key(ctx, layer, key); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -1172,6 +1239,7 @@ impl<'a> Renderer<'a> { /// 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, self.mb_on(ctx, layer), effects)); + let key = self.content_key(ctx, layer, key); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -1273,9 +1341,13 @@ impl<'a> Renderer<'a> { /// Buffer pixel → output pixel matrix for the layer at the context time. fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 { - let s = self.opts.scale; let (l2c, _) = ctx.layer_to_comp(layer); - let l2c = self.outer.map_or(l2c, |o| o * l2c); + self.place_matrix(self.outer.map_or(l2c, |o| o * l2c), buf) + } + + /// Buffer pixel → output pixel matrix for a layer → (top-level) comp matrix. + fn place_matrix(&self, l2c: Mat3, buf: &Buf) -> Mat3 { + let s = self.opts.scale; self.out_matrix() * Mat3::scale(vec2(s, s)) * l2c @@ -1283,6 +1355,112 @@ impl<'a> Renderer<'a> { * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1])) } + /// The collapsed precomp layers this comp is drawn through, outermost first. + fn links(&self) -> impl Iterator> { + self.through.iter().map_while(Option::as_ref) + } + + /// The shutter of a layer's motion blur: its comp's, or for a collapsed precomp's layers the + /// outermost containing comp's. + fn shutter(&self, ctx: &EvalCtx) -> Shutter { + let c: &Comp = match self.links().next() { + Some(l) => l.comp, + None => ctx.comp, + }; + Shutter { + angle: c.shutter_angle / 360.0, + phase: c.shutter_phase / 360.0, + frame: c.frame_duration().seconds(), + samples: c.motion_blur_samples, + limit: c.motion_blur_adaptive_limit, + } + } + + /// Whether a layer moves within the shutter: its own motion blur, or that of a collapsed + /// precomp layer it is drawn through. + fn blurred(&self, ctx: &EvalCtx, layer: &Layer) -> bool { + self.mb_on(ctx, layer) || (!self.through_lost && self.links().any(|l| l.blur)) + } + + /// The collapsed precomps' matrix (nested comp → the frame's comp) and the nested comp's time + /// `dt` seconds into the outermost comp's shutter: each precomp layer moves if it is motion + /// blurred, and times map down through each one (stretch, time remapping). + fn outer_at(&self, ctx: &EvalCtx, dt: f64) -> (Option, Tick) { + let shift = Tick::from_seconds_f64(dt); + let mut outer: Option = None; + let mut t: Option = None; + for l in self.links() { + let at = EvalCtx { comp_id: l.comp_id, comp: l.comp, time: t.unwrap_or(l.time + shift), ..*ctx }; + let (m, _) = if l.blur { at.layer_to_comp(l.layer) } else { EvalCtx { time: l.time, ..at }.layer_to_comp(l.layer) }; + outer = Some(outer.map_or(m, |o| o * m)); + t = Some(at.nested_time(l.layer)); + } + (outer, t.unwrap_or(ctx.time + shift)) + } + + /// [`Self::buf_matrix`] for the motion-blur sub-sample `dt` seconds into the shutter: the + /// layer moves if it is motion blurred, the collapsed precomps above it if theirs are. + fn buf_matrix_at(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, dt: f64) -> Mat3 { + if self.through_lost || self.links().next().is_none() { + return self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(dt)), layer, buf); + } + let (outer, t) = self.outer_at(ctx, dt); + let lctx = if self.mb_on(ctx, layer) { ctx.at(t) } else { *ctx }; + let (l2c, _) = lctx.layer_to_comp(layer); + self.place_matrix(outer.map_or(l2c, |o| o * l2c), buf) + } + + /// Shape and text content that animates within the shutter (paths, Trim Paths, text + /// animators…) of a motion-blurred layer: the times its source is drawn at and averaged, + /// Samples Per Frame of them, as After Effects does for shape layers. `None` when the content + /// holds still (compared through its cache key, which leaves the transform out). + fn content_times(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + if !matches!(layer.source, LayerSource::Shape | LayerSource::Text) || !self.mb_on(ctx, layer) { + return None; + } + let sh = self.shutter(ctx); + if sh.angle <= 0.0 { + return None; + } + let n = if self.opts.draft { 4 } else { (sh.samples as usize).clamp(2, 64) }; + let times: Vec = (0..n).map(|i| self.outer_at(ctx, sh.offset(i, n)).1).collect(); + let key = |t: Tick| cache::layer_key(&ctx.at(t), layer, 1.0, false, false); + let k0 = key(*times.first()?)?; + let moves = [times.get(n / 2), times.last()].into_iter().flatten().any(|t| key(*t) != Some(k0)); + moves.then_some(times) + } + + /// The layer's source drawn at each of `times` and averaged (premultiplied) on the union of + /// their bounds. Content that also changes its raster scale within the shutter, or would need + /// an outsized buffer, is drawn once. + fn blurred_source(&self, ctx: &EvalCtx, layer: &Layer, times: &[Tick]) -> Option { + let bufs: Vec = times.iter().filter_map(|t| self.source(&ctx.at(*t), layer)).collect(); + let scale = bufs.first()?.scale; + // Pixel = layer point × scale + offset: with the largest offset every buffer lands at + // x + (off - its offset) ≥ 0. + let off = [0, 1].map(|i| bufs.iter().map(|b| b.offset[i]).fold(f64::MIN, f64::max)); + let w = bufs.iter().map(|b| off[0] - b.offset[0] + b.img.width as f64).fold(0.0, f64::max).ceil(); + let h = bufs.iter().map(|b| off[1] - b.offset[1] + b.img.height as f64).fold(0.0, f64::max).ceil(); + if bufs.iter().any(|b| (b.scale - scale).abs() > 1e-9) || !(w * h).is_finite() || w * h > (1u64 << 26) as f64 { + return self.source(ctx, layer); + } + let mut acc = Image::new(w as u32, h as u32); + let k = 1.0 / bufs.len() as f32; + for b in &bufs { + let m = Mat3::translate(vec2(off[0] - b.offset[0], off[1] - b.offset[1])); + effectcraft_raster::accumulate_warp(&mut acc, &b.img, &m, effectcraft_raster::Sampling::Bilinear, k); + } + Some(Buf { img: acc, offset: off, scale }) + } + + /// A processed-layer cache key that also covers content motion blur (the averaged source + /// depends on the content at every sub-sample). + fn content_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option) -> Option { + let k = key?; + let Some(times) = self.content_times(ctx, layer) else { return Some(k) }; + times.iter().try_fold(cache::derive(k, 0x6d62_6c72), |d, t| Some(cache::derive(d, cache::layer_key(&ctx.at(*t), layer, 1.0, false, false)?))) + } + /// 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. @@ -1304,7 +1482,7 @@ impl<'a> Renderer<'a> { /// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that /// does not move within the shutter is drawn once. fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> usize { - if !self.mb_on(ctx, layer) { + if !self.blurred(ctx, layer) { return 1; } let travel = self.shutter_travel(ctx, layer, buf); @@ -1314,22 +1492,21 @@ impl<'a> Renderer<'a> { if self.opts.draft { return 4; } - adaptive_samples(travel, ctx.comp.motion_blur_samples, ctx.comp.motion_blur_adaptive_limit) + let sh = self.shutter(ctx); + adaptive_samples(travel, sh.samples, sh.limit) } /// Largest distance (output pixels) a corner of the layer buffer moves during the shutter, /// measured along the path at a few points. fn shutter_travel(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> f64 { - let fd = ctx.comp.frame_duration().seconds(); - let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0); + let sh = self.shutter(ctx); let (w, h) = (buf.img.width as f64, buf.img.height as f64); let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h), (w * 0.5, h * 0.5)]; const PROBES: usize = 8; let mut prev: Option> = None; let mut travel = vec![0.0f64; corners.len()]; for i in 0..PROBES { - let f = phase + angle * i as f64 / (PROBES - 1) as f64; - let m = self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf); + let m = self.buf_matrix_at(ctx, layer, buf, sh.offset(i, PROBES)); let pts: Vec<_> = corners.iter().map(|&(x, y)| m.apply(vec2(x, y))).collect(); if let Some(p) = &prev { for (k, (a, b)) in p.iter().zip(&pts).enumerate() { @@ -1388,15 +1565,8 @@ impl<'a> Renderer<'a> { if samples <= 1 { return Placement { matrices: vec![self.buf_matrix(ctx, layer, buf)], sampling, seed }; } - let fd = ctx.comp.frame_duration().seconds(); - let angle = ctx.comp.shutter_angle / 360.0; - let phase = ctx.comp.shutter_phase / 360.0; - let matrices = (0..samples) - .map(|i| { - let f = phase + angle * i as f64 / (samples - 1) as f64; - self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf) - }) - .collect(); + let sh = self.shutter(ctx); + let matrices = (0..samples).map(|i| self.buf_matrix_at(ctx, layer, buf, sh.offset(i, samples))).collect(); Placement { matrices, sampling, seed } } diff --git a/docs/parity.md b/docs/parity.md index be5baa25..9dccad34 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -422,6 +422,13 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph Editor keys are small squares whatever their interpolation, as in After Effects. +Since (M4.13): motion blur follows a collapsed precomp layer's own motion. Its nested layers are +drawn with the precomp layer's transform at every sub-sample, and with the containing comp's +shutter, as they are drawn into its frames. Shape and text layers whose content animates within +the shutter (paths, Trim Paths, shape transforms, text animators) are drawn at Samples Per Frame +times and averaged, as After Effects does for shape layers; content that holds still is drawn +once. + Since (M3.15): the Timeline shows one tab per open comp, as in After Effects. Double-clicking a comp in the Project panel opens it as a new tab next to the comps already open; it no longer renames (Enter or the context menu still do). A tab's × closes that comp's Timeline, not the @@ -715,8 +722,8 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r 13. ~~Lottie, WebM, SVG and PSD import~~ (landed; a disk cache too). 14. ~~Puppet and paint tools~~ (second wave; puppet rigging and follow-through in M13.14–M13.16). 15. ~~Preferences and a shortcut editor that can rebind; real Wiggler, Smoother and Motion Sketch; the marker dialog.~~ (second and third waves) -16. Motion blur of collapsed precomps (their own motion, the parent camera's) and of animated - content inside a layer (shape paths, text animators, nested frames). +16. ~~Motion blur of collapsed precomps and of animated content inside a layer (shape paths, text + animators).~~ (M4.13) 17. Content-keyed RAM preview frames, so an edit keeps the frames of comps it doesn't touch. 18. View ▸ New Viewer (several unlocked Composition viewers), nested comp markers on the precomp layer bar, audio scrubbing.