Skip to main content

oximedia_transcode/
crf_optimizer.rs

1//! CRF/quality optimizer for finding optimal encoding parameters.
2//!
3//! This module provides binary search over CRF space to find the
4//! optimal encoding quality that meets a given target within bitrate constraints.
5
6/// Quality target specification.
7#[derive(Debug, Clone)]
8pub struct QualityTarget {
9    /// Minimum acceptable PSNR in decibels.
10    pub min_psnr_db: f32,
11    /// Minimum acceptable SSIM (0.0–1.0).
12    pub min_ssim: f32,
13    /// Maximum allowed bitrate in kilobits per second.
14    pub max_bitrate_kbps: u32,
15}
16
17impl QualityTarget {
18    /// Creates a new quality target.
19    #[must_use]
20    pub fn new(min_psnr_db: f32, min_ssim: f32, max_bitrate_kbps: u32) -> Self {
21        Self {
22            min_psnr_db,
23            min_ssim,
24            max_bitrate_kbps,
25        }
26    }
27}
28
29/// CRF range definition for a codec.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct CrfRange {
32    /// Minimum CRF value (best quality).
33    pub min_crf: u8,
34    /// Maximum CRF value (worst quality).
35    pub max_crf: u8,
36}
37
38impl CrfRange {
39    /// Creates a new CRF range.
40    #[must_use]
41    pub fn new(min_crf: u8, max_crf: u8) -> Self {
42        Self { min_crf, max_crf }
43    }
44
45    /// Returns the CRF range for H.264 (17–51).
46    #[must_use]
47    pub fn h264_range() -> Self {
48        Self {
49            min_crf: 17,
50            max_crf: 51,
51        }
52    }
53
54    /// Returns the CRF range for AV1 (0–63).
55    #[must_use]
56    pub fn av1_range() -> Self {
57        Self {
58            min_crf: 0,
59            max_crf: 63,
60        }
61    }
62
63    /// Returns the midpoint CRF value.
64    #[must_use]
65    pub fn midpoint(&self) -> u8 {
66        self.min_crf + (self.max_crf - self.min_crf) / 2
67    }
68
69    /// Returns the number of CRF values in this range.
70    #[must_use]
71    pub fn span(&self) -> u8 {
72        self.max_crf - self.min_crf
73    }
74}
75
76/// Result of CRF optimization.
77#[derive(Debug, Clone)]
78pub struct CrfOptimizerResult {
79    /// The optimal CRF value found.
80    pub optimal_crf: u8,
81    /// Estimated bitrate at the optimal CRF.
82    pub estimated_bitrate_kbps: u32,
83    /// Estimated PSNR at the optimal CRF.
84    pub estimated_psnr: f32,
85}
86
87/// CRF optimizer using binary search over the CRF space.
88#[derive(Debug, Clone, Default)]
89pub struct CrfOptimizer;
90
91impl CrfOptimizer {
92    /// Creates a new `CrfOptimizer`.
93    #[must_use]
94    pub fn new() -> Self {
95        Self
96    }
97
98    /// Finds the optimal CRF for the given quality target using binary search.
99    ///
100    /// The bitrate model is: `bitrate = complexity * base * 2^((28 - crf) / 6)`.
101    /// Higher CRF → lower quality → lower bitrate.
102    /// Searches for the lowest CRF (best quality) that keeps bitrate ≤ `max_bitrate_kbps`.
103    #[must_use]
104    pub fn find_optimal(
105        target: &QualityTarget,
106        crf_range: CrfRange,
107        content_complexity: f32,
108    ) -> CrfOptimizerResult {
109        // Binary search: we want the highest CRF (lowest bitrate) that
110        // still produces a bitrate ≤ max_bitrate_kbps.
111        // However, we also want the best quality that meets bitrate.
112        // Strategy: find lowest CRF whose estimated bitrate ≤ max_bitrate_kbps.
113        let mut lo = crf_range.min_crf;
114        let mut hi = crf_range.max_crf;
115        let mut best_crf = crf_range.max_crf;
116
117        // We want highest CRF (worst quality) that stays within budget
118        while lo <= hi {
119            let mid = lo + (hi - lo) / 2;
120            let bitrate = BitrateModel::predict(mid, content_complexity, "h264");
121            if bitrate <= target.max_bitrate_kbps {
122                best_crf = mid;
123                // Try going lower CRF (higher quality) if budget allows
124                if mid == 0 {
125                    break;
126                }
127                hi = mid.saturating_sub(1);
128            } else {
129                lo = mid.saturating_add(1);
130                if lo > hi {
131                    break;
132                }
133            }
134        }
135
136        let estimated_bitrate_kbps = BitrateModel::predict(best_crf, content_complexity, "h264");
137        let estimated_psnr = Self::estimate_psnr(best_crf, content_complexity);
138
139        CrfOptimizerResult {
140            optimal_crf: best_crf,
141            estimated_bitrate_kbps,
142            estimated_psnr,
143        }
144    }
145
146    /// Estimates PSNR for a given CRF.
147    ///
148    /// Simple heuristic: lower CRF → higher PSNR. CRF=0 → ~50dB, CRF=51 → ~30dB.
149    #[must_use]
150    pub fn estimate_psnr(crf: u8, _complexity: f32) -> f32 {
151        50.0 - (f32::from(crf) * 20.0 / 51.0)
152    }
153}
154
155/// Bitrate model for predicting bitrate from CRF and content complexity.
156#[derive(Debug, Clone, Default)]
157pub struct BitrateModel;
158
159impl BitrateModel {
160    /// Predicts bitrate in kbps for a given CRF, content complexity, and codec.
161    ///
162    /// Model: `bitrate = complexity * base * 2^((crf - 28) / 6)`
163    ///
164    /// Codec-specific base bitrates (kbps):
165    /// - h264: 2000
166    /// - vp9: 1500 (typically more efficient)
167    /// - av1: 1200 (most efficient)
168    /// - hevc / h265: 1400
169    /// - others: 2000
170    #[must_use]
171    pub fn predict(crf: u8, complexity: f32, codec: &str) -> u32 {
172        let base_kbps = match codec {
173            "h264" | "libx264" => 2000.0_f32,
174            "vp9" | "libvpx-vp9" => 1500.0,
175            "av1" | "libaom-av1" | "libsvtav1" => 1200.0,
176            "hevc" | "h265" | "libx265" => 1400.0,
177            _ => 2000.0,
178        };
179
180        // Higher CRF → lower quality → lower bitrate: negate the exponent
181        let exponent = (28.0 - f32::from(crf)) / 6.0;
182        let scale = 2.0_f32.powf(exponent);
183        let bitrate = complexity * base_kbps * scale;
184
185        // Clamp to reasonable range [10, 100_000] kbps
186        bitrate.clamp(10.0, 100_000.0).round() as u32
187    }
188
189    /// Estimates the CRF needed to achieve a target bitrate.
190    #[must_use]
191    pub fn crf_for_bitrate(target_kbps: u32, complexity: f32, codec: &str) -> u8 {
192        let base_kbps = match codec {
193            "h264" | "libx264" => 2000.0_f32,
194            "vp9" | "libvpx-vp9" => 1500.0,
195            "av1" | "libaom-av1" | "libsvtav1" => 1200.0,
196            "hevc" | "h265" | "libx265" => 1400.0,
197            _ => 2000.0,
198        };
199
200        if complexity <= 0.0 || base_kbps <= 0.0 {
201            return 28;
202        }
203
204        // Solve: target = complexity * base * 2^((28-crf)/6)
205        // → (28-crf)/6 = log2(target / (complexity * base))
206        // → crf = 28 - 6 * log2(target / (complexity * base))
207        let ratio = target_kbps as f32 / (complexity * base_kbps);
208        if ratio <= 0.0 {
209            return 51;
210        }
211        let crf_f = 28.0 - 6.0 * ratio.log2();
212        (crf_f.round() as i32).clamp(0, 63) as u8
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn test_quality_target_new() {
222        let t = QualityTarget::new(35.0, 0.95, 4000);
223        assert_eq!(t.min_psnr_db, 35.0);
224        assert_eq!(t.min_ssim, 0.95);
225        assert_eq!(t.max_bitrate_kbps, 4000);
226    }
227
228    #[test]
229    fn test_crf_range_h264() {
230        let r = CrfRange::h264_range();
231        assert_eq!(r.min_crf, 17);
232        assert_eq!(r.max_crf, 51);
233    }
234
235    #[test]
236    fn test_crf_range_av1() {
237        let r = CrfRange::av1_range();
238        assert_eq!(r.min_crf, 0);
239        assert_eq!(r.max_crf, 63);
240    }
241
242    #[test]
243    fn test_crf_range_midpoint() {
244        let r = CrfRange::new(0, 63);
245        assert_eq!(r.midpoint(), 31);
246    }
247
248    #[test]
249    fn test_crf_range_span() {
250        let r = CrfRange::h264_range();
251        assert_eq!(r.span(), 34);
252    }
253
254    #[test]
255    fn test_bitrate_model_predict_h264() {
256        // At CRF 28 with complexity 1.0, bitrate should equal base (2000 kbps)
257        let b = BitrateModel::predict(28, 1.0, "h264");
258        assert_eq!(b, 2000);
259    }
260
261    #[test]
262    fn test_bitrate_model_predict_higher_crf_lower_bitrate() {
263        let b_low = BitrateModel::predict(20, 1.0, "h264");
264        let b_high = BitrateModel::predict(35, 1.0, "h264");
265        assert!(b_low > b_high, "Lower CRF should produce higher bitrate");
266    }
267
268    #[test]
269    fn test_bitrate_model_predict_av1_lower_than_h264() {
270        let h264 = BitrateModel::predict(28, 1.0, "h264");
271        let av1 = BitrateModel::predict(28, 1.0, "av1");
272        assert!(av1 < h264, "AV1 should have lower base bitrate");
273    }
274
275    #[test]
276    fn test_bitrate_model_complexity_scaling() {
277        let b1 = BitrateModel::predict(28, 1.0, "h264");
278        let b2 = BitrateModel::predict(28, 2.0, "h264");
279        assert_eq!(b2, b1 * 2);
280    }
281
282    #[test]
283    fn test_crf_optimizer_finds_within_budget() {
284        let target = QualityTarget::new(30.0, 0.9, 5000);
285        let crf_range = CrfRange::h264_range();
286        let result = CrfOptimizer::find_optimal(&target, crf_range, 1.0);
287        assert!(
288            result.estimated_bitrate_kbps <= 5000,
289            "Bitrate {} should be <= 5000",
290            result.estimated_bitrate_kbps
291        );
292        assert!(result.optimal_crf >= crf_range.min_crf);
293        assert!(result.optimal_crf <= crf_range.max_crf);
294    }
295
296    #[test]
297    fn test_crf_optimizer_result_fields() {
298        let target = QualityTarget::new(30.0, 0.9, 4000);
299        let result = CrfOptimizer::find_optimal(&target, CrfRange::h264_range(), 1.0);
300        assert!(result.estimated_psnr > 0.0);
301        assert!(result.estimated_bitrate_kbps > 0);
302    }
303
304    #[test]
305    fn test_estimate_psnr_decreases_with_crf() {
306        let psnr_low = CrfOptimizer::estimate_psnr(17, 1.0);
307        let psnr_high = CrfOptimizer::estimate_psnr(51, 1.0);
308        assert!(psnr_low > psnr_high);
309    }
310}