use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect};
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, probe_video_metadata, video_frame_cache,
};
use rustmotion_core::schema::{ImageFit, TimelineStep};
use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
fn default_volume() -> f32 {
1.0
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct Video {
pub src: String,
#[serde(default)]
pub trim_start: Option<f64>,
#[serde(default)]
pub trim_end: Option<f64>,
#[serde(default)]
pub playback_rate: Option<f64>,
#[serde(default)]
pub fit: ImageFit,
#[serde(default = "default_volume")]
pub volume: f32,
#[serde(default)]
pub loop_video: Option<bool>,
#[serde(flatten)]
pub timing: TimingConfig,
#[serde(default)]
pub style: CssStyle,
#[serde(default)]
pub timeline: Vec<TimelineStep>,
#[serde(default)]
pub stagger: Option<f32>,
}
rustmotion_core::impl_traits!(Video {
Animatable => animation,
Timed => timing,
Styled => style,
});
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)
}
}
}
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);
}
}
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 {
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,
canvas: &Canvas,
layout: &BoxLayout,
_props: &AnimatedProperties,
ctx: &PaintCtx,
) {
let source_time = self.effective_source_time(ctx.time);
let width = layout.width as u32;
let height = layout.height as u32;
let cache_key = format!("{}:{}x{}", self.src, width, height);
let cache = video_frame_cache();
if let Some(cached_frames) = cache.get(&cache_key) {
if let Some((rgba, fw, fh)) = find_closest_frame(&cached_frames, source_time) {
let img_info = ImageInfo::new(
(fw as i32, fh as i32),
ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
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) {
draw_fitted(canvas, img, &self.fit, layout);
}
return;
}
}
let frame_data = match extract_video_frame(&self.src, source_time, width, height) {
Ok(data) => data,
Err(e) => {
if crate::warn_once_for(&format!("video-frame:{}", self.src)) {
eprintln!(
"Warning: video '{}' could not be decoded: {e}. This component will \
render nothing for the remainder of the video.",
self.src
);
}
return;
}
};
let skia_data = skia_safe::Data::new_copy(&frame_data);
if let Some(img) = skia_safe::Image::from_encoded(skia_data) {
draw_fitted(canvas, img, &self.fit, layout);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustmotion_core::engine::animator::AnimatedProperties;
use rustmotion_core::engine::layout_pass::BoxLayout;
use rustmotion_core::traits::PaintCtx;
fn base_ctx() -> PaintCtx {
PaintCtx {
time: 0.0,
scenario_time: 0.0,
scene_duration: 1.0,
frame_index: 0,
fps: 30,
video_width: 100,
video_height: 100,
stagger_offset: 0.0,
}
}
#[test]
fn a_failed_frame_extraction_must_claim_its_warn_once_slot() {
let missing_src = std::env::temp_dir().join(format!(
"rustmotion-video-test-missing-{}-{}.mp4",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let _ = std::fs::remove_file(&missing_src);
let src_str = missing_src.to_str().unwrap().to_string();
let video = Video {
src: src_str.clone(),
trim_start: None,
trim_end: None,
playback_rate: None,
fit: Default::default(),
volume: 1.0,
loop_video: None,
timing: Default::default(),
style: CssStyle::default(),
timeline: Vec::new(),
stagger: None,
};
let layout = BoxLayout {
width: 40.0,
height: 40.0,
..Default::default()
};
let ctx = base_ctx();
let props = AnimatedProperties::default();
let mut surface = skia_safe::surfaces::raster_n32_premul((40, 40)).unwrap();
{
let canvas = surface.canvas();
video.paint_content(canvas, &layout, &props, &ctx);
}
let key = format!("video-frame:{}", src_str);
assert!(
!crate::warn_once_for(&key),
"paint_content must have claimed this warning slot on the failed extraction \
path — it is still unclaimed (first sighting), meaning nothing warned about \
the failure"
);
}
}