Skip to main content

oximedia_transcode/
bitrate_estimator.rs

1//! Bitrate estimation from QP/CRF, resolution, and frame-rate parameters.
2//!
3//! This module also integrates [`RunningStats`](crate::running_stats::RunningStats)
4//! and [`BitrateRunningAnalyzer`]
5//! so that callers can accumulate per-frame bitrate statistics incrementally
6//! (Welford online algorithm) without storing all past values.
7
8use crate::running_stats::BitrateRunningAnalyzer;
9
10/// Estimates output bitrate (bits per second) from encode parameters.
11///
12/// The model is a simplified empirical formula:
13/// `bitrate ≈ base_bpp * pixels_per_frame * frame_rate * quality_factor`
14///
15/// where `quality_factor` decreases as QP/CRF increases.
16///
17/// An embedded [`BitrateRunningAnalyzer`] accumulates per-frame bitrate
18/// statistics incrementally via Welford's online algorithm so that callers
19/// can query running mean/variance without storing historical data.
20///
21/// # Example
22///
23/// ```
24/// use oximedia_transcode::bitrate_estimator::BitrateEstimator;
25///
26/// let mut est = BitrateEstimator::new();
27/// let bps = est.estimate_from_crf(23, 1920, 1080, 30.0);
28/// assert!(bps > 0);
29///
30/// // Feed observed per-frame bit counts into the running analyzer
31/// est.record_frame_bits(50_000);
32/// est.record_frame_bits(45_000);
33/// let summary = est.running_summary();
34/// assert!(summary.mean_bps > 0.0);
35/// ```
36#[derive(Debug)]
37pub struct BitrateEstimator {
38    /// Base bits-per-pixel at CRF/QP = 0.
39    base_bpp: f64,
40    /// Exponential decay coefficient for quality degradation with QP.
41    decay: f64,
42    /// Online running statistics for per-frame bitrate (Welford algorithm).
43    analyzer: BitrateRunningAnalyzer,
44}
45
46impl Default for BitrateEstimator {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl BitrateEstimator {
53    /// Creates a `BitrateEstimator` with default empirical parameters.
54    ///
55    /// The embedded running analyzer is initialised for 30 fps with a 60-frame
56    /// rolling window; call [`with_params_and_fps`](Self::with_params_and_fps)
57    /// to customise these.
58    #[must_use]
59    pub fn new() -> Self {
60        Self {
61            base_bpp: 0.10, // 0.10 bits per pixel at lossless
62            decay: 0.065,   // tuned to match real-world H.264/VP9 behaviour
63            analyzer: BitrateRunningAnalyzer::new(30.0, 60),
64        }
65    }
66
67    /// Creates a `BitrateEstimator` with custom parameters.
68    ///
69    /// * `base_bpp` – bits per pixel at QP = 0.
70    /// * `decay` – exponential decay constant; higher = steeper quality/bitrate curve.
71    #[must_use]
72    pub fn with_params(base_bpp: f64, decay: f64) -> Self {
73        Self {
74            base_bpp,
75            decay,
76            analyzer: BitrateRunningAnalyzer::new(30.0, 60),
77        }
78    }
79
80    /// Creates a `BitrateEstimator` with custom model parameters and analyzer config.
81    ///
82    /// * `fps`           – Frame rate for the running analyzer (bits/frame → bits/s).
83    /// * `window_frames` – Rolling-window size for recent-peak detection.
84    #[must_use]
85    pub fn with_params_and_fps(base_bpp: f64, decay: f64, fps: f64, window_frames: usize) -> Self {
86        Self {
87            base_bpp,
88            decay,
89            analyzer: BitrateRunningAnalyzer::new(fps, window_frames),
90        }
91    }
92
93    /// Records the observed bit count for one encoded frame into the running analyzer.
94    ///
95    /// Use this to track actual per-frame bitrate statistics incrementally
96    /// (Welford algorithm) without buffering all historical frame data.
97    pub fn record_frame_bits(&mut self, bits_per_frame: u64) {
98        self.analyzer.push_frame(bits_per_frame);
99    }
100
101    /// Returns a snapshot of current running bitrate statistics.
102    ///
103    /// The summary reflects all frames recorded via [`record_frame_bits`](Self::record_frame_bits).
104    #[must_use]
105    pub fn running_summary(&self) -> crate::running_stats::BitrateSummary {
106        self.analyzer.summary()
107    }
108
109    /// Resets the running bitrate statistics to their initial state.
110    pub fn reset_running_stats(&mut self) {
111        self.analyzer.reset();
112    }
113
114    /// Estimates output bitrate in bits/s from a CRF value (0–51 for H.264/H.265).
115    ///
116    /// * `crf`        – Constant Rate Factor (0 = lossless, 51 = worst).
117    /// * `width`      – Frame width in pixels.
118    /// * `height`     – Frame height in pixels.
119    /// * `frame_rate` – Frames per second.
120    #[must_use]
121    pub fn estimate_from_crf(&self, crf: u8, width: u32, height: u32, frame_rate: f64) -> u64 {
122        self.estimate_from_qp(f64::from(crf), width, height, frame_rate)
123    }
124
125    /// Estimates output bitrate in bits/s from a floating-point QP value.
126    ///
127    /// * `qp`         – Quantization parameter.
128    /// * `width`      – Frame width in pixels.
129    /// * `height`     – Frame height in pixels.
130    /// * `frame_rate` – Frames per second.
131    #[must_use]
132    pub fn estimate_from_qp(&self, qp: f64, width: u32, height: u32, frame_rate: f64) -> u64 {
133        if frame_rate <= 0.0 || width == 0 || height == 0 {
134            return 0;
135        }
136        let pixels = f64::from(width) * f64::from(height);
137        let quality_factor = (-self.decay * qp).exp();
138        let bps = self.base_bpp * pixels * frame_rate * quality_factor;
139        bps.round() as u64
140    }
141
142    /// Estimates bitrate from a target VMAF score (0–100).
143    ///
144    /// Linearly maps VMAF → effective QP, then delegates to `estimate_from_qp`.
145    /// VMAF 100 ≈ QP 0 (lossless), VMAF 0 ≈ QP 51 (worst).
146    #[must_use]
147    pub fn estimate_from_vmaf(&self, vmaf: f64, width: u32, height: u32, frame_rate: f64) -> u64 {
148        let vmaf_clamped = vmaf.clamp(0.0, 100.0);
149        let qp = 51.0 * (1.0 - vmaf_clamped / 100.0);
150        self.estimate_from_qp(qp, width, height, frame_rate)
151    }
152
153    /// Infers the CRF value that would be required to hit a target bitrate.
154    ///
155    /// Returns `None` when the target cannot be reached with valid QP values (0–63).
156    #[must_use]
157    pub fn crf_for_target_bitrate(
158        &self,
159        target_bps: u64,
160        width: u32,
161        height: u32,
162        frame_rate: f64,
163    ) -> Option<u8> {
164        if frame_rate <= 0.0 || width == 0 || height == 0 || target_bps == 0 {
165            return None;
166        }
167        let pixels = f64::from(width) * f64::from(height);
168        // bps = base_bpp * pixels * fps * e^(-decay * qp)
169        // qp = -ln(bps / (base_bpp * pixels * fps)) / decay
170        let denominator = self.base_bpp * pixels * frame_rate;
171        if denominator <= 0.0 {
172            return None;
173        }
174        let qp = -(target_bps as f64 / denominator).ln() / self.decay;
175        if !(0.0..=63.0).contains(&qp) {
176            return None;
177        }
178        Some(qp.round() as u8)
179    }
180
181    /// Returns an estimated size in bytes for encoding `duration_secs` of video.
182    #[must_use]
183    pub fn estimate_file_size(
184        &self,
185        crf: u8,
186        width: u32,
187        height: u32,
188        frame_rate: f64,
189        duration_secs: f64,
190    ) -> u64 {
191        let bps = self.estimate_from_crf(crf, width, height, frame_rate);
192        ((bps as f64 * duration_secs) / 8.0).round() as u64
193    }
194}
195
196/// A helper that bundles video parameters for convenience.
197#[derive(Debug, Clone, Copy)]
198pub struct VideoParams {
199    /// Width in pixels.
200    pub width: u32,
201    /// Height in pixels.
202    pub height: u32,
203    /// Frames per second.
204    pub frame_rate: f64,
205    /// CRF value (0–51 for most codecs).
206    pub crf: u8,
207}
208
209impl VideoParams {
210    /// Creates new video params.
211    #[must_use]
212    pub fn new(width: u32, height: u32, frame_rate: f64, crf: u8) -> Self {
213        Self {
214            width,
215            height,
216            frame_rate,
217            crf,
218        }
219    }
220
221    /// Estimates bitrate using a `BitrateEstimator`.
222    #[must_use]
223    pub fn estimate_bitrate(&self, estimator: &BitrateEstimator) -> u64 {
224        estimator.estimate_from_crf(self.crf, self.width, self.height, self.frame_rate)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_estimate_from_crf_positive() {
234        let est = BitrateEstimator::new();
235        let bps = est.estimate_from_crf(23, 1920, 1080, 30.0);
236        assert!(bps > 0, "Expected positive bitrate, got {bps}");
237    }
238
239    #[test]
240    fn test_lower_crf_higher_bitrate() {
241        let est = BitrateEstimator::new();
242        let high_quality = est.estimate_from_crf(18, 1920, 1080, 30.0);
243        let low_quality = est.estimate_from_crf(28, 1920, 1080, 30.0);
244        assert!(
245            high_quality > low_quality,
246            "CRF 18 should yield more bits than CRF 28"
247        );
248    }
249
250    #[test]
251    fn test_higher_resolution_higher_bitrate() {
252        let est = BitrateEstimator::new();
253        let fhd = est.estimate_from_crf(23, 1920, 1080, 30.0);
254        let uhd = est.estimate_from_crf(23, 3840, 2160, 30.0);
255        assert!(uhd > fhd, "4K should require more bits than 1080p");
256    }
257
258    #[test]
259    fn test_higher_fps_higher_bitrate() {
260        let est = BitrateEstimator::new();
261        let fps30 = est.estimate_from_crf(23, 1920, 1080, 30.0);
262        let fps60 = est.estimate_from_crf(23, 1920, 1080, 60.0);
263        assert!(fps60 > fps30, "60 fps should require more bits than 30 fps");
264        assert!(
265            (fps60 as f64 / fps30 as f64 - 2.0).abs() < 0.01,
266            "Should scale linearly with fps"
267        );
268    }
269
270    #[test]
271    fn test_zero_dimensions_returns_zero() {
272        let est = BitrateEstimator::new();
273        assert_eq!(est.estimate_from_crf(23, 0, 1080, 30.0), 0);
274        assert_eq!(est.estimate_from_crf(23, 1920, 0, 30.0), 0);
275        assert_eq!(est.estimate_from_crf(23, 1920, 1080, 0.0), 0);
276    }
277
278    #[test]
279    fn test_vmaf_estimate_high_quality() {
280        let est = BitrateEstimator::new();
281        let high = est.estimate_from_vmaf(95.0, 1920, 1080, 30.0);
282        let low = est.estimate_from_vmaf(50.0, 1920, 1080, 30.0);
283        assert!(high > low, "VMAF 95 should need more bits than VMAF 50");
284    }
285
286    #[test]
287    fn test_crf_for_target_bitrate_roundtrip() {
288        let est = BitrateEstimator::new();
289        let target_crf: u8 = 23;
290        let bps = est.estimate_from_crf(target_crf, 1920, 1080, 30.0);
291        if let Some(inferred_crf) = est.crf_for_target_bitrate(bps, 1920, 1080, 30.0) {
292            // Allow ±1 due to rounding.
293            assert!(
294                (inferred_crf as i16 - target_crf as i16).abs() <= 1,
295                "Expected CRF ~{target_crf}, got {inferred_crf}"
296            );
297        }
298    }
299
300    #[test]
301    fn test_estimate_file_size() {
302        let est = BitrateEstimator::new();
303        let bytes = est.estimate_file_size(23, 1920, 1080, 30.0, 60.0); // 60 s clip
304        assert!(bytes > 0);
305        // File size in bytes = bps * duration / 8
306        let bps = est.estimate_from_crf(23, 1920, 1080, 30.0);
307        let expected = (bps as f64 * 60.0 / 8.0).round() as u64;
308        assert_eq!(bytes, expected);
309    }
310
311    #[test]
312    fn test_video_params_estimate_bitrate() {
313        let params = VideoParams::new(1920, 1080, 30.0, 23);
314        let est = BitrateEstimator::new();
315        let bps = params.estimate_bitrate(&est);
316        assert_eq!(bps, est.estimate_from_crf(23, 1920, 1080, 30.0));
317    }
318
319    #[test]
320    fn test_custom_params() {
321        let est = BitrateEstimator::with_params(0.2, 0.05);
322        let bps = est.estimate_from_crf(20, 1280, 720, 25.0);
323        assert!(bps > 0);
324    }
325
326    // ── Running-statistics integration tests (T1) ─────────────────────────────
327
328    /// Verify that `RunningStats` (Welford) produces the same mean and sample
329    /// variance as a classic two-pass batch computation over the same data.
330    #[test]
331    fn test_running_stats_matches_batch_computation() {
332        use crate::running_stats::RunningStats;
333
334        // Representative per-frame bit counts (arbitrary realistic values)
335        let samples = [
336            10_000.0_f64,
337            12_500.0,
338            8_700.0,
339            15_000.0,
340            9_800.0,
341            11_300.0,
342            13_600.0,
343            7_900.0,
344            14_200.0,
345            10_500.0,
346        ];
347
348        // Online (Welford) accumulator
349        let mut stats = RunningStats::new();
350        for &s in &samples {
351            stats.push(s);
352        }
353
354        // Batch computation (two-pass)
355        let n = samples.len() as f64;
356        let batch_mean = samples.iter().sum::<f64>() / n;
357        let batch_var = samples
358            .iter()
359            .map(|&x| (x - batch_mean).powi(2))
360            .sum::<f64>()
361            / (n - 1.0); // sample variance (n-1)
362
363        let tol = 1e-6;
364        assert!(
365            (stats.mean() - batch_mean).abs() < tol,
366            "mean mismatch: welford={}, batch={}",
367            stats.mean(),
368            batch_mean
369        );
370        assert!(
371            (stats.variance() - batch_var).abs() < tol,
372            "variance mismatch: welford={}, batch={}",
373            stats.variance(),
374            batch_var
375        );
376    }
377
378    /// Verify that incremental updates to `RunningStats` give the same final
379    /// state as pushing all samples in one go.
380    #[test]
381    fn test_running_stats_incremental_update() {
382        use crate::running_stats::RunningStats;
383
384        let all_samples = [1.0_f64, 4.0, 9.0, 16.0, 25.0, 36.0];
385
386        // Single-batch accumulator
387        let mut batch = RunningStats::new();
388        for &s in &all_samples {
389            batch.push(s);
390        }
391
392        // Incremental: push half, check intermediate state, then push the rest
393        let mut incremental = RunningStats::new();
394        let (first_half, second_half) = all_samples.split_at(3);
395        for &s in first_half {
396            incremental.push(s);
397        }
398        // Intermediate mean should match the first three samples
399        let expected_mid_mean = first_half.iter().sum::<f64>() / first_half.len() as f64;
400        assert!(
401            (incremental.mean() - expected_mid_mean).abs() < 1e-10,
402            "mid-point mean mismatch: got {}, expected {}",
403            incremental.mean(),
404            expected_mid_mean
405        );
406
407        for &s in second_half {
408            incremental.push(s);
409        }
410
411        // Final state must match the single-pass accumulator
412        let tol = 1e-10;
413        assert_eq!(incremental.count(), batch.count());
414        assert!(
415            (incremental.mean() - batch.mean()).abs() < tol,
416            "final mean mismatch"
417        );
418        assert!(
419            (incremental.variance() - batch.variance()).abs() < tol,
420            "final variance mismatch"
421        );
422        assert_eq!(incremental.min(), batch.min());
423        assert_eq!(incremental.max(), batch.max());
424    }
425}