Files
SnapOtter/docs/superpowers/plans/2026-03-22-phase2-core-tools.md
T
Siddharth Kumar Sah 5ab4c96ef7 docs: add Phase 2 implementation plan for core tools
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.
2026-03-22 03:37:03 +08:00

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 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<string>
  • 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

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/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

# 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 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

# 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 -- 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

# 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-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

# 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 (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

# 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 -- 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

# 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 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

# 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 -- 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

# 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) to toolSettingsMap in apps/web/src/pages/tool-page.tsx, all pointing to the same ColorAdjustmentsSettings component with a defaultTab prop

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 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 <img> 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

# 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-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<string, QueuedJob> 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

# 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