Skip to main content

oximedia_optimize/
transcode_optimizer.rs

1//! Transcode optimization helpers for `oximedia-optimize`.
2//!
3//! Provides high-level goal-oriented optimization: given a target (file size,
4//! quality, or streaming bitrate), suggest the best CRF and encoding settings.
5
6#![allow(dead_code)]
7
8/// High-level goal that drives the optimization strategy.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum OptimizationGoal {
11    /// Minimize file size while maintaining acceptable quality.
12    MinimizeSize,
13    /// Maximize perceptual quality regardless of file size.
14    MaximizeQuality,
15    /// Hit a specific streaming bitrate as closely as possible.
16    TargetBitrate,
17    /// Balance quality and file size equally.
18    Balanced,
19    /// Optimize for fast real-time encoding (e.g. live streaming).
20    RealTime,
21}
22
23impl OptimizationGoal {
24    /// Returns a human-readable description of the goal.
25    #[must_use]
26    pub fn description(&self) -> &'static str {
27        match self {
28            Self::MinimizeSize => "Minimize output file size",
29            Self::MaximizeQuality => "Maximize perceptual quality",
30            Self::TargetBitrate => "Hit a specific target bitrate",
31            Self::Balanced => "Balance quality and file size",
32            Self::RealTime => "Optimize for real-time encoding speed",
33        }
34    }
35
36    /// Returns the default CRF value associated with this goal (H.264/H.265 scale).
37    #[must_use]
38    pub fn default_crf(&self) -> u8 {
39        match self {
40            Self::MinimizeSize => 30,
41            Self::MaximizeQuality => 16,
42            Self::TargetBitrate => 23,
43            Self::Balanced => 23,
44            Self::RealTime => 28,
45        }
46    }
47}
48
49impl Default for OptimizationGoal {
50    fn default() -> Self {
51        Self::Balanced
52    }
53}
54
55/// Configuration describing the input and desired output for a transcode job.
56#[derive(Debug, Clone)]
57pub struct TranscodeConfig {
58    /// Input video width in pixels.
59    pub width: u32,
60    /// Input video height in pixels.
61    pub height: u32,
62    /// Frame rate.
63    pub fps: f64,
64    /// Duration in seconds.
65    pub duration_secs: f64,
66    /// Constant Rate Factor (0 = lossless, 51 = worst, 23 = default).
67    pub crf: u8,
68    /// Average audio bitrate in kbps (e.g. 128, 192, 320).
69    pub audio_kbps: u32,
70    /// Target bitrate override in kbps (used with `TargetBitrate` goal).
71    pub target_bitrate_kbps: Option<f64>,
72}
73
74impl TranscodeConfig {
75    /// Creates a new transcode config.
76    #[must_use]
77    pub fn new(width: u32, height: u32, fps: f64, duration_secs: f64, crf: u8) -> Self {
78        Self {
79            width,
80            height,
81            fps,
82            duration_secs,
83            crf,
84            audio_kbps: 192,
85            target_bitrate_kbps: None,
86        }
87    }
88
89    /// Estimates the output file size in megabytes based on CRF, resolution and duration.
90    ///
91    /// Uses a simplified empirical formula. Actual sizes vary by content.
92    #[allow(clippy::cast_precision_loss)]
93    #[must_use]
94    pub fn estimated_size_mb(&self) -> f64 {
95        let pixels_per_sec = self.width as f64 * self.height as f64 * self.fps;
96        let crf_scale = (-f64::from(self.crf) / 6.0_f64).exp2();
97        let video_kbps = (pixels_per_sec / 500.0) * crf_scale;
98        let total_kbps = video_kbps + f64::from(self.audio_kbps);
99        // Convert kbps × seconds to megabytes: kbps × sec / 8 / 1024
100        total_kbps * self.duration_secs / 8.0 / 1024.0
101    }
102
103    /// Returns the pixel count (width × height).
104    #[must_use]
105    pub fn pixel_count(&self) -> u64 {
106        u64::from(self.width) * u64::from(self.height)
107    }
108
109    /// Returns `true` if this config describes a 4K or larger resolution.
110    #[must_use]
111    pub fn is_4k_or_above(&self) -> bool {
112        self.width >= 3840 || self.height >= 2160
113    }
114}
115
116impl Default for TranscodeConfig {
117    fn default() -> Self {
118        Self::new(1920, 1080, 25.0, 60.0, 23)
119    }
120}
121
122/// Optimizer that adjusts [`TranscodeConfig`] fields to meet an
123/// [`OptimizationGoal`].
124#[derive(Debug)]
125pub struct TranscodeOptimizer {
126    goal: OptimizationGoal,
127    /// Maximum acceptable file size in MB (used for `MinimizeSize` goal).
128    max_size_mb: Option<f64>,
129    /// Target bitrate in kbps (used for `TargetBitrate` goal).
130    target_kbps: Option<f64>,
131}
132
133impl TranscodeOptimizer {
134    /// Creates a new optimizer for the given goal.
135    #[must_use]
136    pub fn new(goal: OptimizationGoal) -> Self {
137        Self {
138            goal,
139            max_size_mb: None,
140            target_kbps: None,
141        }
142    }
143
144    /// Sets the maximum acceptable file size for `MinimizeSize` goal.
145    #[must_use]
146    pub fn with_max_size_mb(mut self, mb: f64) -> Self {
147        self.max_size_mb = Some(mb);
148        self
149    }
150
151    /// Sets the target bitrate for `TargetBitrate` goal.
152    #[must_use]
153    pub fn with_target_kbps(mut self, kbps: f64) -> Self {
154        self.target_kbps = Some(kbps);
155        self
156    }
157
158    /// Returns the active optimization goal.
159    #[must_use]
160    pub fn goal(&self) -> OptimizationGoal {
161        self.goal
162    }
163
164    /// Returns an optimized [`TranscodeConfig`] derived from the input config
165    /// that best satisfies the optimizer's goal.
166    #[allow(clippy::cast_precision_loss)]
167    #[must_use]
168    pub fn optimize_for_goal(&self, config: &TranscodeConfig) -> TranscodeConfig {
169        let mut out = config.clone();
170        match self.goal {
171            OptimizationGoal::MinimizeSize => {
172                out.crf = 30; // Higher CRF = smaller file
173                out.audio_kbps = 128;
174            }
175            OptimizationGoal::MaximizeQuality => {
176                out.crf = 16; // Lower CRF = better quality
177                out.audio_kbps = 320;
178            }
179            OptimizationGoal::TargetBitrate => {
180                if let Some(target) = self.target_kbps {
181                    out.crf = self.suggest_crf(config, target);
182                    out.target_bitrate_kbps = Some(target);
183                }
184            }
185            OptimizationGoal::Balanced => {
186                out.crf = 23;
187                out.audio_kbps = 192;
188            }
189            OptimizationGoal::RealTime => {
190                out.crf = 28;
191                out.audio_kbps = 128;
192            }
193        }
194        out
195    }
196
197    /// Suggests a CRF value that would produce output close to `target_kbps`.
198    ///
199    /// Uses binary-search style iteration over the CRF range [0, 51].
200    #[allow(clippy::cast_possible_truncation)]
201    #[allow(clippy::cast_sign_loss)]
202    #[allow(clippy::cast_precision_loss)]
203    #[must_use]
204    pub fn suggest_crf(&self, config: &TranscodeConfig, target_kbps: f64) -> u8 {
205        let duration = config.duration_secs;
206        if duration <= 0.0 {
207            return 23;
208        }
209        let mut best_crf: u8 = 23;
210        let mut best_delta = f64::MAX;
211        for crf in 0u8..=51u8 {
212            let probe = TranscodeConfig {
213                crf,
214                ..config.clone()
215            };
216            let size_mb = probe.estimated_size_mb();
217            let kbps = size_mb * 8.0 * 1024.0 / duration;
218            let delta = (kbps - target_kbps).abs();
219            if delta < best_delta {
220                best_delta = delta;
221                best_crf = crf;
222            }
223        }
224        best_crf
225    }
226
227    /// Returns the estimated output size in MB for a given config under the
228    /// current optimization goal.
229    #[must_use]
230    pub fn estimated_output_size_mb(&self, config: &TranscodeConfig) -> f64 {
231        self.optimize_for_goal(config).estimated_size_mb()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_goal_description_non_empty() {
241        for g in [
242            OptimizationGoal::MinimizeSize,
243            OptimizationGoal::MaximizeQuality,
244            OptimizationGoal::TargetBitrate,
245            OptimizationGoal::Balanced,
246            OptimizationGoal::RealTime,
247        ] {
248            assert!(!g.description().is_empty());
249        }
250    }
251
252    #[test]
253    fn test_goal_default_crf_in_range() {
254        for g in [
255            OptimizationGoal::MinimizeSize,
256            OptimizationGoal::MaximizeQuality,
257            OptimizationGoal::Balanced,
258            OptimizationGoal::RealTime,
259        ] {
260            let crf = g.default_crf();
261            assert!(crf <= 51, "CRF {crf} out of range for {g:?}");
262        }
263    }
264
265    #[test]
266    fn test_transcode_config_estimated_size_positive() {
267        let cfg = TranscodeConfig::default();
268        assert!(cfg.estimated_size_mb() > 0.0);
269    }
270
271    #[test]
272    fn test_transcode_config_lower_crf_larger_size() {
273        let low = TranscodeConfig::new(1920, 1080, 25.0, 60.0, 16);
274        let high = TranscodeConfig::new(1920, 1080, 25.0, 60.0, 30);
275        assert!(low.estimated_size_mb() > high.estimated_size_mb());
276    }
277
278    #[test]
279    fn test_transcode_config_pixel_count() {
280        let cfg = TranscodeConfig::new(1920, 1080, 25.0, 60.0, 23);
281        assert_eq!(cfg.pixel_count(), 1920 * 1080);
282    }
283
284    #[test]
285    fn test_transcode_config_is_4k_above() {
286        let uhd = TranscodeConfig::new(3840, 2160, 24.0, 120.0, 23);
287        assert!(uhd.is_4k_or_above());
288    }
289
290    #[test]
291    fn test_transcode_config_not_4k() {
292        let hd = TranscodeConfig::new(1280, 720, 30.0, 60.0, 23);
293        assert!(!hd.is_4k_or_above());
294    }
295
296    #[test]
297    fn test_optimizer_goal_accessor() {
298        let opt = TranscodeOptimizer::new(OptimizationGoal::MinimizeSize);
299        assert_eq!(opt.goal(), OptimizationGoal::MinimizeSize);
300    }
301
302    #[test]
303    fn test_optimize_for_minimize_size_increases_crf() {
304        let cfg = TranscodeConfig::default(); // crf=23
305        let opt = TranscodeOptimizer::new(OptimizationGoal::MinimizeSize);
306        let out = opt.optimize_for_goal(&cfg);
307        assert!(out.crf > cfg.crf);
308    }
309
310    #[test]
311    fn test_optimize_for_max_quality_decreases_crf() {
312        let cfg = TranscodeConfig::default(); // crf=23
313        let opt = TranscodeOptimizer::new(OptimizationGoal::MaximizeQuality);
314        let out = opt.optimize_for_goal(&cfg);
315        assert!(out.crf < cfg.crf);
316    }
317
318    #[test]
319    fn test_suggest_crf_in_range() {
320        let cfg = TranscodeConfig::default();
321        let opt = TranscodeOptimizer::new(OptimizationGoal::TargetBitrate);
322        let crf = opt.suggest_crf(&cfg, 2000.0);
323        assert!(crf <= 51);
324    }
325
326    #[test]
327    fn test_suggest_crf_zero_duration_returns_default() {
328        let mut cfg = TranscodeConfig::default();
329        cfg.duration_secs = 0.0;
330        let opt = TranscodeOptimizer::new(OptimizationGoal::TargetBitrate);
331        assert_eq!(opt.suggest_crf(&cfg, 2000.0), 23);
332    }
333
334    #[test]
335    fn test_optimize_for_target_bitrate_sets_crf() {
336        let cfg = TranscodeConfig::default();
337        let opt = TranscodeOptimizer::new(OptimizationGoal::TargetBitrate).with_target_kbps(4000.0);
338        let out = opt.optimize_for_goal(&cfg);
339        assert!(out.crf <= 51);
340    }
341
342    #[test]
343    fn test_estimated_output_size_positive() {
344        let cfg = TranscodeConfig::default();
345        let opt = TranscodeOptimizer::new(OptimizationGoal::Balanced);
346        assert!(opt.estimated_output_size_mb(&cfg) > 0.0);
347    }
348}