From 63804c345634b814ece6e8c2dcee38c6efb5b1d3 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 10:04:55 +0200 Subject: [PATCH] fix(paint): let a self-painting component see its animated background Six components paint their own background and read it from their own style: badge, callout, caption, kbd, stat, tooltip. The animated value never reached them. resolve_transition_css_overrides writes the interpolated colour onto the node's css.background, and the cascade only carries inheritable properties onto the clone a component is painted from -- background is not one. So a badge with a timeline step changing style.background painted its declared colour for the whole scene, and the generic background step underneath painted the animated one where the component's own opaque fill covered it. with_resolved_background writes the node's resolved background onto a clone of the six. It returns None when the resolved value already equals the component's own, so a component whose background is not animated is not cloned at all -- the serde round-trip is not paid per frame for the common case. stat is the reason this is not folded into with_cascaded_style: that function is about inheritance and classifies stat as non-typographic, so it returns None for it and the fix would have missed a sixth of the components it is for. Verified on a badge with a 1.0s linear transition: red at 0.2s, (127, 0, 127) at 1.0s, blue at 1.8s. Before, all three were red. --- .../src/legacy_dispatch.rs | 4 +- crates/rustmotion-components/src/lib.rs | 35 +++++++ crates/rustmotion/src/tests.rs | 99 +++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index ff3b716..234e2ae 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -81,7 +81,9 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { } let cascaded_component = child.component.with_cascaded_style(css); - let effective_component = cascaded_component.as_ref().unwrap_or(&child.component); + let cascaded = cascaded_component.as_ref().unwrap_or(&child.component); + let repainted = cascaded.with_resolved_background(css); + let effective_component = repainted.as_ref().unwrap_or(cascaded); let Some(painter) = effective_component.as_painter() else { return; }; diff --git a/crates/rustmotion-components/src/lib.rs b/crates/rustmotion-components/src/lib.rs index 6685fe0..f03ee75 100644 --- a/crates/rustmotion-components/src/lib.rs +++ b/crates/rustmotion-components/src/lib.rs @@ -619,6 +619,41 @@ impl Component { } } + pub fn paints_its_own_background(&self) -> bool { + matches!( + self, + Component::Badge(_) + | Component::Callout(_) + | Component::Caption(_) + | Component::Kbd(_) + | Component::Stat(_) + | Component::Tooltip(_) + ) + } + + pub fn with_resolved_background(&self, resolved: &CssStyle) -> Option { + if !self.paints_its_own_background() { + return None; + } + let background = resolved.background.clone()?; + if self.as_styled().style_config().background.as_ref() == Some(&background) { + return None; + } + let value = serde_json::to_value(self).ok()?; + let mut clone: Component = serde_json::from_value(value).ok()?; + let style = match &mut clone { + Component::Badge(c) => c.style_config_mut(), + Component::Callout(c) => c.style_config_mut(), + Component::Caption(c) => c.style_config_mut(), + Component::Kbd(c) => c.style_config_mut(), + Component::Stat(c) => c.style_config_mut(), + Component::Tooltip(c) => c.style_config_mut(), + _ => unreachable!("classified by paints_its_own_background above"), + }; + style.background = Some(background); + Some(clone) + } + pub fn with_cascaded_style(&self, resolved: &CssStyle) -> Option { let is_typographic = match self { Component::Text(_) diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index fa6509d..b404ffb 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -3671,3 +3671,102 @@ mod node_reference_resolution { )); } } + +#[cfg(test)] +mod self_painting_background_animation_tests { + use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask}; + use crate::loader::load_scenario_from_source; + + const W: usize = 400; + const H: usize = 200; + + fn scenario(component: &str) -> String { + format!( + r##"{{ + "version": "1.0", + "video": {{ "width": {W}, "height": {H}, "fps": 30, "background": "#000000" }}, + "scenes": [{{ "duration": 2.0, "children": [{component}] }}] + }}"## + ) + } + + fn centre_rgb(json: &str, time: f64) -> (u8, u8, u8) { + let scenario = load_scenario_from_source(None, Some(json)).expect("load"); + let fps = scenario.video.fps as f64; + let wanted = (time * fps).round() as u32; + let tasks = build_frame_tasks(&scenario); + let task = tasks + .iter() + .find(|t| match t { + FrameTask::Normal { global_frame, .. } => *global_frame == wanted, + _ => false, + }) + .expect("a normal frame at that instant"); + let frame = render_frame_task(&scenario.video, &scenario, task).expect("render"); + let i = ((H / 2) * W + W / 2) * 4; + (frame[i], frame[i + 1], frame[i + 2]) + } + + const BADGE: &str = r##"{ + "type": "badge", "text": "AB", + "style": { "position": "absolute", "left": 100, "top": 70, "font-size": 40, + "background": "#FF0000", "color": "#FFFFFF", "border-radius": 999, + "transition": { "duration": 1.0, "easing": "linear" } }, + "timeline": [{ "at": 0.5, "style": { "background": "#0000FF" } }] }"##; + + const STAT: &str = r##"{ + "type": "stat", "value": "42", + "style": { "position": "absolute", "left": 100, "top": 50, "width": 200, "height": 100, + "background": "#FF0000", "color": "#FFFFFF", + "transition": { "duration": 1.0, "easing": "linear" } }, + "timeline": [{ "at": 0.5, "style": { "background": "#0000FF" } }] }"##; + + #[test] + fn a_badge_follows_an_animated_background_instead_of_repainting_its_own() { + let json = scenario(BADGE); + assert_eq!( + centre_rgb(&json, 0.2), + (255, 0, 0), + "before the step the badge is the colour it declares" + ); + let (r, g, b) = centre_rgb(&json, 1.0); + assert!( + r > 80 && r < 180 && g < 20 && b > 80 && b < 180, + "halfway through the transition the badge must be between its two colours, got \ + ({r}, {g}, {b}) — repainting its own static background gives (255, 0, 0)" + ); + assert_eq!( + centre_rgb(&json, 1.8), + (0, 0, 255), + "past the transition the badge is the colour the timeline asked for" + ); + } + + #[test] + fn a_stat_follows_it_too_although_the_cascade_never_clones_it() { + let json = scenario(STAT); + assert_eq!(centre_rgb(&json, 0.2), (255, 0, 0)); + let (r, g, b) = centre_rgb(&json, 1.0); + assert!( + r > 80 && r < 180 && g < 20 && b > 80 && b < 180, + "stat is classified non-typographic, so with_cascaded_style returns None for it — \ + the resolved background has to reach it by its own path, got ({r}, {g}, {b})" + ); + assert_eq!(centre_rgb(&json, 1.8), (0, 0, 255)); + } + + #[test] + fn a_component_with_no_background_animation_is_untouched() { + let json = scenario( + r##"{ "type": "badge", "text": "AB", + "style": { "position": "absolute", "left": 100, "top": 70, "font-size": 40, + "background": "#FF0000", "color": "#FFFFFF", "border-radius": 999 } }"##, + ); + assert_eq!(centre_rgb(&json, 0.2), (255, 0, 0)); + assert_eq!( + centre_rgb(&json, 1.8), + (255, 0, 0), + "with nothing animating the background, the clone must not be taken at all" + ); + } +}