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}