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