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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 29 additions & 39 deletions .claude/skills/rustmotion/rules/timeline-sequencing.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -54,26 +76,15 @@ 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": [...]
}
```

**Timing design:** Card N's `slide_out_up.delay` = Card N+1's `scale_in.delay`. They animate simultaneously for a smooth parallax handoff.

**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)
Expand All @@ -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": {}
}
```
51 changes: 49 additions & 2 deletions crates/rustmotion-components/src/box_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ where
children: child_boxes,
intrinsic: None,
source_path: None,
window: None,
};

BuiltScene { root, components }
Expand Down Expand Up @@ -175,15 +176,42 @@ 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);
}
}
}
}

// 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);

Expand All @@ -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<rustmotion_core::schema::AnimationEffect> {
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
Expand Down
5 changes: 4 additions & 1 deletion crates/rustmotion-components/src/caption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -22,6 +22,8 @@ pub struct Caption {
pub max_width: Option<f32>,
#[serde(default)]
pub style: CssStyle,
#[serde(flatten)]
pub timing: TimingConfig,
#[serde(default)]
pub timeline: Vec<TimelineStep>,
#[serde(default)]
Expand All @@ -30,6 +32,7 @@ pub struct Caption {

rustmotion_core::impl_traits!(Caption {
Animatable => animation,
Timed => timing,
Styled => style,
});

Expand Down
5 changes: 3 additions & 2 deletions crates/rustmotion-components/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

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

Expand Down
9 changes: 8 additions & 1 deletion crates/rustmotion-components/src/positioned.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -16,9 +17,15 @@ pub struct Positioned {
pub children: Vec<ChildComponent>,
#[serde(default)]
pub style: CssStyle,
#[serde(flatten)]
pub timing: TimingConfig,
#[serde(default)]
pub timeline: Vec<TimelineStep>,
}

rustmotion_core::impl_traits!(Positioned {
Animatable => animation,
Timed => timing,
Styled => style,
});

Expand Down
20 changes: 20 additions & 0 deletions crates/rustmotion-core/src/engine/box_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<PaintWindow>,
}

/// Half-open visibility window `[start, end)`; `None` bounds are unbounded.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaintWindow {
pub start: Option<f64>,
pub end: Option<f64>,
}

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 {
Expand All @@ -35,6 +53,7 @@ impl BoxNode {
children,
intrinsic: None,
source_path: None,
window: None,
}
}

Expand All @@ -46,6 +65,7 @@ impl BoxNode {
children: Vec::new(),
intrinsic: Some(intrinsic),
source_path: None,
window: None,
}
}

Expand Down
11 changes: 11 additions & 0 deletions crates/rustmotion-core/src/engine/paint_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -923,6 +930,7 @@ mod hit_tests {
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let mut root = BoxNode {
id: 0,
Expand All @@ -937,6 +945,7 @@ mod hit_tests {
children: vec![leaf],
intrinsic: None,
source_path: None,
window: None,
};
root.assign_ids(0);

Expand Down Expand Up @@ -982,6 +991,7 @@ mod hit_tests {
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let mut root = BoxNode {
id: 0,
Expand All @@ -996,6 +1006,7 @@ mod hit_tests {
children: vec![leaf],
intrinsic: None,
source_path: None,
window: None,
};
root.assign_ids(0);

Expand Down
4 changes: 4 additions & 0 deletions crates/rustmotion-core/src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
};

Expand Down
23 changes: 23 additions & 0 deletions crates/rustmotion-core/src/schema/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion crates/rustmotion-core/src/traits/animatable.rs
Original file line number Diff line number Diff line change
@@ -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] {
&[]
}
}
Loading
Loading