Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 66 additions & 4 deletions crates/rustmotion-components/src/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<f64> {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, Option<f64>>>,
> = 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,
Expand All @@ -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;

Expand Down
95 changes: 95 additions & 0 deletions crates/rustmotion-components/tests/audit_ws_d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Loading