Skip to main content

oximedia_proxy/
offline_edit.rs

1//! Offline editing proxy management.
2//!
3//! This module provides tools for managing offline editing workflows, including
4//! proxy configuration for various NLE software and relinking proxies to
5//! high-resolution master files after the offline edit is complete.
6
7#![allow(dead_code)]
8
9use serde::{Deserialize, Serialize};
10
11/// Configuration for offline editing proxy generation.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct OfflineEditConfig {
14    /// Codec to use for the proxy (e.g. "dnxhd", "prores_proxy", "h264").
15    pub proxy_codec: String,
16    /// Proxy resolution (width, height).
17    pub resolution: (u32, u32),
18    /// Target bitrate in kilobits per second.
19    pub bitrate_kbps: u32,
20    /// Number of audio channels.
21    pub audio_channels: u8,
22}
23
24impl OfflineEditConfig {
25    /// Avid DNxHD proxy preset: 1920×1080, 36 Mbps, stereo.
26    #[must_use]
27    pub fn avid_dnxhd_proxy() -> Self {
28        Self {
29            proxy_codec: "dnxhd".to_string(),
30            resolution: (1920, 1080),
31            bitrate_kbps: 36_000,
32            audio_channels: 2,
33        }
34    }
35
36    /// Apple ProRes Proxy preset: 1920×1080, 45 Mbps, stereo.
37    #[must_use]
38    pub fn prores_proxy() -> Self {
39        Self {
40            proxy_codec: "prores_proxy".to_string(),
41            resolution: (1920, 1080),
42            bitrate_kbps: 45_000,
43            audio_channels: 2,
44        }
45    }
46
47    /// H.264 offline proxy preset: 1280×720, 8 Mbps, stereo.
48    #[must_use]
49    pub fn h264_proxy() -> Self {
50        Self {
51            proxy_codec: "h264".to_string(),
52            resolution: (1280, 720),
53            bitrate_kbps: 8_000,
54            audio_channels: 2,
55        }
56    }
57}
58
59/// Represents the relink relationship between a proxy clip and its master file.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct OnlineRelink {
62    /// ID of the proxy clip.
63    pub proxy_id: String,
64    /// ID of the matched master file.
65    pub master_id: String,
66    /// Frame offset between proxy and master (positive = master ahead).
67    pub offset_frames: i64,
68    /// Confidence score for the match (0.0–1.0).
69    pub confidence: f32,
70}
71
72/// Strategy used when relinking proxies to masters.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74pub enum RelinkStrategy {
75    /// Require an exact ID/timecode match.
76    ExactMatch,
77    /// Allow fuzzy matching based on metadata similarity.
78    FuzzyMatch,
79    /// Require manual approval for every link.
80    ManualApproval,
81}
82
83impl RelinkStrategy {
84    /// Minimum confidence score required to accept a match automatically.
85    #[must_use]
86    pub fn min_confidence(self) -> f32 {
87        match self {
88            Self::ExactMatch => 1.0,
89            Self::FuzzyMatch => 0.75,
90            Self::ManualApproval => 0.0,
91        }
92    }
93}
94
95/// An edit event from the offline edit (a clip usage in the timeline).
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct EditEvent {
98    /// Clip identifier (should match a proxy ID).
99    pub clip_id: String,
100    /// In-point (inclusive) in frames.
101    pub in_point: u64,
102    /// Out-point (exclusive) in frames.
103    pub out_point: u64,
104}
105
106/// A master (high-resolution) media file available for relinking.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct MasterFile {
109    /// Unique identifier.
110    pub id: String,
111    /// File system path.
112    pub path: String,
113    /// Total duration in frames.
114    pub duration_frames: u64,
115}
116
117/// Relinks a proxy edit to master files.
118pub struct OfflineEditRelinker {
119    /// Strategy to apply when matching.
120    pub strategy: RelinkStrategy,
121}
122
123impl OfflineEditRelinker {
124    /// Create a new relinker with the given strategy.
125    #[must_use]
126    pub fn new(strategy: RelinkStrategy) -> Self {
127        Self { strategy }
128    }
129
130    /// Create a relinker with the default `FuzzyMatch` strategy.
131    #[must_use]
132    pub fn default_fuzzy() -> Self {
133        Self::new(RelinkStrategy::FuzzyMatch)
134    }
135
136    /// Attempt to relink every edit event to a master file.
137    ///
138    /// Each `EditEvent.clip_id` is compared against `MasterFile.id`.
139    /// - Exact match: clip_id == master_id → confidence 1.0
140    /// - Fuzzy match: clip_id starts with master_id prefix → confidence 0.85
141    /// - Otherwise: confidence 0.0 (skipped if strategy requires higher confidence)
142    #[must_use]
143    pub fn relink(
144        &self,
145        proxy_edit: &[EditEvent],
146        master_files: &[MasterFile],
147    ) -> Vec<OnlineRelink> {
148        let min_conf = self.strategy.min_confidence();
149        let mut results = Vec::new();
150
151        for event in proxy_edit {
152            // Try exact match first
153            if let Some(master) = master_files.iter().find(|m| m.id == event.clip_id) {
154                let link = OnlineRelink {
155                    proxy_id: event.clip_id.clone(),
156                    master_id: master.id.clone(),
157                    offset_frames: 0,
158                    confidence: 1.0,
159                };
160                if link.confidence >= min_conf {
161                    results.push(link);
162                    continue;
163                }
164            }
165
166            // Fuzzy match: strip suffix digits / common prefix comparison
167            let best = master_files.iter().find_map(|m| {
168                let conf = compute_fuzzy_confidence(&event.clip_id, &m.id);
169                if conf >= min_conf {
170                    Some((m, conf))
171                } else {
172                    None
173                }
174            });
175
176            if let Some((master, conf)) = best {
177                results.push(OnlineRelink {
178                    proxy_id: event.clip_id.clone(),
179                    master_id: master.id.clone(),
180                    offset_frames: 0,
181                    confidence: conf,
182                });
183            }
184        }
185
186        results
187    }
188}
189
190/// Compute a simple fuzzy confidence score between two IDs.
191fn compute_fuzzy_confidence(proxy_id: &str, master_id: &str) -> f32 {
192    // Both empty → no useful match information
193    if proxy_id.is_empty() && master_id.is_empty() {
194        return 0.0;
195    }
196    if proxy_id == master_id {
197        return 1.0;
198    }
199    // Prefix-based fuzzy match: common prefix relative to shorter string,
200    // boosted by squaring the ratio so IDs sharing a long base name
201    // (e.g. "clip_001_proxy" vs "clip_001_master") get a high score.
202    let common = proxy_id
203        .chars()
204        .zip(master_id.chars())
205        .take_while(|(a, b)| a == b)
206        .count();
207    let min_len = proxy_id.len().min(master_id.len());
208    if min_len == 0 {
209        return 0.0;
210    }
211    let ratio = common as f32 / min_len as f32;
212    // Use sqrt to boost partial prefix matches, cap at 0.95
213    (ratio.sqrt() * 0.95).min(0.95)
214}
215
216/// Summary report of a relink operation.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct RelinkReport {
219    /// Total number of clips processed.
220    pub total_clips: u32,
221    /// Number of clips successfully relinked.
222    pub relinked: u32,
223    /// Number of clips that could not be relinked.
224    pub failed: u32,
225    /// Average confidence across relinked clips.
226    pub confidence_avg: f32,
227}
228
229impl RelinkReport {
230    /// Build a report from a set of edit events and their relink results.
231    #[must_use]
232    pub fn from_results(events: &[EditEvent], relinks: &[OnlineRelink]) -> Self {
233        let total_clips = events.len() as u32;
234        let relinked = relinks.len() as u32;
235        let failed = total_clips.saturating_sub(relinked);
236        let confidence_avg = if relinked == 0 {
237            0.0
238        } else {
239            relinks.iter().map(|r| r.confidence).sum::<f32>() / relinked as f32
240        };
241        Self {
242            total_clips,
243            relinked,
244            failed,
245            confidence_avg,
246        }
247    }
248
249    /// Fraction of clips that were successfully relinked (0.0–1.0).
250    #[must_use]
251    pub fn success_rate(&self) -> f32 {
252        if self.total_clips == 0 {
253            return 1.0;
254        }
255        self.relinked as f32 / self.total_clips as f32
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_avid_dnxhd_proxy_preset() {
265        let cfg = OfflineEditConfig::avid_dnxhd_proxy();
266        assert_eq!(cfg.proxy_codec, "dnxhd");
267        assert_eq!(cfg.resolution, (1920, 1080));
268        assert_eq!(cfg.bitrate_kbps, 36_000);
269        assert_eq!(cfg.audio_channels, 2);
270    }
271
272    #[test]
273    fn test_prores_proxy_preset() {
274        let cfg = OfflineEditConfig::prores_proxy();
275        assert_eq!(cfg.proxy_codec, "prores_proxy");
276        assert_eq!(cfg.resolution, (1920, 1080));
277        assert_eq!(cfg.bitrate_kbps, 45_000);
278    }
279
280    #[test]
281    fn test_h264_proxy_preset() {
282        let cfg = OfflineEditConfig::h264_proxy();
283        assert_eq!(cfg.proxy_codec, "h264");
284        assert_eq!(cfg.resolution, (1280, 720));
285        assert_eq!(cfg.bitrate_kbps, 8_000);
286    }
287
288    #[test]
289    fn test_relink_strategy_min_confidence() {
290        assert!((RelinkStrategy::ExactMatch.min_confidence() - 1.0).abs() < f32::EPSILON);
291        assert!(RelinkStrategy::FuzzyMatch.min_confidence() < 1.0);
292        assert!((RelinkStrategy::ManualApproval.min_confidence() - 0.0).abs() < f32::EPSILON);
293    }
294
295    #[test]
296    fn test_exact_relink() {
297        let relinker = OfflineEditRelinker::new(RelinkStrategy::ExactMatch);
298        let events = vec![EditEvent {
299            clip_id: "clip_001".to_string(),
300            in_point: 0,
301            out_point: 100,
302        }];
303        let masters = vec![MasterFile {
304            id: "clip_001".to_string(),
305            path: "/media/clip_001.mov".to_string(),
306            duration_frames: 200,
307        }];
308        let relinks = relinker.relink(&events, &masters);
309        assert_eq!(relinks.len(), 1);
310        assert!((relinks[0].confidence - 1.0).abs() < f32::EPSILON);
311        assert_eq!(relinks[0].master_id, "clip_001");
312    }
313
314    #[test]
315    fn test_exact_relink_no_match() {
316        let relinker = OfflineEditRelinker::new(RelinkStrategy::ExactMatch);
317        let events = vec![EditEvent {
318            clip_id: "clip_999".to_string(),
319            in_point: 0,
320            out_point: 50,
321        }];
322        let masters = vec![MasterFile {
323            id: "clip_001".to_string(),
324            path: "/media/clip_001.mov".to_string(),
325            duration_frames: 200,
326        }];
327        let relinks = relinker.relink(&events, &masters);
328        // No exact match, and fuzzy confidence < 1.0 so not accepted
329        assert_eq!(relinks.len(), 0);
330    }
331
332    #[test]
333    fn test_fuzzy_relink_partial_match() {
334        let relinker = OfflineEditRelinker::new(RelinkStrategy::FuzzyMatch);
335        let events = vec![EditEvent {
336            clip_id: "clip_001_proxy".to_string(),
337            in_point: 0,
338            out_point: 100,
339        }];
340        let masters = vec![MasterFile {
341            id: "clip_001_master".to_string(),
342            path: "/media/clip_001_master.mov".to_string(),
343            duration_frames: 200,
344        }];
345        let relinks = relinker.relink(&events, &masters);
346        // "clip_001_" prefix is shared; should get a reasonable fuzzy score
347        assert_eq!(relinks.len(), 1);
348        assert!(relinks[0].confidence >= 0.75);
349    }
350
351    #[test]
352    fn test_manual_approval_strategy_accepts_all() {
353        let relinker = OfflineEditRelinker::new(RelinkStrategy::ManualApproval);
354        let events = vec![EditEvent {
355            clip_id: "xyz".to_string(),
356            in_point: 0,
357            out_point: 10,
358        }];
359        let masters = vec![MasterFile {
360            id: "abc".to_string(),
361            path: "/media/abc.mov".to_string(),
362            duration_frames: 100,
363        }];
364        // With min_confidence=0, even low-confidence fuzzy matches are accepted
365        let relinks = relinker.relink(&events, &masters);
366        // "xyz" vs "abc" share no prefix → confidence 0.0, which still >= 0.0
367        assert_eq!(relinks.len(), 1);
368    }
369
370    #[test]
371    fn test_relink_multiple_events() {
372        let relinker = OfflineEditRelinker::new(RelinkStrategy::ExactMatch);
373        let events = vec![
374            EditEvent {
375                clip_id: "a".to_string(),
376                in_point: 0,
377                out_point: 10,
378            },
379            EditEvent {
380                clip_id: "b".to_string(),
381                in_point: 10,
382                out_point: 20,
383            },
384            EditEvent {
385                clip_id: "c".to_string(),
386                in_point: 20,
387                out_point: 30,
388            },
389        ];
390        let masters = vec![
391            MasterFile {
392                id: "a".to_string(),
393                path: "/a.mov".to_string(),
394                duration_frames: 50,
395            },
396            MasterFile {
397                id: "b".to_string(),
398                path: "/b.mov".to_string(),
399                duration_frames: 50,
400            },
401        ];
402        let relinks = relinker.relink(&events, &masters);
403        assert_eq!(relinks.len(), 2); // "c" has no master
404    }
405
406    #[test]
407    fn test_relink_report_success_rate() {
408        let events = vec![
409            EditEvent {
410                clip_id: "a".to_string(),
411                in_point: 0,
412                out_point: 10,
413            },
414            EditEvent {
415                clip_id: "b".to_string(),
416                in_point: 10,
417                out_point: 20,
418            },
419        ];
420        let relinks = vec![OnlineRelink {
421            proxy_id: "a".to_string(),
422            master_id: "a".to_string(),
423            offset_frames: 0,
424            confidence: 1.0,
425        }];
426        let report = RelinkReport::from_results(&events, &relinks);
427        assert_eq!(report.total_clips, 2);
428        assert_eq!(report.relinked, 1);
429        assert_eq!(report.failed, 1);
430        assert!((report.success_rate() - 0.5).abs() < f32::EPSILON);
431    }
432
433    #[test]
434    fn test_relink_report_empty() {
435        let report = RelinkReport::from_results(&[], &[]);
436        assert_eq!(report.total_clips, 0);
437        assert!((report.success_rate() - 1.0).abs() < f32::EPSILON);
438    }
439
440    #[test]
441    fn test_relink_report_confidence_avg() {
442        let events = vec![
443            EditEvent {
444                clip_id: "a".to_string(),
445                in_point: 0,
446                out_point: 5,
447            },
448            EditEvent {
449                clip_id: "b".to_string(),
450                in_point: 5,
451                out_point: 10,
452            },
453        ];
454        let relinks = vec![
455            OnlineRelink {
456                proxy_id: "a".to_string(),
457                master_id: "a".to_string(),
458                offset_frames: 0,
459                confidence: 1.0,
460            },
461            OnlineRelink {
462                proxy_id: "b".to_string(),
463                master_id: "b".to_string(),
464                offset_frames: 0,
465                confidence: 0.5,
466            },
467        ];
468        let report = RelinkReport::from_results(&events, &relinks);
469        assert!((report.confidence_avg - 0.75).abs() < f32::EPSILON);
470    }
471
472    #[test]
473    fn test_compute_fuzzy_confidence_identical() {
474        assert!((compute_fuzzy_confidence("abc", "abc") - 1.0).abs() < f32::EPSILON);
475    }
476
477    #[test]
478    fn test_compute_fuzzy_confidence_partial() {
479        let c = compute_fuzzy_confidence("abcXXX", "abcYYY");
480        // 3 common out of 6 max → 3/6 * 0.95 ≈ 0.475
481        assert!(c > 0.0 && c < 1.0);
482    }
483
484    #[test]
485    fn test_compute_fuzzy_confidence_empty() {
486        assert!((compute_fuzzy_confidence("", "") - 0.0).abs() < f32::EPSILON);
487    }
488}