use thiserror::Error;
pub const APV_MAGIC: &[u8; 4] = b"APV1";
pub const APV_HEADER_SIZE: usize = 16;
pub const APV_MAX_DIMENSION: u32 = 16384;
pub const APV_MAX_QP: u8 = 63;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ApvProfile {
Simple = 0,
Main = 1,
High = 2,
}
impl ApvProfile {
pub fn from_byte(b: u8) -> Result<Self, ApvError> {
match b {
0 => Ok(Self::Simple),
1 => Ok(Self::Main),
2 => Ok(Self::High),
_ => Err(ApvError::UnsupportedProfile),
}
}
#[must_use]
pub fn to_byte(self) -> u8 {
self as u8
}
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Simple => "APV-S",
Self::Main => "APV-M",
Self::High => "APV-H",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ApvChromaFormat {
Yuv420 = 0,
Yuv422 = 1,
Yuv444 = 2,
}
impl ApvChromaFormat {
pub fn from_byte(b: u8) -> Result<Self, ApvError> {
match b {
0 => Ok(Self::Yuv420),
1 => Ok(Self::Yuv422),
2 => Ok(Self::Yuv444),
_ => Err(ApvError::InvalidBitstream(format!(
"unknown chroma format code {b}"
))),
}
}
#[must_use]
pub fn to_byte(self) -> u8 {
self as u8
}
#[must_use]
pub fn chroma_planes(self) -> usize {
2
}
#[must_use]
pub fn chroma_h_shift(self) -> u32 {
match self {
Self::Yuv420 | Self::Yuv422 => 1,
Self::Yuv444 => 0,
}
}
#[must_use]
pub fn chroma_v_shift(self) -> u32 {
match self {
Self::Yuv420 => 1,
Self::Yuv422 | Self::Yuv444 => 0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ApvBitDepth {
Eight = 0,
Ten = 1,
Twelve = 2,
}
impl ApvBitDepth {
#[must_use]
pub fn bits(self) -> u8 {
match self {
Self::Eight => 8,
Self::Ten => 10,
Self::Twelve => 12,
}
}
#[must_use]
pub fn max_value(self) -> u16 {
(1u16 << self.bits()) - 1
}
pub fn from_byte(b: u8) -> Result<Self, ApvError> {
match b {
0 => Ok(Self::Eight),
1 => Ok(Self::Ten),
2 => Ok(Self::Twelve),
_ => Err(ApvError::InvalidBitstream(format!(
"unknown bit depth code {b}"
))),
}
}
#[must_use]
pub fn to_byte(self) -> u8 {
self as u8
}
}
#[derive(Clone, Debug)]
pub struct ApvConfig {
pub width: u32,
pub height: u32,
pub profile: ApvProfile,
pub bit_depth: ApvBitDepth,
pub chroma_format: ApvChromaFormat,
pub qp: u8,
pub tile_cols: u16,
pub tile_rows: u16,
}
impl ApvConfig {
pub fn new(width: u32, height: u32) -> Result<Self, ApvError> {
if width == 0 || height == 0 {
return Err(ApvError::InvalidDimensions {
width,
height,
reason: "width and height must be non-zero".to_string(),
});
}
if width > APV_MAX_DIMENSION || height > APV_MAX_DIMENSION {
return Err(ApvError::InvalidDimensions {
width,
height,
reason: format!("exceeds maximum dimension {APV_MAX_DIMENSION}"),
});
}
Ok(Self {
width,
height,
profile: ApvProfile::Simple,
bit_depth: ApvBitDepth::Eight,
chroma_format: ApvChromaFormat::Yuv420,
qp: 22,
tile_cols: 1,
tile_rows: 1,
})
}
#[must_use]
pub fn with_profile(mut self, profile: ApvProfile) -> Self {
self.profile = profile;
self
}
#[must_use]
pub fn with_bit_depth(mut self, bit_depth: ApvBitDepth) -> Self {
self.bit_depth = bit_depth;
self
}
#[must_use]
pub fn with_chroma_format(mut self, chroma_format: ApvChromaFormat) -> Self {
self.chroma_format = chroma_format;
self
}
#[must_use]
pub fn with_qp(mut self, qp: u8) -> Self {
self.qp = qp.min(APV_MAX_QP);
self
}
pub fn with_tiles(mut self, cols: u16, rows: u16) -> Result<Self, ApvError> {
if cols == 0 || rows == 0 {
return Err(ApvError::InvalidDimensions {
width: cols as u32,
height: rows as u32,
reason: "tile_cols and tile_rows must be ≥ 1".to_string(),
});
}
self.tile_cols = cols;
self.tile_rows = rows;
Ok(self)
}
pub fn validate(&self) -> Result<(), ApvError> {
if self.width == 0 || self.height == 0 {
return Err(ApvError::InvalidDimensions {
width: self.width,
height: self.height,
reason: "width and height must be non-zero".to_string(),
});
}
if self.width > APV_MAX_DIMENSION || self.height > APV_MAX_DIMENSION {
return Err(ApvError::InvalidDimensions {
width: self.width,
height: self.height,
reason: format!("exceeds maximum dimension {APV_MAX_DIMENSION}"),
});
}
if self.qp > APV_MAX_QP {
return Err(ApvError::InvalidQp(self.qp));
}
if self.tile_cols == 0 || self.tile_rows == 0 {
return Err(ApvError::InvalidDimensions {
width: self.tile_cols as u32,
height: self.tile_rows as u32,
reason: "tile_cols and tile_rows must be ≥ 1".to_string(),
});
}
Ok(())
}
#[must_use]
pub fn tile_width(&self, col_idx: u16) -> u32 {
let base = self.width / self.tile_cols as u32;
let remainder = self.width % self.tile_cols as u32;
if col_idx < self.tile_cols - 1 {
base
} else {
base + remainder
}
}
#[must_use]
pub fn tile_height(&self, row_idx: u16) -> u32 {
let base = self.height / self.tile_rows as u32;
let remainder = self.height % self.tile_rows as u32;
if row_idx < self.tile_rows - 1 {
base
} else {
base + remainder
}
}
#[must_use]
pub fn tile_x_offset(&self, col_idx: u16) -> u32 {
let base = self.width / self.tile_cols as u32;
base * col_idx as u32
}
#[must_use]
pub fn tile_y_offset(&self, row_idx: u16) -> u32 {
let base = self.height / self.tile_rows as u32;
base * row_idx as u32
}
}
impl Default for ApvConfig {
fn default() -> Self {
Self {
width: 1920,
height: 1080,
profile: ApvProfile::Simple,
bit_depth: ApvBitDepth::Eight,
chroma_format: ApvChromaFormat::Yuv420,
qp: 22,
tile_cols: 1,
tile_rows: 1,
}
}
}
#[derive(Clone, Debug)]
pub struct ApvFrameHeader {
pub profile: ApvProfile,
pub width: u32,
pub height: u32,
pub bit_depth: ApvBitDepth,
pub chroma_format: ApvChromaFormat,
pub qp: u8,
pub tile_cols: u16,
pub tile_rows: u16,
}
impl ApvFrameHeader {
#[must_use]
pub fn to_bytes(&self) -> [u8; APV_HEADER_SIZE] {
let mut buf = [0u8; APV_HEADER_SIZE];
buf[0..4].copy_from_slice(APV_MAGIC);
buf[4] = self.profile.to_byte();
buf[5..7].copy_from_slice(&(self.width as u16).to_be_bytes());
buf[7..9].copy_from_slice(&(self.height as u16).to_be_bytes());
buf[9] = self.bit_depth.to_byte();
buf[10] = self.chroma_format.to_byte();
buf[11] = self.qp;
buf[12..14].copy_from_slice(&self.tile_cols.to_be_bytes());
buf[14..16].copy_from_slice(&self.tile_rows.to_be_bytes());
buf
}
pub fn from_bytes(data: &[u8]) -> Result<Self, ApvError> {
if data.len() < APV_HEADER_SIZE {
return Err(ApvError::InvalidBitstream(format!(
"header too short: {} bytes (need {})",
data.len(),
APV_HEADER_SIZE
)));
}
if &data[0..4] != APV_MAGIC.as_slice() {
return Err(ApvError::InvalidBitstream(
"missing APV1 magic bytes".to_string(),
));
}
let profile = ApvProfile::from_byte(data[4])?;
let width = u16::from_be_bytes([data[5], data[6]]) as u32;
let height = u16::from_be_bytes([data[7], data[8]]) as u32;
let bit_depth = ApvBitDepth::from_byte(data[9])?;
let chroma_format = ApvChromaFormat::from_byte(data[10])?;
let qp = data[11];
let tile_cols = u16::from_be_bytes([data[12], data[13]]);
let tile_rows = u16::from_be_bytes([data[14], data[15]]);
if width == 0 || height == 0 {
return Err(ApvError::InvalidBitstream(
"frame dimensions must be non-zero".to_string(),
));
}
if qp > APV_MAX_QP {
return Err(ApvError::InvalidQp(qp));
}
if tile_cols == 0 || tile_rows == 0 {
return Err(ApvError::InvalidBitstream(
"tile_cols and tile_rows must be ≥ 1".to_string(),
));
}
Ok(Self {
profile,
width,
height,
bit_depth,
chroma_format,
qp,
tile_cols,
tile_rows,
})
}
#[must_use]
pub fn from_config(config: &ApvConfig) -> Self {
Self {
profile: config.profile,
width: config.width,
height: config.height,
bit_depth: config.bit_depth,
chroma_format: config.chroma_format,
qp: config.qp,
tile_cols: config.tile_cols,
tile_rows: config.tile_rows,
}
}
}
#[derive(Clone, Debug)]
pub struct ApvTileInfo {
pub col: u16,
pub row: u16,
pub offset: usize,
pub size: usize,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Error)]
pub enum ApvError {
#[error("APV invalid dimensions {width}x{height}: {reason}")]
InvalidDimensions {
width: u32,
height: u32,
reason: String,
},
#[error("APV invalid QP {0} (must be 0–63)")]
InvalidQp(u8),
#[error("APV encoding failed: {0}")]
EncodingFailed(String),
#[error("APV decoding failed: {0}")]
DecodingFailed(String),
#[error("APV invalid bitstream: {0}")]
InvalidBitstream(String),
#[error("APV unsupported profile")]
UnsupportedProfile,
#[error("APV buffer too small")]
BufferTooSmall,
}
impl From<ApvError> for crate::error::CodecError {
fn from(e: ApvError) -> Self {
match e {
ApvError::InvalidDimensions {
width,
height,
reason,
} => crate::error::CodecError::InvalidParameter(format!(
"APV dimensions {width}x{height}: {reason}"
)),
ApvError::InvalidQp(qp) => {
crate::error::CodecError::InvalidParameter(format!("APV QP {qp} out of range"))
}
ApvError::EncodingFailed(msg) => crate::error::CodecError::Internal(msg),
ApvError::DecodingFailed(msg) => crate::error::CodecError::DecoderError(msg),
ApvError::InvalidBitstream(msg) => crate::error::CodecError::InvalidBitstream(msg),
ApvError::UnsupportedProfile => crate::error::CodecError::UnsupportedFeature(
"APV profile not supported".to_string(),
),
ApvError::BufferTooSmall => {
crate::error::CodecError::BufferTooSmall { needed: 0, have: 0 }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_new_valid() {
let config = ApvConfig::new(1920, 1080);
assert!(config.is_ok());
let c = config.expect("valid config");
assert_eq!(c.width, 1920);
assert_eq!(c.height, 1080);
assert_eq!(c.qp, 22);
assert_eq!(c.profile, ApvProfile::Simple);
assert_eq!(c.bit_depth, ApvBitDepth::Eight);
assert_eq!(c.chroma_format, ApvChromaFormat::Yuv420);
assert_eq!(c.tile_cols, 1);
assert_eq!(c.tile_rows, 1);
}
#[test]
fn test_config_zero_width() {
let result = ApvConfig::new(0, 480);
assert!(result.is_err());
}
#[test]
fn test_config_zero_height() {
let result = ApvConfig::new(640, 0);
assert!(result.is_err());
}
#[test]
fn test_config_exceeds_max() {
let result = ApvConfig::new(20000, 1080);
assert!(result.is_err());
}
#[test]
fn test_config_with_qp() {
let config = ApvConfig::new(320, 240).expect("valid").with_qp(50);
assert_eq!(config.qp, 50);
}
#[test]
fn test_config_qp_clamped() {
let config = ApvConfig::new(320, 240).expect("valid").with_qp(100);
assert_eq!(config.qp, APV_MAX_QP);
}
#[test]
fn test_config_validate_invalid_qp() {
let mut config = ApvConfig::default();
config.qp = 64;
assert!(config.validate().is_err());
}
#[test]
fn test_config_tiles() {
let config = ApvConfig::new(640, 480)
.expect("valid")
.with_tiles(2, 2)
.expect("valid tiles");
assert_eq!(config.tile_cols, 2);
assert_eq!(config.tile_rows, 2);
assert_eq!(config.tile_width(0), 320);
assert_eq!(config.tile_width(1), 320);
assert_eq!(config.tile_height(0), 240);
assert_eq!(config.tile_height(1), 240);
}
#[test]
fn test_config_tiles_remainder() {
let config = ApvConfig::new(641, 481)
.expect("valid")
.with_tiles(2, 2)
.expect("valid tiles");
assert_eq!(config.tile_width(0), 320);
assert_eq!(config.tile_width(1), 321);
assert_eq!(config.tile_height(0), 240);
assert_eq!(config.tile_height(1), 241);
}
#[test]
fn test_config_zero_tiles() {
let result = ApvConfig::new(640, 480).expect("valid").with_tiles(0, 1);
assert!(result.is_err());
}
#[test]
fn test_profile_roundtrip() {
for profile in [ApvProfile::Simple, ApvProfile::Main, ApvProfile::High] {
let byte = profile.to_byte();
let decoded = ApvProfile::from_byte(byte).expect("valid byte");
assert_eq!(decoded, profile);
}
}
#[test]
fn test_profile_invalid_byte() {
assert!(ApvProfile::from_byte(3).is_err());
}
#[test]
fn test_profile_name() {
assert_eq!(ApvProfile::Simple.name(), "APV-S");
assert_eq!(ApvProfile::Main.name(), "APV-M");
assert_eq!(ApvProfile::High.name(), "APV-H");
}
#[test]
fn test_chroma_format_roundtrip() {
for fmt in [
ApvChromaFormat::Yuv420,
ApvChromaFormat::Yuv422,
ApvChromaFormat::Yuv444,
] {
let byte = fmt.to_byte();
let decoded = ApvChromaFormat::from_byte(byte).expect("valid");
assert_eq!(decoded, fmt);
}
}
#[test]
fn test_chroma_format_shifts() {
assert_eq!(ApvChromaFormat::Yuv420.chroma_h_shift(), 1);
assert_eq!(ApvChromaFormat::Yuv420.chroma_v_shift(), 1);
assert_eq!(ApvChromaFormat::Yuv422.chroma_h_shift(), 1);
assert_eq!(ApvChromaFormat::Yuv422.chroma_v_shift(), 0);
assert_eq!(ApvChromaFormat::Yuv444.chroma_h_shift(), 0);
assert_eq!(ApvChromaFormat::Yuv444.chroma_v_shift(), 0);
}
#[test]
fn test_bit_depth_values() {
assert_eq!(ApvBitDepth::Eight.bits(), 8);
assert_eq!(ApvBitDepth::Ten.bits(), 10);
assert_eq!(ApvBitDepth::Twelve.bits(), 12);
assert_eq!(ApvBitDepth::Eight.max_value(), 255);
assert_eq!(ApvBitDepth::Ten.max_value(), 1023);
assert_eq!(ApvBitDepth::Twelve.max_value(), 4095);
}
#[test]
fn test_bit_depth_roundtrip() {
for bd in [ApvBitDepth::Eight, ApvBitDepth::Ten, ApvBitDepth::Twelve] {
let byte = bd.to_byte();
let decoded = ApvBitDepth::from_byte(byte).expect("valid");
assert_eq!(decoded, bd);
}
}
#[test]
fn test_frame_header_serialize_roundtrip() {
let header = ApvFrameHeader {
profile: ApvProfile::Simple,
width: 1920,
height: 1080,
bit_depth: ApvBitDepth::Ten,
chroma_format: ApvChromaFormat::Yuv422,
qp: 30,
tile_cols: 4,
tile_rows: 2,
};
let bytes = header.to_bytes();
assert_eq!(bytes.len(), APV_HEADER_SIZE);
assert_eq!(&bytes[0..4], b"APV1");
let restored = ApvFrameHeader::from_bytes(&bytes).expect("valid header");
assert_eq!(restored.profile, ApvProfile::Simple);
assert_eq!(restored.width, 1920);
assert_eq!(restored.height, 1080);
assert_eq!(restored.bit_depth, ApvBitDepth::Ten);
assert_eq!(restored.chroma_format, ApvChromaFormat::Yuv422);
assert_eq!(restored.qp, 30);
assert_eq!(restored.tile_cols, 4);
assert_eq!(restored.tile_rows, 2);
}
#[test]
fn test_frame_header_from_config() {
let config = ApvConfig::new(640, 480)
.expect("valid")
.with_qp(35)
.with_profile(ApvProfile::Main);
let header = ApvFrameHeader::from_config(&config);
assert_eq!(header.width, 640);
assert_eq!(header.height, 480);
assert_eq!(header.qp, 35);
assert_eq!(header.profile, ApvProfile::Main);
}
#[test]
fn test_header_bad_magic() {
let mut bytes = [0u8; APV_HEADER_SIZE];
bytes[0..4].copy_from_slice(b"NOPE");
assert!(ApvFrameHeader::from_bytes(&bytes).is_err());
}
#[test]
fn test_header_too_short() {
assert!(ApvFrameHeader::from_bytes(&[0u8; 10]).is_err());
}
#[test]
fn test_header_zero_dimensions() {
let header = ApvFrameHeader {
profile: ApvProfile::Simple,
width: 0,
height: 100,
bit_depth: ApvBitDepth::Eight,
chroma_format: ApvChromaFormat::Yuv420,
qp: 22,
tile_cols: 1,
tile_rows: 1,
};
let bytes = header.to_bytes();
assert!(ApvFrameHeader::from_bytes(&bytes).is_err());
}
#[test]
fn test_header_invalid_qp_in_bytes() {
let mut header = ApvFrameHeader {
profile: ApvProfile::Simple,
width: 320,
height: 240,
bit_depth: ApvBitDepth::Eight,
chroma_format: ApvChromaFormat::Yuv420,
qp: 22,
tile_cols: 1,
tile_rows: 1,
};
header.qp = 22;
let mut bytes = header.to_bytes();
bytes[11] = 64; assert!(ApvFrameHeader::from_bytes(&bytes).is_err());
}
#[test]
fn test_tile_info_construction() {
let info = ApvTileInfo {
col: 0,
row: 0,
offset: 16,
size: 1024,
width: 320,
height: 240,
};
assert_eq!(info.col, 0);
assert_eq!(info.offset, 16);
assert_eq!(info.size, 1024);
}
#[test]
fn test_tile_offsets() {
let config = ApvConfig::new(640, 480)
.expect("valid")
.with_tiles(2, 2)
.expect("valid tiles");
assert_eq!(config.tile_x_offset(0), 0);
assert_eq!(config.tile_x_offset(1), 320);
assert_eq!(config.tile_y_offset(0), 0);
assert_eq!(config.tile_y_offset(1), 240);
}
#[test]
fn test_error_display() {
let err = ApvError::InvalidDimensions {
width: 0,
height: 0,
reason: "test".to_string(),
};
assert!(format!("{err}").contains("test"));
let err = ApvError::InvalidQp(100);
assert!(format!("{err}").contains("100"));
let err = ApvError::EncodingFailed("oops".to_string());
assert!(format!("{err}").contains("oops"));
let err = ApvError::DecodingFailed("bad".to_string());
assert!(format!("{err}").contains("bad"));
let err = ApvError::InvalidBitstream("corrupt".to_string());
assert!(format!("{err}").contains("corrupt"));
let err = ApvError::UnsupportedProfile;
assert!(format!("{err}").contains("unsupported"));
let err = ApvError::BufferTooSmall;
assert!(format!("{err}").contains("small"));
}
#[test]
fn test_error_into_codec_error() {
let err: crate::error::CodecError = ApvError::InvalidDimensions {
width: 0,
height: 0,
reason: "test".to_string(),
}
.into();
assert!(matches!(err, crate::error::CodecError::InvalidParameter(_)));
let err: crate::error::CodecError = ApvError::InvalidQp(100).into();
assert!(matches!(err, crate::error::CodecError::InvalidParameter(_)));
let err: crate::error::CodecError = ApvError::EncodingFailed("x".to_string()).into();
assert!(matches!(err, crate::error::CodecError::Internal(_)));
let err: crate::error::CodecError = ApvError::DecodingFailed("x".to_string()).into();
assert!(matches!(err, crate::error::CodecError::DecoderError(_)));
let err: crate::error::CodecError = ApvError::InvalidBitstream("x".to_string()).into();
assert!(matches!(err, crate::error::CodecError::InvalidBitstream(_)));
let err: crate::error::CodecError = ApvError::UnsupportedProfile.into();
assert!(matches!(
err,
crate::error::CodecError::UnsupportedFeature(_)
));
}
#[test]
fn test_config_default() {
let config = ApvConfig::default();
assert_eq!(config.width, 1920);
assert_eq!(config.height, 1080);
assert!(config.validate().is_ok());
}
#[test]
fn test_chroma_format_planes() {
assert_eq!(ApvChromaFormat::Yuv420.chroma_planes(), 2);
assert_eq!(ApvChromaFormat::Yuv422.chroma_planes(), 2);
assert_eq!(ApvChromaFormat::Yuv444.chroma_planes(), 2);
}
#[test]
fn test_chroma_format_invalid_byte() {
assert!(ApvChromaFormat::from_byte(3).is_err());
}
#[test]
fn test_bit_depth_invalid_byte() {
assert!(ApvBitDepth::from_byte(3).is_err());
}
#[test]
fn test_config_with_all_builders() {
let config = ApvConfig::new(640, 480)
.expect("valid")
.with_profile(ApvProfile::High)
.with_bit_depth(ApvBitDepth::Twelve)
.with_chroma_format(ApvChromaFormat::Yuv444)
.with_qp(10);
assert_eq!(config.profile, ApvProfile::High);
assert_eq!(config.bit_depth, ApvBitDepth::Twelve);
assert_eq!(config.chroma_format, ApvChromaFormat::Yuv444);
assert_eq!(config.qp, 10);
}
#[test]
fn test_header_zero_tile_cols() {
let mut header = ApvFrameHeader {
profile: ApvProfile::Simple,
width: 320,
height: 240,
bit_depth: ApvBitDepth::Eight,
chroma_format: ApvChromaFormat::Yuv420,
qp: 22,
tile_cols: 1,
tile_rows: 1,
};
header.tile_cols = 1;
header.tile_rows = 1;
let mut bytes = header.to_bytes();
bytes[12] = 0;
bytes[13] = 0;
assert!(ApvFrameHeader::from_bytes(&bytes).is_err());
}
}