diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index 5f331cf..ed4f6b1 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -221,6 +221,121 @@ fn detect_ghost_effects( (mb, tr) } +fn ghost_css_for( + child: &ChildComponent, + parent_css: &CssStyle, + extra_delay: f64, + scene_duration: f64, + ghost_time: f64, + ghost_opacity_scale: f32, +) -> CssStyle { + let mut css = component_css(&child.component); + css.position = Some(Position::Absolute); + if let Some((x, y)) = child.absolute_position() { + css.left = Some(CLP::Px(x)); + css.top = Some(CLP::Px(y)); + } + if let Some(z) = child.z_index { + css.z_index = Some(z); + } + rustmotion_core::css::cascade::inherit_from(parent_css, &mut css); + 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 - extra_delay, skip_opacity); + let overrides = resolve_transition_css_overrides( + child.component.as_styled().style_config(), + steps, + ghost_time - extra_delay, + ); + if let Some(br) = overrides.border_radius { + css.border_radius = Some(br); + } + if let Some(bg) = overrides.background { + css.background = Some(bg); + } + } + } + if let Some(ghost_effects) = effective_effects(&child.component, extra_delay, ghost_time) { + let props = resolve_props_for_effects(&ghost_effects, ghost_time, scene_duration); + if props_has_paint_overrides(&props) { + apply_animated_props(&mut css, &props); + } + apply_glow_effect(&mut css, &ghost_effects); + carry_paint_pass_effects(&mut css, &ghost_effects); + apply_directional_blur_props(&mut css, &props); + } + let base_opacity = css.opacity.unwrap_or(1.0); + css.opacity = Some((base_opacity * ghost_opacity_scale).clamp(0.0, 1.0)); + css +} + +#[allow(clippy::too_many_arguments)] +fn build_one_ghost<'a>( + child: &'a ChildComponent, + components: &mut Vec>, + stagger_delays: &mut Vec, + time_params: &mut Vec<(f64, f64)>, + next_id: &mut NodeId, + anim: Option, + actx: BuildAnimationCtx, + stagger_delay: f64, + extra_delay: f64, + time_remap: (f64, f64), + parent_css: &CssStyle, + path: &str, + viewport: (f32, f32), + outer_scope: Option<&dyn Scope>, + warn_unresolved: bool, + offset: f64, + ghost_opacity_scale: f32, +) -> BoxNode { + let ghost_time = actx.time - offset; + let ghost_time_remap = (time_remap.0, time_remap.1 - offset); + let ghost_css = ghost_css_for( + child, + parent_css, + extra_delay, + actx.scene_duration, + ghost_time, + ghost_opacity_scale, + ); + let ghost_intrinsic = component_intrinsic(&child.component, &ghost_css); + + let ghost_id = *next_id; + *next_id += 1; + components.push(Some(child)); + stagger_delays.push(extra_delay); + time_params.push(ghost_time_remap); + + let ghost_children = container_children( + &child.component, + components, + stagger_delays, + time_params, + next_id, + anim, + path, + stagger_delay, + ghost_time_remap, + &ghost_css, + viewport, + outer_scope, + warn_unresolved, + ); + + BoxNode { + id: ghost_id, + kind: BoxKind::Ghost(Arc::new(ghost_id)), + css: ghost_css, + children: ghost_children, + intrinsic: ghost_intrinsic, + source_path: None, + window: None, + } +} + #[allow(clippy::too_many_arguments)] fn build_ghosts<'a>( child: &'a ChildComponent, @@ -228,11 +343,17 @@ fn build_ghosts<'a>( stagger_delays: &mut Vec, time_params: &mut Vec<(f64, f64)>, next_id: &mut NodeId, + anim: Option, actx: BuildAnimationCtx, + stagger_delay: f64, extra_delay: f64, time_remap: (f64, f64), effects: &[AnimationEffect], parent_css: &CssStyle, + path: &str, + viewport: (f32, f32), + outer_scope: Option<&dyn Scope>, + warn_unresolved: bool, ) -> Vec { let (mb, tr) = detect_ghost_effects(effects); @@ -248,6 +369,9 @@ fn build_ghosts<'a>( }, } let strategy = if let Some(mc) = mb { + if mc.mode == rustmotion_core::schema::MotionBlurMode::Smear { + return Vec::new(); + } let samples = mc.samples.clamp(1, 16); if samples <= 1 { return Vec::new(); @@ -268,58 +392,6 @@ fn build_ghosts<'a>( return Vec::new(); }; - let base_css_for_ghost = |ghost_time: f64, ghost_opacity_scale: f32| -> CssStyle { - 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); - } - rustmotion_core::css::cascade::inherit_from(parent_css, &mut css); - 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 - extra_delay, skip_opacity); - let overrides = resolve_transition_css_overrides( - child.component.as_styled().style_config(), - steps, - ghost_time - extra_delay, - ); - if let Some(br) = overrides.border_radius { - css.border_radius = Some(br); - } - if let Some(bg) = overrides.background { - css.background = Some(bg); - } - } - } - let ghost_actx = BuildAnimationCtx { - time: ghost_time, - scenario_time: actx.scenario_time, - scene_duration: actx.scene_duration, - fps: actx.fps, - }; - if let Some(ghost_effects) = effective_effects(&child.component, extra_delay, ghost_time) { - 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); - } - apply_glow_effect(&mut css, &ghost_effects); - carry_paint_pass_effects(&mut css, &ghost_effects); - } - 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 { @@ -329,24 +401,26 @@ fn build_ghosts<'a>( } => { 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; - components.push(Some(child)); - stagger_delays.push(extra_delay); - time_params.push(time_remap); - - ghosts.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, - }); + let offset = i as f64 * shutter_window / samples as f64; + ghosts.push(build_one_ghost( + child, + components, + stagger_delays, + time_params, + next_id, + anim, + actx, + stagger_delay, + extra_delay, + time_remap, + parent_css, + path, + viewport, + outer_scope, + warn_unresolved, + offset, + ghost_opacity_scale, + )); } } Strategy::Trail { @@ -356,25 +430,27 @@ fn build_ghosts<'a>( } => { 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 offset = 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(extra_delay); - time_params.push(time_remap); - - 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, - }); + trail_nodes.push(build_one_ghost( + child, + components, + stagger_delays, + time_params, + next_id, + anim, + actx, + stagger_delay, + extra_delay, + time_remap, + parent_css, + path, + viewport, + outer_scope, + warn_unresolved, + offset, + ghost_opacity_scale, + )); } trail_nodes.reverse(); ghosts = trail_nodes; @@ -426,11 +502,17 @@ fn build_child<'a>( stagger_delays, time_params, next_id, + anim, actx, + stagger_delay, anim_delay, time_remap, &effects, parent_css, + &path, + viewport, + outer_scope, + warn_unresolved, ); } } @@ -482,6 +564,16 @@ fn build_child<'a>( } apply_glow_effect(&mut css, &effects); carry_paint_pass_effects(&mut css, &effects); + apply_directional_blur_props(&mut css, &props); + apply_motion_blur_smear( + &mut css, + &effects, + &child.component, + anim_delay, + actx.time, + actx.scene_duration, + actx.fps, + ); } resolve_computed_style( &mut css, @@ -1003,6 +1095,70 @@ fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::Ani css.filter.get_or_insert_with(Vec::new).push(shadow); } +fn apply_directional_blur_props(css: &mut CssStyle, props: &AnimatedProperties) { + use rustmotion_core::css::style::FilterFn; + use rustmotion_core::css::units::Length; + + if props.blur_x <= 0.0 && props.blur_y <= 0.0 { + return; + } + css.filter + .get_or_insert_with(Vec::new) + .push(FilterFn::Blur { + radius: None, + radius_x: (props.blur_x > 0.0).then_some(Length::Px(props.blur_x)), + radius_y: (props.blur_y > 0.0).then_some(Length::Px(props.blur_y)), + }); +} + +#[allow(clippy::too_many_arguments)] +fn apply_motion_blur_smear( + css: &mut CssStyle, + effects: &[rustmotion_core::schema::AnimationEffect], + component: &Component, + extra_delay: f64, + t: f64, + scene_duration: f64, + fps: u32, +) { + use rustmotion_core::css::style::FilterFn; + use rustmotion_core::css::units::Length; + use rustmotion_core::schema::AnimationEffect; + use rustmotion_core::schema::MotionBlurMode; + + let Some(cfg) = effects.iter().find_map(|e| match e { + AnimationEffect::MotionBlur(c) if c.mode == MotionBlurMode::Smear => Some(c), + _ => None, + }) else { + return; + }; + + let shutter_window = (cfg.shutter / fps.max(1) as f64).max(1e-6); + let sample = |at: f64| -> (f32, f32) { + match effective_effects(component, extra_delay, at) { + Some(e) => { + let p = resolve_props_for_effects(&e, at, scene_duration); + (p.translate_x, p.translate_y) + } + None => (0.0, 0.0), + } + }; + let (x0, y0) = sample(t - shutter_window); + let (x1, y1) = sample(t); + let radius_x = (x1 - x0).abs(); + let radius_y = (y1 - y0).abs(); + if radius_x < 0.5 && radius_y < 0.5 { + return; + } + css.filter + .get_or_insert_with(Vec::new) + .push(FilterFn::Blur { + radius: None, + radius_x: Some(Length::Px(radius_x)), + radius_y: Some(Length::Px(radius_y)), + }); +} + fn component_intrinsic( component: &Component, cascaded_css: &CssStyle, @@ -2922,4 +3078,408 @@ mod tests { bleed: false, } } + + use crate::legacy_dispatch::LegacyPaintDispatcher; + use rustmotion_core::css::style::{AlignItems, JustifyContent}; + use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; + + #[test] + fn ghost_does_not_steal_a_flex_slot() { + let root_css = CssStyle { + display: Some(Display::Flex), + flex_direction: Some(FlexDirection::Column), + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + gap: Some(Gap::Uniform(CLP::Px(20.0))), + width: Some(CSize::Length(CLP::Px(640.0))), + height: Some(CSize::Length(CLP::Px(360.0))), + ..Default::default() + }; + let scene = vec![ + child_from_json(json!({ + "type": "shape", "shape": "rect", "fill": "#FF4FB0", + "style": { "width": 200, "height": 60, "animation": [{ "name": "motion_blur" }] } + })), + child_from_json(json!({ + "type": "shape", "shape": "rect", "fill": "#4B5BFF", + "style": { "width": 200, "height": 60 } + })), + ]; + let anim = BuildAnimationCtx { + time: 10.0 / 30.0, + scenario_time: 10.0 / 30.0, + scene_duration: 1.0, + fps: 30, + }; + let built = build_scene_at_time(&scene, (640.0, 360.0), root_css, anim); + let layout = run_layout(&built.root, (640.0, 360.0), &ConversionContext::default()); + + let principals: Vec<_> = built + .root + .children + .iter() + .filter(|n| matches!(n.kind, BoxKind::Component(_))) + .collect(); + assert_eq!( + principals.len(), + 2, + "exactly the two real elements; ghosts must not be counted among them" + ); + + let first = layout.get(principals[0].id).expect("first box laid out"); + let second = layout.get(principals[1].id).expect("second box laid out"); + + assert!( + (first.y - 110.0).abs() < 1.0, + "the motion-blurred box itself must sit at the flex-resolved position (110), got {}", + first.y + ); + assert!( + (second.y - 190.0).abs() < 1.0, + "a motion-blurred sibling must not push the next flex item down (expected 190, \ + regression of #66), got {}", + second.y + ); + } + + #[test] + fn ghost_of_a_container_carries_its_nested_child() { + let container_component: Component = serde_json::from_value(json!({ + "type": "div", + "style": { + "width": 200, "height": 100, + "background": "#FF00FF", + "animation": [{ "name": "trail", "copies": 1, "spacing": 0.05, "falloff": 1.0 }] + }, + "children": [{ + "type": "shape", "shape": "rect", "fill": "#00FF00", + "position": "absolute", "x": 20, "y": 20, + "style": { "width": 40, "height": 40 } + }] + })) + .expect("container deserializes"); + let scene = vec![ChildComponent { + id: None, + component: container_component, + position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + bleed: false, + }]; + + let frame_time = 0.2; + let anim = BuildAnimationCtx { + time: frame_time, + scenario_time: frame_time, + scene_duration: 1.0, + fps: 30, + }; + let mut built = build_scene_at_time( + &scene, + (200.0, 100.0), + default_root_css((200.0, 100.0)), + anim, + ); + + let ghost_index = built + .root + .children + .iter() + .position(|n| matches!(n.kind, BoxKind::Ghost(_))) + .expect("a ghost was built for the trail effect"); + let ghost = built.root.children.remove(ghost_index); + assert_eq!( + ghost.children.len(), + 1, + "the ghost of a container must carry the container's own children, not an empty subtree" + ); + + let mini_root = BoxNode::container(default_root_css((200.0, 100.0)), vec![ghost]); + let layout = run_layout(&mini_root, (200.0, 100.0), &ConversionContext::default()); + + let mut surface = + skia_safe::surfaces::raster_n32_premul((200, 100)).expect("raster surface"); + let canvas = surface.canvas(); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); + let frame = PaintFrame { + light: Default::default(), + time: frame_time, + scenario_time: frame_time, + frame_index: 6, + fps: 30, + video_width: 200, + video_height: 100, + scene_duration: 1.0, + camera: None, + }; + paint_tree(canvas, &mini_root, &layout, &frame, &dispatcher); + + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (1, 1), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let mut buf = [0u8; 4]; + assert!(snapshot.read_pixels( + &info, + &mut buf, + 4, + skia_safe::IPoint::new(40, 40), + skia_safe::image::CachingHint::Disallow, + )); + assert!( + buf[1] > 200 && buf[0] < 80 && buf[2] < 80, + "the ghost must paint the nested green shape, not just the container's own \ + background; got {:?}", + buf + ); + } + + #[test] + fn ghost_of_a_measured_leaf_keeps_its_intrinsic_size() { + let scene = vec![child_from_json(json!({ + "type": "text", + "content": "TEXT", + "position": "absolute", "x": 100, "y": 40, + "style": { + "font-size": 96, "color": "#FFFFFF", + "animation": [ + { "name": "keyframes", "keyframes": [{ "property": "translate_x", "easing": "linear", "keyframes": [ + { "time": 0, "value": 1200 }, { "time": 1, "value": -1200 }] }] }, + { "name": "motion_blur", "samples": 4, "shutter": 1.0 } + ] + } + }))]; + let anim = BuildAnimationCtx { + time: 0.5, + scenario_time: 0.5, + scene_duration: 1.0, + fps: 30, + }; + let built = build_scene_at_time( + &scene, + (1280.0, 360.0), + default_root_css((1280.0, 360.0)), + anim, + ); + let layout = run_layout(&built.root, (1280.0, 360.0), &ConversionContext::default()); + + let ghosts: Vec<_> = built + .root + .children + .iter() + .filter(|n| matches!(n.kind, BoxKind::Ghost(_))) + .collect(); + assert!( + !ghosts.is_empty(), + "expected ghosts for a motion-blurred text node" + ); + for g in &ghosts { + assert!( + g.intrinsic.is_some(), + "a ghost of a measured component (text) needs its own intrinsic to size itself" + ); + let l = layout.get(g.id).expect("ghost laid out"); + assert!( + l.width > 0.0 && l.height > 0.0, + "text ghost collapsed to a zero-size box, got {}x{}", + l.width, + l.height + ); + } + } + + #[test] + fn ghost_of_a_path_driven_pointer_samples_a_different_waypoint_offset() { + let scene = vec![child_from_json(json!({ + "type": "pointer", + "size": 220, "tone": "dark", + "path": [ + { "time": 0.0, "x": 1900, "y": 1000 }, + { "time": 0.6, "x": 900, "y": 500 } + ], + "path_easing": "linear", + "style": { "animation": [{ "name": "motion_blur", "samples": 4, "shutter": 3.0 }] } + }))]; + let frame_time = 0.5; + let anim = BuildAnimationCtx { + time: frame_time, + scenario_time: frame_time, + scene_duration: 1.0, + fps: 30, + }; + let built = build_scene_at_time( + &scene, + (1920.0, 1080.0), + default_root_css((1920.0, 1080.0)), + anim, + ); + + let (path, click_duration, path_easing) = match &scene[0].component { + Component::Pointer(p) => (p.path.clone(), p.click_duration, p.path_easing), + _ => panic!("expected a pointer component"), + }; + + let ghost_ids: Vec = built + .root + .children + .iter() + .filter(|n| matches!(n.kind, BoxKind::Ghost(_))) + .map(|n| n.id) + .collect(); + assert!(ghost_ids.len() >= 2, "expected several ghosts"); + + let offsets: Vec<(f32, f32)> = ghost_ids + .iter() + .map(|&id| { + let (scale, shift) = built.time_params[id as usize]; + let local_time = frame_time * scale + shift; + crate::cursor::waypoint_offset(&path, local_time, click_duration, path_easing) + }) + .collect(); + + let distinct = offsets + .windows(2) + .any(|w| (w[0].0 - w[1].0).abs() > 1.0 || (w[0].1 - w[1].1).abs() > 1.0); + assert!( + distinct, + "ghosts of a path-driven pointer must sample different points along the path, got {:?}", + offsets + ); + + let live_offset = + crate::cursor::waypoint_offset(&path, frame_time, click_duration, path_easing); + assert!( + offsets + .iter() + .all(|o| (o.0 - live_offset.0).abs() > 1.0 || (o.1 - live_offset.1).abs() > 1.0), + "every ghost sampled the live pointer position instead of its own instant; \ + got {:?} vs live {:?}", + offsets, + live_offset + ); + } + + #[test] + fn animated_blur_x_becomes_a_directional_blur_filter() { + use rustmotion_core::css::style::FilterFn; + use rustmotion_core::css::units::Length; + + let scene = vec![child_from_json(json!({ + "type": "shape", "shape": "rect", "fill": "#FFFFFF", + "style": { + "width": 100, "height": 100, + "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [ + { "property": "blur_x", "easing": "linear", "keyframes": [ + { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 40.0 } + ] } + ] }] + } + }))]; + let anim = BuildAnimationCtx { + time: 0.5, + scenario_time: 0.5, + scene_duration: 1.0, + fps: 30, + }; + let built = build_scene_at_time( + &scene, + (400.0, 400.0), + default_root_css((400.0, 400.0)), + anim, + ); + + let filters = built.root.children[0] + .css + .filter + .clone() + .expect("a blur filter was applied"); + let blur = filters + .iter() + .find(|f| matches!(f, FilterFn::Blur { .. })) + .expect("a Blur filter"); + match blur { + FilterFn::Blur { + radius, + radius_x, + radius_y, + } => { + assert!(radius.is_none(), "isotropic radius must stay unset"); + assert!( + matches!(radius_x, Some(Length::Px(v)) if (*v - 20.0).abs() < 1.0), + "expected ~20px halfway through a 0->40 linear blur_x ramp, got {:?}", + radius_x + ); + assert!( + radius_y.is_none(), + "blur_y was never animated, must stay unset" + ); + } + other => panic!("expected Blur, got {:?}", other), + } + } + + #[test] + fn motion_blur_smear_mode_produces_no_ghosts_and_a_directional_filter_instead() { + use rustmotion_core::css::style::FilterFn; + use rustmotion_core::css::units::Length; + + let scene = vec![child_from_json(json!({ + "type": "shape", "shape": "rect", "fill": "#FFFFFF", + "position": "absolute", "x": 0, "y": 0, + "style": { + "width": 100, "height": 60, + "animation": [ + { "name": "keyframes", "duration": 1.0, "keyframes": [ + { "property": "translate_x", "easing": "linear", "keyframes": [ + { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 } + ] } + ] }, + { "name": "motion_blur", "mode": "smear", "shutter": 1.0 } + ] + } + }))]; + let anim = BuildAnimationCtx { + time: 0.5, + scenario_time: 0.5, + scene_duration: 1.0, + fps: 30, + }; + let built = build_scene_at_time( + &scene, + (1920.0, 1080.0), + default_root_css((1920.0, 1080.0)), + anim, + ); + + assert_eq!( + built.root.children.len(), + 1, + "smear mode must not create ghost nodes" + ); + assert!(matches!(built.root.children[0].kind, BoxKind::Component(_))); + + let filters = built.root.children[0] + .css + .filter + .clone() + .expect("a smear filter was applied"); + let blur = filters + .iter() + .find(|f| matches!(f, FilterFn::Blur { .. })) + .expect("a Blur filter"); + match blur { + FilterFn::Blur { radius_x, .. } => { + assert!( + matches!(radius_x, Some(Length::Px(v)) if (*v - 30.0).abs() < 3.0), + "900px/s over a 1/30s shutter window is a ~30px streak, got {:?}", + radius_x + ); + } + other => panic!("expected Blur, got {:?}", other), + } + } } diff --git a/crates/rustmotion-core/src/css/animation.rs b/crates/rustmotion-core/src/css/animation.rs index 7cf260f..99a08e3 100644 --- a/crates/rustmotion-core/src/css/animation.rs +++ b/crates/rustmotion-core/src/css/animation.rs @@ -46,7 +46,9 @@ pub fn apply_animated_props(css: &mut CssStyle, props: &AnimatedProperties) { let mut filters: Vec = Vec::new(); if props.blur > 0.0 { filters.push(FilterFn::Blur { - radius: Length::Px(props.blur), + radius: Some(Length::Px(props.blur)), + radius_x: None, + radius_y: None, }); } if props.glow_radius > 0.0 && props.glow_intensity > 0.0 { diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index 3d60c54..f3fe47f 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -1258,7 +1258,24 @@ pub struct TransformOrigin { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "fn", rename_all = "kebab-case")] pub enum FilterFn { + /// Isotropic by default (`radius`). `radius-x`/`radius-y` make it + /// anisotropic — a horizontal-only smear is `radius-x` with `radius-y` + /// at 0 (or omitted). When both are given they win over `radius` on + /// their own axis; when neither is given the filter falls back to + /// `radius` on both axes. Blur { + #[serde(default)] + radius: Option, + #[serde(default, rename = "radius-x")] + radius_x: Option, + #[serde(default, rename = "radius-y")] + radius_y: Option, + }, + /// A single-axis blur held at `angle` degrees (0 = along +x, 90 = along + /// +y) instead of being locked to the horizontal or vertical axis — + /// the diagonal case `radius-x`/`radius-y` can't express. + DirectionalBlur { + angle: f32, radius: Length, }, Brightness { @@ -1856,4 +1873,52 @@ mod tests { "must not emit the legacy snake_case key any more" ); } + + #[test] + fn blur_filter_accepts_isotropic_radius_with_no_axis_fields() { + let json = r#"{ "filter": [{ "fn": "blur", "radius": 24 }] }"#; + let s: CssStyle = serde_json::from_str(json).unwrap(); + let f = &s.filter.expect("filter set")[0]; + assert!(matches!( + f, + FilterFn::Blur { + radius: Some(_), + radius_x: None, + radius_y: None + } + )); + } + + #[test] + fn blur_filter_accepts_per_axis_radius_with_no_isotropic_radius() { + let json = r#"{ "filter": [{ "fn": "blur", "radius-x": 40, "radius-y": 0 }] }"#; + let s: CssStyle = serde_json::from_str(json).unwrap(); + let f = &s.filter.expect("filter set")[0]; + match f { + FilterFn::Blur { + radius, + radius_x, + radius_y, + } => { + assert!(radius.is_none()); + assert!(matches!(radius_x, Some(Length::Px(v)) if (*v - 40.0).abs() < 1e-6)); + assert!(matches!(radius_y, Some(Length::Px(v)) if v.abs() < 1e-6)); + } + other => panic!("expected Blur, got {:?}", other), + } + } + + #[test] + fn directional_blur_deserializes_with_kebab_case_tag() { + let json = r#"{ "filter": [{ "fn": "directional-blur", "angle": 90, "radius": 40 }] }"#; + let s: CssStyle = serde_json::from_str(json).unwrap(); + let f = &s.filter.expect("filter set")[0]; + match f { + FilterFn::DirectionalBlur { angle, radius } => { + assert!((*angle - 90.0).abs() < 1e-6); + assert!(matches!(radius, Length::Px(v) if (*v - 40.0).abs() < 1e-6)); + } + other => panic!("expected DirectionalBlur, got {:?}", other), + } + } } diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index ce7ec1f..f114047 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -516,6 +516,8 @@ pub struct AnimatedProperties { pub scale_y: f32, pub rotation: f32, pub blur: f32, + pub blur_x: f32, + pub blur_y: f32, pub visible_chars: i32, pub visible_chars_progress: f32, pub color: Option, @@ -548,6 +550,8 @@ impl Default for AnimatedProperties { scale_y: 1.0, rotation: 0.0, blur: 0.0, + blur_x: 0.0, + blur_y: 0.0, visible_chars: -1, visible_chars_progress: -1.0, color: None, @@ -595,6 +599,12 @@ impl AnimatedProperties { if other.blur > 0.001 { self.blur = other.blur; } + if other.blur_x > 0.001 { + self.blur_x = other.blur_x; + } + if other.blur_y > 0.001 { + self.blur_y = other.blur_y; + } if other.visible_chars >= 0 { self.visible_chars = other.visible_chars; } @@ -899,6 +909,8 @@ fn apply_property(props: &mut AnimatedProperties, property: &str, value: f64) { "scale.y" => props.scale_y = value as f32, "rotation" => props.rotation = value as f32, "blur" => props.blur = value as f32, + "blur_x" => props.blur_x = value as f32, + "blur_y" => props.blur_y = value as f32, "visible_chars" => props.visible_chars = value as i32, "visible_chars_progress" => props.visible_chars_progress = value as f32, "border_radius" => props.border_radius = value as f32, @@ -1128,6 +1140,8 @@ fn get_property_value(props: &AnimatedProperties, property: &str) -> f64 { "scale.y" => props.scale_y as f64, "rotation" => props.rotation as f64, "blur" => props.blur as f64, + "blur_x" => props.blur_x as f64, + "blur_y" => props.blur_y as f64, "border_radius" => props.border_radius as f64, "font_size" => props.font_size as f64, "width" => props.width as f64, diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index bcdc2f2..a7c0000 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -10,7 +10,9 @@ use crate::css::style::{ Background, BackgroundLayer, BorderEdges, BorderRadius, BorderStyle, BoxShadow, ClipPath, Color, CssStyle, Edges, Material, MaterialPreset, Overflow, TransformFn, TransformOrigin, }; -use crate::css::units::{parse_origin_component, LengthContext, LengthPercentage, ParsedLength}; +use crate::css::units::{ + parse_origin_component, Length, LengthContext, LengthPercentage, ParsedLength, +}; use crate::engine::box_tree::{BoxKind, BoxNode, NodeId}; use crate::engine::layout_pass::{BoxLayout, LayoutResult}; @@ -263,7 +265,7 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u } if let Some(filters) = node.css.backdrop_filter.as_deref() { - if let Some(backdrop) = filters_to_image_filter(filters, &length_ctx) { + if let Some(backdrop) = filters_to_image_filter(filters, &length_ctx, box_layout) { let radius = node .css .border_radius @@ -286,7 +288,7 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u .css .filter .as_deref() - .and_then(|list| filters_to_image_filter(list, &length_ctx)); + .and_then(|list| filters_to_image_filter(list, &length_ctx, box_layout)); let aberration_shift = active_chromatic_aberration(&node.css, ctx.frame.time) .map(|(cfg, progress)| crate::engine::animator::chromatic_aberration_shift(cfg, progress)); let aberration_filter = aberration_shift.and_then(chromatic_aberration_image_filter); @@ -608,12 +610,39 @@ fn depth_of_field_image_filter(sigma: f32) -> Option { skia_safe::image_filters::blur((sigma, sigma), skia_safe::TileMode::Decal, None, None) } +fn resolve_blur_radii( + radius: Option<&Length>, + radius_x: Option<&Length>, + radius_y: Option<&Length>, + ctx: &LengthContext, +) -> (f32, f32) { + let iso = radius.map(|r| r.resolve(ctx).max(0.0)); + let rx = radius_x + .map(|r| r.resolve(ctx).max(0.0)) + .or(iso) + .unwrap_or(0.0); + let ry = radius_y + .map(|r| r.resolve(ctx).max(0.0)) + .or(iso) + .unwrap_or(0.0); + (rx, ry) +} + fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f32 { use crate::css::style::FilterFn; let mut bleed = 0.0f32; for f in list { let b = match f { - FilterFn::Blur { radius } => radius.resolve(ctx).max(0.0) * 1.5, + FilterFn::Blur { + radius, + radius_x, + radius_y, + } => { + let (rx, ry) = + resolve_blur_radii(radius.as_ref(), radius_x.as_ref(), radius_y.as_ref(), ctx); + rx.max(ry) * 1.5 + } + FilterFn::DirectionalBlur { radius, .. } => radius.resolve(ctx).max(0.0) * 1.5, FilterFn::DropShadow { offset_x, offset_y, @@ -681,6 +710,7 @@ fn subtree_layout_bounds(node: &BoxNode, layout: &LayoutResult) -> Option fn filters_to_image_filter( list: &[crate::css::style::FilterFn], ctx: &LengthContext, + layout: &BoxLayout, ) -> Option { use crate::css::style::FilterFn; use skia_safe::image_filters; @@ -688,12 +718,30 @@ fn filters_to_image_filter( let mut chain: Option = None; for f in list { chain = match f { - FilterFn::Blur { radius } => { + FilterFn::Blur { + radius, + radius_x, + radius_y, + } => { + let (rx, ry) = + resolve_blur_radii(radius.as_ref(), radius_x.as_ref(), radius_y.as_ref(), ctx); + if rx <= 0.0 && ry <= 0.0 { + chain + } else { + image_filters::blur( + (rx / 2.0, ry / 2.0), + skia_safe::TileMode::Clamp, + chain, + None, + ) + } + } + FilterFn::DirectionalBlur { angle, radius } => { let r = radius.resolve(ctx).max(0.0); if r <= 0.0 { chain } else { - image_filters::blur((r / 2.0, r / 2.0), skia_safe::TileMode::Clamp, chain, None) + directional_blur_image_filter(r, *angle, (layout.cx(), layout.cy()), chain) } } FilterFn::DropShadow { @@ -727,6 +775,27 @@ fn filters_to_image_filter( chain } +fn directional_blur_image_filter( + radius: f32, + angle_deg: f32, + pivot: (f32, f32), + input: Option, +) -> Option { + use skia_safe::{image_filters, Matrix, Point, SamplingOptions, TileMode}; + + let sigma = radius / 2.0; + if sigma <= 0.0 { + return input; + } + let point = Point::new(pivot.0, pivot.1); + let sampling = SamplingOptions::default(); + let to_axis = Matrix::rotate_deg_pivot(-angle_deg, point); + let back_to_angle = Matrix::rotate_deg_pivot(angle_deg, point); + let aligned = image_filters::matrix_transform(&to_axis, sampling, input); + let blurred = image_filters::blur((sigma, 0.0), TileMode::Clamp, aligned, None)?; + image_filters::matrix_transform(&back_to_angle, sampling, blurred) +} + fn noise_image_filter(intensity: f32, seed: u64) -> Option { use skia_safe::image_filters; @@ -834,7 +903,10 @@ fn color_matrix_for(f: &crate::css::style::FilterFn) -> Option<[f32; 20]> { ]; Some(m) } - FilterFn::Blur { .. } | FilterFn::DropShadow { .. } | FilterFn::Noise { .. } => None, + FilterFn::Blur { .. } + | FilterFn::DirectionalBlur { .. } + | FilterFn::DropShadow { .. } + | FilterFn::Noise { .. } => None, } } @@ -2351,7 +2423,9 @@ mod hit_tests { width: Some(CSize::Length(CLP::Px(100.0))), height: Some(CSize::Length(CLP::Px(100.0))), backdrop_filter: Some(vec![FilterFn::Blur { - radius: Length::Px(10.0), + radius: Some(Length::Px(10.0)), + radius_x: None, + radius_y: None, }]), ..Default::default() }, @@ -2443,7 +2517,9 @@ mod hit_tests { width: Some(CSize::Length(CLP::Px(100.0))), height: Some(CSize::Length(CLP::Px(100.0))), backdrop_filter: Some(vec![FilterFn::Blur { - radius: Length::Px(10.0), + radius: Some(Length::Px(10.0)), + radius_x: None, + radius_y: None, }]), opacity: Some(0.99), ..Default::default() @@ -4342,7 +4418,9 @@ mod paint_order_tests { background: Some(Background::Color(CssColor::String("#ff0000".into()))), opacity: Some(0.999), filter: Some(vec![FilterFn::Blur { - radius: Length::Px(24.0), + radius: Some(Length::Px(24.0)), + radius_x: None, + radius_y: None, }]), ..Default::default() }, diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 8c5c1ee..ff0f3cf 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -620,6 +620,8 @@ const KNOWN_MOTION_PROPERTIES: &[&str] = &[ "rotate_x", "rotate_y", "blur", + "blur_x", + "blur_y", "visible_chars", "visible_chars_progress", "border_radius", @@ -703,6 +705,12 @@ pub struct KeyframesConfig { /// /// `samples = 1` is the degenerate case: the single ghost falls at `t - 0` and /// superimposes exactly on the principal → visually equivalent to no blur. +/// +/// `mode: "smear"` skips the ghost sampler entirely: the node's displacement +/// over the `shutter / fps` window that precedes `t` is turned into a +/// `{ "fn": "blur", "radius-x": …, "radius-y": … }` filter on the principal +/// itself — one streaked copy instead of up to sixteen stacked ones, and no +/// risk of a ghost taking a flex slot since none are created. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct MotionBlurConfig { @@ -713,12 +721,30 @@ pub struct MotionBlurConfig { 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). + /// Ignored when `mode` is `"smear"`. #[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. + /// The temporal spread equals `shutter / fps` seconds. Also the window + /// `mode: "smear"` samples velocity over. #[serde(default = "default_motion_blur_shutter")] pub shutter: f64, + /// `"stack"` (default) paints ghost copies at sampled instants. `"smear"` + /// derives a directional blur from instantaneous velocity instead. + #[serde(default)] + pub mode: MotionBlurMode, +} + +/// How `motion_blur` renders its trail. See `MotionBlurConfig`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MotionBlurMode { + /// Ghost copies painted at sampled instants (the historical behaviour). + #[default] + Stack, + /// A directional `blur` derived from instantaneous velocity, applied to + /// the principal itself. Creates no ghost nodes; `samples` is ignored. + Smear, } fn default_motion_blur_samples() -> u32 { diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 755b91d..990a25e 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -247,6 +247,8 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/svg-text-fonts.md](rules/svg-text-fonts.md) - Why `` inside an `svg` needs a resolvable font face, and what happens when the host has none - [rules/vhs-tear.md](rules/vhs-tear.md) - The `vhs` scene effect: bands slid sideways, noise, scanlines and a travelling tracking line — bounded by `at`/`duration` because it is a beat, not a filter - [rules/rich-text-pills.md](rules/rich-text-pills.md) - Pill spans in `rich_text`: padding that moves the following span, `box-decoration-break: clone` on a wrap, and rotation that turns the box without touching layout +- [rules/motion-blur-and-trail.md](rules/motion-blur-and-trail.md) - `motion_blur` and `trail`: ghosts never take a flex slot, they carry their node's children, and `mode: "smear"` replaces them with a displacement blur +- [rules/directional-blur.md](rules/directional-blur.md) - Blur on one axis: `radius-x`/`radius-y`, the `directional-blur` filter, and the animatable `blur_x`/`blur_y` - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [rules/border-style.md](rules/border-style.md) - `dashed`, `dotted` and `double` borders, their cadence formulas, and animating `border-radius` by keyframe - [rules/clip-path-morph.md](rules/clip-path-morph.md) - Animating a `clip-path` via `kind: "morph"` and the scalar `clip_path_progress`, what interpolates, and what a kind mismatch does diff --git a/crates/rustmotion/skills/rules/directional-blur.md b/crates/rustmotion/skills/rules/directional-blur.md new file mode 100644 index 0000000..c8802d9 --- /dev/null +++ b/crates/rustmotion/skills/rules/directional-blur.md @@ -0,0 +1,43 @@ +# Rule: Flou directionnel — `radius-x`/`radius-y`, `directional-blur`, `blur_x`/`blur_y` + +`filter: [{ "fn": "blur", "radius": N }]` est isotrope : à forte valeur, un mot qui traverse le cadre bave verticalement autant qu'horizontalement et perd sa forme. Trois briques couvrent le cas d'un élément étiré le long de son axe de déplacement — un aplat qui glisse, un mot qui whip, une étiquette qui tombe. + +## Flou statique par axe : `radius-x` / `radius-y` + +```json +{ "filter": [{ "fn": "blur", "radius-x": 40, "radius-y": 0 }] } +``` + +`Blur` a maintenant trois champs, tous optionnels : `radius` (isotrope, comportement historique), `radius-x`, `radius-y`. Quand `radius-x`/`radius-y` sont donnés ils l'emportent sur `radius` **sur leur propre axe** ; l'axe omis retombe sur `radius` si présent, sinon vaut 0. `{ "radius-x": 40, "radius-y": 0 }` donne un flou strictement horizontal ; `{ "radius": 24 }` reste un flou isotrope classique — aucun scénario existant ne change de rendu. + +> Piège de casing : ces deux champs sont en kebab-case (`radius-x`), comme tout le reste de `FilterFn` — cohérent avec `offset-x`/`offset-y` de `box-shadow` et `drop-shadow`. `blur_x`/`blur_y` (voir plus bas) sont en revanche en **snake_case**, parce qu'ils vivent dans l'espace de noms des propriétés animables (`translate_x`, `scale.x`, …), pas dans celui des champs de filtre — deux conventions différentes, chacune cohérente avec ses voisines immédiates. + +## Flou statique en diagonale : `directional-blur` + +```json +{ "filter": [{ "fn": "directional-blur", "angle": 90, "radius": 40 }] } +``` + +Pour un déplacement qui n'est ni horizontal ni vertical. `angle` est en degrés (`0` = along +x, `90` = along +y), `radius` la longueur du flou le long de cet axe. Rendu par rotation du contenu échantillonné autour du centre de la boîte, flou mono-axe, rotation inverse — pas une vraie convolution orientée, mais visuellement équivalent pour un flou raisonnable, et sans coût suffisant pour justifier un noyau dédié. + +## Flou animé : `blur_x` / `blur_y` + +```json +{ "property": "blur_x", "keyframes": [{ "time": 0, "value": 40 }, { "time": 0.3, "value": 0 }] } +``` + +Deux nouvelles propriétés animables (`KNOWN_MOTION_PROPERTIES`), au même titre que `blur` (qui reste isotrope et inchangé). Elles se posent en filtre `Blur { radius-x, radius-y }` sur le nœud — se combinent avec un `radius`/`radius-x`/`radius-y` statique déclaré par ailleurs en s'ajoutant à la liste `filter`, pas en le remplaçant. + +## `motion_blur mode: "smear"` en tire parti automatiquement + +Voir [rules/motion-blur-and-trail.md](motion-blur-and-trail.md) — le mode `smear` de `motion_blur` calcule lui-même un `radius-x`/`radius-y` à partir de la vitesse instantanée et pose ce même filtre `Blur`, sans que l'auteur du scénario ait à l'écrire à la main. + +## Ce qui n'est pas câblé : `blur_axis` par caractère + +L'issue #360 proposait aussi un axe de flou **par unité** sur les presets `char_*` : + +```json +{ "name": "char_blur_in", "granularity": "word", "blur_axis": "motion", "stretch": 1.3 } +``` + +Non implémenté : le rendu par-caractère (`char_*`) vit dans le composant `text` et son renderer, pas dans `animator.rs`/`box_builder.rs`. Ajouter `blur_axis`/`stretch` au schéma sans que le renderer les consomme produirait un champ accepté mais inerte — exactement ce que ce projet évite ailleurs (`text-autofit` sur un composant qui ne l'implémente pas). À traiter comme un chantier séparé, dans `text.rs`/`renderer/text.rs`. diff --git a/crates/rustmotion/skills/rules/motion-blur-and-trail.md b/crates/rustmotion/skills/rules/motion-blur-and-trail.md new file mode 100644 index 0000000..eab09c5 --- /dev/null +++ b/crates/rustmotion/skills/rules/motion-blur-and-trail.md @@ -0,0 +1,42 @@ +# Rule: `motion_blur` et `trail` — les fantômes ne sont plus des enfants du flex + +`motion_blur` et `trail` (`style.animation`) peignent des copies fantômes du composant à des instants antérieurs (`BoxKind::Ghost` dans `box_builder.rs`), avec une opacité décroissante. Jusqu'à la régression de l'issue #359, ces fantômes se comportaient mal sur trois points distincts. Les trois sont corrigés ; ce fichier documente le comportement actuel et ce qui reste volontairement hors scope. + +```json +{ + "type": "div", + "style": { + "width": 200, "height": 60, "background": "#FF4FB0", + "animation": [{ "name": "motion_blur", "samples": 8, "shutter": 1.0 }] + } +} +``` + +## 1. Un fantôme ne prend jamais de place dans le flex + +Avant la correction, un fantôme d'un enfant **en flux** (sans `position: absolute`) devenait lui-même un item flex à part entière — `samples` copies pleine taille en plus de l'élément réel, qui poussaient les frères suivants hors cadre. Un fantôme est maintenant systématiquement `position: absolute`, que le nœud qu'il duplique soit lui-même en flux ou déjà positionné : + +- Nœud déjà `position: absolute` → le fantôme reprend exactement son `left`/`top` (comportement inchangé). +- Nœud en flux → le fantôme n'a pas d'inset explicite ; Taffy le positionne alors selon `justify-content`/`align-items` du conteneur, comme n'importe quel enfant absolu sans `top`/`left` — il ne consomme aucun slot et ne déplace aucun frère, même si la position exacte du fantôme peut légèrement différer de celle du principal dans une mise en page asymétrique (`space-between`, plusieurs frères de tailles différentes). Le principal, lui, reste résolu par le flex normalement. + +## 2. Le fantôme d'un conteneur porte son propre sous-arbre + +Un fantôme n'est plus construit avec `children: Vec::new()`. Un `div` avec un fond et un enfant `text` voit maintenant les deux dupliqués — le sous-arbre est reconstruit à l'instant propre du fantôme (via `container_children`), pas simplement recopié depuis le principal : un enfant qui a sa propre animation (délai, keyframes) est donc rejoué à l'instant du fantôme, pas à l'instant courant de la scène. C'est la brique qui permet à une carte ou un mockup entier de traîner comme une unité. + +Un fantôme d'un composant **mesuré** (`text`, `counter`, `badge`, `table`, `rich_text`, `kbd`, `caption`, `number_wheel`) porte aussi son propre `intrinsic` — sans quoi la boîte se mesurait à zéro et rien ne se peignait, exactement le symptôme "le texte n'a aucune traînée, la forme d'à côté oui" de l'issue. + +> Pas de champ `scope: "self" | "subtree"` pour choisir de ne fantômer que la boîte du conteneur sans ses enfants — chaque fantôme d'un conteneur embarque systématiquement tout son sous-arbre. Aucun cas d'usage vérifié n'en a besoin ; à ajouter si un scénario réel le demande. + +## 3. `pointer.path` est échantillonné par fantôme + +Un `pointer` dont le déplacement vient de `path` (pas de `translate_x`/`translate_y` en keyframes) calcule sa position dans son propre `paint_content`, à partir de `ctx.time` — pas via `style.transform`. Chaque fantôme reçoit maintenant sa propre horloge locale (`time_params`, la même table que celle qui pilote `stagger_offset`), décalée exactement de l'écart temporel de cet échantillon. Un pointeur dont le trajet est piloté par `path` laisse donc une traînée le long de sa trajectoire, comme un pointeur piloté par keyframes. + +## `mode: "smear"` — pas de fantômes du tout + +```json +{ "name": "motion_blur", "mode": "smear", "shutter": 1.0 } +``` + +Au lieu d'empiler des copies (`mode: "stack"`, le défaut), `smear` mesure le déplacement du composant sur la fenêtre `shutter / fps` qui précède l'instant courant, et pose directement un filtre `{ "fn": "blur", "radius-x": …, "radius-y": … }` sur le principal — voir [rules/directional-blur.md](directional-blur.md). Zéro nœud fantôme créé : `samples` est ignoré en mode `smear`. C'est la solution recommandée pour un déplacement rapide et rectiligne (un mot qui traverse le cadre) — un vrai flou directionnel au lieu d'un escalier de copies visibles à haute vitesse. + +`samples: "auto"` (densifier les copies de `mode: "stack"` jusqu'à moins de 2px d'écart entre elles, mentionné dans l'issue #360 comme filet de sécurité si un vrai noyau de flou n'était pas atteignable) n'est pas implémenté — `mode: "smear"` couvre ce besoin directement, `samples` reste un entier `1..=16`.