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
16static 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
23pub fn clear_asset_cache() {
25 if let Some(cache) = ASSET_CACHE.get() {
26 cache.clear();
27 }
28}
29
30static 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
38pub const ICON_OVERSAMPLE: u32 = 2;
48
49pub 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
75pub 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
94fn 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
103pub 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
111pub 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 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
165static 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
197pub 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
252pub 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
304pub 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#[derive(Debug, Clone, PartialEq)]
322pub struct VideoProbe {
323 pub width: u32,
324 pub height: u32,
325 pub duration_secs: f64,
326 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
357fn 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
371pub 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 #[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 #[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 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 #[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 let second = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("cache hit");
566 assert_eq!(first, second);
567 }
568
569 #[test]
572 fn ffmpeg_available_does_not_panic_either_way() {
573 let _ = ffmpeg_available();
577 }
578
579 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 #[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 #[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 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}