From ba89789cdf8316ffc9797d580b927e8c27560833 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 18 Jul 2026 21:18:49 +0200 Subject: [PATCH] fix(engine): honor start_at/end_at windows and wire timeline steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_at/end_at were silently ignored by the box pipeline (only Particle was gated, on a legacy path) and timeline was consumed nowhere. - BoxNode carries a PaintWindow built from as_timed(); paint_node skips the node and its subtree outside [start_at, end_at) while keeping its layout space (CSS visibility semantics — siblings don't jump) - timeline steps resolve as their animations with delay += step.at, merged after style.animation (new AnimationEffect::shift_delay; continuous effects without a delay concept are unaffected) - Caption gains flattened TimingConfig; Positioned gains timing, timeline and animation support; both join the timed/animatable dispatch (the last two components excluded from it) - skill rule timeline-sequencing.md rewritten for the new semantics --- .../rustmotion/rules/timeline-sequencing.md | 68 ++++++++---------- .../rustmotion-components/src/box_builder.rs | 51 ++++++++++++- crates/rustmotion-components/src/caption.rs | 5 +- crates/rustmotion-components/src/lib.rs | 5 +- .../rustmotion-components/src/positioned.rs | 9 ++- crates/rustmotion-core/src/engine/box_tree.rs | 20 ++++++ .../rustmotion-core/src/engine/paint_pass.rs | 11 +++ crates/rustmotion-core/src/macros.rs | 4 ++ crates/rustmotion-core/src/schema/video.rs | 23 ++++++ .../rustmotion-core/src/traits/animatable.rs | 8 ++- crates/rustmotion/src/tests.rs | 71 +++++++++++++++++++ 11 files changed, 229 insertions(+), 46 deletions(-) diff --git a/.claude/skills/rustmotion/rules/timeline-sequencing.md b/.claude/skills/rustmotion/rules/timeline-sequencing.md index 5c962a8..3337d03 100644 --- a/.claude/skills/rustmotion/rules/timeline-sequencing.md +++ b/.claude/skills/rustmotion/rules/timeline-sequencing.md @@ -1,14 +1,36 @@ # Rule: Sequential Component Reveals -## Critical: `start_at`, `end_at`, and `timeline` are NOT processed in the rendering pipeline +## Timing semantics: `start_at`/`end_at` vs `style.animation` vs `timeline` -These fields exist on component structs but are **silently ignored** for box-based components (card, div, text, icon, etc.) in the new CSS rendering pipeline. Using them produces no effect — the component is always visible regardless of `start_at`. +All three are processed by the rendering pipeline. Pick by intent: -**Only `style.animation` effects are processed.** +| Field | Effect | Use for | +|---|---|---| +| `start_at` / `end_at` | Hard visibility window `[start_at, end_at)` — the component (and its subtree) paints nothing outside the window but **keeps its layout space** (CSS `visibility` semantics: siblings don't jump) | Hard cuts: appear/disappear without animation | +| `style.animation` | Animated effects; `delay` is absolute scene time | Entrances, exits, continuous effects | +| `timeline` | `[{ "at": t, "animation": [...] }]` — each step's animations run with `delay += at` | Grouping several timed animation phases on one component | + +`start_at`/`end_at` control **visibility**, not animation timing. To delay an animation, use the animation's `delay` (or a `timeline` step's `at`). + +```json +// Hard cut: card visible only between t=2 and t=5 +{ "type": "card", "start_at": 2.0, "end_at": 5.0, "style": {}, "children": [...] } + +// Timeline: pulse at t=1, fade out at t=3 +{ + "type": "icon", "icon": "bell", + "timeline": [ + { "at": 1.0, "animation": [{ "name": "pulse", "duration": 0.6 }] }, + { "at": 3.0, "animation": [{ "name": "fade_out", "duration": 0.5 }] } + ] +} +``` + +**Combining:** a `start_at` hard cut composes with an entrance animation whose `delay` equals `start_at` (the window reveals the element exactly when the animation begins). --- -## Sequential reveals: the correct pattern +## Sequential reveals with animated handoff: the parallax pattern To make components appear sequentially (each replacing the previous with an entrance + parallax exit), use: 1. All components at the same `position: absolute` coordinates @@ -17,7 +39,7 @@ To make components appear sequentially (each replacing the previous with an entr ### Why it works -Animation preset keyframes use absolute scene time. Before the entrance `delay`, the preset's first keyframe returns `opacity: 0.0`, making the component invisible. No `start_at` needed. +Animation preset keyframes use absolute scene time. Before the entrance `delay`, the preset's first keyframe returns `opacity: 0.0`, making the component invisible. For `scale_in` with `delay: 2.0, duration: 0.5`: - t < 2.0 → opacity = 0 (first keyframe value at t=2.0 is 0.0) @@ -54,19 +76,6 @@ For `scale_in` with `delay: 2.0, duration: 0.5`: ] }, "children": [...] -}, -{ - "type": "card", - "position": "absolute", - "x": 200, "y": 400, - "style": { - "width": 1500, "padding": 40, "border-radius": 20, - "background": "#0C1A2E", "z-index": 3, - "animation": [ - { "name": "scale_in", "delay": 4.5, "duration": 0.5 } - ] - }, - "children": [...] } ``` @@ -74,6 +83,8 @@ For `scale_in` with `delay: 2.0, duration: 0.5`: **Z-index rule:** Later cards get higher z-index so they appear above the exiting card during the overlap window. +**Layout note:** exited components still occupy layout space. For flowed (non-absolute) layouts where the element must disappear *and* free its slot, absolute positioning remains the pattern — `end_at` hides pixels, not layout. + --- ## Sequential stagger (same-direction, no exit) @@ -85,24 +96,3 @@ For items entering one by one without exits, use increasing `delay` on sibling e { "type": "text", "content": "Second", "style": { "animation": [{ "name": "fade_in_up", "delay": 0.2, "duration": 0.6 }] } }, { "type": "text", "content": "Third", "style": { "animation": [{ "name": "fade_in_up", "delay": 0.4, "duration": 0.6 }] } } ``` - ---- - -## What NOT to do - -```json -// ❌ start_at is ignored — card is always visible -{ - "type": "card", - "start_at": 2.0, - "timeline": [{ "at": 0.0, "animation": [{"name": "scale_in"}] }], - "style": { "z-index": 1 } -} - -// ❌ end_at is ignored — card never disappears -{ - "type": "card", - "end_at": 3.0, - "style": {} -} -``` diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index be95267..e458b8d 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -129,6 +129,7 @@ where children: child_boxes, intrinsic: None, source_path: None, + window: None, }; BuiltScene { root, components } @@ -175,8 +176,28 @@ fn build_child<'a>( if let Some(actx) = anim { if let Some(animatable) = child.component.as_animatable() { let effects = animatable.animation_effects(); - if !effects.is_empty() { - let props = resolve_props_for_effects(effects, actx.time, actx.scene_duration); + let steps = animatable.timeline_steps(); + let props = if steps.is_empty() { + if effects.is_empty() { + None + } else { + Some(resolve_props_for_effects( + effects, + actx.time, + actx.scene_duration, + )) + } + } else { + // Timeline steps resolve as their animations shifted by the + // step's `at` (merged with the plain `style.animation` list). + let merged = merge_timeline_effects(effects, steps); + Some(resolve_props_for_effects( + &merged, + actx.time, + actx.scene_duration, + )) + }; + if let Some(props) = props { if props_has_paint_overrides(&props) { apply_animated_props(&mut css, &props); } @@ -184,6 +205,13 @@ fn build_child<'a>( } } + // Visibility window (start_at/end_at) — enforced by the paint pass. + let window = child.component.as_timed().and_then(|t| { + let (start, end) = t.timing(); + (start.is_some() || end.is_some()) + .then_some(rustmotion_core::engine::box_tree::PaintWindow { start, end }) + }); + let children_boxes = container_children(&child.component, components, next_id, anim, &path); let intrinsic = component_intrinsic(&child.component); @@ -194,7 +222,26 @@ fn build_child<'a>( children: children_boxes, intrinsic, source_path: Some(path), + window, + } +} + +/// Flatten `timeline` steps into plain animation effects: each step's +/// animations run with `delay += step.at`, appended after the component's +/// own `style.animation` list. +fn merge_timeline_effects( + effects: &[rustmotion_core::schema::AnimationEffect], + steps: &[rustmotion_core::schema::TimelineStep], +) -> Vec { + let mut merged = effects.to_vec(); + for step in steps { + for effect in &step.animation { + let mut e = effect.clone(); + e.shift_delay(step.at); + merged.push(e); + } } + merged } /// Quick gate: does this resolved `AnimatedProperties` carry any property diff --git a/crates/rustmotion-components/src/caption.rs b/crates/rustmotion-components/src/caption.rs index 2b5570e..611abdd 100644 --- a/crates/rustmotion-components/src/caption.rs +++ b/crates/rustmotion-components/src/caption.rs @@ -9,7 +9,7 @@ use rustmotion_core::engine::renderer::{ draw_text_with_fallback, emoji_typeface, font_mgr, measure_text_with_fallback, paint_from_hex, }; use rustmotion_core::schema::{CaptionStyle, CaptionWord, TimelineStep}; -use rustmotion_core::traits::{PaintCtx, Painter}; +use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct Caption { @@ -22,6 +22,8 @@ pub struct Caption { pub max_width: Option, #[serde(default)] pub style: CssStyle, + #[serde(flatten)] + pub timing: TimingConfig, #[serde(default)] pub timeline: Vec, #[serde(default)] @@ -30,6 +32,7 @@ pub struct Caption { rustmotion_core::impl_traits!(Caption { Animatable => animation, + Timed => timing, Styled => style, }); diff --git a/crates/rustmotion-components/src/lib.rs b/crates/rustmotion-components/src/lib.rs index 761c50d..4c5ba18 100644 --- a/crates/rustmotion-components/src/lib.rs +++ b/crates/rustmotion-components/src/lib.rs @@ -294,7 +294,7 @@ impl Component { Component::Grid(c) => Some(c), Component::Card(c) => Some(c), Component::Container(c) => Some(c), - Component::Positioned(_) => None, + Component::Positioned(c) => Some(c), } } @@ -353,7 +353,8 @@ impl Component { Component::Grid(c) => Some(c), Component::Card(c) => Some(c), Component::Container(c) => Some(c), - Component::Caption(_) | Component::Positioned(_) => None, + Component::Caption(c) => Some(c), + Component::Positioned(c) => Some(c), } } diff --git a/crates/rustmotion-components/src/positioned.rs b/crates/rustmotion-components/src/positioned.rs index 181d615..22b2fa7 100644 --- a/crates/rustmotion-components/src/positioned.rs +++ b/crates/rustmotion-components/src/positioned.rs @@ -4,7 +4,8 @@ use skia_safe::Canvas; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::traits::{PaintCtx, Painter}; +use rustmotion_core::schema::TimelineStep; +use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; use crate::ChildComponent; @@ -16,9 +17,15 @@ pub struct Positioned { pub children: Vec, #[serde(default)] pub style: CssStyle, + #[serde(flatten)] + pub timing: TimingConfig, + #[serde(default)] + pub timeline: Vec, } rustmotion_core::impl_traits!(Positioned { + Animatable => animation, + Timed => timing, Styled => style, }); diff --git a/crates/rustmotion-core/src/engine/box_tree.rs b/crates/rustmotion-core/src/engine/box_tree.rs index ee8f434..4ea9564 100644 --- a/crates/rustmotion-core/src/engine/box_tree.rs +++ b/crates/rustmotion-core/src/engine/box_tree.rs @@ -24,6 +24,24 @@ pub struct BoxNode { /// JSON path of this node relative to its scene's `children` array, e.g. /// "/children/2/children/0". `None` for synthetic nodes (the scene root). pub source_path: Option, + /// Visibility window from the component's `start_at`/`end_at` (seconds, + /// scene-relative). Outside the window the node and its subtree are not + /// painted but still occupy layout space (CSS `visibility` semantics — + /// siblings must not jump when the component appears). + pub window: Option, +} + +/// Half-open visibility window `[start, end)`; `None` bounds are unbounded. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PaintWindow { + pub start: Option, + pub end: Option, +} + +impl PaintWindow { + pub fn contains(&self, t: f64) -> bool { + self.start.is_none_or(|s| t >= s) && self.end.is_none_or(|e| t < e) + } } impl BoxNode { @@ -35,6 +53,7 @@ impl BoxNode { children, intrinsic: None, source_path: None, + window: None, } } @@ -46,6 +65,7 @@ impl BoxNode { children: Vec::new(), intrinsic: Some(intrinsic), source_path: None, + window: None, } } diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index d2064f4..b8e7f26 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -155,6 +155,13 @@ struct PaintContext<'a> { } fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) { + // Visibility window (start_at/end_at): the node keeps its layout space + // but paints nothing — subtree included — outside the window. + if let Some(window) = &node.window { + if !window.contains(ctx.frame.time) { + return; + } + } let Some(box_layout) = ctx.layout.get(node.id) else { return; }; @@ -923,6 +930,7 @@ mod hit_tests { children: vec![], intrinsic: None, source_path: None, + window: None, }; let mut root = BoxNode { id: 0, @@ -937,6 +945,7 @@ mod hit_tests { children: vec![leaf], intrinsic: None, source_path: None, + window: None, }; root.assign_ids(0); @@ -982,6 +991,7 @@ mod hit_tests { children: vec![], intrinsic: None, source_path: None, + window: None, }; let mut root = BoxNode { id: 0, @@ -996,6 +1006,7 @@ mod hit_tests { children: vec![leaf], intrinsic: None, source_path: None, + window: None, }; root.assign_ids(0); diff --git a/crates/rustmotion-core/src/macros.rs b/crates/rustmotion-core/src/macros.rs index b5bc6f9..d8a86cc 100644 --- a/crates/rustmotion-core/src/macros.rs +++ b/crates/rustmotion-core/src/macros.rs @@ -24,6 +24,10 @@ macro_rules! impl_traits { fn animation_effects(&self) -> &[$crate::schema::AnimationEffect] { &self.style.animation } + + fn timeline_steps(&self) -> &[$crate::schema::TimelineStep] { + &self.timeline + } } }; diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index c6b0319..3b2da34 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -82,6 +82,29 @@ pub enum AnimationEffect { } impl AnimationEffect { + /// Shift the effect's start delay by `by` seconds. Used by `timeline` + /// steps, whose animations run relative to the step's `at`. Continuous + /// effects without a delay concept (glow, wiggle, orbit, motion blur) + /// are unaffected. + pub fn shift_delay(&mut self, by: f64) { + use AnimationEffect::*; + match self { + FadeIn(t) | FadeInUp(t) | FadeInDown(t) | FadeInLeft(t) | FadeInRight(t) + | SlideInLeft(t) | SlideInRight(t) | SlideInUp(t) | SlideInDown(t) | ScaleIn(t) + | BounceIn(t) | BlurIn(t) | RotateIn(t) | ElasticIn(t) | FadeOut(t) | FadeOutUp(t) + | FadeOutDown(t) | SlideOutLeft(t) | SlideOutRight(t) | SlideOutUp(t) + | SlideOutDown(t) | ScaleOut(t) | BounceOut(t) | BlurOut(t) | RotateOut(t) + | Pulse(t) | Float(t) | Shake(t) | Spin(t) | FlipInX(t) | FlipInY(t) | FlipOutX(t) + | FlipOutY(t) | DrawIn(t) | StrokeReveal(t) | Typewriter(t) | WipeLeft(t) + | WipeRight(t) | Float3d(t) => t.delay += by, + TiltIn(c) => c.delay += by, + CharScaleIn(c) | CharFadeIn(c) | CharWave(c) | CharBounce(c) | CharRotateIn(c) + | CharSlideUp(c) => c.delay += by, + Keyframes(c) => c.delay += by, + Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) => {} + } + } + /// If this is a preset variant, return the corresponding AnimationPreset and timing. pub fn as_preset(&self) -> Option<(AnimationPreset, &AnimationTiming)> { match self { diff --git a/crates/rustmotion-core/src/traits/animatable.rs b/crates/rustmotion-core/src/traits/animatable.rs index 45995d1..65d25f8 100644 --- a/crates/rustmotion-core/src/traits/animatable.rs +++ b/crates/rustmotion-core/src/traits/animatable.rs @@ -1,6 +1,12 @@ -use crate::schema::AnimationEffect; +use crate::schema::{AnimationEffect, TimelineStep}; /// Trait for components that support animation. pub trait Animatable { fn animation_effects(&self) -> &[AnimationEffect]; + + /// Timed animation steps (`timeline` field): each step's animations are + /// resolved with `delay += step.at`. Empty by default. + fn timeline_steps(&self) -> &[TimelineStep] { + &[] + } } diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index d64053c..1bf735d 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -538,6 +538,77 @@ mod component_smoke { ); } + /// Build a one-child scene with a red 100x80 rect carrying `extra` fields + /// merged into the component JSON, absolutely positioned at (60, 40). + fn red_rect_scene(extra: serde_json::Value) -> Vec { + let mut json = serde_json::json!({ + "type": "shape", + "shape": "rect", + "fill": "#ff3366", + "style": { "width": "100px", "height": "80px" } + }); + for (k, v) in extra.as_object().unwrap() { + json[k] = v.clone(); + } + let component: Component = serde_json::from_value(json).expect("deserialize"); + vec![crate::components::ChildComponent { + component, + position: Some(crate::components::PositionMode::Absolute { x: 60.0, y: 40.0 }), + x: None, + y: None, + z_index: None, + }] + } + + fn red_sum(buf: &[u8]) -> u64 { + buf.chunks_exact(4).map(|p| p[0] as u64).sum() + } + + #[test] + fn start_at_hides_component_before_its_window() { + // start_at gates *visibility* in the paint pass: nothing painted + // before the window opens, normal paint after. It was silently + // ignored by the box pipeline before this test existed. + let scene = red_rect_scene(serde_json::json!({ "start_at": 2.0 })); + let before = render_new_at(&scene, 400, 300, 1.0, 4.0); + let after = render_new_at(&scene, 400, 300, 3.0, 4.0); + assert_eq!( + red_sum(&before), + 0, + "component painted before its start_at window" + ); + assert!(red_sum(&after) > 0, "component absent after start_at"); + } + + #[test] + fn end_at_hides_component_after_its_window() { + let scene = red_rect_scene(serde_json::json!({ "end_at": 2.0 })); + let before = render_new_at(&scene, 400, 300, 1.0, 4.0); + let after = render_new_at(&scene, 400, 300, 3.0, 4.0); + assert!(red_sum(&before) > 0, "component absent before end_at"); + assert_eq!( + red_sum(&after), + 0, + "component painted after its end_at window" + ); + } + + #[test] + fn timeline_steps_trigger_delayed_animations() { + // A timeline step resolves as its animations with `delay += at`: + // fade_in(1s) at t=2 must be ~5% opaque at t=2.05 and ~95% at t=2.95. + let scene = red_rect_scene(serde_json::json!({ + "timeline": [{ "at": 2.0, "animation": [{ "name": "fade_in", "duration": 1.0 }] }] + })); + let early = render_new_at(&scene, 400, 300, 2.05, 4.0); + let late = render_new_at(&scene, 400, 300, 2.95, 4.0); + let (early_red, late_red) = (red_sum(&early), red_sum(&late)); + assert!( + late_red > early_red * 3, + "timeline fade_in did not amplify red over its window (early={early_red}, late={late_red})" + ); + } + /// X centroid of lit pixels, weighted by red intensity. /// Returns None when no pixels are lit. fn red_centroid_x(buf: &[u8], width: u32) -> Option {