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:
162
pixstrip-core/src/encoder.rs
Normal file
162
pixstrip-core/src/encoder.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
88
pixstrip-core/tests/encoder_tests.rs
Normal file
88
pixstrip-core/tests/encoder_tests.rs
Normal 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");
|
||||||
|
}
|
||||||
85
pixstrip-core/tests/integration_tests.rs
Normal file
85
pixstrip-core/tests/integration_tests.rs
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user