Skip to main content

oximedia_transcode/
benchmark.rs

1//! TranscodeBenchmark — compare speed and quality metrics across codec configurations.
2//!
3//! `TranscodeBenchmark` measures and stores encoding metrics (time, bitrate,
4//! quality estimations) for multiple `BenchmarkCandidate` configurations, then
5//! produces ranked comparison reports.
6
7#![allow(dead_code)]
8#![allow(clippy::cast_precision_loss)]
9
10use std::time::{Duration, Instant};
11
12use crate::{Result, TranscodeError};
13
14// ─── Quality metrics ──────────────────────────────────────────────────────────
15
16/// Quality and performance metrics from a single encode run.
17#[derive(Debug, Clone)]
18pub struct EncodeMetrics {
19    /// Candidate name.
20    pub name: String,
21    /// Wall-clock encoding time.
22    pub encode_time: Duration,
23    /// Encoded file size in bytes.
24    pub file_size_bytes: u64,
25    /// Average bitrate in kbps (computed from size and duration).
26    pub bitrate_kbps: f64,
27    /// Content duration in seconds.
28    pub duration_secs: f64,
29    /// Encoding speed factor (content_duration / encode_time).
30    pub speed_factor: f64,
31    /// Peak signal-to-noise ratio (dB) — `None` if not measured.
32    pub psnr_db: Option<f64>,
33    /// Structural similarity (0.0–1.0) — `None` if not measured.
34    pub ssim: Option<f64>,
35    /// Video Multi-Method Assessment Fusion score — `None` if not measured.
36    pub vmaf: Option<f64>,
37}
38
39impl EncodeMetrics {
40    /// Creates a new `EncodeMetrics` from timing / size data alone.
41    #[must_use]
42    pub fn new(
43        name: impl Into<String>,
44        encode_time: Duration,
45        file_size_bytes: u64,
46        duration_secs: f64,
47    ) -> Self {
48        let encode_secs = encode_time.as_secs_f64();
49        let speed_factor = if encode_secs > 0.0 {
50            duration_secs / encode_secs
51        } else {
52            f64::INFINITY
53        };
54        let bitrate_kbps = if duration_secs > 0.0 {
55            file_size_bytes as f64 * 8.0 / 1_000.0 / duration_secs
56        } else {
57            0.0
58        };
59        Self {
60            name: name.into(),
61            encode_time,
62            file_size_bytes,
63            bitrate_kbps,
64            duration_secs,
65            speed_factor,
66            psnr_db: None,
67            ssim: None,
68            vmaf: None,
69        }
70    }
71
72    /// Attaches PSNR.
73    #[must_use]
74    pub fn with_psnr(mut self, psnr: f64) -> Self {
75        self.psnr_db = Some(psnr);
76        self
77    }
78
79    /// Attaches SSIM.
80    #[must_use]
81    pub fn with_ssim(mut self, ssim: f64) -> Self {
82        self.ssim = Some(ssim);
83        self
84    }
85
86    /// Attaches VMAF.
87    #[must_use]
88    pub fn with_vmaf(mut self, vmaf: f64) -> Self {
89        self.vmaf = Some(vmaf);
90        self
91    }
92
93    /// Returns a "BD-Rate proxy" — bits-per-pixel-per-frame.
94    ///
95    /// Lower is more efficient.
96    #[must_use]
97    pub fn bits_per_pixel_per_frame(
98        &self,
99        width: u32,
100        height: u32,
101        fps_num: u32,
102        fps_den: u32,
103    ) -> f64 {
104        let pixels_per_frame = u64::from(width) * u64::from(height);
105        let total_frames = if fps_den > 0 && fps_num > 0 {
106            self.duration_secs * f64::from(fps_num) / f64::from(fps_den)
107        } else {
108            1.0
109        };
110        if pixels_per_frame == 0 || total_frames <= 0.0 {
111            return f64::INFINITY;
112        }
113        let total_bits = self.file_size_bytes as f64 * 8.0;
114        total_bits / (pixels_per_frame as f64 * total_frames)
115    }
116}
117
118// ─── BenchmarkCandidate ───────────────────────────────────────────────────────
119
120/// A codec configuration to be benchmarked.
121#[derive(Debug, Clone)]
122pub struct BenchmarkCandidate {
123    /// Human-readable name.
124    pub name: String,
125    /// Codec name (e.g. `"av1"`, `"vp9"`, `"h264"`).
126    pub codec: String,
127    /// Encoder preset / speed (e.g. `"medium"`, `"5"`, `"slow"`).
128    pub preset: String,
129    /// CRF value.
130    pub crf: u8,
131    /// Additional codec-specific parameters (key → value).
132    pub extra_params: Vec<(String, String)>,
133}
134
135impl BenchmarkCandidate {
136    /// Creates a new candidate.
137    #[must_use]
138    pub fn new(
139        name: impl Into<String>,
140        codec: impl Into<String>,
141        preset: impl Into<String>,
142        crf: u8,
143    ) -> Self {
144        Self {
145            name: name.into(),
146            codec: codec.into(),
147            preset: preset.into(),
148            crf,
149            extra_params: Vec::new(),
150        }
151    }
152
153    /// Adds an extra codec parameter.
154    #[must_use]
155    pub fn param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
156        self.extra_params.push((key.into(), value.into()));
157        self
158    }
159}
160
161// ─── BenchmarkResult ──────────────────────────────────────────────────────────
162
163/// Outcome of a single benchmark run.
164#[derive(Debug, Clone)]
165pub struct BenchmarkResult {
166    /// The candidate that was benchmarked.
167    pub candidate: BenchmarkCandidate,
168    /// Measured metrics.
169    pub metrics: EncodeMetrics,
170}
171
172impl BenchmarkResult {
173    /// Returns a composite quality score combining PSNR, SSIM, and speed.
174    ///
175    /// The score is on an arbitrary scale; higher = better.
176    /// Weights: PSNR 40 %, SSIM 40 %, speed 20 %.
177    #[must_use]
178    pub fn composite_score(&self) -> f64 {
179        let psnr_component = self
180            .metrics
181            .psnr_db
182            .map(|p| (p - 30.0).max(0.0) / 20.0)
183            .unwrap_or(0.5);
184
185        let ssim_component = self.metrics.ssim.unwrap_or(0.9);
186
187        // Normalise speed: 1× = 0.5, 2× = 0.75, 4× = 1.0 (capped)
188        let speed_component = (self.metrics.speed_factor / 4.0).min(1.0);
189
190        0.4 * psnr_component + 0.4 * ssim_component + 0.2 * speed_component
191    }
192}
193
194// ─── TranscodeBenchmark ───────────────────────────────────────────────────────
195
196/// Utility for benchmarking and comparing multiple codec configurations.
197///
198/// `TranscodeBenchmark` accumulates `BenchmarkResult`s and provides sorting /
199/// reporting helpers.  The actual encoding is driven by the caller; this type
200/// manages the result lifecycle and report generation.
201pub struct TranscodeBenchmark {
202    /// All recorded results.
203    results: Vec<BenchmarkResult>,
204    /// Content duration used as the reference.
205    content_duration_secs: f64,
206}
207
208impl TranscodeBenchmark {
209    /// Creates a new benchmark for content with the given duration.
210    #[must_use]
211    pub fn new(content_duration_secs: f64) -> Self {
212        Self {
213            results: Vec::new(),
214            content_duration_secs,
215        }
216    }
217
218    /// Starts timing for a candidate.  Returns a [`BenchmarkTimer`] that records
219    /// the elapsed time when dropped or when [`BenchmarkTimer::finish`] is called.
220    #[must_use]
221    pub fn start_timing(&self) -> BenchmarkTimer {
222        BenchmarkTimer {
223            start: Instant::now(),
224        }
225    }
226
227    /// Records a result from an already-completed encode.
228    pub fn record_result(&mut self, result: BenchmarkResult) {
229        self.results.push(result);
230    }
231
232    /// Convenience: build a `BenchmarkResult` from timing and file-size data and
233    /// record it.
234    pub fn record(
235        &mut self,
236        candidate: BenchmarkCandidate,
237        elapsed: Duration,
238        file_size_bytes: u64,
239        psnr: Option<f64>,
240        ssim: Option<f64>,
241    ) {
242        let mut metrics = EncodeMetrics::new(
243            &candidate.name,
244            elapsed,
245            file_size_bytes,
246            self.content_duration_secs,
247        );
248        if let Some(p) = psnr {
249            metrics = metrics.with_psnr(p);
250        }
251        if let Some(s) = ssim {
252            metrics = metrics.with_ssim(s);
253        }
254        self.results.push(BenchmarkResult { candidate, metrics });
255    }
256
257    /// Returns the number of recorded results.
258    #[must_use]
259    pub fn result_count(&self) -> usize {
260        self.results.len()
261    }
262
263    /// Returns results sorted by encoding speed (fastest first).
264    #[must_use]
265    pub fn by_speed(&self) -> Vec<&BenchmarkResult> {
266        let mut sorted: Vec<&BenchmarkResult> = self.results.iter().collect();
267        sorted.sort_by(|a, b| {
268            b.metrics
269                .speed_factor
270                .partial_cmp(&a.metrics.speed_factor)
271                .unwrap_or(std::cmp::Ordering::Equal)
272        });
273        sorted
274    }
275
276    /// Returns results sorted by file size (smallest first).
277    #[must_use]
278    pub fn by_file_size(&self) -> Vec<&BenchmarkResult> {
279        let mut sorted: Vec<&BenchmarkResult> = self.results.iter().collect();
280        sorted.sort_by_key(|r| r.metrics.file_size_bytes);
281        sorted
282    }
283
284    /// Returns results sorted by PSNR (highest first).
285    ///
286    /// Results without PSNR data are sorted to the end.
287    #[must_use]
288    pub fn by_psnr(&self) -> Vec<&BenchmarkResult> {
289        let mut sorted: Vec<&BenchmarkResult> = self.results.iter().collect();
290        sorted.sort_by(|a, b| {
291            let pa = a.metrics.psnr_db.unwrap_or(f64::NEG_INFINITY);
292            let pb = b.metrics.psnr_db.unwrap_or(f64::NEG_INFINITY);
293            pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal)
294        });
295        sorted
296    }
297
298    /// Returns results sorted by composite score (best first).
299    #[must_use]
300    pub fn by_composite_score(&self) -> Vec<&BenchmarkResult> {
301        let mut sorted: Vec<&BenchmarkResult> = self.results.iter().collect();
302        sorted.sort_by(|a, b| {
303            b.composite_score()
304                .partial_cmp(&a.composite_score())
305                .unwrap_or(std::cmp::Ordering::Equal)
306        });
307        sorted
308    }
309
310    /// Returns the result with the best composite score, if any.
311    #[must_use]
312    pub fn best(&self) -> Option<&BenchmarkResult> {
313        self.by_composite_score().into_iter().next()
314    }
315
316    /// Generates a human-readable Markdown report table.
317    ///
318    /// # Errors
319    ///
320    /// Returns an error if there are no results to report.
321    pub fn report(&self) -> Result<String> {
322        if self.results.is_empty() {
323            return Err(TranscodeError::PipelineError(
324                "No benchmark results to report".into(),
325            ));
326        }
327
328        let mut out = String::new();
329        out.push_str("| Name | Codec | CRF | Speed | Size (MB) | Bitrate (kbps) | PSNR (dB) | SSIM | Score |\n");
330        out.push_str("|------|-------|-----|-------|-----------|----------------|-----------|------|-------|\n");
331
332        for result in self.by_composite_score() {
333            let m = &result.metrics;
334            let c = &result.candidate;
335            let size_mb = m.file_size_bytes as f64 / (1024.0 * 1024.0);
336            let psnr = m
337                .psnr_db
338                .map(|p| format!("{p:.2}"))
339                .unwrap_or_else(|| "-".to_string());
340            let ssim = m
341                .ssim
342                .map(|s| format!("{s:.4}"))
343                .unwrap_or_else(|| "-".to_string());
344            out.push_str(&format!(
345                "| {} | {} | {} | {:.2}x | {:.2} | {:.0} | {} | {} | {:.3} |\n",
346                c.name,
347                c.codec,
348                c.crf,
349                m.speed_factor,
350                size_mb,
351                m.bitrate_kbps,
352                psnr,
353                ssim,
354                result.composite_score(),
355            ));
356        }
357
358        Ok(out)
359    }
360
361    /// Returns all raw results.
362    #[must_use]
363    pub fn results(&self) -> &[BenchmarkResult] {
364        &self.results
365    }
366}
367
368// ─── BenchmarkTimer ───────────────────────────────────────────────────────────
369
370/// A simple wall-clock timer for benchmarking.
371pub struct BenchmarkTimer {
372    start: Instant,
373}
374
375impl BenchmarkTimer {
376    /// Returns the elapsed time since the timer was created.
377    #[must_use]
378    pub fn elapsed(&self) -> Duration {
379        self.start.elapsed()
380    }
381
382    /// Consumes the timer and returns the elapsed duration.
383    #[must_use]
384    pub fn finish(self) -> Duration {
385        self.start.elapsed()
386    }
387}
388
389// ─── Tests ────────────────────────────────────────────────────────────────────
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    fn make_result(name: &str, codec: &str, crf: u8, secs: f64, size: u64) -> BenchmarkResult {
396        let candidate = BenchmarkCandidate::new(name, codec, "medium", crf);
397        let metrics = EncodeMetrics::new(name, Duration::from_secs_f64(secs), size, 60.0);
398        BenchmarkResult { candidate, metrics }
399    }
400
401    #[test]
402    fn test_encode_metrics_speed_factor() {
403        let m = EncodeMetrics::new("test", Duration::from_secs(10), 10_000_000, 60.0);
404        assert!((m.speed_factor - 6.0).abs() < 1e-9);
405    }
406
407    #[test]
408    fn test_encode_metrics_bitrate() {
409        // 10 MB / 60 s * 8 / 1000 = 13333 kbps
410        let m = EncodeMetrics::new("test", Duration::from_secs(1), 10_000_000, 60.0);
411        assert!((m.bitrate_kbps - 10_000_000.0 * 8.0 / 1_000.0 / 60.0).abs() < 1.0);
412    }
413
414    #[test]
415    fn test_encode_metrics_zero_duration() {
416        let m = EncodeMetrics::new("test", Duration::from_secs(1), 1024, 0.0);
417        assert_eq!(m.bitrate_kbps, 0.0);
418    }
419
420    #[test]
421    fn test_benchmark_record_and_count() {
422        let mut bench = TranscodeBenchmark::new(60.0);
423        let cand = BenchmarkCandidate::new("AV1 CRF28", "av1", "5", 28);
424        bench.record(
425            cand,
426            Duration::from_secs(20),
427            5_000_000,
428            Some(42.5),
429            Some(0.97),
430        );
431        assert_eq!(bench.result_count(), 1);
432    }
433
434    #[test]
435    fn test_benchmark_by_speed() {
436        let mut bench = TranscodeBenchmark::new(60.0);
437        bench.record_result(make_result("slow", "h264", 23, 60.0, 10_000_000));
438        bench.record_result(make_result("fast", "h264", 23, 10.0, 12_000_000));
439
440        let sorted = bench.by_speed();
441        assert_eq!(sorted[0].candidate.name, "fast");
442    }
443
444    #[test]
445    fn test_benchmark_by_file_size() {
446        let mut bench = TranscodeBenchmark::new(60.0);
447        bench.record_result(make_result("big", "h264", 18, 20.0, 50_000_000));
448        bench.record_result(make_result("small", "av1", 30, 60.0, 5_000_000));
449
450        let sorted = bench.by_file_size();
451        assert_eq!(sorted[0].candidate.name, "small");
452    }
453
454    #[test]
455    fn test_benchmark_by_psnr() {
456        let mut bench = TranscodeBenchmark::new(60.0);
457        let cand_a = BenchmarkCandidate::new("A", "h264", "medium", 23);
458        let cand_b = BenchmarkCandidate::new("B", "av1", "5", 30);
459        let m_a = EncodeMetrics::new("A", Duration::from_secs(10), 5_000_000, 60.0).with_psnr(42.0);
460        let m_b = EncodeMetrics::new("B", Duration::from_secs(30), 4_000_000, 60.0).with_psnr(44.0);
461        bench.record_result(BenchmarkResult {
462            candidate: cand_a,
463            metrics: m_a,
464        });
465        bench.record_result(BenchmarkResult {
466            candidate: cand_b,
467            metrics: m_b,
468        });
469
470        let sorted = bench.by_psnr();
471        assert_eq!(sorted[0].candidate.name, "B");
472    }
473
474    #[test]
475    fn test_benchmark_best() {
476        let mut bench = TranscodeBenchmark::new(60.0);
477        bench.record_result(make_result("a", "h264", 23, 20.0, 5_000_000));
478        bench.record_result(make_result("b", "av1", 30, 90.0, 3_000_000));
479        assert!(bench.best().is_some());
480    }
481
482    #[test]
483    fn test_benchmark_report() {
484        let mut bench = TranscodeBenchmark::new(60.0);
485        let cand = BenchmarkCandidate::new("VP9 medium", "vp9", "medium", 31);
486        let metrics = EncodeMetrics::new("VP9 medium", Duration::from_secs(15), 8_000_000, 60.0)
487            .with_psnr(41.0)
488            .with_ssim(0.96);
489        bench.record_result(BenchmarkResult {
490            candidate: cand,
491            metrics,
492        });
493
494        let report = bench.report().expect("report ok");
495        assert!(report.contains("VP9 medium"));
496        assert!(report.contains("41.00"));
497        assert!(report.contains("0.9600"));
498    }
499
500    #[test]
501    fn test_benchmark_report_empty_error() {
502        let bench = TranscodeBenchmark::new(60.0);
503        assert!(bench.report().is_err());
504    }
505
506    #[test]
507    fn test_benchmark_timer() {
508        let bench = TranscodeBenchmark::new(60.0);
509        let timer = bench.start_timing();
510        let elapsed = timer.finish();
511        // Should be very short in test environment
512        assert!(elapsed.as_secs() < 10);
513    }
514
515    #[test]
516    fn test_composite_score_range() {
517        let mut bench = TranscodeBenchmark::new(60.0);
518        let cand = BenchmarkCandidate::new("X", "h264", "fast", 23);
519        let metrics = EncodeMetrics::new("X", Duration::from_secs(5), 4_000_000, 60.0)
520            .with_psnr(40.0)
521            .with_ssim(0.95);
522        let result = BenchmarkResult {
523            candidate: cand,
524            metrics,
525        };
526        let score = result.composite_score();
527        assert!(
528            score >= 0.0 && score <= 1.0,
529            "score {score} out of range [0,1]"
530        );
531        bench.record_result(result);
532        assert_eq!(bench.result_count(), 1);
533    }
534
535    #[test]
536    fn test_bits_per_pixel_per_frame() {
537        let m = EncodeMetrics::new("t", Duration::from_secs(10), 9_000_000, 30.0);
538        let bppf = m.bits_per_pixel_per_frame(1920, 1080, 30, 1);
539        // 9_000_000 * 8 = 72_000_000 bits / (1920*1080*30*30) ≈ 72M / (1866240000)
540        assert!(bppf > 0.0 && bppf < 1.0);
541    }
542}