Skip to main content

rustmotion_core/engine/renderer/
assets.rs

1use std::path::{Path, PathBuf};
2use std::sync::{Arc, OnceLock};
3
4use dashmap::DashMap;
5
6use crate::error::{Result, RustmotionError};
7
8type GifFrame = (Vec<u8>, u32, u32);
9type GifData = Arc<(Vec<GifFrame>, Vec<f64>, f64)>;
10type GifCacheMap = Arc<DashMap<String, GifData>>;
11
12type VideoFrame = (f64, Vec<u8>, u32, u32);
13type VideoFrameList = Arc<Vec<VideoFrame>>;
14type VideoFrameCacheMap = Arc<DashMap<String, VideoFrameList>>;
15
16/// Global asset cache for decoded images (keyed by file path)
17static ASSET_CACHE: OnceLock<Arc<DashMap<String, skia_safe::Image>>> = OnceLock::new();
18
19pub fn asset_cache() -> &'static Arc<DashMap<String, skia_safe::Image>> {
20    ASSET_CACHE.get_or_init(|| Arc::new(DashMap::new()))
21}
22
23/// Clear the asset cache (call between renders if needed)
24pub fn clear_asset_cache() {
25    if let Some(cache) = ASSET_CACHE.get() {
26        cache.clear();
27    }
28}
29
30/// GIF frame data cache: stores decoded frames with pre-computed cumulative timestamps
31/// (frames_rgba, cumulative_times, total_duration) keyed by file path
32static GIF_CACHE: OnceLock<GifCacheMap> = OnceLock::new();
33
34pub fn gif_cache() -> &'static GifCacheMap {
35    GIF_CACHE.get_or_init(|| Arc::new(DashMap::new()))
36}
37
38// ─── Icon fetching ──────────────────────────────────────────────────────────
39
40/// How much larger than the *target* (layout) size icons are rasterized, so
41/// Skia's downscale keeps edges crisp under sub-pixel positioning and minor
42/// scale animations.
43///
44/// This lives here — not as a local `const` inside the painter — because it
45/// must feed the exact same computation [`icon_cache_key`] uses. See that
46/// function's doc for why (issue #166).
47pub const ICON_OVERSAMPLE: u32 = 2;
48
49/// Single source of truth for both the oversampled rasterization size and
50/// the [`asset_cache`] key used for a given icon at a given *target*
51/// (layout) size. Returns `(render_width, render_height, cache_key)`.
52///
53/// # Issue #166
54///
55/// Before this function existed, `icon.rs`'s painter and `preload.rs`'s
56/// prefetcher each built the cache key from their own inlined `format!`.
57/// The painter multiplied the target size by [`ICON_OVERSAMPLE`] before
58/// building the key; the preloader did not. For a 40×40 icon the painter
59/// looked up `"icon:...:80x80"` while the preloader could only ever have
60/// written `"icon:...:40x40"` — the two keys could never collide, so
61/// `prefetch_icons` never once avoided a duplicate network fetch, and (had
62/// its rasterization size matched the key by coincidence) would have cached
63/// a bitmap at half the resolution the painter actually samples.
64///
65/// Both call sites now go through this one function, so they cannot drift
66/// apart again — fixing the key without also fixing the raster size (or
67/// vice versa) is no longer expressible.
68pub fn icon_cache_key(icon: &str, color: &str, target_w: u32, target_h: u32) -> (u32, u32, String) {
69    let render_w = target_w.max(1) * ICON_OVERSAMPLE;
70    let render_h = target_h.max(1) * ICON_OVERSAMPLE;
71    let cache_key = format!("icon:{icon}:{color}:{render_w}x{render_h}");
72    (render_w, render_h, cache_key)
73}
74
75/// Returns the icon disk-cache directory: `~/.cache/rustmotion/icons`.
76///
77/// Mirrors [`google_fonts::font_cache_dir`](super::google_fonts::font_cache_dir),
78/// which does the same thing for downloaded font files — see that module
79/// for the disk-cache-before-network shape this was lifted from.
80pub fn icon_cache_dir() -> PathBuf {
81    #[cfg(target_os = "windows")]
82    let base = std::env::var_os("LOCALAPPDATA")
83        .map(PathBuf::from)
84        .unwrap_or_else(|| PathBuf::from("."));
85
86    #[cfg(not(target_os = "windows"))]
87    let base = std::env::var_os("HOME")
88        .map(|h| PathBuf::from(h).join(".cache"))
89        .unwrap_or_else(|| PathBuf::from(".cache"));
90
91    base.join("rustmotion").join("icons")
92}
93
94/// Deterministic on-disk file name for a given (icon, color, size). Icon ids
95/// contain `:` (`"lucide:home"`); replaced so the id survives as a legible
96/// file name instead of being hashed away.
97fn icon_cache_file(cache_dir: &Path, icon: &str, color: &str, width: u32, height: u32) -> PathBuf {
98    let slug = icon.replace(':', "_");
99    let hex_color = color.trim_start_matches('#').to_lowercase();
100    cache_dir.join(format!("{slug}-{hex_color}-{width}x{height}.svg"))
101}
102
103/// Fetch an icon's SVG bytes, checking the on-disk cache first and falling
104/// back to the Iconify API on a miss. Same public signature as before this
105/// fix — every existing caller (icon.rs, preload.rs, badge.rs,
106/// notification.rs, list.rs, stat.rs) gets the disk cache for free.
107pub fn fetch_icon_svg(icon: &str, color: &str, width: u32, height: u32) -> Result<Vec<u8>> {
108    fetch_icon_svg_in(icon, color, width, height, &icon_cache_dir())
109}
110
111/// Core of [`fetch_icon_svg`], with the cache directory injectable so tests
112/// can exercise the cache-hit path without touching `$HOME` or the network —
113/// mirrors `google_fonts::resolve_google_font`'s `cache_dir` parameter.
114pub fn fetch_icon_svg_in(
115    icon: &str,
116    color: &str,
117    width: u32,
118    height: u32,
119    cache_dir: &Path,
120) -> Result<Vec<u8>> {
121    let (prefix, name) =
122        icon.split_once(':')
123            .ok_or_else(|| RustmotionError::InvalidIconFormat {
124                icon: icon.to_string(),
125            })?;
126    let hex_color = color.trim_start_matches('#');
127    let width = width.max(1);
128    let height = height.max(1);
129
130    let cache_file = icon_cache_file(cache_dir, icon, color, width, height);
131    if let Ok(data) = std::fs::read(&cache_file) {
132        if !data.is_empty() {
133            return Ok(data);
134        }
135    }
136
137    let url = format!(
138        "https://api.iconify.design/{}/{}.svg?color=%23{}&width={}&height={}",
139        prefix, name, hex_color, width, height
140    );
141    let response = ureq::get(&url)
142        .call()
143        .map_err(|e| RustmotionError::IconFetch {
144            icon: icon.to_string(),
145            reason: e.to_string(),
146        })?;
147    let body = response
148        .into_body()
149        .read_to_vec()
150        .map_err(|e| RustmotionError::IconFetch {
151            icon: icon.to_string(),
152            reason: e.to_string(),
153        })?;
154
155    // Best-effort disk-cache write: failing to persist must not fail a
156    // fetch that already succeeded (matches the in-memory `asset_cache`'s
157    // existing tolerance for a cache that just doesn't get populated).
158    if std::fs::create_dir_all(cache_dir).is_ok() {
159        let _ = std::fs::write(&cache_file, &body);
160    }
161
162    Ok(body)
163}
164
165// ─── Video frame extraction ─────────────────────────────────────────────────
166
167/// Cache for pre-extracted video frames: key = "src:width:height", value = sorted list of (time, RGBA data, width, height)
168static VIDEO_FRAME_CACHE: OnceLock<VideoFrameCacheMap> = OnceLock::new();
169
170pub fn video_frame_cache() -> &'static VideoFrameCacheMap {
171    VIDEO_FRAME_CACHE.get_or_init(|| Arc::new(DashMap::new()))
172}
173
174pub fn find_closest_frame(
175    frames: &[(f64, Vec<u8>, u32, u32)],
176    target_time: f64,
177) -> Option<(&[u8], u32, u32)> {
178    if frames.is_empty() {
179        return None;
180    }
181    let idx = frames.partition_point(|(t, _, _, _)| *t < target_time);
182    let best = if idx == 0 {
183        0
184    } else if idx >= frames.len() {
185        frames.len() - 1
186    } else {
187        if (frames[idx].0 - target_time).abs() < (frames[idx - 1].0 - target_time).abs() {
188            idx
189        } else {
190            idx - 1
191        }
192    };
193    let (_, ref rgba, w, h) = frames[best];
194    Some((rgba, w, h))
195}
196
197/// Returns `true` if `ffmpeg` is on `PATH`.
198///
199/// Single source of truth for "should we even attempt to shell out to
200/// ffmpeg" — `extract_video_frame` below already surfaces a missing binary
201/// as `RustmotionError::FfmpegSpawn` on first use, but callers that decode
202/// many frames up front (`preload::preextract_video_frames`) want to check
203/// once and print one clear warning instead of failing identically once per
204/// frame. Mirrors the `ffmpeg_available` helper the `rustmotion` crate's
205/// `encode::video_audio` module already uses for the embedded-audio
206/// extraction path (PR #151) — same check, same reasoning, different asset
207/// kind.
208pub fn ffmpeg_available() -> bool {
209    std::process::Command::new("ffmpeg")
210        .args(["-version"])
211        .stdout(std::process::Stdio::null())
212        .stderr(std::process::Stdio::null())
213        .status()
214        .map(|s| s.success())
215        .unwrap_or(false)
216}
217
218pub fn extract_video_frame(src: &str, time: f64, width: u32, height: u32) -> Result<Vec<u8>> {
219    let output = std::process::Command::new("ffmpeg")
220        .args([
221            "-ss",
222            &format!("{:.3}", time),
223            "-i",
224            src,
225            "-vframes",
226            "1",
227            "-vf",
228            &format!("scale={}:{}", width, height),
229            "-f",
230            "image2pipe",
231            "-vcodec",
232            "png",
233            "-y",
234            "pipe:1",
235        ])
236        .stdout(std::process::Stdio::piped())
237        .stderr(std::process::Stdio::null())
238        .output()
239        .map_err(|e| RustmotionError::FfmpegSpawn {
240            reason: e.to_string(),
241        })?;
242
243    if !output.status.success() {
244        return Err(RustmotionError::FfmpegFrameExtract {
245            src: src.to_string(),
246        });
247    }
248
249    Ok(output.stdout)
250}
251
252// ─── Media metadata probing ────────────────────────────────────────────────
253//
254// "How long is this audio file? What are the dimensions of this image?" —
255// `rustmotion info` (`crates/rustmotion/src/cli/commands/info.rs`) answers
256// these by calling the functions below, one per asset kind. Two rules tie
257// them together:
258//
259// 1. Never touch the network. A `src` starting with `http://`/`https://` is
260//    identified and reported by the *caller* as "remote, not probed" before
261//    any of these functions ever run — probing a remote asset could mean
262//    downloading an unbounded amount of data just to read a header (e.g. a
263//    large file whose metadata atom sits at the end). These functions are
264//    written and tested only against local paths on the assumption the
265//    caller has already filtered URLs out; they do not special-case `http(s)
266//    ://` themselves.
267// 2. Cheap when a cheap path exists, honest when it does not. Image
268//    dimensions come from the `image` crate's `into_dimensions()`, which
269//    parses only the header bytes the decoder needs — not a full raster
270//    decode. Video has no such shortcut available in this codebase (nothing
271//    here links an ffmpeg *library*, only the `ffmpeg`/`ffprobe`
272//    *binaries*), so `probe_video_metadata` shells out to `ffprobe` — the
273//    same "assume PATH, fail with an actionable message otherwise" contract
274//    `ffmpeg_available`/`extract_video_frame` above already establish for
275//    ffmpeg itself.
276
277/// Cheap (header-only) dimensions of a local raster image file. The `image`
278/// crate's `ImageReader::into_dimensions` builds just enough of the decoder
279/// to read its declared dimensions, without decoding any pixel data —
280/// unlike every existing paint-time image load in this codebase (`image.rs`,
281/// `avatar.rs`, `mockup.rs`, ...), which all go through
282/// `skia_safe::Image::from_encoded` and pay for a full raster decode because
283/// they need the pixels themselves. A metadata-only query has no such need,
284/// so it takes the cheaper of the two paths instead of reusing theirs.
285pub fn probe_image_dimensions(path: &str) -> Result<(u32, u32)> {
286    let reader = image::ImageReader::open(path)
287        .map_err(|e| RustmotionError::ImageLoad {
288            path: path.to_string(),
289            reason: e.to_string(),
290        })?
291        .with_guessed_format()
292        .map_err(|e| RustmotionError::ImageLoad {
293            path: path.to_string(),
294            reason: e.to_string(),
295        })?;
296    reader
297        .into_dimensions()
298        .map_err(|e| RustmotionError::ImageLoad {
299            path: path.to_string(),
300            reason: e.to_string(),
301        })
302}
303
304/// Returns `true` if `ffprobe` is on `PATH`. Mirrors [`ffmpeg_available`]
305/// above — ffprobe ships alongside ffmpeg in every common distribution but
306/// is its own binary, so its own check.
307pub fn ffprobe_available() -> bool {
308    std::process::Command::new("ffprobe")
309        .args(["-version"])
310        .stdout(std::process::Stdio::null())
311        .stderr(std::process::Stdio::null())
312        .status()
313        .map(|s| s.success())
314        .unwrap_or(false)
315}
316
317/// Duration, dimensions and frame rate of a local video file, read from its
318/// container/stream metadata via `ffprobe` — never by decoding a frame (that
319/// is [`extract_video_frame`]'s job, and it decodes exactly one, not the
320/// metadata).
321#[derive(Debug, Clone, PartialEq)]
322pub struct VideoProbe {
323    pub width: u32,
324    pub height: u32,
325    pub duration_secs: f64,
326    /// `None` when ffprobe reports no parseable frame rate for the stream —
327    /// absence is reported as such, never guessed at.
328    pub fps: Option<f64>,
329}
330
331#[derive(Debug, Default, serde::Deserialize)]
332struct FfprobeOutput {
333    #[serde(default)]
334    streams: Vec<FfprobeStream>,
335    #[serde(default)]
336    format: Option<FfprobeFormat>,
337}
338
339#[derive(Debug, Default, serde::Deserialize)]
340struct FfprobeStream {
341    #[serde(default)]
342    width: Option<u32>,
343    #[serde(default)]
344    height: Option<u32>,
345    #[serde(default)]
346    r_frame_rate: Option<String>,
347    #[serde(default)]
348    duration: Option<String>,
349}
350
351#[derive(Debug, Default, serde::Deserialize)]
352struct FfprobeFormat {
353    #[serde(default)]
354    duration: Option<String>,
355}
356
357/// Parses ffprobe's `r_frame_rate` field ("30/1", "30000/1001", ...) into a
358/// float. `None` on anything that is not a clean `num/den` pair — including
359/// a zero denominator, which ffprobe can itself report for a stream with no
360/// meaningful frame rate.
361fn parse_frame_rate(s: &str) -> Option<f64> {
362    let (num, den) = s.split_once('/')?;
363    let num: f64 = num.trim().parse().ok()?;
364    let den: f64 = den.trim().parse().ok()?;
365    if den == 0.0 {
366        return None;
367    }
368    Some(num / den)
369}
370
371/// Probes a local video file's metadata via `ffprobe -show_streams
372/// -show_format -of json`, a single subprocess call (no frame decode, no
373/// download): the first video stream's width/height/frame rate, and a
374/// duration that prefers the stream's own `duration` field but falls back to
375/// the container's `format.duration` (some containers — notably ones
376/// produced by streaming muxers — only populate the latter).
377pub fn probe_video_metadata(src: &str) -> Result<VideoProbe> {
378    if !ffprobe_available() {
379        return Err(RustmotionError::Generic(format!(
380            "Cannot read metadata for '{src}': ffprobe not found on PATH. ffprobe ships with \
381             ffmpeg — install it with `brew install ffmpeg` (macOS) or see \
382             https://ffmpeg.org/download.html."
383        )));
384    }
385
386    let output = std::process::Command::new("ffprobe")
387        .args([
388            "-v",
389            "error",
390            "-select_streams",
391            "v:0",
392            "-show_streams",
393            "-show_format",
394            "-of",
395            "json",
396            src,
397        ])
398        .output()
399        .map_err(|e| RustmotionError::Generic(format!("Failed to run ffprobe on '{src}': {e}")))?;
400
401    if !output.status.success() {
402        let stderr = String::from_utf8_lossy(&output.stderr);
403        return Err(RustmotionError::Generic(format!(
404            "ffprobe could not read '{src}': {}",
405            stderr.trim()
406        )));
407    }
408
409    let parsed: FfprobeOutput = serde_json::from_slice(&output.stdout).map_err(|e| {
410        RustmotionError::Generic(format!(
411            "ffprobe produced output that could not be parsed for '{src}': {e}"
412        ))
413    })?;
414
415    let stream = parsed.streams.first().ok_or_else(|| {
416        RustmotionError::Generic(format!("'{src}' has no video stream ffprobe could find"))
417    })?;
418
419    let width = stream
420        .width
421        .ok_or_else(|| RustmotionError::Generic(format!("'{src}': ffprobe reported no width")))?;
422    let height = stream
423        .height
424        .ok_or_else(|| RustmotionError::Generic(format!("'{src}': ffprobe reported no height")))?;
425
426    let duration_secs = stream
427        .duration
428        .as_deref()
429        .and_then(|d| d.parse::<f64>().ok())
430        .or_else(|| {
431            parsed
432                .format
433                .as_ref()
434                .and_then(|f| f.duration.as_deref())
435                .and_then(|d| d.parse::<f64>().ok())
436        })
437        .ok_or_else(|| {
438            RustmotionError::Generic(format!("'{src}': ffprobe reported no duration"))
439        })?;
440
441    let fps = stream.r_frame_rate.as_deref().and_then(parse_frame_rate);
442
443    Ok(VideoProbe {
444        width,
445        height,
446        duration_secs,
447        fps,
448    })
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    fn unique_temp_dir(name: &str) -> PathBuf {
456        let dir = std::env::temp_dir()
457            .join("rustmotion-test-icons")
458            .join(format!(
459                "{name}-{}-{}",
460                std::process::id(),
461                std::time::SystemTime::now()
462                    .duration_since(std::time::UNIX_EPOCH)
463                    .unwrap()
464                    .as_nanos()
465            ));
466        std::fs::create_dir_all(&dir).expect("create test cache dir");
467        dir
468    }
469
470    // ── icon_cache_key: the fix for issue #166 ──────────────────────────────
471
472    /// Regression for issue #166: `icon.rs`'s painter and `preload.rs`'s
473    /// prefetcher used to build the cache key independently and disagreed
474    /// (painter oversampled, preloader did not — see the RED-phase output
475    /// this test replaced: `"icon:lucide:home:#FFFFFF:80x80"` vs
476    /// `"icon:lucide:home:#FFFFFF:40x40"`). Both call sites now go through
477    /// this one function, so there is only one formula left to test.
478    #[test]
479    fn oversamples_the_target_size_and_keys_on_the_oversampled_size() {
480        let (render_w, render_h, key) = icon_cache_key("lucide:home", "#FFFFFF", 40, 40);
481        assert_eq!(render_w, 40 * ICON_OVERSAMPLE);
482        assert_eq!(render_h, 40 * ICON_OVERSAMPLE);
483        assert_eq!(key, "icon:lucide:home:#FFFFFF:80x80");
484    }
485
486    #[test]
487    fn zero_target_size_is_clamped_to_at_least_one_before_oversampling() {
488        let (render_w, render_h, _key) = icon_cache_key("lucide:home", "#FFFFFF", 0, 0);
489        assert_eq!(render_w, ICON_OVERSAMPLE);
490        assert_eq!(render_h, ICON_OVERSAMPLE);
491    }
492
493    #[test]
494    fn distinct_icons_or_colors_never_collide() {
495        let (_, _, key_a) = icon_cache_key("lucide:home", "#FFFFFF", 40, 40);
496        let (_, _, key_b) = icon_cache_key("lucide:home", "#000000", 40, 40);
497        let (_, _, key_c) = icon_cache_key("lucide:settings", "#FFFFFF", 40, 40);
498        assert_ne!(key_a, key_b);
499        assert_ne!(key_a, key_c);
500    }
501
502    // ── fetch_icon_svg_in: disk cache (issue #166 item 2) ────────────────────
503
504    #[test]
505    fn disk_cache_hit_returns_bytes_without_touching_the_network() {
506        let cache_dir = unique_temp_dir("cache-hit");
507        let icon = "test-suite:offline-icon";
508        let color = "#ABCDEF";
509        let (w, h) = (48, 48);
510        let svg_bytes = b"<svg>fake cached icon for the test suite</svg>".to_vec();
511
512        let cache_file = icon_cache_file(&cache_dir, icon, color, w, h);
513        std::fs::write(&cache_file, &svg_bytes).unwrap();
514
515        // If this ever fell through to the network, either the test host is
516        // offline (fast, deterministic `IconFetch` error — `unwrap` panics
517        // clearly) or "test-suite:offline-icon" 404s upstream (same
518        // outcome). A silent pass here means the cache was genuinely hit.
519        let result = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("cache hit");
520        assert_eq!(result, svg_bytes);
521    }
522
523    #[test]
524    fn disk_cache_is_keyed_by_icon_color_and_size() {
525        let cache_dir = unique_temp_dir("cache-keying");
526        let a = icon_cache_file(&cache_dir, "lucide:home", "#FFFFFF", 80, 80);
527        let b = icon_cache_file(&cache_dir, "lucide:home", "#000000", 80, 80);
528        let c = icon_cache_file(&cache_dir, "lucide:home", "#FFFFFF", 40, 40);
529        assert_ne!(a, b, "different colors must not share a cache file");
530        assert_ne!(a, c, "different sizes must not share a cache file");
531    }
532
533    #[test]
534    fn missing_colon_fails_fast_without_touching_disk_or_network() {
535        let cache_dir = unique_temp_dir("invalid-format");
536        let result = fetch_icon_svg_in("not-a-valid-icon-id", "#FFFFFF", 40, 40, &cache_dir);
537        assert!(matches!(
538            result,
539            Err(RustmotionError::InvalidIconFormat { .. })
540        ));
541    }
542
543    // Live network test — mirrors `google_fonts`'s `live_fetch_inter_400`:
544    // excluded from normal runs, exercised manually when touching this path.
545    #[test]
546    #[ignore = "requires network access"]
547    fn live_fetch_writes_through_to_the_disk_cache() {
548        let cache_dir = unique_temp_dir("live-fetch");
549        let icon = "lucide:home";
550        let color = "#FFFFFF";
551        let (w, h) = (32, 32);
552
553        let first = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("live fetch");
554        assert!(!first.is_empty());
555
556        let cache_file = icon_cache_file(&cache_dir, icon, color, w, h);
557        assert!(
558            cache_file.exists(),
559            "a successful live fetch must be persisted to disk"
560        );
561
562        // A second call must be served from disk. `disk_cache_hit_returns_
563        // bytes_without_touching_the_network` already proves the mechanism
564        // in isolation; this just confirms the live-written file round-trips.
565        let second = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("cache hit");
566        assert_eq!(first, second);
567    }
568
569    // ── ffmpeg_available ──────────────────────────────────────────────────
570
571    #[test]
572    fn ffmpeg_available_does_not_panic_either_way() {
573        // Not asserting the actual bool: whether ffmpeg is installed depends
574        // on the host. This just proves the probe itself cannot panic or
575        // hang the preload path that depends on it (item 3).
576        let _ = ffmpeg_available();
577    }
578
579    // ── media-io: probe_image_dimensions ────────────────────────────────────
580
581    fn scratch_path(name: &str) -> PathBuf {
582        std::env::temp_dir().join(format!(
583            "rm_assets_probe_test_{}_{}_{}",
584            std::process::id(),
585            std::time::SystemTime::now()
586                .duration_since(std::time::UNIX_EPOCH)
587                .unwrap()
588                .as_nanos(),
589            name
590        ))
591    }
592
593    fn write_test_png(path: &Path, w: u32, h: u32) {
594        let img = image::RgbImage::from_pixel(w, h, image::Rgb([10, 20, 30]));
595        img.save(path).expect("write PNG fixture");
596    }
597
598    fn write_test_gif(path: &Path, w: u32, h: u32) {
599        use image::codecs::gif::GifEncoder;
600        let file = std::fs::File::create(path).expect("create GIF fixture");
601        let mut encoder = GifEncoder::new(file);
602        let frame = image::Frame::new(image::RgbaImage::from_pixel(
603            w,
604            h,
605            image::Rgba([200, 50, 10, 255]),
606        ));
607        encoder.encode_frame(frame).expect("encode GIF fixture");
608    }
609
610    #[test]
611    fn probe_image_dimensions_reads_a_png_header() {
612        let path = scratch_path("dims.png");
613        write_test_png(&path, 37, 21);
614        let (w, h) = probe_image_dimensions(path.to_str().unwrap()).expect("must read PNG dims");
615        assert_eq!((w, h), (37, 21));
616        let _ = std::fs::remove_file(&path);
617    }
618
619    /// Issue: nothing in `rustmotion-core` decoded GIF before this fix
620    /// (`Cargo.toml`'s `image` dependency only enabled png/jpeg/webp) — the
621    /// `gif` feature this test depends on is itself part of the fix.
622    #[test]
623    fn probe_image_dimensions_reads_a_gif_header() {
624        let path = scratch_path("dims.gif");
625        write_test_gif(&path, 12, 9);
626        let (w, h) = probe_image_dimensions(path.to_str().unwrap()).expect("must read GIF dims");
627        assert_eq!((w, h), (12, 9));
628        let _ = std::fs::remove_file(&path);
629    }
630
631    #[test]
632    fn probe_image_dimensions_on_a_missing_file_is_an_error_not_a_panic() {
633        let path = scratch_path("does-not-exist.png");
634        let result = probe_image_dimensions(path.to_str().unwrap());
635        assert!(
636            result.is_err(),
637            "missing file must be an error, not a panic"
638        );
639    }
640
641    #[test]
642    fn probe_image_dimensions_on_garbage_bytes_is_an_error_not_a_panic() {
643        let path = scratch_path("garbage.png");
644        std::fs::write(&path, b"this is not an image").unwrap();
645        let result = probe_image_dimensions(path.to_str().unwrap());
646        assert!(
647            result.is_err(),
648            "unreadable content must be an error, not a panic"
649        );
650        let _ = std::fs::remove_file(&path);
651    }
652
653    // ── media-io: parse_frame_rate ──────────────────────────────────────────
654
655    #[test]
656    fn parse_frame_rate_reads_integer_and_ntsc_fractions() {
657        assert_eq!(parse_frame_rate("30/1"), Some(30.0));
658        assert!((parse_frame_rate("30000/1001").unwrap() - 29.97).abs() < 0.01);
659    }
660
661    #[test]
662    fn parse_frame_rate_rejects_zero_denominator_and_garbage() {
663        assert_eq!(parse_frame_rate("30/0"), None);
664        assert_eq!(parse_frame_rate("not-a-rate"), None);
665    }
666
667    // ── media-io: probe_video_metadata ──────────────────────────────────────
668
669    fn make_test_video(path: &Path, width: u32, height: u32, fps: u32, duration_s: u32) -> bool {
670        std::process::Command::new("ffmpeg")
671            .args([
672                "-y",
673                "-loglevel",
674                "error",
675                "-f",
676                "lavfi",
677                "-i",
678                &format!("testsrc=size={width}x{height}:rate={fps}:duration={duration_s}"),
679                "-pix_fmt",
680                "yuv420p",
681            ])
682            .arg(path)
683            .status()
684            .map(|s| s.success())
685            .unwrap_or(false)
686    }
687
688    #[test]
689    fn probe_video_metadata_reads_dimensions_duration_and_fps() {
690        if !ffmpeg_available() || !ffprobe_available() {
691            eprintln!(
692                "probe_video_metadata_reads_dimensions_duration_and_fps: ffmpeg/ffprobe not \
693                 found on PATH — skipping"
694            );
695            return;
696        }
697        let path = scratch_path("probe.mp4");
698        assert!(
699            make_test_video(&path, 64, 36, 25, 2),
700            "fixture video must encode"
701        );
702
703        let probe =
704            probe_video_metadata(path.to_str().unwrap()).expect("must probe video metadata");
705        assert_eq!(probe.width, 64);
706        assert_eq!(probe.height, 36);
707        assert!(
708            (probe.duration_secs - 2.0).abs() < 0.2,
709            "duration: {}",
710            probe.duration_secs
711        );
712        assert!(probe.fps.is_some(), "expected a frame rate");
713        assert!(
714            (probe.fps.unwrap() - 25.0).abs() < 0.1,
715            "fps: {:?}",
716            probe.fps
717        );
718
719        let _ = std::fs::remove_file(&path);
720    }
721
722    #[test]
723    fn probe_video_metadata_on_a_missing_file_is_an_error_not_a_panic() {
724        if !ffprobe_available() {
725            eprintln!(
726                "probe_video_metadata_on_a_missing_file_is_an_error_not_a_panic: ffprobe not \
727                 found on PATH — skipping"
728            );
729            return;
730        }
731        let path = scratch_path("does-not-exist.mp4");
732        let result = probe_video_metadata(path.to_str().unwrap());
733        assert!(
734            result.is_err(),
735            "missing file must be an error, not a panic"
736        );
737    }
738
739    #[test]
740    fn probe_video_metadata_on_garbage_bytes_is_an_error_not_a_panic() {
741        if !ffprobe_available() {
742            eprintln!(
743                "probe_video_metadata_on_garbage_bytes_is_an_error_not_a_panic: ffprobe not \
744                 found on PATH — skipping"
745            );
746            return;
747        }
748        let path = scratch_path("garbage.mp4");
749        std::fs::write(&path, b"not a real video file").unwrap();
750        let result = probe_video_metadata(path.to_str().unwrap());
751        assert!(
752            result.is_err(),
753            "unreadable content must be an error, not a panic"
754        );
755        let _ = std::fs::remove_file(&path);
756    }
757
758    #[test]
759    fn ffprobe_available_does_not_panic_either_way() {
760        let _ = ffprobe_available();
761    }
762}