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}