Skip to main content

oximedia_transcode/
transcode_metrics.rs

1//! Transcoding performance metrics collection and reporting.
2//!
3//! Provides `FrameMetric`, `MetricsSummary`, and `TranscodeMetricsCollector`
4//! for recording and analysing per-frame and aggregate encode statistics.
5
6#![allow(dead_code)]
7
8/// Per-frame encoding metric captured during transcoding.
9#[derive(Debug, Clone)]
10pub struct FrameMetric {
11    /// Frame index (0-based).
12    pub frame_index: u64,
13    /// Encode time for this frame in microseconds.
14    pub encode_us: u64,
15    /// Compressed size of this frame in bytes.
16    pub compressed_bytes: u64,
17    /// PSNR value for this frame (dB), if computed.
18    pub psnr_db: Option<f64>,
19}
20
21impl FrameMetric {
22    /// Creates a new frame metric.
23    #[must_use]
24    pub fn new(frame_index: u64, encode_us: u64, compressed_bytes: u64) -> Self {
25        Self {
26            frame_index,
27            encode_us,
28            compressed_bytes,
29            psnr_db: None,
30        }
31    }
32
33    /// Attaches a PSNR measurement.
34    #[must_use]
35    pub fn with_psnr(mut self, psnr_db: f64) -> Self {
36        self.psnr_db = Some(psnr_db);
37        self
38    }
39
40    /// Returns the instantaneous bitrate for this frame given a frame rate.
41    ///
42    /// `fps` is frames-per-second as a float.
43    #[allow(clippy::cast_precision_loss)]
44    #[must_use]
45    pub fn instantaneous_bitrate_bps(&self, fps: f64) -> f64 {
46        self.compressed_bytes as f64 * 8.0 * fps
47    }
48}
49
50/// Summary statistics over a collection of frame metrics.
51#[derive(Debug, Clone)]
52pub struct MetricsSummary {
53    /// Total number of frames.
54    pub frame_count: u64,
55    /// Mean encode time per frame in microseconds.
56    pub mean_encode_us: f64,
57    /// Peak encode time in microseconds.
58    pub peak_encode_us: u64,
59    /// Total compressed bytes.
60    pub total_bytes: u64,
61    /// Mean PSNR in dB (None if not measured).
62    pub mean_psnr_db: Option<f64>,
63    /// Minimum PSNR in dB (None if not measured).
64    pub min_psnr_db: Option<f64>,
65}
66
67impl MetricsSummary {
68    /// Returns the mean bitrate in bits-per-second given input fps.
69    #[allow(clippy::cast_precision_loss)]
70    #[must_use]
71    pub fn mean_bitrate_bps(&self, fps: f64) -> f64 {
72        if self.frame_count == 0 || fps <= 0.0 {
73            return 0.0;
74        }
75        let total_bits = self.total_bytes as f64 * 8.0;
76        let duration_secs = self.frame_count as f64 / fps;
77        total_bits / duration_secs
78    }
79
80    /// Returns the encode throughput in frames per second.
81    #[allow(clippy::cast_precision_loss)]
82    #[must_use]
83    pub fn encode_fps(&self) -> f64 {
84        if self.mean_encode_us <= 0.0 {
85            return 0.0;
86        }
87        1_000_000.0 / self.mean_encode_us
88    }
89}
90
91/// Collects frame-level metrics during a transcode session.
92#[derive(Debug, Default)]
93pub struct TranscodeMetricsCollector {
94    metrics: Vec<FrameMetric>,
95}
96
97impl TranscodeMetricsCollector {
98    /// Creates a new, empty collector.
99    #[must_use]
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Creates a collector with pre-allocated capacity.
105    #[must_use]
106    pub fn with_capacity(cap: usize) -> Self {
107        Self {
108            metrics: Vec::with_capacity(cap),
109        }
110    }
111
112    /// Records a frame metric.
113    pub fn record(&mut self, metric: FrameMetric) {
114        self.metrics.push(metric);
115    }
116
117    /// Returns the number of recorded frame metrics.
118    #[must_use]
119    pub fn frame_count(&self) -> usize {
120        self.metrics.len()
121    }
122
123    /// Returns `true` if no metrics have been recorded.
124    #[must_use]
125    pub fn is_empty(&self) -> bool {
126        self.metrics.is_empty()
127    }
128
129    /// Computes and returns a summary over all recorded metrics.
130    #[allow(clippy::cast_precision_loss)]
131    pub fn summarise(&self) -> MetricsSummary {
132        let count = self.metrics.len() as u64;
133        if count == 0 {
134            return MetricsSummary {
135                frame_count: 0,
136                mean_encode_us: 0.0,
137                peak_encode_us: 0,
138                total_bytes: 0,
139                mean_psnr_db: None,
140                min_psnr_db: None,
141            };
142        }
143
144        let total_encode_us: u64 = self.metrics.iter().map(|m| m.encode_us).sum();
145        let peak_encode_us = self.metrics.iter().map(|m| m.encode_us).max().unwrap_or(0);
146        let total_bytes: u64 = self.metrics.iter().map(|m| m.compressed_bytes).sum();
147
148        let psnr_values: Vec<f64> = self.metrics.iter().filter_map(|m| m.psnr_db).collect();
149
150        let mean_psnr_db = if psnr_values.is_empty() {
151            None
152        } else {
153            Some(psnr_values.iter().sum::<f64>() / psnr_values.len() as f64)
154        };
155
156        let min_psnr_db = psnr_values.iter().copied().reduce(f64::min);
157
158        MetricsSummary {
159            frame_count: count,
160            mean_encode_us: total_encode_us as f64 / count as f64,
161            peak_encode_us,
162            total_bytes,
163            mean_psnr_db,
164            min_psnr_db,
165        }
166    }
167
168    /// Returns the worst (lowest) PSNR frame, if PSNR data is available.
169    #[must_use]
170    pub fn worst_psnr_frame(&self) -> Option<&FrameMetric> {
171        self.metrics
172            .iter()
173            .filter(|m| m.psnr_db.is_some())
174            .min_by(|a, b| {
175                a.psnr_db
176                    .expect("invariant: filter ensures psnr_db is Some")
177                    .partial_cmp(
178                        &b.psnr_db
179                            .expect("invariant: filter ensures psnr_db is Some"),
180                    )
181                    .unwrap_or(std::cmp::Ordering::Equal)
182            })
183    }
184
185    /// Returns the slowest (highest encode time) frame metric.
186    #[must_use]
187    pub fn slowest_frame(&self) -> Option<&FrameMetric> {
188        self.metrics.iter().max_by_key(|m| m.encode_us)
189    }
190
191    /// Clears all recorded metrics.
192    pub fn clear(&mut self) {
193        self.metrics.clear();
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn make_metric(index: u64, encode_us: u64, bytes: u64) -> FrameMetric {
202        FrameMetric::new(index, encode_us, bytes)
203    }
204
205    #[test]
206    fn test_frame_metric_creation() {
207        let m = make_metric(0, 5000, 10_000);
208        assert_eq!(m.frame_index, 0);
209        assert_eq!(m.encode_us, 5000);
210        assert_eq!(m.compressed_bytes, 10_000);
211        assert!(m.psnr_db.is_none());
212    }
213
214    #[test]
215    fn test_frame_metric_with_psnr() {
216        let m = make_metric(0, 5000, 10_000).with_psnr(42.5);
217        assert_eq!(m.psnr_db, Some(42.5));
218    }
219
220    #[test]
221    fn test_instantaneous_bitrate() {
222        let m = make_metric(0, 5000, 1000); // 1000 bytes at 30fps
223        let bps = m.instantaneous_bitrate_bps(30.0);
224        assert!((bps - 240_000.0).abs() < 0.01);
225    }
226
227    #[test]
228    fn test_collector_empty() {
229        let c = TranscodeMetricsCollector::new();
230        assert!(c.is_empty());
231        assert_eq!(c.frame_count(), 0);
232    }
233
234    #[test]
235    fn test_collector_record_increments_count() {
236        let mut c = TranscodeMetricsCollector::new();
237        c.record(make_metric(0, 1000, 500));
238        c.record(make_metric(1, 2000, 600));
239        assert_eq!(c.frame_count(), 2);
240        assert!(!c.is_empty());
241    }
242
243    #[test]
244    fn test_summarise_empty() {
245        let c = TranscodeMetricsCollector::new();
246        let s = c.summarise();
247        assert_eq!(s.frame_count, 0);
248        assert_eq!(s.total_bytes, 0);
249        assert!(s.mean_psnr_db.is_none());
250    }
251
252    #[test]
253    fn test_summarise_mean_encode_us() {
254        let mut c = TranscodeMetricsCollector::new();
255        c.record(make_metric(0, 1000, 100));
256        c.record(make_metric(1, 3000, 100));
257        let s = c.summarise();
258        assert!((s.mean_encode_us - 2000.0).abs() < f64::EPSILON);
259    }
260
261    #[test]
262    fn test_summarise_peak_encode_us() {
263        let mut c = TranscodeMetricsCollector::new();
264        c.record(make_metric(0, 1000, 100));
265        c.record(make_metric(1, 5000, 200));
266        let s = c.summarise();
267        assert_eq!(s.peak_encode_us, 5000);
268    }
269
270    #[test]
271    fn test_summarise_total_bytes() {
272        let mut c = TranscodeMetricsCollector::new();
273        c.record(make_metric(0, 1000, 400));
274        c.record(make_metric(1, 1000, 600));
275        let s = c.summarise();
276        assert_eq!(s.total_bytes, 1000);
277    }
278
279    #[test]
280    fn test_summarise_psnr() {
281        let mut c = TranscodeMetricsCollector::new();
282        c.record(make_metric(0, 100, 100).with_psnr(40.0));
283        c.record(make_metric(1, 100, 100).with_psnr(44.0));
284        let s = c.summarise();
285        assert!((s.mean_psnr_db.expect("should succeed in test") - 42.0).abs() < 0.001);
286        assert!((s.min_psnr_db.expect("should succeed in test") - 40.0).abs() < 0.001);
287    }
288
289    #[test]
290    fn test_mean_bitrate_bps() {
291        let mut c = TranscodeMetricsCollector::new();
292        // 30 frames, each 1000 bytes at 30 fps → 1s → 240kbps
293        for i in 0..30 {
294            c.record(make_metric(i, 1000, 1000));
295        }
296        let s = c.summarise();
297        let bps = s.mean_bitrate_bps(30.0);
298        assert!((bps - 240_000.0).abs() < 1.0);
299    }
300
301    #[test]
302    fn test_encode_fps() {
303        let mut c = TranscodeMetricsCollector::new();
304        // 33333 µs ≈ 30 fps
305        c.record(make_metric(0, 33_333, 100));
306        c.record(make_metric(1, 33_333, 100));
307        let s = c.summarise();
308        let fps = s.encode_fps();
309        assert!((fps - 30.0).abs() < 0.1);
310    }
311
312    #[test]
313    fn test_slowest_frame() {
314        let mut c = TranscodeMetricsCollector::new();
315        c.record(make_metric(0, 1000, 100));
316        c.record(make_metric(1, 9000, 200));
317        c.record(make_metric(2, 500, 50));
318        let sf = c.slowest_frame().expect("should succeed in test");
319        assert_eq!(sf.frame_index, 1);
320    }
321
322    #[test]
323    fn test_worst_psnr_frame() {
324        let mut c = TranscodeMetricsCollector::new();
325        c.record(make_metric(0, 100, 100).with_psnr(45.0));
326        c.record(make_metric(1, 100, 100).with_psnr(35.0));
327        c.record(make_metric(2, 100, 100).with_psnr(50.0));
328        let worst = c.worst_psnr_frame().expect("should succeed in test");
329        assert_eq!(worst.frame_index, 1);
330    }
331
332    #[test]
333    fn test_clear() {
334        let mut c = TranscodeMetricsCollector::new();
335        c.record(make_metric(0, 100, 100));
336        c.clear();
337        assert!(c.is_empty());
338    }
339}