# Phase 2: Core Tools Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the image processing engine, file upload/download pipeline, and the first 10 core tools with both API endpoints and frontend settings UI. This is the phase where Stirling-Image goes from a shell to a functional image processing suite. **Architecture:** Each tool follows a uniform pattern: Fastify route accepts multipart file upload + JSON settings, delegates to the `@stirling-image/image-engine` package (Sharp wrapper), returns the processed file for download. A generic route factory eliminates boilerplate across all 37 tools. Batch processing uses p-queue for concurrency control and SSE for progress. **Tech Stack:** Sharp (libvips), @fastify/multipart, p-queue, archiver (ZIP), react-image-crop, Zustand, SSE **Spec:** `PRD.md` sections 5.1, 5.2, 5.3, 15 **Depends on:** Phase 1 (foundation) -- completed --- ## Task 1: Image Engine Package Build the Sharp wrapper in `packages/image-engine`. Each operation is a separate file exporting a single async function. All operations accept a Sharp instance (or Buffer) and return a Sharp instance (or Buffer), making them composable for the pipeline builder later. ### Files to create ``` packages/image-engine/ ├── src/ │ ├── index.ts # Re-exports all operations + types │ ├── types.ts # Shared types: OperationResult, ImageInfo, format enums │ ├── engine.ts # Core engine: load image, detect format, apply operations, output │ ├── operations/ │ │ ├── resize.ts # Resize by pixels, percentage, fit mode │ │ ├── crop.ts # Crop by coordinates (left, top, width, height) │ │ ├── rotate.ts # Rotate by angle, auto-crop background │ │ ├── flip.ts # Flip horizontal / vertical │ │ ├── convert.ts # Convert between formats with format-specific options │ │ ├── compress.ts # Quality-based and target-size compression │ │ ├── strip-metadata.ts # Selective metadata removal (EXIF, GPS, ICC, XMP) │ │ ├── brightness.ts # Brightness adjustment via Sharp modulate/linear │ │ ├── contrast.ts # Contrast adjustment via linear transform │ │ ├── saturation.ts # Saturation adjustment via Sharp modulate │ │ ├── color-channels.ts # Per-channel R/G/B multipliers via recomb │ │ ├── grayscale.ts # Convert to grayscale │ │ ├── sepia.ts # Sepia tone via recomb matrix │ │ └── invert.ts # Invert colors via Sharp negate │ ├── formats/ │ │ └── detect.ts # Format detection from buffer magic bytes + MIME mapping │ └── utils/ │ ├── metadata.ts # Read/parse EXIF, GPS, camera info via Sharp metadata() │ └── mime.ts # Extension <-> MIME type mapping ``` ### Files to modify ``` packages/image-engine/package.json # Add sharp dependency ``` ### Key interfaces ```typescript // types.ts export interface ImageInfo { width: number; height: number; format: string; channels: number; size: number; hasAlpha: boolean; metadata: Record; } export interface OperationResult { buffer: Buffer; info: ImageInfo; } // engine.ts export async function processImage( input: Buffer, operations: ImageOperation[], outputFormat?: OutputFormat ): Promise; export async function getImageInfo(input: Buffer): Promise; // operations/resize.ts export interface ResizeOptions { width?: number; height?: number; fit?: 'contain' | 'cover' | 'fill' | 'inside' | 'outside'; withoutEnlargement?: boolean; percentage?: number; } export async function resize(image: Sharp, options: ResizeOptions): Promise; // operations/crop.ts export interface CropOptions { left: number; top: number; width: number; height: number; } export async function crop(image: Sharp, options: CropOptions): Promise; // operations/compress.ts export interface CompressOptions { quality?: number; // 1-100 targetSizeBytes?: number; // binary search to hit target format?: OutputFormat; } export async function compress(image: Sharp, options: CompressOptions): Promise; ``` ### Steps - [ ] Add `sharp` as a dependency in `packages/image-engine/package.json` - [ ] Create `src/types.ts` with `ImageInfo`, `OperationResult`, `ResizeOptions`, `CropOptions`, `RotateOptions`, `FlipOptions`, `ConvertOptions`, `CompressOptions`, `StripMetadataOptions`, `BrightnessOptions`, `ContrastOptions`, `SaturationOptions`, `ColorChannelOptions`, `OutputFormat` type - [ ] Create `src/formats/detect.ts` -- use Sharp metadata + magic-byte fallback to detect input format, export `detectFormat(buffer: Buffer): Promise` - [ ] Create `src/utils/mime.ts` -- bidirectional map between file extensions and MIME types for all supported formats - [ ] Create `src/utils/metadata.ts` -- wrap `sharp(buffer).metadata()` and parse EXIF fields into structured object - [ ] Create `src/operations/resize.ts` -- use `sharp.resize()` with fit mode mapping - [ ] Create `src/operations/crop.ts` -- use `sharp.extract()` with bounds validation - [ ] Create `src/operations/rotate.ts` -- use `sharp.rotate(angle)` with background option for non-90 angles - [ ] Create `src/operations/flip.ts` -- use `sharp.flip()` and `sharp.flop()` - [ ] Create `src/operations/convert.ts` -- use `sharp.toFormat()` with per-format quality/option defaults from PRD section 6.2 - [ ] Create `src/operations/compress.ts` -- quality mode: pass quality to format encoder; target-size mode: binary search (max 8 iterations) adjusting quality until output is within 5% of target - [ ] Create `src/operations/strip-metadata.ts` -- use `sharp.withMetadata()` / `sharp.keepMetadata()` with selective field control - [ ] Create `src/operations/brightness.ts` -- use `sharp.modulate({ brightness })` where 1.0 = no change, map -100..+100 slider to 0..2 multiplier - [ ] Create `src/operations/contrast.ts` -- use `sharp.linear(a, b)` where a is contrast multiplier, map -100..+100 to 0.5..1.5 - [ ] Create `src/operations/saturation.ts` -- use `sharp.modulate({ saturation })` where 1.0 = no change - [ ] Create `src/operations/color-channels.ts` -- use `sharp.recomb()` with 3x3 matrix for per-channel multipliers - [ ] Create `src/operations/grayscale.ts` -- use `sharp.grayscale()` - [ ] Create `src/operations/sepia.ts` -- use `sharp.recomb()` with sepia matrix `[[0.393,0.769,0.189],[0.349,0.686,0.168],[0.272,0.534,0.131]]` - [ ] Create `src/operations/invert.ts` -- use `sharp.negate()` - [ ] Create `src/engine.ts` -- the orchestrator that loads a buffer, chains operations, and outputs in the requested format - [ ] Update `src/index.ts` to re-export everything - [ ] Write unit tests: `packages/image-engine/tests/operations.test.ts` -- test each operation with a small test image (1x1 or 10x10 PNG generated in-memory via Sharp). Verify output dimensions, format, and that no errors are thrown. ### Test ```bash cd packages/image-engine && pnpm test ``` Create a test that generates a 100x100 red PNG in-memory, runs each operation, and asserts the output is a valid image buffer with expected properties. ### Commit ``` feat(image-engine): add Sharp wrapper with 14 image operations Operations: resize, crop, rotate, flip, convert, compress, strip-metadata, brightness, contrast, saturation, color-channels, grayscale, sepia, invert. Includes format detection, MIME mapping, and metadata parsing. ``` --- ## Task 2: File Upload & Download System Add multipart file upload to Fastify, workspace session management (temp directory per processing request), and download routes. This is the backbone all tools share. ### Files to create ``` apps/api/src/plugins/upload.ts # Register @fastify/multipart with size limits apps/api/src/lib/workspace.ts # Create/manage temp dirs per job: create(jobId), getPath(jobId, filename), cleanup(jobId) apps/api/src/routes/files.ts # POST /api/v1/upload, GET /api/v1/download/:jobId/:filename apps/api/src/lib/file-validation.ts # Validate file type (magic bytes, not just extension), size, megapixel limit ``` ### Files to modify ``` apps/api/src/index.ts # Register upload plugin + file routes apps/api/src/lib/env.ts # Already has WORKSPACE_PATH, MAX_UPLOAD_SIZE_MB -- no changes needed apps/web/src/lib/api.ts # Add apiUpload() for multipart form data, apiDownload() for blob download ``` ### Key interfaces ```typescript // plugins/upload.ts export async function registerUpload(app: FastifyInstance): Promise; // Registers @fastify/multipart with limits: fileSize from env.MAX_UPLOAD_SIZE_MB // lib/workspace.ts export function createWorkspace(jobId: string): string; // returns absolute path to temp dir export function getWorkspacePath(jobId: string): string; export function cleanupWorkspace(jobId: string): Promise; // lib/file-validation.ts export interface ValidationResult { valid: boolean; error?: string; detectedFormat: string; } export async function validateImageFile(buffer: Buffer, filename: string): Promise; // Checks: buffer not empty, magic bytes match image format, format in SUPPORTED_INPUT_FORMATS, // dimensions within MAX_MEGAPIXELS // routes/files.ts // POST /api/v1/upload -- accepts multipart/form-data, saves to workspace, returns { jobId, files: [{ name, size, format }] } // GET /api/v1/download/:jobId/:filename -- serves file from workspace with Content-Disposition: attachment // web lib/api.ts additions export async function apiUpload(file: File): Promise; export async function apiDownloadBlob(jobId: string, filename: string): Promise; ``` ### Steps - [ ] Create `apps/api/src/plugins/upload.ts` -- register `@fastify/multipart` with `limits: { fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 }` and `attachFieldsToBody: false` (use streaming/pump approach) - [ ] Create `apps/api/src/lib/workspace.ts` -- `createWorkspace` creates `${WORKSPACE_PATH}/${jobId}/input/` and `${WORKSPACE_PATH}/${jobId}/output/` directories, returns the job workspace root - [ ] Create `apps/api/src/lib/file-validation.ts` -- validate magic bytes using first 12 bytes of buffer against known signatures (JPEG: `FF D8 FF`, PNG: `89 50 4E 47`, WebP: `52 49 46 46...57 45 42 50`, etc.), check format against `SUPPORTED_INPUT_FORMATS`, check dimensions via `sharp(buffer).metadata()` against `MAX_MEGAPIXELS` - [ ] Create `apps/api/src/routes/files.ts` -- upload route: generate jobId via `randomUUID()`, create workspace, iterate multipart parts, validate each file, save to `input/` directory, return job metadata. Download route: resolve path within workspace, validate it exists, stream file with proper Content-Type and Content-Disposition headers. Guard against path traversal (reject `..` in filenames) - [ ] Modify `apps/api/src/index.ts` -- import and register upload plugin and file routes - [ ] Add `apiUpload` to `apps/web/src/lib/api.ts` -- construct FormData, POST to `/api/v1/upload`, return parsed JSON response - [ ] Add `apiDownloadBlob` to `apps/web/src/lib/api.ts` -- fetch blob from download route, return blob for client-side download trigger ### Test ```bash # Manual: use curl to upload a test image curl -X POST http://localhost:1349/api/v1/upload \ -H "Authorization: Bearer " \ -F "file=@test.jpg" # Verify response contains jobId and file metadata # Then download: curl http://localhost:1349/api/v1/download//test.jpg \ -H "Authorization: Bearer " -o output.jpg ``` ### Commit ``` feat(api): add multipart file upload, workspace management, and download routes POST /api/v1/upload accepts images with magic-byte validation. GET /api/v1/download/:jobId/:filename serves processed results. Workspace creates isolated temp dirs per job with auto-cleanup. ``` --- ## Task 3: Generic Tool Route Factory Create a reusable pattern for tool API routes. Every tool follows the same flow: accept file upload + JSON settings body, process via image-engine, return processed file. The factory eliminates duplicating this boilerplate for each of the 37 tools. ### Files to create ``` apps/api/src/routes/tool-factory.ts # Generic route factory apps/api/src/routes/tools/index.ts # Registers all tool routes ``` ### Files to modify ``` apps/api/src/index.ts # Import and register tool routes packages/shared/src/types.ts # Add ToolSettings base type, ProcessResponse type ``` ### Key interfaces ```typescript // routes/tool-factory.ts export interface ToolRouteConfig { toolId: string; // matches TOOLS[].id from shared constants settingsSchema: ZodType; // Zod schema for validating settings JSON process: (input: Buffer, settings: TSettings, info: ImageInfo) => Promise; acceptsMultiple?: boolean; // default false; true for batch tools } export function createToolRoute( app: FastifyInstance, config: ToolRouteConfig ): void; // Registers: POST /api/v1/tools/:toolId // Flow: // 1. Parse multipart: extract file(s) + "settings" JSON field // 2. Validate settings against config.settingsSchema // 3. Create workspace (jobId) // 4. Save input file to workspace // 5. Call config.process(buffer, validatedSettings, imageInfo) // 6. Save output to workspace // 7. Return { jobId, output: { filename, size, format, downloadUrl } } // routes/tools/index.ts export async function registerToolRoutes(app: FastifyInstance): Promise; // Loops through tool configs and calls createToolRoute for each // shared/types.ts additions export interface ProcessResponse { jobId: string; output: { filename: string; size: number; format: string; width: number; height: number; downloadUrl: string; }; originalSize: number; processingTimeMs: number; } ``` ### Steps - [ ] Add `ProcessResponse` type to `packages/shared/src/types.ts` - [ ] Create `apps/api/src/routes/tool-factory.ts` -- implement `createToolRoute` that handles the full upload-process-download cycle. Use `performance.now()` to measure processing time. Catch errors from the process function and return structured error responses with the original filename - [ ] Create `apps/api/src/routes/tools/index.ts` -- placeholder that will import and register each tool as they are built in tasks 4-10 - [ ] Modify `apps/api/src/index.ts` -- register tool routes via `registerToolRoutes(app)` after auth middleware ### Test ```bash # Will be fully testable after Task 4 (Resize) adds the first tool # For now: verify the factory compiles and the /api/v1/tools route prefix is registered pnpm --filter @stirling-image/api typecheck ``` ### Commit ``` feat(api): add generic tool route factory for uniform tool endpoints createToolRoute() handles multipart upload, settings validation, image processing delegation, and download URL generation. Eliminates per-tool boilerplate for all 37 tools. ``` --- ## Task 4: Resize Tool First tool built on the factory. API route + full frontend settings panel. ### Files to create ``` apps/api/src/routes/tools/resize.ts # Tool config using createToolRoute apps/web/src/components/tools/resize-settings.tsx # Width/height inputs, aspect ratio lock, presets, fit mode apps/web/src/stores/file-store.ts # Zustand store for uploaded files + processing state apps/web/src/hooks/use-tool-processor.ts # Hook: upload file, send settings, poll/wait, trigger download ``` ### Files to modify ``` apps/api/src/routes/tools/index.ts # Register resize route apps/web/src/pages/tool-page.tsx # Render tool-specific settings component based on toolId ``` ### Key interfaces ```typescript // routes/tools/resize.ts const resizeSettingsSchema = z.object({ width: z.number().int().min(1).max(16384).optional(), height: z.number().int().min(1).max(16384).optional(), percentage: z.number().min(1).max(1000).optional(), fit: z.enum(['contain', 'cover', 'fill', 'inside', 'outside']).default('contain'), withoutEnlargement: z.boolean().default(true), outputFormat: z.enum(['jpg', 'png', 'webp', 'avif', 'tiff']).optional(), }); // components/tools/resize-settings.tsx interface ResizeSettingsProps { settings: ResizeSettings; onChange: (settings: ResizeSettings) => void; imageInfo?: ImageInfo; // original image dimensions for aspect ratio calc } // stores/file-store.ts (Zustand) interface FileStore { files: UploadedFile[]; activeFile: UploadedFile | null; processing: boolean; result: ProcessResponse | null; addFiles: (files: File[]) => void; removeFile: (id: string) => void; setResult: (result: ProcessResponse) => void; reset: () => void; } // hooks/use-tool-processor.ts function useToolProcessor(toolId: string): { process: (file: File, settings: Record) => Promise; download: (jobId: string, filename: string) => Promise; processing: boolean; progress: number; result: ProcessResponse | null; error: string | null; }; ``` ### Steps - [ ] Create `apps/web/src/stores/file-store.ts` -- Zustand store tracking uploaded files (id, name, size, objectUrl for preview, file reference), active file selection, processing state, and result - [ ] Create `apps/web/src/hooks/use-tool-processor.ts` -- hook that wraps apiUpload + tool processing call + download trigger. Constructs FormData with file + settings JSON, POSTs to `/api/v1/tools/:toolId`, manages loading/error states - [ ] Create `apps/api/src/routes/tools/resize.ts` -- define `resizeSettingsSchema`, call `resize()` from image-engine, register via `createToolRoute` - [ ] Register resize in `apps/api/src/routes/tools/index.ts` - [ ] Create `apps/web/src/components/tools/resize-settings.tsx` -- width/height number inputs (linked by aspect ratio lock toggle), social media presets dropdown (from `SOCIAL_MEDIA_PRESETS` in shared constants), fit mode selector (radio group: contain/cover/fill), percentage input as alternative mode - [ ] Modify `apps/web/src/pages/tool-page.tsx` -- import a `toolSettingsMap` keyed by `toolId`, render the matching settings component in the sidebar. Wire `Dropzone.onFiles` to `fileStore.addFiles`. Wire Process button to `useToolProcessor.process`. Show download button when result is available ### Test ```bash # Start dev servers pnpm dev # 1. Navigate to /resize # 2. Upload a test image # 3. Set width=500, height=500, fit=cover # 4. Click Process # 5. Verify download produces a 500x500 image # 6. Test aspect ratio lock: enter width, verify height auto-calculates # 7. Test social media presets: select "Instagram Post", verify 1080x1080 ``` ### Commit ``` feat: add resize tool with API endpoint and frontend settings UI Includes social media presets, aspect ratio lock, fit mode selector. Also adds file-store (Zustand), use-tool-processor hook, and tool-page settings rendering pattern used by all subsequent tools. ``` --- ## Task 5: Crop Tool Interactive visual crop on the uploaded image preview. ### Files to create ``` apps/api/src/routes/tools/crop.ts # Tool config apps/web/src/components/tools/crop-settings.tsx # Aspect ratio presets, dimension inputs apps/web/src/components/common/image-cropper.tsx # Interactive crop component wrapping react-image-crop ``` ### Files to modify ``` apps/api/src/routes/tools/index.ts # Register crop route apps/web/src/pages/tool-page.tsx # Add crop to toolSettingsMap apps/web/package.json # Add react-image-crop dependency ``` ### Key interfaces ```typescript // routes/tools/crop.ts const cropSettingsSchema = z.object({ left: z.number().min(0), top: z.number().min(0), width: z.number().min(1), height: z.number().min(1), }); // components/common/image-cropper.tsx interface ImageCropperProps { src: string; // object URL of uploaded image aspectRatio?: number; // locked aspect ratio (e.g., 1 for 1:1, 16/9) onCropChange: (crop: CropArea) => void; } // Uses react-image-crop to render a draggable/resizable crop box over the image. // Outputs pixel coordinates (left, top, width, height) relative to original image dimensions. // components/tools/crop-settings.tsx // Aspect ratio preset buttons: Free, 1:1, 4:3, 16:9, 2:3, 4:5, 9:16 // Manual dimension inputs for left, top, width, height (updates crop box) // Displays current crop dimensions ``` ### Steps - [ ] Add `react-image-crop` to `apps/web/package.json` - [ ] Create `apps/web/src/components/common/image-cropper.tsx` -- wrap `ReactCrop` component, handle percentage-to-pixel coordinate conversion based on actual image dimensions vs rendered dimensions, emit `CropArea` with absolute pixel values - [ ] Create `apps/api/src/routes/tools/crop.ts` -- validate coordinates are within image bounds (clamp if needed), call `crop()` from image-engine - [ ] Register crop in `apps/api/src/routes/tools/index.ts` - [ ] Create `apps/web/src/components/tools/crop-settings.tsx` -- aspect ratio preset buttons that lock the `ReactCrop` aspect, manual coordinate inputs that sync bidirectionally with the crop box - [ ] Modify `apps/web/src/pages/tool-page.tsx` -- for crop tool, render `ImageCropper` in the main area instead of static preview. Add crop to `toolSettingsMap` ### Test ```bash # 1. Navigate to /crop # 2. Upload a 1920x1080 image # 3. Draw a crop area, verify coordinates display # 4. Select 1:1 aspect ratio, verify crop box constrains # 5. Click Process, verify output dimensions match crop area # 6. Test edge case: crop area exceeds image bounds ``` ### Commit ``` feat: add crop tool with interactive visual crop area and aspect presets Uses react-image-crop for drag-to-select crop region. Supports aspect ratio presets (1:1, 4:3, 16:9, etc.) and manual coordinate input. ``` --- ## Task 6: Rotate & Flip Tool ### Files to create ``` apps/api/src/routes/tools/rotate.ts # Tool config apps/web/src/components/tools/rotate-settings.tsx # Rotation controls + flip buttons ``` ### Files to modify ``` apps/api/src/routes/tools/index.ts # Register rotate route apps/web/src/pages/tool-page.tsx # Add rotate to toolSettingsMap ``` ### Key interfaces ```typescript // routes/tools/rotate.ts const rotateSettingsSchema = z.object({ angle: z.number().min(0).max(360).default(0), flipHorizontal: z.boolean().default(false), flipVertical: z.boolean().default(false), backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#000000'), // fill for non-90 angles }); // components/tools/rotate-settings.tsx // - 90-degree quick buttons: [Rotate Left] [Rotate Right] (decrement/increment by 90) // - Arbitrary angle input: number input or slider (0-360) // - Flip buttons: [Flip Horizontal] [Flip Vertical] (toggles) // - Background color picker (for non-90-degree rotation fill) // - Live rotation preview via CSS transform on the image thumbnail ``` ### Steps - [ ] Create `apps/api/src/routes/tools/rotate.ts` -- apply rotation first (via `rotate()` from image-engine), then flip if requested (via `flip()` from image-engine). For non-90-degree angles, use `backgroundColor` option - [ ] Register rotate in `apps/api/src/routes/tools/index.ts` - [ ] Create `apps/web/src/components/tools/rotate-settings.tsx` -- four quick-rotate buttons (-90, +90, 180, 0/reset), arbitrary angle slider (0-360 range), flip H/V toggle buttons with icons, background color picker (shown only when angle is not a multiple of 90). Apply `CSS transform: rotate(Xdeg) scaleX(flip)` on the image element for instant visual feedback before server processing - [ ] Add rotate to `toolSettingsMap` in `apps/web/src/pages/tool-page.tsx` ### Test ```bash # 1. Navigate to /rotate # 2. Upload an image # 3. Click Rotate Right, verify preview rotates 90 degrees CW # 4. Click Flip Horizontal, verify preview mirrors # 5. Set angle to 45, verify preview shows angled image # 6. Process and download, verify output matches preview # 7. Verify non-90-degree rotation fills background with selected color ``` ### Commit ``` feat: add rotate & flip tool with live CSS preview and arbitrary angle support Quick 90-degree buttons, arbitrary angle slider, flip H/V toggles. CSS transform preview before server-side processing. ``` --- ## Task 7: Convert Tool ### Files to create ``` apps/api/src/routes/tools/convert.ts # Tool config apps/web/src/components/tools/convert-settings.tsx # Format picker, quality options ``` ### Files to modify ``` apps/api/src/routes/tools/index.ts # Register convert route apps/web/src/pages/tool-page.tsx # Add convert to toolSettingsMap ``` ### Key interfaces ```typescript // routes/tools/convert.ts const convertSettingsSchema = z.object({ targetFormat: z.enum(['jpg', 'png', 'webp', 'avif', 'tiff', 'gif']), quality: z.number().min(1).max(100).optional(), // for lossy formats compressionLevel: z.number().min(0).max(9).optional(), // for PNG lossless: z.boolean().optional(), // for WebP/AVIF }); // components/tools/convert-settings.tsx // - Source format: auto-detected, displayed as badge (e.g., "Source: PNG") // - Target format: radio group or dropdown with format icons // - Format-specific options shown conditionally: // - JPG: quality slider (1-100, default 80) // - PNG: compression level (0-9, default 6) // - WebP: quality slider + lossless toggle // - AVIF: quality slider (default 50) // - TIFF: compression type dropdown (none, lzw, deflate) ``` ### Steps - [ ] Create `apps/api/src/routes/tools/convert.ts` -- call `convert()` from image-engine with format and quality options. Set output filename extension to match target format - [ ] Register convert in `apps/api/src/routes/tools/index.ts` - [ ] Create `apps/web/src/components/tools/convert-settings.tsx` -- auto-detect source format from uploaded file metadata (returned by upload endpoint), render format radio group with icons for each supported output format, conditionally show format-specific quality controls. Display estimated output size when possible - [ ] Add convert to `toolSettingsMap` in `apps/web/src/pages/tool-page.tsx` ### Test ```bash # 1. Upload a JPG, convert to WebP, verify output is valid WebP # 2. Upload a PNG with transparency, convert to JPG, verify alpha is composited on white # 3. Convert to AVIF, verify quality slider works (small file at q=30, larger at q=80) # 4. Convert PNG to PNG with compression level 9, verify file is smaller # 5. Test lossless WebP toggle ``` ### Commit ``` feat: add format conversion tool with auto-detection and per-format quality options Supports JPG, PNG, WebP, AVIF, TIFF, GIF output. Format-specific quality controls shown conditionally (quality slider, lossless toggle, compression level). ``` --- ## Task 8: Compress Tool ### Files to create ``` apps/api/src/routes/tools/compress.ts # Tool config apps/web/src/components/tools/compress-settings.tsx # Quality vs target size mode ``` ### Files to modify ``` apps/api/src/routes/tools/index.ts # Register compress route apps/web/src/pages/tool-page.tsx # Add compress to toolSettingsMap ``` ### Key interfaces ```typescript // routes/tools/compress.ts const compressSettingsSchema = z.object({ mode: z.enum(['quality', 'targetSize']), quality: z.number().min(1).max(100).optional(), // used when mode=quality targetSizeKB: z.number().min(1).max(102400).optional(), // used when mode=targetSize outputFormat: z.enum(['jpg', 'png', 'webp', 'avif']).optional(), // keep original if not set }); // components/tools/compress-settings.tsx // - Mode toggle: [Quality] / [Target File Size] (segmented control) // - Quality mode: slider 1-100 with labels (Low / Medium / High / Original) // - Target size mode: number input + unit dropdown (KB / MB) // - Before/after file size display: "4.2 MB -> ~890 KB (79% reduction)" // (estimated from quality, confirmed after processing) // - Output format selector (optional -- keep original format by default) ``` ### Steps - [ ] Create `apps/api/src/routes/tools/compress.ts` -- two code paths: quality mode passes quality directly to `compress()` from image-engine; target-size mode passes `targetSizeBytes` which triggers binary search in the engine - [ ] Register compress in `apps/api/src/routes/tools/index.ts` - [ ] Create `apps/web/src/components/tools/compress-settings.tsx` -- segmented control for mode toggle, quality slider with labeled ticks, target size input with KB/MB unit toggle. After processing, show before/after comparison: original size, compressed size, percentage reduction, compression ratio - [ ] Add compress to `toolSettingsMap` in `apps/web/src/pages/tool-page.tsx` ### Test ```bash # 1. Upload a 5MB JPG # 2. Quality mode: set quality=50, process, verify output is significantly smaller # 3. Target size mode: set target=200KB, process, verify output is within ~10% of 200KB # 4. Verify file size comparison shows correct values # 5. Edge case: target size larger than original -- return original unchanged # 6. Edge case: target size impossibly small -- return lowest quality result with warning ``` ### Commit ``` feat: add compress tool with quality slider and target file size modes Binary search compression for target size (within 5% accuracy, max 8 iterations). Before/after file size comparison in the UI. ``` --- ## Task 9: Strip Metadata Tool ### Files to create ``` apps/api/src/routes/tools/strip-metadata.ts # Tool config apps/web/src/components/tools/strip-metadata-settings.tsx # Metadata field checkboxes ``` ### Files to modify ``` apps/api/src/routes/tools/index.ts # Register strip-metadata route apps/web/src/pages/tool-page.tsx # Add strip-metadata to toolSettingsMap ``` ### Key interfaces ```typescript // routes/tools/strip-metadata.ts const stripMetadataSettingsSchema = z.object({ removeExif: z.boolean().default(true), removeGps: z.boolean().default(true), removeCameraInfo: z.boolean().default(true), removeIccProfile: z.boolean().default(false), // ICC affects color -- default keep removeXmp: z.boolean().default(true), removeIptc: z.boolean().default(true), }); // components/tools/strip-metadata-settings.tsx // - Checkbox group with descriptions: // [x] EXIF Data (camera settings, date, software) // [x] GPS Location (latitude, longitude, altitude) // [x] Camera Info (make, model, lens, serial number) // [ ] ICC Color Profile (affects color accuracy -- caution) // [x] XMP Data (editing history, keywords) // [x] IPTC Data (copyright, caption, credits) // - "Select All" / "Deselect All" buttons // - Before/after metadata preview: show what will be removed // - Warning when removing ICC profile ``` ### Steps - [ ] Create `apps/api/src/routes/tools/strip-metadata.ts` -- call `stripMetadata()` from image-engine with the field flags. Return both the processed image and a diff of what metadata was removed - [ ] Register strip-metadata in `apps/api/src/routes/tools/index.ts` - [ ] Create `apps/web/src/components/tools/strip-metadata-settings.tsx` -- checkbox group with field descriptions, select all/none toggles. Before processing: show current metadata summary (fetched from image info). After processing: show removed fields in a collapsible diff - [ ] Add strip-metadata to `toolSettingsMap` in `apps/web/src/pages/tool-page.tsx` ### Test ```bash # 1. Upload a photo with rich EXIF (phone photo with GPS) # 2. Check all boxes, process, verify metadata is stripped (inspect with exiftool or image info tool) # 3. Uncheck ICC Profile, process, verify ICC is preserved but EXIF/GPS removed # 4. Verify output image is visually identical to input # 5. Verify file size is slightly smaller (metadata removed) ``` ### Commit ``` feat: add strip metadata tool with selective field removal Checkboxes for EXIF, GPS, Camera, ICC, XMP, IPTC. Shows metadata before/after diff. ICC removal warns about color accuracy impact. ``` --- ## Task 10: Color Adjustments Tool Combines brightness, contrast, saturation, color channels, and color effects into a single comprehensive tool page (maps to PRD sections A-01 through A-04 under Adjustments). ### Files to create ``` apps/api/src/routes/tools/color-adjustments.ts # Tool config apps/web/src/components/tools/color-adjustments-settings.tsx # Tabbed settings: Adjust / Channels / Effects ``` ### Files to modify ``` apps/api/src/routes/tools/index.ts # Register color adjustment routes apps/web/src/pages/tool-page.tsx # Add all four adjustment tool IDs to toolSettingsMap ``` ### Key interfaces ```typescript // routes/tools/color-adjustments.ts // Handles four tool IDs: brightness-contrast, saturation, color-channels, color-effects // They share one route handler since the operations are composable const colorAdjustmentsSchema = z.object({ brightness: z.number().min(-100).max(100).default(0), contrast: z.number().min(-100).max(100).default(0), saturation: z.number().min(-100).max(100).default(0), exposure: z.number().min(-100).max(100).default(0), channelR: z.number().min(0).max(200).default(100), // percentage channelG: z.number().min(0).max(200).default(100), channelB: z.number().min(0).max(200).default(100), effect: z.enum(['none', 'grayscale', 'sepia', 'invert']).default('none'), effectIntensity: z.number().min(0).max(100).default(100), }); // components/tools/color-adjustments-settings.tsx // Three tabs or accordion sections: // // [Adjust] tab: // - Brightness slider (-100 to +100, center=0) // - Contrast slider (-100 to +100, center=0) // - Saturation slider (-100 to +100, center=0) // - Exposure slider (-100 to +100, center=0) // - Reset All button // // [Channels] tab: // - Red slider (0% to 200%, center=100%) // - Green slider (0% to 200%, center=100%) // - Blue slider (0% to 200%, center=100%) // - Colored slider tracks (red/green/blue tinted) // // [Effects] tab: // - Effect buttons: [Original] [Grayscale] [Sepia] [Invert] // - Intensity slider (0-100%) for sepia // - Active effect highlighted ``` ### Steps - [ ] Create `apps/api/src/routes/tools/color-adjustments.ts` -- apply operations in deterministic order: brightness -> contrast -> saturation -> color channels -> effect. Use the individual operation functions from image-engine. Register four route variants (one per tool ID) that all use the same handler but with different default tab focus - [ ] Register all four adjustment tool IDs in `apps/api/src/routes/tools/index.ts` - [ ] Create `apps/web/src/components/tools/color-adjustments-settings.tsx` -- three-tab layout using shadcn Tabs component. Each slider shows its current value. Double-click a slider to reset to default. "Reset All" clears everything. When navigated to via `/brightness-contrast`, auto-select the Adjust tab; via `/color-channels`, auto-select Channels tab; via `/color-effects`, auto-select Effects tab - [ ] Add all four adjustment tool IDs (`brightness-contrast`, `saturation`, `color-channels`, `color-effects`) to `toolSettingsMap` in `apps/web/src/pages/tool-page.tsx`, all pointing to the same `ColorAdjustmentsSettings` component with a `defaultTab` prop ### Test ```bash # 1. Upload an image to /brightness-contrast # 2. Drag brightness to +50, verify image appears brighter after processing # 3. Navigate to /color-effects, apply Grayscale, verify output is grayscale # 4. Apply Sepia at 50% intensity, verify tinted output # 5. Navigate to /color-channels, set Red to 0%, verify red channel is removed # 6. Apply multiple adjustments together: brightness +30, contrast +20, saturation -50 # 7. Verify Reset All returns all sliders to default ``` ### Commit ``` feat: add color adjustments tool with brightness, contrast, saturation, channels, and effects (grayscale, sepia, invert) Tabbed settings UI serves four tool routes. Operations are composable and applied in deterministic order. ``` --- ## Task 11: Before/After Preview Component Reusable React component showing original vs processed image with a draggable split slider. Used by compress, color adjustments, and future tools where visual comparison matters. ### Files to create ``` apps/web/src/components/common/before-after-preview.tsx # The slider component apps/web/src/components/common/file-size-badge.tsx # "4.2 MB -> 890 KB (79%)" badge apps/web/src/components/common/image-preview.tsx # Single image preview with zoom/pan ``` ### Files to modify ``` apps/web/src/pages/tool-page.tsx # Replace static dropzone with preview when result exists ``` ### Key interfaces ```typescript // components/common/before-after-preview.tsx interface BeforeAfterPreviewProps { beforeSrc: string; // object URL of original afterSrc: string; // object URL of processed result beforeLabel?: string; // default "Original" afterLabel?: string; // default "Processed" beforeSize?: number; // bytes afterSize?: number; // bytes } // Renders two images stacked with CSS clip-path. A vertical divider bar // is draggable left/right (mouse + touch). Left side shows "before" clipped // to the divider position, right side shows "after". Labels in top corners. // File size comparison badge at bottom. // components/common/file-size-badge.tsx interface FileSizeBadgeProps { originalBytes: number; processedBytes: number; } // Renders: "4.2 MB -> 890 KB (79% smaller)" or "890 KB -> 1.2 MB (35% larger)" // Green for reduction, amber for increase // components/common/image-preview.tsx interface ImagePreviewProps { src: string; alt?: string; maxHeight?: number; onLoad?: (info: { width: number; height: number }) => void; } // Renders image with object-fit contain, optional zoom on scroll, pan on drag ``` ### Steps - [ ] Create `apps/web/src/components/common/image-preview.tsx` -- simple image renderer with `object-fit: contain`, natural dimension detection via `onLoad`, and optional scroll-to-zoom - [ ] Create `apps/web/src/components/common/file-size-badge.tsx` -- format bytes to human-readable (KB/MB), calculate percentage change, color-code (green for smaller, amber for larger) - [ ] Create `apps/web/src/components/common/before-after-preview.tsx` -- implementation approach: two `` elements absolutely positioned in a container; left image clipped with `clip-path: inset(0 ${100-position}% 0 0)`, right image clipped with `clip-path: inset(0 0 0 ${position}%)`; draggable divider bar uses `onPointerDown/Move/Up` for mouse and touch support; position stored in state (default 50%). Include `FileSizeBadge` below the images - [ ] Modify `apps/web/src/pages/tool-page.tsx` -- after processing completes, replace the dropzone area with `BeforeAfterPreview` showing original vs result. Add a "New Image" button to reset and show dropzone again. Add a "Download" button ### Test ```bash # 1. Process any image with compress tool # 2. Verify before/after slider appears showing both images # 3. Drag the slider left and right, verify smooth clipping # 4. Verify file size badge shows correct sizes and percentage # 5. Test on mobile viewport -- verify touch dragging works # 6. Click "New Image", verify dropzone reappears # 7. Verify component works when images have different aspect ratios ``` ### Commit ``` feat: add before/after preview slider with file size comparison Draggable split-view comparing original and processed images. Includes file size badge showing reduction percentage. Mouse and touch support. ``` --- ## Task 12: Batch Processing & ZIP Download Allow multiple files to be uploaded and processed through any tool. Return results as a ZIP file. Progress tracked via Server-Sent Events. ### Files to create ``` apps/api/src/lib/job-queue.ts # p-queue wrapper with concurrency from env.CONCURRENT_JOBS apps/api/src/routes/batch.ts # POST /api/v1/batch/:toolId, GET /api/v1/jobs/:jobId/progress (SSE) apps/api/src/lib/zip.ts # Create ZIP from multiple output files using archiver apps/web/src/components/common/batch-progress.tsx # Per-file progress bars with SSE listener apps/web/src/hooks/use-sse.ts # Hook for consuming SSE endpoint ``` ### Files to modify ``` apps/api/src/index.ts # Register batch routes apps/api/package.json # Add p-queue, archiver dependencies apps/web/src/pages/tool-page.tsx # Show batch progress UI when multiple files uploaded apps/web/src/stores/file-store.ts # Add batch processing state (per-file progress) apps/web/src/components/common/dropzone.tsx # Already supports multiple -- no changes needed ``` ### Key interfaces ```typescript // lib/job-queue.ts import PQueue from 'p-queue'; export const jobQueue: PQueue; // concurrency: env.CONCURRENT_JOBS export interface QueuedJob { jobId: string; toolId: string; files: string[]; settings: Record; progress: Map; } export function enqueueJob(job: QueuedJob): void; // routes/batch.ts // POST /api/v1/batch/:toolId // Body: multipart with multiple files + settings JSON // Response: { jobId: string, totalFiles: number } // // GET /api/v1/jobs/:jobId/progress // Response: SSE stream // Events: // data: { status: "processing", progress: 45, currentFile: "photo3.jpg", completedFiles: 4, totalFiles: 10 } // data: { status: "completed", downloadUrl: "/api/v1/download/:jobId/results.zip" } // data: { status: "failed", error: "...", failedFile: "photo7.psd" } // // GET /api/v1/download/:jobId/results.zip // Response: ZIP file with all processed images // lib/zip.ts export async function createZip(files: Array<{ path: string; name: string }>): Promise; // web hooks/use-sse.ts export function useSSE(url: string | null): { data: T | null; error: string | null; connected: boolean; }; // web components/common/batch-progress.tsx interface BatchProgressProps { jobId: string; totalFiles: number; onComplete: (downloadUrl: string) => void; } // Renders: // - Overall progress bar (X of Y files completed) // - Per-file status list (filename + icon: pending/processing/done/failed) // - Download ZIP button when complete // - Partial failure notice with list of failed files ``` ### Steps - [ ] Add `p-queue` and `archiver` to `apps/api/package.json`, add `@types/archiver` to devDependencies - [ ] Create `apps/api/src/lib/job-queue.ts` -- instantiate `PQueue` with `concurrency: env.CONCURRENT_JOBS`. Export `enqueueJob` that adds a processing function to the queue. Store active jobs in a `Map` for progress lookup - [ ] Create `apps/api/src/lib/zip.ts` -- use `archiver('zip')` to pack multiple files into a ZIP buffer. Accept an array of `{ path, name }` objects - [ ] Create `apps/api/src/routes/batch.ts` -- batch endpoint: accept multiple files via multipart, generate jobId, create workspace, enqueue per-file processing tasks. Each task calls the same `process` function from the tool config. As each file completes, update the job's progress map. SSE endpoint: register on `GET /api/v1/jobs/:jobId/progress`, set `Content-Type: text/event-stream`, push events as files complete. When all files done, create ZIP in workspace and send final `completed` event with download URL. Handle partial failures: continue processing remaining files, report failed ones - [ ] Create `apps/web/src/hooks/use-sse.ts` -- wrap `EventSource` in a React hook. Connect when URL is provided, parse `data` field as JSON, expose latest data and connection state. Clean up on unmount - [ ] Create `apps/web/src/components/common/batch-progress.tsx` -- consume `useSSE` hook, render overall progress bar and per-file status list. "Download ZIP" button triggers `apiDownloadBlob` - [ ] Update `apps/web/src/stores/file-store.ts` -- add batch state: `batchJobId`, `batchProgress` map, `isBatchMode` flag (true when files.length > 1) - [ ] Modify `apps/web/src/pages/tool-page.tsx` -- when multiple files are uploaded, show "Process All (N files)" button instead of single-file process. After clicking, show `BatchProgress` component. Wire download to blob trigger - [ ] Modify `apps/api/src/index.ts` -- register batch routes ### Test ```bash # 1. Navigate to /resize # 2. Upload 5 images at once # 3. Set resize to 500x500 # 4. Click "Process All (5 files)" # 5. Verify progress bar advances per file # 6. Verify SSE events stream correctly (open DevTools Network tab -> EventStream) # 7. On completion, click "Download ZIP" # 8. Extract ZIP, verify all 5 images are 500x500 # 9. Test partial failure: include one invalid file (e.g., .txt renamed to .jpg) # 10. Verify remaining files still process and failed file is reported ``` ### Commit ``` feat: add batch processing with ZIP download and SSE progress tracking Multiple files processed via p-queue with configurable concurrency. Progress streamed via Server-Sent Events. Results packaged as ZIP. Partial failure handling continues processing and reports failed files. ``` --- ## Dependency Graph ``` Task 1 (Image Engine) └─> Task 2 (Upload/Download) └─> Task 3 (Route Factory) ├─> Task 4 (Resize) ─> Task 11 (Before/After Preview) ├─> Task 5 (Crop) ├─> Task 6 (Rotate) ├─> Task 7 (Convert) ├─> Task 8 (Compress) ─> Task 11 (Before/After Preview) ├─> Task 9 (Metadata) └─> Task 10 (Color) ─> Task 11 (Before/After Preview) └─> Task 12 (Batch + ZIP) ``` Tasks 4-10 can be built in parallel once Task 3 is done. Task 11 can be built alongside tasks 4-10 but should be wired in after at least one tool exists. Task 12 depends on everything else. ## Total New Files | Area | Count | |------|-------| | `packages/image-engine/src/` | 18 files (engine, types, 14 operations, format detect, 2 utils) | | `apps/api/src/routes/` | 10 files (factory, 7 tool routes, batch, files) | | `apps/api/src/lib/` | 4 files (workspace, file-validation, job-queue, zip) | | `apps/api/src/plugins/` | 1 file (upload) | | `apps/web/src/components/tools/` | 7 files (settings for each tool) | | `apps/web/src/components/common/` | 4 files (image-cropper, before-after, file-size-badge, image-preview, batch-progress) | | `apps/web/src/stores/` | 1 file (file-store) | | `apps/web/src/hooks/` | 2 files (use-tool-processor, use-sse) | | Tests | 1 file (image-engine operations) | | **Total** | **~48 files** |