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, probe_video_metadata, 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
49fn fit_rect(fit: &ImageFit, img_w: f32, img_h: f32, target_w: f32, target_h: f32) -> Rect {
50    match fit {
51        ImageFit::Fill => Rect::from_xywh(0.0, 0.0, target_w, target_h),
52        ImageFit::Contain => {
53            let scale = (target_w / img_w).min(target_h / img_h);
54            let w = img_w * scale;
55            let h = img_h * scale;
56            Rect::from_xywh((target_w - w) / 2.0, (target_h - h) / 2.0, w, h)
57        }
58        ImageFit::Cover => {
59            let scale = (target_w / img_w).max(target_h / img_h);
60            let w = img_w * scale;
61            let h = img_h * scale;
62            Rect::from_xywh((target_w - w) / 2.0, (target_h - h) / 2.0, w, h)
63        }
64    }
65}
66
67fn draw_fitted(canvas: &Canvas, img: skia_safe::Image, fit: &ImageFit, layout: &BoxLayout) {
68    let dst = fit_rect(
69        fit,
70        img.width() as f32,
71        img.height() as f32,
72        layout.width,
73        layout.height,
74    );
75    let paint = Paint::default();
76    if matches!(fit, ImageFit::Cover) {
77        canvas.save();
78        canvas.clip_rect(
79            Rect::from_xywh(0.0, 0.0, layout.width, layout.height),
80            skia_safe::ClipOp::Intersect,
81            true,
82        );
83        canvas.draw_image_rect(img, None, dst, &paint);
84        canvas.restore();
85    } else {
86        canvas.draw_image_rect(img, None, dst, &paint);
87    }
88}
89
90fn video_duration_secs(src: &str) -> Option<f64> {
91    static CACHE: std::sync::OnceLock<
92        std::sync::Mutex<std::collections::HashMap<String, Option<f64>>>,
93    > = std::sync::OnceLock::new();
94    let cache = CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
95
96    if let Some(hit) = cache
97        .lock()
98        .unwrap_or_else(std::sync::PoisonError::into_inner)
99        .get(src)
100    {
101        return *hit;
102    }
103    let probed = probe_video_metadata(src).ok().map(|p| p.duration_secs);
104    cache
105        .lock()
106        .unwrap_or_else(std::sync::PoisonError::into_inner)
107        .insert(src.to_string(), probed);
108    probed
109}
110
111impl Video {
112    fn effective_source_time(&self, ctx_time: f64) -> f64 {
113        let rate = self.playback_rate.unwrap_or(1.0);
114        let trim_start = self.trim_start.unwrap_or(0.0);
115        let raw = trim_start + ctx_time * rate;
116
117        if let Some(end) = self.trim_end {
118            if end > trim_start {
119                return if self.loop_video == Some(true) {
120                    trim_start + (raw - trim_start).rem_euclid(end - trim_start)
121                } else {
122                    raw.min(end)
123                };
124            }
125        } else if self.loop_video == Some(true) {
126            if let Some(duration) = video_duration_secs(&self.src) {
127                if duration > trim_start {
128                    return trim_start + (raw - trim_start).rem_euclid(duration - trim_start);
129                }
130            }
131        }
132
133        raw
134    }
135}
136
137impl Painter for Video {
138    fn paint_content(
139        &self,
140        canvas: &Canvas,
141        layout: &BoxLayout,
142        _props: &AnimatedProperties,
143        ctx: &PaintCtx,
144    ) {
145        let source_time = self.effective_source_time(ctx.time);
146        let width = layout.width as u32;
147        let height = layout.height as u32;
148
149        let cache_key = format!("{}:{}x{}", self.src, width, height);
150        let cache = video_frame_cache();
151
152        if let Some(cached_frames) = cache.get(&cache_key) {
153            if let Some((rgba, fw, fh)) = find_closest_frame(&cached_frames, source_time) {
154                let img_info = ImageInfo::new(
155                    (fw as i32, fh as i32),
156                    ColorType::RGBA8888,
157                    skia_safe::AlphaType::Unpremul,
158                    None,
159                );
160                let row_bytes = fw as usize * 4;
161                let data = skia_safe::Data::new_copy(rgba);
162                if let Some(img) = skia_safe::images::raster_from_data(&img_info, data, row_bytes) {
163                    draw_fitted(canvas, img, &self.fit, layout);
164                }
165                return;
166            }
167        }
168
169        let frame_data = match extract_video_frame(&self.src, source_time, width, height) {
170            Ok(data) => data,
171            Err(e) => {
172                if crate::warn_once_for(&format!("video-frame:{}", self.src)) {
173                    eprintln!(
174                        "Warning: video '{}' could not be decoded: {e}. This component will \
175                         render nothing for the remainder of the video.",
176                        self.src
177                    );
178                }
179                return;
180            }
181        };
182        let skia_data = skia_safe::Data::new_copy(&frame_data);
183        if let Some(img) = skia_safe::Image::from_encoded(skia_data) {
184            draw_fitted(canvas, img, &self.fit, layout);
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use rustmotion_core::engine::animator::AnimatedProperties;
193    use rustmotion_core::engine::layout_pass::BoxLayout;
194    use rustmotion_core::traits::PaintCtx;
195
196    fn base_ctx() -> PaintCtx {
197        PaintCtx {
198            time: 0.0,
199            scenario_time: 0.0,
200            scene_duration: 1.0,
201            frame_index: 0,
202            fps: 30,
203            video_width: 100,
204            video_height: 100,
205            stagger_offset: 0.0,
206        }
207    }
208
209    #[test]
210    fn a_failed_frame_extraction_must_claim_its_warn_once_slot() {
211        let missing_src = std::env::temp_dir().join(format!(
212            "rustmotion-video-test-missing-{}-{}.mp4",
213            std::process::id(),
214            std::time::SystemTime::now()
215                .duration_since(std::time::UNIX_EPOCH)
216                .unwrap()
217                .as_nanos(),
218        ));
219        let _ = std::fs::remove_file(&missing_src);
220        let src_str = missing_src.to_str().unwrap().to_string();
221
222        let video = Video {
223            src: src_str.clone(),
224            trim_start: None,
225            trim_end: None,
226            playback_rate: None,
227            fit: Default::default(),
228            volume: 1.0,
229            loop_video: None,
230            timing: Default::default(),
231            style: CssStyle::default(),
232            timeline: Vec::new(),
233            stagger: None,
234        };
235        let layout = BoxLayout {
236            width: 40.0,
237            height: 40.0,
238            ..Default::default()
239        };
240        let ctx = base_ctx();
241        let props = AnimatedProperties::default();
242        let mut surface = skia_safe::surfaces::raster_n32_premul((40, 40)).unwrap();
243        {
244            let canvas = surface.canvas();
245            video.paint_content(canvas, &layout, &props, &ctx);
246        }
247
248        let key = format!("video-frame:{}", src_str);
249        assert!(
250            !crate::warn_once_for(&key),
251            "paint_content must have claimed this warning slot on the failed extraction \
252             path — it is still unclaimed (first sighting), meaning nothing warned about \
253             the failure"
254        );
255    }
256}