Skip to main content

oximedia_transcode/
frame_stats.rs

1#![allow(dead_code)]
2//! Per-frame statistics collection for transcoding analysis.
3
4/// The type of a compressed video frame.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum FrameType {
7    /// Intra-coded frame — self-contained, can be decoded independently.
8    I,
9    /// Predictive frame — encoded relative to a prior reference frame.
10    P,
11    /// Bi-directional frame — uses both past and future references.
12    B,
13    /// Switching P frame (H.264 SP).
14    SP,
15    /// Switching I frame (H.264 SI).
16    SI,
17}
18
19impl FrameType {
20    /// Returns `true` if this frame can be used as a reference by other frames.
21    #[must_use]
22    pub fn is_reference(&self) -> bool {
23        matches!(self, Self::I | Self::P | Self::SP | Self::SI)
24    }
25
26    /// Returns `true` for intra-coded frame types.
27    #[must_use]
28    pub fn is_intra(&self) -> bool {
29        matches!(self, Self::I | Self::SI)
30    }
31
32    /// Returns `true` for inter-coded frame types.
33    #[must_use]
34    pub fn is_inter(&self) -> bool {
35        matches!(self, Self::P | Self::B | Self::SP)
36    }
37
38    /// Returns a short ASCII tag for logging.
39    #[must_use]
40    pub fn tag(&self) -> &'static str {
41        match self {
42            Self::I => "I",
43            Self::P => "P",
44            Self::B => "B",
45            Self::SP => "SP",
46            Self::SI => "SI",
47        }
48    }
49}
50
51/// Statistics for a single encoded frame.
52#[derive(Debug, Clone)]
53pub struct FrameStat {
54    /// Sequential frame index (0-based).
55    pub index: u64,
56    /// Frame type.
57    pub frame_type: FrameType,
58    /// Encoded size in bytes.
59    pub size_bytes: u64,
60    /// Frame width in pixels.
61    pub width: u32,
62    /// Frame height in pixels.
63    pub height: u32,
64    /// Quantisation parameter used by the encoder.
65    pub qp: u8,
66}
67
68impl FrameStat {
69    /// Create a new frame stat entry.
70    #[must_use]
71    pub fn new(
72        index: u64,
73        frame_type: FrameType,
74        size_bytes: u64,
75        width: u32,
76        height: u32,
77        qp: u8,
78    ) -> Self {
79        Self {
80            index,
81            frame_type,
82            size_bytes,
83            width,
84            height,
85            qp,
86        }
87    }
88
89    /// Bits per pixel for this frame.
90    #[allow(clippy::cast_precision_loss)]
91    #[must_use]
92    pub fn bits_per_pixel(&self) -> f64 {
93        let pixels = u64::from(self.width) * u64::from(self.height);
94        if pixels == 0 {
95            return 0.0;
96        }
97        (self.size_bytes * 8) as f64 / pixels as f64
98    }
99
100    /// Returns `true` when the frame is an I-frame (IDR or SI).
101    #[must_use]
102    pub fn is_keyframe(&self) -> bool {
103        self.frame_type.is_intra()
104    }
105}
106
107/// Collects and aggregates per-frame statistics across a transcode session.
108#[derive(Debug, Default)]
109pub struct FrameStatsCollector {
110    frames: Vec<FrameStat>,
111}
112
113impl FrameStatsCollector {
114    /// Create an empty collector.
115    #[must_use]
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Record a frame's statistics.
121    pub fn record(&mut self, stat: FrameStat) {
122        self.frames.push(stat);
123    }
124
125    /// Number of recorded frames.
126    #[must_use]
127    pub fn frame_count(&self) -> usize {
128        self.frames.len()
129    }
130
131    /// Number of I-frames recorded.
132    #[must_use]
133    pub fn i_frame_count(&self) -> usize {
134        self.frames
135            .iter()
136            .filter(|f| f.frame_type.is_intra())
137            .count()
138    }
139
140    /// Number of P-frames recorded.
141    #[must_use]
142    pub fn p_frame_count(&self) -> usize {
143        self.frames
144            .iter()
145            .filter(|f| matches!(f.frame_type, FrameType::P | FrameType::SP))
146            .count()
147    }
148
149    /// Number of B-frames recorded.
150    #[must_use]
151    pub fn b_frame_count(&self) -> usize {
152        self.frames
153            .iter()
154            .filter(|f| f.frame_type == FrameType::B)
155            .count()
156    }
157
158    /// Average encoded size in bits across all recorded frames.
159    #[allow(clippy::cast_precision_loss)]
160    #[must_use]
161    pub fn avg_bits_per_frame(&self) -> f64 {
162        if self.frames.is_empty() {
163            return 0.0;
164        }
165        let total_bytes: u64 = self.frames.iter().map(|f| f.size_bytes).sum();
166        (total_bytes * 8) as f64 / self.frames.len() as f64
167    }
168
169    /// Total encoded bytes across all frames.
170    #[must_use]
171    pub fn total_bytes(&self) -> u64 {
172        self.frames.iter().map(|f| f.size_bytes).sum()
173    }
174
175    /// Largest frame (by size) in the recording.
176    #[must_use]
177    pub fn largest_frame(&self) -> Option<&FrameStat> {
178        self.frames.iter().max_by_key(|f| f.size_bytes)
179    }
180
181    /// Average QP across all frames.
182    #[allow(clippy::cast_precision_loss)]
183    #[must_use]
184    pub fn avg_qp(&self) -> f64 {
185        if self.frames.is_empty() {
186            return 0.0;
187        }
188        let sum: u64 = self.frames.iter().map(|f| u64::from(f.qp)).sum();
189        sum as f64 / self.frames.len() as f64
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn make_stat(idx: u64, ft: FrameType, size: u64) -> FrameStat {
198        FrameStat::new(idx, ft, size, 1920, 1080, 28)
199    }
200
201    #[test]
202    fn test_frame_type_is_reference_i() {
203        assert!(FrameType::I.is_reference());
204    }
205
206    #[test]
207    fn test_frame_type_is_reference_b() {
208        assert!(!FrameType::B.is_reference());
209    }
210
211    #[test]
212    fn test_frame_type_is_intra() {
213        assert!(FrameType::I.is_intra());
214        assert!(FrameType::SI.is_intra());
215        assert!(!FrameType::P.is_intra());
216    }
217
218    #[test]
219    fn test_frame_type_is_inter() {
220        assert!(FrameType::P.is_inter());
221        assert!(FrameType::B.is_inter());
222        assert!(!FrameType::I.is_inter());
223    }
224
225    #[test]
226    fn test_frame_type_tag() {
227        assert_eq!(FrameType::I.tag(), "I");
228        assert_eq!(FrameType::B.tag(), "B");
229        assert_eq!(FrameType::SP.tag(), "SP");
230    }
231
232    #[test]
233    fn test_frame_stat_bits_per_pixel() {
234        // 1920 * 1080 = 2_073_600 pixels; 100_000 bytes = 800_000 bits
235        let s = FrameStat::new(0, FrameType::I, 100_000, 1920, 1080, 20);
236        let bpp = s.bits_per_pixel();
237        assert!((bpp - 800_000.0 / 2_073_600.0).abs() < 1e-6);
238    }
239
240    #[test]
241    fn test_frame_stat_bits_per_pixel_zero_dimension() {
242        let s = FrameStat::new(0, FrameType::I, 1000, 0, 0, 20);
243        assert_eq!(s.bits_per_pixel(), 0.0);
244    }
245
246    #[test]
247    fn test_frame_stat_is_keyframe() {
248        let i = FrameStat::new(0, FrameType::I, 0, 100, 100, 1);
249        assert!(i.is_keyframe());
250        let b = FrameStat::new(1, FrameType::B, 0, 100, 100, 1);
251        assert!(!b.is_keyframe());
252    }
253
254    #[test]
255    fn test_collector_i_frame_count() {
256        let mut c = FrameStatsCollector::new();
257        c.record(make_stat(0, FrameType::I, 50_000));
258        c.record(make_stat(1, FrameType::P, 10_000));
259        c.record(make_stat(2, FrameType::B, 5_000));
260        assert_eq!(c.i_frame_count(), 1);
261    }
262
263    #[test]
264    fn test_collector_b_frame_count() {
265        let mut c = FrameStatsCollector::new();
266        c.record(make_stat(0, FrameType::B, 5_000));
267        c.record(make_stat(1, FrameType::B, 6_000));
268        assert_eq!(c.b_frame_count(), 2);
269    }
270
271    #[test]
272    fn test_collector_avg_bits_per_frame() {
273        let mut c = FrameStatsCollector::new();
274        c.record(make_stat(0, FrameType::I, 100)); // 800 bits
275        c.record(make_stat(1, FrameType::P, 100)); // 800 bits
276        assert!((c.avg_bits_per_frame() - 800.0).abs() < 1e-9);
277    }
278
279    #[test]
280    fn test_collector_total_bytes() {
281        let mut c = FrameStatsCollector::new();
282        c.record(make_stat(0, FrameType::I, 1000));
283        c.record(make_stat(1, FrameType::P, 2000));
284        assert_eq!(c.total_bytes(), 3000);
285    }
286
287    #[test]
288    fn test_collector_largest_frame() {
289        let mut c = FrameStatsCollector::new();
290        c.record(make_stat(0, FrameType::I, 100_000));
291        c.record(make_stat(1, FrameType::P, 10_000));
292        let largest = c.largest_frame().expect("should succeed in test");
293        assert_eq!(largest.index, 0);
294    }
295
296    #[test]
297    fn test_collector_avg_qp() {
298        let mut c = FrameStatsCollector::new();
299        let mut f1 = make_stat(0, FrameType::I, 0);
300        f1.qp = 20;
301        let mut f2 = make_stat(1, FrameType::P, 0);
302        f2.qp = 30;
303        c.record(f1);
304        c.record(f2);
305        assert!((c.avg_qp() - 25.0).abs() < 1e-9);
306    }
307}