Skip to main content

oximedia_proxy/
smart_proxy.rs

1//! Intelligent proxy selection and recommendation.
2//!
3//! This module analyses the editing context (NLE software, resolution, network
4//! conditions) and recommends the most appropriate proxy configuration for a
5//! given set of source files.
6
7#![allow(dead_code)]
8
9use crate::transcode_queue::ProxySpec;
10use serde::{Deserialize, Serialize};
11
12/// The NLE (Non-Linear Editor) software being used.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum EditingSoftware {
15    /// DaVinci Resolve (Blackmagic Design).
16    Resolve,
17    /// Adobe Premiere Pro.
18    Premiere,
19    /// Avid Media Composer.
20    Avid,
21    /// Apple Final Cut Pro.
22    FinalCut,
23    /// VEGAS Pro (Magix).
24    Vegas,
25    /// Kdenlive (KDE).
26    Kdenlive,
27}
28
29impl EditingSoftware {
30    /// The proxy codec preferred by this software.
31    #[must_use]
32    pub fn preferred_proxy_codec(self) -> &'static str {
33        match self {
34            Self::Resolve => "prores_proxy",
35            Self::Premiere => "h264",
36            Self::Avid => "dnxhd",
37            Self::FinalCut => "prores_proxy",
38            Self::Vegas => "h264",
39            Self::Kdenlive => "h264",
40        }
41    }
42}
43
44/// Information about an editing session's context.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct EditingContext {
47    /// NLE software in use.
48    pub software: EditingSoftware,
49    /// Project/sequence resolution (width, height).
50    pub resolution: (u32, u32),
51    /// Codecs the editing system can decode in real-time.
52    pub codec_support: Vec<String>,
53    /// Available network bandwidth in Mbit/s (for shared storage workflows).
54    pub network_speed_mbps: f32,
55}
56
57impl EditingContext {
58    /// Create a new editing context.
59    #[must_use]
60    pub fn new(
61        software: EditingSoftware,
62        resolution: (u32, u32),
63        codec_support: Vec<String>,
64        network_speed_mbps: f32,
65    ) -> Self {
66        Self {
67            software,
68            resolution,
69            codec_support,
70            network_speed_mbps,
71        }
72    }
73}
74
75/// Source file specification used when choosing a proxy.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct SourceSpec {
78    /// File path.
79    pub path: String,
80    /// Source resolution (width, height).
81    pub resolution: (u32, u32),
82    /// Source codec identifier.
83    pub codec: String,
84    /// Source bitrate in kbit/s.
85    pub bitrate_kbps: u32,
86    /// Source frame rate.
87    pub fps: f32,
88}
89
90impl SourceSpec {
91    /// Create a new source spec.
92    #[must_use]
93    pub fn new(
94        path: impl Into<String>,
95        resolution: (u32, u32),
96        codec: impl Into<String>,
97        bitrate_kbps: u32,
98        fps: f32,
99    ) -> Self {
100        Self {
101            path: path.into(),
102            resolution,
103            codec: codec.into(),
104            bitrate_kbps,
105            fps,
106        }
107    }
108}
109
110/// Recommends proxy specifications based on editing context.
111pub struct ProxyRecommender;
112
113impl ProxyRecommender {
114    /// Recommend a list of proxy specs for the given sources and editing context.
115    ///
116    /// The recommendation strategy:
117    /// 1. Use the codec preferred by the editing software.
118    /// 2. Target the editing context resolution (downscaling if the source is larger).
119    /// 3. Adjust bitrate based on network speed: slower networks → lower bitrate.
120    #[must_use]
121    pub fn recommend(context: &EditingContext, source_specs: &[SourceSpec]) -> Vec<ProxySpec> {
122        let preferred_codec = context.software.preferred_proxy_codec().to_string();
123
124        // Determine target resolution: use context resolution, but do not upscale
125        let (ctx_w, ctx_h) = context.resolution;
126
127        source_specs
128            .iter()
129            .map(|src| {
130                let (src_w, src_h) = src.resolution;
131                // Do not upscale
132                let target_w = ctx_w.min(src_w);
133                let target_h = ctx_h.min(src_h);
134
135                // Bitrate: scale by pixel area ratio vs context resolution,
136                // then clamp based on network speed
137                let area_ratio =
138                    (target_w * target_h) as f32 / (src_w.max(1) * src_h.max(1)) as f32;
139                let base_bitrate_kbps = (src.bitrate_kbps as f32 * area_ratio) as u32;
140
141                // Cap bitrate to what the network can sustain (rough heuristic:
142                // leave 50% of bandwidth headroom, convert Mbit/s → kbit/s)
143                let max_net_kbps = (context.network_speed_mbps * 1000.0 * 0.5) as u32;
144                let bitrate_kbps = if max_net_kbps > 0 {
145                    base_bitrate_kbps.min(max_net_kbps)
146                } else {
147                    base_bitrate_kbps
148                };
149
150                // Use the software's preferred codec, unless not supported
151                let codec = if context
152                    .codec_support
153                    .iter()
154                    .any(|c| c == preferred_codec.as_str())
155                    || context.codec_support.is_empty()
156                {
157                    preferred_codec.clone()
158                } else {
159                    // Fall back to first supported codec
160                    context
161                        .codec_support
162                        .first()
163                        .cloned()
164                        .unwrap_or_else(|| "h264".to_string())
165                };
166
167                ProxySpec::new((target_w, target_h), codec, bitrate_kbps.max(500))
168            })
169            .collect()
170    }
171}
172
173/// Result of a proxy–context compatibility check.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct CompatibilityResult {
176    /// Whether the proxy is fully compatible with the editing context.
177    pub compatible: bool,
178    /// Non-fatal warnings (e.g. sub-optimal codec choice).
179    pub warnings: Vec<String>,
180    /// Compatibility score (0.0 = incompatible, 1.0 = perfect).
181    pub score: f32,
182}
183
184impl CompatibilityResult {
185    /// Create a fully compatible result with no warnings.
186    #[must_use]
187    pub fn ok() -> Self {
188        Self {
189            compatible: true,
190            warnings: vec![],
191            score: 1.0,
192        }
193    }
194
195    /// Create an incompatible result.
196    #[must_use]
197    pub fn incompatible(reason: impl Into<String>) -> Self {
198        Self {
199            compatible: false,
200            warnings: vec![reason.into()],
201            score: 0.0,
202        }
203    }
204}
205
206/// Checks whether a proxy spec is compatible with an editing context.
207pub struct ProxyCompatibilityChecker;
208
209impl ProxyCompatibilityChecker {
210    /// Check compatibility and return a detailed result.
211    #[must_use]
212    pub fn check(proxy: &ProxySpec, context: &EditingContext) -> CompatibilityResult {
213        let mut warnings = Vec::new();
214        let mut score = 1.0f32;
215
216        // 1. Codec check
217        let preferred = context.software.preferred_proxy_codec();
218        if proxy.codec != preferred {
219            warnings.push(format!(
220                "Proxy codec '{}' is not the preferred codec '{}' for {:?}",
221                proxy.codec, preferred, context.software
222            ));
223            score -= 0.2;
224        }
225
226        // 2. Resolution check: proxy should not exceed context resolution
227        let (p_w, p_h) = proxy.resolution;
228        let (c_w, c_h) = context.resolution;
229        if p_w > c_w || p_h > c_h {
230            warnings.push(format!(
231                "Proxy resolution {}×{} exceeds editing resolution {}×{}",
232                p_w, p_h, c_w, c_h
233            ));
234            score -= 0.2;
235        }
236
237        // 3. Bitrate vs network check
238        let max_net_kbps = (context.network_speed_mbps * 1000.0 * 0.5) as u32;
239        if max_net_kbps > 0 && proxy.bitrate_kbps > max_net_kbps {
240            warnings.push(format!(
241                "Proxy bitrate {} kbps exceeds safe network limit {} kbps",
242                proxy.bitrate_kbps, max_net_kbps
243            ));
244            score -= 0.3;
245        }
246
247        // 4. Codec support list (if provided)
248        if !context.codec_support.is_empty()
249            && !context
250                .codec_support
251                .iter()
252                .any(|c| c == proxy.codec.as_str())
253        {
254            warnings.push(format!(
255                "Proxy codec '{}' is not in the supported codec list",
256                proxy.codec
257            ));
258            score -= 0.3;
259        }
260
261        CompatibilityResult {
262            compatible: score > 0.4,
263            warnings,
264            score: score.clamp(0.0, 1.0),
265        }
266    }
267}
268
269/// Estimates disk storage required for a batch of proxy files.
270pub struct ProxyStorageEstimator;
271
272impl ProxyStorageEstimator {
273    /// Estimate the total storage in gigabytes.
274    ///
275    /// # Arguments
276    /// * `source_count` – Number of source files.
277    /// * `avg_duration_mins` – Average duration of each source file in minutes.
278    /// * `spec` – Proxy specification (bitrate drives the estimate).
279    #[must_use]
280    pub fn estimate_gb(source_count: u32, avg_duration_mins: f32, spec: &ProxySpec) -> f64 {
281        if source_count == 0 || avg_duration_mins <= 0.0 {
282            return 0.0;
283        }
284        // bitrate_kbps * 1000 bits/kbit → bits/s
285        // * 60 seconds/minute * avg_duration_mins → total bits
286        // / 8 → bytes, / 1e9 → gigabytes
287        let bits_per_file = spec.bitrate_kbps as f64 * 1_000.0 * 60.0 * avg_duration_mins as f64;
288        let bytes_per_file = bits_per_file / 8.0;
289        let total_bytes = bytes_per_file * source_count as f64;
290        total_bytes / 1_000_000_000.0
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn make_context(software: EditingSoftware, res: (u32, u32), net: f32) -> EditingContext {
299        EditingContext::new(software, res, vec![], net)
300    }
301
302    #[test]
303    fn test_editing_software_preferred_codec() {
304        assert_eq!(
305            EditingSoftware::Resolve.preferred_proxy_codec(),
306            "prores_proxy"
307        );
308        assert_eq!(EditingSoftware::Premiere.preferred_proxy_codec(), "h264");
309        assert_eq!(EditingSoftware::Avid.preferred_proxy_codec(), "dnxhd");
310        assert_eq!(
311            EditingSoftware::FinalCut.preferred_proxy_codec(),
312            "prores_proxy"
313        );
314        assert_eq!(EditingSoftware::Vegas.preferred_proxy_codec(), "h264");
315        assert_eq!(EditingSoftware::Kdenlive.preferred_proxy_codec(), "h264");
316    }
317
318    #[test]
319    fn test_recommender_codec_matches_software() {
320        let ctx = make_context(EditingSoftware::Avid, (1920, 1080), 1000.0);
321        let src = vec![SourceSpec::new(
322            "/a.mov",
323            (3840, 2160),
324            "h264",
325            100_000,
326            25.0,
327        )];
328        let recs = ProxyRecommender::recommend(&ctx, &src);
329        assert_eq!(recs.len(), 1);
330        assert_eq!(recs[0].codec, "dnxhd");
331    }
332
333    #[test]
334    fn test_recommender_does_not_upscale() {
335        let ctx = make_context(EditingSoftware::Premiere, (3840, 2160), 1000.0);
336        let src = vec![SourceSpec::new(
337            "/b.mov",
338            (1920, 1080),
339            "h264",
340            10_000,
341            25.0,
342        )];
343        let recs = ProxyRecommender::recommend(&ctx, &src);
344        assert_eq!(recs[0].resolution, (1920, 1080));
345    }
346
347    #[test]
348    fn test_recommender_network_cap() {
349        // Network: 1 Mbit/s → max 500 kbps usable
350        let ctx = make_context(EditingSoftware::Premiere, (1920, 1080), 1.0);
351        let src = vec![SourceSpec::new(
352            "/c.mov",
353            (1920, 1080),
354            "h264",
355            100_000,
356            25.0,
357        )];
358        let recs = ProxyRecommender::recommend(&ctx, &src);
359        assert!(recs[0].bitrate_kbps <= 500);
360    }
361
362    #[test]
363    fn test_recommender_empty_sources() {
364        let ctx = make_context(EditingSoftware::Resolve, (1920, 1080), 100.0);
365        let recs = ProxyRecommender::recommend(&ctx, &[]);
366        assert!(recs.is_empty());
367    }
368
369    #[test]
370    fn test_compatibility_check_perfect() {
371        let proxy = ProxySpec::new((1920, 1080), "prores_proxy", 10_000);
372        let ctx = EditingContext::new(
373            EditingSoftware::Resolve,
374            (1920, 1080),
375            vec!["prores_proxy".to_string()],
376            1000.0,
377        );
378        let result = ProxyCompatibilityChecker::check(&proxy, &ctx);
379        assert!(result.compatible);
380        assert!(result.warnings.is_empty());
381        assert!((result.score - 1.0).abs() < f32::EPSILON);
382    }
383
384    #[test]
385    fn test_compatibility_check_wrong_codec() {
386        let proxy = ProxySpec::new((1920, 1080), "h264", 10_000);
387        let ctx = EditingContext::new(
388            EditingSoftware::Avid,
389            (1920, 1080),
390            vec!["dnxhd".to_string()],
391            1000.0,
392        );
393        let result = ProxyCompatibilityChecker::check(&proxy, &ctx);
394        assert!(!result.warnings.is_empty());
395        assert!(result.score < 1.0);
396    }
397
398    #[test]
399    fn test_compatibility_check_resolution_too_large() {
400        let proxy = ProxySpec::new((3840, 2160), "h264", 5_000);
401        let ctx = make_context(EditingSoftware::Premiere, (1920, 1080), 1000.0);
402        let result = ProxyCompatibilityChecker::check(&proxy, &ctx);
403        assert!(!result.warnings.is_empty());
404        assert!(result.score < 1.0);
405    }
406
407    #[test]
408    fn test_compatibility_check_bitrate_too_high() {
409        // Network 1 Mbit/s → max 500 kbps
410        let proxy = ProxySpec::new((1280, 720), "h264", 50_000);
411        let ctx = make_context(EditingSoftware::Premiere, (1920, 1080), 1.0);
412        let result = ProxyCompatibilityChecker::check(&proxy, &ctx);
413        assert!(!result.warnings.is_empty());
414    }
415
416    #[test]
417    fn test_storage_estimator_basic() {
418        let spec = ProxySpec::new((1920, 1080), "h264", 8_000);
419        let gb = ProxyStorageEstimator::estimate_gb(100, 5.0, &spec);
420        // 8000 kbps * 1000 * 60 * 5 / 8 / 1e9 * 100 = 30 GB
421        assert!((gb - 30.0).abs() < 0.01);
422    }
423
424    #[test]
425    fn test_storage_estimator_zero_files() {
426        let spec = ProxySpec::new((1920, 1080), "h264", 8_000);
427        let gb = ProxyStorageEstimator::estimate_gb(0, 5.0, &spec);
428        assert!((gb - 0.0).abs() < f64::EPSILON);
429    }
430
431    #[test]
432    fn test_storage_estimator_zero_duration() {
433        let spec = ProxySpec::new((1920, 1080), "h264", 8_000);
434        let gb = ProxyStorageEstimator::estimate_gb(10, 0.0, &spec);
435        assert!((gb - 0.0).abs() < f64::EPSILON);
436    }
437}
438
439// ============================================================================
440// Multi-Resolution Smart Proxy
441// ============================================================================
442
443/// A named proxy variant at a specific resolution tier.
444#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct ResolutionVariant {
446    /// Human-readable tier label (e.g. "quarter", "half", "full").
447    pub label: String,
448    /// Fraction of source resolution (0.0 < scale ≤ 1.0).
449    pub scale: f32,
450    /// Explicit resolution (width, height), computed from source + scale.
451    pub resolution: (u32, u32),
452    /// Codec identifier.
453    pub codec: String,
454    /// Bitrate in kbit/s.
455    pub bitrate_kbps: u32,
456}
457
458impl ResolutionVariant {
459    /// Construct a variant from a source resolution and a scale factor.
460    ///
461    /// Width and height are rounded down to the nearest even number to keep
462    /// them compatible with YUV 4:2:0 encoding.
463    #[must_use]
464    pub fn from_source(
465        label: impl Into<String>,
466        source: (u32, u32),
467        scale: f32,
468        codec: impl Into<String>,
469        base_bitrate_kbps: u32,
470    ) -> Self {
471        let (sw, sh) = source;
472        let w = ((sw as f32 * scale) as u32) & !1; // even
473        let h = ((sh as f32 * scale) as u32) & !1;
474        let actual_scale = (w * h) as f32 / ((sw * sh).max(1) as f32);
475        let bitrate = (base_bitrate_kbps as f32 * actual_scale) as u32;
476        Self {
477            label: label.into(),
478            scale,
479            resolution: (w.max(2), h.max(2)),
480            codec: codec.into(),
481            bitrate_kbps: bitrate.max(200),
482        }
483    }
484}
485
486/// A multi-resolution proxy set containing quarter, half, and full variants.
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct MultiResolutionProxy {
489    /// Source file path.
490    pub source_path: String,
491    /// Quarter-resolution variant (25% area).
492    pub quarter: ResolutionVariant,
493    /// Half-resolution variant (50% area).
494    pub half: ResolutionVariant,
495    /// Full-resolution variant.
496    pub full: ResolutionVariant,
497}
498
499impl MultiResolutionProxy {
500    /// Build a complete multi-resolution proxy set from a source spec.
501    ///
502    /// All three variants share the same codec as the preferred NLE codec for
503    /// `software`. Bitrates are derived from `base_bitrate_kbps` scaled by
504    /// pixel-area ratio.
505    #[must_use]
506    pub fn from_source(
507        source_path: impl Into<String>,
508        source_resolution: (u32, u32),
509        codec: impl Into<String>,
510        base_bitrate_kbps: u32,
511    ) -> Self {
512        let codec = codec.into();
513        let path = source_path.into();
514        Self {
515            source_path: path,
516            quarter: ResolutionVariant::from_source(
517                "quarter",
518                source_resolution,
519                0.5, // 0.5 linear → 0.25 area
520                &codec,
521                base_bitrate_kbps,
522            ),
523            half: ResolutionVariant::from_source(
524                "half",
525                source_resolution,
526                0.707, // √0.5 linear → 0.5 area
527                &codec,
528                base_bitrate_kbps,
529            ),
530            full: ResolutionVariant::from_source(
531                "full",
532                source_resolution,
533                1.0,
534                &codec,
535                base_bitrate_kbps,
536            ),
537        }
538    }
539}
540
541/// Selects the best `ResolutionVariant` for a given display window size.
542///
543/// The algorithm picks the smallest variant whose resolution is at least as
544/// large as the display window in both dimensions, falling back to `full` if
545/// no variant meets the threshold.
546pub struct DisplayAwareSelector;
547
548impl DisplayAwareSelector {
549    /// Choose the appropriate variant for a display of `(display_w, display_h)` pixels.
550    ///
551    /// Selection rules (applied in order, first match wins):
552    /// 1. If display area ≤ quarter area → use `quarter`
553    /// 2. If display area ≤ half area    → use `half`
554    /// 3. Otherwise                      → use `full`
555    #[must_use]
556    pub fn select<'a>(
557        proxy: &'a MultiResolutionProxy,
558        display: (u32, u32),
559    ) -> &'a ResolutionVariant {
560        let display_area = display.0 as u64 * display.1 as u64;
561        let (qw, qh) = proxy.quarter.resolution;
562        let quarter_area = qw as u64 * qh as u64;
563        let (hw, hh) = proxy.half.resolution;
564        let half_area = hw as u64 * hh as u64;
565
566        if display_area <= quarter_area {
567            &proxy.quarter
568        } else if display_area <= half_area {
569            &proxy.half
570        } else {
571            &proxy.full
572        }
573    }
574
575    /// Select and return the label string for the chosen variant.
576    #[must_use]
577    pub fn select_label(proxy: &MultiResolutionProxy, display: (u32, u32)) -> &str {
578        Self::select(proxy, display).label.as_str()
579    }
580}
581
582#[cfg(test)]
583mod multi_res_tests {
584    use super::*;
585
586    fn make_proxy() -> MultiResolutionProxy {
587        MultiResolutionProxy::from_source("/src/4k.mov", (3840, 2160), "h264", 50_000)
588    }
589
590    #[test]
591    fn test_multi_res_variants_created() {
592        let p = make_proxy();
593        // quarter: 3840*0.5=1920, 2160*0.5=1080
594        assert_eq!(p.quarter.resolution, (1920, 1080));
595        // half: 3840*0.707≈2714 (even), 2160*0.707≈1527 (even)
596        let (hw, hh) = p.half.resolution;
597        assert!(hw > 1920 && hw < 3840);
598        assert!(hh > 1080 && hh < 2160);
599        // full
600        assert_eq!(p.full.resolution, (3840, 2160));
601    }
602
603    #[test]
604    fn test_variant_label() {
605        let p = make_proxy();
606        assert_eq!(p.quarter.label, "quarter");
607        assert_eq!(p.half.label, "half");
608        assert_eq!(p.full.label, "full");
609    }
610
611    #[test]
612    fn test_variant_scale() {
613        let p = make_proxy();
614        assert!((p.quarter.scale - 0.5).abs() < 1e-3);
615        assert!((p.full.scale - 1.0).abs() < 1e-3);
616    }
617
618    #[test]
619    fn test_display_aware_selects_quarter_for_small_display() {
620        let p = make_proxy();
621        // 960×540 is smaller than quarter (1920×1080)
622        let label = DisplayAwareSelector::select_label(&p, (960, 540));
623        assert_eq!(label, "quarter");
624    }
625
626    #[test]
627    fn test_display_aware_selects_half_for_medium_display() {
628        let p = make_proxy();
629        // 1920×1080 fits quarter exactly — select quarter
630        // 2000×1200 > quarter (1920×1080) but ≤ half → half
631        let label = DisplayAwareSelector::select_label(&p, (2000, 1200));
632        assert_eq!(label, "half");
633    }
634
635    #[test]
636    fn test_display_aware_selects_full_for_large_display() {
637        let p = make_proxy();
638        // 3840×2160 is the full display
639        let label = DisplayAwareSelector::select_label(&p, (3840, 2160));
640        assert_eq!(label, "full");
641    }
642
643    #[test]
644    fn test_display_aware_exact_quarter_area() {
645        let p = make_proxy();
646        let (qw, qh) = p.quarter.resolution;
647        // Exactly the quarter area should resolve to quarter
648        let label = DisplayAwareSelector::select_label(&p, (qw, qh));
649        assert_eq!(label, "quarter");
650    }
651
652    #[test]
653    fn test_bitrate_scales_with_area() {
654        let p = make_proxy();
655        // Quarter has smaller bitrate than half which has smaller than full
656        assert!(p.quarter.bitrate_kbps < p.half.bitrate_kbps);
657        assert!(p.half.bitrate_kbps < p.full.bitrate_kbps);
658    }
659
660    #[test]
661    fn test_select_returns_reference() {
662        let p = make_proxy();
663        let variant = DisplayAwareSelector::select(&p, (640, 360));
664        assert!(!variant.label.is_empty());
665    }
666
667    #[test]
668    fn test_multi_res_codec_propagated() {
669        let p = MultiResolutionProxy::from_source("/a.mov", (1920, 1080), "prores_proxy", 20_000);
670        assert_eq!(p.quarter.codec, "prores_proxy");
671        assert_eq!(p.half.codec, "prores_proxy");
672        assert_eq!(p.full.codec, "prores_proxy");
673    }
674
675    #[test]
676    fn test_resolution_variant_from_source_even_dimensions() {
677        // Source 1001×999 → even rounding
678        let v = ResolutionVariant::from_source("half", (1001, 999), 0.5, "h264", 10_000);
679        assert_eq!(v.resolution.0 % 2, 0);
680        assert_eq!(v.resolution.1 % 2, 0);
681    }
682}