1use std::path::{Path, PathBuf};
9
10use image::DynamicImage;
11use ndarray::Array3;
12
13use crate::error::{InferenceError, Result};
14
15#[derive(Debug, Clone)]
17pub enum Source {
18 Image(PathBuf),
20 ImageBuffer(DynamicImage),
22 Array(Array3<u8>),
24 ImageUrl(String),
26 ImageList(Vec<PathBuf>),
28 Video(PathBuf),
30 Webcam(u32),
32 Stream(String),
34 Directory(PathBuf),
36 Glob(String),
38}
39
40impl Source {
41 #[must_use]
47 pub const fn is_image(&self) -> bool {
48 matches!(
49 self,
50 Self::Image(_) | Self::ImageBuffer(_) | Self::Array(_) | Self::ImageUrl(_)
51 )
52 }
53
54 #[must_use]
60 pub const fn is_video(&self) -> bool {
61 matches!(self, Self::Video(_) | Self::Webcam(_) | Self::Stream(_))
62 }
63
64 #[must_use]
70 pub const fn is_batch(&self) -> bool {
71 matches!(
72 self,
73 Self::Directory(_) | Self::Glob(_) | Self::ImageList(_)
74 )
75 }
76
77 #[must_use]
83 pub fn path(&self) -> Option<&Path> {
84 match self {
85 Self::Image(p) | Self::Video(p) | Self::Directory(p) => Some(p),
86 _ => None,
87 }
88 }
89
90 fn is_image_url(url: &str) -> bool {
92 let url_lower = url.to_lowercase();
93 let path_part = url_lower.split('?').next().unwrap_or(&url_lower);
95
96 std::path::Path::new(path_part)
97 .extension()
98 .is_some_and(|ext| {
99 let s = ext.to_string_lossy();
100 s.eq_ignore_ascii_case("jpg")
101 || s.eq_ignore_ascii_case("jpeg")
102 || s.eq_ignore_ascii_case("png")
103 || s.eq_ignore_ascii_case("bmp")
104 || s.eq_ignore_ascii_case("gif")
105 || s.eq_ignore_ascii_case("webp")
106 || s.eq_ignore_ascii_case("tiff")
107 || s.eq_ignore_ascii_case("tif")
108 })
109 }
110}
111
112impl From<&str> for Source {
114 fn from(s: &str) -> Self {
115 if let Ok(idx) = s.parse::<u32>() {
117 return Self::Webcam(idx);
118 }
119
120 if s.starts_with("http://") || s.starts_with("https://") {
122 if Self::is_image_url(s) {
124 return Self::ImageUrl(s.to_string());
125 }
126 return Self::Stream(s.to_string());
128 }
129
130 if s.starts_with("rtsp://") || s.starts_with("rtmp://") {
132 return Self::Stream(s.to_string());
133 }
134
135 if s.contains('*') {
137 return Self::Glob(s.to_string());
138 }
139
140 let path = PathBuf::from(s)
141 .canonicalize()
142 .unwrap_or_else(|_| PathBuf::from(s));
143
144 if path.is_dir() {
146 return Self::Directory(path);
147 }
148
149 if let Some(ext) = path.extension() {
151 let ext = ext.to_string_lossy().to_lowercase();
152 if matches!(
153 ext.as_str(),
154 "mp4" | "avi" | "mov" | "mkv" | "wmv" | "flv" | "webm" | "m4v" | "mpeg" | "mpg"
155 ) {
156 return Self::Video(path);
157 }
158 }
159
160 Self::Image(path)
162 }
163}
164
165impl From<String> for Source {
166 fn from(s: String) -> Self {
167 Self::from(s.as_str())
168 }
169}
170
171impl From<PathBuf> for Source {
172 fn from(path: PathBuf) -> Self {
173 Self::from(path.to_string_lossy().as_ref())
174 }
175}
176
177impl From<&Path> for Source {
178 fn from(path: &Path) -> Self {
179 Self::from(path.to_string_lossy().as_ref())
180 }
181}
182
183impl From<DynamicImage> for Source {
184 fn from(img: DynamicImage) -> Self {
185 Self::ImageBuffer(img)
186 }
187}
188
189impl From<Array3<u8>> for Source {
190 fn from(arr: Array3<u8>) -> Self {
191 Self::Array(arr)
192 }
193}
194
195impl From<u32> for Source {
196 fn from(idx: u32) -> Self {
197 Self::Webcam(idx)
198 }
199}
200
201impl From<i32> for Source {
202 fn from(idx: i32) -> Self {
203 #[allow(clippy::cast_sign_loss)]
204 Self::Webcam(idx as u32)
205 }
206}
207
208#[derive(Debug, Clone)]
210pub struct SourceMeta {
211 pub frame_idx: usize,
213 pub total_frames: Option<usize>,
215 pub path: String,
217 pub fps: Option<f32>,
219}
220
221impl Default for SourceMeta {
222 fn default() -> Self {
223 Self {
224 frame_idx: 0,
225 total_frames: Some(1),
226 path: String::new(),
227 fps: None,
228 }
229 }
230}
231
232#[cfg(feature = "video")]
233use ffmpeg_next as ffmpeg;
234
235#[cfg(feature = "video")]
244#[cfg_attr(coverage_nightly, coverage(off))]
245fn frame_to_rgb_image(
246 scaler: &mut Option<ffmpeg::software::scaling::context::Context>,
247 decoded: &ffmpeg::util::frame::video::Video,
248) -> Result<DynamicImage> {
249 let reusable = scaler.take().filter(|s| {
252 let i = s.input();
253 i.format == decoded.format() && i.width == decoded.width() && i.height == decoded.height()
254 });
255 let context = match reusable {
256 Some(s) => s,
257 None => ffmpeg::software::scaling::context::Context::get(
258 decoded.format(),
259 decoded.width(),
260 decoded.height(),
261 ffmpeg::format::Pixel::RGB24,
262 decoded.width(),
263 decoded.height(),
264 ffmpeg::software::scaling::flag::Flags::BILINEAR,
265 )
266 .map_err(|e| InferenceError::VideoError(format!("Scaler init: {e}")))?,
267 };
268
269 let mut rgb_frame = ffmpeg::util::frame::video::Video::empty();
270 scaler
271 .insert(context)
272 .run(decoded, &mut rgb_frame)
273 .map_err(|e| InferenceError::VideoError(format!("Scale: {e}")))?;
274
275 let width = rgb_frame.width();
276 let height = rgb_frame.height();
277 let data = rgb_frame.data(0);
278 let stride = rgb_frame.stride(0);
279
280 let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
282 for y in 0..height as usize {
283 let row = &data[y * stride..y * stride + (width as usize) * 3];
284 rgb_data.extend_from_slice(row);
285 }
286
287 let img_buffer = image::RgbImage::from_raw(width, height, rgb_data).ok_or_else(|| {
288 InferenceError::ImageError("Failed to create image from video frame".into())
289 })?;
290 Ok(DynamicImage::ImageRgb8(img_buffer))
291}
292
293#[cfg(feature = "video")]
294struct BilinearVideoDecoder {
295 input_ctx: ffmpeg::format::context::Input,
296 decoder: ffmpeg::decoder::Video,
297 scaler: Option<ffmpeg::software::scaling::context::Context>,
298 stream_index: usize,
299 total_frames: Option<usize>,
301 fps: f32,
303}
304
305#[cfg(feature = "video")]
306impl BilinearVideoDecoder {
307 #[cfg_attr(coverage_nightly, coverage(off))]
309 fn new(path: &Path) -> Result<Self> {
310 ffmpeg::init().map_err(|e| InferenceError::VideoError(format!("FFmpeg init: {e}")))?;
311
312 let input_ctx = ffmpeg::format::input(path).map_err(|e| {
313 InferenceError::VideoError(format!("Cannot open {}: {e}", path.display()))
314 })?;
315
316 let stream = input_ctx
317 .streams()
318 .best(ffmpeg::media::Type::Video)
319 .ok_or_else(|| InferenceError::VideoError("No video stream found".into()))?;
320
321 let stream_index = stream.index();
322
323 #[allow(clippy::cast_possible_truncation)]
325 let fps = f64::from(stream.avg_frame_rate()) as f32;
326 #[allow(clippy::cast_precision_loss)]
327 let duration_secs = input_ctx.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE);
328 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
329 let total_frames = if duration_secs > 0.0 && fps > 0.0 {
330 Some((duration_secs * f64::from(fps)) as usize)
331 } else {
332 None
333 };
334
335 let context_decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
336 .map_err(|e| InferenceError::VideoError(format!("Codec context: {e}")))?;
337 let decoder = context_decoder
338 .decoder()
339 .video()
340 .map_err(|e| InferenceError::VideoError(format!("Video decoder: {e}")))?;
341
342 Ok(Self {
343 input_ctx,
344 decoder,
345 scaler: None,
346 stream_index,
347 total_frames,
348 fps,
349 })
350 }
351
352 #[cfg_attr(coverage_nightly, coverage(off))]
354 fn decode_next(&mut self) -> Option<Result<DynamicImage>> {
355 let mut decoded = ffmpeg::util::frame::video::Video::empty();
356
357 loop {
358 if self.decoder.receive_frame(&mut decoded).is_ok() {
360 return Some(self.frame_to_image(&decoded));
361 }
362
363 let mut found_packet = false;
365 for (stream, packet) in self.input_ctx.packets() {
366 if stream.index() == self.stream_index {
367 if self.decoder.send_packet(&packet).is_err() {
368 continue;
369 }
370 found_packet = true;
371 break;
372 }
373 }
374
375 if !found_packet {
376 let _ = self.decoder.send_eof();
378 return if self.decoder.receive_frame(&mut decoded).is_ok() {
379 Some(self.frame_to_image(&decoded))
380 } else {
381 None
382 };
383 }
384
385 if self.decoder.receive_frame(&mut decoded).is_ok() {
387 return Some(self.frame_to_image(&decoded));
388 }
389 }
390 }
391
392 #[cfg_attr(coverage_nightly, coverage(off))]
394 fn frame_to_image(
395 &mut self,
396 decoded: &ffmpeg::util::frame::video::Video,
397 ) -> Result<DynamicImage> {
398 frame_to_rgb_image(&mut self.scaler, decoded)
399 }
400}
401
402pub struct SourceIterator {
404 source: Source,
405 current_frame: usize,
406 image_paths: Vec<PathBuf>,
407 #[cfg(feature = "video")]
408 decoder: Option<BilinearVideoDecoder>,
409 #[cfg(feature = "video")]
410 webcam_decoder: Option<(ffmpeg::format::context::Input, ffmpeg::decoder::Video)>,
411 #[cfg(feature = "video")]
413 webcam_scaler: Option<ffmpeg::software::scaling::context::Context>,
414 #[cfg(feature = "video")]
415 webcam_stream_index: usize,
416 #[cfg(feature = "video")]
417 total_frames: Option<usize>,
418 #[cfg(feature = "video")]
419 webcam_init_failed: bool,
420 #[cfg(feature = "video")]
421 video_init_failed: bool,
422}
423
424impl SourceIterator {
425 pub fn new(source: Source) -> Result<Self> {
439 let image_paths = match &source {
440 Source::Directory(path) => Self::collect_images_from_dir(path)?,
441 Source::Glob(pattern) => Self::collect_images_from_glob(pattern)?,
442 Source::Image(path) => vec![path.clone()],
443 Source::ImageList(paths) => paths.clone(),
445 _ => vec![],
446 };
447
448 Ok(Self {
449 source,
450 current_frame: 0,
451 image_paths,
452 #[cfg(feature = "video")]
453 decoder: None,
454 #[cfg(feature = "video")]
455 webcam_decoder: None,
456 #[cfg(feature = "video")]
457 webcam_scaler: None,
458 #[cfg(feature = "video")]
459 webcam_stream_index: 0,
460 #[cfg(feature = "video")]
461 total_frames: None,
462 #[cfg(feature = "video")]
463 webcam_init_failed: false,
464 #[cfg(feature = "video")]
465 video_init_failed: false,
466 })
467 }
468
469 fn collect_images_from_dir(dir: &Path) -> Result<Vec<PathBuf>> {
471 if !dir.is_dir() {
472 return Err(InferenceError::ImageError(format!(
473 "Not a directory: {}",
474 dir.display()
475 )));
476 }
477
478 let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
479 .filter_map(std::result::Result::ok)
480 .map(|entry| entry.path())
481 .filter(|path| Self::is_image_file(path))
482 .collect();
483
484 paths.sort();
485 Ok(paths)
486 }
487
488 fn collect_images_from_glob(pattern: &str) -> Result<Vec<PathBuf>> {
493 if let Some(star_pos) = pattern.find('*') {
496 let dir_part = &pattern[..star_pos];
497 let dir = if dir_part.is_empty() {
498 Path::new(".")
499 } else {
500 Path::new(dir_part.trim_end_matches('/').trim_end_matches('\\'))
501 };
502
503 let ext_filter: Option<String> = pattern[star_pos..]
505 .strip_prefix("*.")
506 .map(str::to_lowercase);
507
508 if !dir.is_dir() {
509 return Err(InferenceError::ImageError(format!(
510 "Directory not found: {}",
511 dir.display()
512 )));
513 }
514
515 let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
516 .filter_map(std::result::Result::ok)
517 .map(|entry| entry.path())
518 .filter(|path| {
519 ext_filter.as_ref().map_or_else(
520 || Self::is_image_file(path),
521 |ext| {
522 path.extension()
523 .is_some_and(|e| e.to_string_lossy().to_lowercase() == *ext)
524 },
525 )
526 })
527 .collect();
528
529 paths.sort();
530 Ok(paths)
531 } else {
532 Ok(vec![PathBuf::from(pattern)])
534 }
535 }
536
537 fn is_image_file(path: &Path) -> bool {
539 path.extension().is_some_and(|ext| {
540 let ext = ext.to_string_lossy().to_lowercase();
541 matches!(
542 ext.as_str(),
543 "jpg" | "jpeg" | "png" | "bmp" | "gif" | "webp" | "tiff" | "tif"
544 )
545 })
546 }
547
548 fn download_image(url: &str) -> Result<DynamicImage> {
550 let mut response = ureq::get(url)
551 .call()
552 .map_err(|e| InferenceError::ImageError(format!("Failed to download {url}: {e}")))?
553 .into_body();
554
555 let bytes = response.read_to_vec().map_err(|e| {
556 InferenceError::ImageError(format!("Failed to read response from {url}: {e}"))
557 })?;
558
559 image::load_from_memory(&bytes).map_err(|e| {
560 InferenceError::ImageError(format!("Failed to decode image from {url}: {e}"))
561 })
562 }
563
564 fn next_image_url(&mut self, url: &str) -> Option<Result<(DynamicImage, SourceMeta)>> {
566 if self.current_frame > 0 {
567 return None;
568 }
569
570 self.current_frame = 1;
571 let meta = SourceMeta {
572 frame_idx: 0,
573 total_frames: Some(1),
574 path: url.to_string(),
575 fps: None,
576 };
577
578 match Self::download_image(url) {
579 Ok(img) => Some(Ok((img, meta))),
580 Err(e) => Some(Err(e)),
581 }
582 }
583
584 fn next_image(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
586 if self.current_frame >= self.image_paths.len() {
587 return None;
588 }
589
590 let path = &self.image_paths[self.current_frame];
591 let meta = SourceMeta {
592 frame_idx: self.current_frame,
593 total_frames: Some(self.image_paths.len()),
594 path: path.to_string_lossy().to_string(),
595 fps: None,
596 };
597
598 self.current_frame += 1;
599
600 match image::open(path) {
601 Ok(img) => Some(Ok((img, meta))),
602 Err(e) => Some(Err(InferenceError::ImageError(format!(
603 "Failed to load {}: {e}",
604 path.display()
605 )))),
606 }
607 }
608
609 #[cfg(feature = "video")]
613 #[cfg_attr(coverage_nightly, coverage(off))]
614 #[allow(unsafe_code)]
615 fn open_webcam(&mut self, idx: u32) -> Result<()> {
616 ffmpeg::init().ok();
617
618 let (format_name, device_name) = if cfg!(target_os = "macos") {
619 ("avfoundation", idx.to_string()) } else if cfg!(target_os = "linux") {
621 ("video4linux2", format!("/dev/video{idx}"))
622 } else if cfg!(target_os = "windows") {
623 ("dshow", format!("video={idx}"))
624 } else {
625 return Err(InferenceError::VideoError(
626 "Unsupported OS for webcam".to_string(),
627 ));
628 };
629
630 let c_name = std::ffi::CString::new(format_name).map_err(|_| {
632 InferenceError::VideoError(format!("Invalid input format name '{format_name}'"))
633 })?;
634 let ptr = unsafe { ffmpeg::ffi::av_find_input_format(c_name.as_ptr()) };
635 if ptr.is_null() {
636 return Err(InferenceError::VideoError(format!(
637 "Input format '{format_name}' not found"
638 )));
639 }
640 #[allow(clippy::ptr_cast_constness)]
641 let input_format = unsafe { ffmpeg::format::Input::wrap(ptr.cast_mut()) };
642
643 let mut options = ffmpeg::Dictionary::new();
645 options.set("framerate", "30");
646
647 let opened = ffmpeg::format::open_with(
648 &PathBuf::from(&device_name),
649 &ffmpeg::Format::Input(input_format),
650 options,
651 )
652 .map_err(|e| InferenceError::VideoError(format!("Failed to open webcam: {e}")))?;
653
654 let ffmpeg::format::context::Context::Input(ictx) = opened else {
655 return Err(InferenceError::VideoError(
656 "Opened context is not an input context".to_string(),
657 ));
658 };
659
660 let stream = ictx
661 .streams()
662 .best(ffmpeg::media::Type::Video)
663 .ok_or_else(|| {
664 InferenceError::VideoError("No video stream found in webcam".to_string())
665 })?;
666 self.webcam_stream_index = stream.index();
667
668 let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
669 .map_err(|e| {
670 InferenceError::VideoError(format!("Failed to read webcam stream parameters: {e}"))
671 })?
672 .decoder()
673 .video()
674 .map_err(|e| {
675 InferenceError::VideoError(format!("Failed to create webcam decoder: {e}"))
676 })?;
677
678 self.webcam_decoder = Some((ictx, decoder));
679 Ok(())
680 }
681
682 #[cfg(feature = "video")]
684 #[cfg_attr(coverage_nightly, coverage(off))]
685 #[allow(unsafe_code, clippy::too_many_lines)]
686 fn next_video_frame(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
687 let webcam_idx = match &self.source {
690 Source::Webcam(idx) => Some(*idx),
691 _ => None,
692 };
693 if let Some(idx) = webcam_idx {
694 if self.webcam_init_failed {
695 return None;
696 }
697
698 if self.webcam_decoder.is_none()
699 && let Err(e) = self.open_webcam(idx)
700 {
701 self.webcam_init_failed = true;
702 return Some(Err(e));
703 }
704 if let Some((ictx, decoder)) = &mut self.webcam_decoder {
705 let mut decoded = ffmpeg::util::frame::video::Video::empty();
706
707 for (stream, packet) in ictx.packets() {
709 if stream.index() == self.webcam_stream_index
710 && decoder.send_packet(&packet).is_ok()
711 && decoder.receive_frame(&mut decoded).is_ok()
712 {
713 let img = match frame_to_rgb_image(&mut self.webcam_scaler, &decoded) {
714 Ok(img) => img,
715 Err(e) => return Some(Err(e)),
716 };
717
718 let meta = SourceMeta {
719 frame_idx: self.current_frame,
720 total_frames: None,
721 path: format!("Webcam {idx}"),
722 fps: None,
723 };
724 self.current_frame += 1;
725 return Some(Ok((img, meta)));
726 }
727 }
728 return None; }
730 return None;
731 }
732
733 if self.decoder.is_none() {
735 if self.video_init_failed {
736 return None;
737 }
738
739 let path_str = match &self.source {
740 Source::Video(p) => Some(p.to_string_lossy().to_string()),
741 Source::Stream(s) => Some(s.clone()),
742 _ => None,
743 };
744
745 if let Some(path_str) = path_str {
746 match BilinearVideoDecoder::new(Path::new(&path_str)) {
747 Ok(d) => {
748 self.total_frames = d.total_frames;
749 self.decoder = Some(d);
750 }
751 Err(e) => {
752 self.video_init_failed = true;
753 return Some(Err(InferenceError::VideoError(format!(
754 "Failed to create decoder: {e}"
755 ))));
756 }
757 }
758 }
759 }
760
761 if let Some(decoder) = &mut self.decoder {
762 match decoder.decode_next() {
763 Some(Ok(img)) => {
764 let meta = SourceMeta {
765 frame_idx: self.current_frame,
766 total_frames: self.total_frames,
767 path: self
768 .source
769 .path()
770 .map(|p| p.to_string_lossy().to_string())
771 .unwrap_or_default(),
772 fps: Some(decoder.fps),
773 };
774 self.current_frame += 1;
775 Some(Ok((img, meta)))
776 }
777 Some(Err(e)) => Some(Err(e)),
778 None => None,
779 }
780 } else {
781 None
782 }
783 }
784
785 #[cfg(not(feature = "video"))]
786 #[allow(
787 clippy::unused_self,
788 clippy::unnecessary_wraps,
789 clippy::needless_pass_by_ref_mut
790 )]
791 fn next_video_frame(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
792 Some(Err(InferenceError::FeatureNotEnabled(
793 "Video support requires '--features video'".to_string(),
794 )))
795 }
796}
797
798impl Iterator for SourceIterator {
799 type Item = Result<(DynamicImage, SourceMeta)>;
800
801 fn next(&mut self) -> Option<Self::Item> {
802 match &self.source {
803 Source::Image(_) | Source::Directory(_) | Source::Glob(_) | Source::ImageList(_) => {
804 self.next_image()
805 }
806 Source::ImageUrl(url) => {
807 let url = url.clone();
808 self.next_image_url(&url)
809 }
810 Source::ImageBuffer(img) => {
811 if self.current_frame == 0 {
812 self.current_frame = 1;
813 let meta = SourceMeta::default();
814 Some(Ok((img.clone(), meta)))
815 } else {
816 None
817 }
818 }
819 Source::Array(arr) => {
820 if self.current_frame == 0 {
821 self.current_frame = 1;
822 let meta = SourceMeta::default();
823 match crate::utils::array_to_image(arr) {
825 Ok(img) => Some(Ok((img, meta))),
826 Err(e) => Some(Err(e)),
827 }
828 } else {
829 None
830 }
831 }
832 Source::Video(_) | Source::Webcam(_) | Source::Stream(_) => self.next_video_frame(),
833 }
834 }
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840
841 #[test]
842 fn test_source_from_string() {
843 assert!(matches!(Source::from("image.jpg"), Source::Image(_)));
844 assert!(matches!(Source::from("video.mp4"), Source::Video(_)));
845 assert!(matches!(
846 Source::from("rtsp://example.com"),
847 Source::Stream(_)
848 ));
849 assert!(matches!(Source::from("0"), Source::Webcam(0)));
850 assert!(matches!(Source::from("*.jpg"), Source::Glob(_)));
851 }
852
853 #[test]
854 fn test_source_checks() {
855 let img = Source::Image(PathBuf::from("test.jpg"));
856 assert!(img.is_image());
857 assert!(!img.is_video());
858
859 let vid = Source::Video(PathBuf::from("test.mp4"));
860 assert!(!vid.is_image());
861 assert!(vid.is_video());
862
863 let dir = Source::Directory(PathBuf::from("./images"));
864 assert!(dir.is_batch());
865 }
866
867 #[test]
868 fn test_from_str_url_classification() {
869 assert!(matches!(
871 Source::from("https://example.com/cat.png"),
872 Source::ImageUrl(_)
873 ));
874 assert!(matches!(
875 Source::from("http://example.com/dog.JPEG?size=large"),
876 Source::ImageUrl(_)
877 ));
878 assert!(matches!(
879 Source::from("https://example.com/live/stream"),
880 Source::Stream(_)
881 ));
882 assert!(matches!(
883 Source::from("rtmp://example.com/live"),
884 Source::Stream(_)
885 ));
886 }
887
888 #[test]
889 fn test_from_str_video_extensions() {
890 for ext in ["mp4", "avi", "mov", "mkv", "webm", "m4v", "mpeg", "mpg"] {
891 let s = format!("clip.{ext}");
892 assert!(
893 matches!(Source::from(s.as_str()), Source::Video(_)),
894 "{ext}"
895 );
896 }
897 assert!(matches!(Source::from("CLIP.MP4"), Source::Video(_)));
899 }
900
901 #[test]
902 fn test_is_image_url_helper() {
903 assert!(Source::is_image_url("a/b/c.jpg"));
904 assert!(Source::is_image_url("a.PNG?x=1"));
905 assert!(Source::is_image_url("a.tiff"));
906 assert!(!Source::is_image_url("a.mp4"));
907 assert!(!Source::is_image_url("no_extension"));
908 }
909
910 #[test]
911 fn test_from_conversions() {
912 assert!(matches!(
913 Source::from(String::from("a.jpg")),
914 Source::Image(_)
915 ));
916 assert!(matches!(
917 Source::from(PathBuf::from("a.jpg")),
918 Source::Image(_)
919 ));
920 assert!(matches!(Source::from(Path::new("a.jpg")), Source::Image(_)));
921 assert!(matches!(
922 Source::from(image::DynamicImage::new_rgb8(2, 2)),
923 Source::ImageBuffer(_)
924 ));
925 assert!(matches!(
926 Source::from(Array3::<u8>::zeros((2, 2, 3))),
927 Source::Array(_)
928 ));
929 assert!(matches!(Source::from(3u32), Source::Webcam(3)));
930 assert!(matches!(Source::from(5i32), Source::Webcam(5)));
931 }
932
933 #[test]
934 fn test_path_accessor() {
935 assert!(Source::Image(PathBuf::from("a.jpg")).path().is_some());
936 assert!(Source::Video(PathBuf::from("a.mp4")).path().is_some());
937 assert!(Source::Directory(PathBuf::from("d")).path().is_some());
938 assert!(Source::Webcam(0).path().is_none());
939 assert!(Source::Stream("rtsp://x".into()).path().is_none());
940 }
941
942 #[test]
943 fn test_source_meta_default() {
944 let m = SourceMeta::default();
945 assert_eq!(m.frame_idx, 0);
946 assert_eq!(m.total_frames, Some(1));
947 assert!(m.path.is_empty());
948 assert!(m.fps.is_none());
949 }
950
951 fn write_image(path: &Path) {
953 image::DynamicImage::new_rgb8(4, 4).save(path).unwrap();
954 }
955
956 #[test]
957 fn test_collect_images_from_dir() {
958 let tmp = tempfile::tempdir().unwrap();
959 write_image(&tmp.path().join("b.png"));
960 write_image(&tmp.path().join("a.jpg"));
961 std::fs::write(tmp.path().join("notes.txt"), b"ignore me").unwrap();
962
963 let paths = SourceIterator::collect_images_from_dir(tmp.path()).unwrap();
964 assert_eq!(paths.len(), 2);
966 assert!(paths[0].ends_with("a.jpg"));
967 assert!(paths[1].ends_with("b.png"));
968
969 assert!(SourceIterator::collect_images_from_dir(Path::new("definitely/missing")).is_err());
971 }
972
973 #[test]
974 fn test_collect_images_from_glob() {
975 let tmp = tempfile::tempdir().unwrap();
976 write_image(&tmp.path().join("a.jpg"));
977 write_image(&tmp.path().join("b.png"));
978
979 let pattern = format!("{}/*.jpg", tmp.path().display());
981 let jpgs = SourceIterator::collect_images_from_glob(&pattern).unwrap();
982 assert_eq!(jpgs.len(), 1);
983 assert!(jpgs[0].ends_with("a.jpg"));
984
985 let all_pattern = format!("{}/*", tmp.path().display());
987 let all = SourceIterator::collect_images_from_glob(&all_pattern).unwrap();
988 assert_eq!(all.len(), 2);
989
990 assert!(SourceIterator::collect_images_from_glob("missing_dir/*.jpg").is_err());
992
993 let single = SourceIterator::collect_images_from_glob("just/a/file.jpg").unwrap();
995 assert_eq!(single, vec![PathBuf::from("just/a/file.jpg")]);
996 }
997
998 #[test]
999 fn test_iterator_image_buffer_yields_once() {
1000 let src = Source::ImageBuffer(image::DynamicImage::new_rgb8(4, 4));
1001 let mut it = SourceIterator::new(src).unwrap();
1002 assert!(it.next().is_some());
1003 assert!(it.next().is_none());
1004 }
1005
1006 #[test]
1007 fn test_iterator_array_yields_once() {
1008 let src = Source::Array(Array3::<u8>::zeros((4, 4, 3)));
1009 let mut it = SourceIterator::new(src).unwrap();
1010 let first = it.next().unwrap();
1011 assert!(first.is_ok());
1012 assert!(it.next().is_none());
1013 }
1014
1015 #[test]
1016 fn test_iterator_over_directory() {
1017 let tmp = tempfile::tempdir().unwrap();
1018 write_image(&tmp.path().join("a.jpg"));
1019 write_image(&tmp.path().join("b.png"));
1020
1021 let src = Source::Directory(tmp.path().to_path_buf());
1022 let it = SourceIterator::new(src).unwrap();
1023 let count = it.flatten().count();
1024 assert_eq!(count, 2);
1025 }
1026
1027 #[test]
1028 fn test_iterator_image_list_and_missing_file() {
1029 let tmp = tempfile::tempdir().unwrap();
1030 let good = tmp.path().join("a.jpg");
1031 write_image(&good);
1032 let missing = tmp.path().join("missing.jpg");
1033
1034 let src = Source::ImageList(vec![good, missing]);
1035 let mut it = SourceIterator::new(src).unwrap();
1036 assert!(it.next().unwrap().is_ok()); assert!(it.next().unwrap().is_err()); assert!(it.next().is_none());
1039 }
1040
1041 #[cfg(feature = "video")]
1042 #[test]
1043 fn test_iterator_over_video_file() {
1044 use crate::io::VideoWriter;
1045
1046 let tmp = tempfile::tempdir().unwrap();
1048 let path = tmp.path().join("clip.mp4");
1049 let mut writer = VideoWriter::new(&path, 32, 32, 10.0).unwrap();
1050 for _ in 0..5 {
1051 writer
1052 .write_frame(&image::DynamicImage::new_rgb8(32, 32))
1053 .unwrap();
1054 }
1055 writer.finish().unwrap();
1056
1057 let src = Source::Video(path);
1058 assert!(src.is_video());
1059 let mut it = SourceIterator::new(src).unwrap();
1060 let (frame, _meta) = it.next().expect("a frame").expect("decodes");
1062 assert_eq!(frame.width(), 32);
1063 let mut decoded = 1;
1065 for item in it.by_ref() {
1066 if item.is_ok() {
1067 decoded += 1;
1068 }
1069 }
1070 assert!(decoded >= 1);
1071 assert!(it.next().is_none());
1072 }
1073}