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