use super::{ArtifactMetadata, TransformError, read_u16_be, read_u32_be, read_u64_be};
pub(super) fn sniff_avif(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
if bytes.len() < 16 {
return Err(TransformError::DecodeFailed(
"avif file is too short".to_string(),
));
}
if !has_avif_brand(&bytes[8..]) {
return Err(TransformError::DecodeFailed(
"avif file is missing a compatible AVIF brand".to_string(),
));
}
let inspection = inspect_avif_container(bytes)?;
let dimensions = match (inspection.dimensions, inspection.clean_aperture()) {
(Some((width, height)), Some(aperture)) => {
let (_, _, aperture_width, aperture_height) = aperture.rectangle(width, height)?;
Some((aperture_width, aperture_height))
}
(dimensions, _) => dimensions,
};
Ok(ArtifactMetadata {
width: dimensions.map(|(width, _)| width),
height: dimensions.map(|(_, height)| height),
frame_count: if declares_image_sequence(&bytes[8..]) {
count_avif_samples(bytes).max(2)
} else {
1
},
duration: None,
has_alpha: inspection.has_alpha(),
orientation: inspection.orientation(),
})
}
fn count_avif_samples(bytes: &[u8]) -> u32 {
fn walk(bytes: &[u8], inside_moov: bool) -> u32 {
let mut offset = 0;
while offset + 8 <= bytes.len() {
let Ok((box_type, payload, next_offset)) = parse_mp4_box(bytes, offset) else {
return 0;
};
match box_type {
b"moov" => {
let found = walk(payload, true);
if found > 0 {
return found;
}
}
b"trak" | b"mdia" | b"minf" | b"stbl" if inside_moov => {
let found = walk(payload, true);
if found > 0 {
return found;
}
}
b"stsz" if inside_moov && payload.len() >= 12 => {
if let Ok(count) = read_u32_be(&payload[8..12]) {
return count;
}
}
_ => {}
}
if next_offset <= offset {
return 0;
}
offset = next_offset;
}
0
}
walk(bytes, false)
}
fn declares_image_sequence(bytes: &[u8]) -> bool {
if bytes.len() < 4 {
return false;
}
if &bytes[0..4] == b"avis" {
return true;
}
let mut offset = 8;
while offset + 4 <= bytes.len() {
if &bytes[offset..offset + 4] == b"avis" {
return true;
}
offset += 4;
}
false
}
pub(super) fn has_avif_brand(bytes: &[u8]) -> bool {
if bytes.len() < 8 {
return false;
}
if is_avif_brand(&bytes[0..4]) {
return true;
}
let mut offset = 8;
while offset + 4 <= bytes.len() {
if is_avif_brand(&bytes[offset..offset + 4]) {
return true;
}
offset += 4;
}
false
}
fn is_avif_brand(bytes: &[u8]) -> bool {
matches!(bytes, b"avif" | b"avis")
}
const AVIF_ALPHA_AUX_TYPE: &[u8] = b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AvifProperty {
Rotation(u8),
Mirror(u8),
CleanAperture(AvifCleanAperture),
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AvifCleanAperture {
width: (u32, u32),
height: (u32, u32),
horizontal_offset: (i32, u32),
vertical_offset: (i32, u32),
}
impl AvifCleanAperture {
fn parse(payload: &[u8]) -> Result<Self, TransformError> {
if payload.len() < 32 {
return Err(avif_box_too_short("clap"));
}
let field = |index: usize| read_u32_be(&payload[index * 4..index * 4 + 4]);
Ok(Self {
width: (field(0)?, field(1)?),
height: (field(2)?, field(3)?),
horizontal_offset: (field(4)? as i32, field(5)?),
vertical_offset: (field(6)? as i32, field(7)?),
})
}
pub(crate) fn rectangle(
self,
width: u32,
height: u32,
) -> Result<(u32, u32, u32, u32), TransformError> {
let aperture_width = avif_aperture_size(self.width, "width")?;
let aperture_height = avif_aperture_size(self.height, "height")?;
let x = avif_aperture_origin(width, aperture_width, self.horizontal_offset, "horizontal")?;
let y = avif_aperture_origin(height, aperture_height, self.vertical_offset, "vertical")?;
Ok((x, y, aperture_width, aperture_height))
}
}
fn avif_aperture_size(
(numerator, denominator): (u32, u32),
axis: &str,
) -> Result<u32, TransformError> {
if denominator == 0 || numerator == 0 || numerator % denominator != 0 {
return Err(TransformError::DecodeFailed(format!(
"avif clean aperture {axis} {numerator}/{denominator} is not a whole number of pixels"
)));
}
Ok(numerator / denominator)
}
fn avif_aperture_origin(
picture: u32,
aperture: u32,
(numerator, denominator): (i32, u32),
axis: &str,
) -> Result<u32, TransformError> {
if denominator == 0 {
return Err(TransformError::DecodeFailed(format!(
"avif clean aperture {axis} offset has a zero denominator"
)));
}
if aperture > picture {
return Err(TransformError::DecodeFailed(format!(
"avif clean aperture is larger than the {picture}-pixel picture along the {axis} axis"
)));
}
let scaled = i64::from(picture - aperture) * i64::from(denominator) + 2 * i64::from(numerator);
let divisor = 2 * i64::from(denominator);
if scaled % divisor != 0 {
return Err(TransformError::DecodeFailed(format!(
"avif clean aperture {axis} offset {numerator}/{denominator} does not land on a whole pixel"
)));
}
let origin = scaled / divisor;
if origin < 0 || origin + i64::from(aperture) > i64::from(picture) {
return Err(TransformError::DecodeFailed(format!(
"avif clean aperture leaves the picture along the {axis} axis"
)));
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Ok(origin as u32)
}
#[derive(Debug, Default)]
struct AvifInspection {
dimensions: Option<(u32, u32)>,
saw_structured_meta: bool,
found_alpha_item: bool,
primary_item: Option<u32>,
properties: Vec<AvifProperty>,
associations: Vec<(u32, Vec<u16>)>,
}
impl AvifInspection {
fn has_alpha(&self) -> Option<bool> {
if self.saw_structured_meta {
Some(self.found_alpha_item)
} else {
None
}
}
fn orientation(&self) -> Option<u16> {
let mut rotation = None;
let mut mirror = None;
for property in self.primary_properties() {
match property {
AvifProperty::Rotation(angle) => rotation = Some(*angle),
AvifProperty::Mirror(mode) => mirror = Some(*mode),
_ => {}
}
}
if rotation.is_none() && mirror.is_none() {
return None;
}
Some(avif_orientation_value(rotation.unwrap_or(0), mirror))
}
fn clean_aperture(&self) -> Option<AvifCleanAperture> {
self.primary_properties()
.find_map(|property| match property {
AvifProperty::CleanAperture(aperture) => Some(*aperture),
_ => None,
})
}
fn primary_properties(&self) -> impl Iterator<Item = &AvifProperty> {
let positions = self.primary_item.and_then(|primary_item| {
self.associations
.iter()
.find(|(item, _)| *item == primary_item)
.map(|(_, positions)| positions.as_slice())
});
positions.unwrap_or(&[]).iter().filter_map(|position| {
usize::from(*position)
.checked_sub(1)
.and_then(|index| self.properties.get(index))
})
}
}
fn names_a_metadata_item(window: &[u8]) -> bool {
window == b"Exif" || window == b"mime"
}
pub(crate) fn avif_carries_metadata(bytes: &[u8]) -> bool {
fn walk(bytes: &[u8]) -> bool {
let mut offset = 0;
while offset + 8 <= bytes.len() {
let Ok((box_type, payload, next_offset)) = parse_mp4_box(bytes, offset) else {
return false;
};
match box_type {
b"meta" => {
if payload.len() >= 4 && walk(&payload[4..]) {
return true;
}
}
b"iinf" if payload.windows(4).any(names_a_metadata_item) => return true,
_ => {}
}
if next_offset <= offset {
return false;
}
offset = next_offset;
}
false
}
walk(bytes)
}
#[cfg(feature = "avif")]
pub(crate) fn avif_clean_aperture(
bytes: &[u8],
) -> Result<Option<AvifCleanAperture>, TransformError> {
Ok(inspect_avif_container(bytes)?.clean_aperture())
}
pub(super) fn avif_orientation(bytes: &[u8]) -> Option<u16> {
inspect_avif_container(bytes).ok()?.orientation()
}
fn avif_orientation_value(angle: u8, mirror: Option<u8>) -> u16 {
const NO_MIRROR: [u16; 4] = [1, 8, 3, 6];
const TOP_BOTTOM: [u16; 4] = [4, 5, 2, 7];
const LEFT_RIGHT: [u16; 4] = [2, 7, 4, 5];
let row = match mirror {
None => NO_MIRROR,
Some(0) => TOP_BOTTOM,
Some(_) => LEFT_RIGHT,
};
row[usize::from(angle & 0b11)]
}
fn inspect_avif_container(bytes: &[u8]) -> Result<AvifInspection, TransformError> {
let mut inspection = AvifInspection::default();
inspect_avif_boxes(bytes, &mut inspection)?;
Ok(inspection)
}
fn inspect_avif_boxes(bytes: &[u8], inspection: &mut AvifInspection) -> Result<(), TransformError> {
let mut offset = 0;
while offset + 8 <= bytes.len() {
let (box_type, payload, next_offset) = parse_mp4_box(bytes, offset)?;
match box_type {
b"meta" | b"iref" => {
inspection.saw_structured_meta = true;
if payload.len() < 4 {
return Err(TransformError::DecodeFailed(format!(
"{} box is too short",
String::from_utf8_lossy(box_type)
)));
}
inspect_avif_boxes(&payload[4..], inspection)?;
}
b"iprp" => {
inspection.saw_structured_meta = true;
inspect_avif_boxes(payload, inspection)?;
}
b"ipco" => {
inspection.saw_structured_meta = true;
inspect_avif_properties(payload, inspection)?;
}
b"pitm" => {
inspection.saw_structured_meta = true;
inspection.primary_item = Some(parse_avif_pitm(payload)?);
}
b"ipma" => {
inspection.saw_structured_meta = true;
inspection.associations.extend(parse_avif_ipma(payload)?);
}
b"auxl" => {
inspection.saw_structured_meta = true;
inspection.found_alpha_item = true;
}
_ => {}
}
offset = next_offset;
}
if offset != bytes.len() {
return Err(TransformError::DecodeFailed(
"avif box payload has trailing bytes".to_string(),
));
}
Ok(())
}
fn inspect_avif_properties(
bytes: &[u8],
inspection: &mut AvifInspection,
) -> Result<(), TransformError> {
let mut offset = 0;
while offset + 8 <= bytes.len() {
let (box_type, payload, next_offset) = parse_mp4_box(bytes, offset)?;
let property = match box_type {
b"ispe" => {
if inspection.dimensions.is_none() {
inspection.dimensions = Some(parse_avif_ispe(payload)?);
}
AvifProperty::Other
}
b"auxC" => {
if avif_auxc_declares_alpha(payload)? {
inspection.found_alpha_item = true;
}
AvifProperty::Other
}
b"irot" => AvifProperty::Rotation(avif_transform_byte(payload, "irot")? & 0b11),
b"imir" => AvifProperty::Mirror(avif_transform_byte(payload, "imir")? & 0b1),
b"clap" => AvifProperty::CleanAperture(AvifCleanAperture::parse(payload)?),
_ => AvifProperty::Other,
};
inspection.properties.push(property);
offset = next_offset;
}
if offset != bytes.len() {
return Err(TransformError::DecodeFailed(
"avif box payload has trailing bytes".to_string(),
));
}
Ok(())
}
fn avif_box_too_short(box_name: &str) -> TransformError {
TransformError::DecodeFailed(format!("avif {box_name} box is too short"))
}
fn avif_transform_byte(bytes: &[u8], box_name: &str) -> Result<u8, TransformError> {
bytes
.first()
.copied()
.ok_or_else(|| avif_box_too_short(box_name))
}
fn parse_avif_pitm(bytes: &[u8]) -> Result<u32, TransformError> {
let too_short = || avif_box_too_short("pitm");
match bytes.first().ok_or_else(too_short)? {
0 => Ok(u32::from(read_u16_be(
bytes.get(4..6).ok_or_else(too_short)?,
)?)),
_ => read_u32_be(bytes.get(4..8).ok_or_else(too_short)?),
}
}
fn parse_avif_ipma(bytes: &[u8]) -> Result<Vec<(u32, Vec<u16>)>, TransformError> {
let too_short = || avif_box_too_short("ipma");
let version = *bytes.first().ok_or_else(too_short)?;
let wide_positions = bytes.get(3).ok_or_else(too_short)? & 1 == 1;
let entry_count = read_u32_be(bytes.get(4..8).ok_or_else(too_short)?)?;
let mut offset = 8;
let mut entries = Vec::new();
for _ in 0..entry_count {
let item = if version == 0 {
let item = read_u16_be(bytes.get(offset..offset + 2).ok_or_else(too_short)?)?;
offset += 2;
u32::from(item)
} else {
let item = read_u32_be(bytes.get(offset..offset + 4).ok_or_else(too_short)?)?;
offset += 4;
item
};
let count = *bytes.get(offset).ok_or_else(too_short)?;
offset += 1;
let mut positions = Vec::with_capacity(usize::from(count));
for _ in 0..count {
let position = if wide_positions {
let position = read_u16_be(bytes.get(offset..offset + 2).ok_or_else(too_short)?)?;
offset += 2;
position & 0x7FFF
} else {
let position = *bytes.get(offset).ok_or_else(too_short)?;
offset += 1;
u16::from(position & 0x7F)
};
positions.push(position);
}
entries.push((item, positions));
}
Ok(entries)
}
fn parse_mp4_box(bytes: &[u8], offset: usize) -> Result<(&[u8; 4], &[u8], usize), TransformError> {
if offset + 8 > bytes.len() {
return Err(TransformError::DecodeFailed(
"mp4 box header is truncated".to_string(),
));
}
let size = read_u32_be(&bytes[offset..offset + 4])?;
let box_type = bytes[offset + 4..offset + 8]
.try_into()
.map_err(|_| TransformError::DecodeFailed("expected 4-byte box type".to_string()))?;
let mut header_len = 8_usize;
let end = match size {
0 => bytes.len(),
1 => {
if offset + 16 > bytes.len() {
return Err(TransformError::DecodeFailed(
"extended mp4 box header is truncated".to_string(),
));
}
header_len = 16;
let extended_size = read_u64_be(&bytes[offset + 8..offset + 16])?;
usize::try_from(extended_size)
.map_err(|_| TransformError::DecodeFailed("mp4 box is too large".to_string()))?
}
_ => size as usize,
};
if end < header_len {
return Err(TransformError::DecodeFailed(
"mp4 box size is smaller than its header".to_string(),
));
}
let box_end = offset
.checked_add(end)
.ok_or_else(|| TransformError::DecodeFailed("mp4 box is too large".to_string()))?;
if box_end > bytes.len() {
return Err(TransformError::DecodeFailed(
"mp4 box exceeds file length".to_string(),
));
}
Ok((box_type, &bytes[offset + header_len..box_end], box_end))
}
fn parse_avif_ispe(bytes: &[u8]) -> Result<(u32, u32), TransformError> {
if bytes.len() < 12 {
return Err(TransformError::DecodeFailed(
"avif ispe box is too short".to_string(),
));
}
let width = read_u32_be(&bytes[4..8])?;
let height = read_u32_be(&bytes[8..12])?;
Ok((width, height))
}
fn avif_auxc_declares_alpha(bytes: &[u8]) -> Result<bool, TransformError> {
if bytes.len() < 5 {
return Err(TransformError::DecodeFailed(
"avif auxC box is too short".to_string(),
));
}
let urn = &bytes[4..];
Ok(urn
.strip_suffix(&[0])
.is_some_and(|urn| urn == AVIF_ALPHA_AUX_TYPE))
}
#[cfg(feature = "avif")]
mod metadata;
#[cfg(feature = "avif")]
pub(crate) use metadata::{avif_metadata, avif_with_metadata};