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 video_rs::ffmpeg;
234
235#[cfg(feature = "video")]
242struct BilinearVideoDecoder {
243 input_ctx: ffmpeg::format::context::Input,
244 decoder: ffmpeg::decoder::Video,
245 scaler: Option<ffmpeg::software::scaling::context::Context>,
246 stream_index: usize,
247 total_frames: Option<usize>,
249 fps: f32,
251}
252
253#[cfg(feature = "video")]
254impl BilinearVideoDecoder {
255 #[cfg_attr(coverage_nightly, coverage(off))]
256 fn new(path: &Path) -> Result<Self> {
257 ffmpeg::init().map_err(|e| InferenceError::VideoError(format!("FFmpeg init: {e}")))?;
258
259 let input_ctx = ffmpeg::format::input(path).map_err(|e| {
260 InferenceError::VideoError(format!("Cannot open {}: {e}", path.display()))
261 })?;
262
263 let stream = input_ctx
264 .streams()
265 .best(ffmpeg::media::Type::Video)
266 .ok_or_else(|| InferenceError::VideoError("No video stream found".into()))?;
267
268 let stream_index = stream.index();
269
270 #[allow(clippy::cast_possible_truncation)]
272 let fps = f64::from(stream.avg_frame_rate()) as f32;
273 #[allow(clippy::cast_precision_loss)]
274 let duration_secs = input_ctx.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE);
275 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
276 let total_frames = if duration_secs > 0.0 && fps > 0.0 {
277 Some((duration_secs * f64::from(fps)) as usize)
278 } else {
279 None
280 };
281
282 let context_decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
283 .map_err(|e| InferenceError::VideoError(format!("Codec context: {e}")))?;
284 let decoder = context_decoder
285 .decoder()
286 .video()
287 .map_err(|e| InferenceError::VideoError(format!("Video decoder: {e}")))?;
288
289 Ok(Self {
290 input_ctx,
291 decoder,
292 scaler: None,
293 stream_index,
294 total_frames,
295 fps,
296 })
297 }
298
299 #[cfg_attr(coverage_nightly, coverage(off))]
301 fn decode_next(&mut self) -> Option<Result<DynamicImage>> {
302 let mut decoded = ffmpeg::util::frame::video::Video::empty();
303
304 loop {
305 if self.decoder.receive_frame(&mut decoded).is_ok() {
307 return Some(self.frame_to_image(&decoded));
308 }
309
310 let mut found_packet = false;
312 for (stream, packet) in self.input_ctx.packets() {
313 if stream.index() == self.stream_index {
314 if self.decoder.send_packet(&packet).is_err() {
315 continue;
316 }
317 found_packet = true;
318 break;
319 }
320 }
321
322 if !found_packet {
323 let _ = self.decoder.send_eof();
325 return if self.decoder.receive_frame(&mut decoded).is_ok() {
326 Some(self.frame_to_image(&decoded))
327 } else {
328 None
329 };
330 }
331
332 if self.decoder.receive_frame(&mut decoded).is_ok() {
334 return Some(self.frame_to_image(&decoded));
335 }
336 }
337 }
338
339 #[cfg_attr(coverage_nightly, coverage(off))]
341 fn frame_to_image(
342 &mut self,
343 decoded: &ffmpeg::util::frame::video::Video,
344 ) -> Result<DynamicImage> {
345 if self.scaler.is_none() {
347 self.scaler = Some(
348 ffmpeg::software::scaling::context::Context::get(
349 decoded.format(),
350 decoded.width(),
351 decoded.height(),
352 ffmpeg::format::Pixel::RGB24,
353 decoded.width(),
354 decoded.height(),
355 ffmpeg::software::scaling::flag::Flags::BILINEAR,
356 )
357 .map_err(|e| InferenceError::VideoError(format!("Scaler init: {e}")))?,
358 );
359 }
360
361 let mut rgb_frame = ffmpeg::util::frame::video::Video::empty();
362 self.scaler
363 .as_mut()
364 .unwrap()
365 .run(decoded, &mut rgb_frame)
366 .map_err(|e| InferenceError::VideoError(format!("Scale: {e}")))?;
367
368 let width = rgb_frame.width();
369 let height = rgb_frame.height();
370 let data = rgb_frame.data(0);
371 let stride = rgb_frame.stride(0);
372
373 let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
375 for y in 0..height as usize {
376 let row = &data[y * stride..y * stride + (width as usize) * 3];
377 rgb_data.extend_from_slice(row);
378 }
379
380 let img_buffer = image::RgbImage::from_raw(width, height, rgb_data).ok_or_else(|| {
381 InferenceError::ImageError("Failed to create image from video frame".into())
382 })?;
383 Ok(DynamicImage::ImageRgb8(img_buffer))
384 }
385}
386
387pub struct SourceIterator {
389 source: Source,
390 current_frame: usize,
391 image_paths: Vec<PathBuf>,
392 #[cfg(feature = "video")]
393 decoder: Option<BilinearVideoDecoder>,
394 #[cfg(feature = "video")]
395 webcam_decoder: Option<(ffmpeg::format::context::Input, ffmpeg::decoder::Video)>,
396 #[cfg(feature = "video")]
397 webcam_stream_index: usize,
398 #[cfg(feature = "video")]
399 total_frames: Option<usize>,
400 #[cfg(feature = "video")]
401 webcam_init_failed: bool,
402 #[cfg(feature = "video")]
403 video_init_failed: bool,
404}
405
406impl SourceIterator {
407 pub fn new(source: Source) -> Result<Self> {
421 let image_paths = match &source {
422 Source::Directory(path) => Self::collect_images_from_dir(path)?,
423 Source::Glob(pattern) => Self::collect_images_from_glob(pattern)?,
424 Source::Image(path) => vec![path.clone()],
425 Source::ImageList(paths) => paths.clone(),
427 _ => vec![],
428 };
429
430 Ok(Self {
431 source,
432 current_frame: 0,
433 image_paths,
434 #[cfg(feature = "video")]
435 decoder: None,
436 #[cfg(feature = "video")]
437 webcam_decoder: None,
438 #[cfg(feature = "video")]
439 webcam_stream_index: 0,
440 #[cfg(feature = "video")]
441 total_frames: None,
442 #[cfg(feature = "video")]
443 webcam_init_failed: false,
444 #[cfg(feature = "video")]
445 video_init_failed: false,
446 })
447 }
448
449 fn collect_images_from_dir(dir: &Path) -> Result<Vec<PathBuf>> {
451 if !dir.is_dir() {
452 return Err(InferenceError::ImageError(format!(
453 "Not a directory: {}",
454 dir.display()
455 )));
456 }
457
458 let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
459 .filter_map(std::result::Result::ok)
460 .map(|entry| entry.path())
461 .filter(|path| Self::is_image_file(path))
462 .collect();
463
464 paths.sort();
465 Ok(paths)
466 }
467
468 fn collect_images_from_glob(pattern: &str) -> Result<Vec<PathBuf>> {
473 if let Some(star_pos) = pattern.find('*') {
476 let dir_part = &pattern[..star_pos];
477 let dir = if dir_part.is_empty() {
478 Path::new(".")
479 } else {
480 Path::new(dir_part.trim_end_matches('/').trim_end_matches('\\'))
481 };
482
483 let ext_filter: Option<String> = pattern[star_pos..]
485 .strip_prefix("*.")
486 .map(str::to_lowercase);
487
488 if !dir.is_dir() {
489 return Err(InferenceError::ImageError(format!(
490 "Directory not found: {}",
491 dir.display()
492 )));
493 }
494
495 let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)?
496 .filter_map(std::result::Result::ok)
497 .map(|entry| entry.path())
498 .filter(|path| {
499 ext_filter.as_ref().map_or_else(
500 || Self::is_image_file(path),
501 |ext| {
502 path.extension()
503 .is_some_and(|e| e.to_string_lossy().to_lowercase() == *ext)
504 },
505 )
506 })
507 .collect();
508
509 paths.sort();
510 Ok(paths)
511 } else {
512 Ok(vec![PathBuf::from(pattern)])
514 }
515 }
516
517 fn is_image_file(path: &Path) -> bool {
519 path.extension().is_some_and(|ext| {
520 let ext = ext.to_string_lossy().to_lowercase();
521 matches!(
522 ext.as_str(),
523 "jpg" | "jpeg" | "png" | "bmp" | "gif" | "webp" | "tiff" | "tif"
524 )
525 })
526 }
527
528 fn download_image(url: &str) -> Result<DynamicImage> {
530 let mut response = ureq::get(url)
531 .call()
532 .map_err(|e| InferenceError::ImageError(format!("Failed to download {url}: {e}")))?
533 .into_body();
534
535 let bytes = response.read_to_vec().map_err(|e| {
536 InferenceError::ImageError(format!("Failed to read response from {url}: {e}"))
537 })?;
538
539 image::load_from_memory(&bytes).map_err(|e| {
540 InferenceError::ImageError(format!("Failed to decode image from {url}: {e}"))
541 })
542 }
543
544 fn next_image_url(&mut self, url: &str) -> Option<Result<(DynamicImage, SourceMeta)>> {
546 if self.current_frame > 0 {
547 return None;
548 }
549
550 self.current_frame = 1;
551 let meta = SourceMeta {
552 frame_idx: 0,
553 total_frames: Some(1),
554 path: url.to_string(),
555 fps: None,
556 };
557
558 match Self::download_image(url) {
559 Ok(img) => Some(Ok((img, meta))),
560 Err(e) => Some(Err(e)),
561 }
562 }
563
564 fn next_image(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
566 if self.current_frame >= self.image_paths.len() {
567 return None;
568 }
569
570 let path = &self.image_paths[self.current_frame];
571 let meta = SourceMeta {
572 frame_idx: self.current_frame,
573 total_frames: Some(self.image_paths.len()),
574 path: path.to_string_lossy().to_string(),
575 fps: None,
576 };
577
578 self.current_frame += 1;
579
580 match image::open(path) {
581 Ok(img) => Some(Ok((img, meta))),
582 Err(e) => Some(Err(InferenceError::ImageError(format!(
583 "Failed to load {}: {e}",
584 path.display()
585 )))),
586 }
587 }
588
589 #[cfg(feature = "video")]
591 #[cfg_attr(coverage_nightly, coverage(off))]
592 #[allow(unsafe_code, clippy::too_many_lines)]
593 fn next_video_frame(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
594 if let Source::Webcam(idx) = &self.source {
596 if self.webcam_init_failed {
597 return None;
598 }
599
600 if self.webcam_decoder.is_none() {
601 ffmpeg::init().ok();
603
604 let input_format_name = if cfg!(target_os = "macos") {
606 "avfoundation"
607 } else if cfg!(target_os = "linux") {
608 "video4linux2"
609 } else if cfg!(target_os = "windows") {
610 "dshow"
611 } else {
612 self.webcam_init_failed = true;
613 return Some(Err(InferenceError::VideoError(
614 "Unsupported OS for webcam".to_string(),
615 )));
616 };
617
618 let c_name = std::ffi::CString::new(input_format_name).unwrap();
620 #[allow(unsafe_code)]
621 let ptr = unsafe { video_rs::ffmpeg::ffi::av_find_input_format(c_name.as_ptr()) };
622
623 let input_format = if ptr.is_null() {
624 self.webcam_init_failed = true;
625 return Some(Err(InferenceError::VideoError(format!(
626 "Input format '{input_format_name}' not found"
627 ))));
628 } else {
629 #[allow(unsafe_code, clippy::ptr_cast_constness)]
630 unsafe {
631 ffmpeg::format::Input::wrap(ptr.cast_mut())
632 }
633 };
634
635 let device_name = if cfg!(target_os = "macos") {
637 idx.to_string() } else if cfg!(target_os = "linux") {
639 format!("/dev/video{idx}")
640 } else if cfg!(target_os = "windows") {
641 format!("video={idx}")
642 } else {
643 self.webcam_init_failed = true;
644 return Some(Err(InferenceError::VideoError(
645 "Unsupported OS for webcam device name".to_string(),
646 )));
647 };
648
649 let mut options = ffmpeg::Dictionary::new();
651 options.set("framerate", "30");
652
653 match ffmpeg::format::open_with(
654 &PathBuf::from(&device_name),
655 &ffmpeg::Format::Input(input_format),
656 options,
657 ) {
658 #[allow(clippy::single_match_else)]
659 Ok(ctx) => match ctx {
660 ffmpeg::format::context::Context::Input(ictx) => {
661 let input =
662 ictx.streams()
663 .best(ffmpeg::media::Type::Video)
664 .ok_or_else(|| {
665 InferenceError::VideoError(
666 "No video stream found in webcam".to_string(),
667 )
668 });
669
670 match input {
671 Ok(stream) => {
672 let stream_index = stream.index();
673 self.webcam_stream_index = stream_index;
674 let context_decoder =
675 ffmpeg::codec::context::Context::from_parameters(
676 stream.parameters(),
677 )
678 .unwrap();
679 match context_decoder.decoder().video() {
680 Ok(decoder) => {
681 self.webcam_decoder = Some((ictx, decoder));
682 }
683 Err(e) => {
684 self.webcam_init_failed = true;
685 return Some(Err(InferenceError::VideoError(format!(
686 "Failed to create webcam decoder: {e}"
687 ))));
688 }
689 }
690 }
691 Err(e) => {
692 self.webcam_init_failed = true;
693 return Some(Err(e));
694 }
695 }
696 }
697 ffmpeg::format::context::Context::Output(_) => {
698 self.webcam_init_failed = true;
699 return Some(Err(InferenceError::VideoError(
700 "Opened context is not an input context".to_string(),
701 )));
702 }
703 },
704 Err(e) => {
705 self.webcam_init_failed = true;
706 return Some(Err(InferenceError::VideoError(format!(
707 "Failed to open webcam: {e}"
708 ))));
709 }
710 }
711 }
712
713 if let Some((ictx, decoder)) = &mut self.webcam_decoder {
714 let mut decoded = ffmpeg::util::frame::video::Video::empty();
715
716 for (stream, packet) in ictx.packets() {
718 if stream.index() == self.webcam_stream_index
719 && decoder.send_packet(&packet).is_ok()
720 && decoder.receive_frame(&mut decoded).is_ok()
721 {
722 let mut rgb_frame = ffmpeg::util::frame::video::Video::empty();
728 let mut scaler = ffmpeg::software::scaling::context::Context::get(
729 decoded.format(),
730 decoded.width(),
731 decoded.height(),
732 ffmpeg::format::Pixel::RGB24,
733 decoded.width(),
734 decoded.height(),
735 ffmpeg::software::scaling::flag::Flags::BILINEAR,
736 )
737 .unwrap();
738
739 scaler.run(&decoded, &mut rgb_frame).ok();
740
741 let width = rgb_frame.width();
742 let height = rgb_frame.height();
743 let data = rgb_frame.data(0);
744 let stride = rgb_frame.stride(0);
745
746 let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
748 for y in 0..height as usize {
749 let row = &data[y * stride..y * stride + (width as usize) * 3];
750 rgb_data.extend_from_slice(row);
751 }
752
753 let img_buffer =
754 image::RgbImage::from_raw(width, height, rgb_data).unwrap();
755 let img = DynamicImage::ImageRgb8(img_buffer);
756
757 let meta = SourceMeta {
758 frame_idx: self.current_frame,
759 total_frames: None,
760 path: format!("Webcam {idx}"),
761 fps: None,
762 };
763 self.current_frame += 1;
764 return Some(Ok((img, meta)));
765 }
766 }
767 return None; }
769 return None;
770 }
771
772 if self.decoder.is_none() {
774 if self.video_init_failed {
775 return None;
776 }
777
778 let path_str = match &self.source {
779 Source::Video(p) => Some(p.to_string_lossy().to_string()),
780 Source::Stream(s) => Some(s.clone()),
781 _ => None,
782 };
783
784 if let Some(path_str) = path_str {
785 match BilinearVideoDecoder::new(Path::new(&path_str)) {
786 Ok(d) => {
787 self.total_frames = d.total_frames;
788 self.decoder = Some(d);
789 }
790 Err(e) => {
791 self.video_init_failed = true;
792 return Some(Err(InferenceError::VideoError(format!(
793 "Failed to create decoder: {e}"
794 ))));
795 }
796 }
797 }
798 }
799
800 if let Some(decoder) = &mut self.decoder {
801 match decoder.decode_next() {
802 Some(Ok(img)) => {
803 let meta = SourceMeta {
804 frame_idx: self.current_frame,
805 total_frames: self.total_frames,
806 path: self
807 .source
808 .path()
809 .map(|p| p.to_string_lossy().to_string())
810 .unwrap_or_default(),
811 fps: Some(decoder.fps),
812 };
813 self.current_frame += 1;
814 Some(Ok((img, meta)))
815 }
816 Some(Err(e)) => Some(Err(e)),
817 None => None,
818 }
819 } else {
820 None
821 }
822 }
823
824 #[cfg(not(feature = "video"))]
825 #[allow(
826 clippy::unused_self,
827 clippy::unnecessary_wraps,
828 clippy::needless_pass_by_ref_mut
829 )]
830 fn next_video_frame(&mut self) -> Option<Result<(DynamicImage, SourceMeta)>> {
831 Some(Err(InferenceError::FeatureNotEnabled(
832 "Video support requires '--features video'".to_string(),
833 )))
834 }
835}
836
837impl Iterator for SourceIterator {
838 type Item = Result<(DynamicImage, SourceMeta)>;
839
840 fn next(&mut self) -> Option<Self::Item> {
841 match &self.source {
842 Source::Image(_) | Source::Directory(_) | Source::Glob(_) | Source::ImageList(_) => {
843 self.next_image()
844 }
845 Source::ImageUrl(url) => {
846 let url = url.clone();
847 self.next_image_url(&url)
848 }
849 Source::ImageBuffer(img) => {
850 if self.current_frame == 0 {
851 self.current_frame = 1;
852 let meta = SourceMeta::default();
853 Some(Ok((img.clone(), meta)))
854 } else {
855 None
856 }
857 }
858 Source::Array(arr) => {
859 if self.current_frame == 0 {
860 self.current_frame = 1;
861 let meta = SourceMeta::default();
862 match crate::utils::array_to_image(arr) {
864 Ok(img) => Some(Ok((img, meta))),
865 Err(e) => Some(Err(e)),
866 }
867 } else {
868 None
869 }
870 }
871 Source::Video(_) | Source::Webcam(_) | Source::Stream(_) => self.next_video_frame(),
872 }
873 }
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879
880 #[test]
881 fn test_source_from_string() {
882 assert!(matches!(Source::from("image.jpg"), Source::Image(_)));
883 assert!(matches!(Source::from("video.mp4"), Source::Video(_)));
884 assert!(matches!(
885 Source::from("rtsp://example.com"),
886 Source::Stream(_)
887 ));
888 assert!(matches!(Source::from("0"), Source::Webcam(0)));
889 assert!(matches!(Source::from("*.jpg"), Source::Glob(_)));
890 }
891
892 #[test]
893 fn test_source_checks() {
894 let img = Source::Image(PathBuf::from("test.jpg"));
895 assert!(img.is_image());
896 assert!(!img.is_video());
897
898 let vid = Source::Video(PathBuf::from("test.mp4"));
899 assert!(!vid.is_image());
900 assert!(vid.is_video());
901
902 let dir = Source::Directory(PathBuf::from("./images"));
903 assert!(dir.is_batch());
904 }
905
906 #[test]
907 fn test_from_str_url_classification() {
908 assert!(matches!(
910 Source::from("https://example.com/cat.png"),
911 Source::ImageUrl(_)
912 ));
913 assert!(matches!(
914 Source::from("http://example.com/dog.JPEG?size=large"),
915 Source::ImageUrl(_)
916 ));
917 assert!(matches!(
918 Source::from("https://example.com/live/stream"),
919 Source::Stream(_)
920 ));
921 assert!(matches!(
922 Source::from("rtmp://example.com/live"),
923 Source::Stream(_)
924 ));
925 }
926
927 #[test]
928 fn test_from_str_video_extensions() {
929 for ext in ["mp4", "avi", "mov", "mkv", "webm", "m4v", "mpeg", "mpg"] {
930 let s = format!("clip.{ext}");
931 assert!(
932 matches!(Source::from(s.as_str()), Source::Video(_)),
933 "{ext}"
934 );
935 }
936 assert!(matches!(Source::from("CLIP.MP4"), Source::Video(_)));
938 }
939
940 #[test]
941 fn test_is_image_url_helper() {
942 assert!(Source::is_image_url("a/b/c.jpg"));
943 assert!(Source::is_image_url("a.PNG?x=1"));
944 assert!(Source::is_image_url("a.tiff"));
945 assert!(!Source::is_image_url("a.mp4"));
946 assert!(!Source::is_image_url("no_extension"));
947 }
948
949 #[test]
950 fn test_from_conversions() {
951 assert!(matches!(
952 Source::from(String::from("a.jpg")),
953 Source::Image(_)
954 ));
955 assert!(matches!(
956 Source::from(PathBuf::from("a.jpg")),
957 Source::Image(_)
958 ));
959 assert!(matches!(Source::from(Path::new("a.jpg")), Source::Image(_)));
960 assert!(matches!(
961 Source::from(image::DynamicImage::new_rgb8(2, 2)),
962 Source::ImageBuffer(_)
963 ));
964 assert!(matches!(
965 Source::from(Array3::<u8>::zeros((2, 2, 3))),
966 Source::Array(_)
967 ));
968 assert!(matches!(Source::from(3u32), Source::Webcam(3)));
969 assert!(matches!(Source::from(5i32), Source::Webcam(5)));
970 }
971
972 #[test]
973 fn test_path_accessor() {
974 assert!(Source::Image(PathBuf::from("a.jpg")).path().is_some());
975 assert!(Source::Video(PathBuf::from("a.mp4")).path().is_some());
976 assert!(Source::Directory(PathBuf::from("d")).path().is_some());
977 assert!(Source::Webcam(0).path().is_none());
978 assert!(Source::Stream("rtsp://x".into()).path().is_none());
979 }
980
981 #[test]
982 fn test_source_meta_default() {
983 let m = SourceMeta::default();
984 assert_eq!(m.frame_idx, 0);
985 assert_eq!(m.total_frames, Some(1));
986 assert!(m.path.is_empty());
987 assert!(m.fps.is_none());
988 }
989
990 fn write_image(path: &Path) {
992 image::DynamicImage::new_rgb8(4, 4).save(path).unwrap();
993 }
994
995 #[test]
996 fn test_collect_images_from_dir() {
997 let tmp = tempfile::tempdir().unwrap();
998 write_image(&tmp.path().join("b.png"));
999 write_image(&tmp.path().join("a.jpg"));
1000 std::fs::write(tmp.path().join("notes.txt"), b"ignore me").unwrap();
1001
1002 let paths = SourceIterator::collect_images_from_dir(tmp.path()).unwrap();
1003 assert_eq!(paths.len(), 2);
1005 assert!(paths[0].ends_with("a.jpg"));
1006 assert!(paths[1].ends_with("b.png"));
1007
1008 assert!(SourceIterator::collect_images_from_dir(Path::new("definitely/missing")).is_err());
1010 }
1011
1012 #[test]
1013 fn test_collect_images_from_glob() {
1014 let tmp = tempfile::tempdir().unwrap();
1015 write_image(&tmp.path().join("a.jpg"));
1016 write_image(&tmp.path().join("b.png"));
1017
1018 let pattern = format!("{}/*.jpg", tmp.path().display());
1020 let jpgs = SourceIterator::collect_images_from_glob(&pattern).unwrap();
1021 assert_eq!(jpgs.len(), 1);
1022 assert!(jpgs[0].ends_with("a.jpg"));
1023
1024 let all_pattern = format!("{}/*", tmp.path().display());
1026 let all = SourceIterator::collect_images_from_glob(&all_pattern).unwrap();
1027 assert_eq!(all.len(), 2);
1028
1029 assert!(SourceIterator::collect_images_from_glob("missing_dir/*.jpg").is_err());
1031
1032 let single = SourceIterator::collect_images_from_glob("just/a/file.jpg").unwrap();
1034 assert_eq!(single, vec![PathBuf::from("just/a/file.jpg")]);
1035 }
1036
1037 #[test]
1038 fn test_iterator_image_buffer_yields_once() {
1039 let src = Source::ImageBuffer(image::DynamicImage::new_rgb8(4, 4));
1040 let mut it = SourceIterator::new(src).unwrap();
1041 assert!(it.next().is_some());
1042 assert!(it.next().is_none());
1043 }
1044
1045 #[test]
1046 fn test_iterator_array_yields_once() {
1047 let src = Source::Array(Array3::<u8>::zeros((4, 4, 3)));
1048 let mut it = SourceIterator::new(src).unwrap();
1049 let first = it.next().unwrap();
1050 assert!(first.is_ok());
1051 assert!(it.next().is_none());
1052 }
1053
1054 #[test]
1055 fn test_iterator_over_directory() {
1056 let tmp = tempfile::tempdir().unwrap();
1057 write_image(&tmp.path().join("a.jpg"));
1058 write_image(&tmp.path().join("b.png"));
1059
1060 let src = Source::Directory(tmp.path().to_path_buf());
1061 let it = SourceIterator::new(src).unwrap();
1062 let count = it.flatten().count();
1063 assert_eq!(count, 2);
1064 }
1065
1066 #[test]
1067 fn test_iterator_image_list_and_missing_file() {
1068 let tmp = tempfile::tempdir().unwrap();
1069 let good = tmp.path().join("a.jpg");
1070 write_image(&good);
1071 let missing = tmp.path().join("missing.jpg");
1072
1073 let src = Source::ImageList(vec![good, missing]);
1074 let mut it = SourceIterator::new(src).unwrap();
1075 assert!(it.next().unwrap().is_ok()); assert!(it.next().unwrap().is_err()); assert!(it.next().is_none());
1078 }
1079
1080 #[cfg(feature = "video")]
1081 #[test]
1082 fn test_iterator_over_video_file() {
1083 use crate::io::VideoWriter;
1084
1085 let tmp = tempfile::tempdir().unwrap();
1087 let path = tmp.path().join("clip.mp4");
1088 let mut writer = VideoWriter::new(&path, 32, 32, 10.0).unwrap();
1089 for _ in 0..5 {
1090 writer
1091 .write_frame(&image::DynamicImage::new_rgb8(32, 32))
1092 .unwrap();
1093 }
1094 writer.finish().unwrap();
1095
1096 let src = Source::Video(path);
1097 assert!(src.is_video());
1098 let mut it = SourceIterator::new(src).unwrap();
1099 let (frame, _meta) = it.next().expect("a frame").expect("decodes");
1101 assert_eq!(frame.width(), 32);
1102 let mut decoded = 1;
1104 for item in it.by_ref() {
1105 if item.is_ok() {
1106 decoded += 1;
1107 }
1108 }
1109 assert!(decoded >= 1);
1110 assert!(it.next().is_none());
1111 }
1112}