Skip to main content

oximedia_edit/
proxy.rs

1//! Proxy workflow for efficient editing.
2//!
3//! Enables low-resolution editing with full-resolution export. Proxy
4//! files are lightweight stand-ins for high-resolution source media,
5//! allowing smooth playback and editing on modest hardware. At export
6//! time the renderer seamlessly switches back to original media.
7
8#![allow(dead_code)]
9
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13use crate::clip::ClipId;
14use crate::error::{EditError, EditResult};
15
16/// Resolution preset for proxy generation.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ProxyResolution {
19    /// Quarter of original resolution.
20    Quarter,
21    /// Half of original resolution.
22    Half,
23    /// 720p (1280x720).
24    Hd720,
25    /// 480p (854x480).
26    Sd480,
27    /// Custom resolution.
28    Custom(u32, u32),
29}
30
31impl ProxyResolution {
32    /// Compute the proxy dimensions given the original width and height.
33    #[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    /// Returns a human-readable label.
45    #[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/// Status of a proxy file.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ProxyStatus {
60    /// Proxy has not been generated yet.
61    NotGenerated,
62    /// Proxy generation is in progress.
63    Generating,
64    /// Proxy is ready to use.
65    Ready,
66    /// Proxy generation failed.
67    Failed,
68    /// Proxy is outdated (source changed since generation).
69    Outdated,
70}
71
72impl ProxyStatus {
73    /// Returns `true` if the proxy can be used for editing.
74    #[must_use]
75    pub fn is_usable(self) -> bool {
76        matches!(self, Self::Ready)
77    }
78}
79
80/// A mapping between an original media file and its proxy.
81#[derive(Debug, Clone)]
82pub struct ProxyMapping {
83    /// Original (full-resolution) file path.
84    pub original_path: PathBuf,
85    /// Proxy (low-resolution) file path.
86    pub proxy_path: PathBuf,
87    /// Resolution of the proxy.
88    pub resolution: ProxyResolution,
89    /// Status of the proxy.
90    pub status: ProxyStatus,
91    /// Original file dimensions.
92    pub original_width: u32,
93    /// Original file height.
94    pub original_height: u32,
95    /// Proxy dimensions.
96    pub proxy_width: u32,
97    /// Proxy height.
98    pub proxy_height: u32,
99}
100
101impl ProxyMapping {
102    /// Create a new proxy mapping.
103    #[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    /// Get the scale factor (proxy / original).
125    #[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/// Proxy editing mode.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum ProxyMode {
138    /// Use original files for playback and export.
139    Original,
140    /// Use proxy files for playback, originals for export.
141    ProxyPlayback,
142    /// Use proxy files for everything (fastest).
143    ProxyOnly,
144}
145
146impl ProxyMode {
147    /// Returns `true` if proxies are used for playback.
148    #[must_use]
149    pub fn uses_proxy_for_playback(self) -> bool {
150        matches!(self, Self::ProxyPlayback | Self::ProxyOnly)
151    }
152
153    /// Returns `true` if originals are used for export.
154    #[must_use]
155    pub fn uses_original_for_export(self) -> bool {
156        matches!(self, Self::Original | Self::ProxyPlayback)
157    }
158}
159
160/// Manages proxy files for a project.
161#[derive(Debug)]
162pub struct ProxyManager {
163    /// Proxy mappings keyed by original path string.
164    mappings: HashMap<String, ProxyMapping>,
165    /// Clip to original path mapping.
166    clip_sources: HashMap<ClipId, String>,
167    /// Current proxy mode.
168    pub mode: ProxyMode,
169    /// Default proxy resolution.
170    pub default_resolution: ProxyResolution,
171    /// Proxy storage directory.
172    pub proxy_dir: PathBuf,
173}
174
175impl ProxyManager {
176    /// Create a new proxy manager.
177    #[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    /// Register a source file for proxy management.
189    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    /// Associate a clip with a source file.
220    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    /// Get the appropriate file path for a clip given the current mode.
225    ///
226    /// For playback: returns proxy path if mode uses proxies and proxy is ready.
227    /// For export: returns original path unless in ProxyOnly mode.
228    #[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    /// Get the path to use during export.
240    #[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    /// Mark a proxy as ready.
254    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    /// Mark a proxy as failed.
264    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    /// Mark a proxy as outdated.
274    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    /// Get the proxy mapping for a source file.
284    #[must_use]
285    pub fn get_mapping(&self, original_path: &str) -> Option<&ProxyMapping> {
286        self.mappings.get(original_path)
287    }
288
289    /// Get all mappings that need proxy generation.
290    #[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    /// Get total number of registered sources.
299    #[must_use]
300    pub fn source_count(&self) -> usize {
301        self.mappings.len()
302    }
303
304    /// Get count of ready proxies.
305    #[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/// Codec to use for proxy files.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum ProxyCodec {
317    /// VP9 (default, patent-free).
318    Vp9,
319    /// AV1 (smaller, slower encode).
320    Av1,
321    /// VP8 (fastest encode).
322    Vp8,
323}
324
325impl ProxyCodec {
326    /// Human-readable label for this codec.
327    #[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/// Configuration for proxy generation workflow.
338#[derive(Debug, Clone)]
339pub struct ProxyWorkflowConfig {
340    /// Target resolution.
341    pub resolution: ProxyResolution,
342    /// Codec for proxy encoding.
343    pub codec: ProxyCodec,
344    /// Quality / CRF parameter (0–63, lower = better).
345    pub quality: u8,
346    /// Whether to preserve audio in proxy.
347    pub include_audio: bool,
348    /// Maximum concurrent generation tasks.
349    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/// Progress of a single proxy generation job.
365#[derive(Debug, Clone)]
366pub struct ProxyJobProgress {
367    /// Original file path key.
368    pub source_key: String,
369    /// 0.0 – 1.0 progress fraction.
370    pub fraction: f64,
371    /// Estimated remaining seconds (-1 if unknown).
372    pub eta_seconds: f64,
373    /// Current stage description.
374    pub stage: String,
375}
376
377impl ProxyJobProgress {
378    /// Create a new progress entry.
379    #[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    /// Whether the job is complete.
390    #[must_use]
391    pub fn is_complete(&self) -> bool {
392        (self.fraction - 1.0).abs() < 1e-9
393    }
394}
395
396/// Multi-resolution proxy chain for zoom-dependent switching.
397#[derive(Debug, Clone)]
398pub struct ProxyChain {
399    /// Ordered from lowest to highest resolution proxy mappings.
400    entries: Vec<ProxyChainEntry>,
401    /// Original source path.
402    pub source_path: String,
403}
404
405/// One resolution level in the proxy chain.
406#[derive(Debug, Clone)]
407pub struct ProxyChainEntry {
408    /// Resolution enum for this level.
409    pub resolution: ProxyResolution,
410    /// Proxy file path.
411    pub proxy_path: PathBuf,
412    /// Scale factor relative to original (0.0–1.0).
413    pub scale: f64,
414    /// Status of this level.
415    pub status: ProxyStatus,
416}
417
418impl ProxyChain {
419    /// Create a new proxy chain for a given source.
420    #[must_use]
421    pub fn new(source_path: String) -> Self {
422        Self {
423            entries: Vec::new(),
424            source_path,
425        }
426    }
427
428    /// Add a resolution level.
429    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        // Sort by scale ascending (lowest res first).
438        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    /// Select the best proxy for a given zoom level (0.0–1.0 where 1.0 = full res).
446    ///
447    /// Returns the entry whose scale is >= zoom that is ready, or falls back
448    /// to the highest-resolution ready entry.
449    #[must_use]
450    pub fn select_for_zoom(&self, zoom: f64) -> Option<&ProxyChainEntry> {
451        // First try: smallest ready proxy that is >= zoom
452        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        // Fallback: highest-resolution ready proxy
460        self.entries.iter().rev().find(|e| e.status.is_usable())
461    }
462
463    /// Mark a level as ready by scale.
464    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    /// Number of levels.
475    #[must_use]
476    pub fn level_count(&self) -> usize {
477        self.entries.len()
478    }
479
480    /// Number of ready levels.
481    #[must_use]
482    pub fn ready_level_count(&self) -> usize {
483        self.entries.iter().filter(|e| e.status.is_usable()).count()
484    }
485
486    /// Get all entries.
487    #[must_use]
488    pub fn entries(&self) -> &[ProxyChainEntry] {
489        &self.entries
490    }
491}
492
493/// Relink result when matching proxies back to originals.
494#[derive(Debug, Clone)]
495pub struct RelinkResult {
496    /// Proxy path that was relinked.
497    pub proxy_path: PathBuf,
498    /// Original path it was matched to.
499    pub original_path: PathBuf,
500    /// How the match was determined.
501    pub match_method: RelinkMethod,
502    /// Confidence score 0.0–1.0.
503    pub confidence: f64,
504}
505
506/// Method used to match proxy to original.
507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
508pub enum RelinkMethod {
509    /// Matched by filename hash.
510    FilenameHash,
511    /// Matched by metadata (resolution, duration, etc.).
512    Metadata,
513    /// Matched by exact filename pattern.
514    FilenamePattern,
515}
516
517/// Simple FNV-1a-style hash for deterministic filename matching.
518fn 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/// Manager for proxy-to-original relinking.
528#[derive(Debug)]
529pub struct ProxyRelinker {
530    /// Known original files keyed by filename hash.
531    originals_by_hash: HashMap<u64, PathBuf>,
532    /// Known original files keyed by stem (filename without extension).
533    originals_by_stem: HashMap<String, PathBuf>,
534}
535
536impl ProxyRelinker {
537    /// Create a new relinker.
538    #[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    /// Register an original file for matching.
547    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    /// Try to relink a proxy path to its original.
563    ///
564    /// Tries filename-hash match first, then stem-pattern match.
565    #[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        // Strip "proxy_" prefix if present to recover original filename.
573        let original_filename = proxy_filename
574            .strip_prefix("proxy_")
575            .unwrap_or(proxy_filename);
576
577        // Try filename hash match.
578        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        // Try stem pattern match: extract stem from proxy (minus proxy_ prefix).
589        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    /// Number of registered originals.
608    #[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/// Background proxy generation queue with progress tracking.
621#[derive(Debug)]
622pub struct ProxyGenerationQueue {
623    /// Pending jobs (source key, config).
624    pending: Vec<(String, ProxyWorkflowConfig)>,
625    /// In-progress jobs.
626    in_progress: HashMap<String, ProxyJobProgress>,
627    /// Completed jobs (source key).
628    completed: Vec<String>,
629    /// Failed jobs (source key, error message).
630    failed: Vec<(String, String)>,
631    /// Maximum concurrent jobs.
632    max_concurrent: usize,
633}
634
635impl ProxyGenerationQueue {
636    /// Create a new generation queue.
637    #[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    /// Enqueue a proxy generation job.
649    pub fn enqueue(&mut self, source_key: String, config: ProxyWorkflowConfig) {
650        self.pending.push((source_key, config));
651    }
652
653    /// Start the next pending job if capacity allows.
654    ///
655    /// Returns the source key of the started job, or None if queue is
656    /// empty or at capacity.
657    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    /// Update progress for an in-progress job.
668    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    /// Mark a job as completed.
686    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    /// Mark a job as failed.
696    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    /// Get progress for a specific job.
706    #[must_use]
707    pub fn get_progress(&self, source_key: &str) -> Option<&ProxyJobProgress> {
708        self.in_progress.get(source_key)
709    }
710
711    /// Total overall progress (0.0–1.0) across all jobs.
712    #[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    /// Number of pending jobs.
726    #[must_use]
727    pub fn pending_count(&self) -> usize {
728        self.pending.len()
729    }
730
731    /// Number of in-progress jobs.
732    #[must_use]
733    pub fn in_progress_count(&self) -> usize {
734        self.in_progress.len()
735    }
736
737    /// Number of completed jobs.
738    #[must_use]
739    pub fn completed_count(&self) -> usize {
740        self.completed.len()
741    }
742
743    /// Number of failed jobs.
744    #[must_use]
745    pub fn failed_count(&self) -> usize {
746        self.failed.len()
747    }
748
749    /// Get failed jobs with their error messages.
750    #[must_use]
751    pub fn failed_jobs(&self) -> &[(String, String)] {
752        &self.failed
753    }
754
755    /// Whether the queue has no more work (pending or in-progress).
756    #[must_use]
757    pub fn is_idle(&self) -> bool {
758        self.pending.is_empty() && self.in_progress.is_empty()
759    }
760}
761
762/// Workflow manager tying together proxy generation, relinking, and chain selection.
763#[derive(Debug)]
764pub struct ProxyWorkflowManager {
765    /// The underlying proxy manager.
766    pub proxy_manager: ProxyManager,
767    /// Multi-resolution chains per source.
768    chains: HashMap<String, ProxyChain>,
769    /// Generation queue.
770    pub queue: ProxyGenerationQueue,
771    /// Relinker.
772    pub relinker: ProxyRelinker,
773    /// Workflow config.
774    pub config: ProxyWorkflowConfig,
775}
776
777impl ProxyWorkflowManager {
778    /// Create a new workflow manager.
779    #[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    /// Register a source and create a multi-resolution proxy chain.
792    ///
793    /// Creates quarter, half, and full-scale chain entries.
794    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        // Register in underlying manager.
806        self.proxy_manager.register_source(
807            original_path.clone(),
808            original_width,
809            original_height,
810        )?;
811
812        // Register in relinker.
813        self.relinker.register_original(original_path.clone());
814
815        // Build chain with standard resolutions.
816        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    /// Get the chain for a source.
839    #[must_use]
840    pub fn get_chain(&self, source_key: &str) -> Option<&ProxyChain> {
841        self.chains.get(source_key)
842    }
843
844    /// Select the best proxy for a source at a given zoom level.
845    #[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    /// Enqueue proxy generation for all pending sources.
851    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    /// Mark a chain level as ready.
864    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    /// Number of registered chains.
871    #[must_use]
872    pub fn chain_count(&self) -> usize {
873        self.chains.len()
874    }
875}
876
877// ─────────────────────────────────────────────────────────────────────────────
878// Tests
879// ─────────────────────────────────────────────────────────────────────────────
880
881#[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        // Should clamp to minimum 1
900        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        // Not ready yet, should return original
965        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        // Mark ready
973        assert!(mgr.mark_ready(path));
974
975        // Now should return proxy
976        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        // Export should still return original
984        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    // ── Proxy codec tests ──────────────────────────────────────────────
1039
1040    #[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    // ── Proxy job progress tests ───────────────────────────────────────
1057
1058    #[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    // ── Proxy chain tests ──────────────────────────────────────────────
1073
1074    #[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        // Mark half ready
1083        assert!(chain.mark_ready_by_scale(0.5));
1084        assert_eq!(chain.ready_level_count(), 1);
1085
1086        // Select for zoom 0.3 → should get half (smallest >= 0.3 that is ready)
1087        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        // Request zoom 0.8 → nothing >= 0.8 is ready, fallback to 0.25
1100        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    // ── Relinker tests ─────────────────────────────────────────────────
1121
1122    #[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        // Proxy has different extension but same stem after stripping prefix
1141        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    // ── Generation queue tests ─────────────────────────────────────────
1155
1156    #[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        // Start two (max concurrent)
1167        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()); // at capacity
1173        assert_eq!(queue.in_progress_count(), 2);
1174
1175        // Complete one
1176        assert!(queue.mark_completed(j1.as_deref().unwrap_or("")));
1177        assert_eq!(queue.completed_count(), 1);
1178
1179        // Now we can start another
1180        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        // 1 completed, 1 in progress at 0.0 = 1.0/2.0 = 0.5
1204        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    // ── Workflow manager tests ─────────────────────────────────────────
1230
1231    #[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        // Nothing ready yet
1250        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}