Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
736 changes: 648 additions & 88 deletions crates/rustmotion-components/src/box_builder.rs

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion crates/rustmotion-core/src/css/animation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ pub fn apply_animated_props(css: &mut CssStyle, props: &AnimatedProperties) {
let mut filters: Vec<FilterFn> = 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 {
Expand Down
65 changes: 65 additions & 0 deletions crates/rustmotion-core/src/css/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Length>,
#[serde(default, rename = "radius-x")]
radius_x: Option<Length>,
#[serde(default, rename = "radius-y")]
radius_y: Option<Length>,
},
/// 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 {
Expand Down Expand Up @@ -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),
}
}
}
14 changes: 14 additions & 0 deletions crates/rustmotion-core/src/engine/animator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
98 changes: 88 additions & 10 deletions crates/rustmotion-core/src/engine/paint_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -608,12 +610,39 @@ fn depth_of_field_image_filter(sigma: f32) -> Option<skia_safe::ImageFilter> {
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,
Expand Down Expand Up @@ -681,19 +710,38 @@ fn subtree_layout_bounds(node: &BoxNode, layout: &LayoutResult) -> Option<Rect>
fn filters_to_image_filter(
list: &[crate::css::style::FilterFn],
ctx: &LengthContext,
layout: &BoxLayout,
) -> Option<skia_safe::ImageFilter> {
use crate::css::style::FilterFn;
use skia_safe::image_filters;

let mut chain: Option<skia_safe::ImageFilter> = 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 {
Expand Down Expand Up @@ -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<skia_safe::ImageFilter>,
) -> Option<skia_safe::ImageFilter> {
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<skia_safe::ImageFilter> {
use skia_safe::image_filters;

Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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()
},
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
},
Expand Down
28 changes: 27 additions & 1 deletion crates/rustmotion-core/src/schema/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions crates/rustmotion/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<text>` 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
Expand Down
Loading
Loading