#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_possible_truncation)]
use std::collections::HashSet;
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct LadderRung {
pub width: u32,
pub height: u32,
pub bitrate_bps: u64,
pub frame_rate: f64,
pub codec: String,
pub audio_bps: u64,
pub segment_duration_s: f64,
}
impl LadderRung {
#[must_use]
pub fn new(width: u32, height: u32, bitrate_bps: u64, frame_rate: f64, codec: &str) -> Self {
Self {
width,
height,
bitrate_bps,
frame_rate,
codec: codec.to_owned(),
audio_bps: 0,
segment_duration_s: 0.0,
}
}
#[must_use]
pub fn with_audio(mut self, audio_bps: u64) -> Self {
self.audio_bps = audio_bps;
self
}
#[must_use]
pub fn with_segment_duration(mut self, secs: f64) -> Self {
self.segment_duration_s = secs;
self
}
#[must_use]
pub fn pixels(&self) -> u64 {
self.width as u64 * self.height as u64
}
#[must_use]
pub fn bits_per_pixel(&self) -> f64 {
if self.pixels() == 0 || self.frame_rate <= 0.0 {
return 0.0;
}
self.bitrate_bps as f64 / (self.pixels() as f64 * self.frame_rate)
}
#[must_use]
pub fn aspect_ratio(&self) -> (u32, u32) {
let g = gcd(self.width, self.height);
if g == 0 {
return (0, 0);
}
(self.width / g, self.height / g)
}
}
impl fmt::Display for LadderRung {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}×{}@{:.0}fps {:.1}Mbps {}",
self.width,
self.height,
self.frame_rate,
self.bitrate_bps as f64 / 1_000_000.0,
self.codec
)
}
}
fn gcd(a: u32, b: u32) -> u32 {
if b == 0 {
a
} else {
gcd(b, a % b)
}
}
#[derive(Debug, Clone)]
pub struct EncodeLadder {
pub rungs: Vec<LadderRung>,
}
impl EncodeLadder {
#[must_use]
pub fn new(rungs: Vec<LadderRung>) -> Self {
Self { rungs }
}
#[must_use]
pub fn len(&self) -> usize {
self.rungs.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rungs.is_empty()
}
pub fn sort_descending(&mut self) {
self.rungs.sort_by(|a, b| {
b.bitrate_bps.cmp(&a.bitrate_bps)
});
}
#[must_use]
pub fn codec_set(&self) -> HashSet<&str> {
self.rungs.iter().map(|r| r.codec.as_str()).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LadderSpec {
Hls,
Dash,
Cmaf,
LlHls,
Generic,
}
impl LadderSpec {
#[must_use]
pub fn segment_duration_range(self) -> Option<(f64, f64)> {
match self {
Self::Hls => Some((2.0, 10.0)),
Self::Dash => Some((1.0, 10.0)),
Self::Cmaf => Some((1.0, 6.0)),
Self::LlHls => Some((0.5, 2.0)),
Self::Generic => None,
}
}
#[must_use]
pub fn max_rungs(self) -> usize {
match self {
Self::LlHls => 6,
Self::Hls | Self::Dash | Self::Cmaf => 8,
Self::Generic => usize::MAX,
}
}
#[must_use]
pub fn allows_codec(self, codec: &str) -> bool {
match self {
Self::Hls => matches!(codec, "h264" | "h265" | "hevc" | "vp9" | "av1"),
Self::Dash | Self::Cmaf | Self::LlHls => {
matches!(codec, "h264" | "h265" | "hevc" | "vp9" | "av1")
}
Self::Generic => true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FindingSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone)]
pub struct LadderFinding {
pub severity: FindingSeverity,
pub rung_index: Option<usize>,
pub message: String,
}
impl LadderFinding {
fn error(rung: Option<usize>, msg: impl Into<String>) -> Self {
Self {
severity: FindingSeverity::Error,
rung_index: rung,
message: msg.into(),
}
}
fn warning(rung: Option<usize>, msg: impl Into<String>) -> Self {
Self {
severity: FindingSeverity::Warning,
rung_index: rung,
message: msg.into(),
}
}
fn info(rung: Option<usize>, msg: impl Into<String>) -> Self {
Self {
severity: FindingSeverity::Info,
rung_index: rung,
message: msg.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct LadderReport {
pub findings: Vec<LadderFinding>,
pub spec: LadderSpec,
pub rung_count: usize,
}
impl LadderReport {
#[must_use]
pub fn is_ok(&self) -> bool {
!self
.findings
.iter()
.any(|f| f.severity == FindingSeverity::Error)
}
#[must_use]
pub fn errors(&self) -> Vec<&LadderFinding> {
self.findings
.iter()
.filter(|f| f.severity == FindingSeverity::Error)
.collect()
}
#[must_use]
pub fn warnings(&self) -> Vec<&LadderFinding> {
self.findings
.iter()
.filter(|f| f.severity == FindingSeverity::Warning)
.collect()
}
#[must_use]
pub fn infos(&self) -> Vec<&LadderFinding> {
self.findings
.iter()
.filter(|f| f.severity == FindingSeverity::Info)
.collect()
}
#[must_use]
pub fn error_count(&self) -> usize {
self.errors().len()
}
}
#[derive(Debug, Clone)]
pub struct ValidatorConfig {
pub min_bpp: f64,
pub max_bpp: f64,
pub max_bitrate_gap_ratio: f64,
pub min_bitrate_gap_ratio: f64,
pub require_uniform_codec: bool,
pub check_segment_duration: bool,
}
impl Default for ValidatorConfig {
fn default() -> Self {
Self {
min_bpp: 0.03,
max_bpp: 0.50,
max_bitrate_gap_ratio: 3.0,
min_bitrate_gap_ratio: 1.2,
require_uniform_codec: false,
check_segment_duration: true,
}
}
}
#[derive(Debug, Clone)]
pub struct LadderValidator {
spec: LadderSpec,
config: ValidatorConfig,
}
impl LadderValidator {
#[must_use]
pub fn new(spec: LadderSpec) -> Self {
Self {
spec,
config: ValidatorConfig::default(),
}
}
#[must_use]
pub fn with_config(spec: LadderSpec, config: ValidatorConfig) -> Self {
Self { spec, config }
}
#[must_use]
pub fn validate(&self, ladder: &EncodeLadder) -> LadderReport {
let mut findings: Vec<LadderFinding> = Vec::new();
if ladder.is_empty() {
findings.push(LadderFinding::error(None, "Ladder has no rungs"));
return LadderReport {
findings,
spec: self.spec,
rung_count: 0,
};
}
if ladder.len() > self.spec.max_rungs() {
findings.push(LadderFinding::warning(
None,
format!(
"Ladder has {} rungs; spec {:?} recommends at most {}",
ladder.len(),
self.spec,
self.spec.max_rungs()
),
));
}
for (i, rung) in ladder.rungs.iter().enumerate() {
self.check_rung(i, rung, &mut findings);
}
self.check_ordering(ladder, &mut findings);
if self.config.require_uniform_codec {
let codecs = ladder.codec_set();
if codecs.len() > 1 {
findings.push(LadderFinding::warning(
None,
format!(
"Ladder uses multiple codecs ({:?}); consider a uniform codec for \
simpler packaging",
codecs
),
));
}
}
for (i, rung) in ladder.rungs.iter().enumerate() {
if !self.spec.allows_codec(&rung.codec) {
findings.push(LadderFinding::error(
Some(i),
format!("Codec '{}' is not allowed by {:?}", rung.codec, self.spec),
));
}
}
LadderReport {
findings,
spec: self.spec,
rung_count: ladder.len(),
}
}
fn check_rung(&self, i: usize, rung: &LadderRung, findings: &mut Vec<LadderFinding>) {
if rung.width == 0 || rung.height == 0 {
findings.push(LadderFinding::error(
Some(i),
format!("Rung {} has zero dimension ({}×{})", i, rung.width, rung.height),
));
}
if rung.bitrate_bps == 0 {
findings.push(LadderFinding::error(
Some(i),
format!("Rung {} has zero bitrate", i),
));
}
if rung.frame_rate <= 0.0 || !rung.frame_rate.is_finite() {
findings.push(LadderFinding::error(
Some(i),
format!("Rung {} has invalid frame rate {:.3}", i, rung.frame_rate),
));
}
let bpp = rung.bits_per_pixel();
if bpp < self.config.min_bpp && bpp > 0.0 {
findings.push(LadderFinding::warning(
Some(i),
format!(
"Rung {} bpp {:.4} is below minimum {:.4}; may appear blocky",
i, bpp, self.config.min_bpp
),
));
}
if bpp > self.config.max_bpp {
findings.push(LadderFinding::info(
Some(i),
format!(
"Rung {} bpp {:.4} exceeds maximum {:.4}; possibly over-provisioned",
i, bpp, self.config.max_bpp
),
));
}
if self.config.check_segment_duration && rung.segment_duration_s > 0.0 {
if let Some((min_s, max_s)) = self.spec.segment_duration_range() {
if rung.segment_duration_s < min_s || rung.segment_duration_s > max_s {
findings.push(LadderFinding::error(
Some(i),
format!(
"Rung {} segment duration {:.2}s is outside spec range \
[{min_s:.2}, {max_s:.2}] for {:?}",
i, rung.segment_duration_s, self.spec
),
));
}
}
}
}
fn check_ordering(&self, ladder: &EncodeLadder, findings: &mut Vec<LadderFinding>) {
let rungs = &ladder.rungs;
for i in 1..rungs.len() {
let upper = &rungs[i - 1];
let lower = &rungs[i];
if upper.pixels() > lower.pixels() && upper.bitrate_bps < lower.bitrate_bps {
findings.push(LadderFinding::error(
Some(i),
format!(
"Bitrate crossover: rung {} ({}×{} @ {}bps) has higher resolution \
but lower bitrate than rung {} ({}×{} @ {}bps)",
i - 1,
upper.width,
upper.height,
upper.bitrate_bps,
i,
lower.width,
lower.height,
lower.bitrate_bps
),
));
}
if lower.bitrate_bps > 0 {
let ratio = upper.bitrate_bps as f64 / lower.bitrate_bps as f64;
if ratio > self.config.max_bitrate_gap_ratio {
findings.push(LadderFinding::warning(
Some(i),
format!(
"Bitrate gap between rung {} ({} bps) and rung {} ({} bps) is \
{ratio:.2}× — may cause large quality jumps during ABR switching",
i - 1,
upper.bitrate_bps,
i,
lower.bitrate_bps,
),
));
}
if ratio < self.config.min_bitrate_gap_ratio && ratio > 0.0 {
findings.push(LadderFinding::info(
Some(i),
format!(
"Bitrate gap between rung {} and rung {} is only {ratio:.2}× \
— rungs may be redundant",
i - 1,
i,
),
));
}
}
if upper.width == lower.width && upper.height == lower.height {
findings.push(LadderFinding::warning(
Some(i),
format!(
"Rungs {} and {} have the same resolution {}×{}",
i - 1,
i,
upper.width,
upper.height
),
));
}
}
}
}
#[must_use]
pub fn vp9_hls_ladder() -> EncodeLadder {
EncodeLadder::new(vec![
LadderRung::new(1920, 1080, 4_500_000, 30.0, "vp9"),
LadderRung::new(1280, 720, 2_500_000, 30.0, "vp9"),
LadderRung::new(854, 480, 1_000_000, 30.0, "vp9"),
LadderRung::new(640, 360, 500_000, 30.0, "vp9"),
])
}
#[must_use]
pub fn av1_cmaf_ladder() -> EncodeLadder {
EncodeLadder::new(vec![
LadderRung::new(1920, 1080, 3_500_000, 30.0, "av1")
.with_segment_duration(2.0),
LadderRung::new(1280, 720, 1_800_000, 30.0, "av1")
.with_segment_duration(2.0),
LadderRung::new(854, 480, 800_000, 30.0, "av1")
.with_segment_duration(2.0),
LadderRung::new(640, 360, 350_000, 30.0, "av1")
.with_segment_duration(2.0),
])
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_vp9_hls_ladder() -> EncodeLadder {
vp9_hls_ladder()
}
#[test]
fn test_valid_ladder_passes() {
let ladder = valid_vp9_hls_ladder();
let report = LadderValidator::new(LadderSpec::Hls).validate(&ladder);
assert!(report.is_ok(), "errors: {:?}", report.errors());
}
#[test]
fn test_empty_ladder_errors() {
let ladder = EncodeLadder::new(vec![]);
let report = LadderValidator::new(LadderSpec::Hls).validate(&ladder);
assert!(!report.is_ok());
assert!(report.error_count() >= 1);
}
#[test]
fn test_zero_bitrate_errors() {
let ladder = EncodeLadder::new(vec![
LadderRung::new(1920, 1080, 0, 30.0, "vp9"), ]);
let report = LadderValidator::new(LadderSpec::Generic).validate(&ladder);
assert!(!report.is_ok());
let msgs: Vec<&str> = report.errors().iter().map(|f| f.message.as_str()).collect();
assert!(
msgs.iter().any(|m| m.contains("zero bitrate")),
"msgs: {msgs:?}"
);
}
#[test]
fn test_bitrate_crossover_detected() {
let ladder = EncodeLadder::new(vec![
LadderRung::new(1920, 1080, 500_000, 30.0, "vp9"), LadderRung::new(1280, 720, 2_500_000, 30.0, "vp9"),
]);
let report = LadderValidator::new(LadderSpec::Hls).validate(&ladder);
let error_msgs: Vec<&str> = report.errors().iter().map(|f| f.message.as_str()).collect();
assert!(
error_msgs.iter().any(|m| m.contains("crossover")),
"errors: {error_msgs:?}"
);
}
#[test]
fn test_duplicate_resolution_warns() {
let ladder = EncodeLadder::new(vec![
LadderRung::new(1280, 720, 2_000_000, 30.0, "vp9"),
LadderRung::new(1280, 720, 1_000_000, 30.0, "vp9"), ]);
let report = LadderValidator::new(LadderSpec::Hls).validate(&ladder);
let warn_msgs: Vec<&str> = report
.warnings()
.iter()
.map(|f| f.message.as_str())
.collect();
assert!(
warn_msgs.iter().any(|m| m.contains("same resolution")),
"warnings: {warn_msgs:?}"
);
}
#[test]
fn test_invalid_codec_for_spec_errors() {
let ladder = EncodeLadder::new(vec![
LadderRung::new(1920, 1080, 4_000_000, 30.0, "theora"), ]);
let report = LadderValidator::new(LadderSpec::Hls).validate(&ladder);
assert!(report.error_count() > 0, "expected error for unsupported codec");
}
#[test]
fn test_segment_duration_out_of_range_errors() {
let ladder = EncodeLadder::new(vec![
LadderRung::new(1920, 1080, 4_000_000, 30.0, "av1")
.with_segment_duration(0.1), ]);
let report = LadderValidator::new(LadderSpec::Cmaf).validate(&ladder);
let error_msgs: Vec<&str> = report.errors().iter().map(|f| f.message.as_str()).collect();
assert!(
error_msgs.iter().any(|m| m.contains("segment duration")),
"errors: {error_msgs:?}"
);
}
#[test]
fn test_av1_cmaf_ladder_passes() {
let ladder = av1_cmaf_ladder();
let report = LadderValidator::new(LadderSpec::Cmaf).validate(&ladder);
assert!(report.is_ok(), "errors: {:?}", report.errors());
}
#[test]
fn test_bits_per_pixel() {
let rung = LadderRung::new(1920, 1080, 8_294_400, 30.0, "vp9");
let bpp = rung.bits_per_pixel();
assert!((bpp - 0.1333).abs() < 0.001, "bpp {bpp}");
}
#[test]
fn test_aspect_ratio() {
let rung = LadderRung::new(1920, 1080, 4_000_000, 30.0, "vp9");
assert_eq!(rung.aspect_ratio(), (16, 9));
let rung4_3 = LadderRung::new(640, 480, 1_000_000, 30.0, "vp9");
assert_eq!(rung4_3.aspect_ratio(), (4, 3));
}
#[test]
fn test_large_bitrate_gap_warns() {
let ladder = EncodeLadder::new(vec![
LadderRung::new(1920, 1080, 10_000_000, 30.0, "vp9"),
LadderRung::new(1280, 720, 100_000, 30.0, "vp9"), ]);
let report = LadderValidator::new(LadderSpec::Hls).validate(&ladder);
assert!(
!report.warnings().is_empty(),
"expected bitrate gap warning"
);
}
#[test]
fn test_rung_display() {
let rung = LadderRung::new(1920, 1080, 4_500_000, 30.0, "vp9");
let s = rung.to_string();
assert!(s.contains("1920"), "display: {s}");
assert!(s.contains("1080"), "display: {s}");
assert!(s.contains("vp9"), "display: {s}");
}
#[test]
fn test_codec_set() {
let ladder = EncodeLadder::new(vec![
LadderRung::new(1920, 1080, 4_000_000, 30.0, "vp9"),
LadderRung::new(1280, 720, 2_000_000, 30.0, "av1"),
]);
let codecs = ladder.codec_set();
assert!(codecs.contains("vp9"));
assert!(codecs.contains("av1"));
assert_eq!(codecs.len(), 2);
}
#[test]
fn test_sort_descending() {
let mut ladder = EncodeLadder::new(vec![
LadderRung::new(640, 360, 500_000, 30.0, "vp9"),
LadderRung::new(1920, 1080, 4_500_000, 30.0, "vp9"),
LadderRung::new(1280, 720, 2_500_000, 30.0, "vp9"),
]);
ladder.sort_descending();
assert_eq!(ladder.rungs[0].bitrate_bps, 4_500_000);
assert_eq!(ladder.rungs[1].bitrate_bps, 2_500_000);
assert_eq!(ladder.rungs[2].bitrate_bps, 500_000);
}
#[test]
fn test_spec_segment_duration_range() {
assert!(LadderSpec::Hls.segment_duration_range().is_some());
assert!(LadderSpec::LlHls.segment_duration_range().is_some());
assert!(LadderSpec::Generic.segment_duration_range().is_none());
let (min, max) = LadderSpec::LlHls.segment_duration_range().unwrap();
assert!(max < 3.0, "LL-HLS max segment should be short");
assert!(min < max);
}
}