diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index ac6e0da..408d1c6 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -33,6 +33,7 @@ pub struct BuiltScene<'a> { pub components: Vec>, pub stagger_delays: Vec, pub time_params: Vec<(f64, f64)>, + pub ghost_principal: Vec<(NodeId, NodeId)>, } pub fn build_scene<'a>(children: &'a [ChildComponent], viewport: (f32, f32)) -> BuiltScene<'a> { @@ -108,6 +109,7 @@ where let mut components: Vec> = vec![None]; let mut stagger_delays: Vec = vec![0.0]; let mut time_params: Vec<(f64, f64)> = vec![(1.0, 0.0)]; + let mut ghost_principal: Vec<(NodeId, NodeId)> = Vec::new(); let mut next_id: NodeId = 1; let mut child_boxes = Vec::new(); @@ -117,6 +119,7 @@ where &mut components, &mut stagger_delays, &mut time_params, + &mut ghost_principal, &mut next_id, anim, format!("/children/{i}"), @@ -147,6 +150,7 @@ where components, stagger_delays, time_params, + ghost_principal, } } @@ -264,6 +268,7 @@ fn ghost_css_for( carry_paint_pass_effects(&mut css, &ghost_effects); apply_directional_blur_props(&mut css, &props); } + apply_pointer_path_transform(&mut css, &child.component, ghost_time); let base_opacity = css.opacity.unwrap_or(1.0); css.opacity = Some((base_opacity * ghost_opacity_scale).clamp(0.0, 1.0)); css @@ -275,6 +280,7 @@ fn build_one_ghost<'a>( components: &mut Vec>, stagger_delays: &mut Vec, time_params: &mut Vec<(f64, f64)>, + ghost_principal: &mut Vec<(NodeId, NodeId)>, next_id: &mut NodeId, anim: Option, actx: BuildAnimationCtx, @@ -312,6 +318,7 @@ fn build_one_ghost<'a>( components, stagger_delays, time_params, + ghost_principal, next_id, anim, path, @@ -340,6 +347,7 @@ fn build_ghosts<'a>( components: &mut Vec>, stagger_delays: &mut Vec, time_params: &mut Vec<(f64, f64)>, + ghost_principal: &mut Vec<(NodeId, NodeId)>, next_id: &mut NodeId, anim: Option, actx: BuildAnimationCtx, @@ -405,6 +413,7 @@ fn build_ghosts<'a>( components, stagger_delays, time_params, + ghost_principal, next_id, anim, actx, @@ -435,6 +444,7 @@ fn build_ghosts<'a>( components, stagger_delays, time_params, + ghost_principal, next_id, anim, actx, @@ -464,6 +474,7 @@ fn build_child<'a>( components: &mut Vec>, stagger_delays: &mut Vec, time_params: &mut Vec<(f64, f64)>, + ghost_principal: &mut Vec<(NodeId, NodeId)>, next_id: &mut NodeId, anim: Option, path: String, @@ -499,6 +510,7 @@ fn build_child<'a>( components, stagger_delays, time_params, + ghost_principal, next_id, anim, actx, @@ -520,6 +532,9 @@ fn build_child<'a>( components.push(Some(child)); stagger_delays.push(anim_delay); time_params.push(time_remap); + for g in &ghosts { + ghost_principal.push((g.id, id)); + } let mut css = component_css(&child.component); @@ -571,6 +586,7 @@ fn build_child<'a>( actx.fps, ); } + apply_pointer_path_transform(&mut css, &child.component, actx.time); resolve_computed_style( &mut css, &path, @@ -656,6 +672,7 @@ fn build_child<'a>( components, stagger_delays, time_params, + ghost_principal, next_id, anim, &path, @@ -1075,6 +1092,25 @@ fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::Ani css.filter.get_or_insert_with(Vec::new).push(shadow); } +fn apply_pointer_path_transform(css: &mut CssStyle, component: &Component, time: f64) { + use rustmotion_core::css::style::TransformFn; + use rustmotion_core::css::units::LengthPercentage as CssLP; + + let Component::Pointer(p) = component else { + return; + }; + if p.path.is_empty() { + return; + } + let (dx, dy) = crate::cursor::waypoint_offset(&p.path, time, p.click_duration, p.path_easing); + css.transform + .get_or_insert_with(Vec::new) + .push(TransformFn::Translate { + x: CssLP::Px(dx), + y: CssLP::Px(dy), + }); +} + fn apply_directional_blur_props(css: &mut CssStyle, props: &AnimatedProperties) { use rustmotion_core::css::style::FilterFn; use rustmotion_core::css::units::Length; @@ -1125,17 +1161,18 @@ fn apply_motion_blur_smear( }; 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 { + let dx = x1 - x0; + let dy = y1 - y0; + let magnitude = dx.hypot(dy); + if magnitude < 0.5 { return; } + let angle = dy.atan2(dx).to_degrees(); 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)), + .push(FilterFn::DirectionalBlur { + angle, + radius: Length::Px(magnitude), }); } @@ -1177,6 +1214,7 @@ fn container_children<'a>( components: &mut Vec>, stagger_delays: &mut Vec, time_params: &mut Vec<(f64, f64)>, + ghost_principal: &mut Vec<(NodeId, NodeId)>, next_id: &mut NodeId, anim: Option, parent_path: &str, @@ -1213,6 +1251,7 @@ fn container_children<'a>( components, stagger_delays, time_params, + ghost_principal, next_id, anim, format!("{parent_path}/children/{j}"), @@ -3363,6 +3402,97 @@ mod tests { ); } + #[test] + fn a_pointer_moved_by_path_actually_paints_ink_at_a_ghosts_own_position() { + 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": 12, "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 layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default()); + let mut surface = paint_scene_at(&built, &layout, 1920, 1080, frame_time); + + 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 (fx, fy) = (0.1946_f32, 0.4390_f32); + let size = 220.0_f32; + let probe_for = |offset: (f32, f32)| { + ( + (offset.0 + fx * size).round() as i32, + (offset.1 + fy * size).round() as i32, + ) + }; + + let live_offset = + crate::cursor::waypoint_offset(&path, frame_time, click_duration, path_easing); + let live_probe = probe_for(live_offset); + let live_alpha = read_pixel(&mut surface, live_probe.0, live_probe.1)[3]; + assert!( + live_alpha > 200, + "sanity check: the live, un-ghosted pointer must paint its own glyph at the \ + current path position; probed {:?}, alpha={live_alpha}", + live_probe + ); + + let ghost_id = built + .root + .children + .iter() + .filter(|n| matches!(n.kind, BoxKind::Ghost(_))) + .map(|n| n.id) + .min_by(|&a, &b| { + let ta = built.time_params[a as usize]; + let tb = built.time_params[b as usize]; + (frame_time * ta.0 + ta.1) + .partial_cmp(&(frame_time * tb.0 + tb.1)) + .unwrap() + }) + .expect("expected at least one ghost"); + let (scale, shift) = built.time_params[ghost_id as usize]; + let farthest_ghost_time = frame_time * scale + shift; + let ghost_offset = + crate::cursor::waypoint_offset(&path, farthest_ghost_time, click_duration, path_easing); + let ghost_probe = probe_for(ghost_offset); + let ghost_alpha = read_pixel(&mut surface, ghost_probe.0, ghost_probe.1)[3]; + assert!( + ghost_alpha > 20, + "a pointer moved by `path` must be ghosted along its trajectory just like one \ + moved by translate_x/y keyframes — the furthest motion_blur ghost (its own local \ + time {farthest_ghost_time}, opacity 1/13 of the live pointer) must paint ink at \ + its own point along the path ({ghost_offset:?}, live is {live_offset:?}), not \ + stay pixel-identical (fully transparent there) to the same pointer without \ + motion_blur; probed {:?}, alpha={ghost_alpha}", + ghost_probe + ); + + let empty_alpha = read_pixel(&mut surface, 100, 100)[3]; + assert_eq!( + empty_alpha, 0, + "sanity check: a point far from every sampled instant of the path must stay \ + untouched background" + ); + } + #[test] fn animated_blur_x_becomes_a_directional_blur_filter() { use rustmotion_core::css::style::FilterFn; @@ -3469,17 +3599,77 @@ mod tests { .expect("a smear filter was applied"); let blur = filters .iter() - .find(|f| matches!(f, FilterFn::Blur { .. })) - .expect("a Blur filter"); + .find(|f| matches!(f, FilterFn::DirectionalBlur { .. })) + .expect("a DirectionalBlur filter, not an axis-aligned Blur"); match blur { - FilterFn::Blur { radius_x, .. } => { + FilterFn::DirectionalBlur { angle, radius } => { assert!( - matches!(radius_x, Some(Length::Px(v)) if (*v - 30.0).abs() < 3.0), + angle.abs() < 1.0, + "a pure horizontal move must orient the streak along 0 degrees, got {angle}" + ); + assert!( + matches!(radius, 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 + radius ); } - other => panic!("expected Blur, got {:?}", other), + other => panic!("expected DirectionalBlur, got {:?}", other), + } + } + + #[test] + fn a_diagonal_smear_orients_the_directional_blur_along_the_actual_velocity() { + use rustmotion_core::css::style::FilterFn; + + 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 } + ] }, + { "property": "translate_y", "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, + ); + + let filters = built.root.children[0] + .css + .filter + .clone() + .expect("a smear filter was applied"); + let blur = filters + .iter() + .find(|f| matches!(f, FilterFn::DirectionalBlur { .. })) + .expect("a DirectionalBlur filter"); + match blur { + FilterFn::DirectionalBlur { angle, .. } => { + assert!( + (angle - 45.0).abs() < 1.0, + "equal x and y velocity must orient the streak at 45 degrees, not the \ + 0/90 axes a box blur would be limited to, got {angle}" + ); + } + other => panic!("expected DirectionalBlur, got {:?}", other), } } @@ -3570,6 +3760,75 @@ mod tests { surface } + #[test] + fn a_diagonal_smear_paints_a_streak_only_along_the_velocity_not_across_it() { + const VIEWPORT: f32 = 900.0; + let mut principal = child_from_json(json!({ + "type": "shape", "shape": "rect", "fill": "#FFFFFF", + "style": { + "width": 80, "height": 80, + "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 } + ] }, + { "property": "translate_y", "easing": "linear", "keyframes": [ + { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 900.0 } + ] } + ] }, + { "name": "motion_blur", "mode": "smear", "shutter": 1.0 } + ] + } + })); + principal.position = Some(crate::PositionMode::Absolute { x: 60.0, y: 60.0 }); + let scene = vec![principal]; + let anim = BuildAnimationCtx { + time: 0.5, + scenario_time: 0.5, + scene_duration: 1.0, + fps: 30, + }; + let built = build_scene_at_time( + &scene, + (VIEWPORT, VIEWPORT), + default_root_css((VIEWPORT, VIEWPORT)), + anim, + ); + let layout = run_layout( + &built.root, + (VIEWPORT, VIEWPORT), + &ConversionContext::default(), + ); + let mut surface = paint_scene_at(&built, &layout, VIEWPORT as i32, VIEWPORT as i32, 0.5); + + let center = (550.0_f32, 550.0_f32); + let half_diagonal = 40.0 * std::f32::consts::SQRT_2; + let reach = half_diagonal + 15.0; + let along = ( + (center.0 + reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32, + (center.1 + reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32, + ); + let perpendicular = ( + (center.0 + reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32, + (center.1 - reach * std::f32::consts::FRAC_1_SQRT_2).round() as i32, + ); + + let along_alpha = read_pixel(&mut surface, along.0, along.1)[3]; + let perpendicular_alpha = read_pixel(&mut surface, perpendicular.0, perpendicular.1)[3]; + + assert!( + perpendicular_alpha < 10, + "a point the same distance from the box's corner but 90 degrees off the 45-degree \ + motion must stay unlit — a directional blur has zero spread across its own axis; \ + got alpha {perpendicular_alpha}" + ); + assert!( + along_alpha > 25, + "the same distance along the actual 45-degree motion must carry the blur's tail, \ + got alpha {along_alpha}" + ); + } + #[test] fn an_animation_touching_only_border_radius_reaches_the_painted_frame() { const SIZE: i32 = 200; diff --git a/crates/rustmotion-components/src/pointer.rs b/crates/rustmotion-components/src/pointer.rs index f815b60..fe42079 100644 --- a/crates/rustmotion-components/src/pointer.rs +++ b/crates/rustmotion-components/src/pointer.rs @@ -9,7 +9,7 @@ use rustmotion_core::engine::renderer::{paint_from_hex, parse_hex_color}; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; -use crate::cursor::{waypoint_offset, CursorPathEasing, CursorWaypoint}; +use crate::cursor::{CursorPathEasing, CursorWaypoint}; /// Colour scheme of the pointer, so a scene picks one word instead of two /// hex values that have to stay in contrast with each other. @@ -308,16 +308,10 @@ impl Painter for Pointer { _props: &AnimatedProperties, ctx: &PaintCtx, ) { - let (dx, dy) = if self.path.is_empty() { - (0.0, 0.0) - } else { - waypoint_offset(&self.path, ctx.time, self.click_duration, self.path_easing) - }; let click = self.click_progress(ctx.time); let (fill, outline) = self.colors(); canvas.save(); - canvas.translate((dx, dy)); if let (Some(p), Some((stroke_f, travel_f))) = (click, self.click_ring.metrics()) { let ring_fallback = self.ring_fallback_color(&fill, &outline); @@ -373,6 +367,7 @@ impl Painter for Pointer { #[cfg(test)] mod tests { use super::*; + use crate::cursor::waypoint_offset; fn pointer(json: serde_json::Value) -> Pointer { serde_json::from_value(json).expect("pointer fixture") @@ -608,12 +603,23 @@ mod tests { "size": 120.0, "click_ring": "bold", "click_duration": 0.5, - "path": [{ "time": 0.0, "x": 150.0, "y": 150.0 }] + "click_at": [0.0] })); const W: i32 = 300; const H: i32 = 300; let background = skia_safe::Color::from_argb(255, 20, 20, 20); - let mut surface = render(&p, W, H, 0.15, background); + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + canvas.clear(background); + canvas.translate((150.0, 150.0)); + p.paint_content( + canvas, + &BoxLayout::default(), + &AnimatedProperties::default(), + &paint_ctx(0.15, W as u32, H as u32), + ); + } let radius = 0.3 * 1.25 * 120.0; let offset = radius * std::f32::consts::FRAC_1_SQRT_2; diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index 9963aa2..f357d4b 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -1419,9 +1419,9 @@ pub enum FilterFn { Blur { #[serde(default)] radius: Option, - #[serde(default, rename = "radius-x")] + #[serde(default, rename = "radius-x", alias = "radius_x")] radius_x: Option, - #[serde(default, rename = "radius-y")] + #[serde(default, rename = "radius-y", alias = "radius_y")] radius_y: Option, }, /// A single-axis blur held at `angle` degrees (0 = along +x, 90 = along @@ -2138,6 +2138,33 @@ mod tests { )); } + #[test] + fn blur_filter_accepts_snake_case_radius_x_and_radius_y_as_aliases() { + let json = r#"{ "filter": [{ "fn": "blur", "radius_x": 40, "radius_y": 12 }] }"#; + 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), + "the snake_case spelling must be accepted exactly like radius-x, got {:?}", + radius_x + ); + assert!( + matches!(radius_y, Some(Length::Px(v)) if (*v - 12.0).abs() < 1e-6), + "the snake_case spelling must be accepted exactly like radius-y, got {:?}", + radius_y + ); + } + other => panic!("expected Blur, got {:?}", other), + } + } + #[test] fn blur_filter_accepts_per_axis_radius_with_no_isotropic_radius() { let json = r#"{ "filter": [{ "fn": "blur", "radius-x": 40, "radius-y": 0 }] }"#; diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 5f86694..9876962 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -1289,8 +1289,8 @@ fn zoom_blur_transition( surface_to_pixels(streak_surface, width, height) } -const WHIP_STEPS: usize = 10; const WHIP_MAX_REACH: f32 = 0.5; +const WHIP_STEPS: usize = 120; fn whip_transition( frame_a: &[u8], @@ -1902,3 +1902,66 @@ mod iris_pill_shape_tests { ); } } + +#[cfg(test)] +mod whip_continuity_tests { + use super::*; + + fn solid_rgb(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec { + (0..width * height).flat_map(|_| [r, g, b, 255]).collect() + } + + fn off_centre_stripe(width: u32, height: u32) -> Vec { + let mut out = Vec::with_capacity((width * height * 4) as usize); + let (x0, x1) = (width * 5 / 8, width * 7 / 8); + for _y in 0..height { + for x in 0..width { + if x >= x0 && x < x1 { + out.extend_from_slice(&[240, 240, 240, 255]); + } else { + out.extend_from_slice(&[10, 10, 10, 255]); + } + } + } + out + } + + #[test] + fn a_wide_streak_has_no_large_jump_between_adjacent_pixels() { + const W: u32 = 640; + const H: u32 = 48; + let a = off_centre_stripe(W, H); + let b = solid_rgb(W, H, 40, 40, 40); + let out = whip_transition(&a, &b, W, H, 0.5, 6.0, TransitionDirection::Left); + + let row = H / 2; + let value_at = |x: u32| -> i32 { + let i = ((row * W + x) * 4) as usize; + out[i] as i32 + }; + + let shift = (0.5 * W as f32) as u32; + let sharp_edges = [W * 5 / 8 - shift, W * 7 / 8 - shift]; + let excluded = |x: u32| sharp_edges.iter().any(|&e| x.abs_diff(e) < 20); + + let mut max_jump = 0i32; + let mut jump_at = 0u32; + for x in 0..W - 1 { + if excluded(x) || excluded(x + 1) { + continue; + } + let jump = (value_at(x + 1) - value_at(x)).abs(); + if jump > max_jump { + max_jump = jump; + jump_at = x; + } + } + + assert!( + max_jump < 40, + "the streak must fade continuously across the trail, not in visible discrete \ + bands stepping between a handful of stamped copies — the biggest single-pixel \ + jump found was {max_jump} at x={jump_at} (row {row})" + ); + } +} diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index c705ec9..a1be5f6 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -901,6 +901,38 @@ pub struct Camera { /// Keyframe animations for camera properties. #[serde(default)] pub keyframes: Vec, + /// Blurs the frame across a shutter window when the camera itself is + /// moving (pan, zoom, rotation or `shake`) — opt-in, since it renders + /// `samples` sub-frame exposures and averages them instead of one. + /// Absent (the default) renders exactly as before. Skipped on any frame + /// where the resolved camera pose does not change across the window, so + /// a static shot never pays the extra cost. + #[serde(default)] + pub motion_blur: Option, +} + +/// Shutter-window camera motion blur, opt-in via `camera.motion_blur`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CameraMotionBlurConfig { + /// Sub-frame exposures averaged together. Each one is a full render, so + /// this is a direct cost multiplier — default `8`, clamped to `1..=16`. + #[serde(default = "default_camera_motion_blur_samples")] + pub samples: u32, + /// Exposure length as a multiple of one frame duration (`shutter / fps` + /// seconds), ending at the frame's own time. `0.5` (the default) covers + /// half a frame, the 180-degree shutter of a film camera; `1.0` leaves it + /// open for the whole frame and smears twice as far. + #[serde(default = "default_camera_motion_blur_shutter")] + pub shutter: f64, +} + +fn default_camera_motion_blur_samples() -> u32 { + 8 +} + +fn default_camera_motion_blur_shutter() -> f64 { + 0.5 } /// Where the light comes from, and how strong it is. diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 7301193..57d393b 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -245,6 +245,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/dot-map-orthographic.md](rules/dot-map-orthographic.md) - `dot_map` as a globe: orthographic projection, far-hemisphere culling, great-circle arcs and limb shading - [rules/camera-3d.md](rules/camera-3d.md) - Tilt a whole shot with `camera.rotate_x`/`rotate_y`/`perspective`: one shared vanishing point, and rotation scaled by each plane's `style.depth` - [rules/depth-of-field.md](rules/depth-of-field.md) - Defocus by plane: `camera.focus`/`aperture` on the `style.depth` scale, rack focus by keyframe, and why nothing moves without distinct depths +- [rules/camera-motion-blur.md](rules/camera-motion-blur.md) - Blur the shot itself on a fast pan/zoom: `camera.motion_blur: { samples, shutter }`, sub-frame accumulation, free on a still camera - [rules/char-animation-rich-and-gradient-text.md](rules/char-animation-rich-and-gradient-text.md) - `char_*` presets on `rich_text` and `gradient_text`: stagger across span boundaries, per-span `ink_from`, and why `ink_from` is inert on a gradient - [rules/text-morph.md](rules/text-morph.md) - `text.morph`: matched letters travel, unmatched ones fade or scramble — how it differs from `text.states` + `swap` - [rules/text-component-parity.md](rules/text-component-parity.md) - Where `text`, `rich_text` and `gradient_text` disagreed: colour alpha, literal whitespace and baseline, the CSS angle convention and explicit `stops` @@ -1638,6 +1639,7 @@ Scenes support a virtual camera with animatable pan, zoom, and rotation. | `zoom` | f32 | `1.0` | Zoom factor (2.0 = 2x zoom in) | | `rotation` | f32 | `0.0` | Rotation in degrees | | `keyframes` | array | `[]` | `[{ "property", "values": [{ "time", "value" }], "easing" }]` | +| `motion_blur` | object | `null` | Sub-frame shutter accumulation when the camera itself moves — see [Virtual Camera motion blur](rules/camera-motion-blur.md) | **Animatable properties:** `x`, `y`, `zoom`, `rotation` diff --git a/crates/rustmotion/skills/rules/camera-3d.md b/crates/rustmotion/skills/rules/camera-3d.md index f75eaf3..99160e3 100644 --- a/crates/rustmotion/skills/rules/camera-3d.md +++ b/crates/rustmotion/skills/rules/camera-3d.md @@ -1,10 +1,9 @@ -# Incliner tout le plan : `camera.rotate_x` / `rotate_y` / `perspective` +# Tilting the whole plane: `camera.rotate_x` / `rotate_y` / `perspective` -La parallaxe de `style.depth` connaît la translation et le zoom. Pour faire -basculer un plan entier, on copiait jusqu'ici les mêmes keyframes `rotate_x` sur -chaque groupe — et **chaque groupe se retrouvait avec son propre point de fuite**, -ce qui ne lit pas comme une caméra mais comme des cartes qui tournent chacune dans -son coin. +`style.depth` parallax knows about translation and zoom. To tilt a whole plane, +the workaround used to be copying the same `rotate_x` keyframes onto every group +— and **each group ended up with its own vanishing point**, which does not read +as a camera but as cards each spinning in its own corner. ```json "camera": { @@ -17,36 +16,34 @@ son coin. } ``` -| Champ | Défaut | Rôle | +| Field | Default | Role | |---|---|---| -| `rotate_x` | `0` | bascule autour de l'axe horizontal, en degrés | -| `rotate_y` | `0` | bascule autour de l'axe vertical | -| `perspective` | `0` | distance d'observation en pixels ; `0` = projection orthographique | +| `rotate_x` | `0` | tilt about the horizontal axis, in degrees | +| `rotate_y` | `0` | tilt about the vertical axis | +| `perspective` | `0` | viewing distance in pixels; `0` = orthographic projection | -Les trois s'animent par `keyframes` comme `zoom` et `rotation`. +All three animate through `keyframes`, like `zoom` and `rotation`. -## Un seul point de fuite, et l'échelle par profondeur +## One vanishing point, and depth-scaled rotation -La perspective est appliquée **une fois**, autour de l'origine de la caméra. C'est -toute la différence avec la version copiée sur chaque groupe. +Perspective is applied **once**, around the camera's origin. That is the whole +difference from the version copied onto each group. -La rotation, elle, est mise à l'échelle par le `style.depth` de chaque enfant -direct de la scène — la même règle que la parallaxe et que `camera.focus` suivent -déjà. Un plan à `depth: 3` bascule trois fois plus qu'un plan à `depth: 1`, ce qui -est ce qui donne la sensation de volume. +The rotation, in turn, is scaled by each direct scene child's `style.depth` — the +same rule parallax and `camera.focus` already follow. A plane at `depth: 3` tilts +three times as much as one at `depth: 1`, and that is what gives the sense of +volume. -## Deux pièges +## Two traps -**Ça ne s'applique qu'aux enfants directs de la scène.** Comme la parallaxe : un -plan est une couche. Poser `depth` sur un nœud enfoui dans un sous-arbre ne le -fait pas basculer tout seul. +**It only applies to direct scene children.** Like parallax: a plane is a layer. +Putting `depth` on a node buried in a subtree does not tilt it on its own. -**`perspective: 0` n'est pas « pas de rotation ».** C'est une bascule -orthographique — la forme se déforme sans converger. Pour une caméra qui lit comme -une caméra, il faut une distance, et `1200`–`1800` couvre la plupart des cadrages. +**`perspective: 0` is not "no rotation".** It is an orthographic tilt — the shape +skews without converging. For a camera that reads as a camera you need a +distance, and `1200`–`1800` covers most framings. -## Ce qui n'y est pas +## See also -Le flou de mouvement de caméra (#362 partie 2) n'est pas ici. Il demande -d'accumuler plusieurs rendus sous-frame le long du mouvement, ce qui vit dans la -boucle d'encodage et non dans le pass de peinture. +Camera motion blur (#362) is now implemented — `camera.motion_blur`, see +[rules/camera-motion-blur.md](camera-motion-blur.md). diff --git a/crates/rustmotion/skills/rules/camera-motion-blur.md b/crates/rustmotion/skills/rules/camera-motion-blur.md new file mode 100644 index 0000000..d3d0414 --- /dev/null +++ b/crates/rustmotion/skills/rules/camera-motion-blur.md @@ -0,0 +1,55 @@ +# Rule: `camera.motion_blur` — blurring the shot, not the components + +`motion_blur` in `style.animation` trails **one component** along its own animation (see [rules/motion-blur-and-trail.md](motion-blur-and-trail.md)). It sees nothing when the thing moving is the camera: on a fast pan the whole frame should smear, and every component is standing still in its own coordinates. + +`camera.motion_blur` is the other half. It opens a shutter window ending at the frame's own time, renders `samples` sub-frame exposures inside it, and averages them — the same thing a real camera does with a real shutter. + +```json +"camera": { + "motion_blur": { "samples": 8, "shutter": 1.0 }, + "keyframes": [ + { "property": "x", "easing": "ease_in_out", + "values": [{ "time": 0, "value": 0 }, { "time": 2, "value": -900 }] } + ] +} +``` + +| Field | Type | Default | Role | +|---|---|---|---| +| `samples` | int | `8` | Sub-frame exposures averaged together, clamped to `1..=16`. Each one is a full render. | +| `shutter` | f64 | `0.5` | Exposure length as a multiple of one frame (`shutter / fps` seconds), ending at the frame's own time. `0.5` is the 180-degree shutter of a film camera; `1.0` leaves it open for the whole frame and smears twice as far. | + +Absent (the default), nothing changes: the frame renders exactly as it did before. + +## Cost is a direct multiplier, and you only pay it while the camera moves + +`samples` full renders per frame is not an implementation detail to be optimised away later — it is what the feature *is*. Measured at 1920×1080 on a scene with a grid background, three shapes and two text blocks, taking the slope over 180 frames so process startup drops out: + +| | per frame | +|---|---| +| no `camera.motion_blur` | **4.1 ms** | +| `samples: 8`, `shutter: 1.0`, camera panning and zooming | **8.8 ms** | + +2.2× end to end, not 8×, because encoding and frame setup are paid once per frame whatever the render costs. The *render* portion does scale by `samples`, so the ratio climbs toward `samples` as the scene itself gets heavier. Budget for it before putting this on a long shot. + +Every frame resolves the camera pose (`x`, `y`, `zoom`, `rotation`, plus `scene.shake`) at both ends of the shutter window. If they match, the frame takes the ordinary single-render path and costs nothing extra — a held shot in the middle of a scenario that blurs its pans is free. + +## It is a shutter, so content blurs too + +The sub-frames resample *everything* at their own instant, not just the camera transform. A component animating fast during a camera pan smears as well. That is what a physical exposure does, and pinning content to the nominal frame while only the camera moves would mean threading a second, independent time channel through the render core. + +Practical consequence: do not combine a fast `camera.motion_blur` with a component that is itself mid-`char_*` stagger unless you want both softened. + +## An animated background does not smear + +`scene["animated-background"]` is painted in screen space and does not pan with the camera — verified: its grid columns sit at the same x at `time: 0` and at `time: 1.0` of a 900px pan. Since it does not move, averaging the sub-frames leaves it crisp. If you want the backdrop to carry the pan, it has to be real content in the scene tree, not a background preset. + +## Not to be confused with + +- **`style.animation: [{ "name": "motion_blur" }]`** — per component, ghost copies or a `smear` filter. Does nothing for a camera move. +- **the `whip` / `zoom_blur` transitions** — those composite two already-rendered frame buffers between scenes; they never re-render anything. See [rules/whip-transition.md](whip-transition.md). +- **`camera.focus`/`aperture`** — defocus by depth plane, a static property of the shot rather than a function of its movement. See [rules/depth-of-field.md](depth-of-field.md). + +## Where it lives + +In `render_frame_v2_scaled` (`crates/rustmotion/src/engine/render/scene.rs`), not in the encode loop: `render/mod.rs` re-exports scene's public items through an explicit named list, and the frame path already funnels through that one function. The encode side calls exactly what it called before and gets an already-averaged buffer back, so `apply_post_effects` still runs once on the final composited frame rather than once per sub-sample. diff --git a/crates/rustmotion/skills/rules/directional-blur.md b/crates/rustmotion/skills/rules/directional-blur.md index c8802d9..16237d1 100644 --- a/crates/rustmotion/skills/rules/directional-blur.md +++ b/crates/rustmotion/skills/rules/directional-blur.md @@ -1,43 +1,43 @@ -# Rule: Flou directionnel — `radius-x`/`radius-y`, `directional-blur`, `blur_x`/`blur_y` +# Rule: Directional blur — `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. +`filter: [{ "fn": "blur", "radius": N }]` is isotropic: at a high value, a word crossing the frame bleeds as much vertically as horizontally and loses its shape. Three pieces cover an element stretched along its own axis of travel — a flat shape sliding, a word whipping past, a label dropping. -## Flou statique par axe : `radius-x` / `radius-y` +## Static per-axis blur: `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. +`Blur` has three fields, all optional: `radius` (isotropic, the historical behaviour), `radius-x`, `radius-y`. When `radius-x`/`radius-y` are given they win over `radius` **on their own axis**; the omitted axis falls back to `radius` if present, and is 0 otherwise. `{ "radius-x": 40, "radius-y": 0 }` gives a strictly horizontal blur; `{ "radius": 24 }` stays a plain isotropic blur — no existing scenario changes its render. -> 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. +> Casing trap: both fields are kebab-case (`radius-x`), like everything else in `FilterFn` — consistent with `offset-x`/`offset-y` on `box-shadow` and `drop-shadow`. The snake_case spelling (`radius_x`, `radius_y`) is accepted as a parse alias — both spellings deserialise to the same field, but only `radius-x`/`radius-y` is re-emitted. `blur_x`/`blur_y` (below) are snake_case in their own right, because they live in the animatable-property namespace (`translate_x`, `scale.x`, …) rather than the filter-field one — two different conventions, each consistent with its immediate neighbours. -## Flou statique en diagonale : `directional-blur` +## Static diagonal blur: `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é. +For travel that is neither horizontal nor vertical. `angle` is in degrees (`0` = along +x, `90` = along +y), `radius` is the blur's length along that axis. Rendered by rotating the sampled content about the box centre, blurring on one axis, then rotating back — not a true oriented convolution, but visually equivalent for a reasonable blur, and not costly enough to justify a dedicated kernel. -## Flou animé : `blur_x` / `blur_y` +## Animated blur: `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. +Two animatable properties (`KNOWN_MOTION_PROPERTIES`), alongside `blur` (which stays isotropic and unchanged). They land as a `Blur { radius-x, radius-y }` filter on the node — they combine with a static `radius`/`radius-x`/`radius-y` declared elsewhere by being appended to the `filter` list, not by replacing it. -## `motion_blur mode: "smear"` en tire parti automatiquement +## `motion_blur mode: "smear"` uses this automatically -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. +See [rules/motion-blur-and-trail.md](motion-blur-and-trail.md) — `motion_blur`'s `smear` mode computes the component's instantaneous velocity and lays down a `{ "fn": "directional-blur", "angle", "radius" }` oriented along the actual displacement (`angle = atan2(dy, dx)`, `radius = hypot(dy, dx)`), with no need for the author to write it by hand. Diagonal travel therefore produces a streak at 45°, not the isotropic blob a `Blur { radius-x, radius-y }` pinned to the two screen axes would have given. -## Ce qui n'est pas câblé : `blur_axis` par caractère +## What is not wired: per-character `blur_axis` -L'issue #360 proposait aussi un axe de flou **par unité** sur les presets `char_*` : +Issue #360 also proposed a **per-unit** blur axis on the `char_*` presets: ```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`. +Not implemented: per-character rendering (`char_*`) lives in the `text` component and its renderer, not in `animator.rs`/`box_builder.rs`. Adding `blur_axis`/`stretch` to the schema without the renderer consuming them would produce a field that is accepted but inert — exactly what this project avoids elsewhere (`text-autofit` on a component that does not implement it). Treat it as a separate piece of work, in `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 index eab09c5..41919aa 100644 --- a/crates/rustmotion/skills/rules/motion-blur-and-trail.md +++ b/crates/rustmotion/skills/rules/motion-blur-and-trail.md @@ -1,6 +1,6 @@ -# Rule: `motion_blur` et `trail` — les fantômes ne sont plus des enfants du flex +# Rule: `motion_blur` and `trail` — a ghost is never a flex item -`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. +`motion_blur` and `trail` (`style.animation`) paint ghost copies of the component at earlier instants (`BoxKind::Ghost` in `box_builder.rs`), at decreasing opacity. Up to the regression in issue #359 these ghosts misbehaved in three distinct ways. All three are fixed; this file documents the current behaviour and what is deliberately out of scope. ```json { @@ -12,31 +12,35 @@ } ``` -## 1. Un fantôme ne prend jamais de place dans le flex +## 1. A ghost never takes up room in the 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é : +Before the fix, the ghost of an **in-flow** child (no `position: absolute`) became a full flex item in its own right — `samples` full-size copies on top of the real element, pushing the following siblings out of frame. A ghost is now always `position: absolute`, whether the node it duplicates is itself in flow or already positioned: -- 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. +- Node already `position: absolute` → the ghost takes its exact `left`/`top` (unchanged behaviour). +- Node in flow → the ghost has no explicit inset, so Taffy would place it by the container's `justify-content`/`align-items`, like any absolute child with no `top`/`left` — often the wrong spot in an asymmetric layout. The render corrects that afterwards: `apply_ghost_layout_fixup` (`crates/rustmotion/src/engine/render/scene.rs`) shifts every ghost, and its whole subtree by the same delta, onto the position taffy actually resolved for its principal in the `LayoutResult` it has just produced. The ghost therefore lands exactly on the box of the node it duplicates, including under `space-between` or between siblings of different sizes — this is no longer an approximation. -## 2. Le fantôme d'un conteneur porte son propre sous-arbre +## 2. A container's ghost carries its own subtree -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é. +A ghost is no longer built with `children: Vec::new()`. A `div` with a background and a `text` child now sees both duplicated — the subtree is rebuilt at the ghost's own instant (through `container_children`), not simply copied from the principal: a child with its own animation (delay, keyframes) is replayed at the ghost's instant, not at the scene's current one. That is what lets a whole card or mockup trail as one 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. +The ghost of a **measured** component (`text`, `counter`, `badge`, `table`, `rich_text`, `kbd`, `caption`, `number_wheel`) also carries its own `intrinsic` — without it the box measured to zero and nothing painted, exactly the "the text has no trail, the shape next to it does" symptom in the 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. +> There is no `scope: "self" | "subtree"` field to ghost only a container's box without its children — a container's ghost always carries its whole subtree. No verified use case needs it; add it if a real scenario asks. -## 3. `pointer.path` est échantillonné par fantôme +## 3. `pointer.path` is sampled per ghost -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. +A `pointer` moved by `path` no longer computes its position internally from `ctx.time`: `box_builder.rs` resolves `waypoint_offset` while building each node (ghost or principal) and injects it as `style.transform: translate(dx, dy)`, at that node's own instant — the very channel keyframed `translate_x`/`translate_y` already use. `Pointer::paint_content` no longer does that computation itself. -## `mode: "smear"` — pas de fantômes du tout +Each ghost gets its own local clock (`time_params`, the same table that drives `stagger_offset`), offset by exactly this sample's time delta. A pointer driven by `path` therefore trails along its trajectory, like one driven by keyframes. + +> Structural trap found while fixing this: the opacity layer the engine opens for any node at `opacity < 1` (so every ghost) bounds its `SaveLayerRec` on the node's *untransformed* layout box. A displacement applied inside `paint_content` (as the old `canvas.translate` did) is invisible to that bounds computation and gets clipped away. `style.transform` moves the canvas *before* those bounds are computed, so it lines up correctly. Any future in-`Painter` positioning must go through `style.transform`, never through an ad-hoc `canvas.translate`. + +## `mode: "smear"` — no ghosts at all ```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. +Instead of stacking copies (`mode: "stack"`, the default), `smear` measures the component's displacement over the `shutter / fps` window preceding the current instant and puts a `{ "fn": "directional-blur", "angle": …, "radius": … }` filter straight on the principal — see [rules/directional-blur.md](directional-blur.md). Zero ghost nodes created: `samples` is ignored in `smear` mode. This is the recommended answer for fast, straight movement (a word crossing the frame) — a real directional blur instead of a staircase of copies visible at speed. -`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`. +`samples: "auto"` (densifying `mode: "stack"`'s copies until they sit less than 2px apart, mentioned in issue #360 as a safety net if a real blur kernel turned out to be unreachable) is not implemented — `mode: "smear"` covers that need directly, and `samples` stays an integer in `1..=16`. diff --git a/crates/rustmotion/skills/rules/whip-transition.md b/crates/rustmotion/skills/rules/whip-transition.md index e30f58a..66a8801 100644 --- a/crates/rustmotion/skills/rules/whip-transition.md +++ b/crates/rustmotion/skills/rules/whip-transition.md @@ -1,8 +1,8 @@ -# Rule: `whip` — le pan flouté directionnel +# Rule: `whip` — the directional motion-blurred pan -`whip` est une `transition` (au même titre que `slide`, `chromatic_wipe`, `zoom_blur`…) : comme toute transition d'une vue `slide`, elle composite deux frame-buffers **déjà rendus** — aucun élément ne survit à la coupe, seuls les pixels sont mélangés. Voir la section « Composition » de `CLAUDE.md`. +`whip` is a `transition` (like `slide`, `chromatic_wipe`, `zoom_blur`…): as with every transition in a `slide` view, it composites two **already rendered** frame buffers — no element survives the cut, only pixels are mixed. See the "Composition" section of `CLAUDE.md`. -L'effet : un `slide` classique le long d'un axe, dont le déplacement porte un filé de mouvement qui culmine à mi-transition — la scène sortante s'étire en traînée derrière elle le long de `direction` en s'estompant, et la scène entrante arrive déjà striée avant de se stabiliser, nette, à l'arrivée. +The effect: a plain `slide` along an axis, whose displacement carries a motion streak peaking mid-transition — the outgoing scene stretches into a trail behind itself along `direction` as it fades, and the incoming scene arrives already streaked before settling, crisp, on arrival. ```json { @@ -16,32 +16,41 @@ L'effet : un `slide` classique le long d'un axe, dont le déplacement porte un f } ``` -(exemple de placement — `transition` se pose entre deux scènes d'une vue `slide`, comme n'importe quelle autre transition) +The `transition` goes on the scene being **entered** — here, on the second scene, not the first. A transition on a view's first scene has nothing to come from, and the engine says so and ignores it: -## Ne pas confondre avec `slide`, `chromatic_wipe` ni l'effet `motion_blur` +``` +Warning: the `transition` on view 0's first scene has no effect — a transition +belongs to the scene being entered, and the first scene has nothing to come from. +``` + +## Not to be confused with `slide`, `chromatic_wipe` or the `motion_blur` effect -- **`slide`** est le même déplacement, sec, sans traînée : `whip` avec `strength: 0` lui est byte-identique à chaque instant de la transition. -- **`chromatic_wipe`** voyage sur le même axe (`direction` prend les mêmes valeurs), mais son pic est une séparation **chromatique** (rouge/cyan) sur le bord de coupe, pas un filé spatial de la frame entière. -- **`motion_blur`** (effet d'animation, `style.animation`) traîne la trajectoire d'un **composant individuel** en accumulant des échantillons de sa propre animation. Il ne voit rien dans une transition, qui ne dispose plus que de deux buffers RGBA déjà peints — c'est exactement le trou que `whip` bouche côté transition, comme `zoom_blur` l'a fait pour le zoom radial. Voir [rules/zoom-blur-transition.md](zoom-blur-transition.md). +- **`slide`** is the same displacement, dry, with no trail: `whip` with `strength: 0` is byte-identical to it at every instant of the transition. +- **`chromatic_wipe`** travels along the same axis (`direction` takes the same values), but its peak is a **chromatic** split (red/cyan) at the cut edge, not a spatial streak of the whole frame. +- **`motion_blur`** (an animation effect, `style.animation`) trails an **individual component**'s trajectory by accumulating samples of its own animation. It sees nothing inside a transition, which only has two already-painted RGBA buffers — exactly the gap `whip` fills on the transition side, as `zoom_blur` did for the radial zoom. See [rules/zoom-blur-transition.md](zoom-blur-transition.md). -## Champs +## Fields -| Champ | Rôle | Défaut | +| Field | Role | Default | |---|---|---| -| `direction` | Axe de déplacement des deux frames, mêmes valeurs que `slide`/`chromatic_wipe` (`left`/`right`/`up`/`down`). | `left` | -| `strength` | Portée de la traînée. `0` supprime la passe de flou et laisse un `slide` sec — pas de traînée à aucun instant, même à mi-transition. Les valeurs plus grandes tirent les copies plus loin derrière leur position courante. | `1.0` | -| `duration`, `easing` | Communs à toutes les transitions. | `0.5`, `ease_in_out` | +| `direction` | Travel axis for both frames, same values as `slide`/`chromatic_wipe` (`left`/`right`/`up`/`down`). | `left` | +| `strength` | Reach of the trail. `0` removes the blur pass and leaves a dry `slide` — no trail at any instant, not even mid-transition. Larger values pull the copies further behind their current position. | `1.0` | +| `duration`, `easing` | Common to every transition. | `0.5`, `ease_in_out` | + +## Zero at both ends, by construction + +Like `zoom_blur` and `chromatic_wipe`, the intensity follows `peak = 1 - |2p - 1|`: zero at `progress = 0` and at `progress = 1`, whatever `strength` is. The engine does not let that curve merely tend to zero: at `reach <= 0.0` (so `peak == 0`, at both bounds) it returns the crisp slide directly, never building the trail pass at all — a short circuit, not a floating-point fade that could leave a rounding residue. `progress = 0` renders exactly the source frame, `progress = 1` exactly the destination frame: nothing bleeds into the next scene. -## Zéro aux deux bouts, par construction +`strength: 0` takes the same short circuit at **every** instant of the transition, not only at the edges: the transition then degenerates into a dry `slide`, never laying down the trail pass. -Comme `zoom_blur` et `chromatic_wipe`, l'intensité suit `peak = 1 - |2p - 1|` : nulle à `progress = 0` et à `progress = 1`, quelle que soit `strength`. Le moteur ne laisse pas cette courbe tendre vers zéro : à `reach <= 0.0` (donc `peak == 0`, aux deux bornes), il retourne directement le slide net, sans jamais construire la passe de traînée — un court-circuit, pas une atténuation flottante qui pourrait laisser un résidu d'arrondi. `progress = 0` rend exactement la frame source, `progress = 1` exactement la frame de destination : rien ne bave sur la scène suivante. +## How it is built -`strength: 0` prend le même court-circuit à **tout instant** de la transition, pas seulement aux bords : la transition dégénère alors en un `slide` sec, sans jamais poser la passe de traînée. +The crisp base (`sharp`) is the same computation as `chromatic_wipe`'s internal slide — factored into `directional_slide` and shared by both transitions: two fully opaque frames, tiled side by side along `direction`, with no alpha mixing at all. When `strength` and the position in the transition call for it, a fine series of translated copies of **each** frame (120 of them, close enough together that no visible band survives) — the outgoing one AND the incoming one, unlike `zoom_blur`, which only trails the outgoing frame — are redrawn further and further behind their current position, at an opacity decreasing with distance. It is the same weighted sum of copies as `zoom_blur`, translated along an axis instead of scaled radially around an `origin`. -## Comment c'est construit +The step count is fixed rather than scaled to `reach`, and it costs: 120 steps means 240 `draw_image` calls per transition frame, measured at about 10 ms per frame at 1920×1080. Only transition frames pay it, so a 0.6 s whip adds roughly 0.2 s to a render. -Le socle net (`sharp`) est le même calcul que le `slide` interne de `chromatic_wipe` — factorisé dans `directional_slide` et partagé par les deux transitions : les deux frames pleinement opaques, carrelées côte à côte le long de `direction`, sans aucun mélange alpha. Quand `strength` et la position dans la transition l'exigent, une dizaine de copies translatées de **chaque** frame — la sortante ET l'entrante, contrairement à `zoom_blur` qui ne traîne que la frame sortante — sont redessinées de plus en plus loin derrière leur position courante, à une opacité qui décroît avec la distance. C'est la même somme de copies pondérées que `zoom_blur`, translatée le long d'un axe au lieu d'être mise à l'échelle radialement autour d'un `origin`. +> A truly reach-invariant result would need the trail computed as a real weight *integral* (additive accumulation normalised by sample density) rather than N sequential "over" composites, which saturate non-linearly. The fixed step count is what keeps two different `strength` values comparable: making the density follow `reach` gives them different sampling and breaks the "a bigger strength streaks further" property. -## Piège : une `strength` élevée fait déborder le fantôme sur l'autre scène +## Trap: a high `strength` bleeds the ghost onto the other scene -Les copies translatées sont dessinées sur tout le cadre, pas seulement dans le territoire qui appartient encore à leur propre frame. Une `strength` très élevée fait donc bleeder un fantôme semi-transparent de la scène sortante dans la zone déjà occupée par l'entrante, et réciproquement — c'est voulu, c'est précisément ce qui donne l'impression d'un filé de mouvement qui traverse la coupe plutôt que deux images qui glissent l'une sur l'autre. Si l'effet paraît trop étalé, baisser `strength` plutôt que `duration` : raccourcir la durée ne change rien au pic de `peak`, seulement la vitesse à laquelle la transition le traverse. +The translated copies are drawn over the whole frame, not only in the territory still owned by their own frame. A very high `strength` therefore bleeds a semi-transparent ghost of the outgoing scene into the area the incoming one already occupies, and vice versa — that is intended, and is precisely what reads as a motion streak crossing the cut rather than two images sliding over each other. If the effect looks too spread out, lower `strength` rather than `duration`: shortening the duration does not change `peak`'s maximum, only how fast the transition travels through it. diff --git a/crates/rustmotion/src/cli/commands/schema.rs b/crates/rustmotion/src/cli/commands/schema.rs index 89e814f..cd1511f 100644 --- a/crates/rustmotion/src/cli/commands/schema.rs +++ b/crates/rustmotion/src/cli/commands/schema.rs @@ -62,6 +62,8 @@ const SERDE_FIELD_ALIASES: &[(&str, &str, &[&str])] = &[ ("BorderRadius", "bottom-right", &["bottom_right"]), ("BorderRadius", "bottom-left", &["bottom_left"]), ("GradientTextStop", "position", &["offset"]), + ("FilterFn", "radius-x", &["radius_x"]), + ("FilterFn", "radius-y", &["radius_y"]), ]; fn widen_properties_with(value: &mut serde_json::Value, canonical: &str, aliases: &[&str]) { @@ -270,7 +272,21 @@ mod serde_alias_exposure_tests { } } - fn aliases_declared_in_the_sources() -> Vec<(String, String)> { + struct DeclaredAlias { + file: String, + alias: String, + renamed_sibling: Option, + } + + fn quoted_value_after(attribute: &str, key: &str) -> Option { + let needle = format!("{key} = \""); + let at = attribute.find(&needle)?; + let rest = &attribute[at + needle.len()..]; + let close = rest.find('"')?; + Some(rest[..close].to_string()) + } + + fn aliases_declared_in_the_sources() -> Vec { let mut files = Vec::new(); rust_sources(&workspace_root().join("crates"), &mut files); let mut found = Vec::new(); @@ -283,11 +299,16 @@ mod serde_alias_exposure_tests { if !trimmed.starts_with("#[serde(") || !trimmed.contains("alias = \"") { continue; } + let renamed_sibling = quoted_value_after(trimmed, "rename"); let mut rest = trimmed; while let Some(at) = rest.find("alias = \"") { rest = &rest[at + "alias = \"".len()..]; let Some(close) = rest.find('"') else { break }; - found.push((file.display().to_string(), rest[..close].to_string())); + found.push(DeclaredAlias { + file: file.display().to_string(), + alias: rest[..close].to_string(), + renamed_sibling: renamed_sibling.clone(), + }); rest = &rest[close..]; } } @@ -295,6 +316,28 @@ mod serde_alias_exposure_tests { found } + fn some_properties_object_carries_both( + value: &serde_json::Value, + canonical: &str, + alias: &str, + ) -> bool { + match value { + serde_json::Value::Object(map) => { + if let Some(serde_json::Value::Object(properties)) = map.get("properties") { + if properties.contains_key(canonical) && properties.contains_key(alias) { + return true; + } + } + map.values() + .any(|child| some_properties_object_carries_both(child, canonical, alias)) + } + serde_json::Value::Array(items) => items + .iter() + .any(|item| some_properties_object_carries_both(item, canonical, alias)), + _ => false, + } + } + #[test] fn every_serde_alias_in_the_sources_is_reachable_from_the_exported_schema() { let declared = aliases_declared_in_the_sources(); @@ -304,11 +347,23 @@ mod serde_alias_exposure_tests { declared.len() ); - let schema = serde_json::to_string(&build_schema()).expect("schema serializes"); + let schema = build_schema(); + let flat = serde_json::to_string(&schema).expect("schema serializes"); let missing: Vec = declared .iter() - .filter(|(_, alias)| !schema.contains(&format!("\"{alias}\""))) - .map(|(file, alias)| format!("{alias} (declared in {file})")) + .filter(|d| match &d.renamed_sibling { + Some(canonical) => { + !some_properties_object_carries_both(&schema, canonical, &d.alias) + } + None => !flat.contains(&format!("\"{}\"", d.alias)), + }) + .map(|d| match &d.renamed_sibling { + Some(canonical) => format!( + "{} (declared in {}, expected beside {canonical})", + d.alias, d.file + ), + None => format!("{} (declared in {})", d.alias, d.file), + }) .collect(); assert!( diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index c4fb3fe..058ddae 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -113,6 +113,7 @@ static IDENTITY_CAMERA: Camera = Camera { rotate_x: 0.0, rotate_y: 0.0, perspective: 0.0, + motion_blur: None, }; fn effective_camera(scene: &Scene) -> Option<&Camera> { @@ -236,10 +237,130 @@ pub fn render_frame_v2_scaled( root_children: &[ChildComponent], scale_factor: f32, prev_bg: Option<(&crate::schema::ResolvedBackground, f64)>, +) -> Result> { + if let Some(plan) = camera_motion_blur_plan(scene, frame_index, config.fps) { + return render_frame_v2_scaled_camera_blurred( + config, + scene, + frame_index, + scenario_time, + root_children, + scale_factor, + prev_bg, + plan, + ); + } + let scene_time = SceneTime::for_frame(scene, frame_index, config.fps); + render_frame_v2_scaled_core( + config, + scene, + scene_time, + frame_index, + scenario_time, + root_children, + scale_factor, + prev_bg, + ) +} + +struct CameraMotionBlurPlan { + samples: u32, + shutter_seconds: f64, +} + +fn camera_motion_blur_plan( + scene: &Scene, + frame_index: u32, + fps: u32, +) -> Option { + let camera = effective_camera(scene)?; + let cfg = camera.motion_blur.as_ref()?; + let nominal = frame_index as f64 / fps.max(1) as f64; + let shutter_seconds = (cfg.shutter / fps.max(1) as f64).max(0.0); + if shutter_seconds <= 0.0 { + return None; + } + let pose_start = camera_pose_at(scene, camera, (nominal - shutter_seconds) as f32); + let pose_end = camera_pose_at(scene, camera, nominal as f32); + let moved = (pose_start.0 - pose_end.0).abs() > 0.01 + || (pose_start.1 - pose_end.1).abs() > 0.01 + || (pose_start.2 - pose_end.2).abs() > 0.0005 + || (pose_start.3 - pose_end.3).abs() > 0.01; + if !moved { + return None; + } + Some(CameraMotionBlurPlan { + samples: cfg.samples.clamp(1, 16), + shutter_seconds, + }) +} + +fn camera_pose_at(scene: &Scene, camera: &Camera, time: f32) -> (f32, f32, f32, f32) { + let shake = scene_shake_offset(scene, time); + let x = interpolate_camera_property(camera, "x", time) + shake.x as f32; + let y = interpolate_camera_property(camera, "y", time) + shake.y as f32; + let zoom = interpolate_camera_property(camera, "zoom", time); + let rotation = interpolate_camera_property(camera, "rotation", time) + shake.rotation as f32; + (x, y, zoom, rotation) +} + +#[allow(clippy::too_many_arguments)] +fn render_frame_v2_scaled_camera_blurred( + config: &VideoConfig, + scene: &Scene, + frame_index: u32, + scenario_time: f64, + root_children: &[ChildComponent], + scale_factor: f32, + prev_bg: Option<(&crate::schema::ResolvedBackground, f64)>, + plan: CameraMotionBlurPlan, +) -> Result> { + let nominal = frame_index as f64 / config.fps.max(1) as f64; + let samples = plan.samples.max(1); + let mut accum: Option> = None; + for i in 0..samples { + let offset = (i as f64 + 0.5) * plan.shutter_seconds / samples as f64; + let sample_time = nominal - offset; + let scene_time = SceneTime::for_local_time(scene, sample_time); + let pixels = render_frame_v2_scaled_core( + config, + scene, + scene_time, + frame_index, + scenario_time, + root_children, + scale_factor, + prev_bg, + )?; + match accum.as_mut() { + Some(sum) => { + for (s, p) in sum.iter_mut().zip(pixels.iter()) { + *s += *p as u32; + } + } + None => accum = Some(pixels.iter().map(|&p| p as u32).collect()), + } + } + let sum = accum.ok_or(RustmotionError::SurfaceCreation)?; + Ok(sum + .into_iter() + .map(|s| ((s + samples / 2) / samples) as u8) + .collect()) +} + +#[allow(clippy::too_many_arguments)] +fn render_frame_v2_scaled_core( + config: &VideoConfig, + scene: &Scene, + scene_time: SceneTime, + frame_index: u32, + scenario_time: f64, + root_children: &[ChildComponent], + scale_factor: f32, + prev_bg: Option<(&crate::schema::ResolvedBackground, f64)>, ) -> Result> { let scaled_w = (config.width as f32 * scale_factor) as i32; let scaled_h = (config.height as f32 * scale_factor) as i32; - let scene_time = SceneTime::for_frame(scene, frame_index, config.fps); let time = scene_time.seconds(); let info = ImageInfo::new( @@ -613,7 +734,8 @@ fn render_with_new_pipeline_iter<'a, I>( } }; - let layout = run_layout(&built.root, viewport, &conversion); + let mut layout = run_layout(&built.root, viewport, &conversion); + apply_ghost_layout_fixup(&built, &mut layout); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { light: scene_light(scene), @@ -754,6 +876,50 @@ pub fn resolve_node_references( Ok(frame) } +fn apply_ghost_layout_fixup( + built: &rustmotion_components::box_builder::BuiltScene<'_>, + layout: &mut rustmotion_core::engine::layout_pass::LayoutResult, +) { + use rustmotion_core::engine::box_tree::NodeId; + + if built.ghost_principal.is_empty() { + return; + } + let deltas: Vec<(NodeId, f32, f32)> = built + .ghost_principal + .iter() + .filter_map(|&(ghost_id, principal_id)| { + let ghost_box = layout.get(ghost_id)?; + let principal_box = layout.get(principal_id)?; + let dx = principal_box.x - ghost_box.x; + let dy = principal_box.y - ghost_box.y; + (dx != 0.0 || dy != 0.0).then_some((ghost_id, dx, dy)) + }) + .collect(); + + for (ghost_id, dx, dy) in deltas { + let Some(ghost_node) = built.root.find(ghost_id) else { + continue; + }; + shift_subtree_layout(ghost_node, dx, dy, layout); + } +} + +fn shift_subtree_layout( + node: &rustmotion_core::engine::box_tree::BoxNode, + dx: f32, + dy: f32, + layout: &mut rustmotion_core::engine::layout_pass::LayoutResult, +) { + if let Some(box_layout) = layout.layouts.get_mut(&node.id) { + box_layout.x += dx; + box_layout.y += dy; + } + for child in &node.children { + shift_subtree_layout(child, dx, dy, layout); + } +} + struct ResolvingTextMetrics<'a> { components: &'a [Option<&'a rustmotion_components::ChildComponent>], } @@ -886,11 +1052,12 @@ pub fn render_scene_hits( fps: config.fps, }); let built = build_scene_from_refs(children.iter(), (vw, vh), root_css, anim); - let layout = run_layout( + let mut layout = run_layout( &built.root, (vw, vh), &ConversionContext::for_viewport(vw, vh), ); + apply_ghost_layout_fixup(&built, &mut layout); let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { light: scene_light(scene), @@ -1468,11 +1635,7 @@ pub(super) fn apply_camera_transform( width: f32, height: f32, ) { - let shake = scene_shake_offset(scene, time); - let x = interpolate_camera_property(camera, "x", time) + shake.x as f32; - let y = interpolate_camera_property(camera, "y", time) + shake.y as f32; - let zoom = interpolate_camera_property(camera, "zoom", time); - let rotation = interpolate_camera_property(camera, "rotation", time) + shake.rotation as f32; + let (x, y, zoom, rotation) = camera_pose_at(scene, camera, time); let (cx, cy) = resolve_camera_origin(camera, time, width, height); canvas.save(); @@ -1653,3 +1816,217 @@ mod halo_transition_wiring_tests { ); } } + +#[cfg(test)] +mod camera_motion_blur_tests { + use super::camera_motion_blur_plan; + use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask}; + + const W: usize = 400; + const H: usize = 200; + + fn panning_camera_scenario(motion_blur_json: &str) -> crate::schema::ResolvedScenario { + let json = format!( + r##"{{ + "version": "1.0", + "video": {{ "width": {W}, "height": {H}, "fps": 30, "background": "#000000" }}, + "scenes": [{{ + "duration": 1.0, + "camera": {{ + "keyframes": [{{ "property": "x", "easing": "linear", "values": [ + {{ "time": 0.0, "value": 0.0 }}, {{ "time": 0.45, "value": 0.0 }}, + {{ "time": 0.55, "value": 60.0 }}, {{ "time": 1.0, "value": 60.0 }} + ] }}]{motion_blur_json} + }}, + "children": [ + {{ "type": "shape", "shape": "rect", "fill": "#FFFFFF", + "position": "absolute", "x": 200, "y": 0, + "style": {{ "width": 400, "height": 200 }} }} + ] + }}] + }}"## + ); + crate::loader::load_scenario_from_source(None, Some(&json)).expect("load") + } + + fn frame_15(scenario: &crate::schema::ResolvedScenario) -> Vec { + let tasks = build_frame_tasks(scenario); + let task = tasks + .iter() + .find(|task| { + matches!( + task, + FrameTask::Normal { + scene_idx: 0, + frame_in_scene: 15, + .. + } + ) + }) + .expect("a frame task for scene 0 frame 15 must exist"); + render_frame_task(&scenario.video, scenario, task).expect("render") + } + + fn rgb_at(frame: &[u8], x: usize, y: usize) -> (u8, u8, u8) { + let i = (y * W + x) * 4; + (frame[i], frame[i + 1], frame[i + 2]) + } + + #[test] + fn a_fast_pan_with_motion_blur_softens_the_edge_a_crisp_render_would_keep_hard() { + let scenario = + panning_camera_scenario(r##", "motion_blur": { "samples": 8, "shutter": 1.0 }"##); + let frame = frame_15(&scenario); + + let (r, g, b) = rgb_at(&frame, 180, 100); + assert!( + (60..=200).contains(&r) && (60..=200).contains(&g) && (60..=200).contains(&b), + "the camera pans 60px in the 0.1s straddling this frame, so the rectangle's left \ + edge (nominally at screen x=170 at t=0.5) sweeps across x=180 for exactly half of \ + the 8 sub-frame samples this shutter window covers — averaging them must leave an \ + intermediate grey, not the hard black/white a single-sample render would give; \ + got rgb=({r},{g},{b})" + ); + + let (wr, wg, wb) = rgb_at(&frame, 350, 100); + assert!( + wr > 240 && wg > 240 && wb > 240, + "well inside the rectangle for every sample in the window, the pixel must still \ + read as solid white; got rgb=({wr},{wg},{wb})" + ); + + let (br, bg, bb) = rgb_at(&frame, 30, 100); + assert!( + br < 15 && bg < 15 && bb < 15, + "well outside the rectangle for every sample in the window, the pixel must still \ + read as solid background; got rgb=({br},{bg},{bb})" + ); + } + + #[test] + fn no_motion_blur_config_keeps_the_same_pan_perfectly_crisp() { + let scenario = panning_camera_scenario(""); + let frame = frame_15(&scenario); + + let (r, g, b) = rgb_at(&frame, 180, 100); + assert!( + r > 240 && g > 240 && b > 240, + "without camera.motion_blur, x=180 at t=0.5 is strictly past the single-sample \ + edge (screen x=170) and must read as solid white, not the blurred grey the other \ + test expects at the same coordinate; got rgb=({r},{g},{b})" + ); + } + + #[test] + fn a_camera_that_is_not_moving_is_not_planned_for_sub_frame_sampling() { + let scenario = crate::loader::load_scenario_from_source( + None, + Some(&format!( + r##"{{ + "version": "1.0", + "video": {{ "width": {W}, "height": {H}, "fps": 30, "background": "#000000" }}, + "scenes": [{{ + "duration": 1.0, + "camera": {{ "zoom": 1.5, + "motion_blur": {{ "samples": 8, "shutter": 1.0 }} }}, + "children": [] + }}] + }}"## + )), + ) + .expect("load"); + let scene = &scenario.views[0].scenes[0]; + + assert!( + camera_motion_blur_plan(scene, 15, 30).is_none(), + "a camera held at a fixed pose must not be scheduled for sub-frame sampling — \ + there is nothing to average, only cost to pay" + ); + } + + #[test] + fn a_panning_camera_is_planned_for_sub_frame_sampling() { + let scenario = + panning_camera_scenario(r##", "motion_blur": { "samples": 8, "shutter": 1.0 }"##); + let scene = &scenario.views[0].scenes[0]; + + let plan = camera_motion_blur_plan(scene, 15, 30) + .expect("a moving camera with motion_blur configured must be planned"); + assert_eq!(plan.samples, 8); + assert!((plan.shutter_seconds - 1.0 / 30.0).abs() < 1e-9); + } +} + +#[cfg(test)] +mod ghost_in_flow_placement_tests { + use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask}; + + const W: usize = 400; + const H: usize = 200; + + fn row_scenario_with_motion_blur_on_second_child() -> crate::schema::ResolvedScenario { + let json = format!( + r##"{{ + "version": "1.0", + "video": {{ "width": {W}, "height": {H}, "fps": 30, "background": "#000000" }}, + "scenes": [{{ + "duration": 1.0, + "children": [{{ + "type": "div", + "style": {{ "display": "flex", "flex-direction": "row", + "width": {W}, "height": {H} }}, + "children": [ + {{ "type": "shape", "shape": "rect", "fill": "#FF0000", + "style": {{ "width": 100, "height": 100 }} }}, + {{ "type": "shape", "shape": "rect", "fill": "#0000FF", + "style": {{ "width": 100, "height": 100, + "animation": [ + {{ "name": "motion_blur", "samples": 8, "shutter": 1.0 }} + ] }} }} + ] + }}] + }}] + }}"## + ); + crate::loader::load_scenario_from_source(None, Some(&json)).expect("load") + } + + fn first_frame(scenario: &crate::schema::ResolvedScenario) -> Vec { + let tasks = build_frame_tasks(scenario); + let task = tasks + .iter() + .find(|task| { + matches!( + task, + FrameTask::Normal { + scene_idx: 0, + frame_in_scene: 0, + .. + } + ) + }) + .expect("a frame task for scene 0 frame 0 must exist"); + render_frame_task(&scenario.video, scenario, task).expect("render") + } + + #[test] + fn a_motion_blurred_flex_sibling_does_not_ghost_onto_the_previous_in_flow_item() { + let scenario = row_scenario_with_motion_blur_on_second_child(); + let frame = first_frame(&scenario); + + let i = (50 * W + 50) * 4; + let (r, g, b) = (frame[i] as i32, frame[i + 1] as i32, frame[i + 2] as i32); + assert!( + b < 20, + "the first flex item's own box (pure red, at x=50,y=50) must not carry a blue \ + tint from its motion-blurred sibling's ghosts — an in-flow ghost with no inset \ + lands wherever the container's default alignment puts an absolute child with no \ + top/left, which for a row is the same spot as the first item; got rgb=({r},{g},{b})" + ); + assert!( + r > 200, + "the first flex item must still read as solid red where no ghost should ever \ + reach; got rgb=({r},{g},{b})" + ); + } +}