Skip to main content

oximedia_transcode/
transcode_metrics.rs

1//! Real-time transcoding metrics collection and reporting.
2//!
3//! Provides atomic counters, per-frame metrics, rolling-window statistics,
4//! CSV export, and Prometheus text-format export.
5
6use std::collections::VecDeque;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10// ─── FrameType ────────────────────────────────────────────────────────────────
11
12/// Video frame type.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum FrameType {
15    /// Intra-coded frame — full reference, largest.
16    I,
17    /// Predicted frame — depends on one past reference.
18    P,
19    /// Bi-directionally predicted frame — smallest, best compression.
20    B,
21}
22
23impl FrameType {
24    /// Single-character label for CSV/Prometheus output.
25    #[must_use]
26    pub fn label(self) -> &'static str {
27        match self {
28            Self::I => "I",
29            Self::P => "P",
30            Self::B => "B",
31        }
32    }
33}
34
35// ─── FrameMetric ─────────────────────────────────────────────────────────────
36
37/// Per-frame encoding statistics.
38#[derive(Debug, Clone)]
39pub struct FrameMetric {
40    /// 0-based frame number within the stream.
41    pub frame_number: u64,
42    /// Time spent encoding this frame, in microseconds.
43    pub encode_time_us: u32,
44    /// PSNR for this frame (dB).
45    pub psnr: f32,
46    /// Frame coding type.
47    pub frame_type: FrameType,
48    /// Compressed frame size in bits.
49    pub output_bits: u32,
50}
51
52impl FrameMetric {
53    /// Constructs a new `FrameMetric`.
54    #[must_use]
55    pub fn new(
56        frame_number: u64,
57        encode_time_us: u32,
58        psnr: f32,
59        frame_type: FrameType,
60        output_bits: u32,
61    ) -> Self {
62        Self {
63            frame_number,
64            encode_time_us,
65            psnr,
66            frame_type,
67            output_bits,
68        }
69    }
70
71    /// Returns the output size in bytes (rounded up from bits).
72    #[must_use]
73    pub fn output_bytes(&self) -> u32 {
74        (self.output_bits + 7) / 8
75    }
76
77    /// Returns the instantaneous bitrate given a frame rate.
78    #[must_use]
79    pub fn instant_bitrate_kbps(&self, fps: f32) -> f32 {
80        if fps <= 0.0 {
81            return 0.0;
82        }
83        self.output_bits as f32 * fps / 1000.0
84    }
85}
86
87// ─── TranscodeMetrics ────────────────────────────────────────────────────────
88
89/// Thread-safe atomic counters for a transcoding session.
90#[derive(Debug)]
91pub struct TranscodeMetrics {
92    /// Total frames successfully encoded.
93    pub frames_encoded: AtomicU64,
94    /// Frames that were dropped (not encoded).
95    pub frames_dropped: AtomicU64,
96    /// Total compressed bytes written to output.
97    pub bytes_output: AtomicU64,
98    /// Number of encoding errors encountered.
99    pub encoding_errors: AtomicU64,
100}
101
102impl TranscodeMetrics {
103    /// Creates a new zeroed metrics structure.
104    #[must_use]
105    pub fn new() -> Self {
106        Self {
107            frames_encoded: AtomicU64::new(0),
108            frames_dropped: AtomicU64::new(0),
109            bytes_output: AtomicU64::new(0),
110            encoding_errors: AtomicU64::new(0),
111        }
112    }
113
114    /// Atomically increments the encoded frame counter.
115    pub fn inc_frames_encoded(&self, delta: u64) {
116        self.frames_encoded.fetch_add(delta, Ordering::Relaxed);
117    }
118
119    /// Atomically increments the dropped frame counter.
120    pub fn inc_frames_dropped(&self, delta: u64) {
121        self.frames_dropped.fetch_add(delta, Ordering::Relaxed);
122    }
123
124    /// Atomically adds to the bytes output counter.
125    pub fn add_bytes_output(&self, bytes: u64) {
126        self.bytes_output.fetch_add(bytes, Ordering::Relaxed);
127    }
128
129    /// Atomically increments the error counter.
130    pub fn inc_errors(&self, delta: u64) {
131        self.encoding_errors.fetch_add(delta, Ordering::Relaxed);
132    }
133
134    /// Snapshot of current values (non-atomic-consistent, for reporting).
135    #[must_use]
136    pub fn snapshot(&self) -> MetricsSnapshot {
137        MetricsSnapshot {
138            frames_encoded: self.frames_encoded.load(Ordering::Relaxed),
139            frames_dropped: self.frames_dropped.load(Ordering::Relaxed),
140            bytes_output: self.bytes_output.load(Ordering::Relaxed),
141            encoding_errors: self.encoding_errors.load(Ordering::Relaxed),
142        }
143    }
144}
145
146impl Default for TranscodeMetrics {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152/// A non-atomic snapshot of `TranscodeMetrics` at a point in time.
153#[derive(Debug, Clone)]
154pub struct MetricsSnapshot {
155    /// Frames encoded.
156    pub frames_encoded: u64,
157    /// Frames dropped.
158    pub frames_dropped: u64,
159    /// Bytes written to output.
160    pub bytes_output: u64,
161    /// Encoding errors.
162    pub encoding_errors: u64,
163}
164
165// ─── EncodingRate ─────────────────────────────────────────────────────────────
166
167/// Instantaneous or average encoding throughput.
168#[derive(Debug, Clone)]
169pub struct EncodingRate {
170    /// Encoded frames per second.
171    pub fps: f32,
172    /// Ratio of encoded content time to wall-clock time.
173    /// 1.0 = real-time; > 1.0 = faster than real-time.
174    pub real_time_factor: f32,
175    /// Current output bitrate in kbps.
176    pub instant_bitrate_kbps: u32,
177}
178
179impl EncodingRate {
180    /// Returns `true` if the encoder is keeping up with real-time.
181    #[must_use]
182    pub fn is_realtime(&self) -> bool {
183        self.real_time_factor >= 1.0
184    }
185}
186
187// ─── QualityMetrics ──────────────────────────────────────────────────────────
188
189/// Aggregate quality metrics for a session or window.
190#[derive(Debug, Clone)]
191pub struct QualityMetrics {
192    /// Average PSNR in dB.
193    pub avg_psnr: f32,
194    /// Average SSIM (structural similarity, \[0.0, 1.0\]).
195    pub avg_ssim: f32,
196    /// Average VMAF score (\[0.0, 100.0\]).
197    pub avg_vmaf: f32,
198}
199
200impl QualityMetrics {
201    /// Creates a new zero-initialised quality metrics.
202    #[must_use]
203    pub fn zero() -> Self {
204        Self {
205            avg_psnr: 0.0,
206            avg_ssim: 0.0,
207            avg_vmaf: 0.0,
208        }
209    }
210}
211
212// ─── SessionMetrics ──────────────────────────────────────────────────────────
213
214/// Full metrics for a single transcoding session.
215#[derive(Debug)]
216pub struct SessionMetrics {
217    /// Unique session identifier.
218    pub session_id: u64,
219    /// Wall-clock start time (milliseconds since Unix epoch).
220    pub start_time_ms: i64,
221    /// Current encoding throughput.
222    pub encoding_rate: EncodingRate,
223    /// Current quality measurements.
224    pub quality: QualityMetrics,
225    /// Ring buffer of the last 100 per-frame metrics.
226    pub per_frame_metrics: VecDeque<FrameMetric>,
227}
228
229/// Maximum number of recent per-frame metrics retained.
230const PER_FRAME_WINDOW: usize = 100;
231
232impl SessionMetrics {
233    /// Creates a new session metrics record.
234    #[must_use]
235    pub fn new(session_id: u64, start_time_ms: i64) -> Self {
236        Self {
237            session_id,
238            start_time_ms,
239            encoding_rate: EncodingRate {
240                fps: 0.0,
241                real_time_factor: 0.0,
242                instant_bitrate_kbps: 0,
243            },
244            quality: QualityMetrics::zero(),
245            per_frame_metrics: VecDeque::with_capacity(PER_FRAME_WINDOW),
246        }
247    }
248
249    /// Pushes a frame metric onto the ring buffer; evicts oldest if full.
250    pub fn push_frame(&mut self, metric: FrameMetric) {
251        if self.per_frame_metrics.len() >= PER_FRAME_WINDOW {
252            self.per_frame_metrics.pop_front();
253        }
254        self.per_frame_metrics.push_back(metric);
255    }
256
257    /// Returns the elapsed time since `start_time_ms` in seconds.
258    #[must_use]
259    pub fn elapsed_secs(&self, current_time_ms: i64) -> f64 {
260        let delta = current_time_ms.saturating_sub(self.start_time_ms);
261        delta as f64 / 1000.0
262    }
263}
264
265// ─── MetricAggregator ────────────────────────────────────────────────────────
266
267/// Aggregates per-frame metrics and computes derived statistics.
268#[derive(Debug)]
269pub struct MetricAggregator {
270    session_metrics: SessionMetrics,
271    shared_counters: Arc<TranscodeMetrics>,
272    /// Source frame rate (used for real-time-factor calculation).
273    source_fps: f32,
274}
275
276impl MetricAggregator {
277    /// Creates a new aggregator for `session_id` starting at `start_time_ms`.
278    #[must_use]
279    pub fn new(session_id: u64, start_time_ms: i64, source_fps: f32) -> Self {
280        Self {
281            session_metrics: SessionMetrics::new(session_id, start_time_ms),
282            shared_counters: Arc::new(TranscodeMetrics::new()),
283            source_fps,
284        }
285    }
286
287    /// Returns a shared reference to the atomic counters.
288    #[must_use]
289    pub fn counters(&self) -> Arc<TranscodeMetrics> {
290        Arc::clone(&self.shared_counters)
291    }
292
293    /// Records a new frame metric, updates ring buffer and atomic counters.
294    pub fn update_frame(&mut self, metric: FrameMetric) {
295        let byte_count = metric.output_bytes() as u64;
296        self.shared_counters.inc_frames_encoded(1);
297        self.shared_counters.add_bytes_output(byte_count);
298        self.session_metrics.push_frame(metric);
299    }
300
301    /// Computes current `EncodingRate` given the current wall-clock time.
302    #[must_use]
303    pub fn compute_rate(&self, current_time_ms: i64) -> EncodingRate {
304        let elapsed = self.session_metrics.elapsed_secs(current_time_ms);
305        if elapsed <= 0.0 {
306            return EncodingRate {
307                fps: 0.0,
308                real_time_factor: 0.0,
309                instant_bitrate_kbps: 0,
310            };
311        }
312
313        let frames_encoded = self.shared_counters.frames_encoded.load(Ordering::Relaxed);
314        let bytes_output = self.shared_counters.bytes_output.load(Ordering::Relaxed);
315
316        let fps = frames_encoded as f32 / elapsed as f32;
317        let rtf = if self.source_fps > 0.0 {
318            fps / self.source_fps
319        } else {
320            0.0
321        };
322
323        let bitrate_kbps = if elapsed > 0.0 {
324            (bytes_output as f64 * 8.0 / elapsed / 1000.0) as u32
325        } else {
326            0
327        };
328
329        EncodingRate {
330            fps,
331            real_time_factor: rtf,
332            instant_bitrate_kbps: bitrate_kbps,
333        }
334    }
335
336    /// Computes the rolling average PSNR over the last `window` frame metrics.
337    ///
338    /// Clamps `window` to the available ring-buffer size.
339    #[must_use]
340    pub fn rolling_avg_psnr(&self, window: usize) -> f32 {
341        let buf = &self.session_metrics.per_frame_metrics;
342        if buf.is_empty() {
343            return 0.0;
344        }
345        let effective_window = window.min(buf.len());
346        let start = buf.len() - effective_window;
347        let sum: f32 = buf.iter().skip(start).map(|m| m.psnr).sum();
348        sum / effective_window as f32
349    }
350
351    /// Exports all per-frame metrics in CSV format.
352    ///
353    /// Columns: `frame_number,frame_type,encode_time_us,output_bits,psnr`
354    #[must_use]
355    pub fn export_csv(&self) -> String {
356        let mut out = String::from("frame_number,frame_type,encode_time_us,output_bits,psnr\n");
357        for m in &self.session_metrics.per_frame_metrics {
358            out.push_str(&format!(
359                "{},{},{},{},{:.4}\n",
360                m.frame_number,
361                m.frame_type.label(),
362                m.encode_time_us,
363                m.output_bits,
364                m.psnr,
365            ));
366        }
367        out
368    }
369
370    /// Exports current session metrics in Prometheus text exposition format.
371    ///
372    /// All metric names are prefixed `oximedia_transcode_`.
373    #[must_use]
374    pub fn to_prometheus(&self, session_id: u64) -> String {
375        let snapshot = self.shared_counters.snapshot();
376        let rate = self.compute_rate(self.session_metrics.start_time_ms); // rate from session start
377
378        let mut buf = String::new();
379
380        buf.push_str(
381            "# HELP oximedia_transcode_frames_encoded Total frames successfully encoded\n",
382        );
383        buf.push_str("# TYPE oximedia_transcode_frames_encoded counter\n");
384        buf.push_str(&format!(
385            "oximedia_transcode_frames_encoded{{session=\"{session_id}\"}} {}\n",
386            snapshot.frames_encoded
387        ));
388
389        buf.push_str("# HELP oximedia_transcode_frames_dropped Total frames dropped\n");
390        buf.push_str("# TYPE oximedia_transcode_frames_dropped counter\n");
391        buf.push_str(&format!(
392            "oximedia_transcode_frames_dropped{{session=\"{session_id}\"}} {}\n",
393            snapshot.frames_dropped
394        ));
395
396        buf.push_str("# HELP oximedia_transcode_bytes_output Total compressed bytes written\n");
397        buf.push_str("# TYPE oximedia_transcode_bytes_output counter\n");
398        buf.push_str(&format!(
399            "oximedia_transcode_bytes_output{{session=\"{session_id}\"}} {}\n",
400            snapshot.bytes_output
401        ));
402
403        buf.push_str("# HELP oximedia_transcode_encoding_errors Total encoding errors\n");
404        buf.push_str("# TYPE oximedia_transcode_encoding_errors counter\n");
405        buf.push_str(&format!(
406            "oximedia_transcode_encoding_errors{{session=\"{session_id}\"}} {}\n",
407            snapshot.encoding_errors
408        ));
409
410        buf.push_str("# HELP oximedia_transcode_fps Current encoding frames per second\n");
411        buf.push_str("# TYPE oximedia_transcode_fps gauge\n");
412        buf.push_str(&format!(
413            "oximedia_transcode_fps{{session=\"{session_id}\"}} {:.3}\n",
414            rate.fps
415        ));
416
417        buf.push_str(
418            "# HELP oximedia_transcode_real_time_factor Encoding speed relative to real-time\n",
419        );
420        buf.push_str("# TYPE oximedia_transcode_real_time_factor gauge\n");
421        buf.push_str(&format!(
422            "oximedia_transcode_real_time_factor{{session=\"{session_id}\"}} {:.4}\n",
423            rate.real_time_factor
424        ));
425
426        buf.push_str(
427            "# HELP oximedia_transcode_bitrate_kbps Instantaneous output bitrate in kbps\n",
428        );
429        buf.push_str("# TYPE oximedia_transcode_bitrate_kbps gauge\n");
430        buf.push_str(&format!(
431            "oximedia_transcode_bitrate_kbps{{session=\"{session_id}\"}} {}\n",
432            rate.instant_bitrate_kbps
433        ));
434
435        let avg_psnr = self.rolling_avg_psnr(PER_FRAME_WINDOW);
436        buf.push_str(
437            "# HELP oximedia_transcode_avg_psnr Rolling average PSNR over last 100 frames\n",
438        );
439        buf.push_str("# TYPE oximedia_transcode_avg_psnr gauge\n");
440        buf.push_str(&format!(
441            "oximedia_transcode_avg_psnr{{session=\"{session_id}\"}} {:.4}\n",
442            avg_psnr
443        ));
444
445        buf
446    }
447
448    /// Returns a reference to the session metrics.
449    #[must_use]
450    pub fn session(&self) -> &SessionMetrics {
451        &self.session_metrics
452    }
453}
454
455// ─── Legacy API compatibility types ──────────────────────────────────────────
456// Kept for modules that import the old types; new code should use the above.
457
458/// Per-frame encoding metric captured during transcoding (legacy).
459#[derive(Debug, Clone)]
460pub struct LegacyFrameMetric {
461    /// Frame index (0-based).
462    pub frame_index: u64,
463    /// Encode time for this frame in microseconds.
464    pub encode_us: u64,
465    /// Compressed size of this frame in bytes.
466    pub compressed_bytes: u64,
467    /// PSNR value for this frame (dB), if computed.
468    pub psnr_db: Option<f64>,
469}
470
471impl LegacyFrameMetric {
472    /// Creates a new frame metric.
473    #[must_use]
474    pub fn new(frame_index: u64, encode_us: u64, compressed_bytes: u64) -> Self {
475        Self {
476            frame_index,
477            encode_us,
478            compressed_bytes,
479            psnr_db: None,
480        }
481    }
482
483    /// Attaches a PSNR measurement.
484    #[must_use]
485    pub fn with_psnr(mut self, psnr_db: f64) -> Self {
486        self.psnr_db = Some(psnr_db);
487        self
488    }
489
490    /// Returns the instantaneous bitrate for this frame given a frame rate.
491    #[must_use]
492    pub fn instantaneous_bitrate_bps(&self, fps: f64) -> f64 {
493        self.compressed_bytes as f64 * 8.0 * fps
494    }
495}
496
497/// Summary statistics over a collection of frame metrics (legacy).
498#[derive(Debug, Clone)]
499pub struct MetricsSummary {
500    /// Total number of frames.
501    pub frame_count: u64,
502    /// Mean encode time per frame in microseconds.
503    pub mean_encode_us: f64,
504    /// Peak encode time in microseconds.
505    pub peak_encode_us: u64,
506    /// Total compressed bytes.
507    pub total_bytes: u64,
508    /// Mean PSNR in dB (None if not measured).
509    pub mean_psnr_db: Option<f64>,
510    /// Minimum PSNR in dB (None if not measured).
511    pub min_psnr_db: Option<f64>,
512}
513
514impl MetricsSummary {
515    /// Returns the mean bitrate in bits-per-second given input fps.
516    #[must_use]
517    pub fn mean_bitrate_bps(&self, fps: f64) -> f64 {
518        if self.frame_count == 0 || fps <= 0.0 {
519            return 0.0;
520        }
521        let total_bits = self.total_bytes as f64 * 8.0;
522        let duration_secs = self.frame_count as f64 / fps;
523        total_bits / duration_secs
524    }
525
526    /// Returns the encode throughput in frames per second.
527    #[must_use]
528    pub fn encode_fps(&self) -> f64 {
529        if self.mean_encode_us <= 0.0 {
530            return 0.0;
531        }
532        1_000_000.0 / self.mean_encode_us
533    }
534}
535
536/// Collects frame-level metrics during a transcode session (legacy).
537#[derive(Debug, Default)]
538pub struct TranscodeMetricsCollector {
539    metrics: Vec<LegacyFrameMetric>,
540}
541
542impl TranscodeMetricsCollector {
543    /// Creates a new, empty collector.
544    #[must_use]
545    pub fn new() -> Self {
546        Self::default()
547    }
548
549    /// Creates a collector with pre-allocated capacity.
550    #[must_use]
551    pub fn with_capacity(cap: usize) -> Self {
552        Self {
553            metrics: Vec::with_capacity(cap),
554        }
555    }
556
557    /// Records a frame metric.
558    pub fn record(&mut self, metric: LegacyFrameMetric) {
559        self.metrics.push(metric);
560    }
561
562    /// Returns the number of recorded frame metrics.
563    #[must_use]
564    pub fn frame_count(&self) -> usize {
565        self.metrics.len()
566    }
567
568    /// Returns `true` if no metrics have been recorded.
569    #[must_use]
570    pub fn is_empty(&self) -> bool {
571        self.metrics.is_empty()
572    }
573
574    /// Computes and returns a summary over all recorded metrics.
575    pub fn summarise(&self) -> MetricsSummary {
576        let count = self.metrics.len() as u64;
577        if count == 0 {
578            return MetricsSummary {
579                frame_count: 0,
580                mean_encode_us: 0.0,
581                peak_encode_us: 0,
582                total_bytes: 0,
583                mean_psnr_db: None,
584                min_psnr_db: None,
585            };
586        }
587
588        let total_encode_us: u64 = self.metrics.iter().map(|m| m.encode_us).sum();
589        let peak_encode_us = self.metrics.iter().map(|m| m.encode_us).max().unwrap_or(0);
590        let total_bytes: u64 = self.metrics.iter().map(|m| m.compressed_bytes).sum();
591
592        let psnr_values: Vec<f64> = self.metrics.iter().filter_map(|m| m.psnr_db).collect();
593
594        let mean_psnr_db = if psnr_values.is_empty() {
595            None
596        } else {
597            Some(psnr_values.iter().sum::<f64>() / psnr_values.len() as f64)
598        };
599
600        let min_psnr_db = psnr_values.iter().copied().reduce(f64::min);
601
602        MetricsSummary {
603            frame_count: count,
604            mean_encode_us: total_encode_us as f64 / count as f64,
605            peak_encode_us,
606            total_bytes,
607            mean_psnr_db,
608            min_psnr_db,
609        }
610    }
611
612    /// Returns the worst (lowest) PSNR frame, if PSNR data is available.
613    #[must_use]
614    pub fn worst_psnr_frame(&self) -> Option<&LegacyFrameMetric> {
615        self.metrics
616            .iter()
617            .filter(|m| m.psnr_db.is_some())
618            .min_by(|a, b| {
619                let pa = a.psnr_db.expect("filter ensures Some");
620                let pb = b.psnr_db.expect("filter ensures Some");
621                pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
622            })
623    }
624
625    /// Returns the slowest (highest encode time) frame metric.
626    #[must_use]
627    pub fn slowest_frame(&self) -> Option<&LegacyFrameMetric> {
628        self.metrics.iter().max_by_key(|m| m.encode_us)
629    }
630
631    /// Clears all recorded metrics.
632    pub fn clear(&mut self) {
633        self.metrics.clear();
634    }
635}
636
637// ─── Tests ────────────────────────────────────────────────────────────────────
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    fn make_frame(n: u64, enc_us: u32, psnr: f32, ftype: FrameType, bits: u32) -> FrameMetric {
644        FrameMetric::new(n, enc_us, psnr, ftype, bits)
645    }
646
647    // ── FrameType ─────────────────────────────────────────────────────────────
648
649    #[test]
650    fn test_frame_type_labels() {
651        assert_eq!(FrameType::I.label(), "I");
652        assert_eq!(FrameType::P.label(), "P");
653        assert_eq!(FrameType::B.label(), "B");
654    }
655
656    #[test]
657    fn test_frame_metric_output_bytes() {
658        let m = make_frame(0, 1000, 42.0, FrameType::I, 800); // 800 bits = 100 bytes
659        assert_eq!(m.output_bytes(), 100);
660    }
661
662    #[test]
663    fn test_frame_metric_output_bytes_partial() {
664        // 801 bits → 101 bytes (ceiling)
665        let m = make_frame(0, 1000, 42.0, FrameType::P, 801);
666        assert_eq!(m.output_bytes(), 101);
667    }
668
669    #[test]
670    fn test_frame_metric_instant_bitrate() {
671        // 8000 bits at 30 fps = 240 kbps
672        let m = make_frame(0, 5000, 40.0, FrameType::I, 8000);
673        let kbps = m.instant_bitrate_kbps(30.0);
674        assert!((kbps - 240.0).abs() < 0.01, "kbps={kbps}");
675    }
676
677    #[test]
678    fn test_frame_metric_zero_fps() {
679        let m = make_frame(0, 5000, 40.0, FrameType::I, 8000);
680        assert_eq!(m.instant_bitrate_kbps(0.0), 0.0);
681    }
682
683    // ── TranscodeMetrics (atomic) ──────────────────────────────────────────────
684
685    #[test]
686    fn test_atomic_counters_start_at_zero() {
687        let m = TranscodeMetrics::new();
688        let s = m.snapshot();
689        assert_eq!(s.frames_encoded, 0);
690        assert_eq!(s.frames_dropped, 0);
691        assert_eq!(s.bytes_output, 0);
692        assert_eq!(s.encoding_errors, 0);
693    }
694
695    #[test]
696    fn test_atomic_counters_increment() {
697        let m = TranscodeMetrics::new();
698        m.inc_frames_encoded(5);
699        m.inc_frames_dropped(2);
700        m.add_bytes_output(1024);
701        m.inc_errors(1);
702        let s = m.snapshot();
703        assert_eq!(s.frames_encoded, 5);
704        assert_eq!(s.frames_dropped, 2);
705        assert_eq!(s.bytes_output, 1024);
706        assert_eq!(s.encoding_errors, 1);
707    }
708
709    // ── MetricAggregator ─────────────────────────────────────────────────────
710
711    #[test]
712    fn test_aggregator_update_frame_increments_counters() {
713        let mut agg = MetricAggregator::new(1, 0, 30.0);
714        agg.update_frame(make_frame(0, 5000, 42.0, FrameType::I, 8000));
715        agg.update_frame(make_frame(1, 4000, 41.0, FrameType::P, 4000));
716        let s = agg.counters().snapshot();
717        assert_eq!(s.frames_encoded, 2);
718        assert_eq!(s.bytes_output, 1000 + 500); // 8000/8=1000, 4000/8=500
719    }
720
721    #[test]
722    fn test_aggregator_rolling_avg_psnr_empty() {
723        let agg = MetricAggregator::new(1, 0, 30.0);
724        assert_eq!(agg.rolling_avg_psnr(10), 0.0);
725    }
726
727    #[test]
728    fn test_aggregator_rolling_avg_psnr_basic() {
729        let mut agg = MetricAggregator::new(1, 0, 30.0);
730        agg.update_frame(make_frame(0, 1000, 40.0, FrameType::I, 8000));
731        agg.update_frame(make_frame(1, 1000, 44.0, FrameType::P, 4000));
732        let avg = agg.rolling_avg_psnr(2);
733        assert!((avg - 42.0).abs() < 0.01, "avg={avg}");
734    }
735
736    #[test]
737    fn test_aggregator_rolling_avg_psnr_window_clamp() {
738        let mut agg = MetricAggregator::new(1, 0, 30.0);
739        agg.update_frame(make_frame(0, 1000, 50.0, FrameType::I, 8000));
740        // request window=100 but only 1 frame present
741        let avg = agg.rolling_avg_psnr(100);
742        assert!((avg - 50.0).abs() < 0.01);
743    }
744
745    #[test]
746    fn test_aggregator_ring_buffer_eviction() {
747        let mut agg = MetricAggregator::new(1, 0, 30.0);
748        for i in 0..=100_u64 {
749            agg.update_frame(make_frame(i, 1000, i as f32, FrameType::P, 1000));
750        }
751        // Ring buffer should hold at most 100 entries
752        assert_eq!(agg.session().per_frame_metrics.len(), 100);
753    }
754
755    #[test]
756    fn test_aggregator_compute_rate_zero_elapsed() {
757        let agg = MetricAggregator::new(1, 1000, 30.0);
758        let rate = agg.compute_rate(1000); // same time → 0 elapsed
759        assert_eq!(rate.fps, 0.0);
760    }
761
762    #[test]
763    fn test_aggregator_compute_rate_basic() {
764        let mut agg = MetricAggregator::new(1, 0, 30.0);
765        // Encode 30 frames
766        for i in 0..30 {
767            agg.update_frame(make_frame(i, 1000, 42.0, FrameType::P, 8000));
768        }
769        // After 1 second
770        let rate = agg.compute_rate(1000);
771        assert!((rate.fps - 30.0).abs() < 0.01, "fps={}", rate.fps);
772        assert!((rate.real_time_factor - 1.0).abs() < 0.01);
773    }
774
775    #[test]
776    fn test_export_csv_header() {
777        let agg = MetricAggregator::new(1, 0, 30.0);
778        let csv = agg.export_csv();
779        assert!(csv.starts_with("frame_number,frame_type,encode_time_us,output_bits,psnr"));
780    }
781
782    #[test]
783    fn test_export_csv_rows() {
784        let mut agg = MetricAggregator::new(1, 0, 30.0);
785        agg.update_frame(make_frame(0, 5000, 42.5, FrameType::I, 8000));
786        agg.update_frame(make_frame(1, 3000, 40.0, FrameType::B, 2000));
787        let csv = agg.export_csv();
788        let lines: Vec<&str> = csv.lines().collect();
789        assert_eq!(lines.len(), 3); // header + 2 data rows
790        assert!(lines[1].starts_with("0,I,"));
791        assert!(lines[2].starts_with("1,B,"));
792    }
793
794    #[test]
795    fn test_prometheus_export_contains_required_metrics() {
796        let mut agg = MetricAggregator::new(42, 0, 30.0);
797        agg.update_frame(make_frame(0, 5000, 42.0, FrameType::I, 8000));
798        let prom = agg.to_prometheus(42);
799        assert!(prom.contains("oximedia_transcode_frames_encoded"));
800        assert!(prom.contains("oximedia_transcode_frames_dropped"));
801        assert!(prom.contains("oximedia_transcode_bytes_output"));
802        assert!(prom.contains("oximedia_transcode_encoding_errors"));
803        assert!(prom.contains("oximedia_transcode_fps"));
804        assert!(prom.contains("oximedia_transcode_real_time_factor"));
805        assert!(prom.contains("oximedia_transcode_bitrate_kbps"));
806        assert!(prom.contains("oximedia_transcode_avg_psnr"));
807    }
808
809    #[test]
810    fn test_prometheus_export_session_label() {
811        let agg = MetricAggregator::new(99, 0, 30.0);
812        let prom = agg.to_prometheus(99);
813        assert!(prom.contains("session=\"99\""));
814    }
815
816    #[test]
817    fn test_encoding_rate_is_realtime() {
818        let fast = EncodingRate {
819            fps: 60.0,
820            real_time_factor: 2.0,
821            instant_bitrate_kbps: 5000,
822        };
823        let slow = EncodingRate {
824            fps: 10.0,
825            real_time_factor: 0.5,
826            instant_bitrate_kbps: 1000,
827        };
828        assert!(fast.is_realtime());
829        assert!(!slow.is_realtime());
830    }
831
832    #[test]
833    fn test_quality_metrics_zero() {
834        let q = QualityMetrics::zero();
835        assert_eq!(q.avg_psnr, 0.0);
836        assert_eq!(q.avg_ssim, 0.0);
837        assert_eq!(q.avg_vmaf, 0.0);
838    }
839
840    // ── Legacy API ────────────────────────────────────────────────────────────
841
842    #[test]
843    fn test_legacy_collector_record_and_summarise() {
844        let mut c = TranscodeMetricsCollector::new();
845        c.record(LegacyFrameMetric::new(0, 1000, 400));
846        c.record(LegacyFrameMetric::new(1, 3000, 600));
847        let s = c.summarise();
848        assert_eq!(s.frame_count, 2);
849        assert_eq!(s.total_bytes, 1000);
850        assert!((s.mean_encode_us - 2000.0).abs() < 1e-6);
851    }
852
853    #[test]
854    fn test_legacy_worst_psnr() {
855        let mut c = TranscodeMetricsCollector::new();
856        c.record(LegacyFrameMetric::new(0, 100, 100).with_psnr(45.0));
857        c.record(LegacyFrameMetric::new(1, 100, 100).with_psnr(35.0));
858        let worst = c.worst_psnr_frame().expect("should exist");
859        assert_eq!(worst.frame_index, 1);
860    }
861
862    #[test]
863    fn test_legacy_clear() {
864        let mut c = TranscodeMetricsCollector::new();
865        c.record(LegacyFrameMetric::new(0, 100, 100));
866        c.clear();
867        assert!(c.is_empty());
868    }
869}