use alloc::format;
use alloc::vec::Vec;
use crate::types::{Error, Fraction, GainMapMetadata, Result, UnsignedFraction};
pub const ISO_VERSION: u8 = 0;
const FLAG_MULTI_CHANNEL: u8 = 0x80;
const FLAG_USE_BASE_COLOUR_SPACE: u8 = 0x40;
const FLAG_COMMON_DENOMINATOR: u8 = 0x08;
const FLAG_BACKWARD_DIRECTION: u8 = 0x04;
const HEADER_SIZE: usize = 6;
const FRACTION_SIZE: usize = 8;
const HEADROOM_FRACTIONS: usize = 2;
const FRACTIONS_PER_CHANNEL: usize = 5;
pub fn parse_iso21496(data: &[u8], format: crate::Iso21496Format) -> Result<GainMapMetadata> {
match format {
crate::Iso21496Format::AvifTmap => parse_iso21496_avif(data),
crate::Iso21496Format::JpegApp2 => parse_iso21496_jpeg(data),
}
}
pub fn serialize_iso21496(metadata: &GainMapMetadata, format: crate::Iso21496Format) -> Vec<u8> {
match format {
crate::Iso21496Format::AvifTmap => serialize_iso21496_avif(metadata),
crate::Iso21496Format::JpegApp2 => serialize_iso21496_jpeg(metadata),
}
}
fn parse_iso21496_avif(data: &[u8]) -> Result<GainMapMetadata> {
if data.len() < HEADER_SIZE {
return Err(Error::IsoParse(format!(
"data too short: need at least {} bytes, got {}",
HEADER_SIZE,
data.len()
)));
}
let mut pos = 0;
let version = data[pos];
pos += 1;
if version != ISO_VERSION {
return Err(Error::IsoParse(format!(
"unsupported version {}, expected {}",
version, ISO_VERSION
)));
}
let minimum_version = read_u16_be(data, pos);
pos += 2;
if minimum_version > 0 {
return Err(Error::IsoParse(format!(
"unsupported minimum_version {}",
minimum_version
)));
}
let writer_version = read_u16_be(data, pos);
pos += 2;
if writer_version < minimum_version {
return Err(Error::IsoParse(format!(
"writer_version {} < minimum_version {}",
writer_version, minimum_version
)));
}
let flags = data[pos];
pos += 1;
let is_multichannel = (flags & FLAG_MULTI_CHANNEL) != 0;
let use_base_colour_space = (flags & FLAG_USE_BASE_COLOUR_SPACE) != 0;
let backward_direction = (flags & FLAG_BACKWARD_DIRECTION) != 0;
let common_denominator = (flags & FLAG_COMMON_DENOMINATOR) != 0;
let channel_count: usize = if is_multichannel { 3 } else { 1 };
if common_denominator {
read_payload_common_denom(
data,
pos,
channel_count,
use_base_colour_space,
backward_direction,
)
} else {
let required = HEADER_SIZE
+ HEADROOM_FRACTIONS * FRACTION_SIZE
+ channel_count * FRACTIONS_PER_CHANNEL * FRACTION_SIZE;
if data.len() < required {
return Err(Error::IsoParse(format!(
"data truncated: need {} bytes for {} channel(s), got {}",
required,
channel_count,
data.len()
)));
}
read_payload(
data,
pos,
channel_count,
use_base_colour_space,
backward_direction,
)
}
}
fn serialize_iso21496_avif(metadata: &GainMapMetadata) -> Vec<u8> {
let is_multichannel = !metadata.is_single_channel();
let channel_count: usize = if is_multichannel { 3 } else { 1 };
let capacity = HEADER_SIZE
+ HEADROOM_FRACTIONS * FRACTION_SIZE
+ channel_count * FRACTIONS_PER_CHANNEL * FRACTION_SIZE;
let mut buf = Vec::with_capacity(capacity);
buf.push(ISO_VERSION);
buf.extend_from_slice(&0u16.to_be_bytes());
buf.extend_from_slice(&0u16.to_be_bytes());
let mut flags = 0u8;
if is_multichannel {
flags |= FLAG_MULTI_CHANNEL;
}
if metadata.use_base_color_space {
flags |= FLAG_USE_BASE_COLOUR_SPACE;
}
if metadata.backward_direction {
flags |= FLAG_BACKWARD_DIRECTION;
}
buf.push(flags);
write_payload(&mut buf, metadata, channel_count);
buf
}
const JPEG_HEADER_SIZE: usize = 5;
fn serialize_iso21496_jpeg(metadata: &GainMapMetadata) -> Vec<u8> {
let is_multichannel = !metadata.is_single_channel();
let channel_count: usize = if is_multichannel { 3 } else { 1 };
let capacity = JPEG_HEADER_SIZE
+ HEADROOM_FRACTIONS * FRACTION_SIZE
+ channel_count * FRACTIONS_PER_CHANNEL * FRACTION_SIZE;
let mut buf = Vec::with_capacity(capacity);
buf.extend_from_slice(&0u16.to_be_bytes());
buf.extend_from_slice(&0u16.to_be_bytes());
let mut flags = 0u8;
if is_multichannel {
flags |= FLAG_MULTI_CHANNEL;
}
if metadata.use_base_color_space {
flags |= FLAG_USE_BASE_COLOUR_SPACE;
}
if metadata.backward_direction {
flags |= FLAG_BACKWARD_DIRECTION;
}
buf.push(flags);
write_payload(&mut buf, metadata, channel_count);
buf
}
fn parse_iso21496_jpeg(data: &[u8]) -> Result<GainMapMetadata> {
if data.len() < JPEG_HEADER_SIZE {
return Err(Error::IsoParse(format!(
"JPEG ISO data too short: need at least {} bytes, got {}",
JPEG_HEADER_SIZE,
data.len()
)));
}
let mut pos = 0;
let minimum_version = read_u16_be(data, pos);
pos += 2;
if minimum_version > 0 {
return Err(Error::IsoParse(format!(
"unsupported minimum_version {}",
minimum_version
)));
}
let _writer_version = read_u16_be(data, pos);
pos += 2;
let flags = data[pos];
pos += 1;
let is_multichannel = (flags & FLAG_MULTI_CHANNEL) != 0;
let use_base_colour_space = (flags & FLAG_USE_BASE_COLOUR_SPACE) != 0;
let backward_direction = (flags & FLAG_BACKWARD_DIRECTION) != 0;
let common_denominator = (flags & FLAG_COMMON_DENOMINATOR) != 0;
let channel_count: usize = if is_multichannel { 3 } else { 1 };
if common_denominator {
read_payload_common_denom(
data,
pos,
channel_count,
use_base_colour_space,
backward_direction,
)
} else {
let required = JPEG_HEADER_SIZE
+ HEADROOM_FRACTIONS * FRACTION_SIZE
+ channel_count * FRACTIONS_PER_CHANNEL * FRACTION_SIZE;
if data.len() < required {
return Err(Error::IsoParse(format!(
"data truncated: need {} bytes for {} channel(s), got {}",
required,
channel_count,
data.len()
)));
}
read_payload(
data,
pos,
channel_count,
use_base_colour_space,
backward_direction,
)
}
}
pub struct JpegIsoMarkers {
pub primary: Vec<u8>,
pub gain_map: Vec<u8>,
}
pub fn create_jpeg_iso_markers(metadata: &crate::GainMapMetadata) -> JpegIsoMarkers {
let iso_payload = serialize_iso21496_jpeg(metadata);
JpegIsoMarkers {
primary: create_version_only_iso_app2(),
gain_map: create_iso_app2_marker(&iso_payload),
}
}
pub fn create_version_only_iso_app2() -> Vec<u8> {
let version_payload: &[u8] = &[0x00, 0x00, 0x00, 0x00]; create_iso_app2_marker(version_payload)
}
pub fn create_iso_app2_marker(iso_data: &[u8]) -> Vec<u8> {
let namespace = b"urn:iso:std:iso:ts:21496:-1\0";
let total_length = 2 + namespace.len() + iso_data.len();
let mut marker = Vec::with_capacity(2 + total_length);
marker.push(0xFF);
marker.push(0xE2); marker.push(((total_length >> 8) & 0xFF) as u8);
marker.push((total_length & 0xFF) as u8);
marker.extend_from_slice(namespace);
marker.extend_from_slice(iso_data);
marker
}
#[inline]
fn read_u16_be(data: &[u8], pos: usize) -> u16 {
u16::from_be_bytes([data[pos], data[pos + 1]])
}
fn read_signed_fraction(data: &[u8], pos: usize) -> Result<(Fraction, usize)> {
if pos + FRACTION_SIZE > data.len() {
return Err(Error::IsoParse(
"unexpected end of data reading fraction".into(),
));
}
let numerator = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
let denominator =
u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]]);
if denominator == 0 {
return Err(Error::IsoParse(
"zero denominator in signed fraction".into(),
));
}
Ok((Fraction::new(numerator, denominator), pos + FRACTION_SIZE))
}
fn read_unsigned_fraction(data: &[u8], pos: usize) -> Result<(UnsignedFraction, usize)> {
if pos + FRACTION_SIZE > data.len() {
return Err(Error::IsoParse(
"unexpected end of data reading unsigned fraction".into(),
));
}
let numerator = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
let denominator =
u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]]);
if denominator == 0 {
return Err(Error::IsoParse(
"zero denominator in unsigned fraction".into(),
));
}
Ok((
UnsignedFraction::new(numerator, denominator),
pos + FRACTION_SIZE,
))
}
fn write_signed_fraction(buf: &mut Vec<u8>, frac: Fraction) {
buf.extend_from_slice(&frac.numerator.to_be_bytes());
buf.extend_from_slice(&frac.denominator.to_be_bytes());
}
fn write_unsigned_fraction(buf: &mut Vec<u8>, frac: UnsignedFraction) {
buf.extend_from_slice(&frac.numerator.to_be_bytes());
buf.extend_from_slice(&frac.denominator.to_be_bytes());
}
fn read_payload(
data: &[u8],
mut pos: usize,
channel_count: usize,
use_base_colour_space: bool,
backward_direction: bool,
) -> Result<GainMapMetadata> {
let is_multichannel = channel_count == 3;
let (base_headroom, new_pos) = read_unsigned_fraction(data, pos)?;
pos = new_pos;
let (alt_headroom, new_pos) = read_unsigned_fraction(data, pos)?;
pos = new_pos;
let mut metadata = GainMapMetadata {
base_hdr_headroom: base_headroom.to_f32() as f64,
alternate_hdr_headroom: alt_headroom.to_f32() as f64,
use_base_color_space: use_base_colour_space,
backward_direction,
..Default::default()
};
for ch in 0..channel_count {
let (min_frac, p) = read_signed_fraction(data, pos)?;
pos = p;
let (max_frac, p) = read_signed_fraction(data, pos)?;
pos = p;
let (gamma_frac, p) = read_unsigned_fraction(data, pos)?;
pos = p;
let (base_offset_frac, p) = read_signed_fraction(data, pos)?;
pos = p;
let (alt_offset_frac, p) = read_signed_fraction(data, pos)?;
pos = p;
let min_val = min_frac.to_f32() as f64;
let max_val = max_frac.to_f32() as f64;
let gamma_val = gamma_frac.to_f32() as f64;
let base_off = base_offset_frac.to_f32() as f64;
let alt_off = alt_offset_frac.to_f32() as f64;
if is_multichannel {
metadata.gain_map_min[ch] = min_val;
metadata.gain_map_max[ch] = max_val;
metadata.gamma[ch] = gamma_val;
metadata.base_offset[ch] = base_off;
metadata.alternate_offset[ch] = alt_off;
} else {
metadata.gain_map_min = [min_val; 3];
metadata.gain_map_max = [max_val; 3];
metadata.gamma = [gamma_val; 3];
metadata.base_offset = [base_off; 3];
metadata.alternate_offset = [alt_off; 3];
}
}
Ok(metadata)
}
fn read_payload_common_denom(
data: &[u8],
mut pos: usize,
channel_count: usize,
use_base_colour_space: bool,
backward_direction: bool,
) -> Result<GainMapMetadata> {
if pos + 4 > data.len() {
return Err(Error::IsoParse(
"unexpected end of data reading common denominator".into(),
));
}
let common_d = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
pos += 4;
if common_d == 0 {
return Err(Error::IsoParse("zero common denominator".into()));
}
let values_needed = 2 + channel_count * 5; if pos + values_needed * 4 > data.len() {
return Err(Error::IsoParse(format!(
"data truncated for common_denominator format: need {} bytes from pos {}, got {}",
values_needed * 4,
pos,
data.len() - pos
)));
}
let base_headroom_n =
u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
pos += 4;
let alt_headroom_n =
u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
pos += 4;
let base_hdr = UnsignedFraction::new(base_headroom_n, common_d).to_f32() as f64;
let alt_hdr = UnsignedFraction::new(alt_headroom_n, common_d).to_f32() as f64;
let mut metadata = GainMapMetadata {
base_hdr_headroom: base_hdr,
alternate_hdr_headroom: alt_hdr,
use_base_color_space: use_base_colour_space,
backward_direction,
..Default::default()
};
for ch in 0..channel_count {
let min_n = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
pos += 4;
let max_n = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
pos += 4;
let gamma_n = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
pos += 4;
let base_off_n =
i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
pos += 4;
let alt_off_n =
i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
pos += 4;
let min_val = Fraction::new(min_n, common_d).to_f32() as f64;
let max_val = Fraction::new(max_n, common_d).to_f32() as f64;
let gamma_val = UnsignedFraction::new(gamma_n, common_d).to_f32() as f64;
let base_off = Fraction::new(base_off_n, common_d).to_f32() as f64;
let alt_off = Fraction::new(alt_off_n, common_d).to_f32() as f64;
if channel_count == 3 {
metadata.gain_map_min[ch] = min_val;
metadata.gain_map_max[ch] = max_val;
metadata.gamma[ch] = gamma_val;
metadata.base_offset[ch] = base_off;
metadata.alternate_offset[ch] = alt_off;
} else {
metadata.gain_map_min = [min_val; 3];
metadata.gain_map_max = [max_val; 3];
metadata.gamma = [gamma_val; 3];
metadata.base_offset = [base_off; 3];
metadata.alternate_offset = [alt_off; 3];
}
}
Ok(metadata)
}
fn write_payload(buf: &mut Vec<u8>, metadata: &GainMapMetadata, channel_count: usize) {
let base_headroom = UnsignedFraction::from_f32(metadata.base_hdr_headroom as f32);
write_unsigned_fraction(buf, base_headroom);
let alt_headroom = UnsignedFraction::from_f32(metadata.alternate_hdr_headroom as f32);
write_unsigned_fraction(buf, alt_headroom);
for ch in 0..channel_count {
write_signed_fraction(buf, Fraction::from_f32(metadata.gain_map_min[ch] as f32));
write_signed_fraction(buf, Fraction::from_f32(metadata.gain_map_max[ch] as f32));
write_unsigned_fraction(buf, UnsignedFraction::from_f32(metadata.gamma[ch] as f32));
write_signed_fraction(buf, Fraction::from_f32(metadata.base_offset[ch] as f32));
write_signed_fraction(
buf,
Fraction::from_f32(metadata.alternate_offset[ch] as f32),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLAGS_OFFSET: usize = 5;
#[test]
fn test_roundtrip_single_channel() {
let original = GainMapMetadata {
gain_map_min: [0.0; 3],
gain_map_max: [2.0; 3],
gamma: [1.0; 3],
base_offset: [0.015625; 3],
alternate_offset: [0.015625; 3],
base_hdr_headroom: 0.0,
alternate_hdr_headroom: 2.0,
use_base_color_space: true,
backward_direction: false,
};
let serialized = serialize_iso21496_avif(&original);
let parsed = parse_iso21496_avif(&serialized).unwrap();
assert!((parsed.gain_map_max[0] - 2.0).abs() < 0.01);
assert!((parsed.gain_map_min[0] - 0.0).abs() < 0.01);
assert!((parsed.alternate_hdr_headroom - 2.0).abs() < 0.01);
assert!((parsed.gamma[0] - 1.0).abs() < 0.01);
assert!((parsed.base_offset[0] - 0.015625).abs() < 0.001);
assert!(parsed.use_base_color_space);
}
#[test]
fn test_roundtrip_multi_channel() {
let original = GainMapMetadata {
gain_map_max: [100.5, 101.5, 102.5],
gain_map_min: [1.5, 1.6, 1.7],
gamma: [1.0, 1.01, 1.02],
base_offset: [0.0625, 0.0875, 0.1125],
alternate_offset: [0.0625, 0.0875, 0.1125],
base_hdr_headroom: 0.0,
alternate_hdr_headroom: 10000.0 / 203.0,
use_base_color_space: false,
backward_direction: false,
};
let serialized = serialize_iso21496_avif(&original);
let parsed = parse_iso21496_avif(&serialized).unwrap();
assert_ne!(
serialized[FLAGS_OFFSET] & FLAG_MULTI_CHANNEL,
0,
"MULTI_CHANNEL flag should be set"
);
assert_eq!(
serialized[FLAGS_OFFSET] & FLAG_USE_BASE_COLOUR_SPACE,
0,
"USE_BASE_COLOUR_SPACE flag should NOT be set"
);
assert!(!parsed.use_base_color_space);
let tol = 0.05;
for i in 0..3 {
assert!(
(parsed.gain_map_max[i] - original.gain_map_max[i]).abs()
/ original.gain_map_max[i]
< tol,
"max_content_boost[{}]: {} vs {}",
i,
parsed.gain_map_max[i],
original.gain_map_max[i]
);
assert!(
(parsed.gain_map_min[i] - original.gain_map_min[i]).abs()
/ original.gain_map_min[i]
< tol,
"min_content_boost[{}]: {} vs {}",
i,
parsed.gain_map_min[i],
original.gain_map_min[i]
);
assert!(
(parsed.gamma[i] - original.gamma[i]).abs() < 0.01,
"gamma[{}]: {} vs {}",
i,
parsed.gamma[i],
original.gamma[i]
);
assert!(
(parsed.base_offset[i] - original.base_offset[i]).abs() < 0.001,
"offset_sdr[{}]: {} vs {}",
i,
parsed.base_offset[i],
original.base_offset[i]
);
assert!(
(parsed.alternate_offset[i] - original.alternate_offset[i]).abs() < 0.001,
"offset_hdr[{}]: {} vs {}",
i,
parsed.alternate_offset[i],
original.alternate_offset[i]
);
}
assert_ne!(parsed.gain_map_max[0], parsed.gain_map_max[1]);
assert_ne!(parsed.gain_map_max[1], parsed.gain_map_max[2]);
}
#[test]
fn test_roundtrip_negative_offsets() {
let original = GainMapMetadata {
gain_map_max: [10.0, 11.0, 12.0],
gain_map_min: [0.5, 0.6, 0.7],
gamma: [1.0, 1.1, 1.2],
base_offset: [-0.0625, -0.0615, -0.0605],
alternate_offset: [-0.0625, -0.0615, -0.0605],
base_hdr_headroom: 0.0,
alternate_hdr_headroom: 1000.0 / 203.0,
use_base_color_space: true,
backward_direction: false,
};
let serialized = serialize_iso21496_avif(&original);
let parsed = parse_iso21496_avif(&serialized).unwrap();
assert!(parsed.use_base_color_space);
let tol = 0.001;
for i in 0..3 {
assert!(
(parsed.base_offset[i] - original.base_offset[i]).abs() < tol,
"offset_sdr[{}]: {} vs {}",
i,
parsed.base_offset[i],
original.base_offset[i]
);
assert!(
(parsed.alternate_offset[i] - original.alternate_offset[i]).abs() < tol,
"offset_hdr[{}]: {} vs {}",
i,
parsed.alternate_offset[i],
original.alternate_offset[i]
);
}
}
#[test]
fn test_header_layout() {
let metadata = GainMapMetadata::new();
let serialized = serialize_iso21496_avif(&metadata);
assert_eq!(serialized[0], ISO_VERSION);
assert_eq!(&serialized[1..3], &[0, 0]);
assert_eq!(&serialized[3..5], &[0, 0]);
let flags = serialized[FLAGS_OFFSET];
assert_eq!(flags & FLAG_MULTI_CHANNEL, 0);
assert_ne!(flags & FLAG_USE_BASE_COLOUR_SPACE, 0);
}
#[test]
fn test_single_channel_size() {
let metadata = GainMapMetadata::new();
let serialized = serialize_iso21496_avif(&metadata);
assert_eq!(
serialized.len(),
HEADER_SIZE
+ HEADROOM_FRACTIONS * FRACTION_SIZE
+ FRACTIONS_PER_CHANNEL * FRACTION_SIZE
);
assert_eq!(serialized.len(), 62);
}
#[test]
fn test_multi_channel_size() {
let metadata = GainMapMetadata {
gain_map_max: [4.0, 5.0, 6.0],
gain_map_min: [1.0, 1.5, 2.0],
gamma: [1.0, 1.1, 1.2],
base_offset: [0.01; 3],
alternate_offset: [0.01; 3],
base_hdr_headroom: 0.0,
alternate_hdr_headroom: 2.585,
use_base_color_space: true,
backward_direction: false,
};
let serialized = serialize_iso21496_avif(&metadata);
assert_eq!(
serialized.len(),
HEADER_SIZE
+ HEADROOM_FRACTIONS * FRACTION_SIZE
+ 3 * FRACTIONS_PER_CHANNEL * FRACTION_SIZE
);
assert_eq!(serialized.len(), 142);
}
#[test]
fn test_parse_known_blob() {
#[rustfmt::skip]
let blob: Vec<u8> = [
0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x40, ].to_vec();
let parsed = parse_iso21496_avif(&blob).unwrap();
assert_eq!(parsed.base_hdr_headroom, 0.0); assert!((parsed.alternate_hdr_headroom - 2.0).abs() < 0.001); assert_eq!(parsed.gain_map_min, [0.0; 3]); assert!((parsed.gain_map_max[0] - 2.0).abs() < 0.001); assert_eq!(parsed.gamma, [1.0; 3]);
assert_eq!(parsed.base_offset, [0.015625; 3]); assert_eq!(parsed.alternate_offset, [0.015625; 3]); assert!(parsed.use_base_color_space);
}
#[test]
fn test_single_channel_replicates_to_all() {
let original = GainMapMetadata {
gain_map_min: [2.0; 3],
gain_map_max: [3.0; 3],
gamma: [1.5; 3],
base_offset: [0.03; 3],
alternate_offset: [0.05; 3],
base_hdr_headroom: 0.0,
alternate_hdr_headroom: 3.0,
use_base_color_space: false,
backward_direction: false,
};
let serialized = serialize_iso21496_avif(&original);
assert_eq!(
serialized[FLAGS_OFFSET] & FLAG_MULTI_CHANNEL,
0,
"should serialize as single channel"
);
let parsed = parse_iso21496_avif(&serialized).unwrap();
for i in 1..3 {
assert_eq!(
parsed.gain_map_min[0], parsed.gain_map_min[i],
"min_content_boost[0] != [{}]",
i
);
assert_eq!(
parsed.gain_map_max[0], parsed.gain_map_max[i],
"max_content_boost[0] != [{}]",
i
);
assert_eq!(parsed.gamma[0], parsed.gamma[i], "gamma[0] != [{}]", i);
assert_eq!(
parsed.base_offset[0], parsed.base_offset[i],
"offset_sdr[0] != [{}]",
i
);
assert_eq!(
parsed.alternate_offset[0], parsed.alternate_offset[i],
"offset_hdr[0] != [{}]",
i
);
}
}
#[test]
fn test_zero_headroom() {
let original = GainMapMetadata {
gain_map_min: [0.0; 3],
gain_map_max: [1.0; 3],
gamma: [1.0; 3],
base_offset: [0.0; 3],
alternate_offset: [0.0; 3],
base_hdr_headroom: 0.0,
alternate_hdr_headroom: 1.0,
use_base_color_space: true,
backward_direction: false,
};
let serialized = serialize_iso21496_avif(&original);
let parsed = parse_iso21496_avif(&serialized).unwrap();
assert!((parsed.base_hdr_headroom - 0.0).abs() < 0.001);
assert!((parsed.alternate_hdr_headroom - 1.0).abs() < 0.001); }
#[test]
fn test_gamma_one() {
let original = GainMapMetadata {
gamma: [1.0; 3],
..GainMapMetadata::new()
};
let serialized = serialize_iso21496_avif(&original);
let parsed = parse_iso21496_avif(&serialized).unwrap();
assert!((parsed.gamma[0] - 1.0).abs() < 0.001);
}
#[test]
fn test_extreme_boost_values() {
let original = GainMapMetadata {
gain_map_min: [-6.644; 3], gain_map_max: [13.288; 3], gamma: [0.01; 3],
base_offset: [0.0; 3],
alternate_offset: [0.0; 3],
base_hdr_headroom: 0.0,
alternate_hdr_headroom: 5.623, use_base_color_space: true,
backward_direction: false,
};
let serialized = serialize_iso21496_avif(&original);
let parsed = parse_iso21496_avif(&serialized).unwrap();
let tol = 0.01;
assert!(
(parsed.gain_map_max[0] - 13.288).abs() < tol,
"gain_map_max: {} vs 13.288",
parsed.gain_map_max[0]
);
assert!(
(parsed.gain_map_min[0] - (-6.644)).abs() < tol,
"gain_map_min: {} vs -6.644",
parsed.gain_map_min[0]
);
assert!(
(parsed.gamma[0] - 0.01).abs() < 0.001,
"gamma: {} vs 0.01",
parsed.gamma[0]
);
}
#[test]
fn test_empty_data() {
assert!(parse_iso21496_avif(&[]).is_err());
}
#[test]
fn test_too_short() {
assert!(parse_iso21496_avif(&[0x00]).is_err());
assert!(parse_iso21496_avif(&[0x00, 0x00]).is_err());
assert!(parse_iso21496_avif(&[0x00, 0x00, 0x00, 0x00, 0x00]).is_err());
}
#[test]
fn test_invalid_version() {
let mut blob = vec![0u8; 62];
blob[0] = 1; let result = parse_iso21496_avif(&blob);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("version"),
"error should mention version: {}",
msg
);
}
#[test]
fn test_truncated_fractions() {
let data = vec![
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, ];
assert!(parse_iso21496_avif(&data).is_err());
}
#[test]
fn test_zero_denominator_in_headroom() {
let mut data = vec![0u8; 62];
data[0] = 0; let result = parse_iso21496_avif(&data);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("denominator"),
"error should mention denominator: {}",
msg
);
}
#[test]
fn test_zero_denominator_in_channel_fraction() {
let metadata = GainMapMetadata::new();
let mut data = serialize_iso21496_avif(&metadata);
data[26] = 0;
data[27] = 0;
data[28] = 0;
data[29] = 0;
let result = parse_iso21496_avif(&data);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("denominator"),
"error should mention denominator: {}",
msg
);
}
#[test]
fn test_app2_marker_structure() {
let iso_data = serialize_iso21496_avif(&GainMapMetadata::new());
let marker = create_iso_app2_marker(&iso_data);
assert_eq!(marker[0], 0xFF);
assert_eq!(marker[1], 0xE2);
let namespace = b"urn:iso:std:iso:ts:21496:-1\0";
let expected_length = 2 + namespace.len() + iso_data.len();
let actual_length = ((marker[2] as usize) << 8) | (marker[3] as usize);
assert_eq!(actual_length, expected_length);
assert_eq!(marker.len(), 2 + expected_length);
let ns_start = 4;
let ns_end = ns_start + namespace.len();
assert_eq!(&marker[ns_start..ns_end], namespace);
assert_eq!(&marker[ns_end..], &iso_data);
}
#[test]
fn test_app2_marker_roundtrip() {
let iso_data = serialize_iso21496_jpeg(&GainMapMetadata::new());
let marker = create_iso_app2_marker(&iso_data);
let namespace = b"urn:iso:std:iso:ts:21496:-1\0";
let payload_start = 4 + namespace.len();
let extracted = &marker[payload_start..];
assert_eq!(extracted, &iso_data);
let parsed = parse_iso21496_jpeg(extracted).unwrap();
let original = GainMapMetadata::new();
assert!((parsed.base_hdr_headroom - original.base_hdr_headroom).abs() < 0.01);
assert!(parsed.use_base_color_space);
}
#[test]
fn test_fraction_roundtrip() {
let values = [0.0, 0.5, 1.0, 2.0, -1.0, 0.015625];
for &v in &values {
let frac = Fraction::from_f32(v);
let back = frac.to_f32();
assert!(
(v - back).abs() < 0.0001,
"Fraction roundtrip failed for {}: got {}",
v,
back
);
}
}
#[test]
fn test_unsigned_fraction_roundtrip() {
let values = [0.0, 0.5, 1.0, 2.0, 0.015625, 49.26];
for &v in &values {
let frac = UnsignedFraction::from_f32(v);
let back = frac.to_f32();
assert!(
(v - back).abs() < 0.001,
"UnsignedFraction roundtrip failed for {}: got {}",
v,
back
);
}
}
#[test]
fn test_unsigned_fraction_clamps_negative() {
let frac = UnsignedFraction::from_f32(-5.0);
assert_eq!(frac.numerator, 0);
}
#[test]
fn test_unsigned_fraction_zero_denominator() {
let frac = UnsignedFraction::new(42, 0);
assert_eq!(frac.to_f32(), 0.0);
}
#[test]
fn test_fraction_produces_canonical_denominators() {
let cases: &[(f32, i32, u32)] = &[
(0.0, 0, 1),
(1.0, 1, 1),
(2.0, 2, 1),
(4.0, 4, 1),
(-1.0, -1, 1),
(0.5, 1, 2),
(0.25, 1, 4),
(0.015625, 1, 64), (-0.015625, -1, 64),
];
for &(value, exp_num, exp_den) in cases {
let frac = Fraction::from_f32(value);
assert_eq!(
(frac.numerator, frac.denominator),
(exp_num, exp_den),
"Fraction::from_f32({value}): expected {exp_num}/{exp_den}, got {}/{}",
frac.numerator,
frac.denominator
);
}
}
#[test]
fn test_unsigned_fraction_produces_canonical_denominators() {
let cases: &[(f32, u32, u32)] = &[
(0.0, 0, 1),
(1.0, 1, 1),
(2.0, 2, 1),
(4.0, 4, 1),
(0.5, 1, 2),
(0.25, 1, 4),
(0.015625, 1, 64),
];
for &(value, exp_num, exp_den) in cases {
let frac = UnsignedFraction::from_f32(value);
assert_eq!(
(frac.numerator, frac.denominator),
(exp_num, exp_den),
"UnsignedFraction::from_f32({value}): expected {exp_num}/{exp_den}, got {}/{}",
frac.numerator,
frac.denominator
);
}
}
const ADOBE_PRIMARY_ISO_PAYLOAD: [u8; 4] = [0x00, 0x00, 0x00, 0x00];
#[rustfmt::skip]
const ADOBE_GAINMAP_ISO_PAYLOAD: [u8; 61] = [
0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x04,
0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01,
0x00, 0x59, 0xF5, 0x41,
0x00, 0x10, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01,
];
#[test]
fn test_parse_adobe_primary_version_block() {
let result = parse_iso21496_jpeg(&ADOBE_PRIMARY_ISO_PAYLOAD);
assert!(
result.is_err(),
"version-only block should not parse as gain map metadata"
);
}
#[test]
fn test_parse_adobe_gainmap_payload() {
let parsed = parse_iso21496_jpeg(&ADOBE_GAINMAP_ISO_PAYLOAD)
.expect("should parse canonical Adobe payload");
assert!(parsed.use_base_color_space);
assert_eq!(parsed.base_hdr_headroom, 0.0);
assert!((parsed.alternate_hdr_headroom - 4.0).abs() < 0.001);
assert_eq!(parsed.gain_map_min, [0.0; 3]);
let expected_max = 5_895_489.0 / 1_048_576.0;
assert!(
(parsed.gain_map_max[0] - expected_max).abs() < 0.001,
"gain_map_max: {} vs {}",
parsed.gain_map_max[0],
expected_max
);
assert_eq!(parsed.gamma, [1.0; 3]);
assert_eq!(parsed.base_offset, [0.0; 3]);
assert_eq!(parsed.alternate_offset, [0.0; 3]);
}
#[test]
fn test_serialize_matches_adobe_fixture_structure() {
let parsed = parse_iso21496_jpeg(&ADOBE_GAINMAP_ISO_PAYLOAD).unwrap();
let reserialized = serialize_iso21496_jpeg(&parsed);
assert_eq!(
reserialized.len(),
ADOBE_GAINMAP_ISO_PAYLOAD.len(),
"serialized length mismatch"
);
assert_eq!(
&reserialized[..5],
&ADOBE_GAINMAP_ISO_PAYLOAD[..5],
"header mismatch"
);
assert_eq!(
&reserialized[5..21],
&ADOBE_GAINMAP_ISO_PAYLOAD[5..21],
"headroom fractions mismatch"
);
assert_eq!(
&reserialized[21..29],
&ADOBE_GAINMAP_ISO_PAYLOAD[21..29],
"gain_map_min mismatch"
);
assert_eq!(
&reserialized[37..45],
&ADOBE_GAINMAP_ISO_PAYLOAD[37..45],
"gamma mismatch"
);
assert_eq!(
&reserialized[45..53],
&ADOBE_GAINMAP_ISO_PAYLOAD[45..53],
"base_offset mismatch"
);
assert_eq!(
&reserialized[53..61],
&ADOBE_GAINMAP_ISO_PAYLOAD[53..61],
"alt_offset mismatch"
);
let our_max_n = i32::from_be_bytes([
reserialized[29],
reserialized[30],
reserialized[31],
reserialized[32],
]);
let our_max_d = u32::from_be_bytes([
reserialized[33],
reserialized[34],
reserialized[35],
reserialized[36],
]);
let our_val = our_max_n as f64 / our_max_d as f64;
let adobe_val = 5_895_489.0 / 1_048_576.0;
assert!(
(our_val - adobe_val).abs() < 1e-6,
"gain_map_max value mismatch: {our_val} vs {adobe_val}"
);
}
#[test]
fn test_version_only_app2_marker() {
let marker = create_version_only_iso_app2();
assert_eq!(marker[0], 0xFF);
assert_eq!(marker[1], 0xE2);
let namespace = b"urn:iso:std:iso:ts:21496:-1\0";
let payload_start = 4 + namespace.len();
let payload = &marker[payload_start..];
assert_eq!(payload, &ADOBE_PRIMARY_ISO_PAYLOAD);
}
#[test]
fn test_serialize_canonical_simple_metadata() {
let metadata = GainMapMetadata {
gain_map_min: [0.0; 3],
gain_map_max: [2.0; 3],
gamma: [1.0; 3],
base_offset: [0.015625; 3], alternate_offset: [0.015625; 3],
base_hdr_headroom: 0.0,
alternate_hdr_headroom: 2.0,
use_base_color_space: true,
backward_direction: false,
};
let serialized = serialize_iso21496_jpeg(&metadata);
let pos = 5; assert_eq!(&serialized[pos..pos + 8], &[0, 0, 0, 0, 0, 0, 0, 1]);
assert_eq!(&serialized[pos + 8..pos + 16], &[0, 0, 0, 2, 0, 0, 0, 1]);
assert_eq!(&serialized[pos + 16..pos + 24], &[0, 0, 0, 0, 0, 0, 0, 1]);
assert_eq!(&serialized[pos + 24..pos + 32], &[0, 0, 0, 2, 0, 0, 0, 1]);
assert_eq!(&serialized[pos + 32..pos + 40], &[0, 0, 0, 1, 0, 0, 0, 1]);
assert_eq!(&serialized[pos + 40..pos + 48], &[0, 0, 0, 1, 0, 0, 0, 64]);
assert_eq!(&serialized[pos + 48..pos + 56], &[0, 0, 0, 1, 0, 0, 0, 64]);
}
#[test]
fn test_create_jpeg_iso_markers() {
let metadata = GainMapMetadata {
gain_map_min: [0.0; 3],
gain_map_max: [2.0; 3],
gamma: [1.0; 3],
base_offset: [0.0; 3],
alternate_offset: [0.0; 3],
base_hdr_headroom: 0.0,
alternate_hdr_headroom: 4.0,
use_base_color_space: true,
backward_direction: false,
};
let markers = create_jpeg_iso_markers(&metadata);
let namespace = b"urn:iso:std:iso:ts:21496:-1\0";
assert_eq!(markers.primary[0], 0xFF);
assert_eq!(markers.primary[1], 0xE2);
let primary_payload = &markers.primary[4 + namespace.len()..];
assert_eq!(primary_payload, &[0x00, 0x00, 0x00, 0x00]);
assert_eq!(markers.gain_map[0], 0xFF);
assert_eq!(markers.gain_map[1], 0xE2);
let gm_payload = &markers.gain_map[4 + namespace.len()..];
let parsed = parse_iso21496_jpeg(gm_payload).unwrap();
assert!(parsed.use_base_color_space);
assert!((parsed.alternate_hdr_headroom - 4.0).abs() < 0.001);
assert!((parsed.gain_map_max[0] - 2.0).abs() < 0.001);
}
#[test]
fn test_no_fraction_uses_million_denominator() {
let test_values: &[f32] = &[
0.0,
1.0,
2.0,
4.0,
0.5,
0.015625,
-0.015625,
0.25,
10000.0 / 203.0,
5.622376,
];
for &v in test_values {
let frac = Fraction::from_f32(v);
assert_ne!(
frac.denominator, 1_000_000,
"Fraction::from_f32({v}) produced denominator 1000000 \
({}/1000000) — this is the non-canonical encoding that \
causes browser interop failures",
frac.numerator
);
}
for &v in test_values {
if v < 0.0 {
continue;
}
let frac = UnsignedFraction::from_f32(v);
assert_ne!(
frac.denominator, 1_000_000,
"UnsignedFraction::from_f32({v}) produced denominator 1000000 \
({}/1000000)",
frac.numerator
);
}
}
#[test]
fn test_serialized_payload_has_no_million_denominators() {
let metadata = GainMapMetadata {
gain_map_min: [0.0, -0.5, -1.0],
gain_map_max: [2.0, 3.0, 4.0],
gamma: [1.0, 1.0, 1.0],
base_offset: [0.015625; 3],
alternate_offset: [0.015625; 3],
base_hdr_headroom: 0.0,
alternate_hdr_headroom: 10000.0_f64 / 203.0,
use_base_color_space: true,
backward_direction: false,
};
for format in [
crate::Iso21496Format::JpegApp2,
crate::Iso21496Format::AvifTmap,
] {
let bytes = serialize_iso21496(&metadata, format);
let header_size = match format {
crate::Iso21496Format::AvifTmap => HEADER_SIZE,
crate::Iso21496Format::JpegApp2 => JPEG_HEADER_SIZE,
};
let frac_data = &bytes[header_size..];
assert_eq!(
frac_data.len() % 8,
0,
"payload not aligned to fraction pairs"
);
for (i, chunk) in frac_data.chunks(8).enumerate() {
let denom = u32::from_be_bytes([chunk[4], chunk[5], chunk[6], chunk[7]]);
assert_ne!(
denom, 1_000_000,
"fraction[{i}] in {format:?} has denominator 1000000 — \
non-canonical encoding"
);
assert_ne!(denom, 0, "fraction[{i}] in {format:?} has zero denominator");
}
}
}
#[test]
fn test_parse_roundtrip_preserves_values_across_formats() {
let original = GainMapMetadata {
gain_map_min: [-1.0, 0.0, 0.5],
gain_map_max: [2.0, 3.5, 5.622376],
gamma: [1.0, 0.5, 2.0],
base_offset: [0.015625, 0.0, 0.03125],
alternate_offset: [0.015625, 0.0, 0.0625],
base_hdr_headroom: 0.0,
alternate_hdr_headroom: 4.0,
use_base_color_space: false,
backward_direction: false,
};
let jpeg_bytes = serialize_iso21496(&original, crate::Iso21496Format::JpegApp2);
let from_jpeg = parse_iso21496(&jpeg_bytes, crate::Iso21496Format::JpegApp2).unwrap();
let avif_bytes = serialize_iso21496(&original, crate::Iso21496Format::AvifTmap);
let from_avif = parse_iso21496(&avif_bytes, crate::Iso21496Format::AvifTmap).unwrap();
let tol = 0.001;
for ch in 0..3 {
assert!(
(from_jpeg.gain_map_min[ch] - from_avif.gain_map_min[ch]).abs() < tol,
"gain_map_min[{ch}] differs: jpeg={} avif={}",
from_jpeg.gain_map_min[ch],
from_avif.gain_map_min[ch]
);
assert!(
(from_jpeg.gain_map_max[ch] - from_avif.gain_map_max[ch]).abs() < tol,
"gain_map_max[{ch}] differs: jpeg={} avif={}",
from_jpeg.gain_map_max[ch],
from_avif.gain_map_max[ch]
);
assert!(
(from_jpeg.gamma[ch] - from_avif.gamma[ch]).abs() < tol,
"gamma[{ch}] differs: jpeg={} avif={}",
from_jpeg.gamma[ch],
from_avif.gamma[ch]
);
assert!(
(from_jpeg.base_offset[ch] - from_avif.base_offset[ch]).abs() < tol,
"base_offset[{ch}] differs: jpeg={} avif={}",
from_jpeg.base_offset[ch],
from_avif.base_offset[ch]
);
assert!(
(from_jpeg.alternate_offset[ch] - from_avif.alternate_offset[ch]).abs() < tol,
"alternate_offset[{ch}] differs: jpeg={} avif={}",
from_jpeg.alternate_offset[ch],
from_avif.alternate_offset[ch]
);
}
assert!((from_jpeg.alternate_hdr_headroom - from_avif.alternate_hdr_headroom).abs() < tol);
assert_eq!(
from_jpeg.use_base_color_space,
from_avif.use_base_color_space
);
}
}