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