From afa493e051c51b163b8da1a86fea42dfd5c05f0f Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 15:50:47 +0200 Subject: [PATCH 1/3] fix(transition): stop the feather leaking, give the pill its corners, add clip-path blob Four items from #432. A feathered wipe built its mask from the exact reveal rect, whose three non-moving edges sit on the frame boundary. MaskFilter::blur softens a shape's own edge wherever it is, so all three bled feather and band tint instead of only the edge that moves. The feathered path now extends those three sides by feather * 6 + 16, comfortably past the Gaussian's reach at sigma = feather / 3, and leaves the moving edge exactly where it was. The hard-clip fast path for an unfeathered wipe is untouched. IRIS_PILL_CORNER_FRACTION was 0.2, so a pill was a barely rounded rectangle. At 1.0 the corner radius equals the smaller half-dimension, which is a true stadium -- and a circle at aspect 1.0, which iris-transition.md already documented. The doc was right and the constant was wrong. clip-path gains a blob kind. It reuses the generator the blob transition already has rather than growing a second one: blob_local_path and scaled_mask_path become pub(crate) and nothing else moves. The documented boundary holds -- mask_transition and blob_transition still do not depend on ClipPath, because a transition has no layout box to resolve a percentage against. A transition that ignores a field now says so. The applicability table is written from apply_transition's own match arms, and a field is reported only when it differs from its default, so an unset field is never flagged -- only an explicit override that does nothing. Refs #432 --- crates/rustmotion-core/src/css/style.rs | 88 ++++++ .../rustmotion-core/src/engine/paint_pass.rs | 80 +++++ .../rustmotion-core/src/engine/transition.rs | 128 +++++++- .../src/cli/commands/validate_schema.rs | 287 +++++++++++++++++- 4 files changed, 577 insertions(+), 6 deletions(-) diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index 151c63b..9963aa2 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -1596,6 +1596,18 @@ impl Material { } } +fn default_clip_path_blob_lobes() -> u32 { + 8 +} + +fn default_clip_path_blob_wobble() -> f32 { + 0.15 +} + +fn default_clip_path_blob_seed() -> u32 { + 11 +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum ClipPath { @@ -1625,6 +1637,30 @@ pub enum ClipPath { Path { d: String, }, + /// A procedurally generated organic silhouette — the same wobbling-lobe + /// shape a `blob` scene `transition` grows across a whole cut, scoped + /// here to one element's own clip instead. `lobes` control points sit + /// around a circle of `radius`, each nudged radially by a deterministic + /// noise scaled by `wobble` and picked by `seed`; the same three values + /// reproduce the same silhouette, byte for byte. `wobble: 0` degenerates + /// to a plain circle, the same way `radius` alone does for `circle`. + Blob { + radius: LengthPercentage, + #[serde(default)] + origin: Option, + /// How many lobes the silhouette grows. Same field as + /// `transition.lobes`. + #[serde(default = "default_clip_path_blob_lobes")] + lobes: u32, + /// How far each lobe's radius wanders from a perfect circle, as a + /// fraction of it. Same field as `transition.wobble`. + #[serde(default = "default_clip_path_blob_wobble")] + wobble: f32, + /// Stable selector for the lobe noise, same selector as + /// `pixel_dissolve.seed` and `transition.seed`. + #[serde(default = "default_clip_path_blob_seed")] + seed: u32, + }, /// Clip to another node's own path geometry, by id, instead of a literal /// `d` frozen at author time. The motivating shape: two copies of the /// same gem, each clipped by the same animated "crack" node, so the two @@ -1724,6 +1760,58 @@ mod tests { assert!(matches!(s2.color, Some(Color::Rgba { r: 255, .. }))); } + #[test] + fn deserialize_clip_path_blob_falls_back_to_the_transition_defaults() { + let json = r#"{ "clip-path": { "kind": "blob", "radius": 120 } }"#; + let s: CssStyle = serde_json::from_str(json).unwrap(); + match s.clip_path { + Some(ClipPath::Blob { + radius, + origin, + lobes, + wobble, + seed, + }) => { + assert_eq!(radius, LengthPercentage::Px(120.0)); + assert!(origin.is_none()); + assert_eq!( + lobes, 8, + "must default to the same lobes as transition: blob" + ); + assert_eq!( + wobble, 0.15, + "must default to the same wobble as transition: blob" + ); + assert_eq!( + seed, 11, + "must default to the same seed as transition: blob" + ); + } + other => panic!("expected ClipPath::Blob, got {other:?}"), + } + } + + #[test] + fn deserialize_clip_path_blob_with_explicit_lobes_wobble_seed() { + let json = r#"{ + "clip-path": { "kind": "blob", "radius": 90, "lobes": 5, "wobble": 0.4, "seed": 3 } + }"#; + let s: CssStyle = serde_json::from_str(json).unwrap(); + match s.clip_path { + Some(ClipPath::Blob { + lobes, + wobble, + seed, + .. + }) => { + assert_eq!(lobes, 5); + assert_eq!(wobble, 0.4); + assert_eq!(seed, 3); + } + other => panic!("expected ClipPath::Blob, got {other:?}"), + } + } + #[test] fn deserialize_transform_list() { let json = r#"{ "transform": [ diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 879a6af..3918f73 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -17,6 +17,7 @@ use crate::css::units::{ use crate::engine::animator::{burst_progress, burst_stroke_span}; use crate::engine::box_tree::{BoxKind, BoxNode, NodeId}; use crate::engine::layout_pass::{BoxLayout, LayoutResult}; +use crate::engine::transition::{blob_local_path, scaled_mask_path}; use crate::schema::{AnimationEffect as SchemaAnimationEffect, BurstConfig}; #[derive(Debug, Clone, Copy)] @@ -2514,6 +2515,27 @@ fn clip_path_to_skia( Some(parsed.with_offset((layout.x, layout.y))) } + ClipPath::Blob { + radius, + origin, + lobes, + wobble, + seed, + } => { + let (cx, cy, _) = resolve_origin(origin.as_ref(), layout, ctx); + let reference = LengthContext { + parent_size: (layout.width.powi(2) + layout.height.powi(2)).sqrt() + / std::f32::consts::SQRT_2, + ..*ctx + }; + let r = radius.resolve(&reference); + if r <= 0.0 { + return Some(PathBuilder::new().detach()); + } + let local = blob_local_path(*lobes, *wobble, *seed); + Some(scaled_mask_path(&local, r, (cx, cy))) + } + ClipPath::NodePath { id } => { eprintln!( "rustmotion: clip-path {{ kind: node-path, id: \"{id}\" }} is not implemented \ @@ -2544,6 +2566,7 @@ fn clip_path_kind_name(clip: &ClipPath) -> &'static str { ClipPath::Ellipse { .. } => "ellipse", ClipPath::Polygon { .. } => "polygon", ClipPath::Path { .. } => "path", + ClipPath::Blob { .. } => "blob", ClipPath::NodePath { .. } => "node-path", ClipPath::Morph { .. } => "morph", } @@ -4207,6 +4230,63 @@ mod paint_order_tests { ); } + #[test] + fn clip_path_blob_keeps_the_centre_and_drops_the_far_corner() { + let blob = ClipPath::Blob { + radius: CLP::Px(150.0), + origin: None, + lobes: 8, + wobble: 0.15, + seed: 11, + }; + assert!( + is_red_at(Some(blob.clone()), 200, 200), + "the centre is inside a centred organic blob of radius 150" + ); + assert!( + !is_red_at(Some(blob), 20, 20), + "the far corner, well beyond the blob's radius, is outside it" + ); + } + + #[test] + fn clip_path_blob_reuses_the_wobbling_lobe_generator_instead_of_drawing_a_plain_circle() { + let radius = 150.0; + let circle = ClipPath::Circle { + radius: CLP::Px(radius), + origin: None, + }; + let blob = ClipPath::Blob { + radius: CLP::Px(radius), + origin: None, + lobes: 8, + wobble: 0.45, + seed: 11, + }; + let mut saw_a_difference = false; + for deg in (0..360).step_by(3) { + let rad = (deg as f32).to_radians(); + let probe_r = radius * 0.98; + let x = 200.0 + probe_r * rad.cos(); + let y = 200.0 + probe_r * rad.sin(); + if !(0.0..400.0).contains(&x) || !(0.0..400.0).contains(&y) { + continue; + } + let (xi, yi) = (x.round() as u32, y.round() as u32); + if is_red_at(Some(circle.clone()), xi, yi) != is_red_at(Some(blob.clone()), xi, yi) { + saw_a_difference = true; + break; + } + } + assert!( + saw_a_difference, + "a wobble of 0.45 must make the blob's edge diverge from a perfect circle \ + somewhere around a ring just inside the nominal radius — if every sample agrees \ + with the circle, clip-path blob is not reusing the transition's wobbling-lobe \ + generator, just drawing a circle under a different name" + ); + } + #[test] fn clip_path_none_paints_exactly_as_no_clip_path_at_all() { for (x, y) in [(20, 20), (200, 200), (380, 380)] { diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 79e5bd0..1103e2f 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -417,6 +417,29 @@ fn wipe_reveal_rect(direction: &Direction, w: f32, h: f32, progress: f32) -> Rec } } +const WIPE_FIXED_EDGE_MARGIN_FEATHER_FACTOR: f32 = 6.0; +const WIPE_FIXED_EDGE_MARGIN_FLOOR: f32 = 16.0; + +fn wipe_fixed_edge_margin(feather: f32) -> f32 { + feather.max(0.0) * WIPE_FIXED_EDGE_MARGIN_FEATHER_FACTOR + WIPE_FIXED_EDGE_MARGIN_FLOOR +} + +fn wipe_feathered_reveal_rect( + direction: &Direction, + w: f32, + h: f32, + progress: f32, + feather: f32, +) -> Rect { + let margin = wipe_fixed_edge_margin(feather); + match direction { + Direction::Left => Rect::from_ltrb(-margin, -margin, w * progress, h + margin), + Direction::Right => Rect::from_ltrb(w * (1.0 - progress), -margin, w + margin, h + margin), + Direction::Up => Rect::from_ltrb(-margin, -margin, w + margin, h * progress), + Direction::Down => Rect::from_ltrb(-margin, h * (1.0 - progress), w + margin, h + margin), + } +} + fn wipe( frame_a: &[u8], frame_b: &[u8], @@ -461,8 +484,9 @@ fn wipe( return surface_to_pixels(surface, width, height); } + let feathered_reveal = wipe_feathered_reveal_rect(&direction, w, h, progress, feather); let mut builder = PathBuilder::new(); - builder.add_rect(reveal, None, None); + builder.add_rect(feathered_reveal, None, None); let path = builder.detach(); composite_through_mask( frame_a, @@ -652,7 +676,7 @@ fn clock_wipe(frame_a: &[u8], frame_b: &[u8], width: u32, height: u32, progress: } const IRIS_PILL_OVERSHOOT: f32 = 1.45; -const IRIS_PILL_CORNER_FRACTION: f32 = 0.2; +const IRIS_PILL_CORNER_FRACTION: f32 = 1.0; fn iris_max_radius( origin: (f32, f32), @@ -845,7 +869,11 @@ fn mask_shape_to_local_path(shape: &MaskShape) -> Option { } } -fn scaled_mask_path(local: &skia_safe::Path, scale: f32, origin: (f32, f32)) -> skia_safe::Path { +pub(crate) fn scaled_mask_path( + local: &skia_safe::Path, + scale: f32, + origin: (f32, f32), +) -> skia_safe::Path { let bounds = *local.bounds(); let cx = (bounds.left + bounds.right) / 2.0; let cy = (bounds.top + bounds.bottom) / 2.0; @@ -1010,7 +1038,7 @@ fn mask_transition( ) } -fn blob_local_path(lobes: u32, wobble: f32, seed: u32) -> skia_safe::Path { +pub(crate) fn blob_local_path(lobes: u32, wobble: f32, seed: u32) -> skia_safe::Path { let n = lobes.max(3); let wobble = wobble.clamp(0.0, 0.95); let points: Vec<(f32, f32)> = (0..n) @@ -1782,3 +1810,95 @@ mod pixel_dissolve_tests { } } } + +#[cfg(test)] +mod wipe_fixed_edge_tests { + use super::*; + + const W: u32 = 1280; + const H: u32 = 720; + + fn frames() -> (Vec, Vec) { + let a: Vec = (0..W * H).flat_map(|_| [200u8, 200, 200, 255]).collect(); + let b: Vec = (0..W * H).flat_map(|_| [40u8, 40, 40, 255]).collect(); + (a, b) + } + + fn pixel(buf: &[u8], x: u32, y: u32) -> [u8; 4] { + let base = ((y * W + x) * 4) as usize; + [buf[base], buf[base + 1], buf[base + 2], buf[base + 3]] + } + + #[test] + fn a_wide_feather_and_band_color_do_not_leak_onto_the_three_fixed_edges_of_a_left_wipe() { + let (a, b) = frames(); + let opts = TransitionOptions { + feather: 320.0, + band_color: Some("#FF8CC6".to_string()), + ..TransitionOptions::default() + }; + let out = apply_transition(&a, &b, W, H, 0.8, &TransitionType::WipeLeft, &opts); + + assert_eq!( + pixel(&out, 640, 3), + [40, 40, 40, 255], + "the top edge never moves for a left wipe — a pixel well inside the revealed area \ + but close to y=0 must be the exact incoming colour, not softened or tinted pink \ + just because it happens to sit near the frame's own fixed top border" + ); + assert_eq!( + pixel(&out, 640, H - 4), + [40, 40, 40, 255], + "the bottom edge never moves for a left wipe either — same leak, opposite border" + ); + assert_eq!( + pixel(&out, 2, 360), + [40, 40, 40, 255], + "the left edge is the wipe's own starting edge, always flush with the frame's left \ + border — it must never show a blended or tinted pixel, only the moving right edge \ + may" + ); + } + + #[test] + fn the_moving_edge_still_feathers_normally_once_the_fixed_edges_are_excluded() { + let (a, b) = frames(); + let opts = TransitionOptions { + feather: 320.0, + ..TransitionOptions::default() + }; + let out = apply_transition(&a, &b, W, H, 0.8, &TransitionType::WipeLeft, &opts); + let moving_edge_x = (W as f32 * 0.8) as u32; + let blended = pixel(&out, moving_edge_x, 360); + assert!( + blended[0] != 200 && blended[0] != 40, + "the wipe's own moving reveal boundary must still show a blended pixel from a wide \ + feather, got {blended:?} — the fixed-edge fix must not have flattened the real \ + moving edge along with the fake ones" + ); + } +} + +#[cfg(test)] +mod iris_pill_shape_tests { + use super::*; + + #[test] + fn a_pill_with_aspect_one_degenerates_into_a_true_circle_not_a_barely_rounded_square() { + let origin = (100.0, 100.0); + let radius = 50.0; + let path = iris_mask_path(origin, IrisShape::Pill, 1.0, radius); + let beyond_the_radius_on_the_diagonal = radius * 1.2 / std::f32::consts::SQRT_2; + let corner_point = ( + origin.0 + beyond_the_radius_on_the_diagonal, + origin.1 + beyond_the_radius_on_the_diagonal, + ); + assert!( + !path.contains(corner_point), + "a pill with aspect 1.0 must degenerate into a circle — a point just beyond the \ + radius on the diagonal must fall outside it; the old 0.2 corner fraction left a \ + barely-rounded square whose corner reached out to radius*sqrt(2), well past this \ + point" + ); + } +} diff --git a/crates/rustmotion/src/cli/commands/validate_schema.rs b/crates/rustmotion/src/cli/commands/validate_schema.rs index 75399da..a695d95 100644 --- a/crates/rustmotion/src/cli/commands/validate_schema.rs +++ b/crates/rustmotion/src/cli/commands/validate_schema.rs @@ -4,8 +4,9 @@ use rustmotion::core::css::style::{ }; use rustmotion::engine::animator::{motion_path_length, MOTION_PATH_MIN_LENGTH}; use rustmotion::schema::{ - AnimationEffect, CharAnimationTiming, MotionPathConfig, ResolvedScenario, SceneStart, - SpringConfig, TimeError, + AnimationEffect, CharAnimationTiming, IrisShape, MotionPathConfig, PanBackground, + PixelDissolveOrder, ResolvedScenario, SceneStart, SpringConfig, TimeError, Transition, + TransitionCorner, TransitionDirection, TransitionType, }; pub fn validate_scenario(scenario: &ResolvedScenario) -> (Vec, Vec) { @@ -28,6 +29,10 @@ pub fn validate_scenario(scenario: &ResolvedScenario) -> (Vec, Vec 0", vi, si)); @@ -44,6 +49,14 @@ pub fn validate_scenario(scenario: &ResolvedScenario) -> (Vec, Vec &'static [&'static str] { + use TransitionType::*; + match transition_type { + Fade => &[], + WipeLeft | WipeRight | WipeUp | WipeDown => &["feather", "band_color"], + ZoomIn | ZoomOut => &[], + Flip => &[], + ClockWipe => &[], + Iris => &[ + "origin", "shape", "aspect", "fill", "hold", "ring", "reverse", + ], + Slide => &[], + Dissolve => &[], + CornerReveal => &["corner"], + PixelDissolve => &["cell", "seed", "order"], + CameraPan => &["background"], + ChromaticWipe => &["direction", "aberration"], + ZoomBlur => &["strength", "origin"], + Whip => &["strength", "direction"], + Mask => &[ + "silhouette", + "origin", + "from_scale", + "to_scale", + "feather", + "band_color", + ], + Blob => &["origin", "lobes", "wobble", "seed", "feather", "band_color"], + TransitionType::None => &[], + } +} + +fn transition_type_json_name(transition_type: &TransitionType) -> String { + serde_json::to_value(transition_type) + .ok() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_else(|| "?".to_string()) +} + +fn check_transition_ignored_fields( + transition: &Transition, + path: &str, + warnings: &mut Vec, +) { + let used = transition_used_field_names(&transition.transition_type); + let is_used = |name: &str| used.contains(&name); + + let mut ignored: Vec<&str> = Vec::new(); + if !is_used("corner") && transition.corner != TransitionCorner::default() { + ignored.push("corner"); + } + if !is_used("cell") && transition.cell != TRANSITION_DEFAULT_CELL { + ignored.push("cell"); + } + if !is_used("seed") && transition.seed != TRANSITION_DEFAULT_SEED { + ignored.push("seed"); + } + if !is_used("order") && transition.order != PixelDissolveOrder::default() { + ignored.push("order"); + } + if !is_used("direction") && transition.direction != TransitionDirection::default() { + ignored.push("direction"); + } + if !is_used("aberration") && transition.aberration != TRANSITION_DEFAULT_ABERRATION { + ignored.push("aberration"); + } + if !is_used("strength") && transition.strength != TRANSITION_DEFAULT_STRENGTH { + ignored.push("strength"); + } + if !is_used("origin") && transition.origin.is_some() { + ignored.push("origin"); + } + if !is_used("shape") && transition.shape != IrisShape::default() { + ignored.push("shape"); + } + if !is_used("aspect") && transition.aspect != TRANSITION_DEFAULT_ASPECT { + ignored.push("aspect"); + } + if !is_used("fill") && transition.fill.is_some() { + ignored.push("fill"); + } + if !is_used("hold") && transition.hold != TRANSITION_DEFAULT_HOLD { + ignored.push("hold"); + } + if !is_used("ring") && transition.ring.is_some() { + ignored.push("ring"); + } + if !is_used("reverse") && transition.reverse { + ignored.push("reverse"); + } + if !is_used("silhouette") && transition.silhouette.is_some() { + ignored.push("silhouette"); + } + if !is_used("from_scale") && transition.from_scale != TRANSITION_DEFAULT_FROM_SCALE { + ignored.push("from_scale"); + } + if !is_used("to_scale") && transition.to_scale != TRANSITION_DEFAULT_TO_SCALE { + ignored.push("to_scale"); + } + if !is_used("lobes") && transition.lobes != TRANSITION_DEFAULT_LOBES { + ignored.push("lobes"); + } + if !is_used("wobble") && transition.wobble != TRANSITION_DEFAULT_WOBBLE { + ignored.push("wobble"); + } + if !is_used("feather") && transition.feather != TRANSITION_DEFAULT_FEATHER { + ignored.push("feather"); + } + if !is_used("band_color") && transition.band_color.is_some() { + ignored.push("band_color"); + } + if !is_used("background") && transition.background != PanBackground::default() { + ignored.push("background"); + } + + if ignored.is_empty() { + return; + } + + let type_name = transition_type_json_name(&transition.transition_type); + let field_word = if ignored.len() == 1 { + "field" + } else { + "fields" + }; + let field_list = ignored + .iter() + .map(|f| format!("`{f}`")) + .collect::>() + .join(", "); + warnings.push(format!( + "{path}.transition: type \"{type_name}\" ignores {field_word} {field_list} — accepted \ + by the schema but without effect on this transition type." + )); +} + fn check_spring_config(spring: &SpringConfig, path: &str, errors: &mut Vec) { if spring.mass <= 0.0 { errors.push(format!( @@ -1770,3 +1931,125 @@ mod unresolved_beat_unit_tests { ); } } + +#[cfg(test)] +mod transition_ignored_field_tests { + use super::*; + use rustmotion::loader::load_scenario_from_source; + + fn parse(json: &str) -> ResolvedScenario { + load_scenario_from_source(None, Some(json)).expect("scenario parses") + } + + #[test] + fn a_fade_transition_names_every_field_it_ignores_in_one_warning() { + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "scenes": [ + {"duration": 1.0, "children": []}, + { + "duration": 1.0, + "children": [], + "transition": { + "type": "fade", + "duration": 0.6, + "origin": {"x": 10, "y": 10}, + "ring": {"color": "#fff", "width": 4}, + "shape": "pill" + } + } + ] + }"##; + let scenario = parse(json); + let (_errors, warnings) = validate_scenario(&scenario); + let hit = warnings + .iter() + .find(|w| w.contains("views[0].scenes[1]") && w.contains("fade")) + .unwrap_or_else(|| panic!("expected an ignored-field warning, got: {warnings:?}")); + for field in ["origin", "ring", "shape"] { + assert!( + hit.contains(&format!("`{field}`")), + "must name `{field}` as ignored by \"fade\": {hit}" + ); + } + } + + #[test] + fn a_field_the_transition_type_actually_uses_produces_no_warning() { + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "scenes": [ + {"duration": 1.0, "children": []}, + { + "duration": 1.0, + "children": [], + "transition": { + "type": "wipe_left", + "duration": 0.5, + "feather": 40.0, + "band_color": "#ff00ff" + } + } + ] + }"##; + let scenario = parse(json); + let (_errors, warnings) = validate_scenario(&scenario); + assert!( + warnings.iter().all(|w| !w.contains("ignores")), + "feather and band_color are used by wipe_left — must not be reported as ignored: \ + {warnings:?}" + ); + } + + #[test] + fn a_field_left_at_its_default_is_not_reported_even_though_the_type_ignores_it() { + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "scenes": [ + {"duration": 1.0, "children": []}, + { + "duration": 1.0, + "children": [], + "transition": { + "type": "fade", + "duration": 0.6, + "shape": "circle" + } + } + ] + }"##; + let scenario = parse(json); + let (_errors, warnings) = validate_scenario(&scenario); + assert!( + warnings.iter().all(|w| !w.contains("ignores")), + "shape: \"circle\" is the default — leaving it at the default must not be flagged \ + as an explicit, ineffective override: {warnings:?}" + ); + } + + #[test] + fn a_view_level_transition_is_checked_the_same_way_as_a_scene_level_one() { + let json = r##"{ + "video": {"width": 64, "height": 64, "fps": 20}, + "composition": [ + { + "type": "slide", + "transition": { + "type": "slide", + "duration": 0.4, + "corner": "top_left", + "cell": 12.0 + }, + "scenes": [ {"duration": 1.0, "children": []} ] + } + ] + }"##; + let scenario = parse(json); + let (_errors, warnings) = validate_scenario(&scenario); + let hit = warnings + .iter() + .find(|w| w.contains("views[0]") && w.contains("slide") && w.contains("ignores")) + .unwrap_or_else(|| panic!("expected a view-level ignored-field warning: {warnings:?}")); + assert!(hit.contains("`corner`") && hit.contains("`cell`"), "{hit}"); + } +} From e9193708fba196d75c99fedf71678c0e452a772f Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 15:53:35 +0200 Subject: [PATCH 2/3] fix(transition): name a broken mask silhouette and a fractional origin A mask transition with no silhouette, or one that does not resolve to a path, falls back to a plain fade and says so on stderr under a zero exit code -- which reads as the transition having been ignored rather than refused. validate now names both cases before anything renders, reusing mask_shape_to_local_path so the check and the render agree by construction instead of by a second opinion. origin on mask, blob, iris and zoom_blur is in frame pixels. Writing { "x": 0.5, "y": 0.5 } for the centre is the natural mistake, and it places the origin half a pixel from the top-left corner with no complaint. A warning now says which unit the field takes and what the centre of the frame would be. This is the fifth item of #432, which the workstream's briefing left out. Closes #432 --- .../rustmotion-core/src/engine/transition.rs | 2 +- .../src/cli/commands/validate_schema.rs | 146 +++++++++++++++++- 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 1103e2f..5f86694 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -848,7 +848,7 @@ fn iris_transition( blend_fade(&filled, frame_b, t) } -fn mask_shape_to_local_path(shape: &MaskShape) -> Option { +pub fn mask_shape_to_local_path(shape: &MaskShape) -> Option { match shape { MaskShape::Polygon { points } => { if points.len() < 3 { diff --git a/crates/rustmotion/src/cli/commands/validate_schema.rs b/crates/rustmotion/src/cli/commands/validate_schema.rs index a695d95..57a7f85 100644 --- a/crates/rustmotion/src/cli/commands/validate_schema.rs +++ b/crates/rustmotion/src/cli/commands/validate_schema.rs @@ -31,6 +31,8 @@ pub fn validate_scenario(scenario: &ResolvedScenario) -> (Vec, Vec (Vec, Vec String { .unwrap_or_else(|| "?".to_string()) } +fn check_mask_transition_shape(transition: &Transition, path: &str, errors: &mut Vec) { + use rustmotion::schema::TransitionType; + + if !matches!(transition.transition_type, TransitionType::Mask) { + return; + } + let Some(shape) = transition.silhouette.as_ref() else { + errors.push(format!( + "{path}.transition: type \"mask\" needs a `silhouette` — a `polygon` with at least \ + 3 points, or a `path` with SVG path data. Without one it falls back to a plain \ + fade at render, which looks like the transition was ignored." + )); + return; + }; + if rustmotion::engine::transition::mask_shape_to_local_path(shape).is_none() { + errors.push(format!( + "{path}.transition.silhouette did not resolve to a path — a `polygon` needs at \ + least 3 points, a `path`'s `d` must be valid SVG path data. It falls back to a \ + plain fade at render." + )); + } +} + +fn check_transition_origin_units(transition: &Transition, path: &str, warnings: &mut Vec) { + use rustmotion::schema::TransitionType; + + if !matches!( + transition.transition_type, + TransitionType::Mask + | TransitionType::Blob + | TransitionType::Iris + | TransitionType::ZoomBlur + ) { + return; + } + let Some(origin) = transition.origin.as_ref() else { + return; + }; + let looks_like_a_fraction = origin.x > 0.0 + && origin.x <= 1.0 + && origin.y > 0.0 + && origin.y <= 1.0 + && (origin.x != 1.0 || origin.y != 1.0); + if looks_like_a_fraction { + warnings.push(format!( + "{path}.transition.origin is ({}, {}), which is in pixels — that places it within \ + one pixel of the frame's top-left corner. `zoom_blur`, `iris`, `mask` and `blob` \ + all take frame pixels here, not a 0..1 fraction: the centre of a 1920x1080 frame \ + is {{ \"x\": 960, \"y\": 540 }}.", + origin.x, origin.y + )); + } +} + fn check_transition_ignored_fields( transition: &Transition, path: &str, @@ -2053,3 +2108,84 @@ mod transition_ignored_field_tests { assert!(hit.contains("`corner`") && hit.contains("`cell`"), "{hit}"); } } + +#[cfg(test)] +mod mask_transition_message_tests { + use super::*; + + fn scenario(transition: serde_json::Value) -> rustmotion::schema::ResolvedScenario { + let json = serde_json::json!({ + "version": "1.0", + "video": { "width": 1920, "height": 1080, "fps": 30 }, + "scenes": [ + { "duration": 1.0, "children": [] }, + { "duration": 1.0, "children": [], "transition": transition } + ] + }) + .to_string(); + rustmotion::loader::load_scenario_from_source(None, Some(&json)).expect("load") + } + + #[test] + fn a_mask_with_no_silhouette_is_named_rather_than_falling_back_to_a_fade_at_render() { + let (errors, _) = validate_scenario(&scenario( + serde_json::json!({ "type": "mask", "duration": 0.6 }), + )); + assert!( + errors.iter().any(|e| e.contains("needs a `silhouette`")), + "the render-time fallback is a stderr line under a zero exit code, which reads as \ + the transition being ignored: {errors:?}" + ); + } + + #[test] + fn a_polygon_with_two_points_is_named_before_the_render_silently_fades() { + let (errors, _) = validate_scenario(&scenario(serde_json::json!({ + "type": "mask", + "duration": 0.6, + "silhouette": { "kind": "polygon", "points": [[0.0, 0.0], [10.0, 0.0]] } + }))); + assert!( + errors + .iter() + .any(|e| e.contains("did not resolve to a path")), + "got {errors:?}" + ); + } + + #[test] + fn a_usable_silhouette_produces_no_error() { + let (errors, _) = validate_scenario(&scenario(serde_json::json!({ + "type": "mask", + "duration": 0.6, + "silhouette": { "kind": "polygon", "points": [[0.0, 0.0], [10.0, 0.0], [5.0, 9.0]] } + }))); + assert!(errors.is_empty(), "got {errors:?}"); + } + + #[test] + fn a_fractional_origin_is_flagged_because_the_field_is_in_frame_pixels() { + let (_, warnings) = validate_scenario(&scenario(serde_json::json!({ + "type": "iris", + "duration": 0.6, + "origin": { "x": 0.5, "y": 0.5 } + }))); + assert!( + warnings.iter().any(|w| w.contains("in pixels")), + "0.5 reads as the frame's centre and lands half a pixel from its corner: {warnings:?}" + ); + } + + #[test] + fn a_pixel_origin_is_left_alone() { + let (_, warnings) = validate_scenario(&scenario(serde_json::json!({ + "type": "iris", + "duration": 0.6, + "origin": { "x": 960.0, "y": 540.0 } + }))); + assert!( + !warnings.iter().any(|w| w.contains("in pixels")), + "got {warnings:?}" + ); + } +} From 54f7ac11a45da95d092fad227cdae19aae9f76af Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 15:54:42 +0200 Subject: [PATCH 3/3] docs(clip-path): document the blob shape the transition already grew The generator existed and only the transition could reach it. Both documents now say clip-path has seven shapes, and that blob is the same silhouette scaled by radius around origin rather than by a transition's progress. --- crates/rustmotion/skills/SKILL.md | 4 ++-- crates/rustmotion/skills/rules/clip-path.md | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index a3a93fc..a4d068b 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -263,7 +263,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [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 -- [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the six `clip-path` shapes, how their percentages resolve, and why `node-path` is not one of them yet +- [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the seven `clip-path` shapes — including `blob`, the same procedural silhouette `transition: { type: "blob" }` grows — how their percentages resolve, and why `node-path` is not one of them yet - [rules/overlapping-scenes.md](rules/overlapping-scenes.md) - Make an element outlive a cut: overlapping `at` windows composite instead of replacing, who supplies the background, and why `snap` never creates an overlap - [rules/even-dimensions.md](rules/even-dimensions.md) - Use even width/height for H.264 encoding - [rules/composition-recipes.md](rules/composition-recipes.md) - **Read this before reaching for a UI-widget component.** Composing KPI cards, pill rows, progress bars, and other former "frozen composition" shapes from primitives, `components`, and `for-each` @@ -967,7 +967,7 @@ All components are discriminated by `"type"`. Rendered in array order (first = b | `letter-spacing` | f32 | `null` | | `white-space` | enum | unset (wraps) — set `"nowrap"`/`"pre"` for single-line text. There is no `wrap` field. The validator emits `unwrappable_text_overflow` if the natural width exceeds the box. See [rules/geometry-safety.md](rules/geometry-safety.md). | | `overflow` | enum | `"visible"` — CSS-like: `"visible"` (default, children may bleed) or `"hidden"` (clip at the box). Validator only checks the **viewport**, never a `visible` parent. | -| `clip-path` | object | Non-rectangular mask on the element itself, background/border/outer-shadow included. `kind`: `inset`, `circle`, `ellipse`, `polygon`, `path`, `none`. A chamfered frame is an eight-point `polygon`. `kind: node-path` is declared but **not implemented** and warns on stderr. See [rules/clip-path.md](rules/clip-path.md). | +| `clip-path` | object | Non-rectangular mask on the element itself, background/border/outer-shadow included. `kind`: `inset`, `circle`, `ellipse`, `polygon`, `path`, `blob`, `none`. A chamfered frame is an eight-point `polygon`. `blob` grows the same procedural silhouette as `transition: { type: "blob" }`, scaled by `radius` around `origin`. `kind: node-path` is declared but **not implemented** and warns on stderr. See [rules/clip-path.md](rules/clip-path.md). | **Per-character / per-word animation (char animation presets):** diff --git a/crates/rustmotion/skills/rules/clip-path.md b/crates/rustmotion/skills/rules/clip-path.md index ecce190..1f42097 100644 --- a/crates/rustmotion/skills/rules/clip-path.md +++ b/crates/rustmotion/skills/rules/clip-path.md @@ -4,7 +4,7 @@ **lui-même** à une forme arbitraire — et contrairement à `overflow`, il emporte le fond, la bordure et l'ombre externe avec lui, comme en CSS. -Six formes, toutes résolues contre la **border box** du nœud : +Sept formes, toutes résolues contre la **border box** du nœud : | `kind` | Champs | Résolution des `%` | |---|---|---| @@ -14,6 +14,7 @@ Six formes, toutes résolues contre la **border box** du nœud : | `ellipse` | `rx` `ry`, `origin` optionnel | `rx` sur la largeur, `ry` sur la hauteur | | `polygon` | `points`: `[[x, y], …]` | `x` sur la largeur, `y` sur la hauteur | | `path` | `d`: données de chemin SVG | coordonnées relatives au coin haut-gauche de la boîte | +| `blob` | `radius`, `origin` optionnel, plus `lobes`/`wobble`/`seed` optionnels (mêmes défauts que `transition: { type: "blob" }` — `8`/`0.15`/`11`) | comme `circle` : `sqrt(w² + h²) / √2` | `origin` prend la même forme que `transform-origin` et vaut le centre par défaut. @@ -56,3 +57,13 @@ En revanche il ne s'interpole pas dans un `timeline`. C'est une propriété de p supportée à l'animation — voir [timeline-sequencing.md](timeline-sequencing.md). Pour une révélation progressive, animer un `transform` sous un parent `overflow: hidden` reste la voie. + +## `blob` : la silhouette de la transition, en masque + +`blob` fait grandir la même silhouette organique procédurale que la transition +[`blob`](mask-transition.md) — mêmes `lobes`, `wobble` et `seed`, **le même +générateur**, pas une seconde implémentation. La différence est ce qui la met à +l'échelle : `radius` autour de `origin`, au lieu du `progress` d'une transition. + +Comme `path` et `node-path`, il n'est pas encore accepté par `kind: "morph"` — +seuls `inset`, `circle`, `ellipse` et `polygon` interpolent entre deux formes.