1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
//! Video quality metrics (SSIM, PSNR).
#![allow(unsafe_code)]
use std::path::Path;
use crate::FilterError;
/// Computes video quality metrics between a reference and a distorted video.
///
/// All methods are static — there is no state to configure.
pub struct QualityMetrics;
impl QualityMetrics {
/// Computes the mean SSIM (Structural Similarity Index Measure) over all
/// frames between `reference` and `distorted`.
///
/// Returns a value in `[0.0, 1.0]`:
/// - `1.0` — the inputs are frame-identical.
/// - `0.0` — no structural similarity.
///
/// Uses `FFmpeg`'s `ssim` filter internally. Both inputs must have the
/// same frame count; if they differ the function returns an error rather
/// than silently comparing only the overlapping portion.
///
/// # Errors
///
/// - [`FilterError::AnalysisFailed`] — either input file is not found, the
/// inputs have different frame counts, or the internal filter graph fails.
///
/// # Examples
///
/// ```ignore
/// use ff_filter::QualityMetrics;
///
/// // Compare a video against itself — should return ≈ 1.0.
/// let ssim = QualityMetrics::ssim("reference.mp4", "reference.mp4")?;
/// assert!(ssim > 0.9999);
/// ```
pub fn ssim(
reference: impl AsRef<Path>,
distorted: impl AsRef<Path>,
) -> Result<f32, FilterError> {
let reference = reference.as_ref();
let distorted = distorted.as_ref();
if !reference.exists() {
return Err(FilterError::AnalysisFailed {
reason: format!("reference file not found: {}", reference.display()),
});
}
if !distorted.exists() {
return Err(FilterError::AnalysisFailed {
reason: format!("distorted file not found: {}", distorted.display()),
});
}
// SAFETY: compute_ssim_unsafe manages all raw pointer lifetimes according
// to the avfilter ownership rules: every allocated object is freed before
// returning, either in the bail! macro or in the normal cleanup path.
unsafe { super::analysis_inner::compute_ssim_unsafe(reference, distorted) }
}
/// Computes the mean PSNR (Peak Signal-to-Noise Ratio, in dB) over all
/// frames between `reference` and `distorted`.
///
/// Uses the luminance (Y-plane) PSNR as the representative value.
///
/// - Identical inputs → `f32::INFINITY` (MSE = 0).
/// - Lightly compressed → typically > 40 dB.
/// - Heavy degradation → typically < 30 dB.
///
/// Uses `FFmpeg`'s `psnr` filter internally. Both inputs must have the
/// same frame count; if they differ the function returns an error.
///
/// # Errors
///
/// - [`FilterError::AnalysisFailed`] — either input file is not found, the
/// inputs have different frame counts, or the internal filter graph fails.
///
/// # Examples
///
/// ```ignore
/// use ff_filter::QualityMetrics;
///
/// // Compare a video against itself — should return infinity.
/// let psnr = QualityMetrics::psnr("reference.mp4", "reference.mp4")?;
/// assert!(psnr > 100.0 || psnr == f32::INFINITY);
/// ```
pub fn psnr(
reference: impl AsRef<Path>,
distorted: impl AsRef<Path>,
) -> Result<f32, FilterError> {
let reference = reference.as_ref();
let distorted = distorted.as_ref();
if !reference.exists() {
return Err(FilterError::AnalysisFailed {
reason: format!("reference file not found: {}", reference.display()),
});
}
if !distorted.exists() {
return Err(FilterError::AnalysisFailed {
reason: format!("distorted file not found: {}", distorted.display()),
});
}
// SAFETY: compute_psnr_unsafe manages all raw pointer lifetimes according
// to the avfilter ownership rules: every allocated object is freed before
// returning, either in the bail! macro or in the normal cleanup path.
unsafe { super::analysis_inner::compute_psnr_unsafe(reference, distorted) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quality_metrics_ssim_missing_reference_should_return_analysis_failed() {
let result = QualityMetrics::ssim("does_not_exist_ref.mp4", "does_not_exist_dist.mp4");
assert!(
matches!(result, Err(FilterError::AnalysisFailed { .. })),
"expected AnalysisFailed for missing reference, got {result:?}"
);
}
#[test]
fn quality_metrics_ssim_missing_distorted_should_return_analysis_failed() {
// Reference exists (any existing file), distorted does not.
// Use a path that is guaranteed to exist: the Cargo.toml for this crate.
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let result = QualityMetrics::ssim(&manifest, "does_not_exist_dist_99999.mp4");
assert!(
matches!(result, Err(FilterError::AnalysisFailed { .. })),
"expected AnalysisFailed for missing distorted, got {result:?}"
);
}
#[test]
fn quality_metrics_psnr_missing_reference_should_return_analysis_failed() {
let result = QualityMetrics::psnr("does_not_exist_ref.mp4", "does_not_exist_dist.mp4");
assert!(
matches!(result, Err(FilterError::AnalysisFailed { .. })),
"expected AnalysisFailed for missing reference, got {result:?}"
);
}
#[test]
fn quality_metrics_psnr_missing_distorted_should_return_analysis_failed() {
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let result = QualityMetrics::psnr(&manifest, "does_not_exist_dist_99999.mp4");
assert!(
matches!(result, Err(FilterError::AnalysisFailed { .. })),
"expected AnalysisFailed for missing distorted, got {result:?}"
);
}
}