Skip to main content

rustmotion_components/
video.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::{
9    extract_video_frame, find_closest_frame, video_frame_cache,
10};
11use rustmotion_core::schema::{ImageFit, TimelineStep};
12use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
13
14fn default_volume() -> f32 {
15    1.0
16}
17
18#[derive(Debug, Serialize, Deserialize, JsonSchema)]
19pub struct Video {
20    pub src: String,
21    #[serde(default)]
22    pub trim_start: Option<f64>,
23    #[serde(default)]
24    pub trim_end: Option<f64>,
25    #[serde(default)]
26    pub playback_rate: Option<f64>,
27    #[serde(default)]
28    pub fit: ImageFit,
29    #[serde(default = "default_volume")]
30    pub volume: f32,
31    #[serde(default)]
32    pub loop_video: Option<bool>,
33    #[serde(flatten)]
34    pub timing: TimingConfig,
35    #[serde(default)]
36    pub style: CssStyle,
37    #[serde(default)]
38    pub timeline: Vec<TimelineStep>,
39    #[serde(default)]
40    pub stagger: Option<f32>,
41}
42
43rustmotion_core::impl_traits!(Video {
44    Animatable => animation,
45    Timed => timing,
46    Styled => style,
47});
48
49impl Painter for Video {
50    fn paint_content(
51        &self,
52        canvas: &Canvas,
53        layout: &BoxLayout,
54        _props: &AnimatedProperties,
55        ctx: &PaintCtx,
56    ) {
57        let rate = self.playback_rate.unwrap_or(1.0);
58        let trim_start = self.trim_start.unwrap_or(0.0);
59        let source_time = trim_start + ctx.time * rate;
60        let width = layout.width as u32;
61        let height = layout.height as u32;
62
63        let cache_key = format!("{}:{}x{}", self.src, width, height);
64        let cache = video_frame_cache();
65
66        if let Some(cached_frames) = cache.get(&cache_key) {
67            if let Some((rgba, fw, fh)) = find_closest_frame(&cached_frames, source_time) {
68                let img_info = ImageInfo::new(
69                    (fw as i32, fh as i32),
70                    ColorType::RGBA8888,
71                    skia_safe::AlphaType::Premul,
72                    None,
73                );
74                let row_bytes = fw as usize * 4;
75                let data = skia_safe::Data::new_copy(rgba);
76                if let Some(img) = skia_safe::images::raster_from_data(&img_info, data, row_bytes) {
77                    let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
78                    let paint = Paint::default();
79                    canvas.draw_image_rect(img, None, dst, &paint);
80                }
81                return;
82            }
83        }
84
85        let frame_data = match extract_video_frame(&self.src, source_time, width, height) {
86            Ok(data) => data,
87            Err(e) => {
88                // Item 3 (issue #167): decoding failures (ffmpeg missing, or
89                // this specific frame failing) used to be a silent `return`
90                // — a video component would render entirely blank with no
91                // trace anywhere. `paint_content` runs once per frame, so
92                // the warning is deduplicated per `src` via `warn_once_for`
93                // (the same guard `lib.rs` already uses for exactly this
94                // per-frame-call-site problem) instead of printing the same
95                // line a thousand times over a render.
96                if crate::warn_once_for(&format!("video-frame:{}", self.src)) {
97                    eprintln!(
98                        "Warning: video '{}' could not be decoded: {e}. This component will \
99                         render nothing for the remainder of the video.",
100                        self.src
101                    );
102                }
103                return;
104            }
105        };
106        let skia_data = skia_safe::Data::new_copy(&frame_data);
107        if let Some(img) = skia_safe::Image::from_encoded(skia_data) {
108            let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
109            let paint = Paint::default();
110            canvas.draw_image_rect(img, None, dst, &paint);
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use rustmotion_core::engine::animator::AnimatedProperties;
119    use rustmotion_core::engine::layout_pass::BoxLayout;
120    use rustmotion_core::traits::PaintCtx;
121
122    fn base_ctx() -> PaintCtx {
123        PaintCtx {
124            time: 0.0,
125            scenario_time: 0.0,
126            scene_duration: 1.0,
127            frame_index: 0,
128            fps: 30,
129            video_width: 100,
130            video_height: 100,
131            stagger_offset: 0.0,
132        }
133    }
134
135    /// A failed frame extraction (bad src, or no ffmpeg) must be reported,
136    /// not swallowed. Pre-fix, `paint_content` never calls `warn_once_for`
137    /// on this path at all, so the slot for this exact src stays unclaimed
138    /// ("first sighting" == true) forever — this is the observable half of
139    /// total silence we can assert on without capturing stderr.
140    #[test]
141    fn a_failed_frame_extraction_must_claim_its_warn_once_slot() {
142        let missing_src = std::env::temp_dir().join(format!(
143            "rustmotion-video-test-missing-{}-{}.mp4",
144            std::process::id(),
145            std::time::SystemTime::now()
146                .duration_since(std::time::UNIX_EPOCH)
147                .unwrap()
148                .as_nanos(),
149        ));
150        let _ = std::fs::remove_file(&missing_src);
151        let src_str = missing_src.to_str().unwrap().to_string();
152
153        let video = Video {
154            src: src_str.clone(),
155            trim_start: None,
156            trim_end: None,
157            playback_rate: None,
158            fit: Default::default(),
159            volume: 1.0,
160            loop_video: None,
161            timing: Default::default(),
162            style: CssStyle::default(),
163            timeline: Vec::new(),
164            stagger: None,
165        };
166        let layout = BoxLayout {
167            width: 40.0,
168            height: 40.0,
169            ..Default::default()
170        };
171        let ctx = base_ctx();
172        let props = AnimatedProperties::default();
173        let mut surface = skia_safe::surfaces::raster_n32_premul((40, 40)).unwrap();
174        {
175            let canvas = surface.canvas();
176            video.paint_content(canvas, &layout, &props, &ctx);
177        }
178
179        let key = format!("video-frame:{}", src_str);
180        assert!(
181            !crate::warn_once_for(&key),
182            "paint_content must have claimed this warning slot on the failed extraction \
183             path — it is still unclaimed (first sighting), meaning nothing warned about \
184             the failure"
185        );
186    }
187}