Skip to main content

oximedia_transcode/
bitrate_estimator.rs

1//! Bitrate estimation from QP/CRF, resolution, and frame-rate parameters.
2
3/// Estimates output bitrate (bits per second) from encode parameters.
4///
5/// The model is a simplified empirical formula:
6/// `bitrate ≈ base_bpp * pixels_per_frame * frame_rate * quality_factor`
7///
8/// where `quality_factor` decreases as QP/CRF increases.
9///
10/// # Example
11///
12/// ```
13/// use oximedia_transcode::bitrate_estimator::BitrateEstimator;
14///
15/// let est = BitrateEstimator::new();
16/// let bps = est.estimate_from_crf(23, 1920, 1080, 30.0);
17/// assert!(bps > 0);
18/// ```
19#[derive(Debug, Clone)]
20pub struct BitrateEstimator {
21    /// Base bits-per-pixel at CRF/QP = 0.
22    base_bpp: f64,
23    /// Exponential decay coefficient for quality degradation with QP.
24    decay: f64,
25}
26
27impl Default for BitrateEstimator {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl BitrateEstimator {
34    /// Creates a `BitrateEstimator` with default empirical parameters.
35    #[must_use]
36    pub fn new() -> Self {
37        Self {
38            base_bpp: 0.10, // 0.10 bits per pixel at lossless
39            decay: 0.065,   // tuned to match real-world H.264/VP9 behaviour
40        }
41    }
42
43    /// Creates a `BitrateEstimator` with custom parameters.
44    ///
45    /// * `base_bpp` – bits per pixel at QP = 0.
46    /// * `decay` – exponential decay constant; higher = steeper quality/bitrate curve.
47    #[must_use]
48    pub fn with_params(base_bpp: f64, decay: f64) -> Self {
49        Self { base_bpp, decay }
50    }
51
52    /// Estimates output bitrate in bits/s from a CRF value (0–51 for H.264/H.265).
53    ///
54    /// * `crf`        – Constant Rate Factor (0 = lossless, 51 = worst).
55    /// * `width`      – Frame width in pixels.
56    /// * `height`     – Frame height in pixels.
57    /// * `frame_rate` – Frames per second.
58    #[must_use]
59    pub fn estimate_from_crf(&self, crf: u8, width: u32, height: u32, frame_rate: f64) -> u64 {
60        self.estimate_from_qp(f64::from(crf), width, height, frame_rate)
61    }
62
63    /// Estimates output bitrate in bits/s from a floating-point QP value.
64    ///
65    /// * `qp`         – Quantization parameter.
66    /// * `width`      – Frame width in pixels.
67    /// * `height`     – Frame height in pixels.
68    /// * `frame_rate` – Frames per second.
69    #[must_use]
70    pub fn estimate_from_qp(&self, qp: f64, width: u32, height: u32, frame_rate: f64) -> u64 {
71        if frame_rate <= 0.0 || width == 0 || height == 0 {
72            return 0;
73        }
74        let pixels = f64::from(width) * f64::from(height);
75        let quality_factor = (-self.decay * qp).exp();
76        let bps = self.base_bpp * pixels * frame_rate * quality_factor;
77        bps.round() as u64
78    }
79
80    /// Estimates bitrate from a target VMAF score (0–100).
81    ///
82    /// Linearly maps VMAF → effective QP, then delegates to `estimate_from_qp`.
83    /// VMAF 100 ≈ QP 0 (lossless), VMAF 0 ≈ QP 51 (worst).
84    #[must_use]
85    pub fn estimate_from_vmaf(&self, vmaf: f64, width: u32, height: u32, frame_rate: f64) -> u64 {
86        let vmaf_clamped = vmaf.clamp(0.0, 100.0);
87        let qp = 51.0 * (1.0 - vmaf_clamped / 100.0);
88        self.estimate_from_qp(qp, width, height, frame_rate)
89    }
90
91    /// Infers the CRF value that would be required to hit a target bitrate.
92    ///
93    /// Returns `None` when the target cannot be reached with valid QP values (0–63).
94    #[must_use]
95    pub fn crf_for_target_bitrate(
96        &self,
97        target_bps: u64,
98        width: u32,
99        height: u32,
100        frame_rate: f64,
101    ) -> Option<u8> {
102        if frame_rate <= 0.0 || width == 0 || height == 0 || target_bps == 0 {
103            return None;
104        }
105        let pixels = f64::from(width) * f64::from(height);
106        // bps = base_bpp * pixels * fps * e^(-decay * qp)
107        // qp = -ln(bps / (base_bpp * pixels * fps)) / decay
108        let denominator = self.base_bpp * pixels * frame_rate;
109        if denominator <= 0.0 {
110            return None;
111        }
112        let qp = -(target_bps as f64 / denominator).ln() / self.decay;
113        if !(0.0..=63.0).contains(&qp) {
114            return None;
115        }
116        Some(qp.round() as u8)
117    }
118
119    /// Returns an estimated size in bytes for encoding `duration_secs` of video.
120    #[must_use]
121    pub fn estimate_file_size(
122        &self,
123        crf: u8,
124        width: u32,
125        height: u32,
126        frame_rate: f64,
127        duration_secs: f64,
128    ) -> u64 {
129        let bps = self.estimate_from_crf(crf, width, height, frame_rate);
130        ((bps as f64 * duration_secs) / 8.0).round() as u64
131    }
132}
133
134/// A helper that bundles video parameters for convenience.
135#[derive(Debug, Clone, Copy)]
136pub struct VideoParams {
137    /// Width in pixels.
138    pub width: u32,
139    /// Height in pixels.
140    pub height: u32,
141    /// Frames per second.
142    pub frame_rate: f64,
143    /// CRF value (0–51 for most codecs).
144    pub crf: u8,
145}
146
147impl VideoParams {
148    /// Creates new video params.
149    #[must_use]
150    pub fn new(width: u32, height: u32, frame_rate: f64, crf: u8) -> Self {
151        Self {
152            width,
153            height,
154            frame_rate,
155            crf,
156        }
157    }
158
159    /// Estimates bitrate using a `BitrateEstimator`.
160    #[must_use]
161    pub fn estimate_bitrate(&self, estimator: &BitrateEstimator) -> u64 {
162        estimator.estimate_from_crf(self.crf, self.width, self.height, self.frame_rate)
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_estimate_from_crf_positive() {
172        let est = BitrateEstimator::new();
173        let bps = est.estimate_from_crf(23, 1920, 1080, 30.0);
174        assert!(bps > 0, "Expected positive bitrate, got {bps}");
175    }
176
177    #[test]
178    fn test_lower_crf_higher_bitrate() {
179        let est = BitrateEstimator::new();
180        let high_quality = est.estimate_from_crf(18, 1920, 1080, 30.0);
181        let low_quality = est.estimate_from_crf(28, 1920, 1080, 30.0);
182        assert!(
183            high_quality > low_quality,
184            "CRF 18 should yield more bits than CRF 28"
185        );
186    }
187
188    #[test]
189    fn test_higher_resolution_higher_bitrate() {
190        let est = BitrateEstimator::new();
191        let fhd = est.estimate_from_crf(23, 1920, 1080, 30.0);
192        let uhd = est.estimate_from_crf(23, 3840, 2160, 30.0);
193        assert!(uhd > fhd, "4K should require more bits than 1080p");
194    }
195
196    #[test]
197    fn test_higher_fps_higher_bitrate() {
198        let est = BitrateEstimator::new();
199        let fps30 = est.estimate_from_crf(23, 1920, 1080, 30.0);
200        let fps60 = est.estimate_from_crf(23, 1920, 1080, 60.0);
201        assert!(fps60 > fps30, "60 fps should require more bits than 30 fps");
202        assert!(
203            (fps60 as f64 / fps30 as f64 - 2.0).abs() < 0.01,
204            "Should scale linearly with fps"
205        );
206    }
207
208    #[test]
209    fn test_zero_dimensions_returns_zero() {
210        let est = BitrateEstimator::new();
211        assert_eq!(est.estimate_from_crf(23, 0, 1080, 30.0), 0);
212        assert_eq!(est.estimate_from_crf(23, 1920, 0, 30.0), 0);
213        assert_eq!(est.estimate_from_crf(23, 1920, 1080, 0.0), 0);
214    }
215
216    #[test]
217    fn test_vmaf_estimate_high_quality() {
218        let est = BitrateEstimator::new();
219        let high = est.estimate_from_vmaf(95.0, 1920, 1080, 30.0);
220        let low = est.estimate_from_vmaf(50.0, 1920, 1080, 30.0);
221        assert!(high > low, "VMAF 95 should need more bits than VMAF 50");
222    }
223
224    #[test]
225    fn test_crf_for_target_bitrate_roundtrip() {
226        let est = BitrateEstimator::new();
227        let target_crf: u8 = 23;
228        let bps = est.estimate_from_crf(target_crf, 1920, 1080, 30.0);
229        if let Some(inferred_crf) = est.crf_for_target_bitrate(bps, 1920, 1080, 30.0) {
230            // Allow ±1 due to rounding.
231            assert!(
232                (inferred_crf as i16 - target_crf as i16).abs() <= 1,
233                "Expected CRF ~{target_crf}, got {inferred_crf}"
234            );
235        }
236    }
237
238    #[test]
239    fn test_estimate_file_size() {
240        let est = BitrateEstimator::new();
241        let bytes = est.estimate_file_size(23, 1920, 1080, 30.0, 60.0); // 60 s clip
242        assert!(bytes > 0);
243        // File size in bytes = bps * duration / 8
244        let bps = est.estimate_from_crf(23, 1920, 1080, 30.0);
245        let expected = (bps as f64 * 60.0 / 8.0).round() as u64;
246        assert_eq!(bytes, expected);
247    }
248
249    #[test]
250    fn test_video_params_estimate_bitrate() {
251        let params = VideoParams::new(1920, 1080, 30.0, 23);
252        let est = BitrateEstimator::new();
253        let bps = params.estimate_bitrate(&est);
254        assert_eq!(bps, est.estimate_from_crf(23, 1920, 1080, 30.0));
255    }
256
257    #[test]
258    fn test_custom_params() {
259        let est = BitrateEstimator::with_params(0.2, 0.05);
260        let bps = est.estimate_from_crf(20, 1280, 720, 25.0);
261        assert!(bps > 0);
262    }
263}