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,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));
}