diff --git a/.claude/skills/rustmotion/rules/timeline-sequencing.md b/.claude/skills/rustmotion/rules/timeline-sequencing.md index 3337d03..350e03c 100644 --- a/.claude/skills/rustmotion/rules/timeline-sequencing.md +++ b/.claude/skills/rustmotion/rules/timeline-sequencing.md @@ -9,6 +9,16 @@ All three are processed by the rendering pipeline. Pick by intent: | `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 | +| `timeline` + `style` states | `[{ "at": t, "style": {...} }]` — the style state applies from `at` onwards (box-model properties snap); with `style.transition` on the component, `opacity` (all components) and `color` (text/counter) interpolate smoothly | State changes: "turns red at 2s", "dims to 20% at 3s" | + +```json +// Text turns blue at t=1 over 0.5s; background snaps at the same instant +{ + "type": "text", "content": "Status", + "timeline": [{ "at": 1.0, "style": { "color": "#3b82f6", "background": "#0f172a" } }], + "style": { "color": "#ef4444", "transition": { "duration": 0.5, "easing": "ease_in_out" } } +} +``` `start_at`/`end_at` control **visibility**, not animation timing. To delay an animation, use the animation's `delay` (or a `timeline` step's `at`). diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index dff11e5..9f9d0b6 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -187,21 +187,30 @@ fn build_child<'a>( css.z_index = Some(z); } + // Timeline style states: merge every state whose (at + stagger) <= t + // into the box CSS. Opacity is excluded when a `transition` smooths it + // (the synthesized keyframes then own its whole history). States affect + // box-model properties (layout, background, border, opacity, transform, + // filter); painter-internal properties like text color flow through the + // keyframes path below instead. + if let Some(animatable) = child.component.as_animatable() { + let steps = animatable.timeline_steps(); + if steps.iter().any(|s| s.style.is_some()) { + let t = anim.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); + } + } + // Resolve animations and apply transform/opacity/filter overrides on the // box's CSS. Only paint-time properties (transform, opacity, filter, // perspective) flow into CSS — internal animations like draw_progress or // char_animation remain on the `AnimatedProperties` legacy path. if let Some(actx) = anim { - if let Some(animatable) = child.component.as_animatable() { - if let Some(effects) = effective_effects( - animatable.animation_effects(), - animatable.timeline_steps(), - stagger_delay, - ) { - let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration); - if props_has_paint_overrides(&props) { - apply_animated_props(&mut css, &props); - } + if let Some(effects) = effective_effects(&child.component, stagger_delay) { + let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration); + if props_has_paint_overrides(&props) { + apply_animated_props(&mut css, &props); } } } @@ -242,15 +251,23 @@ fn build_child<'a>( } /// The full effect list for a component at paint time: `style.animation`, -/// plus `timeline` steps shifted by their `at`, 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. -pub fn effective_effects<'a>( - effects: &'a [rustmotion_core::schema::AnimationEffect], - steps: &[rustmotion_core::schema::TimelineStep], +/// 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. +pub fn effective_effects( + component: &Component, extra_delay: f64, -) -> Option> { - if steps.is_empty() && extra_delay == 0.0 { +) -> Option> { + let animatable = component.as_animatable()?; + let effects = animatable.animation_effects(); + let steps = animatable.timeline_steps(); + // `color` keyframes drive AnimatedProperties.color, consumed by the + // text-like painters only. + let smooth_color = matches!(component, Component::Text(_) | Component::Counter(_)); + let synthesized = + transition_keyframes(component.as_styled().style_config(), steps, smooth_color); + if steps.is_empty() && synthesized.is_empty() && extra_delay == 0.0 { return (!effects.is_empty()).then_some(std::borrow::Cow::Borrowed(effects)); } let mut merged = effects.to_vec(); @@ -261,6 +278,7 @@ pub fn effective_effects<'a>( merged.push(e); } } + merged.extend(synthesized); if extra_delay != 0.0 { for e in &mut merged { e.shift_delay(extra_delay); @@ -269,6 +287,169 @@ pub fn effective_effects<'a>( (!merged.is_empty()).then_some(std::borrow::Cow::Owned(merged)) } +/// Merge every timeline style state whose `at <= t` into `css`, in `at` +/// order. Serialize-merge keeps this schema-complete; `null`s and the empty +/// `animation` array never erase existing values. `skip_opacity` leaves +/// opacity to the synthesized transition keyframes. +pub(crate) fn apply_style_states( + css: &mut CssStyle, + steps: &[rustmotion_core::schema::TimelineStep], + t: f64, + skip_opacity: bool, +) { + let mut due: Vec<&rustmotion_core::schema::TimelineStep> = steps + .iter() + .filter(|s| s.style.is_some() && s.at <= t) + .collect(); + if due.is_empty() { + return; + } + due.sort_by(|a, b| a.at.total_cmp(&b.at)); + + let Ok(serde_json::Value::Object(mut base)) = serde_json::to_value(&*css) else { + return; + }; + for step in due { + let Some(style) = step.style.as_deref() else { + continue; + }; + let Ok(serde_json::Value::Object(state)) = serde_json::to_value(style) else { + continue; + }; + for (k, v) in state { + if v.is_null() { + continue; + } + if k == "animation" && v.as_array().is_some_and(|a| a.is_empty()) { + continue; + } + if skip_opacity && k == "opacity" { + continue; + } + base.insert(k, v); + } + } + if let Ok(merged) = serde_json::from_value::(serde_json::Value::Object(base)) { + *css = merged; + } +} + +/// Synthesize `Keyframes` effects smoothing timeline style-state changes per +/// the component's `style.transition`. Supported: `opacity` (as ratios of the +/// base opacity — `apply_animated_props` multiplies) and, on text-like +/// components, `color` (absolute, via `AnimatedProperties.color`). Color +/// states without a transition still synthesize a near-instant ramp because +/// text painters only see color through this path. +pub(crate) fn transition_keyframes( + base: &CssStyle, + steps: &[rustmotion_core::schema::TimelineStep], + smooth_color: bool, +) -> Vec { + use rustmotion_core::schema::{ + Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig, + }; + + let has_states = steps.iter().any(|s| s.style.is_some()); + if !has_states { + return Vec::new(); + } + let (duration, easing) = match base.transition.as_ref() { + Some(tr) if tr.duration() > 0.0 => (tr.duration(), tr.easing()), + // Color snaps still need the keyframes path (see doc above); a 1ms + // ramp is visually a hard cut. + _ => (0.001, rustmotion_core::schema::EasingType::Linear), + }; + let smooth_opacity = base.transition.is_some(); + + let mut sorted: Vec<&rustmotion_core::schema::TimelineStep> = + steps.iter().filter(|s| s.style.is_some()).collect(); + sorted.sort_by(|a, b| a.at.total_cmp(&b.at)); + + let base_opacity = base.opacity.unwrap_or(1.0) as f64; + let mut opacity_kfs: Vec = Vec::new(); + let mut color_kfs: Vec = Vec::new(); + let mut prev_opacity = base_opacity; + let mut prev_color = base.color.as_ref().map(|c| c.to_css_string()); + + let kf_num = |time: f64, v: f64| Keyframe { + time, + value: KeyframeValue::Number(v), + easing: None, + }; + let kf_color = |time: f64, c: String| Keyframe { + time, + value: KeyframeValue::Color(c), + easing: None, + }; + // Keep keyframe times strictly ascending even when states overlap a + // still-running transition (the resolver walks ordered segments). + let push_pair = |kfs: &mut Vec, at: f64, from: Keyframe, to: Keyframe| { + let floor = kfs.last().map(|k| k.time + 1e-6).unwrap_or(f64::MIN); + let start = at.max(floor); + let mut from = from; + let mut to = to; + from.time = start; + to.time = to.time.max(start + 1e-6); + kfs.push(from); + kfs.push(to); + }; + + for step in sorted { + let style = step.style.as_deref().unwrap(); + if smooth_opacity && base_opacity > 1e-6 { + if let Some(o) = style.opacity { + let target = o as f64; + if (target - prev_opacity).abs() > 1e-6 { + push_pair( + &mut opacity_kfs, + step.at, + kf_num(step.at, prev_opacity / base_opacity), + kf_num(step.at + duration, target / base_opacity), + ); + prev_opacity = target; + } + } + } + if smooth_color { + if let Some(c) = style.color.as_ref() { + let target = c.to_css_string(); + if prev_color.as_ref() != Some(&target) { + if let Some(from) = prev_color.clone() { + push_pair( + &mut color_kfs, + step.at, + kf_color(step.at, from), + kf_color(step.at + duration, target.clone()), + ); + } + prev_color = Some(target); + } + } + } + } + + let mut out = Vec::new(); + let mut push_effect = |property: &str, keyframes: Vec| { + if keyframes.is_empty() { + return; + } + out.push(AnimationEffect::Keyframes(KeyframesConfig { + keyframes: vec![Animation { + property: property.to_string(), + keyframes, + easing: easing.clone(), + spring: None, + }], + delay: 0.0, + duration: 0.0, + repeat: false, + })); + }; + push_effect("opacity", opacity_kfs); + push_effect("color", color_kfs); + out +} + /// Quick gate: does this resolved `AnimatedProperties` carry any property /// that we know how to translate to CSS? Avoids allocating a transform Vec /// when there's nothing to apply. diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs index aa06f27..936fdf1 100644 --- a/crates/rustmotion-components/src/counter.rs +++ b/crates/rustmotion-components/src/counter.rs @@ -59,12 +59,18 @@ impl Counter { layout_width: f32, time: f64, scene_duration: f64, + props: &AnimatedProperties, ctx: &PaintCtx, ) -> Result<()> { use rustmotion_core::engine::animator::ease; let font_size = self.style.font_size_px_or(48.0); - let color = self.style.color_str_or("#FFFFFF"); + // Animated color (timeline style-state transitions) overrides the + // static style color. + let color = props + .color + .as_deref() + .unwrap_or_else(|| self.style.color_str_or("#FFFFFF")); let font_family = self.style.font_family_or("Inter"); let font_weight = match &self.style.font_weight { Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => { @@ -244,9 +250,16 @@ impl Painter for Counter { &self, canvas: &Canvas, layout: &BoxLayout, - _props: &AnimatedProperties, + props: &AnimatedProperties, ctx: &PaintCtx, ) { - let _ = self.paint(canvas, layout.width, ctx.time, ctx.scene_duration, ctx); + let _ = self.paint( + canvas, + layout.width, + ctx.time, + ctx.scene_duration, + props, + ctx, + ); } } diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index f3bb5a3..2f78341 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -87,17 +87,8 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { .get(*node_id as usize) .copied() .unwrap_or(0.0); - let props = match child.component.as_animatable() { - Some(a) => match crate::box_builder::effective_effects( - a.animation_effects(), - a.timeline_steps(), - stagger_delay, - ) { - Some(effects) => { - resolve_props_for_effects(&effects, frame.time, frame.scene_duration) - } - None => AnimatedProperties::default(), - }, + let props = match crate::box_builder::effective_effects(&child.component, stagger_delay) { + Some(effects) => resolve_props_for_effects(&effects, frame.time, frame.scene_duration), None => AnimatedProperties::default(), }; if props.opacity <= 0.0 { diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index af2bf8e..b0f96be 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -318,7 +318,12 @@ impl Text { ctx: &PaintCtx, ) -> Result<()> { let font_size = self.style.font_size_px_or(48.0); - let color = self.style.color_str_or("#FFFFFF"); + // Animated color (timeline style-state transitions) overrides the + // static style color. + let color = props + .color + .as_deref() + .unwrap_or_else(|| self.style.color_str_or("#FFFFFF")); let font_family = self.style.font_family_or("Inter"); let font_weight = match &self.style.font_weight { Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => { diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index d02e3e6..5c59e38 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -101,6 +101,43 @@ pub struct CssStyle { // ---- Animation ---- #[serde(default, deserialize_with = "deserialize_animation_effects")] pub animation: Vec, + /// Smoothing for `timeline` style-state changes. Supported properties: + /// `opacity`, and `color` on text/counter; everything else snaps at the + /// step's `at`. + pub transition: Option, +} + +/// `transition` config: bare number = duration in seconds with the default +/// easing, or a `{ duration, easing }` object. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum StyleTransition { + Duration(f64), + Config { + duration: f64, + #[serde(default = "default_transition_easing")] + easing: crate::schema::EasingType, + }, +} + +fn default_transition_easing() -> crate::schema::EasingType { + crate::schema::EasingType::EaseInOut +} + +impl StyleTransition { + pub fn duration(&self) -> f64 { + match self { + StyleTransition::Duration(d) => *d, + StyleTransition::Config { duration, .. } => *duration, + } + } + + pub fn easing(&self) -> crate::schema::EasingType { + match self { + StyleTransition::Duration(_) => default_transition_easing(), + StyleTransition::Config { easing, .. } => easing.clone(), + } + } } // ---- Painter convenience accessors ---- diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index ee00b04..189b29d 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -105,8 +105,24 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { result.orbits.push(config); } AnimationEffect::Keyframes(config) => { - for kf in &config.keyframes { - result.keyframes.push(kf); + if config.delay.abs() > 1e-9 { + // Keyframe times are absolute scene seconds; the + // config-level delay shifts them (it used to be + // silently ignored, breaking timeline/stagger shifts + // on keyframes effects). + result + .owned_keyframes + .extend(config.keyframes.iter().map(|anim| { + let mut a = anim.clone(); + for kf in &mut a.keyframes { + kf.time += config.delay; + } + a + })); + } else { + for kf in &config.keyframes { + result.keyframes.push(kf); + } } } AnimationEffect::TiltIn(config) => { diff --git a/crates/rustmotion-core/src/schema/style.rs b/crates/rustmotion-core/src/schema/style.rs index 62a92a8..c854265 100644 --- a/crates/rustmotion-core/src/schema/style.rs +++ b/crates/rustmotion-core/src/schema/style.rs @@ -110,6 +110,11 @@ pub struct TimelineStep { /// Animation effects to apply during this step. #[serde(default, deserialize_with = "deserialize_animation_effects")] pub animation: Vec, + /// Style state applied from this step's `at` onwards. Properties snap at + /// `at`, except the ones the component's `style.transition` smooths + /// (opacity; color on text/counter). + #[serde(default)] + pub style: Option>, } /// Font weight — named ("normal"/"bold") or numeric (100-900) diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 368d176..92d290e 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -725,6 +725,116 @@ mod component_smoke { ); } + #[test] + fn style_state_snaps_without_transition() { + // A timeline step carrying a `style` state hard-cuts at `at` when no + // transition is configured: full red before, 20% right after. + let scene = red_rect_scene(serde_json::json!({ + "timeline": [{ "at": 1.0, "style": { "opacity": 0.2 } }] + })); + let before = red_sum(&render_new_at(&scene, 400, 300, 0.5, 4.0)); + let after = red_sum(&render_new_at(&scene, 400, 300, 1.5, 4.0)); + assert!(before > 0, "rect must be visible before the state"); + let ratio = after as f64 / before as f64; + assert!( + (ratio - 0.2).abs() < 0.08, + "state must snap to opacity 0.2 (ratio={ratio:.3})" + ); + } + + #[test] + fn style_state_smooths_with_transition() { + // Same state change with `transition: {duration: 1, easing: linear}`: + // untouched at t=0.5, halfway (~0.6) at t=1.5, settled (0.2) at t=2.5. + let scene = red_rect_scene(serde_json::json!({ + "timeline": [{ "at": 1.0, "style": { "opacity": 0.2 } }], + "style": { + "width": "100px", + "height": "80px", + "transition": { "duration": 1.0, "easing": "linear" } + } + })); + let full = red_sum(&render_new_at(&scene, 400, 300, 0.5, 4.0)) as f64; + let mid = red_sum(&render_new_at(&scene, 400, 300, 1.5, 4.0)) as f64; + let settled = red_sum(&render_new_at(&scene, 400, 300, 2.5, 4.0)) as f64; + assert!(full > 0.0); + let mid_ratio = mid / full; + let settled_ratio = settled / full; + assert!( + (mid_ratio - 0.6).abs() < 0.1, + "mid-transition must sit near opacity 0.6 (ratio={mid_ratio:.3})" + ); + assert!( + (settled_ratio - 0.2).abs() < 0.08, + "transition must settle at opacity 0.2 (ratio={settled_ratio:.3})" + ); + } + + #[test] + fn color_state_transitions_smoothly_on_text() { + // Text color red → blue at t=1 with a 1s transition: pure red before, + // both channels mid-way, pure blue after. + let json = serde_json::json!({ + "type": "text", + "content": "COLOR", + "timeline": [{ "at": 1.0, "style": { "color": "#0000ff" } }], + "style": { + "font-size": "72px", + "color": "#ff0000", + "transition": { "duration": 1.0, "easing": "linear" } + } + }); + let component: Component = serde_json::from_value(json).expect("deserialize"); + let child = crate::components::ChildComponent { + component, + position: Some(crate::components::PositionMode::Absolute { x: 40.0, y: 40.0 }), + x: None, + y: None, + z_index: None, + }; + let scene = vec![child]; + let blue_sum = |buf: &[u8]| -> u64 { buf.chunks_exact(4).map(|p| p[2] as u64).sum() }; + let before = render_new_at(&scene, 400, 300, 0.5, 4.0); + let mid = render_new_at(&scene, 400, 300, 1.5, 4.0); + let after = render_new_at(&scene, 400, 300, 2.5, 4.0); + assert!(red_sum(&before) > 500 && blue_sum(&before) < red_sum(&before) / 10); + assert!( + red_sum(&mid) > 500 && blue_sum(&mid) > 500, + "mid-transition must blend red and blue (r={}, b={})", + red_sum(&mid), + blue_sum(&mid) + ); + assert!(blue_sum(&after) > 500 && red_sum(&after) < blue_sum(&after) / 10); + } + + #[test] + fn keyframes_effect_honors_its_delay() { + // KeyframesConfig.delay was silently ignored by the resolver: an + // opacity ramp 0→1 over 1s with delay 2 must keep the rect invisible + // at t=1 and fully visible at t=3.5. + let scene = red_rect_scene(serde_json::json!({ + "style": { + "width": "100px", + "height": "80px", + "animation": [{ + "name": "keyframes", + "delay": 2.0, + "keyframes": [{ + "property": "opacity", + "keyframes": [ + { "time": 0.0, "value": 0.0 }, + { "time": 1.0, "value": 1.0 } + ] + }] + }] + } + })); + let early = red_sum(&render_new_at(&scene, 400, 300, 1.0, 4.0)); + let late = red_sum(&render_new_at(&scene, 400, 300, 3.5, 4.0)); + assert_eq!(early, 0, "keyframes must not start before their delay"); + assert!(late > 0, "keyframes must complete after delay + ramp"); + } + #[test] fn timeline_steps_trigger_delayed_animations() { // A timeline step resolves as its animations with `delay += at`: