diff --git a/crates/rustmotion-components/src/video.rs b/crates/rustmotion-components/src/video.rs index 10921bce..1500dbb5 100644 --- a/crates/rustmotion-components/src/video.rs +++ b/crates/rustmotion-components/src/video.rs @@ -6,7 +6,7 @@ use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ - extract_video_frame, find_closest_frame, video_frame_cache, + extract_video_frame, find_closest_frame, probe_video_metadata, video_frame_cache, }; use rustmotion_core::schema::{ImageFit, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -92,6 +92,70 @@ fn draw_fitted(canvas: &Canvas, img: skia_safe::Image, fit: &ImageFit, layout: & } } +/// The source clip's own duration, probed via `ffprobe` and memoized per +/// `src` for the life of the process — `effective_source_time` below is +/// called once per painted frame, and re-probing on every one of them would +/// mean one subprocess spawn per frame for any looping video with no +/// explicit `trim_end`. `None` on a probe failure (no ffprobe on `PATH`, or +/// the source can't be read) is memoized too, so a broken source fails fast +/// on every subsequent frame instead of retrying the same failing probe. +fn video_duration_secs(src: &str) -> Option { + static CACHE: std::sync::OnceLock< + std::sync::Mutex>>, + > = std::sync::OnceLock::new(); + let cache = CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())); + + if let Some(hit) = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(src) + { + return *hit; + } + let probed = probe_video_metadata(src).ok().map(|p| p.duration_secs); + cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(src.to_string(), probed); + probed +} + +impl Video { + /// The timestamp to sample from the source clip for a given scene time. + /// + /// Honours `trim_end` on the picture the same way the audio track + /// already does: past `trim_end`, playback holds on the last in-window + /// frame instead of continuing to draw whatever the source contains + /// beyond the intended trim point. When `loop_video` is set, playback + /// wraps within `[trim_start, trim_end)` instead of clamping — falling + /// back to the source's own probed duration as the loop window only + /// when `trim_end` is absent, since that is the only case where the + /// window cannot otherwise be known at all. + fn effective_source_time(&self, ctx_time: f64) -> f64 { + let rate = self.playback_rate.unwrap_or(1.0); + let trim_start = self.trim_start.unwrap_or(0.0); + let raw = trim_start + ctx_time * rate; + + if let Some(end) = self.trim_end { + if end > trim_start { + return if self.loop_video == Some(true) { + trim_start + (raw - trim_start).rem_euclid(end - trim_start) + } else { + raw.min(end) + }; + } + } else if self.loop_video == Some(true) { + if let Some(duration) = video_duration_secs(&self.src) { + if duration > trim_start { + return trim_start + (raw - trim_start).rem_euclid(duration - trim_start); + } + } + } + + raw + } +} + impl Painter for Video { fn paint_content( &self, @@ -100,9 +164,7 @@ impl Painter for Video { _props: &AnimatedProperties, ctx: &PaintCtx, ) { - let rate = self.playback_rate.unwrap_or(1.0); - let trim_start = self.trim_start.unwrap_or(0.0); - let source_time = trim_start + ctx.time * rate; + let source_time = self.effective_source_time(ctx.time); let width = layout.width as u32; let height = layout.height as u32; diff --git a/crates/rustmotion-components/tests/audit_ws_d.rs b/crates/rustmotion-components/tests/audit_ws_d.rs index 3cb43373..e884b076 100644 --- a/crates/rustmotion-components/tests/audit_ws_d.rs +++ b/crates/rustmotion-components/tests/audit_ws_d.rs @@ -186,3 +186,98 @@ fn fill_fit_still_stretches_to_the_whole_box() { "fill must cover every corner" ); } + +// ─── `trim_end` was honoured only on the extracted audio ─────────────────── + +/// Frames beyond `trim_end` sit in the cache (simulating a preextraction +/// window, or a direct extraction, wider than the intended trim), so the +/// picture path must never pick one of them once `trim_end` is set: past +/// `trim_end`, playback holds on the last in-window frame. Before the fix, +/// `source_time` had no upper bound at all — querying past `trim_end` on a +/// cache/source that extends further would draw whatever sits further +/// along the source, not the frame at the trim boundary. +#[test] +fn trim_end_clamps_playback_instead_of_running_past_it() { + let src = unique_src("trimend"); + let cache_key = format!("{src}:20x20"); + let frames = vec![ + (0.0, solid_rgba([255, 0, 0, 255], 2, 2), 2, 2), + (0.5, solid_rgba([0, 0, 255, 255], 2, 2), 2, 2), + (1.0, solid_rgba([0, 255, 0, 255], 2, 2), 2, 2), + (1.5, solid_rgba([128, 0, 128, 255], 2, 2), 2, 2), + (2.0, solid_rgba([255, 165, 0, 255], 2, 2), 2, 2), + ]; + video_frame_cache().insert(cache_key, Arc::new(frames)); + + let v = video(&src, ImageFit::Fill, Some(0.0), Some(1.0), None); + let ctx = ctx_at(1.8); + let pixels = paint_and_read(&v, &ctx, 20, 20, false); + + assert_eq!( + px(&pixels, 20, 10, 10), + [0, 255, 0, 255], + "past trim_end, playback must clamp to the frame at trim_end (green), not the frame \ + nearest the unclamped query time (orange)" + ); +} + +// ─── `loop_video` made neither the picture nor the audio loop ───────────── + +/// Cache frames only cover `[0.0, 1.0)`; `trim_end: Some(1.0)` gives +/// `loop_video` a window to wrap within without needing a real source file +/// to probe. Querying at `ctx.time = 2.1` (raw source time 2.1s, i.e. "2 +/// full loops plus 0.1s") must land near 0.1s once wrapped — nearest to +/// that among `{0.0, 0.25, 0.5, 0.75}` is red. Before this fix, the same +/// query — with the trim-end clamp from the previous test already in place +/// but no loop branch yet — clamped to `min(2.1, 1.0) = 1.0`, whose nearest +/// cached frame is yellow: a clearly different pixel, which is what proves +/// this test is exercising the loop path and not being masked by the clamp. +#[test] +fn loop_video_wraps_playback_within_the_trim_window() { + let src = unique_src("loop"); + let cache_key = format!("{src}:20x20"); + let frames = vec![ + (0.0, solid_rgba([255, 0, 0, 255], 2, 2), 2, 2), + (0.25, solid_rgba([0, 255, 0, 255], 2, 2), 2, 2), + (0.5, solid_rgba([0, 0, 255, 255], 2, 2), 2, 2), + (0.75, solid_rgba([255, 255, 0, 255], 2, 2), 2, 2), + ]; + video_frame_cache().insert(cache_key, Arc::new(frames)); + + let v = video(&src, ImageFit::Fill, Some(0.0), Some(1.0), Some(true)); + let ctx = ctx_at(2.1); + let pixels = paint_and_read(&v, &ctx, 20, 20, false); + + assert_eq!( + px(&pixels, 20, 10, 10), + [255, 0, 0, 255], + "looping must wrap the query time back into the window (nearest: red), not clamp to \ + the window's own end (nearest: yellow)" + ); +} + +/// Without `loop_video`, a `trim_end`-bounded video must still clamp +/// (unaffected by the loop branch existing) rather than wrap — the same +/// scenario as the wrap test above, minus the flag. +#[test] +fn without_loop_video_playback_still_clamps_not_wraps() { + let src = unique_src("no-loop"); + let cache_key = format!("{src}:20x20"); + let frames = vec![ + (0.0, solid_rgba([255, 0, 0, 255], 2, 2), 2, 2), + (0.25, solid_rgba([0, 255, 0, 255], 2, 2), 2, 2), + (0.5, solid_rgba([0, 0, 255, 255], 2, 2), 2, 2), + (0.75, solid_rgba([255, 255, 0, 255], 2, 2), 2, 2), + ]; + video_frame_cache().insert(cache_key, Arc::new(frames)); + + let v = video(&src, ImageFit::Fill, Some(0.0), Some(1.0), None); + let ctx = ctx_at(2.1); + let pixels = paint_and_read(&v, &ctx, 20, 20, false); + + assert_eq!( + px(&pixels, 20, 10, 10), + [255, 255, 0, 255], + "no loop_video: must clamp to the window's end (nearest: yellow), not wrap" + ); +}