diff --git a/.claude/skills/rustmotion/rules/time-remapping.md b/.claude/skills/rustmotion/rules/time-remapping.md new file mode 100644 index 0000000..7737a17 --- /dev/null +++ b/.claude/skills/rustmotion/rules/time-remapping.md @@ -0,0 +1,55 @@ +# Rule: Time Remapping on Containers (time_scale / time_offset) + +Containers (`flex`, `card`, `grid`, `container`/`div`, `positioned`) accept two +optional fields that remap time for their **entire subtree**: + +``` +t_local = (t_global - time_offset) * time_scale +``` + +- `time_scale` (default `1.0`) — playback speed of the subtree. `0.5` = children + run at half speed (slow motion); `2.0` = double speed. **Must be > 0** — + `rustmotion validate` rejects `0` and negatives. +- `time_offset` (default `0.0`) — seconds before the subtree's timeline starts. + `2.0` = the subtree behaves as if the scene started 2 s later. + +Everything inside follows the remap: animation presets, keyframes, timeline +steps, `start_at`/`end_at` windows, stagger, motion blur ghosts, and internal +animations (counter progress, `draw_in`, terminal typewriter…). + +**Slow-motion example** — the card's children fade in at half speed: + +```json +{ + "type": "card", + "time_scale": 0.5, + "children": [ + { "type": "text", "content": "Slow entrance", + "style": { "animation": [{ "name": "fade_in", "duration": 1.0 }] } } + ] +} +``` + +The fade lasts 1 s of *local* time = 2 s of real scene time. Budget the scene +duration accordingly: a child animation ending at `t_local` finishes at +`t_global = t_local / time_scale + time_offset`. + +**Cascade** — nested remapped containers compose: a `time_scale: 0.5` container +inside another `time_scale: 0.5` container runs its subtree at 0.25× speed. +Offsets compose too (each `time_offset` is expressed in its parent's already +remapped time). + +**start_at / end_at** — expressed in the subtree's local time. A child with +`start_at: 1` inside a `time_scale: 0.5` container appears at 2 s of real time. + +**scene_duration stays global** — the physical scene window is not remapped. +Duration-relative behaviors (e.g. a counter spanning the whole scene) keep +their real-time span; only the elapsed-time input is remapped. + +**HTML dialect** — use the snake_case attribute name: + +```html + +``` + +The kebab-case form `time-scale` does NOT map to the field and is ignored. diff --git a/crates/rustmotion-cli/src/commands/validate_schema.rs b/crates/rustmotion-cli/src/commands/validate_schema.rs index df5091a..08e2527 100644 --- a/crates/rustmotion-cli/src/commands/validate_schema.rs +++ b/crates/rustmotion-cli/src/commands/validate_schema.rs @@ -85,6 +85,20 @@ fn validate_children( } } + // Container time remapping: time_scale must be strictly positive + // (0 would freeze the subtree, negative would run it backwards — + // neither is supported; the builder clamps defensively but the + // author must be told). + if let Some(scale) = container_time_scale(&child.component) { + if scale <= 0.0 { + errors.push(format!( + "{}: time_scale must be > 0 (got {}). Use a small positive value \ + (e.g. 0.1) to slow the subtree down.", + p, scale + )); + } + } + // Animation completion budget check: ensure entrance animations finish within the scene. if let Some(anim) = child.component.as_animatable() { let start_at = child @@ -202,6 +216,18 @@ fn validate_children( } } +/// The `time_scale` declared on a container component, if any. +fn container_time_scale(component: &Component) -> Option { + match component { + Component::Card(c) => c.time_scale, + Component::Flex(c) => c.time_scale, + Component::Grid(c) => c.time_scale, + Component::Container(c) => c.time_scale, + Component::Positioned(c) => c.time_scale, + _ => None, + } +} + /// Returns (delay, duration) for animations that have a completion budget, /// or None for exit presets, looped animations, and non-timing effects. fn entrance_budget(effect: &AnimationEffect) -> Option<(f64, f64)> { @@ -348,4 +374,36 @@ mod style_warning_tests { "missing text-overflow warning: {warnings:?}" ); } + + #[test] + fn time_scale_zero_is_an_error() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "flex", + "time_scale": 0.0, + "children": [{ "type": "text", "content": "hi" }] + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors.iter().any(|e| e.contains("time_scale must be > 0")), + "missing time_scale error: {errors:?}" + ); + } + + #[test] + fn positive_time_scale_is_accepted() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "flex", + "time_scale": 0.5, + "time_offset": 1.0, + "children": [{ "type": "text", "content": "hi" }] + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + } } diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index ca92d06..69d267f 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -53,6 +53,11 @@ pub struct BuiltScene<'a> { /// dispatcher so internal animations shift by the same amount as the /// CSS overrides resolved at build time. pub stagger_delays: Vec, + /// Per-node affine time remap accumulated from ancestor containers' + /// `time_scale`/`time_offset`. Entry `i` is `(scale, shift)` where + /// `t_local = scale * t_global + shift`. Default `(1.0, 0.0)` = identity. + /// Indexed like `components` and `stagger_delays`. + pub time_params: Vec<(f64, f64)>, } /// Build a box tree for a flat list of scene-level children at a given @@ -116,6 +121,7 @@ where { let mut components: Vec> = vec![None]; let mut stagger_delays: Vec = vec![0.0]; + let mut time_params: Vec<(f64, f64)> = vec![(1.0, 0.0)]; // slot 0 = root (identity) let mut next_id: NodeId = 1; let mut child_boxes = Vec::new(); @@ -124,10 +130,12 @@ where c, &mut components, &mut stagger_delays, + &mut time_params, &mut next_id, anim, format!("/children/{i}"), 0.0, + (1.0, 0.0), )); } @@ -149,6 +157,7 @@ where root, components, stagger_delays, + time_params, } } @@ -205,9 +214,11 @@ fn build_ghosts<'a>( child: &'a ChildComponent, components: &mut Vec>, stagger_delays: &mut Vec, + time_params: &mut Vec<(f64, f64)>, next_id: &mut NodeId, actx: BuildAnimationCtx, stagger_delay: f64, + time_remap: (f64, f64), effects: &[AnimationEffect], ) -> Vec { let (mb, tr) = detect_ghost_effects(effects); @@ -306,6 +317,7 @@ fn build_ghosts<'a>( // Register a slot so the dispatcher can look up the component. components.push(Some(child)); stagger_delays.push(stagger_delay); + time_params.push(time_remap); ghosts.push(BoxNode { id: ghost_id, @@ -337,6 +349,7 @@ fn build_ghosts<'a>( *next_id += 1; components.push(Some(child)); stagger_delays.push(stagger_delay); + time_params.push(time_remap); trail_nodes.push(BoxNode { id: ghost_id, @@ -366,29 +379,47 @@ fn build_ghosts<'a>( /// /// `stagger_delay` is the animation delay accumulated from ancestor /// containers' `stagger` fields. +/// `time_remap` is the accumulated affine time transform `(scale, shift)` where +/// `t_local = scale * t_global + shift`. Default is `(1.0, 0.0)` (identity). #[allow(clippy::too_many_arguments)] fn build_child<'a>( child: &'a ChildComponent, components: &mut Vec>, stagger_delays: &mut Vec, + time_params: &mut Vec<(f64, f64)>, next_id: &mut NodeId, anim: Option, path: String, stagger_delay: f64, + time_remap: (f64, f64), ) -> Vec { + // Compute the local animation context for this node — remapped by the + // accumulated affine time transform from ancestor containers. + // `t_local = scale * t_global + shift` + let local_actx = anim.map(|a| { + let (scale, shift) = time_remap; + BuildAnimationCtx { + time: a.time * scale + shift, + scene_duration: a.scene_duration, + fps: a.fps, + } + }); + // ── 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) = anim { + if let Some(actx) = local_actx { if let Some(effects) = effective_effects(&child.component, stagger_delay) { ghosts = build_ghosts( child, components, stagger_delays, + time_params, next_id, actx, stagger_delay, + time_remap, &effects, ); } @@ -398,6 +429,7 @@ fn build_child<'a>( *next_id += 1; components.push(Some(child)); stagger_delays.push(stagger_delay); + time_params.push(time_remap); let mut css = component_css(&child.component); @@ -420,7 +452,7 @@ fn build_child<'a>( 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 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); } @@ -430,7 +462,7 @@ fn build_child<'a>( // 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(actx) = local_actx { 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) { @@ -446,7 +478,7 @@ fn build_child<'a>( use rustmotion_core::css::style::{AudioReactiveProperty, AudioSource, AudioSourceTag}; use rustmotion_core::engine::renderer::audio_analysis::audio_analysis_cache; - if let Some(actx) = anim { + if let Some(actx) = local_actx { let cache = audio_analysis_cache(); let analysis_opt = if let Some(ref src) = ar.track { cache.get(src).map(|r| r.clone()) @@ -497,24 +529,38 @@ fn build_child<'a>( // Visibility window (start_at/end_at) — enforced by the paint pass. // The stagger delay shifts the window too, so a hard-cut child appears // in step with its staggered siblings. + // When there is an accumulated time remap, the window times (which are in + // local/remapped time) must be converted back to global time so the paint + // pass (which operates on global time) can apply them correctly. + // If `t_local = scale * t_global + shift`, then `t_global = (t_local - shift) / scale`. let window = child.component.as_timed().and_then(|t| { let (start, end) = t.timing(); - (start.is_some() || end.is_some()).then_some( + (start.is_some() || end.is_some()).then_some({ + let (scale, shift) = time_remap; + let to_global = |t_local: f64| -> f64 { + if scale.abs() < 1e-10 { + t_local + } else { + (t_local - shift) / scale + } + }; rustmotion_core::engine::box_tree::PaintWindow { - start: start.map(|s| s + stagger_delay), - end: end.map(|e| e + stagger_delay), - }, - ) + start: start.map(|s| to_global(s + stagger_delay)), + end: end.map(|e| to_global(e + stagger_delay)), + } + }) }); let children_boxes = container_children( &child.component, components, stagger_delays, + time_params, next_id, anim, &path, stagger_delay, + time_remap, ); let intrinsic = component_intrinsic(&child.component); @@ -785,24 +831,72 @@ fn component_intrinsic( /// If the component is a container, recurse into its children. Otherwise /// return an empty Vec. A container's `stagger` adds `index * stagger` /// to each child's inherited animation delay (cumulative across nesting). +/// `time_remap` is the accumulated affine time transform `(scale, shift)` for +/// this container node; the container's own `time_scale`/`time_offset` are +/// composed in to produce the remap for children. #[allow(clippy::too_many_arguments)] fn container_children<'a>( component: &'a Component, components: &mut Vec>, stagger_delays: &mut Vec, + time_params: &mut Vec<(f64, f64)>, next_id: &mut NodeId, anim: Option, parent_path: &str, inherited_delay: f64, + time_remap: (f64, f64), ) -> Vec { - let (children, stagger): (&[ChildComponent], Option) = match component { - Component::Card(c) => (&c.children, c.stagger), - Component::Flex(c) => (&c.children, c.stagger), - Component::Grid(c) => (&c.children, c.stagger), - Component::Container(c) => (&c.children, c.stagger), - Component::Positioned(c) => (&c.children, None), - _ => return Vec::new(), - }; + let (children, stagger, child_scale, child_offset): (&[ChildComponent], Option, f64, f64) = + match component { + Component::Card(c) => ( + &c.children, + c.stagger, + c.time_scale.unwrap_or(1.0), + c.time_offset.unwrap_or(0.0), + ), + Component::Flex(c) => ( + &c.children, + c.stagger, + c.time_scale.unwrap_or(1.0), + c.time_offset.unwrap_or(0.0), + ), + Component::Grid(c) => ( + &c.children, + c.stagger, + c.time_scale.unwrap_or(1.0), + c.time_offset.unwrap_or(0.0), + ), + Component::Container(c) => ( + &c.children, + c.stagger, + c.time_scale.unwrap_or(1.0), + c.time_offset.unwrap_or(0.0), + ), + Component::Positioned(c) => ( + &c.children, + None, + c.time_scale.unwrap_or(1.0), + c.time_offset.unwrap_or(0.0), + ), + _ => return Vec::new(), + }; + + // Clamp scale defensively to avoid division-by-zero downstream. + let child_scale = child_scale.max(1e-6); + + // Compose the container's time remap with the inherited (accumulated) remap. + // Accumulated remap: `t_parent = scale_acc * t_global + shift_acc` + // Container formula: `t_child = (t_parent - child_offset) * child_scale` + // = child_scale * (scale_acc * t_global + shift_acc) - child_scale * child_offset + // = (child_scale * scale_acc) * t_global + child_scale * (shift_acc - child_offset) + let (scale_acc, shift_acc) = time_remap; + let new_scale = child_scale * scale_acc; + let new_shift = child_scale * (shift_acc - child_offset); + let child_remap = (new_scale, new_shift); + + // `anim` stays GLOBAL all the way down the recursion — each `build_child` + // derives its node-local time from the accumulated `child_remap`. Passing + // a pre-remapped ctx here would double-apply the transform. let step = stagger.unwrap_or(0.0) as f64; let mut result = Vec::new(); for (j, c) in children.iter().enumerate() { @@ -810,10 +904,12 @@ fn container_children<'a>( c, components, stagger_delays, + time_params, next_id, anim, format!("{parent_path}/children/{j}"), inherited_delay + j as f64 * step, + child_remap, )); } result @@ -1216,6 +1312,8 @@ mod tests { style, timeline: Vec::new(), stagger: None, + time_scale: None, + time_offset: None, }) } @@ -1426,6 +1524,8 @@ mod tests { }, timeline: Vec::new(), stagger: None, + time_scale: None, + time_offset: None, }), position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, diff --git a/crates/rustmotion-components/src/card.rs b/crates/rustmotion-components/src/card.rs index 4d93938..88a80a6 100644 --- a/crates/rustmotion-components/src/card.rs +++ b/crates/rustmotion-components/src/card.rs @@ -23,6 +23,10 @@ pub struct Card { pub timeline: Vec, #[serde(default)] pub stagger: Option, + #[serde(default)] + pub time_scale: Option, + #[serde(default)] + pub time_offset: Option, } rustmotion_core::impl_traits!(Card { diff --git a/crates/rustmotion-components/src/container.rs b/crates/rustmotion-components/src/container.rs index 24d7139..b53d0bc 100644 --- a/crates/rustmotion-components/src/container.rs +++ b/crates/rustmotion-components/src/container.rs @@ -25,6 +25,10 @@ pub struct ContainerComponent { pub timeline: Vec, #[serde(default)] pub stagger: Option, + #[serde(default)] + pub time_scale: Option, + #[serde(default)] + pub time_offset: Option, } rustmotion_core::impl_traits!(ContainerComponent { diff --git a/crates/rustmotion-components/src/flex.rs b/crates/rustmotion-components/src/flex.rs index c231927..3dccf22 100644 --- a/crates/rustmotion-components/src/flex.rs +++ b/crates/rustmotion-components/src/flex.rs @@ -29,6 +29,10 @@ pub struct Flex { pub timeline: Vec, #[serde(default)] pub stagger: Option, + #[serde(default)] + pub time_scale: Option, + #[serde(default)] + pub time_offset: Option, } rustmotion_core::impl_traits!(Flex { diff --git a/crates/rustmotion-components/src/grid.rs b/crates/rustmotion-components/src/grid.rs index 8e774ab..0ea8ec5 100644 --- a/crates/rustmotion-components/src/grid.rs +++ b/crates/rustmotion-components/src/grid.rs @@ -22,6 +22,10 @@ pub struct Grid { pub timeline: Vec, #[serde(default)] pub stagger: Option, + #[serde(default)] + pub time_scale: Option, + #[serde(default)] + pub time_offset: Option, } rustmotion_core::impl_traits!(Grid { diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 24844ca..e2ece47 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -27,6 +27,10 @@ pub struct LegacyPaintDispatcher<'a> { /// Per-node container-stagger delay (indexed like `components`); empty /// when the caller doesn't carry stagger information. stagger_delays: &'a [f64], + /// Per-node accumulated affine time remap `(scale, shift)` from ancestor + /// containers' `time_scale`/`time_offset` (indexed like `components`); + /// `t_local = scale * t_global + shift`. Empty → identity everywhere. + time_params: &'a [(f64, f64)], } impl<'a> LegacyPaintDispatcher<'a> { @@ -34,15 +38,19 @@ impl<'a> LegacyPaintDispatcher<'a> { Self { components, stagger_delays: &[], + time_params: &[], } } /// Build from a [`BuiltScene`], carrying its stagger delays so internal - /// animations shift by the same amount as the CSS overrides. + /// 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, stagger_delays: &built.stagger_delays, + time_params: &built.time_params, } } @@ -87,8 +95,20 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { .get(*node_id as usize) .copied() .unwrap_or(0.0); + // Ancestor `time_scale`/`time_offset` remap the time seen by this + // node's whole animation surface: internal effect resolution below + // AND `PaintCtx.time` (a counter or a typewriter inside a slowed + // container must advance at local time). `scene_duration` stays + // GLOBAL — it describes the physical scene window, not the remapped + // timeline, so duration-relative effects keep their real-time span. + let (t_scale, t_shift) = self + .time_params + .get(*node_id as usize) + .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) { - Some(effects) => resolve_props_for_effects(&effects, frame.time, frame.scene_duration), + Some(effects) => resolve_props_for_effects(&effects, local_time, frame.scene_duration), None => AnimatedProperties::default(), }; if props.opacity <= 0.0 { @@ -103,7 +123,7 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { canvas.translate((layout.x, layout.y)); let paint_ctx = PaintCtx { - time: frame.time, + time: local_time, scene_duration: frame.scene_duration, frame_index: frame.frame_index, fps: frame.fps, @@ -269,6 +289,8 @@ mod tests { }, timeline: Vec::new(), stagger: None, + time_scale: None, + time_offset: None, }), position: Some(PositionMode::Absolute { x: 40.0, y: 30.0 }), x: None, diff --git a/crates/rustmotion-components/src/positioned.rs b/crates/rustmotion-components/src/positioned.rs index 22b2fa7..83aa7a9 100644 --- a/crates/rustmotion-components/src/positioned.rs +++ b/crates/rustmotion-components/src/positioned.rs @@ -21,6 +21,10 @@ pub struct Positioned { pub timing: TimingConfig, #[serde(default)] pub timeline: Vec, + #[serde(default)] + pub time_scale: Option, + #[serde(default)] + pub time_offset: Option, } rustmotion_core::impl_traits!(Positioned { diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index a029be2..7a52a79 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -376,6 +376,8 @@ mod component_smoke { style: card_style, timeline: Vec::new(), stagger: None, + time_scale: None, + time_offset: None, }), position: Some(PositionMode::Absolute { x: 10.0, y: 10.0 }), x: None, @@ -992,6 +994,8 @@ mod component_smoke { style: card_style, timeline: Vec::new(), stagger: None, + time_scale: None, + time_offset: None, }), position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -1118,6 +1122,270 @@ mod component_smoke { layout.width ); } + + // ─── Time Remapping Tests ──────────────────────────────────────────────────── + + /// Build a scene with one flex container (time_scale, time_offset) wrapping + /// a red rect with fade_in, absolutely positioned at (0, 0). + fn flex_fade_in_scene( + time_scale: Option, + time_offset: Option, + fade_duration: f64, + ) -> Vec { + let json = serde_json::json!({ + "type": "flex", + "time_scale": time_scale, + "time_offset": time_offset, + "style": { "width": "400px", "height": "300px" }, + "children": [{ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "style": { + "width": "200px", + "height": "200px", + "animation": [{ "name": "fade_in", "duration": fade_duration }] + } + }] + }); + let component: Component = serde_json::from_value(json).expect("deserialize flex"); + vec![crate::components::ChildComponent { + component, + position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + }] + } + + #[test] + fn time_scale_slows_fade_in_animation() { + // A flex container with time_scale 0.5 wraps a red shape with fade_in duration=1s. + // At t_global=1s the child should be at t_local=0.5s → half-faded. + // At t_global=2s the child should be at t_local=1.0s → fully visible. + // Without remap, at t_global=1s the fade would be complete. + // With scale 0.5, it should still be in-progress at t=1s. + let at_1s = render_new_at( + &flex_fade_in_scene(Some(0.5), None, 1.0), + 400, + 300, + 1.0, + 4.0, + ); + let at_2s = render_new_at( + &flex_fade_in_scene(Some(0.5), None, 1.0), + 400, + 300, + 2.0, + 4.0, + ); + + let red_at_1 = red_sum(&at_1s); + let red_at_2 = red_sum(&at_2s); + assert!( + red_at_2 > red_at_1 + 1000, + "time_scale=0.5: at t=1s (half-faded) red_sum={} should be less than at t=2s (fully visible) red_sum={}", + red_at_1, red_at_2 + ); + assert!( + red_at_2 > 5000, + "at t=2s (t_local=1.0s, fade complete) shape should be fully visible, red_sum={}", + red_at_2 + ); + } + + #[test] + fn time_offset_delays_animation_start() { + // A flex container with time_offset=1.0 wraps a shape with fade_in duration=0.5s. + // t_local = (t_global - 1.0) * 1.0 + // At t_global=0.9s: t_local=-0.1s → nothing visible yet. + // At t_global=1.6s: t_local=0.6s → fade complete (duration=0.5). + let before = render_new_at( + &flex_fade_in_scene(None, Some(1.0), 0.5), + 400, + 300, + 0.9, + 4.0, + ); + let after = render_new_at( + &flex_fade_in_scene(None, Some(1.0), 0.5), + 400, + 300, + 1.6, + 4.0, + ); + + let red_before = red_sum(&before); + let red_after = red_sum(&after); + + assert!( + red_before < 1000, + "time_offset=1.0: at t=0.9s animation hasn't started, expected near-zero red, got {}", + red_before + ); + assert!( + red_after > 5000, + "time_offset=1.0: at t=1.6s fade should be complete, expected high red, got {}", + red_after + ); + } + + #[test] + fn start_at_window_respected_under_time_scale() { + // Child with start_at=1.0 inside flex with time_scale=0.5. + // start_at is in local time. In global: t_global = start_at / scale = 1.0/0.5 = 2.0s. + // At t_global=1.5s: child should still be invisible (local time=0.75 < start_at=1.0). + // At t_global=2.5s: child should be visible (local time=1.25 > start_at=1.0). + let json = serde_json::json!({ + "type": "flex", + "time_scale": 0.5, + "style": { "width": "400px", "height": "300px" }, + "children": [{ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "start_at": 1.0, + "style": { "width": "200px", "height": "200px" } + }] + }); + let make_scene = || { + let component: Component = serde_json::from_value(json.clone()).expect("deserialize"); + vec![crate::components::ChildComponent { + component, + position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + }] + }; + + let invisible = render_new_at(&make_scene(), 400, 300, 1.5, 6.0); + let visible = render_new_at(&make_scene(), 400, 300, 2.5, 6.0); + + assert!( + red_sum(&invisible) < 500, + "start_at=1.0 with scale=0.5: at t_global=1.5 (t_local=0.75) shape should be invisible, red_sum={}", + red_sum(&invisible) + ); + assert!( + red_sum(&visible) > 5000, + "start_at=1.0 with scale=0.5: at t_global=2.5 (t_local=1.25 > start_at=1.0) shape should be visible, red_sum={}", + red_sum(&visible) + ); + } + + #[test] + fn cascaded_time_scale_compounds() { + // Outer flex: time_scale=0.5. Inner flex: time_scale=0.5. + // Effective scale on the shape = 0.5 * 0.5 = 0.25. + // Shape has fade_in duration=1s. + // t_local = t_global * 0.25 (both offsets are 0). + // At t_global=3.0s: t_local=0.75s → still fading (not at 1s yet). + // At t_global=4.5s: t_local=1.125s → fade complete. + let json = serde_json::json!({ + "type": "flex", + "time_scale": 0.5, + "style": { "width": "400px", "height": "300px" }, + "children": [{ + "type": "flex", + "time_scale": 0.5, + "children": [{ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "style": { + "width": "100px", + "height": "100px", + "animation": [{ "name": "fade_in", "duration": 1.0 }] + } + }] + }] + }); + let make_scene = || { + let component: Component = serde_json::from_value(json.clone()).expect("deserialize"); + vec![crate::components::ChildComponent { + component, + position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + }] + }; + + // At t_global=3s: t_local = 3 * 0.25 = 0.75s → fade still incomplete + let at_3s = render_new_at(&make_scene(), 400, 300, 3.0, 6.0); + // At t_global=4.5s: t_local = 4.5 * 0.25 = 1.125s → fade complete + let at_4_5s = render_new_at(&make_scene(), 400, 300, 4.5, 6.0); + + let red_3 = red_sum(&at_3s); + let red_4_5 = red_sum(&at_4_5s); + + assert!( + red_4_5 > red_3 + 500, + "cascaded scale 0.5×0.5=0.25: at t=3s red={} should be less than t=4.5s red={}", + red_3, + red_4_5 + ); + assert!( + red_4_5 > 2000, + "cascaded scale: at t=4.5s (t_local=1.125s) fade should be complete, red_sum={}", + red_4_5 + ); + } + + #[test] + fn time_scale_affects_internal_paint_ctx_time() { + // A line with draw_in duration=1s inside a flex with time_scale=0.5. + // At t_global=1s (t_local=0.5s): the line should be about half-drawn. + // Without remapping, the line would be fully drawn at t=1s. + // We compare draw extent vs a reference without remap. + let make_scene = |time_scale: Option| { + let json = serde_json::json!({ + "type": "flex", + "time_scale": time_scale, + "children": [{ + "type": "line", + "x1": 0.0, + "y1": 150.0, + "x2": 400.0, + "y2": 150.0, + "width": 8.0, + "color": "#ff0000", + "style": { + "animation": [{ "name": "draw_in", "duration": 1.0 }] + } + }] + }); + let component: Component = serde_json::from_value(json).expect("deserialize flex+line"); + vec![crate::components::ChildComponent { + component, + position: Some(crate::components::PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + }] + }; + + // At t_global=1s: without remap the line is fully drawn; with scale=0.5 it should be ~half. + let without_remap = render_new_at(&make_scene(None), 400, 300, 1.0, 2.0); + let with_remap = render_new_at(&make_scene(Some(0.5)), 400, 300, 1.0, 2.0); + + let red_no_remap = red_sum(&without_remap); + let red_remapped = red_sum(&with_remap); + + assert!( + red_no_remap > red_remapped + 500, + "line draw_in with scale=0.5: at t=1s remapped line (red={}) should have less extent \ + than non-remapped (red={})", + red_remapped, + red_no_remap + ); + assert!( + red_remapped > 200, + "line draw_in with scale=0.5: at t=1s (t_local=0.5s) line should be partially drawn, red_sum={}", + red_remapped + ); + } } // ──────────────────────────────────────────────────────────────────────────────