1#![allow(dead_code)]
8#![allow(clippy::cast_precision_loss)]
9
10use std::time::{Duration, Instant};
11
12use crate::{Result, TranscodeError};
13
14#[derive(Debug, Clone)]
18pub struct EncodeMetrics {
19 pub name: String,
21 pub encode_time: Duration,
23 pub file_size_bytes: u64,
25 pub bitrate_kbps: f64,
27 pub duration_secs: f64,
29 pub speed_factor: f64,
31 pub psnr_db: Option<f64>,
33 pub ssim: Option<f64>,
35 pub vmaf: Option<f64>,
37}
38
39impl EncodeMetrics {
40 #[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 #[must_use]
74 pub fn with_psnr(mut self, psnr: f64) -> Self {
75 self.psnr_db = Some(psnr);
76 self
77 }
78
79 #[must_use]
81 pub fn with_ssim(mut self, ssim: f64) -> Self {
82 self.ssim = Some(ssim);
83 self
84 }
85
86 #[must_use]
88 pub fn with_vmaf(mut self, vmaf: f64) -> Self {
89 self.vmaf = Some(vmaf);
90 self
91 }
92
93 #[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#[derive(Debug, Clone)]
122pub struct BenchmarkCandidate {
123 pub name: String,
125 pub codec: String,
127 pub preset: String,
129 pub crf: u8,
131 pub extra_params: Vec<(String, String)>,
133}
134
135impl BenchmarkCandidate {
136 #[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 #[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#[derive(Debug, Clone)]
165pub struct BenchmarkResult {
166 pub candidate: BenchmarkCandidate,
168 pub metrics: EncodeMetrics,
170}
171
172impl BenchmarkResult {
173 #[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 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
194pub struct TranscodeBenchmark {
202 results: Vec<BenchmarkResult>,
204 content_duration_secs: f64,
206}
207
208impl TranscodeBenchmark {
209 #[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 #[must_use]
221 pub fn start_timing(&self) -> BenchmarkTimer {
222 BenchmarkTimer {
223 start: Instant::now(),
224 }
225 }
226
227 pub fn record_result(&mut self, result: BenchmarkResult) {
229 self.results.push(result);
230 }
231
232 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 #[must_use]
259 pub fn result_count(&self) -> usize {
260 self.results.len()
261 }
262
263 #[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 #[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 #[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 #[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 #[must_use]
312 pub fn best(&self) -> Option<&BenchmarkResult> {
313 self.by_composite_score().into_iter().next()
314 }
315
316 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 #[must_use]
363 pub fn results(&self) -> &[BenchmarkResult] {
364 &self.results
365 }
366}
367
368pub struct BenchmarkTimer {
372 start: Instant,
373}
374
375impl BenchmarkTimer {
376 #[must_use]
378 pub fn elapsed(&self) -> Duration {
379 self.start.elapsed()
380 }
381
382 #[must_use]
384 pub fn finish(self) -> Duration {
385 self.start.elapsed()
386 }
387}
388
389#[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 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 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 assert!(bppf > 0.0 && bppf < 1.0);
541 }
542}