Skip to main content

oximedia_proxy/
offline_proxy.rs

1//! Offline proxy workflows for OxiMedia proxy system.
2//!
3//! Provides offline proxy editing workflows including proxy-only editing,
4//! reconnection to original high-resolution media, and substitution strategies.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// Status of an offline proxy clip.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum OfflineStatus {
15    /// Proxy is available and ready for offline editing.
16    Available,
17    /// Proxy is missing; needs to be regenerated.
18    Missing,
19    /// Proxy is being generated.
20    Generating,
21    /// Original media is reconnected; proxy can be replaced.
22    Reconnected,
23    /// Proxy substitution is active.
24    Substituted,
25}
26
27/// A proxy clip record used during offline editing.
28#[derive(Debug, Clone)]
29#[allow(dead_code)]
30pub struct OfflineProxyClip {
31    /// Unique identifier for this clip.
32    pub id: String,
33    /// Path to the proxy file.
34    pub proxy_path: PathBuf,
35    /// Path to the original high-resolution file (may be absent during offline).
36    pub original_path: Option<PathBuf>,
37    /// Current status of the proxy.
38    pub status: OfflineStatus,
39    /// Proxy resolution as a fraction of original (e.g., 0.25 = quarter res).
40    pub resolution_fraction: f32,
41}
42
43impl OfflineProxyClip {
44    /// Create a new offline proxy clip.
45    #[must_use]
46    pub fn new(id: impl Into<String>, proxy_path: impl Into<PathBuf>) -> Self {
47        Self {
48            id: id.into(),
49            proxy_path: proxy_path.into(),
50            original_path: None,
51            status: OfflineStatus::Available,
52            resolution_fraction: 0.25,
53        }
54    }
55
56    /// Set the original media path.
57    #[must_use]
58    pub fn with_original(mut self, original: impl Into<PathBuf>) -> Self {
59        self.original_path = Some(original.into());
60        self
61    }
62
63    /// Set the resolution fraction.
64    #[must_use]
65    pub fn with_resolution_fraction(mut self, fraction: f32) -> Self {
66        self.resolution_fraction = fraction.clamp(0.0, 1.0);
67        self
68    }
69
70    /// Check whether the proxy file is present on disk.
71    #[must_use]
72    pub fn proxy_exists(&self) -> bool {
73        self.proxy_path.exists()
74    }
75
76    /// Check whether this clip has been reconnected to its original.
77    #[must_use]
78    pub fn is_reconnected(&self) -> bool {
79        self.status == OfflineStatus::Reconnected && self.original_path.is_some()
80    }
81}
82
83/// Strategy for handling missing proxies during offline editing.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum SubstitutionStrategy {
86    /// Use a black frame substitute.
87    BlackFrame,
88    /// Use a placeholder image with clip information.
89    Placeholder,
90    /// Skip the clip entirely in the edited timeline.
91    Skip,
92    /// Attempt to regenerate the proxy automatically.
93    AutoRegenerate,
94}
95
96impl Default for SubstitutionStrategy {
97    fn default() -> Self {
98        Self::Placeholder
99    }
100}
101
102/// Manager for offline proxy editing sessions.
103#[allow(dead_code)]
104pub struct OfflineProxySession {
105    /// All clips registered in this session.
106    clips: HashMap<String, OfflineProxyClip>,
107    /// Strategy used when a proxy is missing.
108    substitution_strategy: SubstitutionStrategy,
109    /// Whether the session is in strict mode (error on missing proxy).
110    strict_mode: bool,
111}
112
113impl OfflineProxySession {
114    /// Create a new offline proxy session.
115    #[must_use]
116    pub fn new() -> Self {
117        Self {
118            clips: HashMap::new(),
119            substitution_strategy: SubstitutionStrategy::default(),
120            strict_mode: false,
121        }
122    }
123
124    /// Create a session with a specific substitution strategy.
125    #[must_use]
126    pub fn with_strategy(mut self, strategy: SubstitutionStrategy) -> Self {
127        self.substitution_strategy = strategy;
128        self
129    }
130
131    /// Enable strict mode.
132    #[must_use]
133    pub fn strict(mut self) -> Self {
134        self.strict_mode = true;
135        self
136    }
137
138    /// Register a proxy clip.
139    pub fn register(&mut self, clip: OfflineProxyClip) {
140        self.clips.insert(clip.id.clone(), clip);
141    }
142
143    /// Get a clip by id.
144    #[must_use]
145    pub fn get(&self, id: &str) -> Option<&OfflineProxyClip> {
146        self.clips.get(id)
147    }
148
149    /// Get a mutable reference to a clip by id.
150    pub fn get_mut(&mut self, id: &str) -> Option<&mut OfflineProxyClip> {
151        self.clips.get_mut(id)
152    }
153
154    /// Returns the total number of clips.
155    #[must_use]
156    pub fn clip_count(&self) -> usize {
157        self.clips.len()
158    }
159
160    /// Count clips by status.
161    #[must_use]
162    pub fn count_by_status(&self, status: &OfflineStatus) -> usize {
163        self.clips.values().filter(|c| &c.status == status).count()
164    }
165
166    /// Reconnect a clip to its original media.
167    ///
168    /// Returns `true` if the clip was found and reconnected.
169    pub fn reconnect(&mut self, id: &str, original_path: impl Into<PathBuf>) -> bool {
170        if let Some(clip) = self.clips.get_mut(id) {
171            clip.original_path = Some(original_path.into());
172            clip.status = OfflineStatus::Reconnected;
173            true
174        } else {
175            false
176        }
177    }
178
179    /// Mark a clip as substituted.
180    pub fn substitute(&mut self, id: &str) -> bool {
181        if let Some(clip) = self.clips.get_mut(id) {
182            clip.status = OfflineStatus::Substituted;
183            true
184        } else {
185            false
186        }
187    }
188
189    /// Get the current substitution strategy.
190    #[must_use]
191    pub fn substitution_strategy(&self) -> SubstitutionStrategy {
192        self.substitution_strategy
193    }
194
195    /// Check if strict mode is enabled.
196    #[must_use]
197    pub fn is_strict(&self) -> bool {
198        self.strict_mode
199    }
200
201    /// List all clips that need reconnection.
202    #[must_use]
203    pub fn clips_needing_reconnection(&self) -> Vec<&OfflineProxyClip> {
204        self.clips
205            .values()
206            .filter(|c| c.original_path.is_none())
207            .collect()
208    }
209
210    /// List all reconnected clips.
211    #[must_use]
212    pub fn reconnected_clips(&self) -> Vec<&OfflineProxyClip> {
213        self.clips.values().filter(|c| c.is_reconnected()).collect()
214    }
215}
216
217impl Default for OfflineProxySession {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223/// Reconnect result after attempting to reconnect proxies to originals.
224#[derive(Debug, Default)]
225#[allow(dead_code)]
226pub struct ReconnectResult {
227    /// Number of clips successfully reconnected.
228    pub reconnected: usize,
229    /// Number of clips that could not be reconnected.
230    pub failed: usize,
231    /// Paths of originals that were not found.
232    pub missing_originals: Vec<PathBuf>,
233}
234
235impl ReconnectResult {
236    /// Create an empty reconnect result.
237    #[must_use]
238    pub fn new() -> Self {
239        Self::default()
240    }
241
242    /// Total clips processed.
243    #[must_use]
244    pub fn total(&self) -> usize {
245        self.reconnected + self.failed
246    }
247
248    /// Success rate as a fraction [0.0, 1.0].
249    #[must_use]
250    pub fn success_rate(&self) -> f32 {
251        let total = self.total();
252        if total == 0 {
253            return 1.0;
254        }
255        self.reconnected as f32 / total as f32
256    }
257}
258
259/// Attempts to automatically reconnect proxies to originals in a directory.
260#[allow(dead_code)]
261pub struct AutoReconnector {
262    /// Root directory to search for originals.
263    search_root: PathBuf,
264    /// File extensions to consider as originals.
265    extensions: Vec<String>,
266}
267
268impl AutoReconnector {
269    /// Create a new auto-reconnector.
270    #[must_use]
271    pub fn new(search_root: impl Into<PathBuf>) -> Self {
272        Self {
273            search_root: search_root.into(),
274            extensions: vec![
275                "mov".to_string(),
276                "mxf".to_string(),
277                "mp4".to_string(),
278                "r3d".to_string(),
279                "braw".to_string(),
280            ],
281        }
282    }
283
284    /// Set the file extensions to search for.
285    #[must_use]
286    pub fn with_extensions(mut self, exts: Vec<String>) -> Self {
287        self.extensions = exts;
288        self
289    }
290
291    /// Get the search root directory.
292    #[must_use]
293    pub fn search_root(&self) -> &Path {
294        &self.search_root
295    }
296
297    /// Check if the given extension is included in the search.
298    #[must_use]
299    pub fn includes_extension(&self, ext: &str) -> bool {
300        self.extensions.iter().any(|e| e.eq_ignore_ascii_case(ext))
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn test_offline_proxy_clip_new() {
310        let clip = OfflineProxyClip::new("clip001", "/proxy/clip001.mp4");
311        assert_eq!(clip.id, "clip001");
312        assert_eq!(clip.status, OfflineStatus::Available);
313        assert!(clip.original_path.is_none());
314    }
315
316    #[test]
317    fn test_offline_proxy_clip_with_original() {
318        let clip = OfflineProxyClip::new("clip001", "/proxy/clip001.mp4")
319            .with_original("/original/clip001.mov");
320        assert!(clip.original_path.is_some());
321    }
322
323    #[test]
324    fn test_offline_proxy_clip_resolution_fraction_clamp() {
325        let clip = OfflineProxyClip::new("c1", "/p.mp4").with_resolution_fraction(2.0);
326        assert_eq!(clip.resolution_fraction, 1.0);
327
328        let clip2 = OfflineProxyClip::new("c2", "/p.mp4").with_resolution_fraction(-0.5);
329        assert_eq!(clip2.resolution_fraction, 0.0);
330    }
331
332    #[test]
333    fn test_offline_proxy_clip_is_reconnected_false_without_original() {
334        let mut clip = OfflineProxyClip::new("c1", "/p.mp4");
335        clip.status = OfflineStatus::Reconnected;
336        // No original path → not considered reconnected
337        assert!(!clip.is_reconnected());
338    }
339
340    #[test]
341    fn test_offline_proxy_clip_is_reconnected_true() {
342        let mut clip = OfflineProxyClip::new("c1", "/p.mp4").with_original("/o.mov");
343        clip.status = OfflineStatus::Reconnected;
344        assert!(clip.is_reconnected());
345    }
346
347    #[test]
348    fn test_session_register_and_get() {
349        let mut session = OfflineProxySession::new();
350        session.register(OfflineProxyClip::new("clip001", "/proxy/clip001.mp4"));
351        assert_eq!(session.clip_count(), 1);
352        assert!(session.get("clip001").is_some());
353        assert!(session.get("nonexistent").is_none());
354    }
355
356    #[test]
357    fn test_session_count_by_status() {
358        let mut session = OfflineProxySession::new();
359        session.register(OfflineProxyClip::new("c1", "/p1.mp4"));
360        session.register(OfflineProxyClip::new("c2", "/p2.mp4"));
361        let mut c3 = OfflineProxyClip::new("c3", "/p3.mp4");
362        c3.status = OfflineStatus::Missing;
363        session.register(c3);
364
365        assert_eq!(session.count_by_status(&OfflineStatus::Available), 2);
366        assert_eq!(session.count_by_status(&OfflineStatus::Missing), 1);
367    }
368
369    #[test]
370    fn test_session_reconnect() {
371        let mut session = OfflineProxySession::new();
372        session.register(OfflineProxyClip::new("c1", "/proxy.mp4"));
373        let ok = session.reconnect("c1", "/original.mov");
374        assert!(ok);
375        let clip = session.get("c1").expect("should succeed in test");
376        assert_eq!(clip.status, OfflineStatus::Reconnected);
377        assert!(clip.original_path.is_some());
378    }
379
380    #[test]
381    fn test_session_reconnect_nonexistent() {
382        let mut session = OfflineProxySession::new();
383        let ok = session.reconnect("nonexistent", "/original.mov");
384        assert!(!ok);
385    }
386
387    #[test]
388    fn test_session_substitute() {
389        let mut session = OfflineProxySession::new();
390        session.register(OfflineProxyClip::new("c1", "/proxy.mp4"));
391        session.substitute("c1");
392        assert_eq!(
393            session.get("c1").expect("should succeed in test").status,
394            OfflineStatus::Substituted
395        );
396    }
397
398    #[test]
399    fn test_session_clips_needing_reconnection() {
400        let mut session = OfflineProxySession::new();
401        session.register(OfflineProxyClip::new("c1", "/p1.mp4")); // no original
402        session.register(OfflineProxyClip::new("c2", "/p2.mp4").with_original("/o2.mov"));
403        assert_eq!(session.clips_needing_reconnection().len(), 1);
404    }
405
406    #[test]
407    fn test_reconnect_result_success_rate() {
408        let mut result = ReconnectResult::new();
409        result.reconnected = 8;
410        result.failed = 2;
411        assert!((result.success_rate() - 0.8).abs() < 1e-5);
412    }
413
414    #[test]
415    fn test_reconnect_result_success_rate_empty() {
416        let result = ReconnectResult::new();
417        assert_eq!(result.success_rate(), 1.0);
418    }
419
420    #[test]
421    fn test_auto_reconnector_includes_extension() {
422        let rc = AutoReconnector::new("/media");
423        assert!(rc.includes_extension("mov"));
424        assert!(rc.includes_extension("MXF"));
425        assert!(!rc.includes_extension("avi"));
426    }
427
428    #[test]
429    fn test_auto_reconnector_custom_extensions() {
430        let rc = AutoReconnector::new("/media")
431            .with_extensions(vec!["avi".to_string(), "wmv".to_string()]);
432        assert!(rc.includes_extension("avi"));
433        assert!(!rc.includes_extension("mov"));
434    }
435
436    #[test]
437    fn test_substitution_strategy_default() {
438        let strategy = SubstitutionStrategy::default();
439        assert_eq!(strategy, SubstitutionStrategy::Placeholder);
440    }
441
442    #[test]
443    fn test_session_with_strategy() {
444        let session = OfflineProxySession::new().with_strategy(SubstitutionStrategy::BlackFrame);
445        assert_eq!(
446            session.substitution_strategy(),
447            SubstitutionStrategy::BlackFrame
448        );
449    }
450
451    #[test]
452    fn test_session_strict_mode() {
453        let session = OfflineProxySession::new().strict();
454        assert!(session.is_strict());
455
456        let session2 = OfflineProxySession::new();
457        assert!(!session2.is_strict());
458    }
459}