#![allow(clippy::cast_precision_loss)]
use std::fmt;
use crate::{Result, TranscodeError};
#[derive(Debug, Clone)]
pub struct ValidationTolerances {
pub bitrate_relative: f64,
pub duration_secs: f64,
pub frame_rate_fps: f64,
}
impl ValidationTolerances {
#[must_use]
pub fn strict() -> Self {
Self {
bitrate_relative: 0.02,
duration_secs: 0.1,
frame_rate_fps: 0.001,
}
}
#[must_use]
pub fn moderate() -> Self {
Self {
bitrate_relative: 0.10,
duration_secs: 0.5,
frame_rate_fps: 0.01,
}
}
#[must_use]
pub fn relaxed() -> Self {
Self {
bitrate_relative: 0.20,
duration_secs: 2.0,
frame_rate_fps: 0.1,
}
}
}
impl Default for ValidationTolerances {
fn default() -> Self {
Self::moderate()
}
}
#[derive(Debug, Clone)]
pub struct ValidationProfile {
pub name: String,
pub tolerances: ValidationTolerances,
pub require_codec_match: bool,
pub require_exact_resolution: bool,
pub check_bitrate: bool,
pub check_duration: bool,
pub check_frame_rate: bool,
pub check_container: bool,
}
impl ValidationProfile {
#[must_use]
pub fn streaming() -> Self {
Self {
name: "streaming".to_string(),
tolerances: ValidationTolerances::moderate(),
require_codec_match: true,
require_exact_resolution: true,
check_bitrate: true,
check_duration: true,
check_frame_rate: true,
check_container: false,
}
}
#[must_use]
pub fn broadcast() -> Self {
Self {
name: "broadcast".to_string(),
tolerances: ValidationTolerances::strict(),
require_codec_match: true,
require_exact_resolution: true,
check_bitrate: true,
check_duration: true,
check_frame_rate: true,
check_container: true,
}
}
#[must_use]
pub fn preview() -> Self {
Self {
name: "preview".to_string(),
tolerances: ValidationTolerances::relaxed(),
require_codec_match: false,
require_exact_resolution: false,
check_bitrate: false,
check_duration: true,
check_frame_rate: false,
check_container: false,
}
}
}
impl Default for ValidationProfile {
fn default() -> Self {
Self::streaming()
}
}
#[derive(Debug, Clone, Default)]
pub struct OutputSpec {
pub video_codec: Option<String>,
pub audio_codec: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub video_bitrate_bps: Option<u64>,
pub audio_bitrate_bps: Option<u64>,
pub duration_secs: Option<f64>,
pub frame_rate_num: Option<u32>,
pub frame_rate_den: Option<u32>,
pub container_format: Option<String>,
}
impl OutputSpec {
#[must_use]
pub fn builder() -> OutputSpecBuilder {
OutputSpecBuilder::default()
}
}
#[derive(Debug, Default)]
pub struct OutputSpecBuilder {
spec: OutputSpec,
}
impl OutputSpecBuilder {
#[must_use]
pub fn video_codec(mut self, codec: impl Into<String>) -> Self {
self.spec.video_codec = Some(codec.into());
self
}
#[must_use]
pub fn audio_codec(mut self, codec: impl Into<String>) -> Self {
self.spec.audio_codec = Some(codec.into());
self
}
#[must_use]
pub fn resolution(mut self, width: u32, height: u32) -> Self {
self.spec.width = Some(width);
self.spec.height = Some(height);
self
}
#[must_use]
pub fn video_bitrate_bps(mut self, bps: u64) -> Self {
self.spec.video_bitrate_bps = Some(bps);
self
}
#[must_use]
pub fn audio_bitrate_bps(mut self, bps: u64) -> Self {
self.spec.audio_bitrate_bps = Some(bps);
self
}
#[must_use]
pub fn duration_secs(mut self, secs: f64) -> Self {
self.spec.duration_secs = Some(secs);
self
}
#[must_use]
pub fn frame_rate(mut self, num: u32, den: u32) -> Self {
self.spec.frame_rate_num = Some(num);
self.spec.frame_rate_den = Some(den);
self
}
#[must_use]
pub fn container(mut self, fmt: impl Into<String>) -> Self {
self.spec.container_format = Some(fmt.into());
self
}
#[must_use]
pub fn build(self) -> OutputSpec {
self.spec
}
}
#[derive(Debug, Clone, Default)]
pub struct ActualOutputProperties {
pub video_codec: Option<String>,
pub audio_codec: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub video_bitrate_bps: Option<u64>,
pub audio_bitrate_bps: Option<u64>,
pub duration_secs: Option<f64>,
pub frame_rate_num: Option<u32>,
pub frame_rate_den: Option<u32>,
pub container_format: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FieldResult {
Skipped,
Pass,
Fail(String),
Missing(String),
}
impl FieldResult {
#[must_use]
pub fn is_ok(&self) -> bool {
matches!(self, Self::Pass | Self::Skipped)
}
}
impl fmt::Display for FieldResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Skipped => write!(f, "SKIPPED"),
Self::Pass => write!(f, "PASS"),
Self::Fail(msg) => write!(f, "FAIL: {msg}"),
Self::Missing(field) => write!(f, "MISSING: {field} not measured"),
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationReport {
pub video_codec: FieldResult,
pub audio_codec: FieldResult,
pub width: FieldResult,
pub height: FieldResult,
pub video_bitrate: FieldResult,
pub audio_bitrate: FieldResult,
pub duration: FieldResult,
pub frame_rate: FieldResult,
pub container: FieldResult,
}
impl ValidationReport {
#[must_use]
pub fn passed(&self) -> bool {
self.video_codec.is_ok()
&& self.audio_codec.is_ok()
&& self.width.is_ok()
&& self.height.is_ok()
&& self.video_bitrate.is_ok()
&& self.audio_bitrate.is_ok()
&& self.duration.is_ok()
&& self.frame_rate.is_ok()
&& self.container.is_ok()
}
#[must_use]
pub fn failures(&self) -> Vec<(&'static str, &FieldResult)> {
let fields: [(&'static str, &FieldResult); 9] = [
("video_codec", &self.video_codec),
("audio_codec", &self.audio_codec),
("width", &self.width),
("height", &self.height),
("video_bitrate", &self.video_bitrate),
("audio_bitrate", &self.audio_bitrate),
("duration", &self.duration),
("frame_rate", &self.frame_rate),
("container", &self.container),
];
fields
.into_iter()
.filter(|(_, r)| !r.is_ok())
.collect()
}
#[must_use]
pub fn failure_count(&self) -> usize {
self.failures().len()
}
}
impl fmt::Display for ValidationReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "ValidationReport {{")?;
writeln!(f, " video_codec: {}", self.video_codec)?;
writeln!(f, " audio_codec: {}", self.audio_codec)?;
writeln!(f, " width: {}", self.width)?;
writeln!(f, " height: {}", self.height)?;
writeln!(f, " video_bitrate:{}", self.video_bitrate)?;
writeln!(f, " audio_bitrate:{}", self.audio_bitrate)?;
writeln!(f, " duration: {}", self.duration)?;
writeln!(f, " frame_rate: {}", self.frame_rate)?;
writeln!(f, " container: {}", self.container)?;
write!(
f,
" OVERALL: {}",
if self.passed() { "PASS" } else { "FAIL" }
)
}
}
#[derive(Debug, Clone)]
pub struct OutputValidator {
profile: ValidationProfile,
}
impl OutputValidator {
#[must_use]
pub fn new(profile: ValidationProfile) -> Self {
Self { profile }
}
#[must_use]
pub fn validate(
&self,
spec: &OutputSpec,
actual: &ActualOutputProperties,
) -> ValidationReport {
let tol = &self.profile.tolerances;
ValidationReport {
video_codec: self.check_codec(
"video_codec",
spec.video_codec.as_deref(),
actual.video_codec.as_deref(),
self.profile.require_codec_match,
),
audio_codec: self.check_codec(
"audio_codec",
spec.audio_codec.as_deref(),
actual.audio_codec.as_deref(),
self.profile.require_codec_match,
),
width: self.check_exact_u32(
"width",
spec.width,
actual.width,
self.profile.require_exact_resolution,
),
height: self.check_exact_u32(
"height",
spec.height,
actual.height,
self.profile.require_exact_resolution,
),
video_bitrate: Self::check_bitrate(
"video_bitrate",
spec.video_bitrate_bps,
actual.video_bitrate_bps,
tol.bitrate_relative,
self.profile.check_bitrate,
),
audio_bitrate: Self::check_bitrate(
"audio_bitrate",
spec.audio_bitrate_bps,
actual.audio_bitrate_bps,
tol.bitrate_relative,
self.profile.check_bitrate,
),
duration: Self::check_duration(
spec.duration_secs,
actual.duration_secs,
tol.duration_secs,
self.profile.check_duration,
),
frame_rate: Self::check_frame_rate(
spec.frame_rate_num,
spec.frame_rate_den,
actual.frame_rate_num,
actual.frame_rate_den,
tol.frame_rate_fps,
self.profile.check_frame_rate,
),
container: self.check_codec(
"container",
spec.container_format.as_deref(),
actual.container_format.as_deref(),
self.profile.check_container,
),
}
}
pub fn validate_strict(
&self,
spec: &OutputSpec,
actual: &ActualOutputProperties,
) -> Result<ValidationReport> {
let report = self.validate(spec, actual);
if !report.passed() {
let msgs: Vec<String> = report
.failures()
.into_iter()
.map(|(name, r)| format!("{name}: {r}"))
.collect();
Err(TranscodeError::InvalidOutput(msgs.join("; ")))
} else {
Ok(report)
}
}
fn check_codec(
&self,
field: &str,
expected: Option<&str>,
actual: Option<&str>,
required: bool,
) -> FieldResult {
let Some(exp) = expected else {
return FieldResult::Skipped;
};
if !required {
return FieldResult::Skipped;
}
match actual {
None => FieldResult::Missing(field.to_string()),
Some(act) if act.eq_ignore_ascii_case(exp) => FieldResult::Pass,
Some(act) => FieldResult::Fail(format!("expected {exp:?}, got {act:?}")),
}
}
fn check_exact_u32(
&self,
field: &str,
expected: Option<u32>,
actual: Option<u32>,
required: bool,
) -> FieldResult {
let Some(exp) = expected else {
return FieldResult::Skipped;
};
if !required {
return FieldResult::Skipped;
}
match actual {
None => FieldResult::Missing(field.to_string()),
Some(act) if act == exp => FieldResult::Pass,
Some(act) => FieldResult::Fail(format!("expected {exp}, got {act}")),
}
}
fn check_bitrate(
field: &str,
expected: Option<u64>,
actual: Option<u64>,
relative_tol: f64,
enabled: bool,
) -> FieldResult {
if !enabled {
return FieldResult::Skipped;
}
let Some(exp) = expected else {
return FieldResult::Skipped;
};
let Some(act) = actual else {
return FieldResult::Missing(field.to_string());
};
let exp_f = exp as f64;
let act_f = act as f64;
let rel_diff = (act_f - exp_f).abs() / exp_f.max(1.0);
if rel_diff <= relative_tol {
FieldResult::Pass
} else {
FieldResult::Fail(format!(
"expected {exp} bps, got {act} bps (diff {:.1}% > tolerance {:.1}%)",
rel_diff * 100.0,
relative_tol * 100.0
))
}
}
fn check_duration(
expected: Option<f64>,
actual: Option<f64>,
tol_secs: f64,
enabled: bool,
) -> FieldResult {
if !enabled {
return FieldResult::Skipped;
}
let Some(exp) = expected else {
return FieldResult::Skipped;
};
let Some(act) = actual else {
return FieldResult::Missing("duration".to_string());
};
let diff = (act - exp).abs();
if diff <= tol_secs {
FieldResult::Pass
} else {
FieldResult::Fail(format!(
"expected {exp:.3}s, got {act:.3}s (diff {diff:.3}s > tolerance {tol_secs:.3}s)"
))
}
}
fn check_frame_rate(
exp_num: Option<u32>,
exp_den: Option<u32>,
act_num: Option<u32>,
act_den: Option<u32>,
tol_fps: f64,
enabled: bool,
) -> FieldResult {
if !enabled {
return FieldResult::Skipped;
}
let (Some(en), Some(ed)) = (exp_num, exp_den) else {
return FieldResult::Skipped;
};
if ed == 0 {
return FieldResult::Fail("expected frame rate has zero denominator".to_string());
}
let (Some(an), Some(ad)) = (act_num, act_den) else {
return FieldResult::Missing("frame_rate".to_string());
};
if ad == 0 {
return FieldResult::Fail("actual frame rate has zero denominator".to_string());
}
let exp_fps = en as f64 / ed as f64;
let act_fps = an as f64 / ad as f64;
let diff = (act_fps - exp_fps).abs();
if diff <= tol_fps {
FieldResult::Pass
} else {
FieldResult::Fail(format!(
"expected {exp_fps:.4} fps, got {act_fps:.4} fps (diff {diff:.4} > {tol_fps:.4})"
))
}
}
}
impl Default for OutputValidator {
fn default() -> Self {
Self::new(ValidationProfile::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn perfect_actual() -> ActualOutputProperties {
ActualOutputProperties {
video_codec: Some("vp9".to_string()),
audio_codec: Some("opus".to_string()),
width: Some(1920),
height: Some(1080),
video_bitrate_bps: Some(5_000_000),
audio_bitrate_bps: Some(128_000),
duration_secs: Some(60.0),
frame_rate_num: Some(30),
frame_rate_den: Some(1),
container_format: Some("webm".to_string()),
}
}
fn full_spec() -> OutputSpec {
OutputSpec::builder()
.video_codec("vp9")
.audio_codec("opus")
.resolution(1920, 1080)
.video_bitrate_bps(5_000_000)
.audio_bitrate_bps(128_000)
.duration_secs(60.0)
.frame_rate(30, 1)
.container("webm")
.build()
}
#[test]
fn test_perfect_match_passes() {
let validator = OutputValidator::new(ValidationProfile::broadcast());
let report = validator.validate(&full_spec(), &perfect_actual());
assert!(report.passed(), "report: {report}");
}
#[test]
fn test_codec_mismatch_fails() {
let validator = OutputValidator::new(ValidationProfile::streaming());
let spec = OutputSpec::builder().video_codec("vp9").build();
let actual = ActualOutputProperties {
video_codec: Some("h264".to_string()),
..Default::default()
};
let report = validator.validate(&spec, &actual);
assert!(!report.passed());
assert!(matches!(report.video_codec, FieldResult::Fail(_)));
}
#[test]
fn test_bitrate_within_tolerance_passes() {
let validator = OutputValidator::new(ValidationProfile::streaming());
let spec = OutputSpec::builder().video_bitrate_bps(5_000_000).build();
let actual = ActualOutputProperties {
video_bitrate_bps: Some(5_250_000),
..Default::default()
};
let report = validator.validate(&spec, &actual);
assert!(report.video_bitrate.is_ok());
}
#[test]
fn test_bitrate_outside_tolerance_fails() {
let validator = OutputValidator::new(ValidationProfile::broadcast()); let spec = OutputSpec::builder().video_bitrate_bps(5_000_000).build();
let actual = ActualOutputProperties {
video_bitrate_bps: Some(4_500_000), ..Default::default()
};
let report = validator.validate(&spec, &actual);
assert!(matches!(report.video_bitrate, FieldResult::Fail(_)));
}
#[test]
fn test_resolution_mismatch_fails() {
let validator = OutputValidator::new(ValidationProfile::streaming());
let spec = OutputSpec::builder().resolution(1920, 1080).build();
let actual = ActualOutputProperties {
width: Some(1280),
height: Some(720),
..Default::default()
};
let report = validator.validate(&spec, &actual);
assert!(matches!(report.width, FieldResult::Fail(_)));
assert!(matches!(report.height, FieldResult::Fail(_)));
}
#[test]
fn test_duration_within_tolerance() {
let validator = OutputValidator::new(ValidationProfile::streaming()); let spec = OutputSpec::builder().duration_secs(60.0).build();
let actual = ActualOutputProperties {
duration_secs: Some(60.3),
..Default::default()
};
let report = validator.validate(&spec, &actual);
assert!(report.duration.is_ok());
}
#[test]
fn test_duration_outside_tolerance() {
let validator = OutputValidator::new(ValidationProfile::broadcast()); let spec = OutputSpec::builder().duration_secs(60.0).build();
let actual = ActualOutputProperties {
duration_secs: Some(61.0),
..Default::default()
};
let report = validator.validate(&spec, &actual);
assert!(matches!(report.duration, FieldResult::Fail(_)));
}
#[test]
fn test_frame_rate_pass() {
let validator = OutputValidator::new(ValidationProfile::broadcast());
let spec = OutputSpec::builder().frame_rate(30, 1).build();
let actual = ActualOutputProperties {
frame_rate_num: Some(30),
frame_rate_den: Some(1),
..Default::default()
};
let report = validator.validate(&spec, &actual);
assert!(report.frame_rate.is_ok());
}
#[test]
fn test_frame_rate_mismatch_fails() {
let validator = OutputValidator::new(ValidationProfile::broadcast());
let spec = OutputSpec::builder().frame_rate(30, 1).build();
let actual = ActualOutputProperties {
frame_rate_num: Some(25),
frame_rate_den: Some(1),
..Default::default()
};
let report = validator.validate(&spec, &actual);
assert!(matches!(report.frame_rate, FieldResult::Fail(_)));
}
#[test]
fn test_missing_actual_value() {
let validator = OutputValidator::new(ValidationProfile::streaming());
let spec = OutputSpec::builder().video_codec("vp9").build();
let actual = ActualOutputProperties::default(); let report = validator.validate(&spec, &actual);
assert!(matches!(report.video_codec, FieldResult::Missing(_)));
}
#[test]
fn test_skipped_when_spec_field_absent() {
let validator = OutputValidator::new(ValidationProfile::streaming());
let spec = OutputSpec::default(); let actual = perfect_actual();
let report = validator.validate(&spec, &actual);
assert!(report.passed());
}
#[test]
fn test_validate_strict_returns_error_on_failure() {
let validator = OutputValidator::new(ValidationProfile::streaming());
let spec = OutputSpec::builder().video_codec("vp9").build();
let actual = ActualOutputProperties {
video_codec: Some("av1".to_string()),
..Default::default()
};
let result = validator.validate_strict(&spec, &actual);
assert!(result.is_err());
}
#[test]
fn test_validate_strict_ok_on_pass() {
let validator = OutputValidator::new(ValidationProfile::preview());
let spec = OutputSpec::builder().duration_secs(10.0).build();
let actual = ActualOutputProperties {
duration_secs: Some(10.1),
..Default::default()
};
let result = validator.validate_strict(&spec, &actual);
assert!(result.is_ok());
}
#[test]
fn test_failure_count() {
let validator = OutputValidator::new(ValidationProfile::broadcast());
let spec = full_spec();
let actual = ActualOutputProperties {
video_codec: Some("h264".to_string()),
audio_codec: Some("aac".to_string()),
width: Some(1280),
height: Some(720),
..Default::default()
};
let report = validator.validate(&spec, &actual);
assert!(report.failure_count() > 0);
}
#[test]
fn test_codec_case_insensitive() {
let validator = OutputValidator::new(ValidationProfile::streaming());
let spec = OutputSpec::builder().video_codec("VP9").build();
let actual = ActualOutputProperties {
video_codec: Some("vp9".to_string()),
..Default::default()
};
let report = validator.validate(&spec, &actual);
assert!(report.video_codec.is_ok());
}
#[test]
fn test_preview_profile_skips_bitrate() {
let validator = OutputValidator::new(ValidationProfile::preview());
let spec = OutputSpec::builder().video_bitrate_bps(5_000_000).build();
let actual = ActualOutputProperties {
video_bitrate_bps: Some(1_000_000), ..Default::default()
};
let report = validator.validate(&spec, &actual);
assert_eq!(report.video_bitrate, FieldResult::Skipped);
}
}