From 99308ef1cd221777625bdb62cad9a7a7fa1467ad Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 19 Jul 2026 02:30:53 +0200 Subject: [PATCH] feat(engine): motion blur and trail via temporal ghost nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MotionBlurConfig was parsed and discarded since its introduction. Both effects now materialize as ghost BoxNodes built by build_child: css re-resolved at offset times (shutter window for motion_blur, spacing steps for trail), painted beneath the principal via the new BoxKind::Ghost (excluded from the hit-map). Opacity formula proven by the static test: ghosts at 1/(samples+1), principal at full — the all-attenuated alternative washes static components to ~0.66. BuildAnimationCtx gains fps (shutter is a frame fraction). Documented v1 limits: ghost internal animations sample frame time; container ghosts don't recurse into children. --- .../src/commands/validate_schema.rs | 3 +- .../rustmotion-components/src/box_builder.rs | 267 +++++++++++++-- .../src/legacy_dispatch.rs | 1 + .../tests/caption_presets.rs | 1 + crates/rustmotion-core/src/engine/box_tree.rs | 4 + .../rustmotion-core/src/engine/paint_pass.rs | 11 +- crates/rustmotion-core/src/schema/video.rs | 61 +++- crates/rustmotion/src/engine/render/scene.rs | 2 + crates/rustmotion/src/tests.rs | 321 ++++++++++++++++++ 9 files changed, 647 insertions(+), 24 deletions(-) diff --git a/crates/rustmotion-cli/src/commands/validate_schema.rs b/crates/rustmotion-cli/src/commands/validate_schema.rs index aa5726b..df5091a 100644 --- a/crates/rustmotion-cli/src/commands/validate_schema.rs +++ b/crates/rustmotion-cli/src/commands/validate_schema.rs @@ -279,7 +279,8 @@ fn entrance_budget(effect: &AnimationEffect) -> Option<(f64, f64)> { AnimationEffect::Glow(_) | AnimationEffect::Wiggle(_) | AnimationEffect::Orbit(_) - | AnimationEffect::MotionBlur(_) => None, + | AnimationEffect::MotionBlur(_) + | AnimationEffect::Trail(_) => None, } } diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index 2c15856..ca92d06 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -19,6 +19,7 @@ use rustmotion_core::css::style::{AlignSelf, CssStyle, Position, Size as CSize}; use rustmotion_core::css::{apply_animated_props, LengthPercentage as CLP}; use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties}; use rustmotion_core::engine::box_tree::{BoxKind, BoxNode, NodeId}; +use rustmotion_core::schema::video::{AnimationEffect, MotionBlurConfig, TrailConfig}; use crate::divider::DividerDirection; use crate::timeline::TimelineDirection; @@ -31,6 +32,10 @@ use crate::{ChildComponent, Component}; pub struct BuildAnimationCtx { pub time: f64, pub scene_duration: f64, + /// Frames per second of the output video. Required to convert the + /// `shutter` fraction (in `MotionBlurConfig`) into an absolute temporal + /// offset: `shutter_window = shutter / fps` seconds. + pub fps: u32, } /// Padding allowance to keep arrow/connector heads inside the box. @@ -115,7 +120,7 @@ where let mut child_boxes = Vec::new(); for (i, c) in children.into_iter().enumerate() { - child_boxes.push(build_child( + child_boxes.extend(build_child( c, &mut components, &mut stagger_delays, @@ -157,7 +162,208 @@ fn default_root_css(viewport: (f32, f32)) -> CssStyle { } } -/// Convert a single `ChildComponent` to a `BoxNode`. Recurses into containers. +/// Detect motion_blur and trail configs from a merged effect list. +/// +/// Returns `(motion_blur, trail)`. Only the first occurrence of each type is +/// used; duplicate effects of the same kind are ignored. +fn detect_ghost_effects( + effects: &[AnimationEffect], +) -> (Option, Option) { + let mut mb: Option = None; + let mut tr: Option = None; + for e in effects { + match e { + AnimationEffect::MotionBlur(c) if mb.is_none() => mb = Some(c.clone()), + AnimationEffect::Trail(c) if tr.is_none() => tr = Some(c.clone()), + _ => {} + } + } + (mb, tr) +} + +/// Build ghost `BoxNode`s for motion-blur or trail effects. +/// +/// Returns a `Vec` of ghosts to be prepended (painted underneath) +/// the principal node. Each ghost gets a fresh `NodeId` and its own slot in +/// `components`/`stagger_delays`, both pointing to the same `ChildComponent` +/// as the principal (v1 approximation: internal content uses frame time, not +/// ghost time). +/// +/// # Ghost CSS +/// +/// For **motion_blur**: the CSS is resolved at `t_ghost = t - i * (shutter/fps)/samples` +/// and opacity is `base_opacity / (samples + 1)`. The principal's opacity is +/// kept at its base value so the static (no-motion) case stays visually full. +/// +/// For **trail**: the CSS is resolved at `t_ghost = t - i * spacing` and +/// opacity is `base_opacity * falloff^i`. The principal is unchanged. +/// +/// Only one of `mb` / `tr` is used at a time; if both are present, motion_blur +/// takes priority. +#[allow(clippy::too_many_arguments)] +fn build_ghosts<'a>( + child: &'a ChildComponent, + components: &mut Vec>, + stagger_delays: &mut Vec, + next_id: &mut NodeId, + actx: BuildAnimationCtx, + stagger_delay: f64, + effects: &[AnimationEffect], +) -> Vec { + let (mb, tr) = detect_ghost_effects(effects); + + // Choose the ghost generation strategy. + enum Strategy { + MotionBlur { + samples: u32, + shutter_window: f64, + }, + Trail { + copies: u32, + spacing: f64, + falloff: f32, + }, + } + let strategy = if let Some(mc) = mb { + let samples = mc.samples.clamp(1, 16); + if samples <= 1 { + // Degenerate: no ghosts needed (ghost = principal position). + return Vec::new(); + } + let shutter_window = mc.shutter / actx.fps.max(1) as f64; + Strategy::MotionBlur { + samples, + shutter_window, + } + } else if let Some(tc) = tr { + let copies = tc.copies.clamp(1, 12); + Strategy::Trail { + copies, + spacing: tc.spacing, + falloff: tc.falloff, + } + } else { + return Vec::new(); + }; + + let base_css_for_ghost = |ghost_time: f64, ghost_opacity_scale: f32| -> CssStyle { + // Start from the same base CSS as the principal. + let mut css = component_css(&child.component); + if let Some((x, y)) = child.absolute_position() { + css.position = Some(Position::Absolute); + css.left = Some(CLP::Px(x)); + css.top = Some(CLP::Px(y)); + } + if let Some(z) = child.z_index { + css.z_index = Some(z); + } + // Apply timeline style states at the ghost time. + if let Some(animatable) = child.component.as_animatable() { + let steps = animatable.timeline_steps(); + if steps.iter().any(|s| s.style.is_some()) { + let skip_opacity = css.transition.is_some(); + apply_style_states(&mut css, steps, ghost_time - stagger_delay, skip_opacity); + } + } + // Resolve animation props at the *ghost* time. + let ghost_actx = BuildAnimationCtx { + time: ghost_time, + scene_duration: actx.scene_duration, + fps: actx.fps, + }; + if let Some(ghost_effects) = effective_effects(&child.component, stagger_delay) { + let props = resolve_props_for_effects( + &ghost_effects, + ghost_actx.time, + ghost_actx.scene_duration, + ); + if props_has_paint_overrides(&props) { + apply_animated_props(&mut css, &props); + } + } + // Scale opacity: multiply the base opacity by the ghost opacity factor. + let base_opacity = css.opacity.unwrap_or(1.0); + css.opacity = Some((base_opacity * ghost_opacity_scale).clamp(0.0, 1.0)); + css + }; + + let mut ghosts = Vec::new(); + + match strategy { + Strategy::MotionBlur { + samples, + shutter_window, + } => { + // Ghost opacity: 1 / (samples + 1) of base opacity. + // Principal stays at full base opacity (handled in build_child). + let ghost_opacity_scale = 1.0 / (samples + 1) as f32; + for i in 1..=samples { + let ghost_time = actx.time - (i as f64 * shutter_window / samples as f64); + let ghost_css = base_css_for_ghost(ghost_time, ghost_opacity_scale); + + let ghost_id = *next_id; + *next_id += 1; + // Register a slot so the dispatcher can look up the component. + components.push(Some(child)); + stagger_delays.push(stagger_delay); + + ghosts.push(BoxNode { + id: ghost_id, + kind: BoxKind::Ghost(Arc::new(ghost_id)), + css: ghost_css, + children: Vec::new(), // v1: no child recursion in ghosts + intrinsic: None, // v1: ghosts have no layout-measured content + source_path: None, + window: None, + }); + } + } + Strategy::Trail { + copies, + spacing, + falloff, + } => { + // Ghosts painted from oldest (most-trailing) to newest (closest to principal). + // We build them in reverse order (copies → 1) so that index i=copies + // is the oldest ghost (lowest opacity), and we then reverse to get the + // correct under-to-over paint order. + let mut trail_nodes = Vec::with_capacity(copies as usize); + for i in 1..=copies { + let ghost_time = actx.time - i as f64 * spacing; + let ghost_opacity_scale = falloff.powi(i as i32); + let ghost_css = base_css_for_ghost(ghost_time, ghost_opacity_scale); + + let ghost_id = *next_id; + *next_id += 1; + components.push(Some(child)); + stagger_delays.push(stagger_delay); + + trail_nodes.push(BoxNode { + id: ghost_id, + kind: BoxKind::Ghost(Arc::new(ghost_id)), + css: ghost_css, + children: Vec::new(), + intrinsic: None, + source_path: None, + window: None, + }); + } + // Oldest ghost (most-trailing) painted first → prepend in reverse. + trail_nodes.reverse(); + ghosts = trail_nodes; + } + } + + ghosts +} + +/// Convert a single `ChildComponent` into one or more `BoxNode`s. +/// +/// Returns a `Vec` whose elements are inserted in order into the parent's +/// `children`. The last element is the principal node; any preceding elements +/// are `BoxKind::Ghost` nodes inserted *before* (painted underneath) the +/// principal for motion-blur or trail effects. +/// /// `stagger_delay` is the animation delay accumulated from ancestor /// containers' `stagger` fields. #[allow(clippy::too_many_arguments)] @@ -169,7 +375,25 @@ fn build_child<'a>( anim: Option, path: String, stagger_delay: f64, -) -> BoxNode { +) -> Vec { + // ── Ghost generation (motion_blur / trail) ─────────────────────────────── + // Must happen before allocating the principal's id so that ghost ids are + // lower (earlier in the slot table). The principal's id is allocated below. + let mut ghosts: Vec = Vec::new(); + if let Some(actx) = anim { + if let Some(effects) = effective_effects(&child.component, stagger_delay) { + ghosts = build_ghosts( + child, + components, + stagger_delays, + next_id, + actx, + stagger_delay, + &effects, + ); + } + } + let id = *next_id; *next_id += 1; components.push(Some(child)); @@ -294,7 +518,7 @@ fn build_child<'a>( ); let intrinsic = component_intrinsic(&child.component); - BoxNode { + let principal = BoxNode { id, kind: BoxKind::Component(Arc::new(id)), css, @@ -302,7 +526,12 @@ fn build_child<'a>( intrinsic, source_path: Some(path), window, - } + }; + + // Ghosts prepended (painted underneath the principal). + let mut result = ghosts; + result.push(principal); + result } /// The full effect list for a component at paint time: `style.animation`, @@ -575,21 +804,19 @@ fn container_children<'a>( _ => return Vec::new(), }; let step = stagger.unwrap_or(0.0) as f64; - children - .iter() - .enumerate() - .map(|(j, c)| { - build_child( - c, - components, - stagger_delays, - next_id, - anim, - format!("{parent_path}/children/{j}"), - inherited_delay + j as f64 * step, - ) - }) - .collect() + let mut result = Vec::new(); + for (j, c) in children.iter().enumerate() { + result.extend(build_child( + c, + components, + stagger_delays, + next_id, + anim, + format!("{parent_path}/children/{j}"), + inherited_delay + j as f64 * step, + )); + } + result } /// Pull the component's `CssStyle`, augmented with intrinsic `width`/`height` diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 2f78341..24844ca 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -383,6 +383,7 @@ mod tests { crate::box_builder::BuildAnimationCtx { time, scene_duration: 1.0, + fps: 30, }, ); let layout = run_layout(&built.root, (200.0, 200.0), &ConversionContext::default()); diff --git a/crates/rustmotion-components/tests/caption_presets.rs b/crates/rustmotion-components/tests/caption_presets.rs index 48f5fa3..7a575a5 100644 --- a/crates/rustmotion-components/tests/caption_presets.rs +++ b/crates/rustmotion-components/tests/caption_presets.rs @@ -40,6 +40,7 @@ fn render_caption(json: serde_json::Value, time: f64) -> Vec { BuildAnimationCtx { time, scene_duration: 2.0, + fps: 30, }, ); let layout = run_layout( diff --git a/crates/rustmotion-core/src/engine/box_tree.rs b/crates/rustmotion-core/src/engine/box_tree.rs index 4ea9564..5142639 100644 --- a/crates/rustmotion-core/src/engine/box_tree.rs +++ b/crates/rustmotion-core/src/engine/box_tree.rs @@ -103,6 +103,10 @@ pub enum BoxKind { /// Component-backed leaf or container. Holds an opaque payload that the /// dispatcher knows how to handle. Component(Arc), + /// Temporal ghost for motion-blur / trail effects. Painted exactly like + /// `Component` (same payload, same dispatcher dispatch) but excluded from + /// the hit-map so the studio never selects a ghost node. + Ghost(Arc), } /// Trait implemented by leaves whose intrinsic size depends on their content. diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 9c40eeb..ad69f73 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -197,6 +197,8 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) { // Hit-map: record the on-screen bbox of component-backed nodes. The canvas // matrix here already includes this node's and all ancestors' transforms, // so mapping the (absolute) layout rect yields the device-space AABB. + // Ghost nodes are intentionally excluded — they are temporal echoes for + // motion-blur/trail effects and must never be selectable in the studio. if let (Some(hits), BoxKind::Component(_)) = (ctx.hits, &node.kind) { let local = Rect::from_xywh( box_layout.x, @@ -295,8 +297,13 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) { paint_border(canvas, box_layout, &node.css, border, &length_ctx); } - // 8. component-specific content - if let BoxKind::Component(payload) = &node.kind { + // 8. component-specific content (Ghost is painted identically to Component; + // the only difference is that Ghost is excluded from the hit-map above). + let payload_opt = match &node.kind { + BoxKind::Component(p) | BoxKind::Ghost(p) => Some(p), + BoxKind::Container => None, + }; + if let Some(payload) = payload_opt { ctx.dispatcher .dispatch(canvas, payload.as_ref(), &node.css, box_layout, ctx.frame); } diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 299093d..b4743b0 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -79,6 +79,9 @@ pub enum AnimationEffect { Orbit(OrbitConfig), Keyframes(KeyframesConfig), MotionBlur(MotionBlurConfig), + /// Temporal trail effect: paints copies of the component at prior times + /// with decaying opacity, creating a persistence-of-vision ghost trail. + Trail(TrailConfig), } impl AnimationEffect { @@ -101,7 +104,7 @@ impl AnimationEffect { CharScaleIn(c) | CharFadeIn(c) | CharWave(c) | CharBounce(c) | CharRotateIn(c) | CharSlideUp(c) => c.delay += by, Keyframes(c) => c.delay += by, - Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) => {} + Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) | Trail(_) => {} } } @@ -330,10 +333,66 @@ pub struct KeyframesConfig { } /// Motion blur configuration. +/// +/// Ghost nodes are sampled at times `t - i * (shutter / fps) / samples` for +/// `i` in `1..=samples`. Each ghost and the principal node are painted with +/// opacity `1.0 / (samples + 1)` so their premultiplied-alpha sum approximates +/// the shutter-averaged exposure. +/// +/// `samples = 1` is the degenerate case: the single ghost falls at `t - 0` and +/// superimposes exactly on the principal → visually equivalent to no blur. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct MotionBlurConfig { + /// Reserved for future intensity scaling (currently unused by the ghost + /// sampler — the `samples` parameter controls quality). Kept for schema + /// compatibility with existing JSON that may carry this field. #[serde(default)] pub intensity: f32, + /// Number of ghost samples in the shutter window (default 6, clamped 1..=16). + /// Use 1 to effectively disable (degenerate: ghost = principal position). + #[serde(default = "default_motion_blur_samples")] + pub samples: u32, + /// Fraction of one frame duration used as the shutter window (default 0.5). + /// The temporal spread equals `shutter / fps` seconds. + #[serde(default = "default_motion_blur_shutter")] + pub shutter: f64, +} + +fn default_motion_blur_samples() -> u32 { + 6 +} +fn default_motion_blur_shutter() -> f64 { + 0.5 +} + +/// Trail effect configuration. +/// +/// Produces `copies` ghost nodes behind the principal, each offset in time by +/// `i * spacing` seconds. The i-th ghost (1-based) is painted with opacity +/// `base_opacity * falloff^i`; the principal is unchanged. Unlike motion blur, +/// the trail is additive: the principal retains its full opacity. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct TrailConfig { + /// Number of trailing ghost copies (default 4, clamped 1..=12). + #[serde(default = "default_trail_copies")] + pub copies: u32, + /// Time gap between successive ghost copies in seconds (default 0.05). + #[serde(default = "default_trail_spacing")] + pub spacing: f64, + /// Opacity multiplier applied per copy: ghost `i` uses `falloff^i` times + /// the component's base opacity (default 0.6). + #[serde(default = "default_trail_falloff")] + pub falloff: f32, +} + +fn default_trail_copies() -> u32 { + 4 +} +fn default_trail_spacing() -> f64 { + 0.05 +} +fn default_trail_falloff() -> f32 { + 0.6 } // --- Orbit Config --- diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index 122d19c..175a2c6 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -361,6 +361,7 @@ fn render_with_new_pipeline_iter<'a, I>( let anim = Some(BuildAnimationCtx { time: ctx.time, scene_duration: ctx.scene_duration, + fps: ctx.fps, }); let built = build_scene_from_refs(root_children, (viewport_w, viewport_h), root_css, anim); let layout = run_layout( @@ -579,6 +580,7 @@ pub fn render_scene_hits( let anim = Some(BuildAnimationCtx { time, scene_duration: scene.duration, + fps: config.fps, }); let built = build_scene_from_refs(children.iter(), (vw, vh), root_css, anim); let layout = run_layout(&built.root, (vw, vh), &ConversionContext::default()); diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index f63f1e8..879ac9e 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -436,6 +436,7 @@ mod component_smoke { BuildAnimationCtx { time, scene_duration, + fps: 30, }, ); let layout = run_layout( @@ -1192,6 +1193,7 @@ mod svg_draw_on_tests { BuildAnimationCtx { time: progress, scene_duration, + fps: 30, }, ); let layout = run_layout( @@ -1504,6 +1506,7 @@ mod audio_tests { BuildAnimationCtx { time, scene_duration: 10.0, + fps, }, ); let layout = run_layout( @@ -1775,3 +1778,321 @@ mod audio_tests { ); } } + +// ─── Motion blur / Trail effect tests ───────────────────────────────────────── + +#[cfg(test)] +mod motion_blur_trail { + //! TDD tests for motion_blur and trail animation effects. + //! + //! These tests drive the pipeline through `render_new_at` and inspect raw + //! pixel buffers to verify the ghost-based temporal sampling. + + use crate::components::{ChildComponent, Component, PositionMode}; + use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx}; + use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; + use rustmotion_core::css::taffy_bridge::ConversionContext; + use rustmotion_core::engine::layout_pass::run_layout; + use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; + + const FPS: u32 = 30; + + /// Render the scene at a specific time through the new pipeline. + /// Returns RGBA8888 (premul) pixels. + fn render_at( + children: &[ChildComponent], + w: u32, + h: u32, + time: f64, + scene_duration: f64, + ) -> Vec { + let mut surface = + skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).expect("surface"); + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); + let built = build_scene_with_anim( + children, + (w as f32, h as f32), + BuildAnimationCtx { + time, + scene_duration, + fps: FPS, + }, + ); + let layout = run_layout( + &built.root, + (w as f32, h as f32), + &ConversionContext::default(), + ); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); + let frame = PaintFrame { + time, + frame_index: (time * FPS as f64) as u32, + fps: FPS, + video_width: w, + video_height: h, + scene_duration, + }; + paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); + let row_bytes = w as usize * 4; + let mut pixels = vec![0u8; row_bytes * h as usize]; + let info = skia_safe::ImageInfo::new( + (w as i32, h as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0)); + pixels + } + + /// Count unique x-columns (0..w) that have at least one lit pixel (R > threshold). + fn lit_column_span(pixels: &[u8], w: usize, h: usize, r_threshold: u8) -> usize { + let mut hit = vec![false; w]; + for y in 0..h { + for x in 0..w { + let r = pixels[(y * w + x) * 4]; + if r > r_threshold { + hit[x] = true; + } + } + } + hit.iter().filter(|&&b| b).count() + } + + /// Find the maximum red channel value across all pixels. + fn max_red(pixels: &[u8]) -> u8 { + pixels.chunks_exact(4).map(|p| p[0]).max().unwrap_or(0) + } + + /// Make a red Shape with slide_in_left + motion_blur, positioned at the center. + fn make_motion_blur_scene(samples: u32, intensity: f32) -> Vec { + let json = serde_json::json!({ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "style": { + "width": "80px", + "height": "80px", + "animation": [ + { "name": "slide_in_left", "duration": 1.0 }, + { "name": "motion_blur", "intensity": intensity, "samples": samples } + ] + } + }); + let component: Component = serde_json::from_value(json).expect("motion_blur json"); + vec![ChildComponent { + component, + position: Some(PositionMode::Absolute { x: 200.0, y: 110.0 }), + x: None, + y: None, + z_index: None, + }] + } + + /// Make a red Shape with slide_in_left + trail effect. + fn make_trail_scene(copies: u32, spacing: f64, falloff: f32) -> Vec { + let json = serde_json::json!({ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "style": { + "width": "80px", + "height": "80px", + "animation": [ + { "name": "slide_in_left", "duration": 1.0 }, + { "name": "trail", "copies": copies, "spacing": spacing, "falloff": falloff } + ] + } + }); + let component: Component = serde_json::from_value(json).expect("trail json"); + vec![ChildComponent { + component, + position: Some(PositionMode::Absolute { x: 200.0, y: 110.0 }), + x: None, + y: None, + z_index: None, + }] + } + + // ────────────────────────────────────────────────────────────────────────── + // TDD RED TESTS — these will fail until the implementation is in place. + // ────────────────────────────────────────────────────────────────────────── + + /// Motion blur broadens the x-span of lit pixels compared to no-blur. + #[test] + fn motion_blur_broadens_horizontal_span() { + let w = 500u32; + let h = 300u32; + // t=0.5 → shape mid-animation → significant motion → ghosts should + // land to the left of the principal node. + let without = make_motion_blur_scene(1, 1.0); // samples=1 → no-op (degenerate) + let with_blur = make_motion_blur_scene(6, 1.0); + + let buf_without = render_at(&without, w, h, 0.5, 1.0); + let buf_with = render_at(&with_blur, w, h, 0.5, 1.0); + + let span_without = lit_column_span(&buf_without, w as usize, h as usize, 30); + let span_with = lit_column_span(&buf_with, w as usize, h as usize, 30); + + assert!( + span_with > span_without, + "motion_blur (samples=6) should broaden horizontal span: without={span_without}, with={span_with}" + ); + } + + /// samples=1 is the degenerate case — behaves identically to no blur. + /// We relax this: samples=1 produces one ghost but ghost and principal + /// overlap at t_0 - 0 = t_0. Result should be visually equivalent + /// (total span ≤ span_without + 5 pixels of rounding noise). + #[test] + fn motion_blur_samples_1_is_degenerate() { + let w = 500u32; + let h = 300u32; + let no_effect = { + // A plain shape with no motion_blur at all + let json = serde_json::json!({ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "style": { + "width": "80px", + "height": "80px", + "animation": [{ "name": "slide_in_left", "duration": 1.0 }] + } + }); + let component: Component = serde_json::from_value(json).unwrap(); + vec![ChildComponent { + component, + position: Some(PositionMode::Absolute { x: 200.0, y: 110.0 }), + x: None, + y: None, + z_index: None, + }] + }; + let with_1 = make_motion_blur_scene(1, 1.0); + + let buf_no = render_at(&no_effect, w, h, 0.5, 1.0); + let buf_1 = render_at(&with_1, w, h, 0.5, 1.0); + + let span_no = lit_column_span(&buf_no, w as usize, h as usize, 30); + let span_1 = lit_column_span(&buf_1, w as usize, h as usize, 30); + + assert!( + span_1 <= span_no + 5, + "motion_blur samples=1 should match no-blur (span_no={span_no}, span_1={span_1})" + ); + } + + /// Static component (no transform animation) + motion_blur → ghosts overlap + /// exactly. The render should look the same as without blur except possibly + /// for a reduced opacity of the principal (opacity accumulation). + /// We verify: no horizontal broadening and the image is not black. + #[test] + fn motion_blur_static_component_no_broadening() { + let w = 500u32; + let h = 300u32; + + let no_blur = { + let json = serde_json::json!({ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "style": { "width": "80px", "height": "80px" } + }); + let component: Component = serde_json::from_value(json).unwrap(); + vec![ChildComponent { + component, + position: Some(PositionMode::Absolute { x: 200.0, y: 110.0 }), + x: None, + y: None, + z_index: None, + }] + }; + let with_blur = { + let json = serde_json::json!({ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "style": { + "width": "80px", + "height": "80px", + "animation": [ + { "name": "motion_blur", "intensity": 1.0, "samples": 6 } + ] + } + }); + let component: Component = serde_json::from_value(json).unwrap(); + vec![ChildComponent { + component, + position: Some(PositionMode::Absolute { x: 200.0, y: 110.0 }), + x: None, + y: None, + z_index: None, + }] + }; + + let buf_no = render_at(&no_blur, w, h, 0.5, 1.0); + let buf_blur = render_at(&with_blur, w, h, 0.5, 1.0); + + let span_no = lit_column_span(&buf_no, w as usize, h as usize, 30); + let span_blur = lit_column_span(&buf_blur, w as usize, h as usize, 30); + + // No broadening (ghosts superimpose). + assert!( + span_blur <= span_no + 5, + "static + motion_blur should not broaden: span_no={span_no}, span_blur={span_blur}" + ); + + // Image must not be black (ghosts accumulate enough opacity). + let max_r = max_red(&buf_blur); + assert!( + max_r > 50, + "static + motion_blur must still render visibly (max_r={max_r})" + ); + } + + /// Trail with copies=3 should produce a wider horizontal span than without, + /// and the leading edge (rightmost in slide_in_left animation) should be + /// brighter than the trailing edge. + #[test] + fn trail_produces_multiple_distinct_blobs() { + let w = 600u32; + let h = 300u32; + + // At t=0.5 the shape is at mid-slide. Trail ghosts are at + // t-spacing, t-2*spacing, t-3*spacing → further left. + let no_trail = { + let json = serde_json::json!({ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "style": { + "width": "60px", + "height": "60px", + "animation": [{ "name": "slide_in_left", "duration": 1.0 }] + } + }); + let component: Component = serde_json::from_value(json).unwrap(); + vec![ChildComponent { + component, + position: Some(PositionMode::Absolute { x: 300.0, y: 120.0 }), + x: None, + y: None, + z_index: None, + }] + }; + let with_trail = make_trail_scene(3, 0.1, 0.6); + + let buf_no = render_at(&no_trail, w, h, 0.5, 1.0); + let buf_trail = render_at(&with_trail, w, h, 0.5, 1.0); + + let span_no = lit_column_span(&buf_no, w as usize, h as usize, 20); + let span_trail = lit_column_span(&buf_trail, w as usize, h as usize, 20); + + assert!( + span_trail > span_no, + "trail (copies=3) should broaden horizontal span: span_no={span_no}, span_trail={span_trail}" + ); + } +}