#[derive(Debug, Clone)]
pub struct AvifProbe {
pub width: u32,
pub height: u32,
pub bit_depth: u8,
pub profile: u8,
pub monochrome: bool,
pub chroma_sampling: ChromaSampling,
pub has_alpha: bool,
pub has_animation: bool,
pub lossless: Option<bool>,
pub quality: Option<QualityEstimate>,
pub color_primaries: Option<u8>,
pub transfer_characteristics: Option<u8>,
pub matrix_coefficients: Option<u8>,
pub full_range: Option<bool>,
pub has_icc_profile: bool,
pub recommendations: Vec<Recommendation>,
}
#[derive(Debug, Clone)]
pub struct QualityEstimate {
pub quantizer: u8,
pub estimated_quality: f32,
pub confidence: Confidence,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Confidence {
FromFrameHeader,
Approximate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChromaSampling {
Yuv444,
Yuv422,
Yuv420,
Monochrome,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Recommendation {
ReduceQuality,
UseChromaSubsampling,
ReduceBitDepth,
AvoidReencoding,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProbeError {
TooShort,
NotAvif,
Truncated,
NoAv1Config,
}
impl core::fmt::Display for ProbeError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::TooShort => write!(f, "data too short to be an AVIF file"),
Self::NotAvif => write!(f, "not an AVIF file"),
Self::Truncated => write!(f, "truncated AVIF file"),
Self::NoAv1Config => write!(f, "no AV1 codec configuration found"),
}
}
}
impl std::error::Error for ProbeError {}
pub fn probe(data: &[u8]) -> Result<AvifProbe, ProbeError> {
if data.len() < 12 {
return Err(ProbeError::TooShort);
}
if &data[4..8] != b"ftyp" {
return Err(ProbeError::NotAvif);
}
let parser = zenavif_parse::AvifParser::from_bytes(data).map_err(|e| match e {
zenavif_parse::Error::UnexpectedEOF => ProbeError::Truncated,
zenavif_parse::Error::InvalidData(_) => ProbeError::NotAvif,
_ => ProbeError::Truncated,
})?;
let has_alpha = parser.alpha_data().is_some();
let has_animation = parser.animation_info().is_some();
let (color_primaries, transfer_characteristics, matrix_coefficients, full_range, has_icc) =
match parser.color_info() {
Some(zenavif_parse::ColorInformation::Nclx {
color_primaries: cp,
transfer_characteristics: tc,
matrix_coefficients: mc,
full_range: fr,
}) => (
Some(*cp as u8),
Some(*tc as u8),
Some(*mc as u8),
Some(*fr),
false,
),
Some(zenavif_parse::ColorInformation::IccProfile(_)) => (None, None, None, None, true),
None => (None, None, None, None, false),
};
let primary_data = parser.primary_data().map_err(|_| ProbeError::NoAv1Config)?;
let meta = zenavif_parse::AV1Metadata::parse_av1_bitstream(&primary_data)
.map_err(|_| ProbeError::NoAv1Config)?;
let width = meta.max_frame_width.get();
let height = meta.max_frame_height.get();
let bit_depth = meta.bit_depth;
let profile = meta.seq_profile;
let monochrome = meta.monochrome;
let cs = meta.chroma_subsampling;
let chroma_sampling = if monochrome {
ChromaSampling::Monochrome
} else if cs.horizontal && cs.vertical {
ChromaSampling::Yuv420
} else if cs.horizontal {
ChromaSampling::Yuv422
} else {
ChromaSampling::Yuv444
};
let lossless = meta.lossless;
let quality = meta.base_q_idx.map(|qp| QualityEstimate {
quantizer: qp,
estimated_quality: qp_to_quality(qp),
confidence: Confidence::FromFrameHeader,
});
let mut recommendations = Vec::new();
if lossless == Some(true) {
} else {
if let Some(ref q) = quality {
if q.estimated_quality > 85.0 {
recommendations.push(Recommendation::ReduceQuality);
}
if q.estimated_quality < 30.0 {
recommendations.push(Recommendation::AvoidReencoding);
}
}
}
if chroma_sampling == ChromaSampling::Yuv444 && !monochrome && lossless != Some(true) {
recommendations.push(Recommendation::UseChromaSubsampling);
}
if bit_depth > 8 {
let is_hdr = transfer_characteristics
.map(|tc| tc == 16 || tc == 18) .unwrap_or(false);
if !is_hdr {
recommendations.push(Recommendation::ReduceBitDepth);
}
}
Ok(AvifProbe {
width,
height,
bit_depth,
profile,
monochrome,
chroma_sampling,
has_alpha,
has_animation,
lossless,
quality,
color_primaries,
transfer_characteristics,
matrix_coefficients,
full_range,
has_icc_profile: has_icc,
recommendations,
})
}
impl AvifProbe {
pub fn estimated_quality(&self) -> Option<f32> {
self.quality.as_ref().map(|q| q.estimated_quality)
}
pub fn recommended_quality(&self) -> Option<f32> {
self.quality.as_ref().map(|q| {
(q.estimated_quality + 2.0).min(100.0)
})
}
}
fn qp_to_quality(qp: u8) -> f32 {
if qp == 0 {
return 100.0;
}
let q = qp as f32;
let quality = 100.0 - (q * 100.0 / 255.0);
let quality = quality * (1.0 + 0.3 * (1.0 - quality / 100.0));
quality.clamp(1.0, 100.0)
}
#[cfg(feature = "zencodec")]
impl zencodec::SourceEncodingDetails for AvifProbe {
fn source_generic_quality(&self) -> Option<f32> {
self.quality.as_ref().map(|q| q.estimated_quality)
}
fn is_lossless(&self) -> bool {
self.lossless.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_qp_to_quality_boundaries() {
assert_eq!(qp_to_quality(0), 100.0);
let worst = qp_to_quality(255);
assert!((1.0..=5.0).contains(&worst), "QP 255 → {worst}");
}
#[test]
fn test_qp_to_quality_monotonic() {
let mut prev = 100.0f32;
for qp in 1..=255u8 {
let q = qp_to_quality(qp);
assert!(q <= prev, "QP {qp}: {q} > previous {prev}");
prev = q;
}
}
#[test]
fn test_probe_too_short() {
assert_eq!(probe(&[]).unwrap_err(), ProbeError::TooShort);
assert_eq!(probe(&[0; 11]).unwrap_err(), ProbeError::TooShort);
}
#[test]
fn test_probe_not_avif() {
let mut data = vec![0u8; 32];
data[0..4].copy_from_slice(&12u32.to_be_bytes());
data[4..8].copy_from_slice(b"moov");
data[8..12].copy_from_slice(b"isom");
assert_eq!(probe(&data).unwrap_err(), ProbeError::NotAvif);
let mut data = vec![0u8; 32];
data[0..4].copy_from_slice(&12u32.to_be_bytes());
data[4..8].copy_from_slice(b"ftyp");
data[8..12].copy_from_slice(b"isom");
let err = probe(&data).unwrap_err();
assert!(err == ProbeError::NotAvif || err == ProbeError::Truncated);
}
#[test]
#[ignore] fn test_probe_all_vectors() {
let dir = "tests/vectors/libavif";
let Ok(entries) = std::fs::read_dir(dir) else {
eprintln!("No test vectors at {dir}");
return;
};
let mut probed = 0;
let mut with_qp = 0;
let mut with_lossless = 0;
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("avif") {
continue;
}
let data = std::fs::read(&path).unwrap();
let result = probe(&data);
let name = path.file_name().unwrap().to_str().unwrap();
match result {
Ok(info) => {
assert!(info.width > 0 && info.height > 0, "{name}: zero dimensions");
assert!(
[8, 10, 12].contains(&info.bit_depth),
"{name}: bad bit_depth {}",
info.bit_depth
);
if let Some(ref q) = info.quality {
assert!(
(1.0..=100.0).contains(&q.estimated_quality),
"{name}: quality {:.1} out of range",
q.estimated_quality
);
with_qp += 1;
}
if info.lossless.is_some() {
with_lossless += 1;
}
eprintln!(
" {name}: {}x{} {}bpc {:?} qp={:?} lossless={:?}",
info.width,
info.height,
info.bit_depth,
info.chroma_sampling,
info.quality.as_ref().map(|q| q.quantizer),
info.lossless,
);
#[cfg(feature = "zencodec")]
{
use zencodec::SourceEncodingDetails;
if info.lossless == Some(true) {
assert!(info.is_lossless(), "{name}: lossless but trait says false");
}
}
probed += 1;
}
Err(e) => {
eprintln!(" {name}: probe failed: {e}");
}
}
}
eprintln!(
"\n Probed {probed} files, {with_qp} with QP, {with_lossless} with lossless detection"
);
assert!(probed > 30, "Expected to probe >30 files, got {probed}");
assert!(with_qp > 20, "Expected >20 files with QP, got {with_qp}");
assert!(
with_lossless > 20,
"Expected >20 files with lossless detection, got {with_lossless}"
);
}
}