Skip to main content

oximedia_cli/presets/
mod.rs

1//! Transcoding preset system for OxiMedia.
2//!
3//! Provides a comprehensive preset management system with:
4//! - Built-in presets for common use cases
5//! - Custom user presets via TOML files
6//! - Preset validation and listing
7//! - Organized by categories (Web, Devices, Quality, Archival, Streaming)
8//!
9//! # Examples
10//!
11//! ```rust,ignore
12//! use oximedia_cli::presets::{PresetManager, PresetCategory};
13//!
14//! let manager = PresetManager::new();
15//! let preset = manager.get_preset("youtube-1080p")?;
16//! println!("Video codec: {}", preset.video.codec);
17//! ```
18
19pub mod builtin;
20pub mod custom;
21pub mod device;
22pub mod streaming;
23pub mod validate;
24pub mod web;
25
26use anyhow::{anyhow, Context, Result};
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29use std::path::{Path, PathBuf};
30
31/// Video codec configuration.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct VideoConfig {
34    /// Video codec (av1, vp9, vp8, theora)
35    pub codec: String,
36
37    /// Target bitrate (e.g., "5M", "2.5M", "500k")
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub bitrate: Option<String>,
40
41    /// Constant Rate Factor (quality-based encoding)
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub crf: Option<u32>,
44
45    /// Video width in pixels
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub width: Option<u32>,
48
49    /// Video height in pixels
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub height: Option<u32>,
52
53    /// Frame rate (e.g., 30, 60, 23.976)
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub fps: Option<f64>,
56
57    /// Encoder preset (ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow)
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub preset: Option<String>,
60
61    /// Pixel format (yuv420p, yuv444p, etc.)
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub pixel_format: Option<String>,
64
65    /// Enable two-pass encoding
66    #[serde(default)]
67    pub two_pass: bool,
68
69    /// Maximum bitrate for VBV (Variable Bitrate Video)
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub max_bitrate: Option<String>,
72
73    /// Minimum bitrate for VBV
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub min_bitrate: Option<String>,
76
77    /// Buffer size for VBV
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub buffer_size: Option<String>,
80
81    /// Keyframe interval (GOP size)
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub keyframe_interval: Option<u32>,
84
85    /// Minimum keyframe interval
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub min_keyframe_interval: Option<u32>,
88
89    /// Aspect ratio (e.g., "16:9", "4:3")
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub aspect_ratio: Option<String>,
92}
93
94/// Audio codec configuration.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct AudioConfig {
97    /// Audio codec (opus, vorbis, flac, pcm)
98    pub codec: String,
99
100    /// Target bitrate (e.g., "128k", "192k", "256k")
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub bitrate: Option<String>,
103
104    /// Sample rate in Hz (e.g., 48000, 44100)
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub sample_rate: Option<u32>,
107
108    /// Number of audio channels
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub channels: Option<u32>,
111
112    /// Audio quality (codec-specific)
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub quality: Option<f64>,
115
116    /// Compression level (for FLAC)
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub compression_level: Option<u32>,
119}
120
121/// Filter chain configuration.
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
123pub struct FilterConfig {
124    /// Video filters (e.g., "scale=1920:1080,fps=30")
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub video_filters: Option<Vec<String>>,
127
128    /// Audio filters (e.g., "volume=0.5")
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub audio_filters: Option<Vec<String>>,
131
132    /// Deinterlacing method
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub deinterlace: Option<String>,
135
136    /// Denoise filter
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub denoise: Option<String>,
139}
140
141/// Complete transcoding preset.
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
143pub struct Preset {
144    /// Preset name (unique identifier)
145    pub name: String,
146
147    /// Human-readable description
148    pub description: String,
149
150    /// Preset category
151    pub category: PresetCategory,
152
153    /// Video configuration
154    pub video: VideoConfig,
155
156    /// Audio configuration
157    pub audio: AudioConfig,
158
159    /// Container format (webm, mkv, ogg, flac, wav)
160    pub container: String,
161
162    /// Optional filter chain
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub filters: Option<FilterConfig>,
165
166    /// Whether this is a built-in preset (non-modifiable)
167    #[serde(default)]
168    pub builtin: bool,
169
170    /// Preset tags for searching/filtering
171    #[serde(default)]
172    pub tags: Vec<String>,
173}
174
175/// Preset category for organization.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
177pub enum PresetCategory {
178    /// Web platform presets (YouTube, Vimeo, Social Media)
179    Web,
180
181    /// Device-specific presets (iPhone, Android, TV)
182    Device,
183
184    /// Quality tier presets (4K, 1080p, 720p, 480p)
185    Quality,
186
187    /// Archival presets (lossless, high quality)
188    Archival,
189
190    /// Streaming presets (HLS/DASH variants)
191    Streaming,
192
193    /// Custom user presets
194    Custom,
195}
196
197impl PresetCategory {
198    /// Get category name.
199    pub fn name(&self) -> &'static str {
200        match self {
201            Self::Web => "Web",
202            Self::Device => "Device",
203            Self::Quality => "Quality",
204            Self::Archival => "Archival",
205            Self::Streaming => "Streaming",
206            Self::Custom => "Custom",
207        }
208    }
209
210    /// Get category description.
211    pub fn description(&self) -> &'static str {
212        match self {
213            Self::Web => "Presets optimized for web platforms (YouTube, Vimeo, social media)",
214            Self::Device => "Presets optimized for specific devices (iPhone, Android, TV)",
215            Self::Quality => "Quality tier presets (4K, 1080p, 720p, 480p)",
216            Self::Archival => "Archival presets (lossless, high quality preservation)",
217            Self::Streaming => "Streaming presets (HLS/DASH adaptive bitrate variants)",
218            Self::Custom => "User-defined custom presets",
219        }
220    }
221
222    /// Parse category from string.
223    pub fn from_str(s: &str) -> Result<Self> {
224        match s.to_lowercase().as_str() {
225            "web" => Ok(Self::Web),
226            "device" => Ok(Self::Device),
227            "quality" => Ok(Self::Quality),
228            "archival" => Ok(Self::Archival),
229            "streaming" => Ok(Self::Streaming),
230            "custom" => Ok(Self::Custom),
231            _ => Err(anyhow!("Unknown preset category: {}", s)),
232        }
233    }
234}
235
236/// Preset manager for loading and managing presets.
237pub struct PresetManager {
238    /// All loaded presets by name
239    presets: HashMap<String, Preset>,
240
241    /// Custom preset directory
242    custom_dir: Option<PathBuf>,
243}
244
245impl PresetManager {
246    /// Create a new preset manager with built-in presets.
247    pub fn new() -> Self {
248        let mut manager = Self {
249            presets: HashMap::new(),
250            custom_dir: None,
251        };
252
253        // Load built-in presets
254        manager.load_builtin_presets();
255
256        manager
257    }
258
259    /// Create a preset manager with custom preset directory.
260    pub fn with_custom_dir<P: AsRef<Path>>(custom_dir: P) -> Result<Self> {
261        let mut manager = Self::new();
262        manager.custom_dir = Some(custom_dir.as_ref().to_path_buf());
263        manager.load_custom_presets()?;
264        Ok(manager)
265    }
266
267    /// Load all built-in presets.
268    fn load_builtin_presets(&mut self) {
269        // Load web platform presets
270        for preset in web::get_web_presets() {
271            self.presets.insert(preset.name.clone(), preset);
272        }
273
274        // Load device presets
275        for preset in device::get_device_presets() {
276            self.presets.insert(preset.name.clone(), preset);
277        }
278
279        // Load streaming presets
280        for preset in streaming::get_streaming_presets() {
281            self.presets.insert(preset.name.clone(), preset);
282        }
283
284        // Load quality and archival presets
285        for preset in builtin::get_quality_presets() {
286            self.presets.insert(preset.name.clone(), preset);
287        }
288
289        for preset in builtin::get_archival_presets() {
290            self.presets.insert(preset.name.clone(), preset);
291        }
292    }
293
294    /// Load custom presets from directory.
295    fn load_custom_presets(&mut self) -> Result<()> {
296        if let Some(ref dir) = self.custom_dir {
297            if dir.exists() && dir.is_dir() {
298                for entry in std::fs::read_dir(dir)? {
299                    let entry = entry?;
300                    let path = entry.path();
301
302                    if path.extension().and_then(|s| s.to_str()) == Some("toml") {
303                        match custom::load_preset_from_file(&path) {
304                            Ok(preset) => {
305                                // Don't allow overriding built-in presets
306                                if let Some(existing) = self.presets.get(&preset.name) {
307                                    if existing.builtin {
308                                        eprintln!(
309                                            "Warning: Cannot override built-in preset '{}' with custom preset",
310                                            preset.name
311                                        );
312                                        continue;
313                                    }
314                                }
315                                self.presets.insert(preset.name.clone(), preset);
316                            }
317                            Err(e) => {
318                                eprintln!(
319                                    "Warning: Failed to load preset from {}: {}",
320                                    path.display(),
321                                    e
322                                );
323                            }
324                        }
325                    }
326                }
327            }
328        }
329        Ok(())
330    }
331
332    /// Get a preset by name.
333    pub fn get_preset(&self, name: &str) -> Result<&Preset> {
334        self.presets
335            .get(name)
336            .ok_or_else(|| anyhow!("Preset '{}' not found", name))
337    }
338
339    /// List all presets.
340    pub fn list_presets(&self) -> Vec<&Preset> {
341        let mut presets: Vec<_> = self.presets.values().collect();
342        presets.sort_by(|a, b| a.name.cmp(&b.name));
343        presets
344    }
345
346    /// List presets by category.
347    pub fn list_presets_by_category(&self, category: PresetCategory) -> Vec<&Preset> {
348        let mut presets: Vec<_> = self
349            .presets
350            .values()
351            .filter(|p| p.category == category)
352            .collect();
353        presets.sort_by(|a, b| a.name.cmp(&b.name));
354        presets
355    }
356
357    /// Get all available preset names.
358    pub fn preset_names(&self) -> Vec<String> {
359        let mut names: Vec<_> = self.presets.keys().cloned().collect();
360        names.sort();
361        names
362    }
363
364    /// Check if a preset exists.
365    #[allow(dead_code)]
366    pub fn has_preset(&self, name: &str) -> bool {
367        self.presets.contains_key(name)
368    }
369
370    /// Add a custom preset.
371    #[allow(dead_code)]
372    pub fn add_preset(&mut self, preset: Preset) -> Result<()> {
373        // Validate preset
374        validate::validate_preset(&preset)?;
375
376        // Don't allow overriding built-in presets
377        if let Some(existing) = self.presets.get(&preset.name) {
378            if existing.builtin {
379                return Err(anyhow!("Cannot override built-in preset '{}'", preset.name));
380            }
381        }
382
383        self.presets.insert(preset.name.clone(), preset);
384        Ok(())
385    }
386
387    /// Save a custom preset to file.
388    #[allow(dead_code)]
389    pub fn save_preset(&self, name: &str) -> Result<()> {
390        let preset = self.get_preset(name)?;
391
392        if preset.builtin {
393            return Err(anyhow!("Cannot save built-in preset '{}'", name));
394        }
395
396        let dir = self
397            .custom_dir
398            .as_ref()
399            .ok_or_else(|| anyhow!("No custom preset directory configured"))?;
400
401        if !dir.exists() {
402            std::fs::create_dir_all(dir).context("Failed to create custom preset directory")?;
403        }
404
405        custom::save_preset_to_file(preset, dir)?;
406        Ok(())
407    }
408
409    /// Remove a custom preset.
410    #[allow(dead_code)]
411    pub fn remove_preset(&mut self, name: &str) -> Result<()> {
412        let preset = self.get_preset(name)?;
413
414        if preset.builtin {
415            return Err(anyhow!("Cannot remove built-in preset '{}'", name));
416        }
417
418        self.presets.remove(name);
419
420        // Also remove file if in custom directory
421        if let Some(ref dir) = self.custom_dir {
422            let path = dir.join(format!("{}.toml", name));
423            if path.exists() {
424                std::fs::remove_file(&path).context("Failed to remove preset file")?;
425            }
426        }
427
428        Ok(())
429    }
430
431    /// Get default custom preset directory.
432    pub fn default_custom_dir() -> Result<PathBuf> {
433        let config_dir =
434            dirs::config_dir().ok_or_else(|| anyhow!("Could not determine config directory"))?;
435        Ok(config_dir.join("oximedia").join("presets"))
436    }
437}
438
439impl Default for PresetManager {
440    fn default() -> Self {
441        Self::new()
442    }
443}
444
445/// Helper function to parse bitrate string to bits per second.
446#[allow(dead_code)]
447pub fn parse_bitrate(bitrate: &str) -> Result<u64> {
448    let bitrate = bitrate.trim();
449    let multiplier = if bitrate.ends_with('M') || bitrate.ends_with('m') {
450        1_000_000
451    } else if bitrate.ends_with('K') || bitrate.ends_with('k') {
452        1_000
453    } else {
454        1
455    };
456
457    let numeric = bitrate.trim_end_matches(|c: char| c.is_alphabetic()).trim();
458
459    let value: f64 = numeric
460        .parse()
461        .context(format!("Invalid bitrate format: {}", bitrate))?;
462
463    Ok((value * multiplier as f64) as u64)
464}
465
466/// Helper function to format bitrate for display.
467#[allow(dead_code)]
468pub fn format_bitrate(bits_per_second: u64) -> String {
469    if bits_per_second >= 1_000_000 {
470        format!("{:.1}M", bits_per_second as f64 / 1_000_000.0)
471    } else if bits_per_second >= 1_000 {
472        format!("{}k", bits_per_second / 1_000)
473    } else {
474        format!("{}", bits_per_second)
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn test_parse_bitrate() {
484        assert_eq!(
485            parse_bitrate("5M").expect("parse should succeed"),
486            5_000_000
487        );
488        assert_eq!(
489            parse_bitrate("2.5M").expect("parse should succeed"),
490            2_500_000
491        );
492        assert_eq!(
493            parse_bitrate("128k").expect("parse should succeed"),
494            128_000
495        );
496        assert_eq!(parse_bitrate("1000").expect("parse should succeed"), 1_000);
497    }
498
499    #[test]
500    fn test_format_bitrate() {
501        assert_eq!(format_bitrate(5_000_000), "5.0M");
502        assert_eq!(format_bitrate(128_000), "128k");
503        assert_eq!(format_bitrate(500), "500");
504    }
505}