use crate::decoder::{Pixels, RawImage};
use crate::logging::{img_debug, img_error, img_info, img_warn};
use crate::Error;
const MIN_AVIF_BYTES: usize = 20;
pub fn encode_avif(
image: &RawImage,
quality: u8,
speed: u8,
alpha_quality: u8,
) -> Result<Vec<u8>, Error> {
let has_transparency = image.has_transparency();
img_debug!(
"encode_avif: {}×{} px, quality={}, alpha_quality={}, speed={}, depth={}, transparency={}",
image.width,
image.height,
quality,
alpha_quality,
speed,
match &image.pixels {
Pixels::Rgba8(_) => "8-bit",
Pixels::Rgba16(_) => "16-bit",
},
has_transparency
);
let ravif_quality = (u32::from(quality.clamp(1, 10)) * 10).min(100) as u8;
let ravif_alpha_quality = if has_transparency {
(u32::from(alpha_quality.clamp(1, 10)) * 10).min(100) as u8
} else {
img_debug!("encode_avif: no transparency detected, treating alpha_quality as no-op");
ravif_quality
};
let avif = match &image.pixels {
Pixels::Rgba8(bytes) => encode_8bit(
image.width,
image.height,
bytes,
ravif_quality,
speed,
ravif_alpha_quality,
),
Pixels::Rgba16(samples) => encode_16bit(
image.width,
image.height,
samples,
ravif_quality,
quality,
speed,
ravif_alpha_quality,
),
}?;
validate_avif_output(&avif, image.width, image.height)?;
#[cfg(feature = "dev-logging")]
img_info!(
"encode_avif: produced {} bytes ({:.1}× compression ratio)",
avif.len(),
compression_ratio(image, avif.len()),
);
#[cfg(not(feature = "dev-logging"))]
img_info!("encode_avif: produced {} bytes", avif.len());
Ok(avif)
}
fn validate_avif_output(bytes: &[u8], width: u32, height: u32) -> Result<(), Error> {
if bytes.is_empty() {
img_error!(
"encode_avif: encoder returned empty output for {}×{} image",
width,
height
);
return Err(Error::Encode(
"AVIF encoder produced empty output — this is a bug; please report it".into(),
));
}
if bytes.len() < MIN_AVIF_BYTES {
img_error!(
"encode_avif: output too short ({} bytes, expected ≥ {}) for {}×{} image",
bytes.len(),
MIN_AVIF_BYTES,
width,
height
);
return Err(Error::Encode(format!(
"AVIF encoder produced truncated output ({} bytes, minimum valid AVIF is {} bytes)",
bytes.len(),
MIN_AVIF_BYTES,
)));
}
if bytes[4..8] != *b"ftyp" {
img_error!(
"encode_avif: output missing ISOBMFF 'ftyp' box — bytes[0..12] = {:02x?}",
&bytes[..bytes.len().min(12)]
);
return Err(Error::Encode(format!(
"AVIF encoder produced invalid container: expected ISOBMFF 'ftyp' box at offset 4, \
got {:02x?}",
&bytes[4..8],
)));
}
if bytes[8..12] != *b"avif" && bytes[8..12] != *b"avis" {
img_error!(
"encode_avif: unexpected major brand — bytes[8..12] = {:02x?}",
&bytes[8..12]
);
return Err(Error::Encode(format!(
"AVIF major brand invalid: expected 'avif' or 'avis', got {:02x?}",
&bytes[8..12],
)));
}
let box_size = u32::from_be_bytes(bytes[0..4].try_into().unwrap()) as usize;
if box_size < MIN_AVIF_BYTES || box_size > bytes.len() {
img_error!(
"encode_avif: ftyp box size {} is invalid (output is {} bytes)",
box_size,
bytes.len()
);
return Err(Error::Encode(format!(
"AVIF ftyp box size invalid: box_size={box_size}, output length={}",
bytes.len()
)));
}
img_debug!(
"encode_avif: output validation passed — {} bytes with ftyp box",
bytes.len()
);
let pixel_count = u64::from(width) * u64::from(height);
if pixel_count > 64 && bytes.len() < 100 {
img_warn!(
"encode_avif: output is suspiciously small ({} bytes) for a {}×{} image — \
verify the AVIF is decodable",
bytes.len(),
width,
height
);
}
Ok(())
}
#[cfg(feature = "dev-logging")]
fn compression_ratio(image: &RawImage, output_bytes: usize) -> f64 {
let input_bytes: u64 = match &image.pixels {
Pixels::Rgba8(b) => b.len() as u64,
Pixels::Rgba16(s) => s.len() as u64 * 2,
};
if output_bytes == 0 {
return 0.0;
}
#[allow(clippy::cast_precision_loss)]
{
input_bytes as f64 / output_bytes as f64
}
}
fn encode_8bit(
width: u32,
height: u32,
pixels: &[u8],
quality: u8,
speed: u8,
alpha_quality: u8,
) -> Result<Vec<u8>, Error> {
use ravif::{EncodedImage, Encoder, Img};
use rgb::FromSlice;
img_debug!(
"encode_8bit: {}×{} RGBA8 → rav1e encode_rgba",
width,
height
);
let rgba = pixels.as_rgba();
let img = Img::new(rgba, width as usize, height as usize);
Encoder::new()
.with_quality(f32::from(quality.clamp(1, 100)))
.with_alpha_quality(f32::from(alpha_quality.clamp(1, 100)))
.with_speed(speed.clamp(1, 10))
.encode_rgba(img)
.map(|EncodedImage { avif_file, .. }| avif_file)
.map_err(|e| {
img_error!("encode_8bit: rav1e failed: {}", e);
Error::Encode(e.to_string())
})
}
fn encode_16bit(
width: u32,
height: u32,
pixels: &[u16],
quality: u8,
quality_1_10: u8,
speed: u8,
alpha_quality: u8,
) -> Result<Vec<u8>, Error> {
use ravif::{EncodedImage, Encoder, MatrixCoefficients, PixelRange};
let width_usize = width as usize;
let height_usize = height as usize;
img_debug!(
"encode_16bit: {}×{} RGBA16 → rav1e encode_raw_planes_10_bit (BT.601 YCbCr)",
width,
height
);
let mut ycbcr_planes: Vec<[u16; 3]> = Vec::with_capacity(width_usize * height_usize);
let mut alpha_plane: Vec<u16> = Vec::with_capacity(width_usize * height_usize);
for chunk in pixels.chunks_exact(4) {
let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]);
ycbcr_planes.push(rgba16_to_10bit_ycbcr_bt601(r, g, b, quality_1_10));
alpha_plane.push(a >> 6);
}
Encoder::new()
.with_quality(f32::from(quality.clamp(1, 100)))
.with_alpha_quality(f32::from(alpha_quality.clamp(1, 100)))
.with_speed(speed.clamp(1, 10))
.encode_raw_planes_10_bit(
width_usize,
height_usize,
ycbcr_planes,
Some(alpha_plane),
PixelRange::Full,
MatrixCoefficients::BT601,
)
.map(|EncodedImage { avif_file, .. }| avif_file)
.map_err(|e| {
img_error!("encode_16bit: rav1e failed: {}", e);
Error::Encode(e.to_string())
})
}
#[inline]
fn rgba16_to_10bit_ycbcr_bt601(r: u16, g: u16, b: u16, quality_1_10: u8) -> [u16; 3] {
const MAX_10BIT: u32 = 1023;
const MAX_10BIT_I32: i32 = 1023;
const KR_Y: u32 = 4894;
const KG_Y: u32 = 9608;
const KB_Y: u32 = 1866;
const HALF_Y: u32 = 1 << 19;
const CB_R: i32 = -173;
const CB_G: i32 = -339;
const CB_B: i32 = 512;
const CR_R: i32 = 511;
const CR_G: i32 = -428;
const CR_B: i32 = -83;
const CHROMA_OFFSET: i32 = 512 * (1 << 16) + (1 << 15);
if quality_1_10 >= 9 {
return rgba16_to_10bit_ycbcr_bt601_f32(r, g, b);
}
let extra_bits: u32 = match quality_1_10 {
7..=8 => 0, 5..=6 => 1, 3..=4 => 2, _ => 3, };
let (r32, g32, b32) = (u32::from(r), u32::from(g), u32::from(b));
let (ri, gi, bi) = (i32::from(r), i32::from(g), i32::from(b));
let y_fp = KR_Y * r32 + KG_Y * g32 + KB_Y * b32;
#[allow(clippy::cast_possible_truncation)]
let y = ((y_fp + HALF_Y) >> 20).min(MAX_10BIT) as u16;
let chroma_b = CB_R * ri + CB_G * gi + CB_B * bi + CHROMA_OFFSET;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let cb = (chroma_b >> 16).clamp(0, MAX_10BIT_I32) as u16;
let chroma_r = CR_R * ri + CR_G * gi + CR_B * bi + CHROMA_OFFSET;
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let cr = (chroma_r >> 16).clamp(0, MAX_10BIT_I32) as u16;
[
apply_extra_rounding(y, extra_bits),
apply_extra_rounding(cb, extra_bits),
apply_extra_rounding(cr, extra_bits),
]
}
#[inline]
fn apply_extra_rounding(v: u16, extra_bits: u32) -> u16 {
if extra_bits == 0 {
return v;
}
let step = 1u32 << extra_bits;
let half = step >> 1;
let rounded = (u32::from(v) + half) & !(step - 1);
#[allow(clippy::cast_possible_truncation)]
{
rounded.min(1023) as u16
}
}
#[inline]
fn rgba16_to_10bit_ycbcr_bt601_f32(r: u16, g: u16, b: u16) -> [u16; 3] {
const MAX10: f32 = 1023.0;
const SCALE: f32 = 1023.0 / 65535.0;
const SHIFT: f32 = 512.0;
const KR: f32 = 0.2990;
const KG: f32 = 0.5870;
const KB: f32 = 0.1140;
let (rf, gf, bf) = (f32::from(r), f32::from(g), f32::from(b));
let y = SCALE * (KR * rf + KG * gf + KB * bf);
let cb = (SCALE * bf - y) * (0.5 / (1.0 - KB)) + SHIFT;
let cr = (SCALE * rf - y) * (0.5 / (1.0 - KR)) + SHIFT;
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::cast_precision_loss
)]
let c10 = |v: f32| v.round().clamp(0.0, MAX10) as u32 as u16;
[c10(y), c10(cb), c10(cr)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::decoder::Pixels;
use std::sync::Arc;
fn solid_rgba8(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> RawImage {
let pixel = [r, g, b, a];
let pixels = pixel.repeat(width as usize * height as usize);
RawImage {
width,
height,
pixels: Pixels::Rgba8(Arc::from(pixels)),
}
}
#[test]
fn encode_and_validate_small_image() {
let img = solid_rgba8(8, 8, 255, 0, 0, 255);
let avif = encode_avif(&img, 80, 6, 80).expect("encode failed");
assert!(avif.len() >= MIN_AVIF_BYTES);
assert_eq!(&avif[4..8], b"ftyp");
}
#[test]
fn validate_rejects_empty() {
let err = validate_avif_output(&[], 4, 4).unwrap_err();
assert!(matches!(err, Error::Encode(_)));
}
#[test]
fn validate_rejects_too_short() {
let err = validate_avif_output(&[0u8; 10], 4, 4).unwrap_err();
assert!(matches!(err, Error::Encode(_)));
}
#[test]
fn validate_rejects_missing_ftyp() {
let mut fake = vec![0u8; 20];
fake[4..8].copy_from_slice(b"moov");
let err = validate_avif_output(&fake, 4, 4).unwrap_err();
assert!(matches!(err, Error::Encode(ref msg) if msg.contains("ftyp")));
}
#[test]
fn validate_accepts_valid_ftyp() {
let mut valid = vec![0u8; 24];
valid[0..4].copy_from_slice(&24u32.to_be_bytes());
valid[4..8].copy_from_slice(b"ftyp");
valid[8..12].copy_from_slice(b"avif");
assert!(validate_avif_output(&valid, 4, 4).is_ok());
}
#[test]
fn validate_rejects_invalid_major_brand() {
let mut fake = vec![0u8; 24];
fake[0..4].copy_from_slice(&24u32.to_be_bytes());
fake[4..8].copy_from_slice(b"ftyp");
fake[8..12].copy_from_slice(b"mp41"); let err = validate_avif_output(&fake, 4, 4).unwrap_err();
assert!(matches!(err, Error::Encode(ref msg) if msg.contains("major brand")));
}
#[test]
fn validate_rejects_invalid_box_size() {
let mut fake = vec![0u8; 24];
fake[0..4].copy_from_slice(&5u32.to_be_bytes()); fake[4..8].copy_from_slice(b"ftyp");
fake[8..12].copy_from_slice(b"avif");
let err = validate_avif_output(&fake, 4, 4).unwrap_err();
assert!(matches!(err, Error::Encode(ref msg) if msg.contains("box size")));
}
#[test]
fn validate_accepts_avis_brand() {
let mut valid = vec![0u8; 24];
valid[0..4].copy_from_slice(&24u32.to_be_bytes());
valid[4..8].copy_from_slice(b"ftyp");
valid[8..12].copy_from_slice(b"avis");
assert!(validate_avif_output(&valid, 4, 4).is_ok());
}
#[test]
fn ycbcr_quality_dependent_rounding() {
let float_ref = |r: u16, g: u16, b: u16| rgba16_to_10bit_ycbcr_bt601_f32(r, g, b);
let cases: &[(u16, u16, u16)] = &[
(65535, 65535, 65535), (0, 0, 0), (65535, 0, 0), (0, 65535, 0), (0, 0, 65535), (32768, 32768, 32768), (32768, 0, 0), (0, 0, 32768), (1000, 800, 600), ];
let tiers: &[(u8, u16)] = &[
(10, 0), (9, 0),
(8, 1), (7, 1),
(6, 2), (5, 2),
(4, 4), (3, 4),
(2, 8), (1, 8),
];
for &(quality, max_allowed_diff) in tiers {
for &(r, g, b) in cases {
let out = rgba16_to_10bit_ycbcr_bt601(r, g, b, quality);
let ref_out = float_ref(r, g, b);
let max_diff = out
.iter()
.zip(ref_out.iter())
.map(|(&a, &b)| a.abs_diff(b))
.max()
.unwrap();
assert!(
max_diff <= max_allowed_diff,
"quality={quality} rgb=({r},{g},{b}): \
out={out:?} ref={ref_out:?} diff={max_diff} allowed={max_allowed_diff}"
);
}
}
for &(quality, _) in tiers {
for level in [0u16, 1024, 16384, 32768, 65535] {
let [_y, cb, cr] = rgba16_to_10bit_ycbcr_bt601(level, level, level, quality);
assert_eq!(
cb, 512,
"quality={quality} grey level {level}: expected Cb=512, got {cb}"
);
assert_eq!(
cr, 512,
"quality={quality} grey level {level}: expected Cr=512, got {cr}"
);
}
}
}
}