1#![allow(dead_code)]
9
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13use crate::clip::ClipId;
14use crate::error::{EditError, EditResult};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ProxyResolution {
19 Quarter,
21 Half,
23 Hd720,
25 Sd480,
27 Custom(u32, u32),
29}
30
31impl ProxyResolution {
32 #[must_use]
34 pub fn dimensions(&self, original_width: u32, original_height: u32) -> (u32, u32) {
35 match self {
36 Self::Quarter => ((original_width / 4).max(1), (original_height / 4).max(1)),
37 Self::Half => ((original_width / 2).max(1), (original_height / 2).max(1)),
38 Self::Hd720 => (1280, 720),
39 Self::Sd480 => (854, 480),
40 Self::Custom(w, h) => (*w, *h),
41 }
42 }
43
44 #[must_use]
46 pub fn label(self) -> &'static str {
47 match self {
48 Self::Quarter => "1/4 Resolution",
49 Self::Half => "1/2 Resolution",
50 Self::Hd720 => "720p",
51 Self::Sd480 => "480p",
52 Self::Custom(_, _) => "Custom",
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ProxyStatus {
60 NotGenerated,
62 Generating,
64 Ready,
66 Failed,
68 Outdated,
70}
71
72impl ProxyStatus {
73 #[must_use]
75 pub fn is_usable(self) -> bool {
76 matches!(self, Self::Ready)
77 }
78}
79
80#[derive(Debug, Clone)]
82pub struct ProxyMapping {
83 pub original_path: PathBuf,
85 pub proxy_path: PathBuf,
87 pub resolution: ProxyResolution,
89 pub status: ProxyStatus,
91 pub original_width: u32,
93 pub original_height: u32,
95 pub proxy_width: u32,
97 pub proxy_height: u32,
99}
100
101impl ProxyMapping {
102 #[must_use]
104 pub fn new(
105 original_path: PathBuf,
106 proxy_path: PathBuf,
107 resolution: ProxyResolution,
108 original_width: u32,
109 original_height: u32,
110 ) -> Self {
111 let (pw, ph) = resolution.dimensions(original_width, original_height);
112 Self {
113 original_path,
114 proxy_path,
115 resolution,
116 status: ProxyStatus::NotGenerated,
117 original_width,
118 original_height,
119 proxy_width: pw,
120 proxy_height: ph,
121 }
122 }
123
124 #[must_use]
126 #[allow(clippy::cast_precision_loss)]
127 pub fn scale_factor(&self) -> f64 {
128 if self.original_width == 0 {
129 return 1.0;
130 }
131 self.proxy_width as f64 / self.original_width as f64
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum ProxyMode {
138 Original,
140 ProxyPlayback,
142 ProxyOnly,
144}
145
146impl ProxyMode {
147 #[must_use]
149 pub fn uses_proxy_for_playback(self) -> bool {
150 matches!(self, Self::ProxyPlayback | Self::ProxyOnly)
151 }
152
153 #[must_use]
155 pub fn uses_original_for_export(self) -> bool {
156 matches!(self, Self::Original | Self::ProxyPlayback)
157 }
158}
159
160#[derive(Debug)]
162pub struct ProxyManager {
163 mappings: HashMap<String, ProxyMapping>,
165 clip_sources: HashMap<ClipId, String>,
167 pub mode: ProxyMode,
169 pub default_resolution: ProxyResolution,
171 pub proxy_dir: PathBuf,
173}
174
175impl ProxyManager {
176 #[must_use]
178 pub fn new(proxy_dir: PathBuf) -> Self {
179 Self {
180 mappings: HashMap::new(),
181 clip_sources: HashMap::new(),
182 mode: ProxyMode::ProxyPlayback,
183 default_resolution: ProxyResolution::Half,
184 proxy_dir,
185 }
186 }
187
188 pub fn register_source(
190 &mut self,
191 original_path: PathBuf,
192 original_width: u32,
193 original_height: u32,
194 ) -> EditResult<()> {
195 let key = original_path
196 .to_str()
197 .ok_or_else(|| EditError::InvalidEdit("Invalid path encoding".to_string()))?
198 .to_string();
199
200 let source_name = original_path
201 .file_name()
202 .and_then(|n| n.to_str())
203 .unwrap_or("unknown");
204 let proxy_filename = format!("proxy_{source_name}");
205 let proxy_path = self.proxy_dir.join(proxy_filename);
206
207 let mapping = ProxyMapping::new(
208 original_path,
209 proxy_path,
210 self.default_resolution,
211 original_width,
212 original_height,
213 );
214
215 self.mappings.insert(key, mapping);
216 Ok(())
217 }
218
219 pub fn associate_clip(&mut self, clip_id: ClipId, original_path: &str) {
221 self.clip_sources.insert(clip_id, original_path.to_string());
222 }
223
224 #[must_use]
229 pub fn resolve_path_for_playback(&self, clip_id: ClipId) -> Option<&PathBuf> {
230 let source_key = self.clip_sources.get(&clip_id)?;
231 let mapping = self.mappings.get(source_key)?;
232 if self.mode.uses_proxy_for_playback() && mapping.status.is_usable() {
233 Some(&mapping.proxy_path)
234 } else {
235 Some(&mapping.original_path)
236 }
237 }
238
239 #[must_use]
241 pub fn resolve_path_for_export(&self, clip_id: ClipId) -> Option<&PathBuf> {
242 let source_key = self.clip_sources.get(&clip_id)?;
243 let mapping = self.mappings.get(source_key)?;
244 if self.mode.uses_original_for_export() {
245 Some(&mapping.original_path)
246 } else if mapping.status.is_usable() {
247 Some(&mapping.proxy_path)
248 } else {
249 Some(&mapping.original_path)
250 }
251 }
252
253 pub fn mark_ready(&mut self, original_path: &str) -> bool {
255 if let Some(mapping) = self.mappings.get_mut(original_path) {
256 mapping.status = ProxyStatus::Ready;
257 true
258 } else {
259 false
260 }
261 }
262
263 pub fn mark_failed(&mut self, original_path: &str) -> bool {
265 if let Some(mapping) = self.mappings.get_mut(original_path) {
266 mapping.status = ProxyStatus::Failed;
267 true
268 } else {
269 false
270 }
271 }
272
273 pub fn mark_outdated(&mut self, original_path: &str) -> bool {
275 if let Some(mapping) = self.mappings.get_mut(original_path) {
276 mapping.status = ProxyStatus::Outdated;
277 true
278 } else {
279 false
280 }
281 }
282
283 #[must_use]
285 pub fn get_mapping(&self, original_path: &str) -> Option<&ProxyMapping> {
286 self.mappings.get(original_path)
287 }
288
289 #[must_use]
291 pub fn pending_generation(&self) -> Vec<&ProxyMapping> {
292 self.mappings
293 .values()
294 .filter(|m| matches!(m.status, ProxyStatus::NotGenerated | ProxyStatus::Outdated))
295 .collect()
296 }
297
298 #[must_use]
300 pub fn source_count(&self) -> usize {
301 self.mappings.len()
302 }
303
304 #[must_use]
306 pub fn ready_count(&self) -> usize {
307 self.mappings
308 .values()
309 .filter(|m| m.status.is_usable())
310 .count()
311 }
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum ProxyCodec {
317 Vp9,
319 Av1,
321 Vp8,
323}
324
325impl ProxyCodec {
326 #[must_use]
328 pub fn label(self) -> &'static str {
329 match self {
330 Self::Vp9 => "VP9",
331 Self::Av1 => "AV1",
332 Self::Vp8 => "VP8",
333 }
334 }
335}
336
337#[derive(Debug, Clone)]
339pub struct ProxyWorkflowConfig {
340 pub resolution: ProxyResolution,
342 pub codec: ProxyCodec,
344 pub quality: u8,
346 pub include_audio: bool,
348 pub max_concurrent: usize,
350}
351
352impl Default for ProxyWorkflowConfig {
353 fn default() -> Self {
354 Self {
355 resolution: ProxyResolution::Half,
356 codec: ProxyCodec::Vp9,
357 quality: 35,
358 include_audio: true,
359 max_concurrent: 4,
360 }
361 }
362}
363
364#[derive(Debug, Clone)]
366pub struct ProxyJobProgress {
367 pub source_key: String,
369 pub fraction: f64,
371 pub eta_seconds: f64,
373 pub stage: String,
375}
376
377impl ProxyJobProgress {
378 #[must_use]
380 pub fn new(source_key: String) -> Self {
381 Self {
382 source_key,
383 fraction: 0.0,
384 eta_seconds: -1.0,
385 stage: "queued".to_string(),
386 }
387 }
388
389 #[must_use]
391 pub fn is_complete(&self) -> bool {
392 (self.fraction - 1.0).abs() < 1e-9
393 }
394}
395
396#[derive(Debug, Clone)]
398pub struct ProxyChain {
399 entries: Vec<ProxyChainEntry>,
401 pub source_path: String,
403}
404
405#[derive(Debug, Clone)]
407pub struct ProxyChainEntry {
408 pub resolution: ProxyResolution,
410 pub proxy_path: PathBuf,
412 pub scale: f64,
414 pub status: ProxyStatus,
416}
417
418impl ProxyChain {
419 #[must_use]
421 pub fn new(source_path: String) -> Self {
422 Self {
423 entries: Vec::new(),
424 source_path,
425 }
426 }
427
428 pub fn add_level(&mut self, resolution: ProxyResolution, proxy_path: PathBuf, scale: f64) {
430 let entry = ProxyChainEntry {
431 resolution,
432 proxy_path,
433 scale: scale.clamp(0.0, 1.0),
434 status: ProxyStatus::NotGenerated,
435 };
436 self.entries.push(entry);
437 self.entries.sort_by(|a, b| {
439 a.scale
440 .partial_cmp(&b.scale)
441 .unwrap_or(std::cmp::Ordering::Equal)
442 });
443 }
444
445 #[must_use]
450 pub fn select_for_zoom(&self, zoom: f64) -> Option<&ProxyChainEntry> {
451 let candidate = self
453 .entries
454 .iter()
455 .find(|e| e.status.is_usable() && e.scale >= zoom);
456 if candidate.is_some() {
457 return candidate;
458 }
459 self.entries.iter().rev().find(|e| e.status.is_usable())
461 }
462
463 pub fn mark_ready_by_scale(&mut self, scale: f64) -> bool {
465 for entry in &mut self.entries {
466 if (entry.scale - scale).abs() < 1e-6 {
467 entry.status = ProxyStatus::Ready;
468 return true;
469 }
470 }
471 false
472 }
473
474 #[must_use]
476 pub fn level_count(&self) -> usize {
477 self.entries.len()
478 }
479
480 #[must_use]
482 pub fn ready_level_count(&self) -> usize {
483 self.entries.iter().filter(|e| e.status.is_usable()).count()
484 }
485
486 #[must_use]
488 pub fn entries(&self) -> &[ProxyChainEntry] {
489 &self.entries
490 }
491}
492
493#[derive(Debug, Clone)]
495pub struct RelinkResult {
496 pub proxy_path: PathBuf,
498 pub original_path: PathBuf,
500 pub match_method: RelinkMethod,
502 pub confidence: f64,
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
508pub enum RelinkMethod {
509 FilenameHash,
511 Metadata,
513 FilenamePattern,
515}
516
517fn fnv_hash_str(s: &str) -> u64 {
519 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
520 for byte in s.as_bytes() {
521 hash ^= u64::from(*byte);
522 hash = hash.wrapping_mul(0x0100_0000_01b3);
523 }
524 hash
525}
526
527#[derive(Debug)]
529pub struct ProxyRelinker {
530 originals_by_hash: HashMap<u64, PathBuf>,
532 originals_by_stem: HashMap<String, PathBuf>,
534}
535
536impl ProxyRelinker {
537 #[must_use]
539 pub fn new() -> Self {
540 Self {
541 originals_by_hash: HashMap::new(),
542 originals_by_stem: HashMap::new(),
543 }
544 }
545
546 pub fn register_original(&mut self, path: PathBuf) {
548 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
549 let hash = fnv_hash_str(filename);
550 self.originals_by_hash.insert(hash, path.clone());
551
552 let stem = path
553 .file_stem()
554 .and_then(|s| s.to_str())
555 .unwrap_or("")
556 .to_string();
557 if !stem.is_empty() {
558 self.originals_by_stem.insert(stem, path);
559 }
560 }
561
562 #[must_use]
566 pub fn relink(&self, proxy_path: &PathBuf) -> Option<RelinkResult> {
567 let proxy_filename = proxy_path
568 .file_name()
569 .and_then(|n| n.to_str())
570 .unwrap_or("");
571
572 let original_filename = proxy_filename
574 .strip_prefix("proxy_")
575 .unwrap_or(proxy_filename);
576
577 let hash = fnv_hash_str(original_filename);
579 if let Some(orig) = self.originals_by_hash.get(&hash) {
580 return Some(RelinkResult {
581 proxy_path: proxy_path.clone(),
582 original_path: orig.clone(),
583 match_method: RelinkMethod::FilenameHash,
584 confidence: 1.0,
585 });
586 }
587
588 let proxy_stem = std::path::Path::new(original_filename)
590 .file_stem()
591 .and_then(|s| s.to_str())
592 .unwrap_or("");
593 if !proxy_stem.is_empty() {
594 if let Some(orig) = self.originals_by_stem.get(proxy_stem) {
595 return Some(RelinkResult {
596 proxy_path: proxy_path.clone(),
597 original_path: orig.clone(),
598 match_method: RelinkMethod::FilenamePattern,
599 confidence: 0.8,
600 });
601 }
602 }
603
604 None
605 }
606
607 #[must_use]
609 pub fn original_count(&self) -> usize {
610 self.originals_by_hash.len()
611 }
612}
613
614impl Default for ProxyRelinker {
615 fn default() -> Self {
616 Self::new()
617 }
618}
619
620#[derive(Debug)]
622pub struct ProxyGenerationQueue {
623 pending: Vec<(String, ProxyWorkflowConfig)>,
625 in_progress: HashMap<String, ProxyJobProgress>,
627 completed: Vec<String>,
629 failed: Vec<(String, String)>,
631 max_concurrent: usize,
633}
634
635impl ProxyGenerationQueue {
636 #[must_use]
638 pub fn new(max_concurrent: usize) -> Self {
639 Self {
640 pending: Vec::new(),
641 in_progress: HashMap::new(),
642 completed: Vec::new(),
643 failed: Vec::new(),
644 max_concurrent: max_concurrent.max(1),
645 }
646 }
647
648 pub fn enqueue(&mut self, source_key: String, config: ProxyWorkflowConfig) {
650 self.pending.push((source_key, config));
651 }
652
653 pub fn start_next(&mut self) -> Option<String> {
658 if self.in_progress.len() >= self.max_concurrent {
659 return None;
660 }
661 let (key, _config) = self.pending.pop()?;
662 let progress = ProxyJobProgress::new(key.clone());
663 self.in_progress.insert(key.clone(), progress);
664 Some(key)
665 }
666
667 pub fn update_progress(
669 &mut self,
670 source_key: &str,
671 fraction: f64,
672 stage: &str,
673 eta: f64,
674 ) -> bool {
675 if let Some(prog) = self.in_progress.get_mut(source_key) {
676 prog.fraction = fraction.clamp(0.0, 1.0);
677 prog.stage = stage.to_string();
678 prog.eta_seconds = eta;
679 true
680 } else {
681 false
682 }
683 }
684
685 pub fn mark_completed(&mut self, source_key: &str) -> bool {
687 if self.in_progress.remove(source_key).is_some() {
688 self.completed.push(source_key.to_string());
689 true
690 } else {
691 false
692 }
693 }
694
695 pub fn mark_job_failed(&mut self, source_key: &str, error: String) -> bool {
697 if self.in_progress.remove(source_key).is_some() {
698 self.failed.push((source_key.to_string(), error));
699 true
700 } else {
701 false
702 }
703 }
704
705 #[must_use]
707 pub fn get_progress(&self, source_key: &str) -> Option<&ProxyJobProgress> {
708 self.in_progress.get(source_key)
709 }
710
711 #[must_use]
713 #[allow(clippy::cast_precision_loss)]
714 pub fn overall_progress(&self) -> f64 {
715 let total =
716 self.pending.len() + self.in_progress.len() + self.completed.len() + self.failed.len();
717 if total == 0 {
718 return 1.0;
719 }
720 let done = self.completed.len() as f64;
721 let in_prog: f64 = self.in_progress.values().map(|p| p.fraction).sum();
722 (done + in_prog) / total as f64
723 }
724
725 #[must_use]
727 pub fn pending_count(&self) -> usize {
728 self.pending.len()
729 }
730
731 #[must_use]
733 pub fn in_progress_count(&self) -> usize {
734 self.in_progress.len()
735 }
736
737 #[must_use]
739 pub fn completed_count(&self) -> usize {
740 self.completed.len()
741 }
742
743 #[must_use]
745 pub fn failed_count(&self) -> usize {
746 self.failed.len()
747 }
748
749 #[must_use]
751 pub fn failed_jobs(&self) -> &[(String, String)] {
752 &self.failed
753 }
754
755 #[must_use]
757 pub fn is_idle(&self) -> bool {
758 self.pending.is_empty() && self.in_progress.is_empty()
759 }
760}
761
762#[derive(Debug)]
764pub struct ProxyWorkflowManager {
765 pub proxy_manager: ProxyManager,
767 chains: HashMap<String, ProxyChain>,
769 pub queue: ProxyGenerationQueue,
771 pub relinker: ProxyRelinker,
773 pub config: ProxyWorkflowConfig,
775}
776
777impl ProxyWorkflowManager {
778 #[must_use]
780 pub fn new(proxy_dir: PathBuf, config: ProxyWorkflowConfig) -> Self {
781 let max_concurrent = config.max_concurrent;
782 Self {
783 proxy_manager: ProxyManager::new(proxy_dir),
784 chains: HashMap::new(),
785 queue: ProxyGenerationQueue::new(max_concurrent),
786 relinker: ProxyRelinker::new(),
787 config,
788 }
789 }
790
791 pub fn register_with_chain(
795 &mut self,
796 original_path: PathBuf,
797 original_width: u32,
798 original_height: u32,
799 ) -> EditResult<()> {
800 let key = original_path
801 .to_str()
802 .ok_or_else(|| EditError::InvalidEdit("Invalid path encoding".to_string()))?
803 .to_string();
804
805 self.proxy_manager.register_source(
807 original_path.clone(),
808 original_width,
809 original_height,
810 )?;
811
812 self.relinker.register_original(original_path.clone());
814
815 let mut chain = ProxyChain::new(key.clone());
817 let filename = original_path
818 .file_name()
819 .and_then(|n| n.to_str())
820 .unwrap_or("unknown");
821
822 let quarter_path = self
823 .proxy_manager
824 .proxy_dir
825 .join(format!("proxy_quarter_{filename}"));
826 chain.add_level(ProxyResolution::Quarter, quarter_path, 0.25);
827
828 let half_path = self
829 .proxy_manager
830 .proxy_dir
831 .join(format!("proxy_half_{filename}"));
832 chain.add_level(ProxyResolution::Half, half_path, 0.5);
833
834 self.chains.insert(key, chain);
835 Ok(())
836 }
837
838 #[must_use]
840 pub fn get_chain(&self, source_key: &str) -> Option<&ProxyChain> {
841 self.chains.get(source_key)
842 }
843
844 #[must_use]
846 pub fn select_for_zoom(&self, source_key: &str, zoom: f64) -> Option<&ProxyChainEntry> {
847 self.chains.get(source_key)?.select_for_zoom(zoom)
848 }
849
850 pub fn enqueue_all_pending(&mut self) {
852 let pending: Vec<String> = self
853 .proxy_manager
854 .pending_generation()
855 .iter()
856 .map(|m| m.original_path.to_str().unwrap_or("unknown").to_string())
857 .collect();
858 for key in pending {
859 self.queue.enqueue(key, self.config.clone());
860 }
861 }
862
863 pub fn mark_chain_ready(&mut self, source_key: &str, scale: f64) -> bool {
865 self.chains
866 .get_mut(source_key)
867 .map_or(false, |c| c.mark_ready_by_scale(scale))
868 }
869
870 #[must_use]
872 pub fn chain_count(&self) -> usize {
873 self.chains.len()
874 }
875}
876
877#[cfg(test)]
882mod tests {
883 use super::*;
884
885 #[test]
886 fn test_proxy_resolution_dimensions() {
887 assert_eq!(ProxyResolution::Quarter.dimensions(3840, 2160), (960, 540));
888 assert_eq!(ProxyResolution::Half.dimensions(1920, 1080), (960, 540));
889 assert_eq!(ProxyResolution::Hd720.dimensions(3840, 2160), (1280, 720));
890 assert_eq!(ProxyResolution::Sd480.dimensions(1920, 1080), (854, 480));
891 assert_eq!(
892 ProxyResolution::Custom(640, 360).dimensions(1920, 1080),
893 (640, 360)
894 );
895 }
896
897 #[test]
898 fn test_proxy_resolution_zero_dimensions() {
899 assert_eq!(ProxyResolution::Quarter.dimensions(2, 2), (1, 1));
901 }
902
903 #[test]
904 fn test_proxy_resolution_label() {
905 assert_eq!(ProxyResolution::Quarter.label(), "1/4 Resolution");
906 assert_eq!(ProxyResolution::Half.label(), "1/2 Resolution");
907 assert_eq!(ProxyResolution::Hd720.label(), "720p");
908 }
909
910 #[test]
911 fn test_proxy_status_is_usable() {
912 assert!(ProxyStatus::Ready.is_usable());
913 assert!(!ProxyStatus::NotGenerated.is_usable());
914 assert!(!ProxyStatus::Generating.is_usable());
915 assert!(!ProxyStatus::Failed.is_usable());
916 assert!(!ProxyStatus::Outdated.is_usable());
917 }
918
919 #[test]
920 fn test_proxy_mapping_scale_factor() {
921 let mapping = ProxyMapping::new(
922 PathBuf::from("/src/video.mp4"),
923 PathBuf::from("/proxy/video.mp4"),
924 ProxyResolution::Half,
925 1920,
926 1080,
927 );
928 assert!((mapping.scale_factor() - 0.5).abs() < 1e-9);
929 }
930
931 #[test]
932 fn test_proxy_mapping_zero_original_width() {
933 let mapping = ProxyMapping::new(
934 PathBuf::from("/src/video.mp4"),
935 PathBuf::from("/proxy/video.mp4"),
936 ProxyResolution::Half,
937 0,
938 0,
939 );
940 assert!((mapping.scale_factor() - 1.0).abs() < 1e-9);
941 }
942
943 #[test]
944 fn test_proxy_mode_logic() {
945 assert!(!ProxyMode::Original.uses_proxy_for_playback());
946 assert!(ProxyMode::Original.uses_original_for_export());
947 assert!(ProxyMode::ProxyPlayback.uses_proxy_for_playback());
948 assert!(ProxyMode::ProxyPlayback.uses_original_for_export());
949 assert!(ProxyMode::ProxyOnly.uses_proxy_for_playback());
950 assert!(!ProxyMode::ProxyOnly.uses_original_for_export());
951 }
952
953 #[test]
954 fn test_proxy_manager_register_and_resolve() {
955 let dir = std::env::temp_dir().join("oximedia_proxy_test");
956 let mut mgr = ProxyManager::new(dir);
957
958 let path = "/media/footage.mp4";
959 mgr.register_source(PathBuf::from(path), 1920, 1080)
960 .expect("registration should succeed");
961
962 mgr.associate_clip(1, path);
963
964 let playback = mgr.resolve_path_for_playback(1);
966 assert!(playback.is_some());
967 assert_eq!(
968 playback.expect("should resolve").to_str(),
969 Some("/media/footage.mp4")
970 );
971
972 assert!(mgr.mark_ready(path));
974
975 let playback = mgr.resolve_path_for_playback(1);
977 assert!(playback.is_some());
978 let p = playback.expect("should resolve");
979 assert!(p
980 .to_str()
981 .map_or(false, |s| s.contains("proxy_footage.mp4")));
982
983 let export = mgr.resolve_path_for_export(1);
985 assert!(export.is_some());
986 assert_eq!(
987 export.expect("should resolve").to_str(),
988 Some("/media/footage.mp4")
989 );
990 }
991
992 #[test]
993 fn test_proxy_manager_pending_generation() {
994 let dir = std::env::temp_dir().join("oximedia_proxy_test2");
995 let mut mgr = ProxyManager::new(dir);
996 mgr.register_source(PathBuf::from("/a.mp4"), 1920, 1080)
997 .expect("ok");
998 mgr.register_source(PathBuf::from("/b.mp4"), 1920, 1080)
999 .expect("ok");
1000 assert_eq!(mgr.pending_generation().len(), 2);
1001
1002 mgr.mark_ready("/a.mp4");
1003 assert_eq!(mgr.pending_generation().len(), 1);
1004 assert_eq!(mgr.ready_count(), 1);
1005 }
1006
1007 #[test]
1008 fn test_proxy_manager_mark_outdated() {
1009 let dir = std::env::temp_dir().join("oximedia_proxy_test3");
1010 let mut mgr = ProxyManager::new(dir);
1011 mgr.register_source(PathBuf::from("/a.mp4"), 1920, 1080)
1012 .expect("ok");
1013 mgr.mark_ready("/a.mp4");
1014 assert_eq!(mgr.ready_count(), 1);
1015 mgr.mark_outdated("/a.mp4");
1016 assert_eq!(mgr.ready_count(), 0);
1017 assert_eq!(mgr.pending_generation().len(), 1);
1018 }
1019
1020 #[test]
1021 fn test_proxy_manager_unknown_path_returns_false() {
1022 let dir = std::env::temp_dir().join("oximedia_proxy_test4");
1023 let mgr = ProxyManager::new(dir);
1024 assert!(mgr.resolve_path_for_playback(999).is_none());
1025 assert!(mgr.resolve_path_for_export(999).is_none());
1026 }
1027
1028 #[test]
1029 fn test_proxy_manager_source_count() {
1030 let dir = std::env::temp_dir().join("oximedia_proxy_test5");
1031 let mut mgr = ProxyManager::new(dir);
1032 assert_eq!(mgr.source_count(), 0);
1033 mgr.register_source(PathBuf::from("/x.mp4"), 1920, 1080)
1034 .expect("ok");
1035 assert_eq!(mgr.source_count(), 1);
1036 }
1037
1038 #[test]
1041 fn test_proxy_codec_labels() {
1042 assert_eq!(ProxyCodec::Vp9.label(), "VP9");
1043 assert_eq!(ProxyCodec::Av1.label(), "AV1");
1044 assert_eq!(ProxyCodec::Vp8.label(), "VP8");
1045 }
1046
1047 #[test]
1048 fn test_proxy_workflow_config_defaults() {
1049 let cfg = ProxyWorkflowConfig::default();
1050 assert_eq!(cfg.codec, ProxyCodec::Vp9);
1051 assert_eq!(cfg.quality, 35);
1052 assert!(cfg.include_audio);
1053 assert_eq!(cfg.max_concurrent, 4);
1054 }
1055
1056 #[test]
1059 fn test_proxy_job_progress_new() {
1060 let p = ProxyJobProgress::new("test.mp4".to_string());
1061 assert!(!p.is_complete());
1062 assert_eq!(p.stage, "queued");
1063 }
1064
1065 #[test]
1066 fn test_proxy_job_progress_complete() {
1067 let mut p = ProxyJobProgress::new("test.mp4".to_string());
1068 p.fraction = 1.0;
1069 assert!(p.is_complete());
1070 }
1071
1072 #[test]
1075 fn test_proxy_chain_add_and_select() {
1076 let mut chain = ProxyChain::new("/src/video.mp4".to_string());
1077 chain.add_level(ProxyResolution::Quarter, PathBuf::from("/p/q.mp4"), 0.25);
1078 chain.add_level(ProxyResolution::Half, PathBuf::from("/p/h.mp4"), 0.5);
1079 assert_eq!(chain.level_count(), 2);
1080 assert_eq!(chain.ready_level_count(), 0);
1081
1082 assert!(chain.mark_ready_by_scale(0.5));
1084 assert_eq!(chain.ready_level_count(), 1);
1085
1086 let selected = chain.select_for_zoom(0.3);
1088 assert!(selected.is_some());
1089 assert!((selected.map(|s| s.scale).unwrap_or(0.0) - 0.5).abs() < 1e-6);
1090 }
1091
1092 #[test]
1093 fn test_proxy_chain_fallback_to_highest_ready() {
1094 let mut chain = ProxyChain::new("/src/video.mp4".to_string());
1095 chain.add_level(ProxyResolution::Quarter, PathBuf::from("/p/q.mp4"), 0.25);
1096 chain.add_level(ProxyResolution::Half, PathBuf::from("/p/h.mp4"), 0.5);
1097 chain.mark_ready_by_scale(0.25);
1098
1099 let selected = chain.select_for_zoom(0.8);
1101 assert!(selected.is_some());
1102 assert!((selected.map(|s| s.scale).unwrap_or(0.0) - 0.25).abs() < 1e-6);
1103 }
1104
1105 #[test]
1106 fn test_proxy_chain_no_ready() {
1107 let chain = ProxyChain::new("/src/video.mp4".to_string());
1108 assert!(chain.select_for_zoom(0.5).is_none());
1109 }
1110
1111 #[test]
1112 fn test_proxy_chain_sorted_by_scale() {
1113 let mut chain = ProxyChain::new("src".to_string());
1114 chain.add_level(ProxyResolution::Half, PathBuf::from("/h"), 0.5);
1115 chain.add_level(ProxyResolution::Quarter, PathBuf::from("/q"), 0.25);
1116 let entries = chain.entries();
1117 assert!(entries[0].scale < entries[1].scale);
1118 }
1119
1120 #[test]
1123 fn test_relinker_hash_match() {
1124 let mut relinker = ProxyRelinker::new();
1125 relinker.register_original(PathBuf::from("/media/footage.mp4"));
1126 assert_eq!(relinker.original_count(), 1);
1127
1128 let result = relinker.relink(&PathBuf::from("/proxies/proxy_footage.mp4"));
1129 assert!(result.is_some());
1130 let r = result.expect("should match");
1131 assert_eq!(r.match_method, RelinkMethod::FilenameHash);
1132 assert!((r.confidence - 1.0).abs() < 1e-9);
1133 }
1134
1135 #[test]
1136 fn test_relinker_stem_match() {
1137 let mut relinker = ProxyRelinker::new();
1138 relinker.register_original(PathBuf::from("/media/clip01.mov"));
1139
1140 let result = relinker.relink(&PathBuf::from("/proxies/proxy_clip01.webm"));
1142 assert!(result.is_some());
1143 let r = result.expect("should match");
1144 assert_eq!(r.match_method, RelinkMethod::FilenamePattern);
1145 }
1146
1147 #[test]
1148 fn test_relinker_no_match() {
1149 let relinker = ProxyRelinker::new();
1150 let result = relinker.relink(&PathBuf::from("/proxies/proxy_unknown.mp4"));
1151 assert!(result.is_none());
1152 }
1153
1154 #[test]
1157 fn test_generation_queue_basic_flow() {
1158 let mut queue = ProxyGenerationQueue::new(2);
1159 assert!(queue.is_idle());
1160
1161 queue.enqueue("a.mp4".to_string(), ProxyWorkflowConfig::default());
1162 queue.enqueue("b.mp4".to_string(), ProxyWorkflowConfig::default());
1163 queue.enqueue("c.mp4".to_string(), ProxyWorkflowConfig::default());
1164 assert_eq!(queue.pending_count(), 3);
1165
1166 let j1 = queue.start_next();
1168 assert!(j1.is_some());
1169 let j2 = queue.start_next();
1170 assert!(j2.is_some());
1171 let j3 = queue.start_next();
1172 assert!(j3.is_none()); assert_eq!(queue.in_progress_count(), 2);
1174
1175 assert!(queue.mark_completed(j1.as_deref().unwrap_or("")));
1177 assert_eq!(queue.completed_count(), 1);
1178
1179 let j3 = queue.start_next();
1181 assert!(j3.is_some());
1182 }
1183
1184 #[test]
1185 fn test_generation_queue_progress() {
1186 let mut queue = ProxyGenerationQueue::new(4);
1187 queue.enqueue("x.mp4".to_string(), ProxyWorkflowConfig::default());
1188 let key = queue.start_next().expect("should start");
1189 assert!(queue.update_progress(&key, 0.5, "encoding", 10.0));
1190 let prog = queue.get_progress(&key);
1191 assert!(prog.is_some());
1192 assert!((prog.map(|p| p.fraction).unwrap_or(0.0) - 0.5).abs() < 1e-9);
1193 }
1194
1195 #[test]
1196 fn test_generation_queue_overall_progress() {
1197 let mut queue = ProxyGenerationQueue::new(4);
1198 queue.enqueue("a.mp4".to_string(), ProxyWorkflowConfig::default());
1199 queue.enqueue("b.mp4".to_string(), ProxyWorkflowConfig::default());
1200 let k1 = queue.start_next().expect("ok");
1201 let _k2 = queue.start_next().expect("ok");
1202 queue.mark_completed(&k1);
1203 assert!((queue.overall_progress() - 0.5).abs() < 1e-6);
1205 }
1206
1207 #[test]
1208 fn test_generation_queue_failure() {
1209 let mut queue = ProxyGenerationQueue::new(4);
1210 queue.enqueue("bad.mp4".to_string(), ProxyWorkflowConfig::default());
1211 let key = queue.start_next().expect("ok");
1212 assert!(queue.mark_job_failed(&key, "codec error".to_string()));
1213 assert_eq!(queue.failed_count(), 1);
1214 assert!(queue.is_idle());
1215 }
1216
1217 #[test]
1218 fn test_generation_queue_idle_after_drain() {
1219 let mut queue = ProxyGenerationQueue::new(4);
1220 assert!(queue.is_idle());
1221 queue.enqueue("z.mp4".to_string(), ProxyWorkflowConfig::default());
1222 assert!(!queue.is_idle());
1223 let key = queue.start_next().expect("ok");
1224 assert!(!queue.is_idle());
1225 queue.mark_completed(&key);
1226 assert!(queue.is_idle());
1227 }
1228
1229 #[test]
1232 fn test_workflow_manager_register_with_chain() {
1233 let dir = std::env::temp_dir().join("oximedia_wf_test1");
1234 let mut wf = ProxyWorkflowManager::new(dir, ProxyWorkflowConfig::default());
1235 wf.register_with_chain(PathBuf::from("/media/clip.mp4"), 3840, 2160)
1236 .expect("should register");
1237 assert_eq!(wf.chain_count(), 1);
1238 let chain = wf.get_chain("/media/clip.mp4");
1239 assert!(chain.is_some());
1240 assert_eq!(chain.map(|c| c.level_count()).unwrap_or(0), 2);
1241 }
1242
1243 #[test]
1244 fn test_workflow_manager_select_for_zoom() {
1245 let dir = std::env::temp_dir().join("oximedia_wf_test2");
1246 let mut wf = ProxyWorkflowManager::new(dir, ProxyWorkflowConfig::default());
1247 wf.register_with_chain(PathBuf::from("/media/clip.mp4"), 1920, 1080)
1248 .expect("ok");
1249 assert!(wf.select_for_zoom("/media/clip.mp4", 0.3).is_none());
1251
1252 wf.mark_chain_ready("/media/clip.mp4", 0.25);
1253 let entry = wf.select_for_zoom("/media/clip.mp4", 0.2);
1254 assert!(entry.is_some());
1255 }
1256
1257 #[test]
1258 fn test_workflow_manager_enqueue_pending() {
1259 let dir = std::env::temp_dir().join("oximedia_wf_test3");
1260 let mut wf = ProxyWorkflowManager::new(dir, ProxyWorkflowConfig::default());
1261 wf.register_with_chain(PathBuf::from("/media/a.mp4"), 1920, 1080)
1262 .expect("ok");
1263 wf.register_with_chain(PathBuf::from("/media/b.mp4"), 1920, 1080)
1264 .expect("ok");
1265 wf.enqueue_all_pending();
1266 assert_eq!(wf.queue.pending_count(), 2);
1267 }
1268
1269 #[test]
1270 fn test_fnv_hash_deterministic() {
1271 let h1 = fnv_hash_str("test.mp4");
1272 let h2 = fnv_hash_str("test.mp4");
1273 assert_eq!(h1, h2);
1274 let h3 = fnv_hash_str("other.mp4");
1275 assert_ne!(h1, h3);
1276 }
1277}