From b9cf23a271cc389eb343094b78128caebf757b1a Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Fri, 25 Sep 2026 09:56:34 +0200 Subject: [PATCH 01/43] fix(animator): gate a timeline step's animation on its own at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A timeline step has two halves and they obeyed different rules. Its `style` was gated by `apply_style_states`, which merges only the states whose `at` the clock has reached. Its `animation` was merged unconditionally by `effective_effects`, so every step contributed from t=0. With one step that is invisible: the step's own first keyframe is the base state, so holding it changes nothing. With two or more it decides the frame. All keyframes effects share one bucket where the last entry wins on a shared property, and the last entry is the last step — which has not begun, so it imposes the value of its first keyframe from the very start. A node that moves in three beats sits at the end of beat two for the whole scene, and only the final beat ever animates. Passing the component's local time in and filtering the steps the same way `apply_style_states` already does aligns the two halves. The last-effect-wins rule for `style.animation` is untouched: it was chosen deliberately so that composition does not depend on an incidental `delay`, and it is pinned by `last_declared_effect_wins_regardless_of_which_one_carries_the_delay`, which still passes. Found by porting an existing promo animation to a scenario rather than by reading: the logo had to enter centred, move to a corner, and come back, and it never left the corner. --- .../rustmotion-components/src/box_builder.rs | 79 +++++++++++++++++-- .../src/legacy_dispatch.rs | 6 +- .../rustmotion/src/cli/commands/geometry.rs | 2 +- crates/rustmotion/src/engine/render/scene.rs | 2 +- 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index f5ab158b..e73513ef 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -316,7 +316,8 @@ fn build_ghosts<'a>( scene_duration: actx.scene_duration, fps: actx.fps, }; - if let Some(ghost_effects) = effective_effects(&child.component, stagger_delay) { + if let Some(ghost_effects) = effective_effects(&child.component, stagger_delay, ghost_time) + { let props = resolve_props_for_effects( &ghost_effects, ghost_actx.time, @@ -452,7 +453,7 @@ fn build_child<'a>( // lower (earlier in the slot table). The principal's id is allocated below. let mut ghosts: Vec = Vec::new(); if let Some(actx) = local_actx { - if let Some(effects) = effective_effects(&child.component, stagger_delay) { + if let Some(effects) = effective_effects(&child.component, stagger_delay, actx.time) { ghosts = build_ghosts( child, components, @@ -534,7 +535,7 @@ fn build_child<'a>( // — internal animations like draw_progress or char_animation remain on the // `AnimatedProperties` legacy path. if let Some(actx) = local_actx { - if let Some(effects) = effective_effects(&child.component, stagger_delay) { + if let Some(effects) = effective_effects(&child.component, stagger_delay, actx.time) { let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration); if props_has_paint_overrides(&props) { apply_animated_props(&mut css, &props); @@ -655,13 +656,19 @@ fn build_child<'a>( } /// The full effect list for a component at paint time: `style.animation`, -/// plus `timeline` steps shifted by their `at`, plus keyframes synthesized -/// from timeline style-state changes (`style.transition`), plus the -/// container-stagger delay applied to everything. Returns `None` when there -/// is nothing to resolve, `Some(Cow::Borrowed)` on the no-merge fast path. +/// plus the `timeline` steps whose `at` `t` has reached, shifted by their +/// `at`, plus keyframes synthesized from timeline style-state changes +/// (`style.transition`), plus the container-stagger delay applied to +/// everything. Returns `None` when there is nothing to resolve, +/// `Some(Cow::Borrowed)` on the no-merge fast path. +/// +/// `t` is the component's own local time, the same clock +/// `resolve_props_for_effects` is called with, and the same one +/// `apply_style_states` gates a step's `style` on. pub fn effective_effects( component: &Component, extra_delay: f64, + t: f64, ) -> Option> { let animatable = component.as_animatable()?; let effects = animatable.animation_effects(); @@ -675,7 +682,7 @@ pub fn effective_effects( return (!effects.is_empty()).then_some(std::borrow::Cow::Borrowed(effects)); } let mut merged = effects.to_vec(); - for step in steps { + for step in steps.iter().filter(|s| s.at <= t - extra_delay) { for effect in &step.animation { let mut e = effect.clone(); e.shift_delay(step.at); @@ -2185,6 +2192,62 @@ pub fn component_kind(c: &Component) -> &'static str { #[cfg(test)] mod tests { use super::*; + + /// Two timeline steps on one node. Each step is documented to trigger at + /// its own `at`, and `apply_style_states` already gates a step's `style` + /// that way — its `animation` half must obey the same rule, or a step + /// that has not begun still sets the value through the shared + /// last-effect-wins bucket. + #[test] + fn a_timeline_step_leaves_the_value_alone_until_its_at() { + let component: Component = serde_json::from_value(json!({ + "type": "shape", + "shape": "circle", + "fill": "#1EA2C2", + "style": { "width": 120, "height": 120 }, + "timeline": [ + { "at": 3.0, "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [ + { "property": "translate_x", "easing": "linear", "keyframes": [ + { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 200.0 }] }] }] }, + { "at": 6.0, "animation": [{ "name": "keyframes", "duration": 1.0, "keyframes": [ + { "property": "translate_x", "easing": "linear", "keyframes": [ + { "time": 0.0, "value": 200.0 }, { "time": 1.0, "value": 0.0 }] }] }] } + ] + })) + .expect("component deserializes"); + + let tx = |t: f64| match effective_effects(&component, 0.0, t) { + Some(effects) => resolve_props_for_effects(&effects, t, 9.0).translate_x as f64, + None => AnimatedProperties::default().translate_x as f64, + }; + + assert!( + tx(0.5).abs() < 1.0, + "before either step, translate_x is 0, got {}", + tx(0.5) + ); + assert!( + (tx(3.5) - 100.0).abs() < 2.0, + "halfway through step one, got {}", + tx(3.5) + ); + assert!( + (tx(5.0) - 200.0).abs() < 1.0, + "step one has ended and holds, got {}", + tx(5.0) + ); + assert!( + (tx(6.5) - 100.0).abs() < 2.0, + "halfway through step two, got {}", + tx(6.5) + ); + assert!( + tx(8.0).abs() < 1.0, + "step two has ended and holds, got {}", + tx(8.0) + ); + } + use rustmotion_core::css::style::{ CssStyle, Display, Edges, FlexDirection, Gap, Size as CSize, }; diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index f3fb225f..31ca09d4 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -117,7 +117,11 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { .copied() .unwrap_or((1.0, 0.0)); let local_time = frame.time * t_scale + t_shift; - let props = match crate::box_builder::effective_effects(&child.component, stagger_delay) { + let props = match crate::box_builder::effective_effects( + &child.component, + stagger_delay, + local_time, + ) { Some(effects) => resolve_props_for_effects(&effects, local_time, frame.scene_duration), None => AnimatedProperties::default(), }; diff --git a/crates/rustmotion/src/cli/commands/geometry.rs b/crates/rustmotion/src/cli/commands/geometry.rs index 9494bab0..88f5b312 100644 --- a/crates/rustmotion/src/cli/commands/geometry.rs +++ b/crates/rustmotion/src/cli/commands/geometry.rs @@ -1504,7 +1504,7 @@ fn walk_anim( .copied() .unwrap_or((1.0, 0.0)); let local_time = scale * time + shift; - let props = match effective_effects(&child.component, stagger_delay) { + let props = match effective_effects(&child.component, stagger_delay, local_time) { Some(effects) => resolve_props_for_effects(&effects, local_time, scene_duration), None => AnimatedProperties::default(), }; diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index cf7d96f2..96200fd6 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -581,7 +581,7 @@ fn paint_decorative_fullscreen( } } - let props = match effective_effects(&child.component, 0.0) { + let props = match effective_effects(&child.component, 0.0, time) { Some(effects) => resolve_props_for_effects(&effects, time, ctx.scene_duration), None => AnimatedProperties::default(), }; From 5bda143d25897eb3002057d09b7b9cfbccb5d125 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Fri, 25 Sep 2026 10:20:46 +0200 Subject: [PATCH 02/43] feat(svg): add a fill-reveal draw-on mode via `reveal: fill` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A filled SVG (no stroke) could only draw-on as a wireframe outline: paint_draw_on always strokes each path with draw_stroke_width, so a solid logo traced in thin lines and only became solid the instant draw_progress hit 1.0. There was no way to express "the logo draws itself" for anything but stroke-art icons. Add `reveal: "stroke" | "fill"` (default "stroke", behavior byte-for-byte unchanged for the default: same branch, same functions). `"fill"` instead sweeps a per-path clip mask (left-to-right, path bounds) over the SVG's own already fully rasterized image (paint_resvg's output, now cached via the new `cached_full_image`, factored out of `paint_static` without behavior change). Clipping the pre-rendered raster keeps gradients/patterns intact, which a per-path flat-color fill (the alternative: reuse collect_paths' color resolution and skia-fill each path with a growing rect clip) would have lost — collect_paths already falls back to white for gradient/pattern paints, which is fine for a 2px trace but not for a filled reveal meant to show the real artwork. Path ordering/windowing (length-weighted, draw_overlap) is reused as-is from paint_draw_on so paths still reveal sequentially. Not verified: full ffmpeg render/encode of a `reveal: fill` scenario (only `still` frames and unit tests); behavior with self-intersecting/evenodd paths beyond the fill-rule set on the mask path. --- crates/rustmotion-components/src/svg.rs | 371 +++++++++++++++++++++--- 1 file changed, 325 insertions(+), 46 deletions(-) diff --git a/crates/rustmotion-components/src/svg.rs b/crates/rustmotion-components/src/svg.rs index ccf9e242..7c86314f 100644 --- a/crates/rustmotion-components/src/svg.rs +++ b/crates/rustmotion-components/src/svg.rs @@ -11,6 +11,18 @@ use rustmotion_core::engine::renderer::asset_cache; use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum SvgReveal { + /// Trace each path's outline progressively (current/legacy behavior). + #[default] + Stroke, + /// Sweep a mask across each path's full, already-painted shape (fills, + /// gradients included) instead of tracing a contour. + Fill, +} + #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct Svg { #[serde(default)] @@ -35,6 +47,10 @@ pub struct Svg { /// 0.0 = strictly sequential (default); 1.0 = all paths drawn in parallel. #[serde(default)] pub draw_overlap: f32, + /// How draw-on animation reveals paths: `stroke` traces contours (default, + /// unchanged), `fill` sweeps a mask across each path's full painted shape. + #[serde(default)] + pub reveal: SvgReveal, } fn default_draw_stroke_width() -> f32 { @@ -142,6 +158,127 @@ fn collect_paths( } } +/// Recursively collect each visible path's geometry (with its SVG fill rule +/// applied), for use as a reveal mask in `reveal: fill` mode. Color/stroke +/// don't matter here: the mask only gates which pixels of the already +/// fully-painted raster (gradients included) get copied to the canvas. +fn collect_paths_for_fill(group: &usvg::Group, out: &mut Vec) { + for node in group.children() { + match node { + usvg::Node::Group(g) => { + collect_paths_for_fill(g, out); + } + usvg::Node::Path(p) => { + if !p.is_visible() { + continue; + } + let mut skia_path = tiny_path_to_skia(p.data(), p.abs_transform()); + let fill_type = match p.fill().map(|f| f.rule()) { + Some(usvg::FillRule::EvenOdd) => skia_safe::PathFillType::EvenOdd, + _ => skia_safe::PathFillType::Winding, + }; + skia_path.set_fill_type(fill_type); + out.push(skia_path); + } + _ => {} + } + } +} + +/// Reveal the SVG progressively at `draw_progress` (0..=1) by sweeping a clip +/// mask across each path's full, already fully-painted shape (`full_image`, +/// gradients and all) instead of tracing a stroked contour. Paths are +/// revealed one after another (or with overlap), using the same per-path +/// length-weighted windowing as `paint_draw_on` so the sequential ordering +/// matches the stroke mode. +fn paint_fill_reveal( + canvas: &Canvas, + group: &usvg::Group, + svg_size: usvg::Size, + layout: &BoxLayout, + progress: f32, + draw_overlap: f32, + full_image: &skia_safe::Image, +) { + let progress = progress.clamp(0.0, 1.0); + + let mut paths: Vec = Vec::new(); + collect_paths_for_fill(group, &mut paths); + + if paths.is_empty() { + return; + } + + let scale_x = if svg_size.width() > 0.0 { + layout.width / svg_size.width() + } else { + 1.0 + }; + let scale_y = if svg_size.height() > 0.0 { + layout.height / svg_size.height() + } else { + 1.0 + }; + + let lengths: Vec = paths + .iter() + .map(|path| { + let mut pm = PathMeasure::new(path, false, None); + pm.length() + }) + .collect(); + + let total_length: f32 = lengths.iter().sum(); + if total_length <= 0.0 { + return; + } + + let overlap = draw_overlap.clamp(0.0, 1.0); + let image_dst = Rect::from_xywh(0.0, 0.0, svg_size.width(), svg_size.height()); + let paint = Paint::default(); + + let mut cumulative = 0.0f32; + for (path, length) in paths.iter().zip(lengths.iter()) { + let base_frac = length / total_length; + let window_size = base_frac * (1.0 - overlap) + overlap; + let start_frac = cumulative * (1.0 - overlap); + cumulative += base_frac; + + let local_t = if window_size > 0.0 { + ((progress - start_frac) / window_size).clamp(0.0, 1.0) + } else if progress >= start_frac { + 1.0 + } else { + 0.0 + }; + + if local_t <= 0.0 { + continue; + } + + canvas.save(); + canvas.scale((scale_x, scale_y)); + canvas.clip_path(path, None, true); + + if local_t < 1.0 { + // Sweep left-to-right: reveal a growing slice of this path's own + // bounding box, intersected with the path shape itself above. + let bounds = path.bounds(); + let revealed_w = bounds.width() * local_t; + let sweep = Rect::from_ltrb( + bounds.left, + bounds.top - 1.0, + bounds.left + revealed_w, + bounds.bottom + 1.0, + ); + canvas.clip_rect(sweep, None, true); + } + + canvas.draw_image_rect(full_image, None, image_dst, &paint); + canvas.restore(); + } +} + /// Draw the SVG paths progressively at `draw_progress` (0..=1). /// Uses a dash PathEffect to reveal each path sequentially (or with overlap). fn paint_draw_on( @@ -311,6 +448,19 @@ impl Painter for Svg { if progress >= 1.0 { // At completion, fall through to normal resvg render so fills are shown. self.paint_resvg(canvas, layout, &svg_data, &tree, svg_size); + } else if self.reveal == SvgReveal::Fill { + let Some(full_image) = self.cached_full_image(layout) else { + return; + }; + paint_fill_reveal( + canvas, + tree.root(), + svg_size, + layout, + progress, + self.draw_overlap, + &full_image, + ); } else { paint_draw_on( canvas, @@ -332,6 +482,20 @@ impl Painter for Svg { impl Svg { /// Normal static render via cached resvg bitmap. fn paint_static(&self, canvas: &Canvas, layout: &BoxLayout) { + let Some(img) = self.cached_full_image(layout) else { + return; + }; + + let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); + let paint = Paint::default(); + canvas.draw_image_rect(img, None, dst, &paint); + } + + /// Resolve (and cache) the fully rasterized SVG — fills, gradients and + /// all — at the layout's pixel size. Shared by `paint_static` and the + /// `reveal: fill` draw-on mode, which clips this same raster per path + /// instead of re-deriving flat per-path colors. + fn cached_full_image(&self, layout: &BoxLayout) -> Option { let target_w_opt: Option = if layout.width > 0.0 { Some(layout.width as u32) } else { @@ -350,7 +514,8 @@ impl Svg { target_w_opt.unwrap_or(0), target_h_opt.unwrap_or(0) ) - } else if let Some(ref data) = self.data { + } else { + let data = self.data.as_ref()?; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); @@ -361,61 +526,45 @@ impl Svg { target_w_opt.unwrap_or(0), target_h_opt.unwrap_or(0) ) - } else { - return; }; let cache = asset_cache(); - let img = if let Some(cached) = cache.get(&cache_key) { - cached.clone() - } else { - let svg_data = if let Some(ref src) = self.src { - let Ok(data) = std::fs::read(src) else { return }; - data - } else if let Some(ref data) = self.data { - data.as_bytes().to_vec() - } else { - return; - }; + if let Some(cached) = cache.get(&cache_key) { + return Some(cached.clone()); + } - let opt = usvg::Options::default(); - let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else { - return; - }; + let svg_data = if let Some(ref src) = self.src { + std::fs::read(src).ok()? + } else { + self.data.as_ref()?.as_bytes().to_vec() + }; - let svg_size = tree.size(); - let target_w = target_w_opt.unwrap_or(svg_size.width() as u32); - let target_h = target_h_opt.unwrap_or(svg_size.height() as u32); + let opt = usvg::Options::default(); + let tree = usvg::Tree::from_data(&svg_data, &opt).ok()?; - let Some(mut pixmap) = tiny_skia::Pixmap::new(target_w, target_h) else { - return; - }; + let svg_size = tree.size(); + let target_w = target_w_opt.unwrap_or(svg_size.width() as u32); + let target_h = target_h_opt.unwrap_or(svg_size.height() as u32); - let scale_x = target_w as f32 / svg_size.width(); - let scale_y = target_h as f32 / svg_size.height(); - let transform = tiny_skia::Transform::from_scale(scale_x, scale_y); + let mut pixmap = tiny_skia::Pixmap::new(target_w, target_h)?; - resvg::render(&tree, transform, &mut pixmap.as_mut()); + let scale_x = target_w as f32 / svg_size.width(); + let scale_y = target_h as f32 / svg_size.height(); + let transform = tiny_skia::Transform::from_scale(scale_x, scale_y); - let img_data = skia_safe::Data::new_copy(pixmap.data()); - let img_info = ImageInfo::new( - (target_w as i32, target_h as i32), - ColorType::RGBA8888, - skia_safe::AlphaType::Premul, - None, - ); - let Some(decoded) = - skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4) - else { - return; - }; - cache.insert(cache_key, decoded.clone()); - decoded - }; + resvg::render(&tree, transform, &mut pixmap.as_mut()); - let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); - let paint = Paint::default(); - canvas.draw_image_rect(img, None, dst, &paint); + let img_data = skia_safe::Data::new_copy(pixmap.data()); + let img_info = ImageInfo::new( + (target_w as i32, target_h as i32), + ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let decoded = + skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4)?; + cache.insert(cache_key, decoded.clone()); + Some(decoded) } /// Render via resvg when draw-on completes (progress == 1.0). @@ -471,3 +620,133 @@ impl Svg { let _ = svg_data; // only used to accept the lifetime; tree holds the parsed data } } + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::engine::layout_pass::Insets; + + const W: i32 = 100; + const H: i32 = 100; + + fn filled_square_svg() -> Svg { + Svg { + src: None, + data: Some( + r##" + + "## + .to_string(), + ), + timing: Default::default(), + style: Default::default(), + timeline: Vec::new(), + stagger: None, + draw: false, + draw_stroke_width: default_draw_stroke_width(), + draw_overlap: 0.0, + reveal: SvgReveal::Fill, + } + } + + fn test_layout() -> BoxLayout { + BoxLayout { + x: 0.0, + y: 0.0, + width: W as f32, + height: H as f32, + border: Insets::default(), + padding: Insets::default(), + } + } + + fn test_ctx() -> PaintCtx { + PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: 1920, + video_height: 1080, + stagger_offset: 0.0, + } + } + + fn red_alpha_at(surface: &mut skia_safe::Surface, x: i32, y: i32) -> (u8, u8, u8, u8) { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (W, H), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (W * H * 4) as usize]; + let ok = snapshot.read_pixels( + &info, + &mut buf, + (W * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + assert!(ok, "pixel read should succeed"); + let idx = ((y * W + x) * 4) as usize; + (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]) + } + + #[test] + fn fill_reveal_paints_interior_pixels_at_partial_progress() { + // A fully-filled 80x80 rect with no stroke. At draw_progress = 0.5 the + // `fill` reveal mode must show painted interior pixels (a swept solid + // region), not just a thin traced outline. + let svg = filled_square_svg(); + let layout = test_layout(); + let props = AnimatedProperties { + draw_progress: 0.5, + ..Default::default() + }; + let ctx = test_ctx(); + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + svg.paint_content(canvas, &layout, &props, &ctx); + } + + // x=30 is well inside the rect's left half (revealed at progress 0.5 + // under a left-to-right sweep) and far from the outline; a stroke-only + // trace would leave it fully transparent. + let (r, g, b, a) = red_alpha_at(&mut surface, 30, 50); + assert!( + a > 200 && r > 200 && g < 50 && b < 50, + "fill reveal at draw_progress=0.5 must paint filled interior pixels, got rgba=({r},{g},{b},{a}) at (30,50)" + ); + } + + #[test] + fn stroke_reveal_default_leaves_interior_unfilled_at_partial_progress() { + // The default `reveal: stroke` behavior must be unchanged: at partial + // draw_progress, only a thin traced outline is visible, so a deep + // interior pixel stays unpainted. + let mut svg = filled_square_svg(); + svg.reveal = SvgReveal::Stroke; + let layout = test_layout(); + let props = AnimatedProperties { + draw_progress: 0.5, + ..Default::default() + }; + let ctx = test_ctx(); + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + svg.paint_content(canvas, &layout, &props, &ctx); + } + + let (_, _, _, a) = red_alpha_at(&mut surface, 50, 50); + assert!( + a < 50, + "default stroke reveal must not fill the interior at partial progress, got alpha={a} at (50,50)" + ); + } +} From 8afc4c16d0aa5db6fcbdabbff2e46e99d0996264 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Fri, 25 Sep 2026 10:30:03 +0200 Subject: [PATCH 03/43] fix(css): default flex-direction to column when unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit taffy_bridge.rs only wrote style.flex_direction when css.flex_direction was Some(..); when a card/div/flex/grid omitted flex-direction, taffy's own Style::DEFAULT (Row) leaked through unnoticed. SKILL.md documents "column" as the default, and the scene root (box_builder.rs's default_root_css) already sets Column explicitly — that root-level override was masking the mismatch, since every top-level layout looked correct while any nested container silently behaved like Row. Measured before choosing: 75/259 card/div/flex/grid instances across examples/*.json omit flex-direction (0/61 for `flex`, since authors always state it explicitly there; the silent gap is entirely in `card` 45/98 and `div` 30/100). Rendered stills of every affected scene in all 7 affected example files before and after, at each scene's sampled midpoint: 25/27 frames are byte-identical; the remaining 2 (both in ferriskey-launch-60s.json, a pill-nav button row) differ by ~0.14% of pixels, a sub-pixel border AA shift on a single-child card whose content is unaffected by axis choice. No `validate` output regresses on any example (the one ferriskey-presentation.json geometry failure predates this change, confirmed by testing the unpatched binary). Alternative considered: fix SKILL.md to document "row" instead, since that's taffy/CSS's real default. Rejected — the scene root, SKILL.md, and effectively every example in the repo are already written assuming column, so "row" would be the surprising, silently-wrong default for the LLM authors this schema targets, not less so than today. Reserve: cargo test --workspace surfaces 7 failures in crates/rustmotion/src/cli/commands/geometry.rs (out of this commit's file scope), isolated to be caused by this change and not concurrent work (confirmed by reverting only this file against the current tree). Root cause: a fixed-height card/div/flex with a single oversized child and no explicit flex-direction used to have that child's cross-axis clamped to the card's declared size under Row+stretch, so a remeasurement pass reliably caught the mismatch as ContentOverflowsBox. Under Column, the child's main-axis (height) is no longer clamped and grows to its natural content size instead, so the layout box already "matches" its own content and that check stops firing. When the grown box also happens to cross the viewport edge, check_viewport still catches it (reclassified, not lost) - but when it doesn't, or when the node carries bleed: true, the overflow escapes detection entirely. Reproduced with a minimal fixture (card w=300 h=80, one wrapped text child needing ~343px, positioned so the grown box stays inside the viewport): the unpatched binary reports ContentOverflowsBox; the patched one reports nothing. This is a real, if narrow, hole in the geometry validator's coverage for a common pattern (45/98 card instances in examples/*.json have no explicit flex-direction), not mere reclassification, and fixing it requires touching geometry.rs's own overflow checks - outside this commit's file scope. Needs escalation before this lands. --- .../rustmotion-core/src/css/taffy_bridge.rs | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/rustmotion-core/src/css/taffy_bridge.rs b/crates/rustmotion-core/src/css/taffy_bridge.rs index 7ee1fbce..f3c1fc5c 100644 --- a/crates/rustmotion-core/src/css/taffy_bridge.rs +++ b/crates/rustmotion-core/src/css/taffy_bridge.rs @@ -105,14 +105,13 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { style.border = border_widths(css.border.as_ref(), ctx); // Flex - if let Some(d) = css.flex_direction { - style.flex_direction = match d { - FlexDirection::Row => tf::FlexDirection::Row, - FlexDirection::RowReverse => tf::FlexDirection::RowReverse, - FlexDirection::Column => tf::FlexDirection::Column, - FlexDirection::ColumnReverse => tf::FlexDirection::ColumnReverse, - }; - } + style.flex_direction = match css.flex_direction { + Some(FlexDirection::Row) => tf::FlexDirection::Row, + Some(FlexDirection::RowReverse) => tf::FlexDirection::RowReverse, + Some(FlexDirection::Column) => tf::FlexDirection::Column, + Some(FlexDirection::ColumnReverse) => tf::FlexDirection::ColumnReverse, + None => tf::FlexDirection::Column, + }; if let Some(w) = css.flex_wrap { style.flex_wrap = match w { FlexWrap::Nowrap => tf::FlexWrap::NoWrap, @@ -668,6 +667,22 @@ mod tests { assert_eq!(s.gap.height, tf::LengthPercentage::length(16.0)); } + #[test] + fn flex_display_without_explicit_direction_defaults_to_column() { + let css = CssStyle { + display: Some(Display::Flex), + ..Default::default() + }; + let s = to_taffy_style(&css, &ctx()); + assert_eq!( + s.flex_direction, + tf::FlexDirection::Column, + "a `display: flex` container with no `flex-direction` must default to \ + Column, matching SKILL.md's documented default and the scene root's \ + behavior — taffy's own default (Row) must not leak through" + ); + } + #[test] fn padding_uniform_resolved() { let css = CssStyle { From 83f7ca6181a26709c4e5631a409cbb583c244221 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Fri, 25 Sep 2026 10:35:25 +0200 Subject: [PATCH 04/43] fix(animator): rebase a start_at'ed node's animation clock on its own start_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_at opened a component's visibility window (PaintWindow, already correct) but never touched the clock its animation effects resolve against — they kept running on raw scene time. An entrance already playing out by the time the node became visible snapped straight to its end state instead of animating; an exit whose own `delay` had elapsed before `start_at` left the node painting nothing for its whole visible window (measured: a badge with start_at: 2.0 rendered zero pixels at every sampled instant, its exit having completed at scene time 1.15). Fix: fold the node's own `start_at` into the same `extra_delay` that a container's `stagger` already contributes in `build_child` (box_builder.rs) — same mechanism, same call sites (effective_effects, apply_style_states, resolve_transition_css_overrides, ghost generation), so `start_at` rebases exactly like stagger already did. `BuiltScene.stagger_delays` now carries this combined delay per node, which legacy_dispatch.rs and geometry.rs (the --strict-anim overflow sampler, outside this change's scope) both already read from — geometry.rs picks up the fix for free without being touched. `paint_decorative_fullscreen` (scene.rs) gets the same treatment for full-viewport leaves, which have no stagger of their own to fold in. Considered rebasing `BuildAnimationCtx.time` itself instead of shifting each effect's `delay`. Rejected: `stagger` already uses the delay-shift form (see `effective_effects`), and the two forms are only equivalent as long as nothing downstream reads the un-shifted clock directly — mixing them would have made that invariant easy to break later. Reusing the proven form keeps `start_at` and `stagger` composing through the identical path. Does not touch `end_at`, `delay` without `start_at` (still resolves against scene time, unchanged), or the timeline-step gate from b9cf23a (`last_declared_effect_wins_regardless_of_which_one_carries_the_delay` still passes) — `t` passed into `effective_effects` stays the raw scene clock, only `extra_delay` grows. Measured against every examples/*.json (43 rendered frames across all 11 files, before/after, pixel-diffed): zero visual change. Only two example components declare start_at at all (both `counter`, mega-showcase.json and rustmotion-promo.json), and counter's own progress reads ctx.time directly rather than going through the effects pipeline this change touches, so even those are unaffected. --- .../rustmotion-components/src/box_builder.rs | 165 +++++++++++++++--- .../src/legacy_dispatch.rs | 20 ++- crates/rustmotion/src/engine/render/scene.rs | 9 +- 3 files changed, 163 insertions(+), 31 deletions(-) diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index e73513ef..218c048e 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -61,10 +61,12 @@ pub struct BuiltScene<'a> { /// Lookup table — `components[id as usize]` is the component for `id`. /// `None` for synthetic boxes (the root scene wrapper). pub components: Vec>, - /// Per-node animation delay accumulated from ancestor containers' - /// `stagger` (indexed like `components`). Consumed by the paint - /// dispatcher so internal animations shift by the same amount as the - /// CSS overrides resolved at build time. + /// Per-node animation delay: ancestor containers' `stagger` plus the + /// node's own `start_at` (indexed like `components`). Consumed by the + /// paint dispatcher so internal animations shift by the same amount as + /// the CSS overrides resolved at build time — and so an entrance/exit + /// animation on a `start_at`ed node plays from its own first keyframe + /// instead of one already resolved at the untouched scene clock. pub stagger_delays: Vec, /// Per-node affine time remap accumulated from ancestor containers' /// `time_scale`/`time_offset`. Entry `i` is `(scale, shift)` where @@ -231,7 +233,7 @@ fn build_ghosts<'a>( time_params: &mut Vec<(f64, f64)>, next_id: &mut NodeId, actx: BuildAnimationCtx, - stagger_delay: f64, + extra_delay: f64, time_remap: (f64, f64), effects: &[AnimationEffect], parent_css: &CssStyle, @@ -291,7 +293,7 @@ fn build_ghosts<'a>( let steps = animatable.timeline_steps(); if steps.iter().any(|s| s.style.is_some()) { let skip_opacity = css.transition.is_some(); - apply_style_states(&mut css, steps, ghost_time - stagger_delay, skip_opacity); + apply_style_states(&mut css, steps, ghost_time - extra_delay, skip_opacity); // Same `border-radius`/`background` smoothing as the // principal path in `build_child`, sampled at `ghost_time` // so a motion-blur/trail ghost mid-transition matches what @@ -299,7 +301,7 @@ fn build_ghosts<'a>( let overrides = resolve_transition_css_overrides( child.component.as_styled().style_config(), steps, - ghost_time - stagger_delay, + ghost_time - extra_delay, ); if let Some(br) = overrides.border_radius { css.border_radius = Some(br); @@ -316,8 +318,7 @@ fn build_ghosts<'a>( scene_duration: actx.scene_duration, fps: actx.fps, }; - if let Some(ghost_effects) = effective_effects(&child.component, stagger_delay, ghost_time) - { + if let Some(ghost_effects) = effective_effects(&child.component, extra_delay, ghost_time) { let props = resolve_props_for_effects( &ghost_effects, ghost_actx.time, @@ -353,7 +354,7 @@ fn build_ghosts<'a>( *next_id += 1; // Register a slot so the dispatcher can look up the component. components.push(Some(child)); - stagger_delays.push(stagger_delay); + stagger_delays.push(extra_delay); time_params.push(time_remap); ghosts.push(BoxNode { @@ -385,7 +386,7 @@ fn build_ghosts<'a>( let ghost_id = *next_id; *next_id += 1; components.push(Some(child)); - stagger_delays.push(stagger_delay); + stagger_delays.push(extra_delay); time_params.push(time_remap); trail_nodes.push(BoxNode { @@ -448,12 +449,27 @@ fn build_child<'a>( } }); + // A node's own `start_at` rebases its animation clock the same way an + // ancestor's `stagger` already does: both push out the instant the + // component's *own* first keyframe is considered reached. Without this, + // `start_at` only gated visibility — the effect list still resolved + // against the untouched scene clock, so an entrance already playing out + // by the time the node became visible snapped straight to its end state, + // and an exit whose own `delay` elapsed before `start_at` left the node + // painting nothing for its whole visible window. + let anim_delay = stagger_delay + + child + .component + .as_timed() + .and_then(|t| t.timing().0) + .unwrap_or(0.0); + // ── Ghost generation (motion_blur / trail) ─────────────────────────────── // Must happen before allocating the principal's id so that ghost ids are // lower (earlier in the slot table). The principal's id is allocated below. let mut ghosts: Vec = Vec::new(); if let Some(actx) = local_actx { - if let Some(effects) = effective_effects(&child.component, stagger_delay, actx.time) { + if let Some(effects) = effective_effects(&child.component, anim_delay, actx.time) { ghosts = build_ghosts( child, components, @@ -461,7 +477,7 @@ fn build_child<'a>( time_params, next_id, actx, - stagger_delay, + anim_delay, time_remap, &effects, parent_css, @@ -472,7 +488,7 @@ fn build_child<'a>( let id = *next_id; *next_id += 1; components.push(Some(child)); - stagger_delays.push(stagger_delay); + stagger_delays.push(anim_delay); time_params.push(time_remap); let mut css = component_css(&child.component); @@ -507,7 +523,7 @@ fn build_child<'a>( if steps.iter().any(|s| s.style.is_some()) { let t = local_actx.map(|a| a.time).unwrap_or(0.0); let skip_opacity = css.transition.is_some(); - apply_style_states(&mut css, steps, t - stagger_delay, skip_opacity); + apply_style_states(&mut css, steps, t - anim_delay, skip_opacity); // `border-radius`/`background` (solid colour, uniform absolute // px only — see `resolve_transition_css_overrides`'s doc // comment) smooth the same way opacity does above, but land @@ -517,7 +533,7 @@ fn build_child<'a>( let overrides = resolve_transition_css_overrides( child.component.as_styled().style_config(), steps, - t - stagger_delay, + t - anim_delay, ); if let Some(br) = overrides.border_radius { css.border_radius = Some(br); @@ -535,7 +551,7 @@ fn build_child<'a>( // — internal animations like draw_progress or char_animation remain on the // `AnimatedProperties` legacy path. if let Some(actx) = local_actx { - if let Some(effects) = effective_effects(&child.component, stagger_delay, actx.time) { + if let Some(effects) = effective_effects(&child.component, anim_delay, actx.time) { let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration); if props_has_paint_overrides(&props) { apply_animated_props(&mut css, &props); @@ -658,9 +674,11 @@ fn build_child<'a>( /// The full effect list for a component at paint time: `style.animation`, /// plus the `timeline` steps whose `at` `t` has reached, shifted by their /// `at`, plus keyframes synthesized from timeline style-state changes -/// (`style.transition`), plus the container-stagger delay applied to -/// everything. Returns `None` when there is nothing to resolve, -/// `Some(Cow::Borrowed)` on the no-merge fast path. +/// (`style.transition`), plus `extra_delay` applied to everything — callers +/// fold in both the ancestor-stagger delay and the node's own `start_at` here, +/// so the effect list is agnostic to which one (or both) it's carrying. +/// Returns `None` when there is nothing to resolve, `Some(Cow::Borrowed)` on +/// the no-merge fast path. /// /// `t` is the component's own local time, the same clock /// `resolve_props_for_effects` is called with, and the same one @@ -2248,6 +2266,113 @@ mod tests { ); } + /// A `start_at`ed entrance must play from its own first keyframe, not + /// from wherever the unrebased scene clock already landed it. Measured + /// bug: a `fade_in_down` (0.6s) on a `start_at: 2.0` node resolved at + /// t=2.0 (the instant it becomes visible) to the animation's value at + /// scene time 2.0 — long past the 0.6s duration — so it appeared already + /// fully faded in instead of animating. + #[test] + fn start_at_rebases_the_entrance_animation_clock() { + let scene = vec![ChildComponent { + component: serde_json::from_value(json!({ + "type": "shape", + "shape": "rect", + "fill": "#1EA2C2", + "start_at": 2.0, + "style": { + "width": 120, "height": 120, + "animation": [{ "name": "fade_in_down", "duration": 0.6 }] + } + })) + .expect("component deserializes"), + position: None, + x: None, + y: None, + z_index: None, + bleed: false, + }]; + + let opacity_at = |t: f64| -> f32 { + let built = build_scene_at_time( + &scene, + (400.0, 400.0), + default_root_css((400.0, 400.0)), + BuildAnimationCtx { + time: t, + scenario_time: t, + scene_duration: 6.0, + fps: 30, + }, + ); + built.root.children[0].css.opacity.unwrap_or(1.0) + }; + + assert!( + opacity_at(2.0) < 0.3, + "at start_at (2.0) the fade_in_down entrance should just be beginning, got opacity {}", + opacity_at(2.0) + ); + assert!( + opacity_at(2.6) > 0.9, + "0.6s after start_at (the entrance's own duration) it should have finished, got opacity {}", + opacity_at(2.6) + ); + } + + /// Companion to the entrance case above, mirroring the measured `badge` + /// bug: an exit declared after the entrance (so it alone owns `opacity` + /// under last-declared-wins) carries its own `delay`. Unrebased, that + /// delay is measured from scene time zero, so the exit can finish before + /// `start_at` is even reached — the component then renders zero pixels + /// for its entire visible window. + #[test] + fn start_at_rebases_an_exit_animation_declared_after_the_entrance() { + let scene = vec![ChildComponent { + component: serde_json::from_value(json!({ + "type": "shape", + "shape": "rect", + "fill": "#1EA2C2", + "start_at": 2.0, + "style": { + "width": 120, "height": 120, + "animation": [ + { "name": "fade_in_down", "duration": 0.6 }, + { "name": "fade_out_up", "delay": 0.85, "duration": 0.3 } + ] + } + })) + .expect("component deserializes"), + position: None, + x: None, + y: None, + z_index: None, + bleed: false, + }]; + + let opacity_at = |t: f64| -> f32 { + let built = build_scene_at_time( + &scene, + (400.0, 400.0), + default_root_css((400.0, 400.0)), + BuildAnimationCtx { + time: t, + scenario_time: t, + scene_duration: 6.0, + fps: 30, + }, + ); + built.root.children[0].css.opacity.unwrap_or(1.0) + }; + + assert!( + opacity_at(2.5) > 0.5, + "the exit's own delay (0.85s) has not elapsed since start_at (2.0), the node should \ + still be visible, got opacity {}", + opacity_at(2.5) + ); + } + use rustmotion_core::css::style::{ CssStyle, Display, Edges, FlexDirection, Gap, Size as CSize, }; diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 31ca09d4..4f3736a2 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -34,8 +34,9 @@ pub struct LegacyPaintDispatcher<'a> { /// `components[id as usize]` is the component for `id`. Slot 0 is the /// synthetic root and is always `None`. components: &'a [Option<&'a ChildComponent>], - /// Per-node container-stagger delay (indexed like `components`); empty - /// when the caller doesn't carry stagger information. + /// Per-node animation delay — ancestor-stagger plus the node's own + /// `start_at` (indexed like `components`); empty when the caller doesn't + /// carry that information. stagger_delays: &'a [f64], /// Per-node accumulated affine time remap `(scale, shift)` from ancestor /// containers' `time_scale`/`time_offset` (indexed like `components`); @@ -52,10 +53,11 @@ impl<'a> LegacyPaintDispatcher<'a> { } } - /// Build from a [`BuiltScene`], carrying its stagger delays so internal - /// animations shift by the same amount as the CSS overrides, and its - /// per-node time remaps so internal animations (counter, draw_in, - /// typewriter…) advance at the same local time as the CSS overrides. + /// Build from a [`BuiltScene`], carrying its per-node animation delays + /// (stagger plus `start_at`) so internal animations shift by the same + /// amount as the CSS overrides, and its per-node time remaps so internal + /// animations (counter, draw_in, typewriter…) advance at the same local + /// time as the CSS overrides. pub fn for_scene(built: &'a crate::box_builder::BuiltScene<'a>) -> Self { Self { components: &built.components, @@ -97,9 +99,9 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { // the CSS overrides injected at box-tree build time, so we don't // wrap the canvas here. `props` is still needed for internal-only // fields like `draw_progress`, `stroke_width`, `visible_chars*`, - // and `char_animation`. Timeline steps and container-stagger delays - // are folded in so those internal animations shift exactly like the - // CSS overrides do. + // and `char_animation`. Timeline steps and the node's accumulated + // delay (ancestor stagger plus its own `start_at`) are folded in so + // those internal animations shift exactly like the CSS overrides do. let stagger_delay = self .stagger_delays .get(*node_id as usize) diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index 96200fd6..d9fe1ed3 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -574,14 +574,19 @@ fn paint_decorative_fullscreen( use rustmotion_core::traits::PaintCtx; let time = ctx.time.seconds(); + // A `start_at` here rebases the animation clock the same way it does in + // `build_child` (`box_builder.rs`) — this leaf has no ancestor stagger of + // its own to fold in, so `start_at` alone is its `extra_delay`. + let mut start_at = 0.0; if let Some(timed) = child.component.as_timed() { let (start, end) = timed.timing(); if !(PaintWindow { start, end }).contains(time) { return; } + start_at = start.unwrap_or(0.0); } - let props = match effective_effects(&child.component, 0.0, time) { + let props = match effective_effects(&child.component, start_at, time) { Some(effects) => resolve_props_for_effects(&effects, time, ctx.scene_duration), None => AnimatedProperties::default(), }; @@ -608,7 +613,7 @@ fn paint_decorative_fullscreen( fps: ctx.fps, video_width: ctx.video_width, video_height: ctx.video_height, - stagger_offset: 0.0, + stagger_offset: start_at, }; canvas.save(); painter.paint_content(canvas, &local, &props, &paint_ctx); From 3ba6f7623a7e8942fb67dfea4c87740240732048 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Fri, 25 Sep 2026 10:52:35 +0200 Subject: [PATCH 05/43] fix(geometry): clamp content-overflow checks to the containing block's box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8afc4c1 (default flex-direction: column) fixed a real layout bug but opened a detection hole here. check_content_overflows_box/check_auto_scroll both compared a leaf's measured content against its OWN post-layout box (layout.content_box()). Under the old row default, align-items: stretch clamped a lone child's cross axis (height) to its container's declared size, so a too-small card produced a real box/content mismatch these checks caught. Under column, that axis is the MAIN axis, which stretch never clamps — the child's box now legitimately grows to match its content exactly (CSS min-height:auto-style overflow), so the self-vs-self comparison became vacuous: the box IS the content, by construction. The paragraph still paints past its card, invisibly to both checks, unless the grown box happens to also cross the viewport edge. Fix: thread the nearest containing block's own resolved content box (container_bound) down through walk(), and clamp an in-flow child's effective box to min(own, container) per axis before comparing. A card's own box stays true to its declared size regardless of its children's overflow (ordinary CSS containing-block behavior), so this recovers exactly the bound the old row-direction clamp used to provide, without resurrecting row as the default. Absolute children are excluded — taken out of flex flow entirely, their box is legitimately sized from their own content only, per the already-passing absolutely_positioned_*_spilling_past_a_visible_card_is_legal tests. That exclusion checks box_node.css.position == Some(Position::Absolute), the exact condition box_builder.rs uses to decide the same thing, not ChildComponent::is_flow() (a stricter, unrelated predicate that's false for any declared `position` shorthand, "absolute" or not, and isn't otherwise read by the layout pipeline). Considered parsing each node's own declared style.width/height directly instead of reading the parent's resolved layout box. Rejected: the overflowing leaf (text/table/codeblock/...) usually has no explicit size of its own — the fixed size lives on the ancestor card — and the parent's resolved box already gives the same number without re-deriving unit/ percentage resolution that CssStyle -> px conversion already does once, correctly, during layout. Of the 7 previously-red tests: 6 were real detection losses, now fixed by container_bound (in_flow_codeblock_shrunk_by_its_card_is_caught_by_auto_ scroll_check, in_flow_table_taller_than_its_card_is_flagged_via_content_ overflows_box, bleed_true_does_not_exempt_content_overflows_box, gradient_text_taller_than_its_fixed_height_card_is_flagged, without_text_autofit_the_same_fixture_still_overflows, text_autofit_does_not_silence_an_overflow_the_floor_cannot_fix). The 7th, wrapped_text_taller_than_its_fixed_height_card_is_flagged, was a genuine reclassification unrelated to this file's fix: its y=200 fixture's grown (unclamped) box now happens to cross the 540px frame edge by ~3px for real, so check_viewport correctly starts firing ViewportOverflow alongside — moved the fixture to y=100 (restoring the isolated-repro invariant its own docstring claims) rather than weakening the assertion, and added a dedicated regression test using the audit's own 1920x1080 numbers. Verified: the audit's own repro (1920x1080, card 300x80 at (660,50), overflowing text) is a named ContentOverflowsBox error again via the CLI. All 69 geometry:: tests and the full `cargo test -p rustmotion` (440 tests) pass. `rustmotion validate` on all 11 examples/*.json is byte-identical before/after this change (built from git-show'd pre-fix geometry.rs vs. the fixed version, everything else held constant) — including the pre-existing, unrelated ferriskey-presentation.json gradient_text overflow. cargo fmt --all and cargo clippy -p rustmotion --all-targets -- -D warnings are clean. Not verified: multi-child containers where siblings jointly (not individually) exceed a fixed-size container's declared axis — container_ bound clamps each child independently against the full container box, not against space already consumed by earlier siblings, so it can under-report in that specific shape. Not exercised by any test or example/ file found. --- .../rustmotion/src/cli/commands/geometry.rs | 151 +++++++++++++++++- 1 file changed, 143 insertions(+), 8 deletions(-) diff --git a/crates/rustmotion/src/cli/commands/geometry.rs b/crates/rustmotion/src/cli/commands/geometry.rs index 88f5b312..62d79540 100644 --- a/crates/rustmotion/src/cli/commands/geometry.rs +++ b/crates/rustmotion/src/cli/commands/geometry.rs @@ -61,7 +61,7 @@ use rustmotion::components::intrinsic::{ }; use rustmotion::components::{ChildComponent, Component}; use rustmotion::core::css::style::{ - CssStyle, TransformFn, TransformOrigin, WhiteSpace, MIN_LEGIBLE_FONT_RATIO, + CssStyle, Position, TransformFn, TransformOrigin, WhiteSpace, MIN_LEGIBLE_FONT_RATIO, TEXT_AUTOFIT_MIN_FONT_PX, }; use rustmotion::core::css::taffy_bridge::ConversionContext; @@ -188,6 +188,11 @@ pub fn validate_geometry(scenario: &ResolvedScenario) -> Vec .as_ref() .filter(|_| !scene_uses_depth(&children)); + let root_bound = layouts + .get(built.root.id) + .map(|l| l.content_box()) + .map(|(_, _, w, h)| (w, h)); + let path_root = format!("views[{}].scenes[{}]", vi, si); walk( &children, @@ -204,6 +209,7 @@ pub fn validate_geometry(scenario: &ResolvedScenario) -> Vec /*parent_clips=*/ false, camera, + root_bound, &mut violations, ); } @@ -263,6 +269,11 @@ fn walk( path_indices: Option<&[usize]>, parent_clips: bool, camera: Option<&Camera>, + // The nearest containing block's own resolved content box (width, + // height) — see `check_content_overflows_box`'s doc comment for why an + // in-flow child's own post-layout box is no longer sufficient on its + // own (RM-34). + container_bound: Option<(f32, f32)>, out: &mut Vec, ) { let viewport_f = (viewport.0 as f32, viewport.1 as f32); @@ -274,6 +285,21 @@ fn walk( None => continue, }; let raw_bbox = bbox_of(layout); + // `box_node.css.position` (not `ChildComponent::is_flow`, a + // different, looser predicate — false for any declared `position` + // shorthand, "absolute" or not, see its doc comment) is the exact + // condition `box_builder.rs` used to decide whether taffy treats + // this node as `Position::Absolute`. Only that actually takes a + // node out of flex flow: its own box is then sized purely from its + // own content/style, never shrunk or grown to fit a sibling slot, + // so the containing block's size is irrelevant to it (see + // `absolutely_positioned_*_spilling_past_a_visible_card_is_legal`, + // which depends on this staying unbound). + let own_bound = if box_node.css.position == Some(Position::Absolute) { + None + } else { + container_bound + }; if !is_exempted(&child.component) { if !parent_clips && !bleeds(child) { @@ -307,7 +333,16 @@ fn walk( out, ); } - check_auto_scroll(&child.component, &child_path, layout, viewport, vi, si, out); + check_auto_scroll( + &child.component, + &child_path, + layout, + own_bound, + viewport, + vi, + si, + out, + ); // Suppressed under a clipping ancestor (parent_clips) exactly // like check_viewport, and when the node clips its own overflow // (paint_pass applies a node's own `overflow: hidden`/clip/ @@ -318,6 +353,7 @@ fn walk( &child.component, &child_path, layout, + own_bound, viewport, vi, si, @@ -352,6 +388,7 @@ fn walk( } if let Some(grandchildren) = container_children(&child.component) { + let (_, _, cw, ch) = layout.content_box(); walk( grandchildren, &box_node.children, @@ -363,6 +400,7 @@ fn walk( None, parent_clips || container_clips(&child.component), camera, + Some((cw, ch)), out, ); } @@ -883,10 +921,30 @@ fn check_unwrappable_text( /// `line_height`, independent of any width constraint, so it's measured at /// `(MaxContent, Definite(ch))` and reported on `Axis::Y` only, leaving /// `Axis::X` to `check_unwrappable_text`. +/// +/// RM-34: `layout.content_box()` is no longer trustworthy as the sole bound +/// on its own. `fix(css): default flex-direction to column when unset` +/// (8afc4c1) means a single in-flow child's MAIN axis (height, in the +/// overwhelmingly common column case) is no longer clamped by `align-items: +/// stretch` — that only ever clamped the CROSS axis. A node's own resolved +/// box now legitimately grows past its container's declared size to match +/// its content exactly (`min-height: auto`-style flex overflow, matching +/// real CSS), which makes a self-vs-self comparison vacuous: the box IS the +/// content, by construction. `container_bound` — the nearest containing +/// block's own resolved content box, threaded down from `walk` — is the +/// fix: an in-flow node's effective box is `min(own, container)` per axis, +/// so a still-fixed-size ancestor (the ordinary case; card/flex/grid boxes +/// are NOT subject to the same unclamped growth, since nothing above forces +/// them to shrink-wrap their own children) keeps constraining what "fits" +/// means, even though the leaf's post-layout box no longer does. `None` +/// (absolutely positioned children, and the historical behavior for callers +/// that don't have an ancestor to compare against) leaves `cw`/`ch` +/// unchanged. fn check_content_overflows_box( component: &Component, path: &str, layout: &BoxLayout, + container_bound: Option<(f32, f32)>, viewport: (u32, u32), vi: usize, si: usize, @@ -897,6 +955,10 @@ fn check_content_overflows_box( }; let (cx, cy, cw, ch) = layout.content_box(); + let (cw, ch) = match container_bound { + Some((bw, bh)) => (cw.min(bw), ch.min(bh)), + None => (cw, ch), + }; if cw <= 0.0 || ch <= 0.0 { return; } @@ -1019,10 +1081,18 @@ fn check_content_overflows_box( /// painter, terminal included, is handed `layout.content_box()` instead — /// so the terminal arm compares against that, not the border box, or it /// under-reports by exactly the node's own padding. +/// +/// RM-34: same `container_bound` clamp as `check_content_overflows_box`, and +/// for the same reason — an in-flow codeblock/terminal that's the sole child +/// of a fixed-height card now grows its own box to its natural (unscrolled) +/// height instead of being shrunk to the card's declared size, which made +/// this check's own-box-vs-own-content comparison vacuous. See that +/// function's doc comment for the full explanation. fn check_auto_scroll( component: &Component, path: &str, layout: &BoxLayout, + container_bound: Option<(f32, f32)>, viewport: (u32, u32), vi: usize, si: usize, @@ -1033,7 +1103,10 @@ fn check_auto_scroll( Component::Codeblock(cb) if !cb.auto_scroll => { let (_, natural_h) = CodeblockIntrinsic::from_codeblock(cb).measure((None, None), max_content); - let bbox = bbox_of(layout); + let mut bbox = bbox_of(layout); + if let Some((_, bh)) = container_bound { + bbox.h = bbox.h.min(bh); + } if natural_h > bbox.h + 0.5 { out.push(GeometryViolation { view_index: vi, @@ -1055,6 +1128,10 @@ fn check_auto_scroll( let (_, natural_h) = TerminalIntrinsic::from_terminal(t).measure((None, None), max_content); let (cx, cy, cw, ch) = layout.content_box(); + let ch = match container_bound { + Some((_, bh)) => ch.min(bh), + None => ch, + }; if natural_h > ch + 0.5 { out.push(GeometryViolation { view_index: vi, @@ -2844,15 +2921,24 @@ mod tests { #[test] fn wrapped_text_taller_than_its_fixed_height_card_is_flagged() { // Exact repro from the audit: a card comfortably inside a 960x540 - // frame (x=330,y=200,w=300,h=80 -> right/bottom edges 630/280, both + // frame (x=330,y=100,w=300,h=80 -> right/bottom edges 630/180, both // well inside frame) with a paragraph that, wrapped at the card's // ~300px content width, needs ~343px of height — but the card is - // fixed at 80px. No viewport check ever fires (card and text both - // report a resting bbox inside the frame); this is purely a - // content-vs-own-box mismatch. + // fixed at 80px. + // + // `y=100` (not the card's own bottom edge) leaves headroom for the + // text's own post-layout box, which — since `fix(css): default + // flex-direction to column when unset` — grows to that full ~343px + // instead of being clamped to the card's 80px: at `y=100` its + // bottom (~443) still lands well inside the 540px frame, so + // `check_viewport` stays quiet and this exercises `ContentOverflowsBox` + // in isolation. A shallower `y` would make the grown box cross the + // frame edge for real and pull `ViewportOverflow` into this fixture + // too — see `spilling_past_a_visible_card_is_still_caught_when_it_ + // leaves_the_viewport` for that (intentional) case. let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"}, "scenes":[{"duration":1.0,"children":[ - {"type":"card","position":"absolute","x":330,"y":200, + {"type":"card","position":"absolute","x":330,"y":100, "style":{"width":300,"height":80,"background":"#1e2233","overflow":"visible"}, "children":[{"type":"text", "content":"Ce paragraphe est beaucoup plus grand que la carte de 80px qui le contient.", @@ -2977,6 +3063,55 @@ mod tests { ); } + /// RM-34 regression: the exact repro that surfaced the hole opened by + /// `fix(css): default flex-direction to column when unset` (8afc4c1). + /// Before that commit, `align-items: stretch` clamped this lone child's + /// CROSS axis (height, under the old row default) to the card's + /// declared 80px, so `check_content_overflows_box`'s self-vs-self + /// comparison caught the mismatch as a side effect. After 8afc4c1 the + /// child's MAIN axis (height, under the new column default) isn't + /// clamped by `stretch` at all — its own post-layout box grows to match + /// its content exactly (343px), making the self-comparison vacuous and + /// this fixture validate clean. Distinct from + /// `wrapped_text_taller_than_its_fixed_height_card_is_flagged` only in + /// using the audit's own numbers (1920x1080, not 960x540) — kept + /// alongside it as the fixture actually quoted in the audit report. + #[test] + fn in_flow_text_grown_past_its_cards_declared_height_is_flagged() { + let json = r##"{"video":{"width":1920,"height":1080,"fps":30,"background":"#0A0A12"}, + "scenes":[{"duration":1.0,"children":[ + {"type":"card","position":"absolute","x":660,"y":50, + "style":{"width":300,"height":80,"background":"#1e2233","overflow":"visible"}, + "children":[{"type":"text", + "content":"Ce paragraphe est beaucoup plus grand que la carte de 80px qui le contient.", + "style":{"font-size":44,"color":"#ffffff"}}]}]}]}"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .all(|v| v.kind != ViolationKind::ViewportOverflow), + "fixture stays inside the 1080px-tall frame by construction — this is purely a \ + content-vs-declared-box mismatch: {:?}", + violations + ); + let v = violations + .iter() + .find(|v| v.kind == ViolationKind::ContentOverflowsBox && v.component == "text") + .unwrap_or_else(|| { + panic!( + "expected ContentOverflowsBox for text taller than its fixed-height card, got: {:?}", + violations + ) + }); + assert_eq!(v.axis, Axis::Y); + assert!( + v.hint.contains("height"), + "hint should point at the height mismatch: {}", + v.hint + ); + } + #[test] fn content_overflow_is_suppressed_under_a_clipping_ancestor() { // Same overflowing paragraph/80px-card fixture, but the card clips From f99224b9f62ffe1e489a02269edc476eaf9f67a6 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Fri, 25 Sep 2026 10:58:12 +0200 Subject: [PATCH 06/43] docs(skills): document the svg reveal modes and how they compose --- crates/rustmotion/skills/SKILL.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 2898e37b..5ddd7003 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -1025,9 +1025,24 @@ Style: `width`, `height` (default: uses image dimensions) | `src` | string | `null` — path to SVG file (either `src` or `data` required) | | `data` | string | `null` — inline SVG markup | | `position` | `{x, y}` | `{0, 0}` | +| `reveal` | enum | `"stroke"` — how a draw-on animation uncovers the artwork: `"stroke"` traces each path as a contour, `"fill"` sweeps a mask across the painted shape so gradients and patterns show as they arrive | +| `draw` | bool | `false` — force draw-on mode even at `draw_progress: 1.0` | +| `draw_stroke_width` | f32 | `2.0` — stroke width used when tracing a fill-only path | Style: `width`, `height` (default: intrinsic SVG dimensions) +Drive either mode with the `draw_progress` animatable property. The two compose: stack a +`reveal: "stroke"` copy that fades out over a `reveal: "fill"` copy that fades in, and the mark +draws its outline first, then takes its colour. + +```json +{ "type": "svg", "src": "logo.svg", "reveal": "fill", + "style": { "width": 260, "height": 281, "animation": [ + { "name": "keyframes", "duration": 2.2, "keyframes": [ + { "property": "draw_progress", "easing": "ease_in_out", + "keyframes": [{ "time": 0, "value": 0 }, { "time": 2.2, "value": 1 }] }] }] } } +``` + ### 5. `icon` Renders an icon from the **Iconify** open-source framework (200,000+ icons from 150+ sets). Icons are fetched from the Iconify API at render time. Browse all icons: https://icon-sets.iconify.design/ From 2ee05c16c74d99a4df5f31a6bcd168481433edd4 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Fri, 25 Sep 2026 12:37:30 +0200 Subject: [PATCH 07/43] docs(skills): teach the schema the binary actually accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every JSON block in the README and the skills was extracted and run through `rustmotion validate` rather than read. Eighteen README examples and six skill examples failed. The recurring causes: a root `size: {width,height}` on the thirteen component types that have no such field (the box comes from `style.width`/`style.height`), `fill`/`stroke`/`timeline` documented under `style` when they are root fields, `box-shadow`/`text-shadow` documented as a single snake_case object when both are kebab-case vectors, and `grid-template-columns: [{"fr":1}]` where `GridTrack` wants a bare number or a string. Around twenty-five documented CLI invocations passed the scenario positionally. No subcommand accepts a positional file; they all take `-f`. `completions zsh` was missing its `generate` subcommand. CLAUDE.md described `--strict-attrs` backwards. Unknown component attributes have been hard errors by default since the flag existed; the flag is a deprecated no-op that prints a notice. This matters more than doc drift usually does: the declared consumer of these files is a model generating scenarios, so a wrong example is not a typo, it is an instruction reproduced on every generation. Also records what the code says and the docs did not: `video.crf` is inert for `render`/`still`/`batch` and read only by the studio's exporter; `world-position`, `persist`, `camera_easing` and `camera_pan_duration` validate clean inside a `slide` view and do nothing there; `animated-background` now requires an explicit `preset`. The CI workflow's cargo-audit ignore rationales named dependency chains that no longer exist — dioxus and webbrowser are gone from the lockfile since the gpui-kit migration. The `--ignore` entries themselves are untouched, being policy rather than documentation. The skill now asks which format to write before writing anything, JSON or the HTML dialect, instead of defaulting to JSON silently. It skips the question when the request needs something the dialect cannot express, and rules/html-dialect.md records what that is — every claim in it verified by transpiling and validating, including the traps: an unknown tag becomes a silent `div`, a style declaration missing its colon is dropped, numeric text content is rejected. Not verified: the fragments the mechanical sweep could not wrap, roughly forty in the README and a hundred in the skills, which use an ellipsis or carry no root. A targeted sample of those turned up nothing beyond what is fixed here. Refs #315 --- .github/workflows/ci.yaml | 13 +- README.md | 221 ++++++++++-------- crates/rustmotion/CLAUDE.md | 25 +- crates/rustmotion/skills/SKILL.md | 50 ++-- .../skills/rules/badge-video-sizing.md | 2 +- .../rustmotion/skills/rules/dynamic-depth.md | 7 +- .../skills/rules/geometry-safety.md | 15 +- .../rustmotion/skills/rules/glassmorphism.md | 2 +- .../skills/rules/gradient-quality.md | 6 +- .../rustmotion/skills/rules/html-dialect.md | 98 ++++++++ .../skills/rules/module-structure.md | 13 +- .../skills/rules/responsive-device-sizing.md | 2 +- .../rustmotion/skills/rules/validate-json.md | 2 +- 13 files changed, 307 insertions(+), 149 deletions(-) create mode 100644 crates/rustmotion/skills/rules/html-dialect.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 65240d15..a57cd9a3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -72,16 +72,21 @@ jobs: # RUSTSEC-2026-0204 — crossbeam-epoch 0.9.18, invalid pointer deref in `fmt::Pointer`. # Via rayon-core <- exr <- image, reaches rustmotion-core/-components. Fix: >=0.9.20. # RUSTSEC-2026-0195, RUSTSEC-2026-0194 — quick-xml 0.38.4 / 0.39.4, DoS + quadratic runtime. - # 0.38.4 via syntect reaches the published crates; 0.39.4 via dioxus-desktop/rfd is - # rustmotion-studio-only (Linux/Wayland file dialogs). Fix: >=0.41.0. + # 0.38.4 reaches the published crates via syntect -> plist. 0.39.4 is + # rustmotion-studio-only (Linux/Wayland), via wayland-client/wayland-protocols* -> + # wayland-scanner, themselves pulled in by rfd (file dialogs) and gpui-pre-linux + # (window backend) — not dioxus-desktop, which is no longer a dependency since the + # gpui-kit migration. Fix: >=0.41.0 (a third, already-fixed instance is in the tree + # today via `xcb`). # RUSTSEC-2026-0285 — rustls 0.23.37, TLS 1.3 handshake level-boundary bug. # Via ureq, used by rustmotion/rustmotion-core for Google Fonts + Iconify fetches. Fix: >=0.23.45. # RUSTSEC-2026-0104, RUSTSEC-2026-0098, RUSTSEC-2026-0099, RUSTSEC-2026-0049 — rustls-webpki # 0.103.9, four CRL/name-constraint parsing bugs. Same ureq path as rustls above. # Fix: >=0.103.13,<0.104.0-alpha.1 (or the matching 0.104 alpha per advisory). # RUSTSEC-2026-0257 — webbrowser 1.2.1, BROWSER env argument injection on Unix. - # Via dioxus-desktop, rustmotion-studio only (`publish = false`, never reaches a published - # crate). Fix: >=1.2.2. + # `webbrowser` is no longer in Cargo.lock at all (it left with dioxus-desktop during the + # gpui-kit migration) — this --ignore is currently inert. Left in rather than dropped + # silently: re-verify before removing, in case something reintroduces the crate. - name: Audit dependencies run: > cargo audit diff --git a/README.md b/README.md index c119f1d9..aaec51c3 100644 --- a/README.md +++ b/README.md @@ -21,57 +21,60 @@ cargo install rustmotion Generate and install completions for your shell: ```bash +# Or let rustmotion install completions for your current shell automatically: +rustmotion completions install + # Zsh (add to ~/.zshrc) -rustmotion completions zsh > ~/.zfunc/_rustmotion +rustmotion completions generate zsh > ~/.zfunc/_rustmotion # then add to .zshrc: fpath=(~/.zfunc $fpath) && autoload -Uz compinit && compinit # Or one-liner for Oh My Zsh: -rustmotion completions zsh > ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/rustmotion/_rustmotion +rustmotion completions generate zsh > ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/rustmotion/_rustmotion # Bash (add to ~/.bashrc) -rustmotion completions bash > ~/.local/share/bash-completion/completions/rustmotion +rustmotion completions generate bash > ~/.local/share/bash-completion/completions/rustmotion # Fish -rustmotion completions fish > ~/.config/fish/completions/rustmotion.fish +rustmotion completions generate fish > ~/.config/fish/completions/rustmotion.fish ``` ## Quick Start ```bash # Render a video -rustmotion render scenario.json -o video.mp4 +rustmotion render -f scenario.json -o video.mp4 # Render with a specific codec -rustmotion render scenario.json -o video.webm --codec vp9 --crf 30 +rustmotion render -f scenario.json -o video.webm --codec vp9 --crf 30 # Export as PNG sequence -rustmotion render scenario.json -o frames/ --format png-seq +rustmotion render -f scenario.json -o frames/ --format png-seq # Export as animated GIF -rustmotion render scenario.json -o output.gif --format gif +rustmotion render -f scenario.json -o output.gif --format gif # Render a single frame for preview -rustmotion render scenario.json --frame 42 -o frame.png +rustmotion render -f scenario.json --frame 42 -o frame.png # Validate without rendering -rustmotion validate scenario.json +rustmotion validate -f scenario.json # Export JSON Schema (for editor autocompletion or LLM prompts) rustmotion schema -o schema.json # Show scenario info -rustmotion info scenario.json +rustmotion info -f scenario.json ``` ## Claude Code Skills -rustmotion ships with built-in [Claude Code](https://claude.ai/claude-code) skills — 30 rules and best practices for generating video scenarios with AI. After installing rustmotion, run: +rustmotion ships with built-in [Claude Code](https://claude.ai/claude-code) skills — 57 rules and best practices for generating video scenarios with AI. After installing rustmotion, run: ```bash # Install skills in your video project (recommended) cd my-video-project/ rustmotion skills install -# → .claude/skills/rustmotion/ (SKILL.md + 29 rules) +# → .claude/skills/rustmotion/ (SKILL.md + 57 rules) # → CLAUDE.md (project instructions) # Or install globally (available in all projects) @@ -190,7 +193,7 @@ rustmotion schema -o schema.json Shows information about a scenario (duration, scene count, dimensions, ...). ```bash -rustmotion info scenario.json +rustmotion info -f scenario.json ``` ### `rustmotion skills` @@ -235,7 +238,7 @@ Generates or installs shell completions — `install`, `uninstall`, `generate ": {...} }` form — set it explicitly either way | Animation speed | | `gradient_type` | `string` | `"linear"` | `"linear"` or `"radial"` | -| `preset` | `string` | | `"gradient_shift"`, `"concentric_circles"`, `"grid_dots"`, `"grid_lines"`, `"halo"`, `"pixel_grid"`, `"heropattern"` | | `element_size` | `f32` | `4.0` | Dot size for grid_dots; stroke width for concentric_circles | | `spacing` | `f32` | `60.0` | Element spacing for grid_dots/concentric_circles | | `count` | `u32` | | Number of circles for concentric_circles (overrides spacing) | @@ -1958,7 +1977,7 @@ Any component can be rendered with true 3D perspective using keyframe animations ```json { "style": { - "box-shadow": { "color": "#00000060", "offset_x": 0, "offset_y": 20, "blur": 60 }, + "box-shadow": [{ "color": "#00000060", "offset-x": 0, "offset-y": 20, "blur": 60 }], "animation": [{ "name": "keyframes", "keyframes": [ @@ -1980,16 +1999,16 @@ Define sequential animation phases within a single scene using the `timeline` fi ```json { "style": { - "animation": [{ "name": "fade_in_up", "duration": 0.6 }], - "timeline": [ - { "at": 2.0, "animation": [{ "name": "shake", "duration": 0.5 }] }, - { "at": 4.0, "animation": [{ "name": "fade_out", "duration": 0.8 }] } - ] - } + "animation": [{ "name": "fade_in_up", "duration": 0.6 }] + }, + "timeline": [ + { "at": 2.0, "animation": [{ "name": "shake", "duration": 0.5 }] }, + { "at": 4.0, "animation": [{ "name": "fade_out", "duration": 0.8 }] } + ] } ``` -Each step activates at `step.at` seconds, with animations resolved relative to that time. Steps merge additively with base animations. +Each step activates at `step.at` seconds, with animations resolved relative to that time. Steps merge additively with base animations. `timeline` is a root field on the component, not nested under `style` (`CssStyle` has no `timeline` key). ### Motion Blur @@ -2011,14 +2030,14 @@ Renders multiple sub-frames and composites them for physically-correct motion bl | Format | Command | Requires | |---|---|---| -| **MP4 (H.264 10-bit)** | `rustmotion render in.json -o out.mp4` | ffmpeg (auto-detected) | -| **MP4 (H.264 8-bit)** | `rustmotion render in.json -o out.mp4` | Built-in (fallback without ffmpeg) | -| **MP4 (H.265)** | `rustmotion render in.json -o out.mp4 --codec h265` | ffmpeg | -| **WebM (VP9)** | `rustmotion render in.json -o out.webm --codec vp9` | ffmpeg | -| **MOV (ProRes)** | `rustmotion render in.json -o out.mov --codec prores` | ffmpeg | -| **Animated GIF** | `rustmotion render in.json -o out.gif --format gif` | Built-in | -| **PNG Sequence** | `rustmotion render in.json -o frames/ --format png-seq` | Built-in | -| **Single Frame** | `rustmotion render in.json --frame 0 -o preview.png` | Built-in | +| **MP4 (H.264 10-bit)** | `rustmotion render -f in.json -o out.mp4` | ffmpeg (auto-detected) | +| **MP4 (H.264 8-bit)** | `rustmotion render -f in.json -o out.mp4` | Built-in (fallback without ffmpeg) | +| **MP4 (H.265)** | `rustmotion render -f in.json -o out.mp4 --codec h265` | ffmpeg | +| **WebM (VP9)** | `rustmotion render -f in.json -o out.webm --codec vp9` | ffmpeg | +| **MOV (ProRes)** | `rustmotion render -f in.json -o out.mov --codec prores` | ffmpeg | +| **Animated GIF** | `rustmotion render -f in.json -o out.gif --format gif` | Built-in | +| **PNG Sequence** | `rustmotion render -f in.json -o frames/ --format png-seq` | Built-in | +| **Single Frame** | `rustmotion render -f in.json --frame 0 -o preview.png` | Built-in | Transparency is supported with `--transparent` for PNG sequences, WebM (VP9), and ProRes 4444. @@ -2045,13 +2064,14 @@ Transparency is supported with `--transparent` for PNG sequences, WebM (VP9), an { "type": "shape", "shape": "rounded_rect", - "size": { "width": 900, "height": 520 }, + "fill": { + "type": "linear", + "colors": ["#6366f1", "#8b5cf6"], + "angle": 135 + }, "style": { - "fill": { - "type": "linear", - "colors": ["#6366f1", "#8b5cf6"], - "angle": 135 - }, + "width": 900, + "height": 520, "border-radius": 32, "animation": [{ "name": "scale_in", "duration": 0.6 }] } @@ -2059,8 +2079,9 @@ Transparency is supported with `--transparent` for PNG sequences, WebM (VP9), an { "type": "icon", "icon": "lucide:rocket", - "size": { "width": 80, "height": 80 }, "style": { + "width": 80, + "height": 80, "color": "#FFFFFF", "animation": [{ "name": "fade_in_up", "delay": 0.3, "duration": 0.6 }] } @@ -2137,7 +2158,7 @@ pub trait Painter { } ``` -`PaintCtx` carries `time`, `scene_duration`, `fps`, `frame_index`, `video_width`, `video_height`, `stagger_offset`. +`PaintCtx` carries `time`, `scenario_time` (elapsed since the scenario/view started — what audio-reactive painters index on, since `time` resets at each scene), `scene_duration`, `fps`, `frame_index`, `video_width`, `video_height`, `stagger_offset`. ### Workspace layout @@ -2147,7 +2168,7 @@ crates/ │ ├── css/ # CssStyle, units, cascade, taffy bridge, animation resolution │ ├── engine/ # box_tree, layout_pass, paint_pass, animator, transitions, Skia primitives │ ├── schema/ # Scenario, Scene, VideoConfig, style, background, animation, codeblock models -│ └── traits/ # Painter, Animatable, Timed, Styled +│ └── traits/ # Painter, Animatable, Timed, Styled, + 7 more (11 files total) ├── rustmotion-components/src/ │ ├── lib.rs # `Component` enum (60 variants) + dispatch (as_painter, as_animatable, ...) │ ├── box_builder.rs # JSON components → BuiltScene (box tree + stagger delays) diff --git a/crates/rustmotion/CLAUDE.md b/crates/rustmotion/CLAUDE.md index 2c54f5df..c130640b 100644 --- a/crates/rustmotion/CLAUDE.md +++ b/crates/rustmotion/CLAUDE.md @@ -12,7 +12,7 @@ Tout JSON de scénario généré doit être validé avec `rustmotion validate` a Aucun contenu textuel ne doit dépasser du device. Quatre propriétés contrôlent ce comportement : - `style.white-space` (default `normal`, donc wrap actif) sur `text` : le texte wrap sur la largeur du parent par défaut. `white-space: "nowrap"` (ou `"pre"`) est légitime uniquement si un `max-width` fini + `font-size` raisonnable garantissent que la ligne tient. Le validateur émet `unwrappable_text_overflow` sinon. Il n'existe pas de champ `style.wrap` — c'est un vocabulaire hérité de l'ancien modèle de style, supprimé de `CssStyle`. Voir [rules/geometry-safety.md](.claude/skills/rustmotion/rules/geometry-safety.md). -- `auto_scroll` (default `true`) sur `codeblock` et `terminal` : quand le contenu dépasse la hauteur du `size`, le moteur scrolle (clip + translate) sans réduire la `font-size`. `auto_scroll: false` → `auto_scroll_disabled_overflow`. +- `auto_scroll` (default `true`) sur `codeblock` et `terminal` : quand le contenu dépasse la hauteur déclarée en `style.height`, le moteur scrolle (clip + translate) sans réduire la `font-size`. Il n'existe pas de champ racine `size` sur ces composants — la boîte se dimensionne via `style.width`/`style.height`, comme les autres composants. `auto_scroll: false` → `auto_scroll_disabled_overflow`. - `style.text-autofit` (default absent) sur `text` et `gradient_text` : réduit la `font-size` jusqu'à ce que le contenu tienne dans sa boîte. À réserver au texte piloté par des données, dont on ne peut pas connaître la longueur à l'avance — pas pour compenser une mise en page qu'on peut simplement dimensionner. Le rétrécissement s'arrête à un plancher de lisibilité calibré ; si ça ne suffit pas, **la violation est toujours signalée**. Seuls ces deux composants l'implémentent : le déclarer ailleurs est inerte. - `style.overflow` (default `visible`) sur les conteneurs : sémantique CSS. `hidden` clippe au bord du parent. Le validateur ne se plaint que si le contenu sort du **viewport**, pas d'un parent `visible`. @@ -23,7 +23,7 @@ CLI : - `--fix` — auto-fix sûr : `auto_scroll: true` sur `auto_scroll_disabled_overflow`, retrait de `style.white-space` sur `unwrappable_text_overflow` (retour au wrapping), et `text-autofit: true` sur `content_overflows_box` pour `text`/`gradient_text`. Les débordements de viewport restent non corrigés : ils demandent un arbitrage de mise en page. `--fix` **refuse** d'écrire sur un scénario templaté, utilisant `include`, ou utilisant `for-each`/`use` — les index de chemin ne correspondraient plus à la source. - `--report r.json` — rapport JSON - `--strict-anim` — vérification frame par frame ; ajoute la détection `animated_text_overflow` (transform animé qui sort du viewport à un instant échantillonné). L'échantillonnage s'arrête à `scene.freeze_at`, puisque rien n'est rendu au-delà. -- `--strict-attrs` — promeut en erreurs les attributs inconnus (détection schéma + did-you-mean, activée par défaut en warnings) +- `--strict-attrs` — **dépréciée, no-op.** Les attributs de composant inconnus (détection schéma + did-you-mean) sont des erreurs **par défaut** depuis que ce flag existait ; il ne change plus rien et se contente d'imprimer un avis, conservé pour ne pas casser les scripts existants. - `--lenient` — warnings au lieu d'errors ## Encodage @@ -54,7 +54,7 @@ Chaque élément du `for-each` lie ses champs directement (`$label`), plus `$ind ## Composition : `scenes` vs `composition` (vues `slide` / `world`) -Un scénario est soit une liste plate `scenes` (racine) — implicitement enveloppée dans une seule vue `slide` — soit un `composition: [...]` explicite, un tableau de **vues** typées `"slide"` ou `"world"`. Les deux sont mutuellement exclusifs (`CompositionAndScenesConflict` si les deux sont présents). +Un scénario est soit une liste plate `scenes` (racine) — implicitement enveloppée dans une seule vue `slide` — soit un `composition: [...]` explicite, un tableau de **vues** typées `"slide"` ou `"world"`. Les deux sont mutuellement exclusifs (`CompositionAndScenesConflict` si les deux sont présents). Le top-level `scenes` reste pleinement supporté mais `render` (pas `validate`) émet un avertissement le qualifiant de legacy et recommandant `composition: [{ type: "slide", scenes: [...] }]` — les exemples de ce fichier utilisent volontairement la forme `scenes` pour sa concision. Dans une vue `slide`, les `transition` entre scènes sont des **composites pixel de deux frame-buffers déjà rendus** (fade, wipe, zoom, flip, iris, slide, chromatic_wipe…) : aucun élément ne survit à la coupe, seuls les pixels sont mélangés. @@ -64,6 +64,8 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en **Piège de casing à connaître :** `world-position` (scène) est en kebab-case, alors que son voisin `freeze_at` (même struct `Scene`) est en snake_case. Vraie inconsistance du schéma, pas une faute de frappe — copier la casse telle quelle. +**Piège plus sérieux :** `world-position` et `persist` (champs de `Scene`, donc acceptés même sur le `scenes` top-level implicite) sont validés sans jamais produire d'effet ni d'avertissement tant qu'on n'est pas dans une vue `composition: [{ "type": "world", ... }]` — `rustmotion validate` répond « Valid ». `camera_easing`/`camera_pan_duration` (champs de `View`, uniquement accessibles via `composition`) sont pareillement inertes sur une vue `"type": "slide"`. Dans tous les cas c'est un no-op silencieux, pas une erreur — vérifier qu'on est bien dans une vue `world` avant de les utiliser. + ## Animated backgrounds `scene["animated-background"]` (kebab-case, like `world-position`) accepts a `preset` from `gradient_shift`, `grid_dots`, `grid_lines`, `concentric_circles`, `halo`, `pixel_grid`, `heropattern`, with its config under a key of the same name, plus the common `x`/`y`/`speed`/`direction` that drift the texture. @@ -165,7 +167,7 @@ pub trait Painter { } ``` -`PaintCtx` contient : `time`, `scene_duration`, `fps`, `frame_index`, `video_width`, `video_height`, `stagger_offset`. +`PaintCtx` contient : `time`, `scenario_time` (temps depuis le début du scénario/de la vue — c'est celui qu'indexent les painters audio-réactifs, pas `time` qui repart à zéro à chaque scène), `scene_duration`, `fps`, `frame_index`, `video_width`, `video_height`, `stagger_offset`. ### Structure des crates @@ -193,11 +195,12 @@ crates/ │ │ ├── animation.rs # EasingType, AnimationPreset, PresetConfig │ │ ├── codeblock_types.rs # CodeblockChrome, CodeblockState │ │ └── video.rs # AnimationEffect, Size, ShapeType, Stroke -│ └── traits/ +│ └── traits/ # 11 fichiers │ ├── painter.rs # Painter trait + PaintCtx + AvailableSize + MeasureCtx │ ├── animatable.rs # Animatable trait │ ├── timed.rs # Timed trait + TimingConfig -│ └── styled.rs # Styled trait +│ ├── styled.rs # Styled trait +│ └── backgrounded.rs, bordered.rs, clipped.rs, container.rs, rounded.rs, shadowed.rs │ ├── rustmotion-components/src/ │ ├── lib.rs # Enum Component + dispatch (as_painter, as_animatable, etc.) @@ -205,11 +208,13 @@ crates/ │ ├── intrinsic.rs # TextIntrinsic, BadgeIntrinsic, CounterIntrinsic, etc. │ ├── legacy_dispatch.rs # LegacyPaintDispatcher (bridge NodeId → Painter) │ ├── chart/ # 10 fichiers (mod + bar/line/pie/radar/scatter/radial/funnel/waterfall/axes) +│ ├── codeblock/ # 7 fichiers (mod, render, dimensions, chrome, highlight, diff, reveal) │ └── *.rs # Un fichier par composant (impl Painter) │ └── rustmotion/src/ ├── cli/ # Le binaire `rustmotion` (clap + sous-commandes) - │ └── commands/ # validate, render, schema, info + │ └── commands/ # render, validate(+validate_attrs, validate_schema, validation), + │ # schema, info, still, batch, captions, geometry ├── encode/ # Encodeurs vidéo/audio, mux └── loader.rs # Chargement JSON/HTML → ResolvedScenario ``` @@ -248,7 +253,7 @@ description de PR ou l'issue. ### Tests ```bash -cargo test --workspace # ~200 tests (layout + serde round-trip + pixel regressions + smoke) -cargo check # Vérification compilation -rustmotion validate file.json # Validation scénario +cargo test --workspace # 1400+ tests (layout + serde round-trip + pixel regressions + smoke) +cargo check # Vérification compilation +rustmotion validate -f file.json # Validation scénario ``` diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 5ddd7003..9b2b19c3 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -5,11 +5,26 @@ metadata: tags: motion, video, rust, animation, composition --- -# Skill: Generate rustmotion JSON Scenarios +# Skill: Generate rustmotion Scenarios (JSON or HTML) ## What is rustmotion? -rustmotion is a CLI tool that renders motion design videos from JSON scenario files. It uses Skia for 2D rendering and supports MP4, WebM, MOV, GIF, and PNG sequence outputs. +rustmotion is a CLI tool that renders motion design videos from scenario files. It uses Skia for 2D rendering and supports MP4, WebM, MOV, GIF, and PNG sequence outputs. Two authoring formats exist and both compile to the same engine: **JSON**, which reaches every component and every field, and an **HTML dialect** (`rustmotion-html`), a thin transpiler for authors who find tags and `style="..."` strings more natural — at the cost of real, structural gaps (see below). + +## Output format: ask before writing anything + +This is the first decision, before any other question and before any file is written. + +1. **The user already named a format** ("write it in HTML", "give me JSON", "use rustmotion-html") → use that, skip the question. +2. **The request obviously needs something the HTML dialect cannot express** → skip the question, use JSON, and don't offer HTML at all (offering a format that can't do the job is worse than not offering a choice). It cannot express, at all: + - Any component field that is an array or object of structured data beyond `style`/`anim` — `chart` (all 12 types), `table`, `list`, `stepper`, the `timeline` *component*, `tag_cloud`, `avatar_group`, `pill_nav`, `sparkline`/`stat`/`heatmap`/`treemap`/`dot_map`'s data arrays, and the per-component `timeline` field (state-transition steps) on any of the 60 components. + - Structured CSS: `box-shadow`, `text-shadow`, `transform`, `filter`/`backdrop-filter`, `border` as an object, `clip-path`, gradients (`fill`/`background` as a gradient object), `font-family` stacks, `grid-column`/`grid-row` span objects, `transition`. Only flat scalar style properties survive inline `style="..."` — `padding`/`margin`/`border-radius` get the CSS 1–4-value shorthand, `grid-template-columns`/`-rows` accept a track list, everything else rejects a multi-token value outright. + - `audio` tracks — so `waveform`, `audio_spectrum`, and `style.audio-reactive` are dead in HTML (nothing to bind to). + - `composition`/`world` views — `world-position` parses but is inert; the HTML dialect only ever emits a flat `scenes` list (one implicit `slide` view). + - Root-level `config`/variables, named `backgrounds` templates, `version`. +3. **Otherwise** — plain layout, text, icons, images, simple flat-color shapes, basic entrance animations — ask once with `AskUserQuestion`: *"Scenario format: JSON (full component set, arrays/objects, charts, audio, world views) or HTML (tags + `style=`, quicker to hand-edit, but flat/scalar styling only and no data-viz/audio/world components)?"* Default to JSON if the answer is unclear — it is the format every rule in this file is written against, and it never has a capability gap to work around. + +If HTML is chosen, see [rules/html-dialect.md](rules/html-dialect.md) for the tag/attribute syntax, the compact `anim` DSL, and the full list of footguns (silent-`div`-on-typo, whitespace significance, the missing-colon-drops-the-declaration trap, no inline text formatting). Every other rule in this file that shows a JSON snippet still describes the right component, field, and value — translate it through that syntax reference rather than treating the two formats as needing separate design rules. ## Quick Reference @@ -40,6 +55,8 @@ rustmotion schema # Print JSON Schema ## Mental Model: Think HTML/CSS, not canvas +This is a way of *thinking about the JSON schema* — not the real `rustmotion-html` dialect covered in [rules/html-dialect.md](rules/html-dialect.md). It applies whichever format you're actually writing. + Rustmotion's JSON API is a direct superset of HTML/CSS. When composing a scene, **think "how would I write this in HTML/CSS?" first** — then translate. Do not think in terms of pixel coordinates; think in terms of flow, flex, and grid. | HTML/CSS | Rustmotion JSON | @@ -215,7 +232,8 @@ For each scene in the validated plan: Read individual rule files for detailed explanations, GOOD/BAD examples, and constraints: -- [rules/html-css-mental-model.md](rules/html-css-mental-model.md) - **CRITICAL:** Think HTML/CSS — flow layout first, absolute only for decorative/overlay elements +- [rules/html-dialect.md](rules/html-dialect.md) - **CRITICAL:** The real `rustmotion-html` tag/attribute syntax, when it can and can't express what's being asked, and the ask-the-user gate before generation starts +- [rules/html-css-mental-model.md](rules/html-css-mental-model.md) - **CRITICAL:** Think HTML/CSS — flow layout first, absolute only for decorative/overlay elements (this is a JSON *mental model*, not the HTML dialect above — see the distinction in rules/html-dialect.md) - [rules/validate-json.md](rules/validate-json.md) - Always validate generated JSON with `rustmotion validate` before presenting - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [rules/even-dimensions.md](rules/even-dimensions.md) - Use even width/height for H.264 encoding @@ -266,7 +284,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con ### Architecture (pour contribuer au code) - [rules/paint-context.md](rules/paint-context.md) - Painter trait API: paint_content(canvas, layout, props, ctx) — remplace l'ancien Widget -- [rules/module-structure.md](rules/module-structure.md) - Structure des crates: rustmotion-core (css/, engine/, traits/) + rustmotion-components (57 composants) +- [rules/module-structure.md](rules/module-structure.md) - Structure des crates: rustmotion-core (css/, engine/, traits/) + rustmotion-components (60 composants) --- @@ -733,7 +751,7 @@ Config types: `string`, `number`, `boolean`, `object`, `array`. Omitted override { "type": "fade", "duration": 0.5 } ``` -**15 types:** `fade`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `zoom_in`, `zoom_out`, `flip`, `clock_wipe`, `iris`, `slide`, `dissolve`, `corner_reveal`, `pixel_dissolve`, `none` +**17 types:** `fade`, `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `zoom_in`, `zoom_out`, `flip`, `clock_wipe`, `iris`, `slide`, `dissolve`, `corner_reveal`, `pixel_dissolve`, `camera_pan`, `chromatic_wipe`, `none` `corner_reveal` uncovers the incoming scene through a rectangle anchored at one corner: two edges stay pinned to the frame, the other two travel until it fills. @@ -770,7 +788,7 @@ Default duration: `0.5` seconds. ### Component Types -The engine has **57** component types total (`Component` enum, `crates/rustmotion-components/src/lib.rs:194-254`). The catalog below has a dedicated write-up with a JSON example for most of them; the rest are containers (`card`/`flex`, `div`, `grid`, `positioned`) covered in the "Mental Model: Think HTML/CSS" section above, plus `waveform`/`audio_spectrum` covered in [rules/audio-reactive.md](rules/audio-reactive.md). +The engine has **60** component types total (`Component` enum, `crates/rustmotion-components/src/lib.rs`). The catalog below has a dedicated write-up with a JSON example for most of them; the rest are containers (`card`/`flex`, `div`, `grid`, `positioned`) covered in the "Mental Model: Think HTML/CSS" section above, plus `waveform`/`audio_spectrum` covered in [rules/audio-reactive.md](rules/audio-reactive.md). All components are discriminated by `"type"`. Rendered in array order (first = bottom). See Rule 7. @@ -1399,7 +1417,7 @@ Speech bubble with directional arrow. } ``` -**Root fields:** `text` (required), `arrow_direction` (top/bottom/left/right), `arrow_size` (default 12), `size` +**Root fields:** `text` (required), `arrow_direction` (top/bottom/left/right), `arrow_size` (default 12). No root `size` field — fixed dimensions are set via `style.width`/`style.height`. Style: `background` (default `"#333333"`), `color` (default `"#FFFFFF"`), `border-radius` (default 8), `font-size` (default 16), `font-family` @@ -1544,7 +1562,7 @@ Device frame with image content inside. } ``` -**Root fields:** `device` (required — iphone/android/laptop/browser), `src` (required — path to image), `theme` (dark/light), `size` +**Root fields:** `device` (required — iphone/android/laptop/browser), `src` (required — path to image), `theme` (dark/light). No root `size` field — device size is set via `style.width`/`style.height`. Default sizes: iPhone 375x812, Android 360x800, Laptop 800x550, Browser 800x600 @@ -2064,7 +2082,7 @@ Text with animated gradient fill. } ``` -**Root fields:** `content` (required), `colors` (array of hex, default ["#3B82F6", "#8B5CF6"]), `angle` (90 — gradient angle in degrees), `animate_angle` (false — rotate gradient over time), `speed` (0.5 — rotations/sec when animate_angle), `size` +**Root fields:** `content` (required), `colors` (array of hex, default ["#3B82F6", "#8B5CF6"]), `angle` (90 — gradient angle in degrees), `animate_angle` (false — rotate gradient over time), `speed` (0.5 — rotations/sec when animate_angle). No root `size` field — dimensions are set via `style.width`/`style.height`. Style: `font-size`, `font-weight`, `font-family` @@ -2664,7 +2682,7 @@ Orbit creates continuous circular or elliptical motion with pseudo-3D depth simu ```bash # Render a scenario file to MP4 -rustmotion render scenario.json -o output.mp4 +rustmotion render -f scenario.json -o output.mp4 # Render from inline JSON rustmotion render --json '{ ... }' -o output.mp4 @@ -2680,22 +2698,22 @@ rustmotion validate -f scenario.json --lenient # warnings only rustmotion schema # Show scenario info -rustmotion info scenario.json +rustmotion info -f scenario.json # Render a single frame (0-indexed) as PNG -rustmotion render scenario.json -o frame.png --frame 0 +rustmotion render -f scenario.json -o frame.png --frame 0 # Render with specific codec/format -rustmotion render scenario.json -o output.webm --codec vp9 --format webm +rustmotion render -f scenario.json -o output.webm --codec vp9 --format webm # Render as GIF -rustmotion render scenario.json -o output.gif --format gif +rustmotion render -f scenario.json -o output.gif --format gif # Render as PNG sequence -rustmotion render scenario.json -o frames/ --format png-seq +rustmotion render -f scenario.json -o frames/ --format png-seq # Machine-readable output -rustmotion render scenario.json -o output.mp4 --output-format json +rustmotion render -f scenario.json -o output.mp4 --output-format json ``` --- diff --git a/crates/rustmotion/skills/rules/badge-video-sizing.md b/crates/rustmotion/skills/rules/badge-video-sizing.md index 3a6ef2f0..2dcc386b 100644 --- a/crates/rustmotion/skills/rules/badge-video-sizing.md +++ b/crates/rustmotion/skills/rules/badge-video-sizing.md @@ -22,11 +22,11 @@ "type": "badge", "text": "Premier plan", "icon": "lucide:zap", - "color": "#6366F1", "position": "absolute", "x": 270, "y": 580, "style": { + "background": "#6366F1", "font-size": 40, "z-index": 2, "box-shadow": [{ "color": "#6366F1A0", "offset-y": 0, "blur": 60 }], diff --git a/crates/rustmotion/skills/rules/dynamic-depth.md b/crates/rustmotion/skills/rules/dynamic-depth.md index eeca0370..0102b9c3 100644 --- a/crates/rustmotion/skills/rules/dynamic-depth.md +++ b/crates/rustmotion/skills/rules/dynamic-depth.md @@ -69,7 +69,11 @@ Each element gets a different `seed`. Because seeds produce different noise curv [ { "type": "shape", + "shape": "circle", + "fill": "#6366F1", "style": { + "width": 24, + "height": 24, "animation": [ { "name": "fade_in", "duration": 0.6 }, { "name": "wiggle", "property": "translate_y", "amplitude": 5, "frequency": 0.4, "seed": 7 }, @@ -88,6 +92,7 @@ Each element gets a different `seed`. Because seeds produce different noise curv }, { "type": "badge", + "text": "New", "style": { "animation": [ { "name": "scale_in", "delay": 0.3, "duration": 0.5 }, @@ -117,7 +122,7 @@ Each element gets a different `seed`. Because seeds produce different noise curv "border-radius": 28, "box-shadow": [{ "color": "#00000060", "offset-y": 40, "blur": 80 }], "animation": [ - { "name": "scale_in", "duration": 0.7, "easing": "ease_out" }, + { "name": "scale_in", "duration": 0.7 }, { "name": "float_3d", "loop": true } ] } diff --git a/crates/rustmotion/skills/rules/geometry-safety.md b/crates/rustmotion/skills/rules/geometry-safety.md index 0601fc3a..a78bf2d9 100644 --- a/crates/rustmotion/skills/rules/geometry-safety.md +++ b/crates/rustmotion/skills/rules/geometry-safety.md @@ -15,7 +15,7 @@ No textual content may bleed out of the device viewport. The renderer enforces t ## 2. Codeblock / Terminal `auto_scroll` -When you give a `codeblock` or `terminal` a fixed `size` smaller than its natural content height, the renderer scrolls the content vertically (clip + translate) so the **last revealed line stays visible**. Font size is **never** reduced. +When you give a `codeblock` or `terminal` a fixed `style.height` smaller than its natural content height, the renderer scrolls the content vertically (clip + translate) so the **last revealed line stays visible**. Font size is **never** reduced. There is no root `size` field on these components — dimensions come from `style.width`/`style.height`, like any other component. - Default: `auto_scroll: true`. - `auto_scroll: false` → validator fails with `auto_scroll_disabled_overflow` if content doesn't fit. @@ -58,7 +58,7 @@ CSS-like semantics: `visible` (default) lets children bleed; `hidden` clips at t ## What the validator catches -`rustmotion validate scenario.json` reports five geometry violation kinds: +`rustmotion validate -f scenario.json` reports five geometry violation kinds: - `viewport_overflow` — absolute bbox crosses the device edge - `unwrappable_text_overflow` — `white-space: "nowrap"`/`"pre"` but natural width > available width @@ -71,16 +71,17 @@ CSS-like semantics: `visible` (default) lets children bleed; `hidden` clips at t ## CLI usage ```bash -rustmotion validate scenario.json # human-readable -rustmotion validate scenario.json --report report.json # JSON report -rustmotion validate scenario.json --fix # safe auto-fixes -rustmotion validate scenario.json --strict-anim # per-frame check, adds animated_text_overflow -rustmotion validate scenario.json --lenient # warnings only +rustmotion validate -f scenario.json # human-readable +rustmotion validate -f scenario.json --report report.json # JSON report +rustmotion validate -f scenario.json --fix # safe auto-fixes +rustmotion validate -f scenario.json --strict-anim # per-frame check, adds animated_text_overflow +rustmotion validate -f scenario.json --lenient # warnings only ``` `--fix` rewrites the file in place: - `auto_scroll_disabled_overflow` → sets `auto_scroll: true`. Safe. - `unwrappable_text_overflow` → removes `style.white-space`, so the text falls back to the `normal` default and wraps again. Non-destructive: it only ever deletes the property that caused the violation. If you want the line to stay unbroken, widen the box or lower `font-size` by hand instead of running `--fix`. +- `content_overflows_box` on `text`/`gradient_text` only → sets `style.text-autofit: true`, shrinking the font until the content fits (down to a calibrated readability floor; if that's not enough, the violation is still reported). Every other component's `content_overflows_box` is left alone — growing the box, shrinking the font, and shortening the copy are all legitimate answers with different visual outcomes, so `--fix` doesn't pick one for you there. Position/size clamping (`viewport_overflow`) is never auto-applied — fix those by hand too. diff --git a/crates/rustmotion/skills/rules/glassmorphism.md b/crates/rustmotion/skills/rules/glassmorphism.md index c1eea24f..6c1b7e54 100644 --- a/crates/rustmotion/skills/rules/glassmorphism.md +++ b/crates/rustmotion/skills/rules/glassmorphism.md @@ -147,7 +147,7 @@ Le glassmorphisme n'a d'intérêt que s'il y a quelque chose à voir derrière. - Opacity blobs : **≥ 50% (`80` en hex)** sur fond sombre — sinon invisibles à travers le flou. - Minimum 2 blobs de couleurs différentes, positions opposées. - Amplitudes wiggle plus grandes que d'habitude (le flou masque les micro-mouvements). -- Positionner les blobs de sorte que `x >= 0` et `x + width <= viewport_width` — le validateur rejette tout débordement, même pour les décoratifs. +- Positionner les blobs de sorte que `x >= 0`, `x + width <= viewport_width`, `y >= 0` et `y + height <= viewport_height` — le validateur rejette tout débordement, même pour les décoratifs, sur les deux axes. L'exemple ci-dessus (`y: 600`, `height: 700`) suppose un canvas d'au moins 1300px de haut (portrait 1080×1920 typique) ; sur un format paysage 1080p, réduire `y` et/ou `height` en conséquence. --- diff --git a/crates/rustmotion/skills/rules/gradient-quality.md b/crates/rustmotion/skills/rules/gradient-quality.md index 6f970ea3..5136214a 100644 --- a/crates/rustmotion/skills/rules/gradient-quality.md +++ b/crates/rustmotion/skills/rules/gradient-quality.md @@ -15,10 +15,11 @@ Dark gradients are prone to color banding (visible steps instead of smooth trans | General use | Default H.264 10-bit (requires ffmpeg) | | No ffmpeg available | Built-in openh264 (8-bit, may show banding on dark gradients) | -**GOOD:** Use `gradient_type: "radial"` with at least 3 colors for smooth transitions: +**GOOD:** Use `gradient_type: "radial"` with at least 3 colors for smooth transitions. `preset` is required — the empty/omitted value is rejected rather than silently defaulting to `gradient_shift`: ```json { "background": { + "preset": "gradient_shift", "colors": ["#0f172a", "#1e1b4b", "#0f172a"], "speed": 20, "gradient_type": "radial" @@ -30,7 +31,7 @@ Or use a named template with `$ref` for reuse across scenes: ```json { "backgrounds": { - "dark_radial": { "colors": ["#0f172a", "#1e1b4b", "#0f172a"], "speed": 20, "gradient_type": "radial" } + "dark_radial": { "preset": "gradient_shift", "colors": ["#0f172a", "#1e1b4b", "#0f172a"], "speed": 20, "gradient_type": "radial" } }, "scenes": [ { "duration": 5, "background": { "$ref": "dark_radial" } } @@ -42,6 +43,7 @@ Or use a named template with `$ref` for reuse across scenes: ```json { "background": { + "preset": "gradient_shift", "colors": ["#0a0a0a", "#0b0b0b"], "gradient_type": "radial" } diff --git a/crates/rustmotion/skills/rules/html-dialect.md b/crates/rustmotion/skills/rules/html-dialect.md new file mode 100644 index 00000000..13e83c5b --- /dev/null +++ b/crates/rustmotion/skills/rules/html-dialect.md @@ -0,0 +1,98 @@ +# Rule: The `rustmotion-html` Dialect — Syntax and Its Real Limits + +`rustmotion-html` transpiles a restricted HTML+inline-CSS document straight to the same scenario JSON every other rule in this skill describes — no browser, no JS, no layout engine of its own. It is a syntax choice, not a different feature set: everything it emits still goes through the JSON schema, so a component or field HTML cannot express is not "different in HTML", it is **absent**. Read [SKILL.md](../SKILL.md)'s "Output format" section first for when to offer this format at all. + +--- + +## Document shape + +```html + + + + + +
+

Ship Faster

+

Built in Rust. No browser.

+ +
+
+
+``` + +`` attributes map to `video` (`width`, `height`, `fps`, `background`). `` maps to a `fonts` entry (`family` + `path`/`src`, plus optional `weights="400,700"` CSV). + +`` known attributes: `duration` (required), `align`/`justify`/`direction`/`gap`/`padding` (become the scene's implicit-flex `layout`, `align`/`justify` default to `center`), `background`, `effects`, `transition`/`transition-duration`/`transition-easing`, `freeze_at`, `world-position` (parses, but see the gaps below — it never affects rendering in this dialect), `animated-background`. Anything else on `` is a named-attribute error, not a silent drop. + +--- + +## Element mapping + +| Tag | Becomes | +|---|---| +| `div`, `section`, `main`, `header`, `footer`, `article` | `{"type": "div"}` | +| `p`, `span`, `h1`–`h6`, `strong`, `em`, `label` | `{"type": "text", "content": ""}` | +| `img`, `video`, `svg` | **Refused outright**, naming the real tag to use: `rm-image`, `rm-video`, `rm-svg` | +| `rm-` | `{"type": ""}` — any of the 60 component types | +| anything else unrecognized | **Silently becomes a `div`.** A typo'd tag (``, ``) does not error — `validate` sees a legal `div`, so there is nothing to catch the mistake on. The visible effect: a `div`'s children each become their own block-level component (column flex by default), where a real `h1`–`h6`/`p`/`span` would have flattened its descendants into one inline text run. Demonstrated: `Hello World` renders **"Hello" and "World" on two separate lines** — under the intended tag it would have been a single line reading "Hello World". Double-check tag spelling. | + +`