From a1649c0d4581f9a47b3dfdc36e87bd501c2a99cf Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 12 Aug 2026 09:51:08 +0200 Subject: [PATCH] feat(info): report what a scenario's media assets actually are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "media metadata readable from the scenario" gap. Nothing answered "how long is this audio track" or "what size is this image", so an author timing a scene against a track had to measure it somewhere else, by hand. `rustmotion info` grows a Media assets section, following the same walk-and-print shape the Springs and Text sizes sections already use rather than adding a `probe` subcommand that would have rebuilt the same plumbing and split "everything knowable about this scenario" across two commands. Three probes, each reusing what already existed: - Audio goes through `decode_audio_file`, the same decoder every render uses — never a second decode path. The cost is a full decode, and that is stated rather than hidden: this repository has no header-only audio path. - Images use `image`'s `into_dimensions`, a header-only read the crate already offered and nothing used; every existing decode goes through `Image::from_encoded`, which rasterises in full to answer a question about dimensions. - Video shells one `ffprobe -show_streams -show_format`, mirroring the existing `ffmpeg_available()` guard, and never decodes a frame. **Probing can never fail the command.** A remote asset is never opened — not the network, not the disk — because reading a header is not worth an unbounded download. A missing file is reported as missing before any decode is attempted. An unreadable one carries the exact reason. In all three cases `info` exits 0 and every other section still prints: audio track 1: audio "does-not-exist.wav" → file not found layer 1: image "corrupt.png" → could not read: unexpected end of file layer 2: image "https://…/remote.png" → remote asset, not fetched The second gap in this batch — secondary artefacts emitted during the render — is deliberately not delivered. Three independent blockers, each sufficient alone, are recorded in the issue filed alongside: the only construction site of `ResolvedScenario` is outside this change's scope (and a second file would break compiling anyway); per-frame RGBA buffers only exist inside the per-format encode loops, so the one in-scope function able to produce a frame *is* the render, and calling it again reproduces exactly the cost of the separate `still` invocation the feature is meant to replace; and `captions.rs` writes non-atomically, contradicting the discipline PR #145 and #151 established, while being frozen here. --- crates/rustmotion-cli/src/commands/info.rs | 399 ++++++++++++++++++ crates/rustmotion-core/Cargo.toml | 2 +- .../src/engine/renderer/assets.rs | 383 +++++++++++++++++ crates/rustmotion/src/encode/audio.rs | 102 +++++ 4 files changed, 885 insertions(+), 1 deletion(-) diff --git a/crates/rustmotion-cli/src/commands/info.rs b/crates/rustmotion-cli/src/commands/info.rs index 1db4e4bd..d545402c 100644 --- a/crates/rustmotion-cli/src/commands/info.rs +++ b/crates/rustmotion-cli/src/commands/info.rs @@ -76,6 +76,14 @@ pub fn cmd_info(input: &PathBuf) -> Result<()> { } } + let media_assets = collect_media_assets(&scenario); + if !media_assets.is_empty() { + println!("Media assets:"); + for report in &media_assets { + println!(" {}", report.describe()); + } + } + Ok(()) } @@ -278,6 +286,397 @@ fn spring_report(label: &str, spring: &SpringConfig) -> SpringReport { } } +/// "How long is this audio file? What are the dimensions of this image?" — +/// answered the same way `rustmotion info` already answers "what does this +/// spring settle at" and "how big does this text measure": walk the +/// scenario, report a line per asset found. `audio[].src` plus every +/// component whose `src` names a required, standalone media file (`image`, +/// `gif`, `video`, `avatar`, `avatar_group`, `mockup`) is covered. +/// +/// `lottie` and `svg` also carry a `src`, but it is optional there — an +/// inline `data` (or, for `lottie`, a pre-rendered `frames_dir`) is a fully +/// valid alternative — and what "metadata" even means for them differs from +/// a raster header or a media container's duration (a Lottie/Bodymovin JSON +/// document has its own `w`/`h`/`fr`/`ip`/`op` fields; an SVG has its own +/// `viewBox`). Deliberately not covered here. +/// +/// Two rules, applied uniformly to every kind below, are what make this safe +/// to run unconditionally as part of `info` rather than needing an opt-in +/// flag: +/// +/// 1. **Never touch the network.** A `src` starting with `http://`/ +/// `https://` is reported as [`MediaStatus::Remote`] without ever being +/// opened — probing a remote asset could mean downloading an unbounded +/// amount of data just to read a header. +/// 2. **A bad asset reports a reason, it never aborts the walk.** A missing +/// local file is [`MediaStatus::Missing`]; one that exists but can't be +/// decoded/probed (corrupt, wrong format, `ffprobe` absent, ...) is +/// [`MediaStatus::Unreadable`] carrying the underlying error's message. +/// `cmd_info` still exits `0` and still prints every other section. +#[derive(Debug)] +struct MediaAssetReport { + label: String, + kind: &'static str, + src: String, + status: MediaStatus, +} + +#[derive(Debug)] +enum MediaStatus { + Remote, + Missing, + Unreadable(String), + Image { + width: u32, + height: u32, + }, + Video { + width: u32, + height: u32, + duration_secs: f64, + fps: Option, + }, + Audio { + duration_secs: f64, + sample_rate: u32, + channels: u32, + }, +} + +impl MediaAssetReport { + fn describe(&self) -> String { + let head = format!("{}: {} \"{}\"", self.label, self.kind, self.src); + match &self.status { + MediaStatus::Remote => format!( + "{head} → remote asset, not fetched (http/https — would risk an unbounded \ + download just to read a header)" + ), + MediaStatus::Missing => format!("{head} → file not found"), + MediaStatus::Unreadable(reason) => format!("{head} → could not read: {reason}"), + MediaStatus::Image { width, height } => format!("{head} → {width}×{height}"), + MediaStatus::Video { + width, + height, + duration_secs, + fps, + } => { + let fps_part = fps + .map(|f| format!(" @ {f:.2}fps")) + .unwrap_or_else(|| " (fps unknown)".to_string()); + format!("{head} → {duration_secs:.2}s, {width}×{height}{fps_part}") + } + MediaStatus::Audio { + duration_secs, + sample_rate, + channels, + } => { + let ch = match channels { + 1 => "mono".to_string(), + 2 => "stereo".to_string(), + n => format!("{n}ch"), + }; + format!("{head} → {duration_secs:.2}s, {sample_rate}Hz {ch}") + } + } + } +} + +/// `true` for a `src` this codebase does not attempt to probe: everything +/// here resolves `src` as a local filesystem path (see the module-level +/// research this fix is built on — no component's `src` supports a remote +/// URL today, video's ffmpeg-backed path only reaches one incidentally by +/// handing the string straight to ffmpeg's own demuxer). Probing would mean +/// this command deciding, on an author's behalf, to make a network request — +/// and for a container whose metadata sits at the end of the file, that can +/// mean downloading the whole thing just to answer "how long is this?". +fn is_remote(src: &str) -> bool { + src.starts_with("http://") || src.starts_with("https://") +} + +fn probe_local_image(src: &str) -> MediaStatus { + if is_remote(src) { + return MediaStatus::Remote; + } + if !std::path::Path::new(src).exists() { + return MediaStatus::Missing; + } + match rustmotion::core::engine::renderer::probe_image_dimensions(src) { + Ok((width, height)) => MediaStatus::Image { width, height }, + Err(e) => MediaStatus::Unreadable(e.to_string()), + } +} + +fn probe_local_video(src: &str) -> MediaStatus { + if is_remote(src) { + return MediaStatus::Remote; + } + if !std::path::Path::new(src).exists() { + return MediaStatus::Missing; + } + match rustmotion::core::engine::renderer::probe_video_metadata(src) { + Ok(p) => MediaStatus::Video { + width: p.width, + height: p.height, + duration_secs: p.duration_secs, + fps: p.fps, + }, + Err(e) => MediaStatus::Unreadable(e.to_string()), + } +} + +fn probe_local_audio(src: &str) -> MediaStatus { + if is_remote(src) { + return MediaStatus::Remote; + } + if !std::path::Path::new(src).exists() { + return MediaStatus::Missing; + } + match rustmotion::encode::audio::probe_audio_metadata(src) { + Ok(p) => MediaStatus::Audio { + duration_secs: p.duration_secs, + sample_rate: p.sample_rate, + channels: p.channels, + }, + Err(e) => MediaStatus::Unreadable(e.to_string()), + } +} + +fn collect_media_assets(scenario: &ResolvedScenario) -> Vec { + let mut out = Vec::new(); + for (i, track) in scenario.audio.iter().enumerate() { + out.push(MediaAssetReport { + label: format!("audio track {}", i + 1), + kind: "audio", + status: probe_local_audio(&track.src), + src: track.src.clone(), + }); + } + for (vi, view) in scenario.views.iter().enumerate() { + for (si, scene) in view.scenes.iter().enumerate() { + let children = deserialize_children(scene); + let path = format!("view {} / scene {}", vi + 1, si + 1); + collect_media_assets_in_children(&children, &path, &mut out); + } + } + out +} + +fn collect_media_assets_in_children( + children: &[ChildComponent], + path: &str, + out: &mut Vec, +) { + for (i, child) in children.iter().enumerate() { + let p = format!("{path} / layer {}", i + 1); + match &child.component { + Component::Image(c) => out.push(MediaAssetReport { + label: p.clone(), + kind: "image", + status: probe_local_image(&c.src), + src: c.src.clone(), + }), + Component::Gif(c) => out.push(MediaAssetReport { + label: p.clone(), + kind: "gif", + status: probe_local_image(&c.src), + src: c.src.clone(), + }), + Component::Video(c) => out.push(MediaAssetReport { + label: p.clone(), + kind: "video", + status: probe_local_video(&c.src), + src: c.src.clone(), + }), + Component::Avatar(c) => out.push(MediaAssetReport { + label: p.clone(), + kind: "avatar", + status: probe_local_image(&c.src), + src: c.src.clone(), + }), + Component::AvatarGroup(c) => { + for (ai, avatar) in c.avatars.iter().enumerate() { + out.push(MediaAssetReport { + label: format!("{p} / avatar {}", ai + 1), + kind: "avatar_group", + status: probe_local_image(&avatar.src), + src: avatar.src.clone(), + }); + } + } + Component::Mockup(c) => out.push(MediaAssetReport { + label: p.clone(), + kind: "mockup", + status: probe_local_image(&c.src), + src: c.src.clone(), + }), + _ => {} + } + match &child.component { + Component::Card(c) => collect_media_assets_in_children(&c.children, &p, out), + Component::Flex(c) => collect_media_assets_in_children(&c.children, &p, out), + Component::Grid(c) => collect_media_assets_in_children(&c.children, &p, out), + Component::Positioned(c) => collect_media_assets_in_children(&c.children, &p, out), + Component::Container(c) => collect_media_assets_in_children(&c.children, &p, out), + _ => {} + } + } +} + +#[cfg(test)] +mod media_asset_tests { + //! media-io lot: `rustmotion info` must report duration/dimensions for + //! every media asset a scenario references, and must never fail the + //! whole command over one bad asset — it reports why instead. + use super::*; + + fn scratch_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "rm_info_media_test_{}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + name + )) + } + + fn write_test_png(path: &std::path::Path, w: u32, h: u32) { + let img = image::RgbImage::from_pixel(w, h, image::Rgb([1, 2, 3])); + img.save(path).expect("write PNG fixture"); + } + + #[test] + fn remote_image_is_reported_without_touching_the_network() { + let status = probe_local_image("https://example.com/does-not-matter.png"); + assert!(matches!(status, MediaStatus::Remote)); + } + + #[test] + fn remote_audio_is_reported_without_touching_the_network() { + let status = probe_local_audio("http://example.com/does-not-matter.mp3"); + assert!(matches!(status, MediaStatus::Remote)); + } + + #[test] + fn missing_local_image_is_reported_as_missing_not_an_error() { + let path = scratch_path("missing.png"); + let status = probe_local_image(path.to_str().unwrap()); + assert!(matches!(status, MediaStatus::Missing)); + } + + #[test] + fn unreadable_local_image_is_reported_with_a_reason() { + let path = scratch_path("corrupt.png"); + std::fs::write(&path, b"not a real png").unwrap(); + let status = probe_local_image(path.to_str().unwrap()); + match status { + MediaStatus::Unreadable(reason) => assert!(!reason.is_empty()), + other => panic!("expected Unreadable, got {other:?}"), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn readable_local_image_reports_its_dimensions() { + let path = scratch_path("ok.png"); + write_test_png(&path, 9, 4); + let status = probe_local_image(path.to_str().unwrap()); + assert!(matches!( + status, + MediaStatus::Image { + width: 9, + height: 4 + } + )); + let _ = std::fs::remove_file(&path); + } + + /// The core "never fails a valid scenario" proof: a scenario whose only + /// problem is one missing image must still walk to completion and + /// report every other section — `collect_media_assets` never returns an + /// `Err`, and never panics, on a missing/unreadable asset. + #[test] + fn collect_media_assets_never_fails_the_walk_on_a_bad_asset() { + let json = serde_json::json!({ + "video": { "width": 64, "height": 64, "fps": 10 }, + "audio": [ { "src": "/definitely/does/not/exist.mp3" } ], + "scenes": [ { "duration": 1.0, "children": [ + { "type": "image", "src": "/definitely/does/not/exist.png", + "style": { "width": 10, "height": 10 } }, + { "type": "text", "content": "hello" } + ] } ] + }); + let scenario: ResolvedScenario = + rustmotion::loader::load_scenario_from_source(None, Some(&json.to_string())) + .expect("scenario must load and validate structurally"); + let reports = collect_media_assets(&scenario); + assert_eq!( + reports.len(), + 2, + "expected the audio track and the image: {reports:?}" + ); + for r in &reports { + assert!( + matches!(r.status, MediaStatus::Missing), + "expected Missing for {}: got {}", + r.src, + r.describe() + ); + } + } + + #[test] + fn recurses_into_containers_the_same_way_springs_and_text_sizes_do() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "card", + "children": [{ + "type": "image", + "src": "/definitely/does/not/exist.png", + "style": { "width": 10, "height": 10 } + }] + })) + .unwrap(); + let mut out = Vec::new(); + collect_media_assets_in_children(&[child], "test", &mut out); + assert_eq!( + out.len(), + 1, + "image nested inside a card must be found: {out:?}" + ); + assert!(out[0].label.contains("layer 1"), "label: {}", out[0].label); + } + + #[test] + fn avatar_group_reports_one_entry_per_avatar() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "avatar_group", + "avatars": [ + { "src": "/does/not/exist/a.png" }, + { "src": "/does/not/exist/b.png" } + ] + })) + .unwrap(); + let mut out = Vec::new(); + collect_media_assets_in_children(&[child], "test", &mut out); + assert_eq!(out.len(), 2, "expected one report per avatar: {out:?}"); + assert!(out[0].label.contains("avatar 1")); + assert!(out[1].label.contains("avatar 2")); + } + + #[test] + fn a_scenario_with_no_media_produces_an_empty_report() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi" + })) + .unwrap(); + let mut out = Vec::new(); + collect_media_assets_in_children(&[child], "test", &mut out); + assert!(out.is_empty(), "unexpected media reports: {out:?}"); + } +} + #[cfg(test)] mod spring_report_tests { //! Issue #167 lot E: `rustmotion info` must surface the settle time of diff --git a/crates/rustmotion-core/Cargo.toml b/crates/rustmotion-core/Cargo.toml index 439de38f..55aaff0f 100644 --- a/crates/rustmotion-core/Cargo.toml +++ b/crates/rustmotion-core/Cargo.toml @@ -21,7 +21,7 @@ ureq = "3" syntect = { version = "5", default-features = false, features = ["default-fancy"] } similar = "2" qrcode = "0.14" -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } notify = "7" taffy = "0.10" cosmic-text = "0.19" diff --git a/crates/rustmotion-core/src/engine/renderer/assets.rs b/crates/rustmotion-core/src/engine/renderer/assets.rs index f6052a19..070e9475 100644 --- a/crates/rustmotion-core/src/engine/renderer/assets.rs +++ b/crates/rustmotion-core/src/engine/renderer/assets.rs @@ -249,6 +249,205 @@ pub fn extract_video_frame(src: &str, time: f64, width: u32, height: u32) -> Res Ok(output.stdout) } +// ─── Media metadata probing ──────────────────────────────────────────────── +// +// "How long is this audio file? What are the dimensions of this image?" — +// `rustmotion info` (`crates/rustmotion-cli/src/commands/info.rs`) answers +// these by calling the functions below, one per asset kind. Two rules tie +// them together: +// +// 1. Never touch the network. A `src` starting with `http://`/`https://` is +// identified and reported by the *caller* as "remote, not probed" before +// any of these functions ever run — probing a remote asset could mean +// downloading an unbounded amount of data just to read a header (e.g. a +// large file whose metadata atom sits at the end). These functions are +// written and tested only against local paths on the assumption the +// caller has already filtered URLs out; they do not special-case `http(s) +// ://` themselves. +// 2. Cheap when a cheap path exists, honest when it does not. Image +// dimensions come from the `image` crate's `into_dimensions()`, which +// parses only the header bytes the decoder needs — not a full raster +// decode. Video has no such shortcut available in this codebase (nothing +// here links an ffmpeg *library*, only the `ffmpeg`/`ffprobe` +// *binaries*), so `probe_video_metadata` shells out to `ffprobe` — the +// same "assume PATH, fail with an actionable message otherwise" contract +// `ffmpeg_available`/`extract_video_frame` above already establish for +// ffmpeg itself. + +/// Cheap (header-only) dimensions of a local raster image file. The `image` +/// crate's `ImageReader::into_dimensions` builds just enough of the decoder +/// to read its declared dimensions, without decoding any pixel data — +/// unlike every existing paint-time image load in this codebase (`image.rs`, +/// `avatar.rs`, `mockup.rs`, ...), which all go through +/// `skia_safe::Image::from_encoded` and pay for a full raster decode because +/// they need the pixels themselves. A metadata-only query has no such need, +/// so it takes the cheaper of the two paths instead of reusing theirs. +pub fn probe_image_dimensions(path: &str) -> Result<(u32, u32)> { + let reader = image::ImageReader::open(path) + .map_err(|e| RustmotionError::ImageLoad { + path: path.to_string(), + reason: e.to_string(), + })? + .with_guessed_format() + .map_err(|e| RustmotionError::ImageLoad { + path: path.to_string(), + reason: e.to_string(), + })?; + reader + .into_dimensions() + .map_err(|e| RustmotionError::ImageLoad { + path: path.to_string(), + reason: e.to_string(), + }) +} + +/// Returns `true` if `ffprobe` is on `PATH`. Mirrors [`ffmpeg_available`] +/// above — ffprobe ships alongside ffmpeg in every common distribution but +/// is its own binary, so its own check. +pub fn ffprobe_available() -> bool { + std::process::Command::new("ffprobe") + .args(["-version"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Duration, dimensions and frame rate of a local video file, read from its +/// container/stream metadata via `ffprobe` — never by decoding a frame (that +/// is [`extract_video_frame`]'s job, and it decodes exactly one, not the +/// metadata). +#[derive(Debug, Clone, PartialEq)] +pub struct VideoProbe { + pub width: u32, + pub height: u32, + pub duration_secs: f64, + /// `None` when ffprobe reports no parseable frame rate for the stream — + /// absence is reported as such, never guessed at. + pub fps: Option, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct FfprobeOutput { + #[serde(default)] + streams: Vec, + #[serde(default)] + format: Option, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct FfprobeStream { + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, + #[serde(default)] + r_frame_rate: Option, + #[serde(default)] + duration: Option, +} + +#[derive(Debug, Default, serde::Deserialize)] +struct FfprobeFormat { + #[serde(default)] + duration: Option, +} + +/// Parses ffprobe's `r_frame_rate` field ("30/1", "30000/1001", ...) into a +/// float. `None` on anything that is not a clean `num/den` pair — including +/// a zero denominator, which ffprobe can itself report for a stream with no +/// meaningful frame rate. +fn parse_frame_rate(s: &str) -> Option { + let (num, den) = s.split_once('/')?; + let num: f64 = num.trim().parse().ok()?; + let den: f64 = den.trim().parse().ok()?; + if den == 0.0 { + return None; + } + Some(num / den) +} + +/// Probes a local video file's metadata via `ffprobe -show_streams +/// -show_format -of json`, a single subprocess call (no frame decode, no +/// download): the first video stream's width/height/frame rate, and a +/// duration that prefers the stream's own `duration` field but falls back to +/// the container's `format.duration` (some containers — notably ones +/// produced by streaming muxers — only populate the latter). +pub fn probe_video_metadata(src: &str) -> Result { + if !ffprobe_available() { + return Err(RustmotionError::Generic(format!( + "Cannot read metadata for '{src}': ffprobe not found on PATH. ffprobe ships with \ + ffmpeg — install it with `brew install ffmpeg` (macOS) or see \ + https://ffmpeg.org/download.html." + ))); + } + + let output = std::process::Command::new("ffprobe") + .args([ + "-v", + "error", + "-select_streams", + "v:0", + "-show_streams", + "-show_format", + "-of", + "json", + src, + ]) + .output() + .map_err(|e| RustmotionError::Generic(format!("Failed to run ffprobe on '{src}': {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(RustmotionError::Generic(format!( + "ffprobe could not read '{src}': {}", + stderr.trim() + ))); + } + + let parsed: FfprobeOutput = serde_json::from_slice(&output.stdout).map_err(|e| { + RustmotionError::Generic(format!( + "ffprobe produced output that could not be parsed for '{src}': {e}" + )) + })?; + + let stream = parsed.streams.first().ok_or_else(|| { + RustmotionError::Generic(format!("'{src}' has no video stream ffprobe could find")) + })?; + + let width = stream + .width + .ok_or_else(|| RustmotionError::Generic(format!("'{src}': ffprobe reported no width")))?; + let height = stream + .height + .ok_or_else(|| RustmotionError::Generic(format!("'{src}': ffprobe reported no height")))?; + + let duration_secs = stream + .duration + .as_deref() + .and_then(|d| d.parse::().ok()) + .or_else(|| { + parsed + .format + .as_ref() + .and_then(|f| f.duration.as_deref()) + .and_then(|d| d.parse::().ok()) + }) + .ok_or_else(|| { + RustmotionError::Generic(format!("'{src}': ffprobe reported no duration")) + })?; + + let fps = stream.r_frame_rate.as_deref().and_then(parse_frame_rate); + + Ok(VideoProbe { + width, + height, + duration_secs, + fps, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -376,4 +575,188 @@ mod tests { // hang the preload path that depends on it (item 3). let _ = ffmpeg_available(); } + + // ── media-io: probe_image_dimensions ──────────────────────────────────── + + fn scratch_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "rm_assets_probe_test_{}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + name + )) + } + + fn write_test_png(path: &Path, w: u32, h: u32) { + let img = image::RgbImage::from_pixel(w, h, image::Rgb([10, 20, 30])); + img.save(path).expect("write PNG fixture"); + } + + fn write_test_gif(path: &Path, w: u32, h: u32) { + use image::codecs::gif::GifEncoder; + let file = std::fs::File::create(path).expect("create GIF fixture"); + let mut encoder = GifEncoder::new(file); + let frame = image::Frame::new(image::RgbaImage::from_pixel( + w, + h, + image::Rgba([200, 50, 10, 255]), + )); + encoder.encode_frame(frame).expect("encode GIF fixture"); + } + + #[test] + fn probe_image_dimensions_reads_a_png_header() { + let path = scratch_path("dims.png"); + write_test_png(&path, 37, 21); + let (w, h) = probe_image_dimensions(path.to_str().unwrap()).expect("must read PNG dims"); + assert_eq!((w, h), (37, 21)); + let _ = std::fs::remove_file(&path); + } + + /// Issue: nothing in `rustmotion-core` decoded GIF before this fix + /// (`Cargo.toml`'s `image` dependency only enabled png/jpeg/webp) — the + /// `gif` feature this test depends on is itself part of the fix. + #[test] + fn probe_image_dimensions_reads_a_gif_header() { + let path = scratch_path("dims.gif"); + write_test_gif(&path, 12, 9); + let (w, h) = probe_image_dimensions(path.to_str().unwrap()).expect("must read GIF dims"); + assert_eq!((w, h), (12, 9)); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn probe_image_dimensions_on_a_missing_file_is_an_error_not_a_panic() { + let path = scratch_path("does-not-exist.png"); + let result = probe_image_dimensions(path.to_str().unwrap()); + assert!( + result.is_err(), + "missing file must be an error, not a panic" + ); + } + + #[test] + fn probe_image_dimensions_on_garbage_bytes_is_an_error_not_a_panic() { + let path = scratch_path("garbage.png"); + std::fs::write(&path, b"this is not an image").unwrap(); + let result = probe_image_dimensions(path.to_str().unwrap()); + assert!( + result.is_err(), + "unreadable content must be an error, not a panic" + ); + let _ = std::fs::remove_file(&path); + } + + // ── media-io: parse_frame_rate ────────────────────────────────────────── + + #[test] + fn parse_frame_rate_reads_integer_and_ntsc_fractions() { + assert_eq!(parse_frame_rate("30/1"), Some(30.0)); + assert!((parse_frame_rate("30000/1001").unwrap() - 29.97).abs() < 0.01); + } + + #[test] + fn parse_frame_rate_rejects_zero_denominator_and_garbage() { + assert_eq!(parse_frame_rate("30/0"), None); + assert_eq!(parse_frame_rate("not-a-rate"), None); + } + + // ── media-io: probe_video_metadata ────────────────────────────────────── + + fn make_test_video(path: &Path, width: u32, height: u32, fps: u32, duration_s: u32) -> bool { + std::process::Command::new("ffmpeg") + .args([ + "-y", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + &format!("testsrc=size={width}x{height}:rate={fps}:duration={duration_s}"), + "-pix_fmt", + "yuv420p", + ]) + .arg(path) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + #[test] + fn probe_video_metadata_reads_dimensions_duration_and_fps() { + if !ffmpeg_available() || !ffprobe_available() { + eprintln!( + "probe_video_metadata_reads_dimensions_duration_and_fps: ffmpeg/ffprobe not \ + found on PATH — skipping" + ); + return; + } + let path = scratch_path("probe.mp4"); + assert!( + make_test_video(&path, 64, 36, 25, 2), + "fixture video must encode" + ); + + let probe = + probe_video_metadata(path.to_str().unwrap()).expect("must probe video metadata"); + assert_eq!(probe.width, 64); + assert_eq!(probe.height, 36); + assert!( + (probe.duration_secs - 2.0).abs() < 0.2, + "duration: {}", + probe.duration_secs + ); + assert!(probe.fps.is_some(), "expected a frame rate"); + assert!( + (probe.fps.unwrap() - 25.0).abs() < 0.1, + "fps: {:?}", + probe.fps + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn probe_video_metadata_on_a_missing_file_is_an_error_not_a_panic() { + if !ffprobe_available() { + eprintln!( + "probe_video_metadata_on_a_missing_file_is_an_error_not_a_panic: ffprobe not \ + found on PATH — skipping" + ); + return; + } + let path = scratch_path("does-not-exist.mp4"); + let result = probe_video_metadata(path.to_str().unwrap()); + assert!( + result.is_err(), + "missing file must be an error, not a panic" + ); + } + + #[test] + fn probe_video_metadata_on_garbage_bytes_is_an_error_not_a_panic() { + if !ffprobe_available() { + eprintln!( + "probe_video_metadata_on_garbage_bytes_is_an_error_not_a_panic: ffprobe not \ + found on PATH — skipping" + ); + return; + } + let path = scratch_path("garbage.mp4"); + std::fs::write(&path, b"not a real video file").unwrap(); + let result = probe_video_metadata(path.to_str().unwrap()); + assert!( + result.is_err(), + "unreadable content must be an error, not a panic" + ); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn ffprobe_available_does_not_panic_either_way() { + let _ = ffprobe_available(); + } } diff --git a/crates/rustmotion/src/encode/audio.rs b/crates/rustmotion/src/encode/audio.rs index 024aa434..62877a15 100644 --- a/crates/rustmotion/src/encode/audio.rs +++ b/crates/rustmotion/src/encode/audio.rs @@ -112,6 +112,36 @@ pub(crate) fn decode_audio_file(path: &str) -> Result<(Vec, u32, u32)> { Ok((all_samples, sample_rate, channels)) } +/// Duration, sample rate and channel count of a local audio file — the +/// `rustmotion info` answer to "how long is this audio track?". +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AudioProbe { + pub duration_secs: f64, + pub sample_rate: u32, + pub channels: u32, +} + +/// Probes a local audio file's duration/sample-rate/channel-count by calling +/// [`decode_audio_file`] — the *exact* decode `mix_audio_tracks_segment` +/// performs at render time, not a second, independently-drifting decode path +/// (e.g. reading a container's declared duration without decoding, which can +/// disagree with what the decoder actually produces for a file with an +/// imprecise header). The cost is a full decode of the file, same as at +/// render time; there is no cheaper header-only path in this codebase for +/// audio (unlike image dimensions, where the underlying decoder does expose +/// one — see `rustmotion_core::engine::renderer::probe_image_dimensions`). +pub fn probe_audio_metadata(path: &str) -> Result { + let (samples, sample_rate, channels) = decode_audio_file(path)?; + let channels = channels.max(1); + let frames = samples.len() as f64 / channels as f64; + let duration_secs = frames / sample_rate.max(1) as f64; + Ok(AudioProbe { + duration_secs, + sample_rate, + channels, + }) +} + /// Mix multiple audio tracks into a single PCM i16 buffer for minimp4. /// Output: interleaved i16, stereo, 44100Hz. /// @@ -757,4 +787,76 @@ mod tests { let _ = std::fs::remove_file(&wav_path); } + + // ── media-io: probe_audio_metadata ────────────────────────────────────── + // + // `rustmotion info` (crates/rustmotion-cli/src/commands/info.rs) needs + // "how long is this audio file, at what rate/channel count" for every + // `audio[].src` a scenario declares. The brief for that fix is explicit: + // reuse `decode_audio_file` — the exact decode `mix_audio_tracks_segment` + // performs at render time — rather than opening a second, independently + // drifting decode path (e.g. reading `symphonia`'s track metadata + // directly without decoding, which can disagree with what actually gets + // decoded for a file with an imprecise container-level duration). + + #[test] + fn probe_audio_metadata_reports_duration_rate_and_channels() { + let wav_path = std::env::temp_dir().join(format!( + "rm_audio_probe_test_{}_{}.wav", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + // 0.5s of mono audio at 22050Hz. + write_minimal_wav(&wav_path, 22_050, 11_025); + + let probe = probe_audio_metadata(wav_path.to_str().unwrap()).expect("must probe wav"); + assert_eq!(probe.sample_rate, 22_050); + assert_eq!(probe.channels, 1); + assert!( + (probe.duration_secs - 0.5).abs() < 0.01, + "duration: {}", + probe.duration_secs + ); + + let _ = std::fs::remove_file(&wav_path); + } + + #[test] + fn probe_audio_metadata_on_a_missing_file_is_an_error_not_a_panic() { + let path = std::env::temp_dir().join(format!( + "rm_audio_probe_missing_{}_{}.wav", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let result = probe_audio_metadata(path.to_str().unwrap()); + assert!( + matches!(result, Err(RustmotionError::AudioOpen { .. })), + "expected AudioOpen for a missing file, got: {result:?}" + ); + } + + #[test] + fn probe_audio_metadata_on_garbage_bytes_is_an_error_not_a_panic() { + let path = std::env::temp_dir().join(format!( + "rm_audio_probe_garbage_{}_{}.wav", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, b"this is not an audio file at all").unwrap(); + let result = probe_audio_metadata(path.to_str().unwrap()); + assert!( + result.is_err(), + "unreadable content must be an error, not a panic: {result:?}" + ); + let _ = std::fs::remove_file(&path); + } }