Covers image engine (Sharp wrapper with 14 operations), file upload/download system, generic tool route factory, and first 10 tools (resize, crop, rotate, convert, compress, strip-metadata, color adjustments, before/after preview, batch processing with ZIP download). 12 tasks total, ~48 new files.
46 KiB
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
// types.ts
export interface ImageInfo {
width: number;
height: number;
format: string;
channels: number;
size: number;
hasAlpha: boolean;
metadata: Record<string, unknown>;
}
export interface OperationResult {
buffer: Buffer;
info: ImageInfo;
}
// engine.ts
export async function processImage(
input: Buffer,
operations: ImageOperation[],
outputFormat?: OutputFormat
): Promise<OperationResult>;
export async function getImageInfo(input: Buffer): Promise<ImageInfo>;
// 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<Sharp>;
// operations/crop.ts
export interface CropOptions {
left: number;
top: number;
width: number;
height: number;
}
export async function crop(image: Sharp, options: CropOptions): Promise<Sharp>;
// 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<Sharp>;
Steps
- Add
sharpas a dependency inpackages/image-engine/package.json - Create
src/types.tswithImageInfo,OperationResult,ResizeOptions,CropOptions,RotateOptions,FlipOptions,ConvertOptions,CompressOptions,StripMetadataOptions,BrightnessOptions,ContrastOptions,SaturationOptions,ColorChannelOptions,OutputFormattype - Create
src/formats/detect.ts-- use Sharp metadata + magic-byte fallback to detect input format, exportdetectFormat(buffer: Buffer): Promise<string> - Create
src/utils/mime.ts-- bidirectional map between file extensions and MIME types for all supported formats - Create
src/utils/metadata.ts-- wrapsharp(buffer).metadata()and parse EXIF fields into structured object - Create
src/operations/resize.ts-- usesharp.resize()with fit mode mapping - Create
src/operations/crop.ts-- usesharp.extract()with bounds validation - Create
src/operations/rotate.ts-- usesharp.rotate(angle)with background option for non-90 angles - Create
src/operations/flip.ts-- usesharp.flip()andsharp.flop() - Create
src/operations/convert.ts-- usesharp.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-- usesharp.withMetadata()/sharp.keepMetadata()with selective field control - Create
src/operations/brightness.ts-- usesharp.modulate({ brightness })where 1.0 = no change, map -100..+100 slider to 0..2 multiplier - Create
src/operations/contrast.ts-- usesharp.linear(a, b)where a is contrast multiplier, map -100..+100 to 0.5..1.5 - Create
src/operations/saturation.ts-- usesharp.modulate({ saturation })where 1.0 = no change - Create
src/operations/color-channels.ts-- usesharp.recomb()with 3x3 matrix for per-channel multipliers - Create
src/operations/grayscale.ts-- usesharp.grayscale() - Create
src/operations/sepia.ts-- usesharp.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-- usesharp.negate() - Create
src/engine.ts-- the orchestrator that loads a buffer, chains operations, and outputs in the requested format - Update
src/index.tsto 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
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
// plugins/upload.ts
export async function registerUpload(app: FastifyInstance): Promise<void>;
// 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<void>;
// lib/file-validation.ts
export interface ValidationResult { valid: boolean; error?: string; detectedFormat: string; }
export async function validateImageFile(buffer: Buffer, filename: string): Promise<ValidationResult>;
// 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<UploadResponse>;
export async function apiDownloadBlob(jobId: string, filename: string): Promise<Blob>;
Steps
- Create
apps/api/src/plugins/upload.ts-- register@fastify/multipartwithlimits: { fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 }andattachFieldsToBody: false(use streaming/pump approach) - Create
apps/api/src/lib/workspace.ts--createWorkspacecreates${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 againstSUPPORTED_INPUT_FORMATS, check dimensions viasharp(buffer).metadata()againstMAX_MEGAPIXELS - Create
apps/api/src/routes/files.ts-- upload route: generate jobId viarandomUUID(), create workspace, iterate multipart parts, validate each file, save toinput/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
apiUploadtoapps/web/src/lib/api.ts-- construct FormData, POST to/api/v1/upload, return parsed JSON response - Add
apiDownloadBlobtoapps/web/src/lib/api.ts-- fetch blob from download route, return blob for client-side download trigger
Test
# Manual: use curl to upload a test image
curl -X POST http://localhost:1349/api/v1/upload \
-H "Authorization: Bearer <token>" \
-F "file=@test.jpg"
# Verify response contains jobId and file metadata
# Then download:
curl http://localhost:1349/api/v1/download/<jobId>/test.jpg \
-H "Authorization: Bearer <token>" -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
// routes/tool-factory.ts
export interface ToolRouteConfig<TSettings> {
toolId: string; // matches TOOLS[].id from shared constants
settingsSchema: ZodType<TSettings>; // Zod schema for validating settings JSON
process: (input: Buffer, settings: TSettings, info: ImageInfo) => Promise<OperationResult>;
acceptsMultiple?: boolean; // default false; true for batch tools
}
export function createToolRoute<TSettings>(
app: FastifyInstance,
config: ToolRouteConfig<TSettings>
): 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<void>;
// 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
ProcessResponsetype topackages/shared/src/types.ts - Create
apps/api/src/routes/tool-factory.ts-- implementcreateToolRoutethat handles the full upload-process-download cycle. Useperformance.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 viaregisterToolRoutes(app)after auth middleware
Test
# 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
// 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<string, unknown>) => Promise<ProcessResponse>;
download: (jobId: string, filename: string) => Promise<void>;
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-- defineresizeSettingsSchema, callresize()from image-engine, register viacreateToolRoute - 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 (fromSOCIAL_MEDIA_PRESETSin shared constants), fit mode selector (radio group: contain/cover/fill), percentage input as alternative mode - Modify
apps/web/src/pages/tool-page.tsx-- import atoolSettingsMapkeyed bytoolId, render the matching settings component in the sidebar. WireDropzone.onFilestofileStore.addFiles. Wire Process button touseToolProcessor.process. Show download button when result is available
Test
# 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
// 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-croptoapps/web/package.json - Create
apps/web/src/components/common/image-cropper.tsx-- wrapReactCropcomponent, handle percentage-to-pixel coordinate conversion based on actual image dimensions vs rendered dimensions, emitCropAreawith absolute pixel values - Create
apps/api/src/routes/tools/crop.ts-- validate coordinates are within image bounds (clamp if needed), callcrop()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 theReactCropaspect, manual coordinate inputs that sync bidirectionally with the crop box - Modify
apps/web/src/pages/tool-page.tsx-- for crop tool, renderImageCropperin the main area instead of static preview. Add crop totoolSettingsMap
Test
# 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
// 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 (viarotate()from image-engine), then flip if requested (viaflip()from image-engine). For non-90-degree angles, usebackgroundColoroption - 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). ApplyCSS transform: rotate(Xdeg) scaleX(flip)on the image element for instant visual feedback before server processing - Add rotate to
toolSettingsMapinapps/web/src/pages/tool-page.tsx
Test
# 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
// 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-- callconvert()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
toolSettingsMapinapps/web/src/pages/tool-page.tsx
Test
# 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
// 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 tocompress()from image-engine; target-size mode passestargetSizeByteswhich 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
toolSettingsMapinapps/web/src/pages/tool-page.tsx
Test
# 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
// 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-- callstripMetadata()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
toolSettingsMapinapps/web/src/pages/tool-page.tsx
Test
# 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
// 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) totoolSettingsMapinapps/web/src/pages/tool-page.tsx, all pointing to the sameColorAdjustmentsSettingscomponent with adefaultTabprop
Test
# 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
// 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 withobject-fit: contain, natural dimension detection viaonLoad, 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<img>elements absolutely positioned in a container; left image clipped withclip-path: inset(0 ${100-position}% 0 0), right image clipped withclip-path: inset(0 0 0 ${position}%); draggable divider bar usesonPointerDown/Move/Upfor mouse and touch support; position stored in state (default 50%). IncludeFileSizeBadgebelow the images - Modify
apps/web/src/pages/tool-page.tsx-- after processing completes, replace the dropzone area withBeforeAfterPreviewshowing original vs result. Add a "New Image" button to reset and show dropzone again. Add a "Download" button
Test
# 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
// 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<string, unknown>;
progress: Map<string, { status: string; progress: number }>;
}
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<Buffer>;
// web hooks/use-sse.ts
export function useSSE<T>(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-queueandarchivertoapps/api/package.json, add@types/archiverto devDependencies - Create
apps/api/src/lib/job-queue.ts-- instantiatePQueuewithconcurrency: env.CONCURRENT_JOBS. ExportenqueueJobthat adds a processing function to the queue. Store active jobs in aMap<string, QueuedJob>for progress lookup - Create
apps/api/src/lib/zip.ts-- usearchiver('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 sameprocessfunction from the tool config. As each file completes, update the job's progress map. SSE endpoint: register onGET /api/v1/jobs/:jobId/progress, setContent-Type: text/event-stream, push events as files complete. When all files done, create ZIP in workspace and send finalcompletedevent with download URL. Handle partial failures: continue processing remaining files, report failed ones - Create
apps/web/src/hooks/use-sse.ts-- wrapEventSourcein a React hook. Connect when URL is provided, parsedatafield as JSON, expose latest data and connection state. Clean up on unmount - Create
apps/web/src/components/common/batch-progress.tsx-- consumeuseSSEhook, render overall progress bar and per-file status list. "Download ZIP" button triggersapiDownloadBlob - Update
apps/web/src/stores/file-store.ts-- add batch state:batchJobId,batchProgressmap,isBatchModeflag (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, showBatchProgresscomponent. Wire download to blob trigger - Modify
apps/api/src/index.ts-- register batch routes
Test
# 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 |