diff --git a/pixstrip-core/src/encoder.rs b/pixstrip-core/src/encoder.rs new file mode 100644 index 0000000..584205d --- /dev/null +++ b/pixstrip-core/src/encoder.rs @@ -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, + ) -> Result> { + 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, + ) -> 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> { + 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> { + 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> { + 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> { + 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> { + 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() + } +} diff --git a/pixstrip-core/src/lib.rs b/pixstrip-core/src/lib.rs index 2c6699f..dd4f864 100644 --- a/pixstrip-core/src/lib.rs +++ b/pixstrip-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod discovery; +pub mod encoder; pub mod error; pub mod loader; pub mod operations; diff --git a/pixstrip-core/tests/encoder_tests.rs b/pixstrip-core/tests/encoder_tests.rs new file mode 100644 index 0000000..ef27946 --- /dev/null +++ b/pixstrip-core/tests/encoder_tests.rs @@ -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"); +} diff --git a/pixstrip-core/tests/integration_tests.rs b/pixstrip-core/tests/integration_tests.rs new file mode 100644 index 0000000..58d52f6 --- /dev/null +++ b/pixstrip-core/tests/integration_tests.rs @@ -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)); +}