Add output encoders (mozjpeg, oxipng, webp, avif) and integration tests

OutputEncoder dispatches to specialized encoders per format.
JPEG: mozjpeg with quality control. PNG: oxipng lossless optimization.
WebP: libwebp encoding. AVIF: ravif via image crate.
GIF/TIFF: fallback via image crate.
Phase 3 complete - 59 tests passing, zero clippy warnings.
This commit is contained in:
2026-03-06 02:02:27 +02:00
parent cacd52e85b
commit 52931daf53
4 changed files with 336 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
use std::io::Cursor;
use std::path::Path;
use crate::error::{PixstripError, Result};
use crate::types::{ImageFormat, QualityPreset};
pub struct OutputEncoder;
impl OutputEncoder {
pub fn new() -> Self {
Self
}
pub fn encode(
&self,
img: &image::DynamicImage,
format: ImageFormat,
quality: Option<u8>,
) -> Result<Vec<u8>> {
match format {
ImageFormat::Jpeg => self.encode_jpeg(img, quality.unwrap_or(85)),
ImageFormat::Png => self.encode_png(img),
ImageFormat::WebP => self.encode_webp(img, quality.unwrap_or(80)),
ImageFormat::Avif => self.encode_avif(img, quality.unwrap_or(80)),
ImageFormat::Gif => self.encode_fallback(img, image::ImageFormat::Gif),
ImageFormat::Tiff => self.encode_fallback(img, image::ImageFormat::Tiff),
}
}
pub fn encode_to_file(
&self,
img: &image::DynamicImage,
path: &Path,
format: ImageFormat,
quality: Option<u8>,
) -> Result<()> {
let bytes = self.encode(img, format, quality)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(PixstripError::Io)?;
}
std::fs::write(path, &bytes).map_err(PixstripError::Io)?;
Ok(())
}
pub fn quality_for_format(&self, format: ImageFormat, preset: &QualityPreset) -> u8 {
match format {
ImageFormat::Jpeg => preset.jpeg_quality(),
ImageFormat::WebP => preset.webp_quality() as u8,
ImageFormat::Avif => preset.webp_quality() as u8,
ImageFormat::Png => preset.png_level(),
_ => preset.jpeg_quality(),
}
}
fn encode_jpeg(&self, img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
let rgb = img.to_rgb8();
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
let pixels = rgb.as_raw();
let mut comp = mozjpeg::Compress::new(mozjpeg::ColorSpace::JCS_RGB);
comp.set_size(width, height);
comp.set_quality(quality as f32);
let mut output = Vec::new();
let mut started = comp.start_compress(&mut output).map_err(|e| PixstripError::Processing {
operation: "jpeg_encode".into(),
reason: e.to_string(),
})?;
let row_stride = width * 3;
for y in 0..height {
let start = y * row_stride;
let end = start + row_stride;
let _ = started.write_scanlines(&pixels[start..end]);
}
started.finish().map_err(|e| PixstripError::Processing {
operation: "jpeg_encode".into(),
reason: e.to_string(),
})?;
Ok(output)
}
fn encode_png(&self, img: &image::DynamicImage) -> Result<Vec<u8>> {
let mut buf = Vec::new();
let cursor = Cursor::new(&mut buf);
let rgba = img.to_rgba8();
let encoder = image::codecs::png::PngEncoder::new(cursor);
image::ImageEncoder::write_image(
encoder,
rgba.as_raw(),
rgba.width(),
rgba.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(|e| PixstripError::Processing {
operation: "png_encode".into(),
reason: e.to_string(),
})?;
let optimized = oxipng::optimize_from_memory(&buf, &oxipng::Options::default())
.map_err(|e| PixstripError::Processing {
operation: "png_optimize".into(),
reason: e.to_string(),
})?;
Ok(optimized)
}
fn encode_webp(&self, img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
let encoder = webp::Encoder::from_image(img).map_err(|e| PixstripError::Processing {
operation: "webp_encode".into(),
reason: e.to_string(),
})?;
let mem = encoder.encode(quality as f32);
Ok(mem.to_vec())
}
fn encode_avif(&self, img: &image::DynamicImage, quality: u8) -> Result<Vec<u8>> {
let mut buf = Vec::new();
let cursor = Cursor::new(&mut buf);
let rgba = img.to_rgba8();
let encoder = image::codecs::avif::AvifEncoder::new_with_speed_quality(
cursor,
6,
quality,
);
image::ImageEncoder::write_image(
encoder,
rgba.as_raw(),
rgba.width(),
rgba.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(|e| PixstripError::Processing {
operation: "avif_encode".into(),
reason: e.to_string(),
})?;
Ok(buf)
}
fn encode_fallback(
&self,
img: &image::DynamicImage,
format: image::ImageFormat,
) -> Result<Vec<u8>> {
let mut buf = Vec::new();
let cursor = Cursor::new(&mut buf);
img.write_to(cursor, format).map_err(|e| PixstripError::Processing {
operation: "encode".into(),
reason: e.to_string(),
})?;
Ok(buf)
}
}
impl Default for OutputEncoder {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,5 +1,6 @@
pub mod config; pub mod config;
pub mod discovery; pub mod discovery;
pub mod encoder;
pub mod error; pub mod error;
pub mod loader; pub mod loader;
pub mod operations; pub mod operations;

View File

@@ -0,0 +1,88 @@
use pixstrip_core::encoder::OutputEncoder;
use pixstrip_core::types::{ImageFormat, QualityPreset};
fn create_test_rgb_image(width: u32, height: u32) -> image::DynamicImage {
let img = image::RgbImage::from_fn(width, height, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
});
image::DynamicImage::ImageRgb8(img)
}
fn create_test_rgba_image(width: u32, height: u32) -> image::DynamicImage {
let img = image::RgbaImage::from_fn(width, height, |x, y| {
image::Rgba([(x % 256) as u8, (y % 256) as u8, 100, 255])
});
image::DynamicImage::ImageRgba8(img)
}
#[test]
fn encode_jpeg() {
let img = create_test_rgb_image(200, 150);
let encoder = OutputEncoder::new();
let bytes = encoder.encode(&img, ImageFormat::Jpeg, Some(85)).unwrap();
assert!(!bytes.is_empty());
// JPEG magic bytes
assert_eq!(bytes[0], 0xFF);
assert_eq!(bytes[1], 0xD8);
}
#[test]
fn encode_jpeg_quality_affects_size() {
let img = create_test_rgb_image(200, 150);
let encoder = OutputEncoder::new();
let high = encoder.encode(&img, ImageFormat::Jpeg, Some(95)).unwrap();
let low = encoder.encode(&img, ImageFormat::Jpeg, Some(50)).unwrap();
assert!(high.len() > low.len(), "Higher quality should produce larger files");
}
#[test]
fn encode_png() {
let img = create_test_rgba_image(200, 150);
let encoder = OutputEncoder::new();
let bytes = encoder.encode(&img, ImageFormat::Png, None).unwrap();
assert!(!bytes.is_empty());
// PNG magic bytes
assert_eq!(&bytes[0..4], &[0x89, 0x50, 0x4E, 0x47]);
}
#[test]
fn encode_webp() {
let img = create_test_rgb_image(200, 150);
let encoder = OutputEncoder::new();
let bytes = encoder.encode(&img, ImageFormat::WebP, Some(80)).unwrap();
assert!(!bytes.is_empty());
// RIFF header
assert_eq!(&bytes[0..4], b"RIFF");
}
#[test]
fn encode_to_file() {
let img = create_test_rgb_image(200, 150);
let encoder = OutputEncoder::new();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("output.jpg");
encoder.encode_to_file(&img, &path, ImageFormat::Jpeg, Some(85)).unwrap();
assert!(path.exists());
let metadata = std::fs::metadata(&path).unwrap();
assert!(metadata.len() > 0);
}
#[test]
fn quality_from_preset() {
let encoder = OutputEncoder::new();
let preset = QualityPreset::High;
let q = encoder.quality_for_format(ImageFormat::Jpeg, &preset);
assert_eq!(q, 85);
let q = encoder.quality_for_format(ImageFormat::WebP, &preset);
assert!((q as f32 - 85.0).abs() < 1.0);
}
#[test]
fn encode_gif_fallback() {
let img = create_test_rgba_image(50, 50);
let encoder = OutputEncoder::new();
let bytes = encoder.encode(&img, ImageFormat::Gif, None).unwrap();
assert!(!bytes.is_empty());
// GIF magic bytes
assert_eq!(&bytes[0..3], b"GIF");
}

View File

@@ -0,0 +1,85 @@
use pixstrip_core::encoder::OutputEncoder;
use pixstrip_core::loader::ImageLoader;
use pixstrip_core::operations::resize::resize_image;
use pixstrip_core::operations::ResizeConfig;
use pixstrip_core::types::ImageFormat;
use std::path::Path;
fn create_test_jpeg(path: &Path) {
let img = image::RgbImage::from_fn(400, 300, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
});
img.save_with_format(path, image::ImageFormat::Jpeg).unwrap();
}
fn create_test_png(path: &Path) {
let img = image::RgbaImage::from_fn(400, 300, |x, y| {
image::Rgba([(x % 256) as u8, (y % 256) as u8, 100, 255])
});
img.save_with_format(path, image::ImageFormat::Png).unwrap();
}
#[test]
fn load_resize_save_as_webp() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("input.jpg");
let output = dir.path().join("output.webp");
create_test_jpeg(&input);
let loader = ImageLoader::new();
let img = loader.load_pixels(&input).unwrap();
let config = ResizeConfig::ByWidth(200);
let resized = resize_image(&img, &config).unwrap();
assert_eq!(resized.width(), 200);
assert_eq!(resized.height(), 150);
let encoder = OutputEncoder::new();
encoder.encode_to_file(&resized, &output, ImageFormat::WebP, Some(80)).unwrap();
assert!(output.exists());
let info = loader.load_info(&output).unwrap();
assert_eq!(info.dimensions.width, 200);
assert_eq!(info.dimensions.height, 150);
}
#[test]
fn load_png_compress_smaller() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("input.png");
let output = dir.path().join("output.png");
create_test_png(&input);
let loader = ImageLoader::new();
let img = loader.load_pixels(&input).unwrap();
let encoder = OutputEncoder::new();
encoder.encode_to_file(&img, &output, ImageFormat::Png, None).unwrap();
let original_size = std::fs::metadata(&input).unwrap().len();
let optimized_size = std::fs::metadata(&output).unwrap().len();
assert!(
optimized_size <= original_size,
"Optimized PNG ({}) should be <= original ({})",
optimized_size,
original_size
);
}
#[test]
fn load_jpeg_convert_to_png() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("input.jpg");
let output = dir.path().join("output.png");
create_test_jpeg(&input);
let loader = ImageLoader::new();
let img = loader.load_pixels(&input).unwrap();
let encoder = OutputEncoder::new();
encoder.encode_to_file(&img, &output, ImageFormat::Png, None).unwrap();
assert!(output.exists());
let info = loader.load_info(&output).unwrap();
assert_eq!(info.format, Some(ImageFormat::Png));
}