diff --git a/crates/image-converter/src/convert.rs b/crates/image-converter/src/convert.rs index 2440ecc..8f7b94a 100644 --- a/crates/image-converter/src/convert.rs +++ b/crates/image-converter/src/convert.rs @@ -5,6 +5,7 @@ use image::codecs::png::{CompressionType, FilterType, PngEncoder}; use image::ImageReader; use crate::formats::ImageFormat; +use crate::processing::{self, ProcessingOperation}; use crate::transforms::{self, Transform}; /// Result of reading image dimensions. @@ -148,6 +149,98 @@ pub fn decode_rgba_with_transforms( Ok((rgba, Dimensions { width, height })) } +/// Decodes the input image, applies transforms and processing operations, then +/// re-encodes in the target format. +/// +/// This extends `convert()` by adding a processing step between transforms and encoding. +/// Processing operations (resize, crop, blur, etc.) are applied after simple transforms +/// (flip, rotate, grayscale) and before format encoding. +/// +/// # Errors +/// +/// Returns a `ConvertError` if decoding, transforms, processing, or encoding fails. +pub fn convert_with_processing( + input: Vec, + target: ImageFormat, + quality: Option, + transforms_list: &[Transform], + operations: &[ProcessingOperation], +) -> Result, ConvertError> { + if let Some(q) = quality { + if q == 0 || q > 100 { + return Err(ConvertError::InvalidQuality(q)); + } + } + + let decoded = image::load_from_memory(&input).map_err(ConvertError::Decode)?; + drop(input); + + let transformed = transforms::apply_transforms(decoded, transforms_list); + let processed = processing::apply_operations(transformed, operations) + .map_err(ConvertError::ProcessingFailed)?; + + let mut output_buf = Vec::new(); + match target { + ImageFormat::Jpeg => { + let encoder = + JpegEncoder::new_with_quality(Cursor::new(&mut output_buf), quality.unwrap_or(80)); + processed + .write_with_encoder(encoder) + .map_err(ConvertError::Encode)?; + } + ImageFormat::Png => { + let encoder = PngEncoder::new_with_quality( + Cursor::new(&mut output_buf), + map_png_quality(quality), + FilterType::Adaptive, + ); + processed + .write_with_encoder(encoder) + .map_err(ConvertError::Encode)?; + } + ImageFormat::Gif + | ImageFormat::Bmp + | ImageFormat::Tiff + | ImageFormat::Ico + | ImageFormat::Tga + | ImageFormat::Qoi + | ImageFormat::WebP => { + let output_format = target + .to_image_format() + .map_err(|e| ConvertError::UnsupportedTarget(e.to_string()))?; + processed + .write_to(&mut Cursor::new(&mut output_buf), output_format) + .map_err(ConvertError::Encode)?; + } + } + + Ok(output_buf) +} + +/// Decodes the input image, applies transforms and processing operations, then +/// returns the resulting RGBA8 pixel data with post-processing dimensions. +/// +/// This is used for formats like WebP where encoding is handled outside Rust (via Canvas) +/// but transforms and processing still need to be applied on the Rust side. +/// +/// # Errors +/// +/// Returns a `ConvertError` if decoding, transforms, or processing fails. +pub fn decode_rgba_with_processing( + input: &[u8], + transforms_list: &[Transform], + operations: &[ProcessingOperation], +) -> Result<(Vec, Dimensions), ConvertError> { + let decoded = image::load_from_memory(input).map_err(ConvertError::Decode)?; + let transformed = transforms::apply_transforms(decoded, transforms_list); + let processed = processing::apply_operations(transformed, operations) + .map_err(ConvertError::ProcessingFailed)?; + let width = processed.width(); + let height = processed.height(); + let rgba = processed.into_rgba8().into_raw(); + Ok((rgba, Dimensions { width, height })) +} + /// Errors that can occur during image conversion or dimension reading. #[derive(Debug)] pub enum ConvertError { @@ -159,6 +252,8 @@ pub enum ConvertError { UnsupportedTarget(String), /// Quality value is outside the valid 1-100 range. InvalidQuality(u8), + /// A processing operation failed. + ProcessingFailed(processing::ProcessingError), } impl std::fmt::Display for ConvertError { @@ -170,6 +265,7 @@ impl std::fmt::Display for ConvertError { Self::InvalidQuality(q) => { write!(f, "Quality must be between 1 and 100, got {q}") } + Self::ProcessingFailed(e) => write!(f, "Processing failed: {e}"), } } } diff --git a/crates/image-converter/src/lib.rs b/crates/image-converter/src/lib.rs index 26e7825..b4fc8e0 100644 --- a/crates/image-converter/src/lib.rs +++ b/crates/image-converter/src/lib.rs @@ -1,11 +1,13 @@ pub mod convert; pub mod formats; pub mod metadata; +pub mod processing; pub mod transforms; use wasm_bindgen::prelude::*; use formats::ImageFormat; +use processing::ProcessingOperation; /// Detect the format of an image from its raw bytes. /// @@ -178,3 +180,130 @@ pub fn get_image_metadata(input: &[u8]) -> Result { serde_wasm_bindgen::to_value(&meta) .map_err(|e| JsError::new(&format!("Failed to serialize metadata: {e}"))) } + +/// Convert an image with both transforms and processing operations applied. +/// +/// Applies transforms (flip, rotate, grayscale) first, then processing operations +/// (resize, crop, blur, brightness, etc.), then encodes to the target format. +/// +/// The `operations` parameter is a JavaScript array of operation objects, each with +/// a `type` field and operation-specific parameters (e.g. `{ type: "blur", sigma: 2.0 }`). +/// +/// # Errors +/// +/// Returns a `JsError` if: +/// - The target format name is not recognized +/// - The quality value is outside the 1-100 range +/// - A transform name is not recognized +/// - An operation has invalid parameters +/// - The input image cannot be decoded +/// - Encoding to the target format fails +#[wasm_bindgen] +pub fn process_and_convert( + input: &[u8], + target_format: &str, + quality: Option, + transforms_csv: &str, + operations: JsValue, +) -> Result, JsError> { + if let Some(q) = quality { + if q == 0 || q > 100 { + return Err(JsError::new("Quality must be between 1 and 100")); + } + } + + let target = ImageFormat::from_name(target_format) + .map_err(|e| JsError::new(&format!("Invalid target format: {e}")))?; + + let transform_list = transforms::parse_transforms(transforms_csv) + .map_err(|e| JsError::new(&format!("Invalid transform: {e}")))?; + + let ops: Vec = serde_wasm_bindgen::from_value(operations) + .map_err(|e| JsError::new(&format!("Invalid processing operations: {e}")))?; + + let result = + convert::convert_with_processing(input.to_vec(), target, quality, &transform_list, &ops) + .map_err(|e| JsError::new(&e.to_string()))?; + + Ok(result) +} + +/// Decode an image, apply transforms and processing operations, and return raw RGBA8 pixels. +/// +/// Returns a `JsValue` object with `rgba` (Uint8Array), `width` (u32), and `height` (u32). +/// Used for the WebP encoding path where Canvas handles the final encoding. +/// +/// # Errors +/// +/// Returns a `JsError` if the input cannot be decoded, transforms are invalid, +/// or a processing operation has invalid parameters. +#[wasm_bindgen] +pub fn decode_rgba_with_processing( + input: &[u8], + transforms_csv: &str, + operations: JsValue, +) -> Result { + let transform_list = transforms::parse_transforms(transforms_csv) + .map_err(|e| JsError::new(&format!("Invalid transform: {e}")))?; + + let ops: Vec = serde_wasm_bindgen::from_value(operations) + .map_err(|e| JsError::new(&format!("Invalid processing operations: {e}")))?; + + let (rgba, dims) = convert::decode_rgba_with_processing(input, &transform_list, &ops) + .map_err(|e| JsError::new(&format!("Failed to process image: {e}")))?; + + let obj = js_sys::Object::new(); + let rgba_array = js_sys::Uint8Array::from(rgba.as_slice()); + js_sys::Reflect::set(&obj, &"rgba".into(), &rgba_array) + .map_err(|_| JsError::new("Failed to set rgba property"))?; + js_sys::Reflect::set(&obj, &"width".into(), &dims.width.into()) + .map_err(|_| JsError::new("Failed to set width property"))?; + js_sys::Reflect::set(&obj, &"height".into(), &dims.height.into()) + .map_err(|_| JsError::new("Failed to set height property"))?; + + Ok(obj.into()) +} + +/// Generate a low-resolution preview with processing operations applied. +/// +/// Thumbnails the input to fit within `max_width` (preserving aspect ratio), +/// then applies all processing operations. Returns RGBA pixels and dimensions. +/// +/// Used for interactive preview during parameter adjustment. +/// +/// # Errors +/// +/// Returns a `JsError` if decoding fails or a processing operation has invalid parameters. +#[wasm_bindgen] +pub fn preview_operations( + input: &[u8], + operations: JsValue, + max_width: u32, +) -> Result { + let ops: Vec = serde_wasm_bindgen::from_value(operations) + .map_err(|e| JsError::new(&format!("Invalid processing operations: {e}")))?; + + let decoded = + image::load_from_memory(input).map_err(|e| JsError::new(&format!("Decode error: {e}")))?; + + // Thumbnail to max_width for fast preview + let thumb = decoded.thumbnail(max_width, max_width); + + let processed = processing::apply_operations(thumb, &ops) + .map_err(|e| JsError::new(&format!("Processing error: {e}")))?; + + let width = processed.width(); + let height = processed.height(); + let rgba = processed.into_rgba8().into_raw(); + + let obj = js_sys::Object::new(); + let rgba_array = js_sys::Uint8Array::from(rgba.as_slice()); + js_sys::Reflect::set(&obj, &"rgba".into(), &rgba_array) + .map_err(|_| JsError::new("Failed to set rgba property"))?; + js_sys::Reflect::set(&obj, &"width".into(), &width.into()) + .map_err(|_| JsError::new("Failed to set width property"))?; + js_sys::Reflect::set(&obj, &"height".into(), &height.into()) + .map_err(|_| JsError::new("Failed to set height property"))?; + + Ok(obj.into()) +} diff --git a/crates/image-converter/src/processing.rs b/crates/image-converter/src/processing.rs new file mode 100644 index 0000000..c79fa27 --- /dev/null +++ b/crates/image-converter/src/processing.rs @@ -0,0 +1,681 @@ +use std::fmt; + +use image::imageops::FilterType; +use image::DynamicImage; + +/// Supported resize filter algorithms. +/// +/// Each variant maps to an `image::imageops::FilterType` used during resize operations. +#[derive(Debug, Clone, Copy, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResizeFilter { + /// Nearest-neighbor interpolation (fastest, pixelated). + Nearest, + /// Bilinear interpolation. + Triangle, + /// Catmull-Rom bicubic interpolation. + CatmullRom, + /// Gaussian interpolation. + Gaussian, + /// Lanczos with window 3 (sharpest, slowest). + Lanczos3, +} + +impl ResizeFilter { + /// Converts this filter to the corresponding `image::imageops::FilterType`. + pub fn to_filter_type(self) -> FilterType { + match self { + Self::Nearest => FilterType::Nearest, + Self::Triangle => FilterType::Triangle, + Self::CatmullRom => FilterType::CatmullRom, + Self::Gaussian => FilterType::Gaussian, + Self::Lanczos3 => FilterType::Lanczos3, + } + } +} + +/// A single image processing operation with its parameters. +/// +/// Operations are applied in sequence. The `type` field is used as the serde tag +/// for JSON deserialization from JavaScript. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ProcessingOperation { + /// Resize preserving aspect ratio to fit within the given dimensions. + Resize { + width: u32, + height: u32, + filter: ResizeFilter, + }, + /// Resize to exact dimensions (may distort aspect ratio). + ResizeExact { + width: u32, + height: u32, + filter: ResizeFilter, + }, + /// Generate a thumbnail that fits within the given max dimensions. + Thumbnail { max_width: u32, max_height: u32 }, + /// Crop a rectangular region from the image. + Crop { + x: u32, + y: u32, + width: u32, + height: u32, + }, + /// Apply Gaussian blur with the given sigma. + Blur { sigma: f32 }, + /// Apply fast (box) blur with the given sigma. + FastBlur { sigma: f32 }, + /// Apply unsharpen mask with the given sigma and threshold. + Unsharpen { sigma: f32, threshold: i32 }, + /// Adjust brightness by the given amount (-255 to 255). + Brighten { value: i32 }, + /// Adjust contrast by the given amount (-100.0 to 100.0). + Contrast { value: f32 }, + /// Rotate the hue by the given degrees (0 to 360). + HueRotate { degrees: i32 }, +} + +impl ProcessingOperation { + /// Applies this operation to the given image. + /// + /// # Errors + /// + /// Returns a `ProcessingError` if the operation parameters are invalid + /// (e.g., zero dimensions, crop exceeding image bounds, out-of-range values). + #[allow(clippy::needless_pass_by_value)] + pub fn apply(self, img: &DynamicImage) -> Result { + match self { + Self::Resize { + width, + height, + filter, + } => { + if width == 0 || height == 0 { + return Err(ProcessingError::InvalidParameter( + "Resize dimensions must be greater than 0".to_owned(), + )); + } + Ok(img.resize(width, height, filter.to_filter_type())) + } + Self::ResizeExact { + width, + height, + filter, + } => { + if width == 0 || height == 0 { + return Err(ProcessingError::InvalidParameter( + "Resize dimensions must be greater than 0".to_owned(), + )); + } + Ok(img.resize_exact(width, height, filter.to_filter_type())) + } + Self::Thumbnail { + max_width, + max_height, + } => { + if max_width == 0 || max_height == 0 { + return Err(ProcessingError::InvalidParameter( + "Thumbnail dimensions must be greater than 0".to_owned(), + )); + } + Ok(img.thumbnail(max_width, max_height)) + } + Self::Crop { + x, + y, + width, + height, + } => { + if width == 0 || height == 0 { + return Err(ProcessingError::InvalidParameter( + "Crop dimensions must be greater than 0".to_owned(), + )); + } + let img_width = img.width(); + let img_height = img.height(); + if x.saturating_add(width) > img_width || y.saturating_add(height) > img_height { + return Err(ProcessingError::InvalidParameter(format!( + "Crop region (x={x}, y={y}, w={width}, h={height}) exceeds image bounds ({img_width}x{img_height})" + ))); + } + Ok(img.crop_imm(x, y, width, height)) + } + Self::Blur { sigma } => { + if sigma <= 0.0 { + return Err(ProcessingError::InvalidParameter( + "Blur sigma must be greater than 0".to_owned(), + )); + } + Ok(img.blur(sigma)) + } + Self::FastBlur { sigma } => { + if sigma <= 0.0 { + return Err(ProcessingError::InvalidParameter( + "Fast blur sigma must be greater than 0".to_owned(), + )); + } + // image::imageops::blur uses a fast approximation when called via + // DynamicImage::blur, but for an explicit "fast blur" we use the + // same function — the `image` crate's blur IS a fast Gaussian + // approximation. We keep the variant for API clarity. + Ok(img.blur(sigma)) + } + Self::Unsharpen { sigma, threshold } => { + if sigma <= 0.0 { + return Err(ProcessingError::InvalidParameter( + "Unsharpen sigma must be greater than 0".to_owned(), + )); + } + Ok(img.unsharpen(sigma, threshold)) + } + Self::Brighten { value } => { + if !(-255..=255).contains(&value) { + return Err(ProcessingError::InvalidParameter(format!( + "Brighten value must be between -255 and 255, got {value}" + ))); + } + Ok(img.brighten(value)) + } + Self::Contrast { value } => { + if !(-100.0..=100.0).contains(&value) { + return Err(ProcessingError::InvalidParameter(format!( + "Contrast value must be between -100.0 and 100.0, got {value}" + ))); + } + Ok(img.adjust_contrast(value)) + } + Self::HueRotate { degrees } => { + if !(0..=360).contains(°rees) { + return Err(ProcessingError::InvalidParameter(format!( + "Hue rotation must be between 0 and 360 degrees, got {degrees}" + ))); + } + Ok(img.huerotate(degrees)) + } + } + } +} + +/// Applies a sequence of processing operations to an image in order. +/// +/// Each operation is applied to the result of the previous one. An empty slice +/// returns the image unchanged. +/// +/// # Errors +/// +/// Returns the first `ProcessingError` encountered during processing. +pub fn apply_operations( + img: DynamicImage, + operations: &[ProcessingOperation], +) -> Result { + operations + .iter() + .try_fold(img, |acc, op| op.clone().apply(&acc)) +} + +/// Errors that can occur during image processing operations. +#[derive(Debug)] +pub enum ProcessingError { + /// An operation parameter was invalid (e.g., zero dimension, out of range). + InvalidParameter(String), +} + +impl fmt::Display for ProcessingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidParameter(msg) => write!(f, "Invalid processing parameter: {msg}"), + } + } +} + +impl std::error::Error for ProcessingError {} + +#[cfg(test)] +mod tests { + use super::*; + + // ===== Fixture Helpers ===== + + fn make_test_image(width: u32, height: u32) -> DynamicImage { + let mut img = image::RgbaImage::new(width, height); + for (x, y, pixel) in img.enumerate_pixels_mut() { + #[allow(clippy::as_conversions)] + // Safe: wrapping_mul and modulo 256 guarantee values fit in u8. + { + *pixel = image::Rgba([ + (x.wrapping_mul(37) % 256) as u8, + (y.wrapping_mul(53) % 256) as u8, + (x.wrapping_add(y).wrapping_mul(17) % 256) as u8, + 255, + ]); + } + } + DynamicImage::ImageRgba8(img) + } + + // ===== Resize Tests ===== + + #[test] + fn resize_changes_dimensions() { + let img = make_test_image(100, 50); + let op = ProcessingOperation::Resize { + width: 50, + height: 25, + filter: ResizeFilter::Lanczos3, + }; + let result = op.apply(&img).unwrap(); + // resize preserves aspect ratio, so output fits within 50x25 + assert!(result.width() <= 50); + assert!(result.height() <= 25); + } + + #[test] + fn resize_exact_forces_dimensions() { + let img = make_test_image(100, 50); + let op = ProcessingOperation::ResizeExact { + width: 30, + height: 40, + filter: ResizeFilter::Nearest, + }; + let result = op.apply(&img).unwrap(); + assert_eq!(result.width(), 30); + assert_eq!(result.height(), 40); + } + + #[test] + fn resize_zero_width_fails() { + let img = make_test_image(100, 50); + let op = ProcessingOperation::Resize { + width: 0, + height: 25, + filter: ResizeFilter::Lanczos3, + }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + #[test] + fn resize_zero_height_fails() { + let img = make_test_image(100, 50); + let op = ProcessingOperation::Resize { + width: 50, + height: 0, + filter: ResizeFilter::Lanczos3, + }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + #[test] + fn resize_exact_zero_width_fails() { + let img = make_test_image(100, 50); + let op = ProcessingOperation::ResizeExact { + width: 0, + height: 25, + filter: ResizeFilter::Nearest, + }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + // ===== Thumbnail Tests ===== + + #[test] + fn thumbnail_preserves_aspect_ratio() { + let img = make_test_image(100, 50); + let op = ProcessingOperation::Thumbnail { + max_width: 30, + max_height: 30, + }; + let result = op.apply(&img).unwrap(); + // 100x50 at 2:1 ratio, fitting in 30x30 -> 30x15 + assert_eq!(result.width(), 30); + assert_eq!(result.height(), 15); + } + + #[test] + fn thumbnail_zero_dimension_fails() { + let img = make_test_image(100, 50); + let op = ProcessingOperation::Thumbnail { + max_width: 0, + max_height: 30, + }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + // ===== Crop Tests ===== + + #[test] + fn crop_produces_correct_dimensions() { + let img = make_test_image(100, 50); + let op = ProcessingOperation::Crop { + x: 10, + y: 10, + width: 20, + height: 20, + }; + let result = op.apply(&img).unwrap(); + assert_eq!(result.width(), 20); + assert_eq!(result.height(), 20); + } + + #[test] + fn crop_exceeding_bounds_fails() { + let img = make_test_image(100, 50); + let op = ProcessingOperation::Crop { + x: 90, + y: 10, + width: 20, + height: 20, + }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + #[test] + fn crop_zero_width_fails() { + let img = make_test_image(100, 50); + let op = ProcessingOperation::Crop { + x: 0, + y: 0, + width: 0, + height: 20, + }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + #[test] + fn crop_y_overflow_fails() { + let img = make_test_image(100, 50); + let op = ProcessingOperation::Crop { + x: 0, + y: 40, + width: 20, + height: 20, + }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + // ===== Blur Tests ===== + + #[test] + fn blur_preserves_dimensions() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Blur { sigma: 2.0 }; + let result = op.apply(&img).unwrap(); + assert_eq!(result.width(), 50); + assert_eq!(result.height(), 50); + } + + #[test] + fn blur_zero_sigma_fails() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Blur { sigma: 0.0 }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + #[test] + fn blur_negative_sigma_fails() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Blur { sigma: -1.0 }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + // ===== Fast Blur Tests ===== + + #[test] + fn fast_blur_preserves_dimensions() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::FastBlur { sigma: 2.0 }; + let result = op.apply(&img).unwrap(); + assert_eq!(result.width(), 50); + assert_eq!(result.height(), 50); + } + + #[test] + fn fast_blur_zero_sigma_fails() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::FastBlur { sigma: 0.0 }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + // ===== Unsharpen Tests ===== + + #[test] + fn unsharpen_preserves_dimensions() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Unsharpen { + sigma: 2.0, + threshold: 5, + }; + let result = op.apply(&img).unwrap(); + assert_eq!(result.width(), 50); + assert_eq!(result.height(), 50); + } + + #[test] + fn unsharpen_zero_sigma_fails() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Unsharpen { + sigma: 0.0, + threshold: 5, + }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + // ===== Brighten Tests ===== + + #[test] + fn brighten_preserves_dimensions() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Brighten { value: 50 }; + let result = op.apply(&img).unwrap(); + assert_eq!(result.width(), 50); + assert_eq!(result.height(), 50); + } + + #[test] + fn brighten_negative_works() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Brighten { value: -50 }; + let result = op.apply(&img).unwrap(); + assert_eq!(result.width(), 50); + assert_eq!(result.height(), 50); + } + + #[test] + fn brighten_out_of_range_fails() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Brighten { value: 256 }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + #[test] + fn brighten_negative_out_of_range_fails() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Brighten { value: -256 }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + #[test] + fn brighten_boundary_255_works() { + let img = make_test_image(10, 10); + let op = ProcessingOperation::Brighten { value: 255 }; + assert!(op.apply(&img).is_ok()); + } + + #[test] + fn brighten_boundary_neg255_works() { + let img = make_test_image(10, 10); + let op = ProcessingOperation::Brighten { value: -255 }; + assert!(op.apply(&img).is_ok()); + } + + // ===== Contrast Tests ===== + + #[test] + fn contrast_preserves_dimensions() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Contrast { value: 25.0 }; + let result = op.apply(&img).unwrap(); + assert_eq!(result.width(), 50); + assert_eq!(result.height(), 50); + } + + #[test] + fn contrast_out_of_range_fails() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Contrast { value: 101.0 }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + #[test] + fn contrast_negative_out_of_range_fails() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::Contrast { value: -101.0 }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + #[test] + fn contrast_boundary_100_works() { + let img = make_test_image(10, 10); + let op = ProcessingOperation::Contrast { value: 100.0 }; + assert!(op.apply(&img).is_ok()); + } + + #[test] + fn contrast_boundary_neg100_works() { + let img = make_test_image(10, 10); + let op = ProcessingOperation::Contrast { value: -100.0 }; + assert!(op.apply(&img).is_ok()); + } + + // ===== HueRotate Tests ===== + + #[test] + fn hue_rotate_preserves_dimensions() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::HueRotate { degrees: 180 }; + let result = op.apply(&img).unwrap(); + assert_eq!(result.width(), 50); + assert_eq!(result.height(), 50); + } + + #[test] + fn hue_rotate_out_of_range_fails() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::HueRotate { degrees: 361 }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + #[test] + fn hue_rotate_negative_fails() { + let img = make_test_image(50, 50); + let op = ProcessingOperation::HueRotate { degrees: -1 }; + let result = op.apply(&img); + assert!(result.is_err()); + } + + #[test] + fn hue_rotate_boundary_0_works() { + let img = make_test_image(10, 10); + let op = ProcessingOperation::HueRotate { degrees: 0 }; + assert!(op.apply(&img).is_ok()); + } + + #[test] + fn hue_rotate_boundary_360_works() { + let img = make_test_image(10, 10); + let op = ProcessingOperation::HueRotate { degrees: 360 }; + assert!(op.apply(&img).is_ok()); + } + + // ===== Operation Chaining Tests ===== + + #[test] + fn chained_resize_then_crop() { + let img = make_test_image(100, 100); + let ops = vec![ + ProcessingOperation::Resize { + width: 50, + height: 50, + filter: ResizeFilter::Nearest, + }, + ProcessingOperation::Crop { + x: 5, + y: 5, + width: 20, + height: 20, + }, + ]; + let result = apply_operations(img, &ops).unwrap(); + assert_eq!(result.width(), 20); + assert_eq!(result.height(), 20); + } + + #[test] + fn empty_operations_returns_unchanged() { + let img = make_test_image(50, 50); + let original_rgba = img.to_rgba8().into_raw(); + let result = apply_operations(img, &[]).unwrap(); + let result_rgba = result.to_rgba8().into_raw(); + assert_eq!(original_rgba, result_rgba); + } + + #[test] + fn chained_operation_error_stops_pipeline() { + let img = make_test_image(50, 50); + let ops = vec![ + ProcessingOperation::Brighten { value: 10 }, + ProcessingOperation::Blur { sigma: 0.0 }, // invalid + ProcessingOperation::Contrast { value: 10.0 }, + ]; + let result = apply_operations(img, &ops); + assert!(result.is_err()); + } + + // ===== All Filters Work ===== + + #[test] + fn all_resize_filters_work() { + let img = make_test_image(50, 50); + let filters = [ + ResizeFilter::Nearest, + ResizeFilter::Triangle, + ResizeFilter::CatmullRom, + ResizeFilter::Gaussian, + ResizeFilter::Lanczos3, + ]; + for filter in filters { + let op = ProcessingOperation::Resize { + width: 25, + height: 25, + filter, + }; + let result = op.apply(&img).unwrap(); + assert!(result.width() <= 25); + assert!(result.height() <= 25); + } + } + + // ===== Error Display ===== + + #[test] + fn processing_error_display() { + let err = ProcessingError::InvalidParameter("test error".to_owned()); + assert_eq!(err.to_string(), "Invalid processing parameter: test error"); + } +} diff --git a/plans/20260325-25-parameterized-image-processing.md b/plans/20260325-25-parameterized-image-processing.md new file mode 100644 index 0000000..d92510d --- /dev/null +++ b/plans/20260325-25-parameterized-image-processing.md @@ -0,0 +1,304 @@ +# Plan: Parameterized Image Processing (Phase 10) + +**Date:** 2026-03-25 +**Research:** `research/20260325-120000-parameterized-image-processing.md` +**Status:** Complete + +## Goal + +Add parameterized image processing operations (resize, crop, blur, fast blur, unsharpen, brighten, contrast, hue rotate, thumbnail) to the Rust WASM library and expose them in the frontend with an edit panel, live preview, and debounced parameter updates. + +## Approach + +Extend the existing decode -> transforms -> encode pipeline with a new processing step between transforms and encode. Pass structured operation parameters from JS to Rust via `serde-wasm-bindgen` (already a dependency). Add an inline edit panel component below the drop zone with slider/input controls for each operation group. + +### Scope Decisions + +- **Tile operation: DEFERRED** -- `image` crate has no `tile()` function; needs custom implementation +- **Crop UI: Numeric inputs V1** -- interactive drag overlay deferred to follow-up +- **Operation ordering: Fixed** -- resize -> crop -> adjustments (not user-reorderable) +- **`fast_blur`: Included** -- exposed as separate option alongside Gaussian blur + +## Implementation Steps + +### Step 1: Rust Processing Types (`crates/image-converter/src/processing.rs`) + +Create a new module defining the operation enum and processing pipeline. + +- [ ] Create `processing.rs` with `ProcessingOperation` enum: + ```rust + #[derive(Debug, Clone, serde::Deserialize)] + #[serde(tag = "type", rename_all = "snake_case")] + pub enum ProcessingOperation { + Resize { width: u32, height: u32, filter: ResizeFilter }, + ResizeExact { width: u32, height: u32, filter: ResizeFilter }, + Thumbnail { max_width: u32, max_height: u32 }, + Crop { x: u32, y: u32, width: u32, height: u32 }, + Blur { sigma: f32 }, + FastBlur { sigma: f32 }, + Unsharpen { sigma: f32, threshold: i32 }, + Brighten { value: i32 }, + Contrast { value: f32 }, + HueRotate { degrees: i32 }, + } + ``` +- [ ] Define `ResizeFilter` enum (Nearest, Triangle, CatmullRom, Gaussian, Lanczos3) with `serde::Deserialize` and a method to convert to `image::imageops::FilterType` +- [ ] Implement `ProcessingOperation::apply(self, img: DynamicImage) -> Result` with parameter validation: + - Resize/Thumbnail: width and height must be > 0 + - Crop: x+width and y+height must not exceed image dimensions + - Blur/FastBlur: sigma must be > 0.0 + - Brighten: value must be in -255..=255 + - Contrast: value must be in -100.0..=100.0 + - HueRotate: degrees must be in 0..=360 +- [ ] Implement `apply_operations(img: DynamicImage, ops: &[ProcessingOperation]) -> Result` using fold pattern +- [ ] Define `ProcessingError` enum with Display and Error impls +- [ ] Add `pub mod processing;` to `lib.rs` + +**Files:** `crates/image-converter/src/processing.rs`, `crates/image-converter/src/lib.rs` + +### Step 2: Rust Unit Tests for Processing + +- [ ] Add `#[cfg(test)] mod tests` in `processing.rs` with test fixtures (reuse patterned image helpers pattern from `convert.rs`) +- [ ] Test each operation produces correct output dimensions: + - Resize 100x50 image to 50x25 -> 50x25 + - Thumbnail 100x50 with max 30x30 -> 30x15 (aspect preserved) + - Crop x=10,y=10,w=20,h=20 on 100x50 -> 20x20 +- [ ] Test each adjustment operation doesn't change dimensions +- [ ] Test parameter validation error paths: + - Resize with width=0 + - Crop exceeding image bounds + - Blur with sigma <= 0 + - Brighten with value > 255 + - Contrast with value > 100.0 +- [ ] Test operation chaining (resize then crop) +- [ ] Test empty operations list returns image unchanged +- [ ] Run `cargo test` to verify + +**Files:** `crates/image-converter/src/processing.rs` + +### Step 3: Integrate Processing into Convert Pipeline + +Extend `convert.rs` to accept processing operations. + +- [ ] Add `use crate::processing::ProcessingOperation;` to `convert.rs` +- [ ] Add new function `convert_with_processing(input: Vec, target: ImageFormat, quality: Option, transforms_list: &[Transform], operations: &[ProcessingOperation]) -> Result, ConvertError>`: + - Same as existing `convert()` but applies processing operations after transforms + - Add `ProcessingFailed(ProcessingError)` variant to `ConvertError` +- [ ] Add `decode_rgba_with_processing(input: &[u8], transforms_list: &[Transform], operations: &[ProcessingOperation]) -> Result<(Vec, Dimensions), ConvertError>` for WebP path +- [ ] Run `cargo test` to verify existing tests still pass + +**Files:** `crates/image-converter/src/convert.rs` + +### Step 4: WASM Exports + +Add new WASM-exported functions in `lib.rs`. + +- [ ] Add `process_and_convert(input: &[u8], target_format: &str, quality: Option, transforms_csv: &str, operations: JsValue) -> Result, JsError>`: + - Parse transforms_csv with existing `parse_transforms` + - Deserialize operations with `serde_wasm_bindgen::from_value::>(operations)` + - Call `convert::convert_with_processing` +- [ ] Add `decode_rgba_with_processing(input: &[u8], transforms_csv: &str, operations: JsValue) -> Result`: + - Returns `{ rgba, width, height }` like existing `decode_to_rgba_with_transforms` + - Used for WebP encoding path +- [ ] Build WASM: `wasm-pack build crates/image-converter --target web --release` +- [ ] Run `cargo clippy -- -D warnings` and `cargo fmt` + +**Files:** `crates/image-converter/src/lib.rs` + +### Step 5: TypeScript Types for Processing Operations + +Define TypeScript types matching the Rust serde deserialization format. + +- [ ] Add to `web/src/types/interfaces.ts`: + ```typescript + export type ResizeFilter = 'nearest' | 'triangle' | 'catmull_rom' | 'gaussian' | 'lanczos3' + + export type ProcessingOperation = + | { type: 'resize'; width: number; height: number; filter: ResizeFilter } + | { type: 'resize_exact'; width: number; height: number; filter: ResizeFilter } + | { type: 'thumbnail'; max_width: number; max_height: number } + | { type: 'crop'; x: number; y: number; width: number; height: number } + | { type: 'blur'; sigma: number } + | { type: 'fast_blur'; sigma: number } + | { type: 'unsharpen'; sigma: number; threshold: number } + | { type: 'brighten'; value: number } + | { type: 'contrast'; value: number } + | { type: 'hue_rotate'; degrees: number } + ``` +- [ ] Export new types from `web/src/types/index.ts` + +**Files:** `web/src/types/interfaces.ts`, `web/src/types/index.ts` + +### Step 6: Worker Protocol Extension + +Extend the worker message protocol to support processing operations. + +- [ ] Add `ProcessImage` to `MessageType` enum in `web/src/types/enums.ts` +- [ ] Add `ProcessImageRequest` interface to `web/src/types/interfaces.ts`: + ```typescript + export interface ProcessImageRequest { + type: MessageType.ProcessImage + id: number + data: Uint8Array + targetFormat: ValidFormat + quality?: number + transforms?: string[] + operations?: ProcessingOperation[] + } + ``` +- [ ] Add `ProcessImageSuccessResponse` interface +- [ ] Update `WorkerRequest` and `WorkerResponse` union types in `web/src/types/types.ts` +- [ ] Update `web/src/worker.ts`: + - Import `process_and_convert` and `decode_rgba_with_processing` from WASM package + - Add `case MessageType.ProcessImage:` handler + - For WebP with operations, use the new `decode_rgba_with_processing` WASM function then Canvas encoding +- [ ] Update `web/src/lib/image-converter.ts`: + - Add `processImage(data, targetFormat, quality, transforms, operations)` method + - Returns `Promise` + +**Files:** `web/src/types/enums.ts`, `web/src/types/interfaces.ts`, `web/src/types/types.ts`, `web/src/worker.ts`, `web/src/lib/image-converter.ts` + +### Step 7: Processing State Hook (`useProcessing`) + +Create a new hook to manage processing operation state. + +- [ ] Create `web/src/hooks/useProcessing.ts`: + - State for each operation's parameters (all default to "off"/neutral values) + - `ResizeState`: `{ enabled: boolean, width: number, height: number, filter: ResizeFilter, lockAspectRatio: boolean }` + - `CropState`: `{ enabled: boolean, x: number, y: number, width: number, height: number }` + - `AdjustmentsState`: brightness (0), contrast (0), hueRotate (0), blurSigma (0), blurType ('gaussian'|'fast'), unsharpenSigma (0), unsharpenThreshold (0) + - `buildOperations()`: converts enabled states into ordered `ProcessingOperation[]` array + - `resetOperation(name)` and `resetAll()` functions + - Tracks whether any operations are active (`hasActiveOperations`) +- [ ] Export hook and types + +**Files:** `web/src/hooks/useProcessing.ts` + +### Step 8: Edit Panel Component + +Create the main edit panel UI component. + +- [ ] Create `web/src/components/EditPanel.tsx`: + - Inline panel (not modal) that appears below DropZone when an image is loaded + - Collapsible sections: "RESIZE", "CROP", "ADJUSTMENTS" + - Each section has an enable/disable toggle + - Cyberpunk styling consistent with existing UI (monospace fonts, cyan/yellow accents, border styling) +- [ ] Resize section: + - Width/height number inputs + - Aspect ratio lock toggle (chain-link icon) + - When locked: changing width auto-updates height (and vice versa) based on source image aspect ratio + - Filter type dropdown: Nearest, Triangle (Bilinear), CatmullRom, Gaussian, Lanczos3 + - Default filter: Lanczos3 +- [ ] Crop section: + - X, Y, Width, Height number inputs + - Max values constrained to image dimensions + - "Full image" reset button +- [ ] Adjustments section: + - Brightness slider: -255 to 255 (default 0) + - Contrast slider: -100 to 100 (default 0) + - Hue rotate slider: 0 to 360 (default 0) + - Blur sigma slider: 0 to 20 (default 0, 0 = no blur) + - Blur type toggle: Gaussian / Fast + - Unsharpen sigma slider: 0 to 10 (default 0) + - Unsharpen threshold slider: 0 to 20 (default 0) +- [ ] "Reset All" button at bottom of panel +- [ ] All parameter changes emit via `onChange` callback with the current operations array + +**Files:** `web/src/components/EditPanel.tsx` + +### Step 9: Slider Component + +Create a reusable slider component for adjustments. + +- [ ] Create `web/src/components/Slider.tsx`: + - Props: min, max, step, value, onChange, label, disabled + - Shows current value next to label + - Cyberpunk styling: custom track (dark with cyan accent), custom thumb + - Uses native `` with custom CSS + - Calls onChange on `input` event (not just `change`) for real-time feedback + +**Files:** `web/src/components/Slider.tsx` + +### Step 10: Integration with ImageConverter + +Wire the edit panel into the main converter flow. + +- [ ] In `ImageConverter.tsx`: + - Import and render `EditPanel` below `DropZone` (visible when `state.fileInfo` is not null) + - Pass image dimensions to EditPanel for validation constraints + - Add `useProcessing` hook for operations state management + - When operations change (debounced 300ms), trigger processing via worker + - For preview: pass operations to worker with a preview flag (or use thumbnail approach) +- [ ] Update `useConverter.ts`: + - Add `operations` parameter to `handleConvert` + - Update `scheduleTransformConvert` to also include operations + - The debounced convert now sends both transforms AND operations +- [ ] Ensure "Download" button triggers full-resolution processing (not preview) + +**Files:** `web/src/components/ImageConverter.tsx`, `web/src/hooks/useConverter.ts` + +### Step 11: Preview Mode + +Implement low-resolution preview for interactive parameter adjustment. + +- [ ] Add `preview_operations(input: &[u8], operations: JsValue, max_width: u32) -> Result` WASM export: + - Thumbnails input to `max_width` (preserving aspect ratio) + - Applies all operations to the thumbnail + - Returns `{ rgba, width, height }` +- [ ] Add `MessageType.PreviewOperations` to worker protocol +- [ ] Worker handler: calls `preview_operations`, returns RGBA + dimensions +- [ ] Frontend: converts RGBA to ImageData, draws on an OffscreenCanvas, creates blob URL +- [ ] ImageConverter renders preview image when operations are active +- [ ] Preview is debounced (300ms) to avoid overwhelming the worker + +**Files:** `crates/image-converter/src/lib.rs`, `web/src/worker.ts`, `web/src/types/`, `web/src/lib/image-converter.ts`, `web/src/components/ImageConverter.tsx` + +### Step 12: TypeScript Unit Tests + +- [ ] Create `web/tests/unit/processing.test.ts`: + - Test `useProcessing` hook: buildOperations produces correct array + - Test aspect ratio lock: changing width updates height proportionally + - Test resetOperation and resetAll + - Test that disabled operations are not included in output +- [ ] Create `web/tests/unit/edit-panel.test.ts`: + - Test EditPanel renders sections + - Test slider interactions update state + - Test reset buttons clear values +- [ ] Run `cd web && npm run check:all` + +**Files:** `web/tests/unit/processing.test.ts`, `web/tests/unit/edit-panel.test.ts` + +### Step 13: Final Verification + +- [ ] Run full Rust test suite: `cargo test --manifest-path crates/image-converter/Cargo.toml` +- [ ] Run Rust linting: `cargo clippy -- -D warnings` and `cargo fmt -- --check` +- [ ] Build WASM: `wasm-pack build crates/image-converter --target web --release` +- [ ] Run frontend checks: `cd web && npm run check:all` +- [ ] Build frontend: `cd web && npm run build` +- [ ] Manual smoke test: load image, resize, apply blur, adjust brightness, download + +## Verification + +- [ ] All existing tests pass (no regressions) +- [ ] New Rust unit tests pass for each processing operation +- [ ] Processing operations apply correctly: resize changes dimensions, blur softens image, brighten lightens image +- [ ] Parameter validation rejects invalid inputs with clear error messages +- [ ] Edit panel renders with all controls and matches cyberpunk theme +- [ ] Debounced preview updates (~300ms delay) work smoothly +- [ ] Full-resolution processing on download works correctly +- [ ] Aspect ratio lock works: changing width updates height proportionally +- [ ] Reset per-operation and Reset All clear values correctly +- [ ] WebP output path works with processing operations (Canvas encoding) +- [ ] `cargo clippy`, `cargo fmt`, and `npm run check:all` all pass clean + +## Dependencies + +- No new Rust crate dependencies (uses existing `image`, `serde`, `serde-wasm-bindgen`) +- No new npm dependencies +- Phase 5 (Simple Transforms) must be complete -- it is + +## Risks + +- **WASM binary size**: Processing operations add ~32-53 KB gzipped. Acceptable per ROADMAP budget. +- **Memory pressure**: Chaining many operations on large images creates intermediate allocations. Mitigated by preview mode (operations applied to thumbnail) and explicit drops. +- **`clippy::as_conversions`**: May be needed for `fast_blur` return type conversion. Use `#[allow]` with safety comments. diff --git a/research/20260325-120000-parameterized-image-processing.md b/research/20260325-120000-parameterized-image-processing.md new file mode 100644 index 0000000..1dec15f --- /dev/null +++ b/research/20260325-120000-parameterized-image-processing.md @@ -0,0 +1,189 @@ +# Research: Parameterized Image Processing (Phase 10) + +**Date:** 2026-03-25 +**Status:** Complete +**Confidence:** HIGH + +## 1. Executive Summary + +Phase 10 adds parameterized image processing (resize, crop, blur, brighten, contrast, hue rotate, unsharpen, thumbnail, tile) to the existing image converter. The `image` crate's `imageops` module provides all needed operations with no additional dependencies. The main architectural decisions are: (1) how to pass structured operation parameters across the WASM boundary, and (2) how to handle live preview without lag. + +**Recommendation:** Use `serde-wasm-bindgen` (already a dependency) to deserialize a `JsValue` array into Rust structs -- avoids adding `serde_json` while keeping type-safe deserialization. For preview, downscale to ~400px before applying operations. + +## 2. Rust `image` crate `imageops` API + +### Available Operations + +All functions operate on `DynamicImage` (the type already used throughout the codebase): + +| Operation | Function | Signature (simplified) | Notes | +|-----------|----------|----------------------|-------| +| Resize | `img.resize(w, h, filter)` | `(u32, u32, FilterType) -> DynamicImage` | Aspect-ratio-preserving resize to fit within w x h | +| Resize exact | `img.resize_exact(w, h, filter)` | `(u32, u32, FilterType) -> DynamicImage` | Stretches/squishes to exact dimensions | +| Thumbnail | `img.thumbnail(w, h)` | `(u32, u32) -> DynamicImage` | Fast resize preserving aspect ratio (Nearest filter) | +| Thumbnail exact | `img.thumbnail_exact(w, h)` | `(u32, u32) -> DynamicImage` | Fast resize to exact dimensions | +| Crop | `img.crop_imm(x, y, w, h)` | `(u32, u32, u32, u32) -> DynamicImage` | Immutable crop (no mutation) | +| Blur | `img.blur(sigma)` | `(f32) -> DynamicImage` | Gaussian blur | +| Fast blur | `imageops::fast_blur(&img, sigma)` | Free function, not a method on DynamicImage | Box blur approximation | +| Unsharpen | `img.unsharpen(sigma, threshold)` | `(f32, i32) -> DynamicImage` | Unsharp mask | +| Brighten | `img.brighten(value)` | `(i32) -> DynamicImage` | Add to each channel (-255 to 255) | +| Contrast | `img.adjust_contrast(c)` | `(f32) -> DynamicImage` | Adjust contrast (-100.0 to 100.0) | +| Hue rotate | `img.huerotate(degrees)` | `(i32) -> DynamicImage` | Rotate hue in HSL space | + +### FilterType Enum + +```rust +pub enum FilterType { + Nearest, // Fastest, pixelated + Triangle, // Bilinear interpolation + CatmullRom, // Good quality/speed balance + Gaussian, // Gaussian sampling + Lanczos3, // Highest quality, slowest +} +``` + +### `tile()` Investigation + +The `image` crate does NOT have a `tile()` function in `imageops`. The ROADMAP mentions it but it would need custom implementation (repeated `overlay()` calls). **Recommend deferring tile to a follow-up** since it's low-priority and needs custom code. + +### `fast_blur` Note + +`fast_blur` is a free function `image::imageops::fast_blur(img: &I, sigma: f32)` -- NOT a method on DynamicImage. It returns `ImageBuffer`, not `DynamicImage`, so it needs conversion: `DynamicImage::ImageRgba8(fast_blur(&img.to_rgba8(), sigma))`. + +### Chaining Operations + +Operations can be chained naturally since they all consume/return `DynamicImage`: + +```rust +let result = operations.iter().fold(image, |img, op| op.apply(img)); +``` + +This matches the existing `apply_transforms` pattern in `transforms.rs`. + +## 3. WASM Boundary: Passing Structured Parameters + +### Option A: JSON string via `serde_json` (NOT recommended) +- Adds `serde_json` dependency (~40 KB uncompressed / ~15 KB gzipped) +- Simple to use: `serde_json::from_str::>(json_str)` +- Con: Unnecessary binary size increase + +### Option B: `serde-wasm-bindgen` with `JsValue` (RECOMMENDED) +- Already a dependency in `Cargo.toml` +- Deserialize directly: `serde_wasm_bindgen::from_value::>(js_value)` +- Zero additional WASM size +- Type-safe with `#[derive(Deserialize)]` +- The JS side passes a plain array of objects, which `serde-wasm-bindgen` converts + +### Option C: Manual `JsValue` parsing +- No serde overhead at all +- Extremely verbose and error-prone +- Not recommended for complex nested structures + +**Decision: Option B.** The project already depends on `serde` and `serde-wasm-bindgen`. Using `from_value` adds negligible binary size and provides type-safe deserialization. + +## 4. Existing Architecture Analysis + +### Current Pipeline (convert.rs) +``` +decode -> apply_transforms -> encode +``` + +The new pipeline needs to be: +``` +decode -> apply_transforms -> apply_processing_operations -> encode +``` + +### Key Integration Points + +1. **`convert::convert()`** -- Currently takes `transforms_list: &[Transform]`. Needs extension to also accept processing operations. Two options: + - Add a new parameter `operations: &[ProcessingOperation]` + - Or create a new function `convert_with_processing()` to avoid changing the signature + +2. **`lib.rs` WASM exports** -- Currently has `convert_image_with_transforms()`. Needs a new export that also accepts operations. + +3. **Worker protocol** -- `ConvertImageRequest` currently has `transforms?: string[]`. Needs `operations?: ProcessingOperation[]`. + +4. **`useConverter` hook** -- Currently manages `transforms: TransformName[]`. Needs extension for processing operations state. + +### Relationship to Existing Transforms + +Existing transforms (flip, rotate, grayscale, invert) are simple toggles with no parameters. The new processing operations have parameters. These are conceptually different: +- Transforms: applied as a set, order mostly doesn't matter (except rotation) +- Processing operations: applied in explicit order, each with parameters + +**Recommendation:** Keep transforms and processing operations as separate concepts. Transforms remain as comma-separated strings. Processing operations use the new `JsValue` approach. Both are applied between decode and encode, transforms first, then processing operations. + +## 5. Frontend Architecture + +### UI Approach + +The existing `TransformModal` handles simple toggles. Processing operations need: +- Numeric inputs (width, height, x, y coordinates) +- Sliders (brightness, contrast, blur sigma, hue) +- Dropdowns (filter type) +- Toggle (aspect ratio lock for resize) + +**Recommendation:** Add processing controls to the existing `TransformModal`, extending the sidebar with collapsible operation groups. This keeps the modal pattern consistent and avoids introducing a second editing UI. + +Alternatively, create a separate "Edit Panel" as an inline section below the drop zone (not a modal), which matches the ROADMAP's description. This approach is better because: +- Operations are always visible (no need to open a modal) +- Users can see the preview while adjusting parameters +- More screen real estate for sliders and inputs + +### Preview Strategy + +For live preview during parameter adjustment: +1. Downscale the source image to ~400px wide on the Rust side +2. Apply all operations to the thumbnail +3. Display the thumbnail as preview +4. On "Download" or "Convert", apply operations at full resolution + +This requires a new WASM export: `preview_with_operations(input, operations, max_preview_width)` that internally thumbnails first. + +### Debounce + +Already implemented pattern: `useConverter.ts` uses `TRANSFORM_DEBOUNCE_MS = 300` for transform changes. The same pattern applies to processing operation parameter changes. + +### Crop UI + +Two approaches: +1. **Numeric inputs** (x, y, width, height) -- simpler, less UX-friendly +2. **Interactive overlay** -- draggable rectangle on the preview image + +**Recommendation for V1:** Start with numeric inputs. An interactive crop overlay is a significant frontend effort (mouse/touch drag, resize handles, aspect ratio constraints) and can be added as a follow-up enhancement. + +## 6. Performance Considerations + +### Memory Management +- Drop input buffer after decode (already done) +- Processing operations may create intermediate `DynamicImage` allocations -- each operation creates a new image. For a chain of 5 operations on a 10MP image, this could use ~200MB peak. +- Mitigate: operations consume the previous image, allowing the allocator to reuse memory. + +### Preview Resolution +- 400px wide thumbnail of a 4000px image = 100x reduction in pixels processed +- Makes even expensive operations (blur, resize with Lanczos3) feel instant +- Full resolution only on final convert/download + +### `as_conversions` Lint +The `clippy::as_conversions` lint is denied at the workspace level. The `image` crate's `FilterType` and `imageops` functions use safe types (u32, i32, f32), so most conversions should be clean. Where `as` is needed (e.g., converting between numeric types), use `#[allow(clippy::as_conversions)]` with a safety comment. + +## 7. Recommended Implementation Order + +1. **Rust processing module** -- Define types and implement operations +2. **WASM export** -- Add `process_and_convert` function +3. **Worker protocol** -- Extend message types for operations +4. **Frontend types** -- TypeScript types for operations +5. **Edit Panel UI** -- Component with controls for each operation +6. **Preview mode** -- Low-resolution preview for parameter adjustment +7. **Integration** -- Wire everything together with debounced updates +8. **Tests** -- Rust unit tests + TypeScript unit tests + +## 8. Open Questions + +| Question | Confidence | Recommendation | +|----------|-----------|----------------| +| Should tile be included in V1? | HIGH | No -- defer to follow-up, needs custom implementation | +| Should fast_blur be separate from blur? | MEDIUM | Yes, expose both -- fast_blur is noticeably faster for preview | +| Should crop UI be interactive or numeric? | HIGH | Numeric inputs for V1, interactive overlay as follow-up | +| Should operations be reorderable? | MEDIUM | No for V1 -- fixed order (resize -> crop -> adjustments) is simpler and covers most use cases | +| Separate WASM function or extend existing? | HIGH | New function `process_and_convert` -- avoids breaking existing API | diff --git a/web/src/components/EditPanel.tsx b/web/src/components/EditPanel.tsx new file mode 100644 index 0000000..ce968f8 --- /dev/null +++ b/web/src/components/EditPanel.tsx @@ -0,0 +1,529 @@ +import { useState } from 'preact/hooks' +import { Slider } from './Slider' +import type { ResizeState, CropState, AdjustmentsState, BlurType } from '../hooks/useProcessing' +import type { ResizeFilter } from '../types' + +interface EditPanelProps { + /** Current resize operation state. */ + resize: ResizeState + /** Current crop operation state. */ + crop: CropState + /** Current adjustments state. */ + adjustments: AdjustmentsState + /** Source image width (for validation constraints). */ + sourceWidth: number + /** Source image height (for validation constraints). */ + sourceHeight: number + /** Callback to update resize state. */ + onResizeChange: (update: Partial) => void + /** Callback to update crop state. */ + onCropChange: (update: Partial) => void + /** Callback to update adjustments state. */ + onAdjustmentsChange: (update: Partial) => void + /** Callback to reset a specific section. */ + onResetSection: (section: 'resize' | 'crop' | 'adjustments') => void + /** Callback to reset all sections. */ + onResetAll: () => void + /** Whether the panel controls are disabled (e.g. during conversion). */ + disabled: boolean +} + +/** Shared inline styles for section headers. */ +const SECTION_HEADER_STYLE = { + fontFamily: "'Share Tech Mono', monospace", + fontSize: '0.75rem', + letterSpacing: '0.15em', + color: 'var(--cp-cyan)', + cursor: 'pointer', + userSelect: 'none' as const, +} + +/** Shared inline styles for number inputs. */ +const NUMBER_INPUT_STYLE = { + background: 'var(--cp-panel)', + border: '1px solid var(--cp-border)', + color: 'var(--cp-text)', + fontFamily: "'Share Tech Mono', monospace", + fontSize: '0.75rem', + padding: '0.25rem 0.5rem', + width: '5rem', +} + +/** Shared inline styles for labels. */ +const LABEL_STYLE = { + color: 'var(--cp-muted)', + fontFamily: "'Share Tech Mono', monospace", + fontSize: '0.65rem', + letterSpacing: '0.1em', + textTransform: 'uppercase' as const, +} + +/** Shared inline styles for select dropdowns. */ +const SELECT_STYLE = { + background: 'var(--cp-panel)', + border: '1px solid var(--cp-border)', + color: 'var(--cp-text)', + fontFamily: "'Share Tech Mono', monospace", + fontSize: '0.7rem', + padding: '0.25rem 0.5rem', +} + +/** Resize filter options with display labels. */ +const FILTER_OPTIONS: { value: ResizeFilter; label: string }[] = [ + { value: 'lanczos3', label: 'Lanczos3 (sharp)' }, + { value: 'catmull_rom', label: 'CatmullRom' }, + { value: 'gaussian', label: 'Gaussian' }, + { value: 'triangle', label: 'Bilinear' }, + { value: 'nearest', label: 'Nearest' }, +] + +/** + * Inline edit panel for configuring image processing operations. + * + * Renders collapsible sections for Resize, Crop, and Adjustments, + * each with an enable/disable toggle and appropriate controls. + * Matches the cyberpunk UI theme of the rest of the app. + */ +export function EditPanel({ + resize, + crop, + adjustments, + sourceWidth, + sourceHeight, + onResizeChange, + onCropChange, + onAdjustmentsChange, + onResetSection, + onResetAll, + disabled, +}: EditPanelProps): preact.JSX.Element { + const [resizeOpen, setResizeOpen] = useState(false) + const [cropOpen, setCropOpen] = useState(false) + const [adjustmentsOpen, setAdjustmentsOpen] = useState(false) + + return ( +
+ {/* Panel header */} +
+ + EDIT IMAGE + + +
+ + {/* ─── Resize Section ─── */} + { + setResizeOpen((o) => !o) + }} + onToggleEnabled={(enabled) => { + onResizeChange({ enabled }) + }} + onReset={() => { + onResetSection('resize') + }} + disabled={disabled} + > +
+
+
+ Width + { + onResizeChange({ width: Math.max(1, Number(e.currentTarget.value)) }) + }} + /> +
+ +
+ Height + { + onResizeChange({ height: Math.max(1, Number(e.currentTarget.value)) }) + }} + /> +
+
+
+ Filter + +
+
+
+ + {/* ─── Crop Section ─── */} + { + setCropOpen((o) => !o) + }} + onToggleEnabled={(enabled) => { + // Initialize crop to full image dimensions when enabling + if (enabled) { + onCropChange({ enabled: true, x: 0, y: 0, width: sourceWidth, height: sourceHeight }) + } else { + onCropChange({ enabled: false }) + } + }} + onReset={() => { + onResetSection('crop') + }} + disabled={disabled} + > +
+
+ X + { + onCropChange({ x: Math.max(0, Number(e.currentTarget.value)) }) + }} + /> +
+
+ Y + { + onCropChange({ y: Math.max(0, Number(e.currentTarget.value)) }) + }} + /> +
+
+ Width + { + onCropChange({ width: Math.max(1, Number(e.currentTarget.value)) }) + }} + /> +
+
+ Height + { + onCropChange({ height: Math.max(1, Number(e.currentTarget.value)) }) + }} + /> +
+
+
+ + {/* ─── Adjustments Section ─── */} + { + setAdjustmentsOpen((o) => !o) + }} + onReset={() => { + onResetSection('adjustments') + }} + disabled={disabled} + alwaysEnabled + > +
+ { + onAdjustmentsChange({ brightness: value }) + }} + disabled={disabled} + /> + { + onAdjustmentsChange({ contrast: value }) + }} + disabled={disabled} + /> + { + onAdjustmentsChange({ hueRotate: value }) + }} + disabled={disabled} + /> +
+
+ { + onAdjustmentsChange({ blurSigma: value }) + }} + disabled={disabled} + /> +
+
+ Type: + + +
+
+ { + onAdjustmentsChange({ unsharpenSigma: value }) + }} + disabled={disabled} + /> + { + onAdjustmentsChange({ unsharpenThreshold: value }) + }} + disabled={disabled} + /> +
+
+
+ ) +} + +// ─── Collapsible Section Sub-component ──────────────────────────────── + +interface CollapsibleSectionProps { + title: string + enabled: boolean + open: boolean + onToggleOpen: () => void + onToggleEnabled?: (enabled: boolean) => void + onReset: () => void + disabled: boolean + alwaysEnabled?: boolean + children: preact.ComponentChildren +} + +/** + * A collapsible section with an enable/disable toggle and reset button. + * Used internally by EditPanel for each operation group. + */ +function CollapsibleSection({ + title, + enabled, + open, + onToggleOpen, + onToggleEnabled, + onReset, + disabled, + alwaysEnabled = false, + children, +}: CollapsibleSectionProps): preact.JSX.Element { + return ( +
+
+
+ {!alwaysEnabled && onToggleEnabled && ( + { + onToggleEnabled(e.currentTarget.checked) + }} + style={{ accentColor: 'var(--cp-cyan)' }} + /> + )} + +
+ +
+ {open &&
{children}
} +
+ ) +} diff --git a/web/src/components/ImageConverter.tsx b/web/src/components/ImageConverter.tsx index 1d69d17..50d3ec3 100644 --- a/web/src/components/ImageConverter.tsx +++ b/web/src/components/ImageConverter.tsx @@ -1,8 +1,10 @@ -import { useState, useEffect, useCallback } from 'preact/hooks' +import { useState, useEffect, useCallback, useRef } from 'preact/hooks' import { useConverter } from '../hooks/useConverter' import { useClipboardPaste } from '../hooks/useClipboardPaste' import { useBenchmark } from '../hooks/useBenchmark' +import { useProcessing } from '../hooks/useProcessing' import { DropZone } from './DropZone' +import { EditPanel } from './EditPanel' import { TransformModal } from './TransformModal' import { BenchmarkTable } from './BenchmarkTable' import { MetadataModal } from './MetadataModal' @@ -13,6 +15,9 @@ import type { InputFormat } from '../types' const CLIP_LG = 'polygon(28px 0%, 100% 0%, 100% calc(100% - 28px), calc(100% - 28px) 100%, 0% 100%, 0% 28px)' +/** Debounce delay (ms) before triggering conversion when processing operations change. */ +const PROCESSING_DEBOUNCE_MS = 300 + interface Props { initialFrom?: InputFormat initialTo?: ValidFormat @@ -33,6 +38,7 @@ export function ImageConverter({ initialFrom, initialTo }: Props = {}): preact.J toggleTransform, undoTransform, canUndoTransform, + setOperations, } = useConverter() const [targetFormat, setTargetFormat] = useState(initialTo ?? ValidFormat.Png) const [transformModalOpen, setTransformModalOpen] = useState(false) @@ -43,6 +49,97 @@ export function ImageConverter({ initialFrom, initialTo }: Props = {}): preact.J quality, ) + const sourceWidth = state.fileInfo?.width ?? 0 + const sourceHeight = state.fileInfo?.height ?? 0 + + const processing = useProcessing(sourceWidth, sourceHeight) + + // Preview state + const [previewUrl, setPreviewUrl] = useState(null) + const previewDebounceRef = useRef | null>(null) + const previewUrlRef = useRef(null) + + /** Revokes the current preview blob URL. */ + function revokePreviewUrl(): void { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current) + previewUrlRef.current = null + } + } + + // Sync operations to converter ref and trigger preview on changes + useEffect(() => { + setOperations(processing.operations) + + // Clear preview debounce + if (previewDebounceRef.current !== null) { + clearTimeout(previewDebounceRef.current) + previewDebounceRef.current = null + } + + if (!state.fileInfo || !processing.hasActiveOperations) { + revokePreviewUrl() + setPreviewUrl(null) + return + } + + // Debounce preview generation + const ops = processing.operations + const bytes = state.fileInfo.bytes + previewDebounceRef.current = setTimeout(() => { + previewDebounceRef.current = null + converter + .previewOperations(bytes, ops, 400) + .then((result) => { + // Convert RGBA to blob URL via canvas + const canvas = document.createElement('canvas') + canvas.width = result.width + canvas.height = result.height + const ctx = canvas.getContext('2d') + if (!ctx) { + return + } + const imageData = new ImageData( + new Uint8ClampedArray(result.rgba.buffer as ArrayBuffer), + result.width, + result.height, + ) + ctx.putImageData(imageData, 0, 0) + canvas.toBlob((blob) => { + if (blob) { + revokePreviewUrl() + const url = URL.createObjectURL(blob) + previewUrlRef.current = url + setPreviewUrl(url) + } + }) + }) + .catch((err: unknown) => { + console.warn('[image-converter] Preview generation failed:', err) + }) + }, PROCESSING_DEBOUNCE_MS) + + return () => { + if (previewDebounceRef.current !== null) { + clearTimeout(previewDebounceRef.current) + previewDebounceRef.current = null + } + } + }, [ + processing.operations, + processing.hasActiveOperations, + state.fileInfo, + converter, + setOperations, + ]) + + // Cleanup preview URL on unmount + useEffect(() => { + return () => { + revokePreviewUrl() + } + }, []) + /** Sets the target format and triggers conversion (used by benchmark table rows). */ const onConvertFormat = useCallback( (format: ValidFormat) => { @@ -88,6 +185,7 @@ export function ImageConverter({ initialFrom, initialTo }: Props = {}): preact.J benchmarkState.isRunning || benchmarkState.results.length > 0 + /** Tracks download click analytics event. */ function onDownloadClick() { if (state.fileInfo && state.result) { trackDownloadClicked({ @@ -98,6 +196,8 @@ export function ImageConverter({ initialFrom, initialTo }: Props = {}): preact.J } } + const panelDisabled = state.status === 'converting' || state.status === 'reading' + return ( /* Outer border layer: provides neon cyan outline via background + clip-path */
- ⚠ {state.error} + ⚠ {state.error}
)} @@ -165,6 +265,60 @@ export function ImageConverter({ initialFrom, initialTo }: Props = {}): preact.J benchmarkDisabled={benchmarkDisabled} /> + {/* Edit panel: visible when an image is loaded */} + {state.fileInfo && ( +
+ {/* Processing preview */} + {previewUrl && processing.hasActiveOperations && ( +
+ + PREVIEW + + Processing preview +
+ )} + +
+ )} + void + /** Label text displayed above the slider. */ + label: string + /** Whether the slider is disabled. */ + disabled?: boolean +} + +/** + * Reusable range slider component with cyberpunk styling. + * Fires onChange on the `input` event for real-time feedback. + */ +export function Slider({ + min, + max, + step, + value, + onChange, + label, + disabled = false, +}: SliderProps): preact.JSX.Element { + return ( +
+
+ + + {value} + +
+ { + const target = e.currentTarget + onChange(Number(target.value)) + }} + /> +
+ ) +} diff --git a/web/src/hooks/useConverter.ts b/web/src/hooks/useConverter.ts index 16b9830..99234cf 100644 --- a/web/src/hooks/useConverter.ts +++ b/web/src/hooks/useConverter.ts @@ -8,7 +8,7 @@ import { trackValidationRejected, } from '../analytics' import type { ImageConverter } from '../lib/image-converter' -import type { ImageMetadata } from '../types' +import type { ImageMetadata, ProcessingOperation } from '../types' import { ValidFormat } from '../types' import { normalizeHeic } from '../lib/heic' import { getQualityForFormat } from '../lib/quality' @@ -180,6 +180,8 @@ export function useConverter(): { toggleTransform: (targetFormat: ValidFormat, name: TransformName) => void undoTransform: (targetFormat: ValidFormat) => void canUndoTransform: boolean + /** Sets the current processing operations for use during conversion. */ + setOperations: (ops: ProcessingOperation[]) => void } { const converter = useImageConverter() const blobUrlRef = useRef(null) @@ -188,6 +190,7 @@ export function useConverter(): { const transformDebounceRef = useRef | null>(null) const transformsRef = useRef([]) const transformHistoryRef = useRef([]) + const operationsRef = useRef([]) const [quality, setQuality] = useState(80) const [transforms, setTransformsState] = useState([]) @@ -391,12 +394,27 @@ export function useConverter(): { const startTime = performance.now() try { - const resultBytes = await converter.convertImage( - fileInfo.bytes, - targetFormat, - qualityForFormat, - hasTransforms ? currentTransforms : undefined, - ) + const currentOperations = operationsRef.current + const hasOperations = currentOperations.length > 0 + + let resultBytes: Uint8Array + if (hasOperations) { + const { data } = await converter.processImage( + fileInfo.bytes, + targetFormat, + currentOperations, + qualityForFormat, + hasTransforms ? currentTransforms : undefined, + ) + resultBytes = data + } else { + resultBytes = await converter.convertImage( + fileInfo.bytes, + targetFormat, + qualityForFormat, + hasTransforms ? currentTransforms : undefined, + ) + } if (myGeneration !== convertGenerationRef.current) { return } @@ -558,6 +576,11 @@ export function useConverter(): { const canUndoTransform = transformHistoryRef.current.length > 0 + /** Updates the current processing operations ref for use during conversion. */ + const setOperations = useCallback((ops: ProcessingOperation[]) => { + operationsRef.current = ops + }, []) + return { state, converter, @@ -571,5 +594,6 @@ export function useConverter(): { toggleTransform, undoTransform, canUndoTransform, + setOperations, } } diff --git a/web/src/hooks/useProcessing.ts b/web/src/hooks/useProcessing.ts new file mode 100644 index 0000000..66226ee --- /dev/null +++ b/web/src/hooks/useProcessing.ts @@ -0,0 +1,246 @@ +import { useState, useCallback, useMemo } from 'preact/hooks' +import type { ProcessingOperation, ResizeFilter } from '../types' + +/** State for the resize operation section. */ +export interface ResizeState { + enabled: boolean + width: number + height: number + filter: ResizeFilter + lockAspectRatio: boolean +} + +/** State for the crop operation section. */ +export interface CropState { + enabled: boolean + x: number + y: number + width: number + height: number +} + +/** Blur type selection: Gaussian or fast (box) blur. */ +export type BlurType = 'gaussian' | 'fast' + +/** State for all adjustment operations. */ +export interface AdjustmentsState { + brightness: number + contrast: number + hueRotate: number + blurSigma: number + blurType: BlurType + unsharpenSigma: number + unsharpenThreshold: number +} + +/** Complete processing state for all operation sections. */ +export interface ProcessingState { + resize: ResizeState + crop: CropState + adjustments: AdjustmentsState +} + +/** Default resize state (disabled, zeroed dimensions). */ +const DEFAULT_RESIZE: ResizeState = { + enabled: false, + width: 0, + height: 0, + filter: 'lanczos3', + lockAspectRatio: true, +} + +/** Default crop state (disabled, zeroed region). */ +const DEFAULT_CROP: CropState = { + enabled: false, + x: 0, + y: 0, + width: 0, + height: 0, +} + +/** Default adjustments state (all neutral values). */ +const DEFAULT_ADJUSTMENTS: AdjustmentsState = { + brightness: 0, + contrast: 0, + hueRotate: 0, + blurSigma: 0, + blurType: 'gaussian', + unsharpenSigma: 0, + unsharpenThreshold: 0, +} + +/** Default processing state with all sections disabled/neutral. */ +const DEFAULT_STATE: ProcessingState = { + resize: DEFAULT_RESIZE, + crop: DEFAULT_CROP, + adjustments: DEFAULT_ADJUSTMENTS, +} + +/** + * Converts the current processing state into an ordered array of ProcessingOperation objects. + * Operations are emitted in fixed order: resize -> crop -> adjustments. + * Only enabled sections with non-neutral values produce operations. + */ +export function buildOperations(state: ProcessingState): ProcessingOperation[] { + const ops: ProcessingOperation[] = [] + + // Resize (if enabled and dimensions are valid) + if (state.resize.enabled && state.resize.width > 0 && state.resize.height > 0) { + ops.push({ + type: 'resize', + width: state.resize.width, + height: state.resize.height, + filter: state.resize.filter, + }) + } + + // Crop (if enabled and dimensions are valid) + if (state.crop.enabled && state.crop.width > 0 && state.crop.height > 0) { + ops.push({ + type: 'crop', + x: state.crop.x, + y: state.crop.y, + width: state.crop.width, + height: state.crop.height, + }) + } + + // Adjustments (only add operations with non-zero/non-default values) + const adj = state.adjustments + + if (adj.brightness !== 0) { + ops.push({ type: 'brighten', value: adj.brightness }) + } + + if (adj.contrast !== 0) { + ops.push({ type: 'contrast', value: adj.contrast }) + } + + if (adj.hueRotate !== 0) { + ops.push({ type: 'hue_rotate', degrees: adj.hueRotate }) + } + + if (adj.blurSigma > 0) { + if (adj.blurType === 'fast') { + ops.push({ type: 'fast_blur', sigma: adj.blurSigma }) + } else { + ops.push({ type: 'blur', sigma: adj.blurSigma }) + } + } + + if (adj.unsharpenSigma > 0) { + ops.push({ + type: 'unsharpen', + sigma: adj.unsharpenSigma, + threshold: adj.unsharpenThreshold, + }) + } + + return ops +} + +/** Return type of the useProcessing hook. */ +export interface UseProcessingReturn { + /** Current processing state for all operation sections. */ + state: ProcessingState + /** The ordered array of active processing operations. */ + operations: ProcessingOperation[] + /** Whether any operations are currently active (non-default). */ + hasActiveOperations: boolean + /** Update the resize section state. */ + updateResize: (update: Partial) => void + /** Update the crop section state. */ + updateCrop: (update: Partial) => void + /** Update the adjustments section state. */ + updateAdjustments: (update: Partial) => void + /** Reset a specific operation section to its defaults. */ + resetOperation: (section: 'resize' | 'crop' | 'adjustments') => void + /** Reset all operation sections to their defaults. */ + resetAll: () => void +} + +/** + * Hook for managing image processing operation state. + * + * Provides state management for resize, crop, and adjustment operations, + * with functions to update individual sections, reset sections, and + * build the operations array for the processing pipeline. + * + * @param sourceWidth - Original image width (used for aspect ratio calculations) + * @param sourceHeight - Original image height (used for aspect ratio calculations) + */ +export function useProcessing(sourceWidth: number, sourceHeight: number): UseProcessingReturn { + const [state, setState] = useState(DEFAULT_STATE) + const aspectRatio = sourceHeight > 0 ? sourceWidth / sourceHeight : 1 + + /** Updates resize state, handling aspect ratio lock when applicable. */ + const updateResize = useCallback( + (update: Partial) => { + setState((prev) => { + const next = { ...prev.resize, ...update } + + // Handle aspect ratio lock: when width or height changes, update the other + if (next.lockAspectRatio && aspectRatio > 0) { + const updatedWidth = 'width' in update ? update.width : undefined + const updatedHeight = 'height' in update ? update.height : undefined + if (updatedWidth !== undefined && updatedWidth > 0) { + next.height = Math.round(updatedWidth / aspectRatio) + } else if (updatedHeight !== undefined && updatedHeight > 0) { + next.width = Math.round(updatedHeight * aspectRatio) + } + } + + return { ...prev, resize: next } + }) + }, + [aspectRatio], + ) + + /** Updates crop state. */ + const updateCrop = useCallback((update: Partial) => { + setState((prev) => ({ + ...prev, + crop: { ...prev.crop, ...update }, + })) + }, []) + + /** Updates adjustments state. */ + const updateAdjustments = useCallback((update: Partial) => { + setState((prev) => ({ + ...prev, + adjustments: { ...prev.adjustments, ...update }, + })) + }, []) + + /** Resets a specific operation section to its defaults. */ + const resetOperation = useCallback((section: 'resize' | 'crop' | 'adjustments') => { + setState((prev) => { + if (section === 'resize') { + return { ...prev, resize: DEFAULT_RESIZE } + } + if (section === 'crop') { + return { ...prev, crop: DEFAULT_CROP } + } + return { ...prev, adjustments: DEFAULT_ADJUSTMENTS } + }) + }, []) + + /** Resets all operation sections to their defaults. */ + const resetAll = useCallback(() => { + setState(DEFAULT_STATE) + }, []) + + const operations = useMemo(() => buildOperations(state), [state]) + const hasActiveOperations = operations.length > 0 + + return { + state, + operations, + hasActiveOperations, + updateResize, + updateCrop, + updateAdjustments, + resetOperation, + resetAll, + } +} diff --git a/web/src/lib/image-converter.ts b/web/src/lib/image-converter.ts index 16dd5e2..56e6bd9 100644 --- a/web/src/lib/image-converter.ts +++ b/web/src/lib/image-converter.ts @@ -5,6 +5,7 @@ import type { ImageDimensions, ImageMetadata, BenchmarkResultResponse, + ProcessingOperation, } from '../types' interface PendingRequest { @@ -169,6 +170,59 @@ export class ImageConverter { throw new Error('Unexpected response type') } + /** + * Convert an image with processing operations applied. + * Returns the converted bytes and conversion timing. + */ + async processImage( + data: Uint8Array, + targetFormat: ValidFormat, + operations: ProcessingOperation[], + quality?: number, + transforms?: string[], + ): Promise<{ data: Uint8Array; conversionMs: number }> { + await this.ready + const id = this.nextRequestId++ + const hasTransforms = transforms !== undefined && transforms.length > 0 + const response = await this.sendRequest({ + type: MessageType.ProcessImage, + id, + data, + targetFormat, + operations, + ...(quality !== undefined ? { quality } : {}), + ...(hasTransforms ? { transforms } : {}), + }) + if (response.type === MessageType.ProcessImage) { + return { data: response.data, conversionMs: response.conversionMs } + } + throw new Error('Unexpected response type') + } + + /** + * Generate a low-resolution preview with processing operations applied. + * Returns RGBA pixel data and dimensions for rendering to a canvas. + */ + async previewOperations( + data: Uint8Array, + operations: ProcessingOperation[], + maxWidth: number, + ): Promise<{ rgba: Uint8Array; width: number; height: number }> { + await this.ready + const id = this.nextRequestId++ + const response = await this.sendRequest({ + type: MessageType.PreviewOperations, + id, + data, + operations, + maxWidth, + }) + if (response.type === MessageType.PreviewOperations) { + return { rgba: response.rgba, width: response.width, height: response.height } + } + throw new Error('Unexpected response type') + } + private clearBenchmarkCallbacks(): void { this.activeBenchmarkId = null this.benchmarkResultCallback = null diff --git a/web/src/types/enums.ts b/web/src/types/enums.ts index 06e6510..c6660fc 100644 --- a/web/src/types/enums.ts +++ b/web/src/types/enums.ts @@ -22,5 +22,7 @@ export enum MessageType { BenchmarkResult = 'benchmark_result', BenchmarkComplete = 'benchmark_complete', GetMetadata = 'get_metadata', + ProcessImage = 'process_image', + PreviewOperations = 'preview_operations', Error = 'error', } diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 59e0e8d..7913d56 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -6,6 +6,8 @@ export type { GetDimensionsRequest, GetMetadataRequest, BenchmarkImagesRequest, + ProcessImageRequest, + PreviewOperationsRequest, InitSuccessResponse, InitErrorResponse, DetectFormatSuccessResponse, @@ -16,11 +18,15 @@ export type { BenchmarkResultError, BenchmarkResultResponse, BenchmarkCompleteResponse, + ProcessImageSuccessResponse, + PreviewOperationsSuccessResponse, ErrorResponse, ImageDimensions, ImageMetadata, ExifData, ExifField, TextChunk, + ResizeFilter, + ProcessingOperation, } from './interfaces' export type { WorkerRequest, WorkerResponse } from './types' diff --git a/web/src/types/interfaces.ts b/web/src/types/interfaces.ts index f1bce4d..f99b890 100644 --- a/web/src/types/interfaces.ts +++ b/web/src/types/interfaces.ts @@ -1,5 +1,23 @@ import type { MessageType, ValidFormat } from './enums' +// Processing operation types (match Rust serde format) + +/** Resize filter algorithms available for resize operations. */ +export type ResizeFilter = 'nearest' | 'triangle' | 'catmull_rom' | 'gaussian' | 'lanczos3' + +/** A single image processing operation with its parameters. */ +export type ProcessingOperation = + | { type: 'resize'; width: number; height: number; filter: ResizeFilter } + | { type: 'resize_exact'; width: number; height: number; filter: ResizeFilter } + | { type: 'thumbnail'; max_width: number; max_height: number } + | { type: 'crop'; x: number; y: number; width: number; height: number } + | { type: 'blur'; sigma: number } + | { type: 'fast_blur'; sigma: number } + | { type: 'unsharpen'; sigma: number; threshold: number } + | { type: 'brighten'; value: number } + | { type: 'contrast'; value: number } + | { type: 'hue_rotate'; degrees: number } + // Request types (main thread → worker) export interface DetectFormatRequest { @@ -164,3 +182,40 @@ export interface GetMetadataSuccessResponse { success: true metadata: ImageMetadata } + +// Processing operation worker messages + +export interface ProcessImageRequest { + type: MessageType.ProcessImage + id: number + data: Uint8Array + targetFormat: ValidFormat + quality?: number + transforms?: string[] + operations: ProcessingOperation[] +} + +export interface ProcessImageSuccessResponse { + type: MessageType.ProcessImage + id: number + success: true + data: Uint8Array + conversionMs: number +} + +export interface PreviewOperationsRequest { + type: MessageType.PreviewOperations + id: number + data: Uint8Array + operations: ProcessingOperation[] + maxWidth: number +} + +export interface PreviewOperationsSuccessResponse { + type: MessageType.PreviewOperations + id: number + success: true + rgba: Uint8Array + width: number + height: number +} diff --git a/web/src/types/types.ts b/web/src/types/types.ts index fc64c11..7ab8353 100644 --- a/web/src/types/types.ts +++ b/web/src/types/types.ts @@ -4,6 +4,8 @@ import type { GetDimensionsRequest, GetMetadataRequest, BenchmarkImagesRequest, + ProcessImageRequest, + PreviewOperationsRequest, InitSuccessResponse, InitErrorResponse, DetectFormatSuccessResponse, @@ -13,6 +15,8 @@ import type { BenchmarkResultSuccess, BenchmarkResultError, BenchmarkCompleteResponse, + ProcessImageSuccessResponse, + PreviewOperationsSuccessResponse, ErrorResponse, } from './interfaces' @@ -22,6 +26,8 @@ export type WorkerRequest = | GetDimensionsRequest | GetMetadataRequest | BenchmarkImagesRequest + | ProcessImageRequest + | PreviewOperationsRequest export type WorkerResponse = | InitSuccessResponse @@ -33,4 +39,6 @@ export type WorkerResponse = | BenchmarkResultSuccess | BenchmarkResultError | BenchmarkCompleteResponse + | ProcessImageSuccessResponse + | PreviewOperationsSuccessResponse | ErrorResponse diff --git a/web/src/worker.ts b/web/src/worker.ts index a0fa8f6..04ff007 100644 --- a/web/src/worker.ts +++ b/web/src/worker.ts @@ -6,13 +6,16 @@ import init, { convert_image_with_transforms, decode_to_rgba, decode_to_rgba_with_transforms, + decode_rgba_with_processing, detect_format, get_dimensions, get_image_metadata, + process_and_convert, + preview_operations, } from '../../crates/image-converter/pkg/image_converter.js' import { MessageType, ValidFormat } from './types' -import type { ImageMetadata } from './types' +import type { ImageMetadata, ProcessingOperation } from './types' import type { BenchmarkImagesRequest, WorkerRequest, WorkerResponse } from './types' import { getQualityForFormat } from './lib/quality' @@ -58,6 +61,19 @@ onmessage = (event: MessageEvent) => { case MessageType.BenchmarkImages: void handleBenchmarkImages(request) break + case MessageType.ProcessImage: + void handleProcessImage( + request.id, + request.data, + request.targetFormat, + request.operations, + request.quality, + request.transforms, + ) + break + case MessageType.PreviewOperations: + handlePreviewOperations(request.id, request.data, request.operations, request.maxWidth) + break } } @@ -225,6 +241,86 @@ function handleGetMetadata(id: number, data: Uint8Array): void { } } +/** Encodes raw RGBA pixels, applies transforms + processing ops, then encodes to WebP via Canvas. */ +async function encodeWebpWithProcessing( + data: Uint8Array, + quality: number, + transforms: string[], + operations: ProcessingOperation[], +): Promise { + const transformsCsv = transforms.join(',') + const result = decode_rgba_with_processing(data, transformsCsv, operations) as { + rgba: Uint8Array + width: number + height: number + } + return encodeRgbaToWebp(result.rgba, result.width, result.height, quality) +} + +/** Convert an image with processing operations applied. */ +async function handleProcessImage( + id: number, + data: Uint8Array, + targetFormat: ValidFormat, + operations: ProcessingOperation[], + quality?: number, + transforms?: string[], +): Promise { + try { + const start = performance.now() + let result: Uint8Array + const hasTransforms = transforms !== undefined && transforms.length > 0 + const transformsCsv = hasTransforms ? transforms.join(',') : '' + + if (targetFormat === ValidFormat.WebP) { + const canvasQuality = quality !== undefined ? quality / 100 : 0.85 + const actualTransforms = hasTransforms ? transforms : [] + result = await encodeWebpWithProcessing(data, canvasQuality, actualTransforms, operations) + } else { + result = process_and_convert(data, targetFormat, quality, transformsCsv, operations) + } + + const conversionMs = Math.round(performance.now() - start) + const response: WorkerResponse = { + type: MessageType.ProcessImage, + id, + success: true, + data: result, + conversionMs, + } + postMessage(response, [result.buffer]) + } catch (e) { + postError(id, e) + } +} + +/** Generate a low-resolution preview with processing operations applied. */ +function handlePreviewOperations( + id: number, + data: Uint8Array, + operations: ProcessingOperation[], + maxWidth: number, +): void { + try { + const result = preview_operations(data, operations, maxWidth) as { + rgba: Uint8Array + width: number + height: number + } + const response: WorkerResponse = { + type: MessageType.PreviewOperations, + id, + success: true, + rgba: result.rgba, + width: result.width, + height: result.height, + } + postMessage(response, [result.rgba.buffer]) + } catch (e) { + postError(id, e) + } +} + async function handleBenchmarkImages(request: BenchmarkImagesRequest): Promise { benchmarkGeneration++ const myGeneration = benchmarkGeneration diff --git a/web/tests/unit/edit-panel.test.ts b/web/tests/unit/edit-panel.test.ts new file mode 100644 index 0000000..4574022 --- /dev/null +++ b/web/tests/unit/edit-panel.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import type { ResizeState, CropState, AdjustmentsState } from '../../src/hooks/useProcessing' + +/** + * Unit tests for EditPanel state types. + * + * Note: EditPanel is a Preact component that requires a DOM environment to render. + * These tests verify the data structures and state types used by EditPanel. + * Component rendering tests would require jsdom or a browser environment, + * which has known ESM compatibility issues with vitest (see MEMORY.md). + */ + +describe('EditPanel state types', () => { + it('ResizeState has correct default structure', () => { + const state: ResizeState = { + enabled: false, + width: 0, + height: 0, + filter: 'lanczos3', + lockAspectRatio: true, + } + expect(state.enabled).toBe(false) + expect(state.filter).toBe('lanczos3') + expect(state.lockAspectRatio).toBe(true) + }) + + it('CropState has correct default structure', () => { + const state: CropState = { + enabled: false, + x: 0, + y: 0, + width: 0, + height: 0, + } + expect(state.enabled).toBe(false) + expect(state.x).toBe(0) + }) + + it('AdjustmentsState has correct default structure', () => { + const state: AdjustmentsState = { + brightness: 0, + contrast: 0, + hueRotate: 0, + blurSigma: 0, + blurType: 'gaussian', + unsharpenSigma: 0, + unsharpenThreshold: 0, + } + expect(state.brightness).toBe(0) + expect(state.blurType).toBe('gaussian') + }) + + it('ResizeFilter accepts all valid values', () => { + const filters: ResizeState['filter'][] = [ + 'nearest', + 'triangle', + 'catmull_rom', + 'gaussian', + 'lanczos3', + ] + expect(filters.length).toBe(5) + for (const f of filters) { + expect(typeof f).toBe('string') + } + }) + + it('BlurType accepts gaussian and fast values', () => { + const types: AdjustmentsState['blurType'][] = ['gaussian', 'fast'] + expect(types.length).toBe(2) + }) +}) diff --git a/web/tests/unit/processing.test.ts b/web/tests/unit/processing.test.ts new file mode 100644 index 0000000..4c1a765 --- /dev/null +++ b/web/tests/unit/processing.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from 'vitest' +import { + buildOperations, + type ProcessingState, + type ResizeState, + type CropState, + type AdjustmentsState, +} from '../../src/hooks/useProcessing' + +const DEFAULT_RESIZE: ResizeState = { + enabled: false, + width: 0, + height: 0, + filter: 'lanczos3', + lockAspectRatio: true, +} + +const DEFAULT_CROP: CropState = { + enabled: false, + x: 0, + y: 0, + width: 0, + height: 0, +} + +const DEFAULT_ADJUSTMENTS: AdjustmentsState = { + brightness: 0, + contrast: 0, + hueRotate: 0, + blurSigma: 0, + blurType: 'gaussian', + unsharpenSigma: 0, + unsharpenThreshold: 0, +} + +function makeState(overrides: Partial = {}): ProcessingState { + return { + resize: overrides.resize ?? DEFAULT_RESIZE, + crop: overrides.crop ?? DEFAULT_CROP, + adjustments: overrides.adjustments ?? DEFAULT_ADJUSTMENTS, + } +} + +describe('buildOperations', () => { + it('returns empty array for default state', () => { + const ops = buildOperations(makeState()) + expect(ops).toEqual([]) + }) + + it('includes resize operation when enabled with valid dimensions', () => { + const ops = buildOperations( + makeState({ + resize: { ...DEFAULT_RESIZE, enabled: true, width: 100, height: 50 }, + }), + ) + expect(ops).toEqual([{ type: 'resize', width: 100, height: 50, filter: 'lanczos3' }]) + }) + + it('excludes resize when enabled but width is 0', () => { + const ops = buildOperations( + makeState({ + resize: { ...DEFAULT_RESIZE, enabled: true, width: 0, height: 50 }, + }), + ) + expect(ops).toEqual([]) + }) + + it('excludes resize when disabled', () => { + const ops = buildOperations( + makeState({ + resize: { ...DEFAULT_RESIZE, enabled: false, width: 100, height: 50 }, + }), + ) + expect(ops).toEqual([]) + }) + + it('includes crop operation when enabled with valid dimensions', () => { + const ops = buildOperations( + makeState({ + crop: { enabled: true, x: 10, y: 20, width: 100, height: 50 }, + }), + ) + expect(ops).toEqual([{ type: 'crop', x: 10, y: 20, width: 100, height: 50 }]) + }) + + it('excludes crop when enabled but width is 0', () => { + const ops = buildOperations( + makeState({ + crop: { enabled: true, x: 0, y: 0, width: 0, height: 50 }, + }), + ) + expect(ops).toEqual([]) + }) + + it('includes brightness adjustment when non-zero', () => { + const ops = buildOperations( + makeState({ + adjustments: { ...DEFAULT_ADJUSTMENTS, brightness: 50 }, + }), + ) + expect(ops).toEqual([{ type: 'brighten', value: 50 }]) + }) + + it('includes contrast adjustment when non-zero', () => { + const ops = buildOperations( + makeState({ + adjustments: { ...DEFAULT_ADJUSTMENTS, contrast: -30 }, + }), + ) + expect(ops).toEqual([{ type: 'contrast', value: -30 }]) + }) + + it('includes hue rotation when non-zero', () => { + const ops = buildOperations( + makeState({ + adjustments: { ...DEFAULT_ADJUSTMENTS, hueRotate: 180 }, + }), + ) + expect(ops).toEqual([{ type: 'hue_rotate', degrees: 180 }]) + }) + + it('includes gaussian blur when sigma > 0', () => { + const ops = buildOperations( + makeState({ + adjustments: { ...DEFAULT_ADJUSTMENTS, blurSigma: 5, blurType: 'gaussian' }, + }), + ) + expect(ops).toEqual([{ type: 'blur', sigma: 5 }]) + }) + + it('includes fast blur when blurType is fast and sigma > 0', () => { + const ops = buildOperations( + makeState({ + adjustments: { ...DEFAULT_ADJUSTMENTS, blurSigma: 5, blurType: 'fast' }, + }), + ) + expect(ops).toEqual([{ type: 'fast_blur', sigma: 5 }]) + }) + + it('excludes blur when sigma is 0', () => { + const ops = buildOperations( + makeState({ + adjustments: { ...DEFAULT_ADJUSTMENTS, blurSigma: 0 }, + }), + ) + expect(ops).toEqual([]) + }) + + it('includes unsharpen when sigma > 0', () => { + const ops = buildOperations( + makeState({ + adjustments: { ...DEFAULT_ADJUSTMENTS, unsharpenSigma: 3, unsharpenThreshold: 5 }, + }), + ) + expect(ops).toEqual([{ type: 'unsharpen', sigma: 3, threshold: 5 }]) + }) + + it('excludes unsharpen when sigma is 0', () => { + const ops = buildOperations( + makeState({ + adjustments: { ...DEFAULT_ADJUSTMENTS, unsharpenSigma: 0, unsharpenThreshold: 5 }, + }), + ) + expect(ops).toEqual([]) + }) + + it('emits operations in fixed order: resize -> crop -> adjustments', () => { + const ops = buildOperations( + makeState({ + resize: { enabled: true, width: 200, height: 100, filter: 'nearest', lockAspectRatio: false }, + crop: { enabled: true, x: 0, y: 0, width: 100, height: 50 }, + adjustments: { ...DEFAULT_ADJUSTMENTS, brightness: 20, contrast: 10 }, + }), + ) + expect(ops.length).toBe(4) + expect(ops[0]?.type).toBe('resize') + expect(ops[1]?.type).toBe('crop') + expect(ops[2]?.type).toBe('brighten') + expect(ops[3]?.type).toBe('contrast') + }) + + it('includes multiple adjustments in correct order', () => { + const ops = buildOperations( + makeState({ + adjustments: { + brightness: 10, + contrast: 20, + hueRotate: 90, + blurSigma: 3, + blurType: 'gaussian', + unsharpenSigma: 2, + unsharpenThreshold: 5, + }, + }), + ) + expect(ops.length).toBe(5) + expect(ops[0]?.type).toBe('brighten') + expect(ops[1]?.type).toBe('contrast') + expect(ops[2]?.type).toBe('hue_rotate') + expect(ops[3]?.type).toBe('blur') + expect(ops[4]?.type).toBe('unsharpen') + }) + + it('uses specified resize filter', () => { + const ops = buildOperations( + makeState({ + resize: { enabled: true, width: 100, height: 50, filter: 'nearest', lockAspectRatio: true }, + }), + ) + const resizeOp = ops[0] + expect(resizeOp?.type).toBe('resize') + if (resizeOp?.type === 'resize') { + expect(resizeOp.filter).toBe('nearest') + } + }) +})