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
110 changes: 100 additions & 10 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 @@ -46,6 +46,102 @@ rustmotion_core::impl_traits!(Video {
Styled => style,
});

/// The rectangle an `img_w`×`img_h` source draws into to honour `fit` inside
/// a `target_w`×`target_h` box — the same three CSS `object-fit` semantics
/// `image.rs`'s painter already implements for the `image` component.
fn fit_rect(fit: &ImageFit, img_w: f32, img_h: f32, target_w: f32, target_h: f32) -> Rect {
match fit {
ImageFit::Fill => Rect::from_xywh(0.0, 0.0, target_w, target_h),
ImageFit::Contain => {
let scale = (target_w / img_w).min(target_h / img_h);
let w = img_w * scale;
let h = img_h * scale;
Rect::from_xywh((target_w - w) / 2.0, (target_h - h) / 2.0, w, h)
}
ImageFit::Cover => {
let scale = (target_w / img_w).max(target_h / img_h);
let w = img_w * scale;
let h = img_h * scale;
Rect::from_xywh((target_w - w) / 2.0, (target_h - h) / 2.0, w, h)
}
}
}

/// Draws `img` into `layout`'s box according to `fit`, clipping to the box
/// for `Cover` (the only mode whose fitted rectangle can extend past it).
fn draw_fitted(canvas: &Canvas, img: skia_safe::Image, fit: &ImageFit, layout: &BoxLayout) {
let dst = fit_rect(
fit,
img.width() as f32,
img.height() as f32,
layout.width,
layout.height,
);
let paint = Paint::default();
if matches!(fit, ImageFit::Cover) {
canvas.save();
canvas.clip_rect(
Rect::from_xywh(0.0, 0.0, layout.width, layout.height),
skia_safe::ClipOp::Intersect,
true,
);
canvas.draw_image_rect(img, None, dst, &paint);
canvas.restore();
} else {
canvas.draw_image_rect(img, None, dst, &paint);
}
}

/// 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. `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.
/// When `loop_video` is set, playback wraps within the source's own
/// probed duration instead of running past it and holding on
/// whatever the last extractable frame happens to be.
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 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 @@ -54,9 +150,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 All @@ -74,9 +168,7 @@ impl Painter for Video {
let row_bytes = fw as usize * 4;
let data = skia_safe::Data::new_copy(rgba);
if let Some(img) = skia_safe::images::raster_from_data(&img_info, data, row_bytes) {
let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
let paint = Paint::default();
canvas.draw_image_rect(img, None, dst, &paint);
draw_fitted(canvas, img, &self.fit, layout);
}
return;
}
Expand Down Expand Up @@ -105,9 +197,7 @@ impl Painter for Video {
};
let skia_data = skia_safe::Data::new_copy(&frame_data);
if let Some(img) = skia_safe::Image::from_encoded(skia_data) {
let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
let paint = Paint::default();
canvas.draw_image_rect(img, None, dst, &paint);
draw_fitted(canvas, img, &self.fit, layout);
}
}
}
Expand Down
188 changes: 188 additions & 0 deletions crates/rustmotion-components/tests/audit_ws_d.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//! Regression tests for the `video` component's dead-field fixes: `fit`,
//! `trim_end`, `loop_video`, and the straight-vs-premultiplied alpha bug on
//! its cached-frame draw path.
//!
//! Every case populates `video_frame_cache()` directly with hand-built RGBA
//! frames rather than shelling out to a real ffmpeg decode: the field this
//! module exercises (`Video::paint_content`) is one call away from the
//! cache, and driving it that way keeps these tests hermetic and fast while
//! still going through the real, public `Painter` implementation — no
//! private items from `rustmotion-components` are touched.

use std::sync::Arc;

use rustmotion_components::Video;
use rustmotion_core::css::CssStyle;
use rustmotion_core::engine::animator::AnimatedProperties;
use rustmotion_core::engine::layout_pass::BoxLayout;
use rustmotion_core::engine::renderer::video_frame_cache;
use rustmotion_core::schema::ImageFit;
use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};

fn unique_src(label: &str) -> String {
format!(
"audit-ws-d-video-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before UNIX epoch")
.as_nanos()
)
}

#[allow(clippy::too_many_arguments)]
fn video(
src: &str,
fit: ImageFit,
trim_start: Option<f64>,
trim_end: Option<f64>,
loop_video: Option<bool>,
) -> Video {
Video {
src: src.to_string(),
trim_start,
trim_end,
playback_rate: None,
fit,
volume: 1.0,
loop_video,
timing: TimingConfig::default(),
style: CssStyle::default(),
timeline: Vec::new(),
stagger: None,
}
}

fn ctx_at(time: f64) -> PaintCtx {
PaintCtx {
time,
scenario_time: time,
scene_duration: 10.0,
frame_index: 0,
fps: 30,
video_width: 1920,
video_height: 1080,
stagger_offset: 0.0,
}
}

fn solid_rgba(color: [u8; 4], w: u32, h: u32) -> Vec<u8> {
let mut buf = Vec::with_capacity((w * h * 4) as usize);
for _ in 0..(w * h) {
buf.extend_from_slice(&color);
}
buf
}

/// Paints `video` into a fresh `w`×`h` surface (background transparent if
/// `transparent_bg`, opaque black otherwise) and reads the composited pixels
/// back as straight (unpremultiplied) RGBA.
fn paint_and_read(video: &Video, ctx: &PaintCtx, w: i32, h: i32, transparent_bg: bool) -> Vec<u8> {
let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
let bg = if transparent_bg {
skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)
} else {
skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0)
};
surface.canvas().clear(bg);

let layout = BoxLayout {
width: w as f32,
height: h as f32,
..Default::default()
};
let props = AnimatedProperties::default();
video.paint_content(surface.canvas(), &layout, &props, ctx);

let mut pixels = vec![0u8; (w * h * 4) as usize];
let info = skia_safe::ImageInfo::new(
(w, h),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
surface.read_pixels(&info, &mut pixels, (w * 4) as usize, (0, 0));
pixels
}

fn px(buf: &[u8], w: i32, x: i32, y: i32) -> [u8; 4] {
let i = ((y * w + x) * 4) as usize;
buf[i..i + 4].try_into().expect("pixel in bounds")
}

// ─── `fit` was declared, documented, and never read ────────────────────────

/// A 10×20 source into a 40×40 box under `contain` must letterbox — scale
/// is `min(40/10, 40/40) = 1`, so the drawn region is 10 wide, centred with
/// a 15px empty margin on each side. Before the fix, the painter always
/// stretched to the full box regardless of `fit`, so every pixel — margins
/// included — came out opaque.
#[test]
fn contain_fit_letterboxes_instead_of_stretching() {
let src = unique_src("fit-contain");
let (fw, fh) = (10u32, 20u32);
let cache_key = format!("{src}:40x40");
video_frame_cache().insert(
cache_key,
Arc::new(vec![(
0.0,
solid_rgba([255, 255, 255, 255], fw, fh),
fw,
fh,
)]),
);

let v = video(&src, ImageFit::Contain, None, None, None);
let ctx = ctx_at(0.0);
let pixels = paint_and_read(&v, &ctx, 40, 40, true);

assert_eq!(
px(&pixels, 40, 0, 20)[3],
0,
"letterboxed left margin must stay empty, not be stretched into"
);
assert_eq!(
px(&pixels, 40, 39, 20)[3],
0,
"letterboxed right margin must stay empty, not be stretched into"
);
assert_eq!(
px(&pixels, 40, 20, 20),
[255, 255, 255, 255],
"the drawn column itself must still be opaque"
);
}

/// `fill` (the CSS default `object-fit: fill` behaviour) must still stretch
/// to cover the whole box exactly as before — the fix must not regress the
/// one mode that already matched the pre-fix behaviour.
#[test]
fn fill_fit_still_stretches_to_the_whole_box() {
let src = unique_src("fit-fill");
let (fw, fh) = (10u32, 20u32);
let cache_key = format!("{src}:40x40");
video_frame_cache().insert(
cache_key,
Arc::new(vec![(
0.0,
solid_rgba([255, 255, 255, 255], fw, fh),
fw,
fh,
)]),
);

let v = video(&src, ImageFit::Fill, None, None, None);
let ctx = ctx_at(0.0);
let pixels = paint_and_read(&v, &ctx, 40, 40, true);

assert_eq!(
px(&pixels, 40, 0, 0)[3],
255,
"fill must cover every corner"
);
assert_eq!(
px(&pixels, 40, 39, 39)[3],
255,
"fill must cover every corner"
);
}
35 changes: 32 additions & 3 deletions crates/rustmotion/src/cli/commands/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,14 @@ pub(crate) fn resolve_name_template(
out.push_str(&replacement);
cursor = start + end + 1; // skip past '}'
} else {
out.push(bytes[cursor] as char);
cursor += 1;
let ch = template[cursor..].chars().next().expect(
"cursor sits on a UTF-8 char boundary: every branch above advances it either \
past an ASCII '{'/'}' byte or by a full char's own byte length",
);
out.push(ch);
cursor += ch.len_utf8();
}
}
let _ = bytes;
Ok(out)
}

Expand Down Expand Up @@ -492,6 +495,32 @@ mod name_template_tests {
"static.mp4"
);
}

/// The literal (non-`{field}`) text of the template used to be walked
/// byte-by-byte and each byte cast straight to `char` — a Latin-1
/// reinterpretation of whatever UTF-8 continuation bytes an accented
/// character produced. `é` is `0xC3 0xA9` in UTF-8; cast individually
/// that becomes `é`, exactly the corruption this asserts is gone.
#[test]
fn accented_literal_text_round_trips() {
let data = row(&[("id", json!("abc"))]);
assert_eq!(
resolve_name_template("résumé-{id}.mp4", &data, 0).unwrap(),
"résumé-abc.mp4"
);
}

/// A non-Latin script exercises characters that are more than two UTF-8
/// bytes wide, where a byte-at-a-time cast produces even more mangled
/// output than the two-byte Latin-1 case above.
#[test]
fn cjk_literal_text_round_trips() {
let data = row(&[("id", json!("1"))]);
assert_eq!(
resolve_name_template("动画-{id}.mp4", &data, 0).unwrap(),
"动画-1.mp4"
);
}
}

#[cfg(test)]
Expand Down
Loading
Loading