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 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 #[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}