Skip to main content

oximedia_proxy/
spec.rs

1//! Proxy specification: defines the desired proxy format, resolution, codec, and bitrate.
2//!
3//! A `ProxySpec` is the authoritative description of how a proxy should be created.
4//! It decouples the "what" (spec) from the "how" (encoder) and "where" (registry).
5
6use crate::generate::ProxyGenerationSettings;
7use crate::{ProxyError, Result};
8use serde::{Deserialize, Serialize};
9
10/// Target video codec for proxy encoding.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub enum ProxyCodec {
13    /// H.264 / AVC (best compatibility).
14    H264,
15    /// H.265 / HEVC (better compression).
16    H265,
17    /// VP9 (open, good quality).
18    Vp9,
19    /// Apple ProRes 422 Proxy (edit-optimized, Apple ecosystem).
20    ProRes422Proxy,
21    /// DNxHD 36 (Avid-compatible lightweight proxy).
22    DnxHd36,
23    /// Custom codec identified by name.
24    Custom(String),
25}
26
27impl ProxyCodec {
28    /// Get the codec identifier string used in settings.
29    #[must_use]
30    pub fn as_str(&self) -> &str {
31        match self {
32            Self::H264 => "h264",
33            Self::H265 => "h265",
34            Self::Vp9 => "vp9",
35            Self::ProRes422Proxy => "prores_proxy",
36            Self::DnxHd36 => "dnxhd36",
37            Self::Custom(s) => s.as_str(),
38        }
39    }
40
41    /// Recommended file container for this codec.
42    #[must_use]
43    pub fn recommended_container(&self) -> &'static str {
44        match self {
45            Self::H264 | Self::H265 | Self::Vp9 => "mp4",
46            Self::ProRes422Proxy => "mov",
47            Self::DnxHd36 => "mxf",
48            Self::Custom(_) => "mp4",
49        }
50    }
51
52    /// Whether this codec is typically hardware-acceleratable.
53    #[must_use]
54    pub const fn hw_accel_supported(&self) -> bool {
55        matches!(self, Self::H264 | Self::H265)
56    }
57}
58
59impl std::fmt::Display for ProxyCodec {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(f, "{}", self.as_str())
62    }
63}
64
65/// Target resolution mode.
66#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
67pub enum ProxyResolutionMode {
68    /// Scale to a fraction of the original (e.g., 0.25 = quarter res).
69    ScaleFactor(f32),
70    /// Specific pixel dimensions (width, height).
71    Fixed(u32, u32),
72    /// Fit within a bounding box preserving aspect ratio.
73    FitWithin {
74        /// Maximum allowed width in pixels.
75        max_width: u32,
76        /// Maximum allowed height in pixels.
77        max_height: u32,
78    },
79}
80
81impl ProxyResolutionMode {
82    /// Compute the output dimensions given the original dimensions.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if scale factor is out of range.
87    pub fn compute_output(&self, orig_w: u32, orig_h: u32) -> Result<(u32, u32)> {
88        match self {
89            Self::ScaleFactor(scale) => {
90                if *scale <= 0.0 || *scale > 4.0 {
91                    return Err(ProxyError::InvalidInput(format!(
92                        "Scale factor must be in (0, 4], got {scale}"
93                    )));
94                }
95                let w = ((orig_w as f32 * scale) as u32).max(2);
96                let h = ((orig_h as f32 * scale) as u32).max(2);
97                // Ensure even dimensions for codec compatibility
98                Ok((w & !1, h & !1))
99            }
100            Self::Fixed(w, h) => {
101                if *w == 0 || *h == 0 {
102                    return Err(ProxyError::InvalidInput(
103                        "Fixed dimensions must be > 0".to_string(),
104                    ));
105                }
106                Ok((*w & !1, *h & !1))
107            }
108            Self::FitWithin {
109                max_width,
110                max_height,
111            } => {
112                if *max_width == 0 || *max_height == 0 {
113                    return Err(ProxyError::InvalidInput(
114                        "FitWithin bounds must be > 0".to_string(),
115                    ));
116                }
117                let w_ratio = *max_width as f32 / orig_w as f32;
118                let h_ratio = *max_height as f32 / orig_h as f32;
119                let scale = w_ratio.min(h_ratio);
120                let w = ((orig_w as f32 * scale) as u32).max(2);
121                let h = ((orig_h as f32 * scale) as u32).max(2);
122                Ok((w & !1, h & !1))
123            }
124        }
125    }
126}
127
128/// Complete proxy specification.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct ProxySpec {
131    /// Human-readable name for this spec.
132    pub name: String,
133    /// Target resolution mode.
134    pub resolution: ProxyResolutionMode,
135    /// Video codec to use.
136    pub codec: ProxyCodec,
137    /// Video bitrate in bits per second.
138    pub video_bitrate: u64,
139    /// Audio codec (e.g. "aac").
140    pub audio_codec: String,
141    /// Audio bitrate in bits per second.
142    pub audio_bitrate: u64,
143    /// Container format override. If `None`, uses `codec.recommended_container()`.
144    pub container: Option<String>,
145    /// Whether to preserve source timecode.
146    pub preserve_timecode: bool,
147    /// Whether to preserve source metadata.
148    pub preserve_metadata: bool,
149    /// Whether to use hardware acceleration.
150    pub use_hw_accel: bool,
151    /// Encoding quality preset ("fast", "medium", "slow").
152    pub quality_preset: String,
153}
154
155impl ProxySpec {
156    /// Create a new proxy spec with required fields.
157    #[must_use]
158    pub fn new(
159        name: impl Into<String>,
160        resolution: ProxyResolutionMode,
161        codec: ProxyCodec,
162        video_bitrate: u64,
163    ) -> Self {
164        Self {
165            name: name.into(),
166            resolution,
167            codec,
168            video_bitrate,
169            audio_codec: "aac".to_string(),
170            audio_bitrate: 128_000,
171            container: None,
172            preserve_timecode: true,
173            preserve_metadata: true,
174            use_hw_accel: true,
175            quality_preset: "fast".to_string(),
176        }
177    }
178
179    /// Get the container format.
180    #[must_use]
181    pub fn container_format(&self) -> &str {
182        self.container
183            .as_deref()
184            .unwrap_or_else(|| self.codec.recommended_container())
185    }
186
187    /// Validate the spec.
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if any field has an invalid value.
192    pub fn validate(&self) -> Result<()> {
193        if self.name.is_empty() {
194            return Err(ProxyError::InvalidInput(
195                "Spec name cannot be empty".to_string(),
196            ));
197        }
198        if self.video_bitrate == 0 {
199            return Err(ProxyError::InvalidInput(
200                "Video bitrate must be > 0".to_string(),
201            ));
202        }
203        if let ProxyResolutionMode::ScaleFactor(s) = self.resolution {
204            if s <= 0.0 || s > 4.0 {
205                return Err(ProxyError::InvalidInput(format!(
206                    "Scale factor {s} out of range (0, 4]"
207                )));
208            }
209        }
210        Ok(())
211    }
212
213    /// Convert to `ProxyGenerationSettings`.
214    #[must_use]
215    pub fn to_generation_settings(&self) -> ProxyGenerationSettings {
216        let scale_factor = match self.resolution {
217            ProxyResolutionMode::ScaleFactor(s) => s,
218            ProxyResolutionMode::Fixed(_, _) | ProxyResolutionMode::FitWithin { .. } => 0.5,
219        };
220        ProxyGenerationSettings {
221            scale_factor,
222            codec: self.codec.as_str().to_string(),
223            bitrate: self.video_bitrate,
224            audio_codec: self.audio_codec.clone(),
225            audio_bitrate: self.audio_bitrate,
226            preserve_frame_rate: true,
227            preserve_timecode: self.preserve_timecode,
228            preserve_metadata: self.preserve_metadata,
229            container: self.container_format().to_string(),
230            use_hw_accel: self.use_hw_accel,
231            threads: 0,
232            quality_preset: self.quality_preset.clone(),
233        }
234    }
235
236    /// Predefined: quarter-resolution H.264 proxy at 2 Mbps.
237    #[must_use]
238    pub fn quarter_h264() -> Self {
239        Self::new(
240            "Quarter H.264",
241            ProxyResolutionMode::ScaleFactor(0.25),
242            ProxyCodec::H264,
243            2_000_000,
244        )
245    }
246
247    /// Predefined: half-resolution H.264 proxy at 5 Mbps.
248    #[must_use]
249    pub fn half_h264() -> Self {
250        Self::new(
251            "Half H.264",
252            ProxyResolutionMode::ScaleFactor(0.5),
253            ProxyCodec::H264,
254            5_000_000,
255        )
256    }
257
258    /// Predefined: 1080p FitWithin H.265 proxy at 4 Mbps.
259    #[must_use]
260    pub fn hd_h265() -> Self {
261        Self::new(
262            "HD H.265",
263            ProxyResolutionMode::FitWithin {
264                max_width: 1920,
265                max_height: 1080,
266            },
267            ProxyCodec::H265,
268            4_000_000,
269        )
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_proxy_codec_as_str() {
279        assert_eq!(ProxyCodec::H264.as_str(), "h264");
280        assert_eq!(ProxyCodec::H265.as_str(), "h265");
281        assert_eq!(ProxyCodec::Vp9.as_str(), "vp9");
282        assert_eq!(ProxyCodec::ProRes422Proxy.as_str(), "prores_proxy");
283        assert_eq!(ProxyCodec::DnxHd36.as_str(), "dnxhd36");
284        assert_eq!(
285            ProxyCodec::Custom("mycodec".to_string()).as_str(),
286            "mycodec"
287        );
288    }
289
290    #[test]
291    fn test_proxy_codec_container() {
292        assert_eq!(ProxyCodec::H264.recommended_container(), "mp4");
293        assert_eq!(ProxyCodec::ProRes422Proxy.recommended_container(), "mov");
294        assert_eq!(ProxyCodec::DnxHd36.recommended_container(), "mxf");
295    }
296
297    #[test]
298    fn test_proxy_codec_hw_accel() {
299        assert!(ProxyCodec::H264.hw_accel_supported());
300        assert!(ProxyCodec::H265.hw_accel_supported());
301        assert!(!ProxyCodec::Vp9.hw_accel_supported());
302    }
303
304    #[test]
305    fn test_proxy_codec_display() {
306        assert_eq!(ProxyCodec::H264.to_string(), "h264");
307    }
308
309    #[test]
310    fn test_resolution_mode_scale_factor() {
311        let (w, h) = ProxyResolutionMode::ScaleFactor(0.25)
312            .compute_output(1920, 1080)
313            .expect("should succeed in test");
314        assert_eq!(w, 480);
315        assert_eq!(h, 270);
316    }
317
318    #[test]
319    fn test_resolution_mode_fixed() {
320        let (w, h) = ProxyResolutionMode::Fixed(640, 360)
321            .compute_output(1920, 1080)
322            .expect("should succeed in test");
323        assert_eq!(w, 640);
324        assert_eq!(h, 360);
325    }
326
327    #[test]
328    fn test_resolution_mode_fit_within() {
329        let (w, h) = ProxyResolutionMode::FitWithin {
330            max_width: 960,
331            max_height: 540,
332        }
333        .compute_output(1920, 1080)
334        .expect("should succeed in test");
335        assert_eq!(w, 960);
336        assert_eq!(h, 540);
337    }
338
339    #[test]
340    fn test_resolution_mode_fit_within_portrait() {
341        // 9:16 source (e.g. vertical video), fit in 1920x1080
342        let (w, h) = ProxyResolutionMode::FitWithin {
343            max_width: 1920,
344            max_height: 1080,
345        }
346        .compute_output(1080, 1920)
347        .expect("should succeed in test");
348        assert!(h <= 1080, "Height {h} should not exceed 1080");
349        assert!(w <= 1920, "Width {w} should not exceed 1920");
350    }
351
352    #[test]
353    fn test_resolution_mode_scale_factor_invalid() {
354        let result = ProxyResolutionMode::ScaleFactor(-0.1).compute_output(1920, 1080);
355        assert!(result.is_err());
356        let result2 = ProxyResolutionMode::ScaleFactor(5.0).compute_output(1920, 1080);
357        assert!(result2.is_err());
358    }
359
360    #[test]
361    fn test_resolution_mode_fixed_zero() {
362        let result = ProxyResolutionMode::Fixed(0, 360).compute_output(1920, 1080);
363        assert!(result.is_err());
364    }
365
366    #[test]
367    fn test_proxy_spec_new() {
368        let spec = ProxySpec::new(
369            "Test",
370            ProxyResolutionMode::ScaleFactor(0.5),
371            ProxyCodec::H264,
372            5_000_000,
373        );
374        assert_eq!(spec.name, "Test");
375        assert_eq!(spec.video_bitrate, 5_000_000);
376        assert!(spec.preserve_timecode);
377    }
378
379    #[test]
380    fn test_proxy_spec_validate_ok() {
381        let spec = ProxySpec::quarter_h264();
382        assert!(spec.validate().is_ok());
383    }
384
385    #[test]
386    fn test_proxy_spec_validate_empty_name() {
387        let mut spec = ProxySpec::quarter_h264();
388        spec.name = String::new();
389        assert!(spec.validate().is_err());
390    }
391
392    #[test]
393    fn test_proxy_spec_validate_zero_bitrate() {
394        let mut spec = ProxySpec::quarter_h264();
395        spec.video_bitrate = 0;
396        assert!(spec.validate().is_err());
397    }
398
399    #[test]
400    fn test_proxy_spec_predefined() {
401        let q = ProxySpec::quarter_h264();
402        assert_eq!(q.codec, ProxyCodec::H264);
403        assert_eq!(q.video_bitrate, 2_000_000);
404
405        let h = ProxySpec::half_h264();
406        assert_eq!(h.video_bitrate, 5_000_000);
407
408        let hd = ProxySpec::hd_h265();
409        assert_eq!(hd.codec, ProxyCodec::H265);
410    }
411
412    #[test]
413    fn test_proxy_spec_container_format() {
414        let spec = ProxySpec::quarter_h264();
415        assert_eq!(spec.container_format(), "mp4");
416
417        let mut prores_spec = spec.clone();
418        prores_spec.codec = ProxyCodec::ProRes422Proxy;
419        assert_eq!(prores_spec.container_format(), "mov");
420
421        let mut custom_spec = spec.clone();
422        custom_spec.container = Some("mkv".to_string());
423        assert_eq!(custom_spec.container_format(), "mkv");
424    }
425
426    #[test]
427    fn test_proxy_spec_to_generation_settings() {
428        let spec = ProxySpec::quarter_h264();
429        let settings = spec.to_generation_settings();
430        assert!((settings.scale_factor - 0.25).abs() < f32::EPSILON);
431        assert_eq!(settings.codec, "h264");
432        assert_eq!(settings.bitrate, 2_000_000);
433        assert_eq!(settings.container, "mp4");
434    }
435
436    #[test]
437    fn test_resolution_output_even_dimensions() {
438        // Should always produce even dimensions for codec compat
439        let (w, h) = ProxyResolutionMode::ScaleFactor(0.33)
440            .compute_output(1920, 1080)
441            .expect("should succeed in test");
442        assert_eq!(w % 2, 0, "Width must be even");
443        assert_eq!(h % 2, 0, "Height must be even");
444    }
445}