From 5e1017be1dc50476365dab4a9c5baa4e23c3ed17 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 00:58:37 +0200 Subject: [PATCH 1/2] fix(motion): ghosts stop stealing flex slots, carry a subtree, and see their own clock (#359, #360) build_ghosts (box_builder.rs) had three defects reported as one issue, all traceable to the same root: a ghost was never given its own position, its own children, or its own paint-time clock. - Regression of #66: base_css_for_ghost only set `position: absolute` when the principal itself already was, so a ghost of a flow child kept the principal's flow-participating CSS and became an extra full-size flex item -- `samples` copies pushing every later sibling out of place. A ghost is now unconditionally `position: absolute`; one already-absolute gets its exact left/top, a flow one falls back to Taffy's justify/align resolution for an inset-less absolute child, which never consumes space. - A ghost of a container was built with `children: Vec::new()`, so only the container's own background/border ever traveled with the trail -- a card's label, a mockup's screenshot, never did. build_one_ghost now calls the existing `container_children` at the ghost's own sampled time (via a `(scale, shift)` remap identical in shape to the principal's, just re-anchored so `ghost_time` plays the role `actx.time` normally does), so nested content is rebuilt, not copied. A ghost of a *measured* leaf (text, counter, badge, table, rich_text, ...) also picks up its own `component_intrinsic`, which the old ghost never set -- without it the box measured to zero and nothing painted, the "shape trails, text doesn't" half of the same issue. - A `pointer` moved by `path` reads its own position from `ctx.time` inside `paint_content`, not from `style.transform` -- so re-evaluating its CSS at a ghost's sampled time did nothing, since dispatch recomputes `ctx.time` itself from `time_params` at paint time. Every ghost now gets its own `time_params` entry, shifted by exactly its own sample offset, so `LegacyPaintDispatcher::dispatch`'s `local_time` -- and therefore `ctx.time` handed to `paint_content` -- lands on the ghost's own instant for *any* effect that reads live time at dispatch, not just transforms baked in at build time. Each of the three has a test named for its fault in box_builder.rs's tests module (ghost_does_not_steal_a_flex_slot, ghost_of_a_container_carries_its_nested_child + ghost_of_a_measured_leaf_keeps_its_intrinsic_size, ghost_of_a_path_driven_pointer_samples_a_different_waypoint_offset), each reverted against the pre-fix build_ghosts and confirmed red before being restored. #360's directional blur rides the same fix, since a smear kernel needed motion_blur's internals anyway: - `FilterFn::Blur` gains optional `radius-x`/`radius-y` (falling back to `radius` per-axis when absent -- fully backward compatible), and a new `FilterFn::DirectionalBlur { angle, radius }` for the diagonal case, rendered in paint_pass.rs by rotating the sampled content around the box's own center, blurring on one axis, and rotating back. - `blur_x`/`blur_y` join `KNOWN_MOTION_PROPERTIES` and `AnimatedProperties` as animatable keyframe properties, surfaced as the same `Blur` filter via a new box_builder.rs helper (apply_animated_props in css/animation.rs isn't in this workstream's owned files, so the isotropic `blur` path there is untouched and the new axes are composed alongside it instead). - `motion_blur mode: "smear"` skips the ghost sampler entirely: it measures the node's own displacement over the `shutter / fps` window and applies that as a `Blur { radius-x, radius-y }` filter on the principal -- the "real kernel" the issue preferred over stacking more visible copies. Touched crates/rustmotion-core/src/engine/paint_pass.rs (shared): the two `filters_to_image_filter` call sites now pass `box_layout` (needed for `DirectionalBlur`'s rotation pivot), the `Blur`/`DirectionalBlur` match arms in `filter_bleed`/`filters_to_image_filter`/`color_matrix_for`, a new `resolve_blur_radii` + `directional_blur_image_filter` helper, and three existing `FilterFn::Blur { radius }` test literals updated for the new fields. Also one mechanical line in css/animation.rs's `apply_animated_props` (outside owned files) to add the two new `Blur` fields to its existing isotropic-blur construction site, unavoidable once `FilterFn::Blur` gained fields. Not implemented, and named as such in the new rule docs: a `scope` toggle to ghost only a container's own box without its children (every container ghost now always carries its subtree; no scenario needed the opt-out), `samples: "auto"` densification (superseded by `mode: "smear"`), and per-character `blur_axis`/`stretch` on the char_* presets (lives in text.rs/renderer/text.rs, outside this workstream's owned files -- wiring the schema field without a consumer would be exactly the inert-field pattern this codebase avoids elsewhere). Two new French rule docs land under skills/rules/ (motion-blur-and-trail.md, directional-blur.md); SKILL.md is deliberately left unlinked since the orchestrator owns that index. --- .../rustmotion-components/src/box_builder.rs | 736 +++++++++++++++--- crates/rustmotion-core/src/css/animation.rs | 4 +- crates/rustmotion-core/src/css/style.rs | 65 ++ crates/rustmotion-core/src/engine/animator.rs | 14 + .../rustmotion-core/src/engine/paint_pass.rs | 98 ++- crates/rustmotion-core/src/schema/video.rs | 28 +- .../skills/rules/directional-blur.md | 43 + .../skills/rules/motion-blur-and-trail.md | 42 + 8 files changed, 930 insertions(+), 100 deletions(-) create mode 100644 crates/rustmotion/skills/rules/directional-blur.md create mode 100644 crates/rustmotion/skills/rules/motion-blur-and-trail.md 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/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`. From 1b6ff0471e69e3c78aba0c01e164f7ceaf70967c Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 01:06:25 +0200 Subject: [PATCH 2/2] docs(skills): link the motion-blur and directional-blur rules into the index --- crates/rustmotion/skills/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) 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