#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(clippy::all)]
#![warn(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)]
pub mod config;
pub mod error;
pub mod memory_guard;
pub mod metadata;
pub mod resize;
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
pub(crate) mod decoder;
pub(crate) mod encoder;
pub(crate) mod logging;
pub use config::Config;
pub use error::Error;
pub use memory_guard::MemoryGuard;
pub use resize::OutputResolution;
#[derive(Debug)]
pub struct ConversionOutput {
pub resolution: OutputResolution,
pub data: Vec<u8>,
}
#[must_use = "call `convert` to perform the conversion"]
pub struct Converter {
config: Config,
}
impl From<Config> for Converter {
fn from(config: Config) -> Self {
Self { config }
}
}
impl Converter {
pub fn new(config: Config) -> Result<Self, Error> {
Ok(Self { config })
}
pub fn convert(&self, input: &[u8]) -> Result<Vec<u8>, Error> {
use logging::{img_error, img_info};
img_info!(
"convert: starting — input {} bytes, quality={}, alpha_quality={}, speed={}, \
strip_exif={}, max_input_bytes={}, max_pixels={}, memory_limit={}",
input.len(),
self.config.quality,
self.config.alpha_quality,
self.config.speed,
self.config.strip_exif,
self.config.max_input_bytes,
self.config.max_pixels,
self.config.memory_limit_bytes,
);
let resolution = self
.config
.output_resolutions
.first()
.copied()
.unwrap_or(OutputResolution::Original);
match self.single_convert(input, resolution) {
Ok(avif) => {
img_info!(
"convert: complete — {} bytes in, {} bytes out",
input.len(),
avif.len(),
);
Ok(avif)
}
Err(e) => {
img_error!("convert: failed — {}", e);
Err(e)
}
}
}
#[allow(clippy::too_many_lines)]
pub fn convert_multi(&self, input: &[u8]) -> Result<Vec<ConversionOutput>, Error> {
use logging::{img_error, img_info};
let resolutions: &[OutputResolution] = if self.config.output_resolutions.is_empty() {
&[OutputResolution::Original]
} else {
&self.config.output_resolutions
};
img_info!(
"convert_multi: starting — input {} bytes, {} resolution(s)",
input.len(),
resolutions.len(),
);
let raw = match self.validate_and_decode(input) {
Ok(r) => r,
Err(e) => {
img_error!("convert_multi: decode failed — {}", e);
return Err(e);
}
};
let guard = MemoryGuard::new(self.config.memory_limit_bytes);
let mut seen: HashSet<OutputResolution> = HashSet::new();
let unique_resolutions: Vec<OutputResolution> = resolutions
.iter()
.copied()
.filter(|r| seen.insert(*r))
.collect();
let resolution_indices: Vec<(usize, OutputResolution)> =
resolutions.iter().copied().enumerate().collect();
#[cfg(not(target_arch = "wasm32"))]
let encode_results = {
use rayon::prelude::*;
unique_resolutions
.par_iter()
.map(|&resolution| {
#[allow(clippy::question_mark)] if let Err(e) = guard.check() {
img_error!(
"convert_multi: pre-resize memory guard failed for {:?}: {}",
resolution,
e
);
return Err(e);
}
let resized = resize::resize_raw_image(&raw, resolution)?;
#[allow(clippy::question_mark)]
if let Err(e) = guard.check() {
img_error!(
"convert_multi: pre-encode memory guard failed for {:?}: {}",
resolution,
e
);
return Err(e);
}
let data = match self.encode_raw(&resized) {
Ok(d) => d,
Err(e) => {
img_error!("convert_multi: encode for {:?} failed — {}", resolution, e);
return Err(e);
}
};
img_info!("convert_multi: {:?} → {} bytes", resolution, data.len());
Ok((resolution, data))
})
.collect::<Result<Vec<_>, Error>>()?
};
#[cfg(target_arch = "wasm32")]
let encode_results = {
let mut results = Vec::new();
for &resolution in &unique_resolutions {
if let Err(e) = guard.check() {
img_error!(
"convert_multi: pre-resize memory guard failed for {:?}: {}",
resolution,
e
);
return Err(e);
}
let resized = resize::resize_raw_image(&raw, resolution)?;
if let Err(e) = guard.check() {
img_error!(
"convert_multi: pre-encode memory guard failed for {:?}: {}",
resolution,
e
);
return Err(e);
}
let data = match self.encode_raw(&resized) {
Ok(d) => d,
Err(e) => {
img_error!("convert_multi: encode for {:?} failed — {}", resolution, e);
return Err(e);
}
};
img_info!("convert_multi: {:?} → {} bytes", resolution, data.len());
results.push((resolution, data));
}
results
};
let dedup_cache: HashMap<OutputResolution, Vec<u8>> = encode_results.into_iter().collect();
let outputs: Vec<ConversionOutput> = resolution_indices
.into_iter()
.map(|(_, resolution)| {
let data = dedup_cache
.get(&resolution)
.expect("resolution should be in cache")
.clone();
ConversionOutput { resolution, data }
})
.collect();
img_info!(
"convert_multi: complete — {} output(s) produced",
outputs.len()
);
Ok(outputs)
}
#[cfg(not(target_arch = "wasm32"))]
#[must_use]
pub fn convert_batch(&self, inputs: &[&[u8]]) -> Vec<Result<Vec<u8>, Error>> {
use logging::img_info;
use rayon::prelude::*;
img_info!("convert_batch: starting — {} image(s)", inputs.len());
let results: Vec<Result<Vec<u8>, Error>> =
inputs.par_iter().map(|input| self.convert(input)).collect();
#[cfg(feature = "dev-logging")]
{
let success_count = results.iter().filter(|r| r.is_ok()).count();
img_info!(
"convert_batch: complete — {}/{} succeeded",
success_count,
inputs.len()
);
}
results
}
#[cfg(target_arch = "wasm32")]
#[must_use]
pub fn convert_batch(&self, inputs: &[&[u8]]) -> Vec<Result<Vec<u8>, Error>> {
use logging::img_info;
img_info!(
"convert_batch: starting — {} image(s) (sequential mode)",
inputs.len()
);
let results: Vec<Result<Vec<u8>, Error>> =
inputs.iter().map(|input| self.convert(input)).collect();
#[cfg(feature = "dev-logging")]
{
let success_count = results.iter().filter(|r| r.is_ok()).count();
img_info!(
"convert_batch: complete — {}/{} succeeded",
success_count,
inputs.len()
);
}
results
}
fn validate_and_decode(&self, input: &[u8]) -> Result<decoder::RawImage, Error> {
use logging::{img_debug, img_error, img_info, img_warn};
if !self.config.strip_exif {
img_warn!(
"validate_and_decode: strip_exif=false — metadata retention increases output size"
);
}
let input_len = u64::try_from(input.len()).unwrap_or(u64::MAX);
if input_len > self.config.max_input_bytes {
img_error!(
"validate_and_decode: input {} bytes exceeds limit of {} bytes",
input.len(),
self.config.max_input_bytes,
);
return Err(Error::Decode(format!(
"input too large: {} bytes exceeds the {}-byte limit",
input.len(),
self.config.max_input_bytes,
)));
}
let guard = MemoryGuard::new(self.config.memory_limit_bytes);
#[cfg(feature = "dev-logging")]
if let Some(rss) = MemoryGuard::current_rss_bytes() {
img_debug!(
"validate_and_decode: pre-decode RSS = {} MiB",
rss / (1024 * 1024)
);
}
#[allow(clippy::question_mark)] if let Err(e) = guard.check() {
img_error!("validate_and_decode: pre-decode memory guard failed: {}", e);
return Err(e);
}
let processed: Cow<[u8]> = if self.config.strip_exif {
match metadata::strip_metadata(input) {
Some(stripped) => {
img_debug!(
"validate_and_decode: metadata stripped — {} → {} bytes",
input.len(),
stripped.len()
);
Cow::Owned(stripped)
}
None => {
img_error!(
"validate_and_decode: strip_exif=true but format not supported \
for metadata stripping ({} bytes)",
input.len()
);
return Err(Error::UnsupportedFormat(
"EXIF stripping is not supported for this image format; \
use strip_exif(false) to convert without stripping metadata"
.into(),
));
}
}
} else {
Cow::Borrowed(input)
};
img_debug!("validate_and_decode: decoding {} bytes", processed.len());
let raw = match decoder::decode(&processed, self.config.max_pixels) {
Ok(r) => r,
Err(e) => {
img_error!("validate_and_decode: decode failed: {}", e);
return Err(e);
}
};
img_info!(
"validate_and_decode: decoded — {}×{} px, {} format",
raw.width,
raw.height,
match &raw.pixels {
decoder::Pixels::Rgba8(_) => "8-bit RGBA",
decoder::Pixels::Rgba16(_) => "16-bit RGBA (10-bit AVIF output)",
}
);
#[cfg(feature = "dev-logging")]
if let Some(rss) = MemoryGuard::current_rss_bytes() {
img_debug!(
"validate_and_decode: post-decode RSS = {} MiB",
rss / (1024 * 1024)
);
}
#[allow(clippy::question_mark)] if let Err(e) = guard.check() {
img_error!(
"validate_and_decode: post-decode memory guard failed: {}",
e
);
return Err(e);
}
Ok(raw)
}
fn encode_raw(&self, raw: &decoder::RawImage) -> Result<Vec<u8>, Error> {
use logging::{img_debug, img_error};
img_debug!(
"encode_raw: {}×{} q={} aq={} s={}",
raw.width,
raw.height,
self.config.quality,
self.config.alpha_quality,
self.config.speed,
);
match encoder::encode_avif(
raw,
self.config.quality,
self.config.speed,
self.config.alpha_quality,
) {
Ok(avif) => Ok(avif),
Err(e) => {
img_error!("encode_raw: failed: {}", e);
Err(e)
}
}
}
fn single_convert(&self, input: &[u8], resolution: OutputResolution) -> Result<Vec<u8>, Error> {
use logging::img_debug;
let raw = self.validate_and_decode(input)?;
img_debug!("single_convert: applying resolution {:?}", resolution);
let resized = resize::resize_raw_image(&raw, resolution)?;
self.encode_raw(&resized)
}
#[must_use]
pub fn config(&self) -> &Config {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_minimal_png(width: u32, height: u32) -> Vec<u8> {
let img = image::RgbaImage::new(width, height);
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
#[test]
fn round_trip_png() {
let png = make_minimal_png(4, 4);
let converter = Converter::new(Config::default()).unwrap();
let avif = converter.convert(&png).expect("conversion failed");
assert!(!avif.is_empty());
}
#[test]
fn rejects_input_too_large() {
let png = make_minimal_png(4, 4);
let config = Config::default().max_pixels(1);
let converter = Converter::new(config).unwrap();
let err = converter.convert(&png).unwrap_err();
assert!(matches!(err, Error::InputTooLarge { .. }));
}
#[test]
fn rejects_garbage_input() {
let garbage = b"this is not an image";
let converter = Converter::new(Config::default()).unwrap();
let err = converter.convert(garbage).unwrap_err();
assert!(matches!(
err,
Error::Decode(_) | Error::UnsupportedFormat(_) | Error::MemoryExceeded { .. }
));
}
#[test]
fn config_builder_clamps_values() {
let cfg = Config::default().quality(200).alpha_quality(200).speed(99);
assert_eq!(cfg.quality, 10);
assert_eq!(cfg.alpha_quality, 10);
assert_eq!(cfg.speed, 10);
let cfg_low = Config::default().quality(0).alpha_quality(0);
assert_eq!(cfg_low.quality, 1);
assert_eq!(cfg_low.alpha_quality, 1);
}
#[test]
fn config_accessor() {
let cfg = Config::default().quality(5);
let converter = Converter::new(cfg).unwrap();
assert_eq!(converter.config().quality, 5);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn convert_batch_processes_multiple_images() {
let png1 = make_minimal_png(8, 8);
let png2 = make_minimal_png(12, 12);
let png3 = make_minimal_png(16, 16);
let inputs = vec![png1.as_slice(), png2.as_slice(), png3.as_slice()];
let converter = Converter::new(Config::default()).unwrap();
let results = converter.convert_batch(&inputs);
assert_eq!(results.len(), 3);
assert!(results[0].is_ok());
assert!(results[1].is_ok());
assert!(results[2].is_ok());
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn convert_batch_handles_mixed_success_and_failure() {
let png = make_minimal_png(8, 8);
let garbage = b"not an image";
let inputs = vec![png.as_slice(), garbage.as_slice(), png.as_slice()];
let converter = Converter::new(Config::default()).unwrap();
let results = converter.convert_batch(&inputs);
assert_eq!(results.len(), 3);
assert!(results[0].is_ok());
assert!(results[1].is_err());
assert!(results[2].is_ok());
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn convert_batch_empty_input() {
let inputs: Vec<&[u8]> = vec![];
let converter = Converter::new(Config::default()).unwrap();
let results = converter.convert_batch(&inputs);
assert_eq!(results.len(), 0);
}
#[test]
fn convert_original_resolution_unchanged() {
let png = make_minimal_png(16, 16);
let config = Config::default().output_resolutions(vec![OutputResolution::Original]);
let converter = Converter::new(config).unwrap();
let avif = converter.convert(&png).expect("conversion failed");
assert!(!avif.is_empty());
}
#[test]
fn convert_width1080_small_image_not_upscaled() {
let png = make_minimal_png(4, 4);
let config = Config::default().output_resolutions(vec![OutputResolution::Width1080]);
let converter = Converter::new(config).unwrap();
let avif = converter.convert(&png).expect("conversion failed");
assert!(!avif.is_empty());
}
#[test]
fn convert_empty_output_resolutions_defaults_to_original() {
let png = make_minimal_png(4, 4);
let config = Config::default().output_resolutions(vec![]);
let converter = Converter::new(config).unwrap();
let avif = converter.convert(&png).expect("conversion failed");
assert!(!avif.is_empty());
}
#[test]
fn convert_multi_single_resolution() {
let png = make_minimal_png(4, 4);
let config = Config::default().output_resolutions(vec![OutputResolution::Original]);
let converter = Converter::new(config).unwrap();
let outputs = converter.convert_multi(&png).expect("convert_multi failed");
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0].resolution, OutputResolution::Original);
assert!(!outputs[0].data.is_empty());
}
#[test]
fn convert_multi_all_resolutions() {
let png = make_minimal_png(8, 8);
let config = Config::default().output_resolutions(vec![
OutputResolution::Original,
OutputResolution::Width2560,
OutputResolution::Width1080,
]);
let converter = Converter::new(config).unwrap();
let outputs = converter.convert_multi(&png).expect("convert_multi failed");
assert_eq!(outputs.len(), 3);
assert_eq!(outputs[0].resolution, OutputResolution::Original);
assert_eq!(outputs[1].resolution, OutputResolution::Width2560);
assert_eq!(outputs[2].resolution, OutputResolution::Width1080);
for out in &outputs {
assert!(
!out.data.is_empty(),
"{:?} produced empty output",
out.resolution
);
}
}
#[test]
fn convert_multi_deduplicates_repeated_resolutions() {
let png = make_minimal_png(12, 8);
let config = Config::default().output_resolutions(vec![
OutputResolution::Original,
OutputResolution::Width1080,
OutputResolution::Width1080,
OutputResolution::Original,
]);
let converter = Converter::new(config).unwrap();
let outputs = converter.convert_multi(&png).expect("convert_multi failed");
assert_eq!(outputs.len(), 4);
assert_eq!(outputs[0].resolution, OutputResolution::Original);
assert_eq!(outputs[1].resolution, OutputResolution::Width1080);
assert_eq!(outputs[2].resolution, OutputResolution::Width1080);
assert_eq!(outputs[3].resolution, OutputResolution::Original);
assert_eq!(outputs[1].data, outputs[2].data);
assert_eq!(outputs[0].data, outputs[3].data);
}
#[test]
fn convert_multi_empty_resolutions_defaults_to_original() {
let png = make_minimal_png(4, 4);
let config = Config::default().output_resolutions(vec![]);
let converter = Converter::new(config).unwrap();
let outputs = converter.convert_multi(&png).expect("convert_multi failed");
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0].resolution, OutputResolution::Original);
}
#[test]
fn convert_multi_propagates_decode_error() {
let config = Config::default().output_resolutions(vec![
OutputResolution::Original,
OutputResolution::Width1080,
]);
let converter = Converter::new(config).unwrap();
let err = converter.convert_multi(b"not an image").unwrap_err();
assert!(matches!(
err,
Error::Decode(_) | Error::UnsupportedFormat(_) | Error::MemoryExceeded { .. }
));
}
}