1use std::collections::VecDeque;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum FrameType {
15 I,
17 P,
19 B,
21}
22
23impl FrameType {
24 #[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#[derive(Debug, Clone)]
39pub struct FrameMetric {
40 pub frame_number: u64,
42 pub encode_time_us: u32,
44 pub psnr: f32,
46 pub frame_type: FrameType,
48 pub output_bits: u32,
50}
51
52impl FrameMetric {
53 #[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 #[must_use]
73 pub fn output_bytes(&self) -> u32 {
74 (self.output_bits + 7) / 8
75 }
76
77 #[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#[derive(Debug)]
91pub struct TranscodeMetrics {
92 pub frames_encoded: AtomicU64,
94 pub frames_dropped: AtomicU64,
96 pub bytes_output: AtomicU64,
98 pub encoding_errors: AtomicU64,
100}
101
102impl TranscodeMetrics {
103 #[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 pub fn inc_frames_encoded(&self, delta: u64) {
116 self.frames_encoded.fetch_add(delta, Ordering::Relaxed);
117 }
118
119 pub fn inc_frames_dropped(&self, delta: u64) {
121 self.frames_dropped.fetch_add(delta, Ordering::Relaxed);
122 }
123
124 pub fn add_bytes_output(&self, bytes: u64) {
126 self.bytes_output.fetch_add(bytes, Ordering::Relaxed);
127 }
128
129 pub fn inc_errors(&self, delta: u64) {
131 self.encoding_errors.fetch_add(delta, Ordering::Relaxed);
132 }
133
134 #[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#[derive(Debug, Clone)]
154pub struct MetricsSnapshot {
155 pub frames_encoded: u64,
157 pub frames_dropped: u64,
159 pub bytes_output: u64,
161 pub encoding_errors: u64,
163}
164
165#[derive(Debug, Clone)]
169pub struct EncodingRate {
170 pub fps: f32,
172 pub real_time_factor: f32,
175 pub instant_bitrate_kbps: u32,
177}
178
179impl EncodingRate {
180 #[must_use]
182 pub fn is_realtime(&self) -> bool {
183 self.real_time_factor >= 1.0
184 }
185}
186
187#[derive(Debug, Clone)]
191pub struct QualityMetrics {
192 pub avg_psnr: f32,
194 pub avg_ssim: f32,
196 pub avg_vmaf: f32,
198}
199
200impl QualityMetrics {
201 #[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#[derive(Debug)]
216pub struct SessionMetrics {
217 pub session_id: u64,
219 pub start_time_ms: i64,
221 pub encoding_rate: EncodingRate,
223 pub quality: QualityMetrics,
225 pub per_frame_metrics: VecDeque<FrameMetric>,
227}
228
229const PER_FRAME_WINDOW: usize = 100;
231
232impl SessionMetrics {
233 #[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 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 #[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#[derive(Debug)]
269pub struct MetricAggregator {
270 session_metrics: SessionMetrics,
271 shared_counters: Arc<TranscodeMetrics>,
272 source_fps: f32,
274}
275
276impl MetricAggregator {
277 #[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 #[must_use]
289 pub fn counters(&self) -> Arc<TranscodeMetrics> {
290 Arc::clone(&self.shared_counters)
291 }
292
293 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 #[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 #[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 #[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 #[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); 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 #[must_use]
450 pub fn session(&self) -> &SessionMetrics {
451 &self.session_metrics
452 }
453}
454
455#[derive(Debug, Clone)]
460pub struct LegacyFrameMetric {
461 pub frame_index: u64,
463 pub encode_us: u64,
465 pub compressed_bytes: u64,
467 pub psnr_db: Option<f64>,
469}
470
471impl LegacyFrameMetric {
472 #[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 #[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 #[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#[derive(Debug, Clone)]
499pub struct MetricsSummary {
500 pub frame_count: u64,
502 pub mean_encode_us: f64,
504 pub peak_encode_us: u64,
506 pub total_bytes: u64,
508 pub mean_psnr_db: Option<f64>,
510 pub min_psnr_db: Option<f64>,
512}
513
514impl MetricsSummary {
515 #[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 #[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#[derive(Debug, Default)]
538pub struct TranscodeMetricsCollector {
539 metrics: Vec<LegacyFrameMetric>,
540}
541
542impl TranscodeMetricsCollector {
543 #[must_use]
545 pub fn new() -> Self {
546 Self::default()
547 }
548
549 #[must_use]
551 pub fn with_capacity(cap: usize) -> Self {
552 Self {
553 metrics: Vec::with_capacity(cap),
554 }
555 }
556
557 pub fn record(&mut self, metric: LegacyFrameMetric) {
559 self.metrics.push(metric);
560 }
561
562 #[must_use]
564 pub fn frame_count(&self) -> usize {
565 self.metrics.len()
566 }
567
568 #[must_use]
570 pub fn is_empty(&self) -> bool {
571 self.metrics.is_empty()
572 }
573
574 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 #[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 #[must_use]
627 pub fn slowest_frame(&self) -> Option<&LegacyFrameMetric> {
628 self.metrics.iter().max_by_key(|m| m.encode_us)
629 }
630
631 pub fn clear(&mut self) {
633 self.metrics.clear();
634 }
635}
636
637#[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 #[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); assert_eq!(m.output_bytes(), 100);
660 }
661
662 #[test]
663 fn test_frame_metric_output_bytes_partial() {
664 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 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 #[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 #[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); }
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 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 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); 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 for i in 0..30 {
767 agg.update_frame(make_frame(i, 1000, 42.0, FrameType::P, 8000));
768 }
769 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); 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 #[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}