Skip to main content
Glama
dutchbase

img-convert MCP Server

by dutchbase
README.md
# img-convert

Fast CLI image converter with Web UI.

Convert images between:

- JPG
- PNG
- WebP
- AVIF
- GIF
- TIFF

Built for developers and AI agents.

## Install

```bash
npx @dutchbase/img-convert
```

or

```bash
npm install -g @dutchbase/img-convert
```

```bash
# Convert a file
img-convert photo.jpg -f webp --json

# Inspect an image without converting
img-convert info photo.jpg

# Give Claude Code native image conversion tools
img-convert mcp
```

---

Fast, scriptable image conversion powered by [Sharp](https://sharp.pixelplumbing.com). Ships as an npm package, a CLI, a REST API, and a [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server — making it a first-class tool for both developers and AI agents.

---

## Table of Contents

- [Why img-convert](#why-img-convert)
- [Agent Skill](#agent-skill)
- [Installation](#installation)
- [CLI Reference](#cli-reference)
  - [Convert](#convert-default-action)
  - [Info](#info-subcommand)
  - [Batch](#batch-subcommand)
  - [MCP Server](#mcp-server-subcommand)
- [AI Agent Integration](#ai-agent-integration)
  - [MCP Tools](#mcp-tools)
  - [JSON Output Design](#json-output-design)
  - [Manifest Batch Mode](#manifest-batch-mode)
- [Node.js API](#nodejs-api)
  - [convert()](#convert)
  - [getInfo()](#getinfo)
  - [batch()](#batch)
- [REST API](#rest-api)
- [Format Support](#format-support)
- [Processing Options](#processing-options)
- [Architecture](#architecture)
- [Development](#development)
- [Contributing](#contributing)

---

## Agent Skill

`img-convert` ships a `SKILL.md` file that coding agents can import to get full, structured knowledge of every command, flag, pattern, and gotcha — without reading this README.

### Import into Claude Code (global, all projects)

```bash
/instinct-import https://raw.githubusercontent.com/dutchbase/img-converter/main/SKILL.md
```

### Import as a project-scoped skill

```bash
/instinct-import https://raw.githubusercontent.com/dutchbase/img-converter/main/SKILL.md --scope project
```

Once imported, any Claude Code session automatically knows:

- Which interface to use (CLI vs API vs MCP vs REST) for a given task
- To always run `img-convert info` before converting unknown images
- The `--json` / stderr separation contract for piping
- Every CLI flag, including new ones (`--grayscale`, `--rotate`, `--normalize`, etc.)
- The manifest format for `batch` subcommand
- All MCP tool signatures and return shapes
- The Node.js API types and common patterns
- Format gotchas (HEIC input-only, alpha→JPEG background, animated GIF rules)
- Common mistakes and how to avoid them

The skill file is kept in sync with the package at [`SKILL.md`](./SKILL.md).

---

## Why img-convert

Most image conversion tools are designed for interactive use — a GUI, a web form, a one-off shell command. `img-convert` is designed for **programmatic use**: CI pipelines, build scripts, AI agent workflows, and server-side processing.

**Key design principles:**

- **Machine-readable output first.** `--json` on every command. `stderr` carries human-facing progress. `stdout` carries data. Every command pipes cleanly to `jq`.
- **AI agent optimized.** Ships a native MCP server. Claude Code, Cursor, and any MCP-compatible agent can call `convert_image` and `get_image_info` as native tools — no shell escaping, no subprocess management, full type safety.
- **Composable.** CLI, Node.js API, and REST API all run the same `processImage()` pipeline under the hood. Behavior is identical regardless of the call path.
- **Minimal published footprint.** The npm bundle is ~50 KB. The full Next.js web UI is excluded from the published package — only `dist/`, `lib/`, `types/`, and `cli/` ship.

---

## Installation

### Global CLI

```bash
npm install -g @dutchbase/img-convert
```

### Local dependency (Node.js API)

```bash
npm install @dutchbase/img-convert
```

### Self-hosted web UI

```bash
git clone https://github.com/dutchbase/img-convert
cd img-convert
npm install
npm run dev        # http://localhost:3000
npm run build      # production Next.js build
```

### Requirements

- **Node.js >= 18.0.0**
- Sharp's native bindings are pre-built for Linux x64/arm64, macOS arm64/x64, and Windows x64. For other platforms, see the [Sharp installation guide](https://sharp.pixelplumbing.com/install).

---

## CLI Reference

### Convert (default action)

```
img-convert [files...] -f <format> [options]
```

`files` accepts file paths, glob patterns, and HTTP/HTTPS URLs. When no files are provided and stdin is a pipe, reads from stdin and writes to stdout (pipe mode).

#### Options

| Flag | Default | Description |
|------|---------|-------------|
| `-f, --format <fmt>` | — | **Required.** Target format: `jpeg` `png` `webp` `avif` `gif` `tiff` |
| `-q, --quality <n>` | `85` | Encoding quality 1–100. Applies to JPEG, WebP, AVIF, TIFF. PNG derives compression level from this value. GIF ignores it. |
| `--width <n>` | — | Resize to this width in pixels. Aspect ratio maintained by default. |
| `--height <n>` | — | Resize to this height in pixels. Aspect ratio maintained by default. |
| `--no-metadata` | — | Strip EXIF/XMP/IPTC metadata. ICC color profile is always preserved. |
| `-o, --output <dir>` | input dir | Write output files into this directory. Created automatically if it doesn't exist. |
| `-c, --concurrency <n>` | `4` | Maximum parallel conversions. |
| `--json` | — | Emit structured JSON to stdout. All progress and warnings go to stderr. |
| `--dry-run` | — | Show what would be written without writing anything. |
| `--quiet` | — | Suppress per-file progress lines. Error summary still shown. |
| `--grayscale` | — | Desaturate the image to grayscale. |
| `--rotate <n>` | — | Rotate by degrees. Any angle accepted; background color fills empty corners. |
| `--flip` | — | Flip horizontally (left–right mirror). |
| `--flop` | — | Flop vertically (top–bottom mirror). |
| `--background <color>` | — | Background fill color for transparent areas (e.g. `#ffffff`, `rgba(0,0,0,0)`). Required for clean PNG→JPEG conversion. |
| `--blur <n>` | — | Gaussian blur sigma (valid range: 0.3–1000). |
| `--sharpen` | — | Apply unsharp mask sharpening with Sharp's default parameters. |
| `--normalize` | — | Stretch contrast to full range. Useful for scanned documents and low-contrast images. |
| `--trim` | — | Auto-trim uniform-color border pixels from all edges. |

#### Examples

```bash
# Single file
img-convert photo.jpg -f webp

# Glob pattern with output directory and quality
img-convert "src/images/*.png" -f avif -q 80 -o dist/images/

# Resize to max 1280px wide, maintain aspect ratio
img-convert banner.png -f jpeg --width 1280 -q 90

# Strip metadata, 4 files at once
img-convert *.jpg -f webp --no-metadata -c 4 -o output/

# Remote URL
img-convert https://example.com/photo.png -f webp -o ./converted/

# Machine-readable output — stdout is pure JSON, stderr is progress
img-convert photo.jpg -f webp --json 2>/dev/null | jq .reduction

# Pipe mode: stdin → stdout (no file args, non-TTY stdin)
cat input.png | img-convert -f webp > output.webp

# Preview without writing
img-convert "*.jpg" -f avif --dry-run --json

# Grayscale + auto contrast for document scans
img-convert scan.jpg -f png --grayscale --normalize

# Flatten PNG transparency to white before JPEG conversion
img-convert logo.png -f jpeg --background "#ffffff"

# Rotate with background fill
img-convert photo.jpg -f jpeg --rotate 90 --background "#000000"
```

#### JSON output shape

**Single file:**

```json
{
  "input": "photo.jpg",
  "output": "/absolute/path/to/photo.webp",
  "inputBytes": 204800,
  "outputBytes": 81920,
  "reduction": 60.0,
  "width": 1920,
  "height": 1080,
  "format": "webp",
  "quality": 85
}
```

**Multiple files:** JSON array with one object per file. Failed files include an `"error"` string field instead of size/dimension data.

**Dry run (with `--json`):**

```json
{
  "input": "photo.jpg",
  "output": "/absolute/path/to/photo.webp",
  "inputBytes": 204800,
  "dryRun": true
}
```

---

### `info` subcommand

Inspect an image without converting it. Always outputs JSON to stdout. Supports file paths and URLs.

```bash
img-convert info <file|url>
```

```bash
img-convert info photo.jpg
img-convert info https://example.com/image.png
```

**Output:**

```json
{
  "format": "jpeg",
  "width": 4032,
  "height": 3024,
  "filesize": 3891200,
  "hasAlpha": false,
  "hasExif": true,
  "colorSpace": "srgb",
  "isAnimated": false,
  "channels": 3,
  "density": 72
}
```

**Field reference:**

| Field | Type | Description |
|-------|------|-------------|
| `format` | string | Format as detected by Sharp: `jpeg`, `png`, `webp`, `gif`, `tiff`, `avif`, etc. |
| `width` | number | Width in pixels |
| `height` | number | Height in pixels |
| `filesize` | number | File size in bytes |
| `hasAlpha` | boolean | Whether an alpha (transparency) channel is present |
| `hasExif` | boolean | Whether EXIF metadata is present |
| `colorSpace` | string | Color space: `srgb`, `p3`, `cmyk`, `grey`, etc. |
| `isAnimated` | boolean | `true` for animated GIFs, multi-page TIFFs, animated WebP |
| `channels` | number | Channel count — 3 = RGB, 4 = RGBA |
| `density` | number | DPI/PPI as embedded in file metadata. `undefined` if not set. |

The `info` command is designed for **pre-conversion inspection** — check `hasAlpha` before converting to JPEG, check `isAnimated` before stripping frames, verify dimensions before a resize.

---

### `batch` subcommand

Convert a list of images defined in a JSON manifest file.

```bash
img-convert batch <manifest.json> [options]
```

**Options:**

| Flag | Default | Description |
|------|---------|-------------|
| `-c, --concurrency <n>` | `4` | Parallel conversion limit |
| `--json` | — | Output results as a JSON array to stdout |

**Manifest format:**

```json
[
  {
    "input": "src/hero.png",
    "output": "dist/hero.webp",
    "format": "webp",
    "quality": 90
  },
  {
    "input": "https://cdn.example.com/avatar.png",
    "output": "assets/avatar.avif",
    "format": "avif",
    "width": 200,
    "height": 200
  },
  {
    "input": "photos/raw.jpg",
    "format": "jpeg",
    "quality": 75,
    "removeMetadata": true
  }
]
```

If `output` is omitted, the file is written next to the input with the new extension.

**Manifest item fields:**

| Field | Required | Description |
|-------|----------|-------------|
| `input` | Yes | File path or HTTP/HTTPS URL |
| `format` | Yes | Target format |
| `output` | No | Output file path. Auto-derived from `input` if omitted. |
| `quality` | No | Quality 1–100, default `85` |
| `width` | No | Resize width in pixels |
| `height` | No | Resize height in pixels |
| `removeMetadata` | No | Strip EXIF metadata, default `false` |

```bash
# Process manifest, capture JSON results
img-convert batch jobs.json --json > results.json 2>/dev/null

# Process with human-readable progress
img-convert batch jobs.json -c 8
```

**JSON output per item:**

```json
{
  "index": 0,
  "input": "src/hero.png",
  "output": "dist/hero.webp",
  "inputBytes": 512000,
  "outputBytes": 102400,
  "reduction": 80.0,
  "width": 1920,
  "height": 1080,
  "format": "webp",
  "quality": 90
}
```

---

### `mcp` subcommand

Start an MCP (Model Context Protocol) server on stdio. This is the primary integration point for AI agents.

```bash
img-convert mcp
```

See [AI Agent Integration](#ai-agent-integration) for full details.

---

## AI Agent Integration

`img-convert` is designed to be called directly by AI agents as a native typed tool — not as a raw shell command.

### MCP Server

[Model Context Protocol](https://modelcontextprotocol.io) is the open standard for giving AI agents structured tool access. `img-convert` ships a production-ready MCP server.

#### Register with Claude Code

Add to `~/.claude/mcp.json`:

```json
{
  "mcpServers": {
    "img-convert": {
      "command": "img-convert",
      "args": ["mcp"]
    }
  }
}
```

After registering, Claude Code can call `convert_image`, `get_image_info`, `batch_convert`, and `list_supported_formats` as native tools — with full type checking, no shell escaping, and structured return values.

#### Register with other MCP clients

Any client that supports the MCP stdio transport works identically. Point it at `img-convert mcp`.

**Cursor** (`~/.cursor/mcp.json`), **Continue**, **Zed**, and any other MCP host follow the same pattern:

```json
{
  "mcpServers": {
    "img-convert": {
      "command": "img-convert",
      "args": ["mcp"]
    }
  }
}
```

### MCP Tools

#### `convert_image`

Convert a single image file. Accepts file paths and URLs.

**Input schema:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `input_path` | string | Yes | File path or HTTP/HTTPS URL |
| `output_format` | string | Yes | One of: `jpeg` `png` `webp` `avif` `gif` `tiff` |
| `output_path` | string | No | Output file path. Derived from `input_path` with new extension if omitted. |
| `quality` | number | No | Quality 1–100, default `85` |
| `width` | number | No | Resize width, maintains aspect ratio |
| `height` | number | No | Resize height, maintains aspect ratio |
| `remove_metadata` | boolean | No | Strip EXIF, default `false` |
| `grayscale` | boolean | No | Desaturate to grayscale |
| `rotate` | number | No | Rotation degrees |
| `background` | string | No | Background fill color (CSS color string) |

**Returns:**

```json
{
  "input_path": "photo.jpg",
  "output_path": "photo.webp",
  "input_bytes": 204800,
  "output_bytes": 81920,
  "reduction": 60.0,
  "width": 1920,
  "height": 1080,
  "format": "webp",
  "quality": 85
}
```

#### `get_image_info`

Get full metadata about an image without converting it.

**Input schema:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `input_path` | string | Yes | File path or HTTP/HTTPS URL |

**Returns:**

```json
{
  "format": "jpeg",
  "width": 4032,
  "height": 3024,
  "filesize": 3891200,
  "hasAlpha": false,
  "hasExif": true,
  "colorSpace": "srgb",
  "isAnimated": false,
  "channels": 3,
  "density": 72
}
```

Use this first to make informed conversion decisions: does the image have transparency (affects JPEG conversion), is it animated (affects frame handling), what is the color space (affects print workflows)?

#### `batch_convert`

Convert multiple images in a single tool call.

**Input schema:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `items` | array | Yes | Array of conversion jobs (see below) |
| `concurrency` | number | No | Parallel limit, default `4` |

Each item in `items`:

| Field | Type | Required |
|-------|------|----------|
| `input_path` | string | Yes |
| `output_format` | string | Yes |
| `output_path` | string | No |
| `quality` | number | No |
| `width` | number | No |
| `height` | number | No |

**Returns:** Array of result objects, one per input item.

#### `list_supported_formats`

Enumerate what the server can read and write.

**Returns:**

```json
{
  "input": ["jpeg", "png", "webp", "avif", "gif", "tiff", "heic", "svg", "bmp"],
  "output": ["jpeg", "png", "webp", "avif", "gif", "tiff"]
}
```

### JSON Output Design

Every command is designed to produce parseable, pipeable output:

- **`--json` flag**: data on stdout as JSON, all progress/warnings on stderr
- **`info` subcommand**: always JSON, no flag needed
- **`batch --json`**: JSON array with one entry per manifest item

This gives agents and scripts clean signal separation:

```bash
# Capture reduction percentage
REDUCTION=$(img-convert photo.jpg -f webp --json 2>/dev/null | jq .reduction)

# Inspect before converting
HAS_ALPHA=$(img-convert info logo.png | jq .hasAlpha)
if [ "$HAS_ALPHA" = "true" ]; then
  img-convert logo.png -f jpeg --background "#ffffff" --json 2>/dev/null
else
  img-convert logo.png -f jpeg --json 2>/dev/null
fi

# Count failed conversions in a batch
FAILED=$(img-convert batch jobs.json --json 2>/dev/null | jq '[.[] | select(.error)] | length')
```

### Manifest Batch Mode

AI agents work naturally with JSON as a data format. The manifest pattern decouples job definition from execution — the agent assembles the job list as a data structure, writes it to a file, and `img-convert batch` executes it:

```typescript
// Agent builds the manifest
const manifest = imagePaths.map(inputPath => ({
  input: inputPath,
  output: inputPath.replace(/\.\w+$/, '.webp'),
  format: 'webp' as const,
  quality: 85,
}))

fs.writeFileSync('convert-jobs.json', JSON.stringify(manifest, null, 2))

// Agent executes it and reads structured results
const stdout = execSync('img-convert batch convert-jobs.json --json 2>/dev/null', {
  encoding: 'utf8',
})
const results = JSON.parse(stdout)
const totalSaved = results.reduce(
  (sum: number, r: { inputBytes: number; outputBytes: number }) =>
    sum + (r.inputBytes - r.outputBytes),
  0
)
```

No shell interpolation, no quoting edge cases, fully declarative, fully auditable.

---

## Node.js API

```typescript
import { convert, getInfo, batch } from '@dutchbase/img-convert'
```

All three functions accept file paths, HTTP/HTTPS URLs, or raw `Buffer` objects as input.

### `convert()`

```typescript
function convert(
  input: string | Buffer,
  options: ConvertApiOptions
): Promise<ConvertApiResult>
```

**`ConvertApiOptions`:**

```typescript
interface ConvertApiOptions {
  format: ImageFormat;           // required — "jpeg"|"png"|"webp"|"avif"|"gif"|"tiff"
  quality?: number;              // default 85
  width?: number;
  height?: number;
  removeMetadata?: boolean;      // default false
  maintainAspectRatio?: boolean; // default true
  allowUpscaling?: boolean;      // default false (prevents enlargement)
  crop?: {
    left: number;
    top: number;
    width: number;
    height: number;
  };
  rotate?: number;               // arbitrary degrees
  autoRotate?: boolean;          // apply and strip EXIF orientation tag
  flip?: boolean;                // horizontal mirror
  flop?: boolean;                // vertical mirror
  background?: string;           // CSS color string
  grayscale?: boolean;
  blur?: number;                 // Gaussian sigma 0.3–1000
  sharpen?: boolean;
  normalize?: boolean;
  trim?: boolean;
}
```

**`ConvertApiResult`:**

```typescript
interface ConvertApiResult {
  buffer: Buffer;
  info: {
    inputBytes: number;
    outputBytes: number;
    width: number;
    height: number;
    format: string;
  };
}
```

**Examples:**

```typescript
import { convert } from '@dutchbase/img-convert'
import fs from 'fs/promises'

// Convert a local file
const result = await convert('./photo.jpg', {
  format: 'webp',
  quality: 85,
  width: 1280,
})
await fs.writeFile('./photo.webp', result.buffer)
console.log(`${result.info.inputBytes} → ${result.info.outputBytes} bytes`)

// Convert from a URL
const fromUrl = await convert('https://example.com/image.png', {
  format: 'avif',
  quality: 70,
})

// Convert from an in-memory Buffer (e.g. from a multipart upload handler)
const fromBuffer = await convert(req.file.buffer, {
  format: 'jpeg',
  quality: 90,
  background: '#ffffff',  // flatten PNG transparency before JPEG encoding
})

// Crop then resize
const cropped = await convert('./screenshot.png', {
  format: 'webp',
  crop: { left: 100, top: 50, width: 800, height: 600 },
  width: 400,
})

// Strip EXIF, rotate to EXIF orientation, then re-encode
const clean = await convert('./camera.jpg', {
  format: 'jpeg',
  autoRotate: true,
  removeMetadata: true,
  quality: 88,
})
```

### `getInfo()`

```typescript
function getInfo(input: string | Buffer): Promise<ImageInfo>
```

```typescript
interface ImageInfo {
  format: string;
  width: number;
  height: number;
  filesize: number;
  hasAlpha: boolean;
  hasExif: boolean;
  colorSpace: string;
  isAnimated: boolean;
  channels?: number;
  density?: number;
}
```

**Examples:**

```typescript
import { getInfo, convert } from '@dutchbase/img-convert'

const info = await getInfo('./photo.jpg')
// { format: 'jpeg', width: 4032, height: 3024, filesize: 3891200,
//   hasAlpha: false, hasExif: true, colorSpace: 'srgb', isAnimated: false }

// Conditional conversion: don't flatten alpha if not needed
const { hasAlpha } = await getInfo('./image.png')
const result = await convert('./image.png', {
  format: 'jpeg',
  ...(hasAlpha ? { background: '#ffffff' } : {}),
})

// Skip animated GIFs in a batch
const infos = await Promise.all(paths.map(p => getInfo(p)))
const staticOnly = paths.filter((_, i) => !infos[i].isAnimated)
```

### `batch()`

```typescript
function batch(
  items: BatchApiItem[],
  options?: BatchApiOptions
): Promise<BatchApiResult[]>
```

```typescript
interface BatchApiItem {
  input: string;           // file path or URL
  output?: string;         // output file path — auto-derived if omitted
  format: ImageFormat;
  quality?: number;
  width?: number;
  height?: number;
  removeMetadata?: boolean;
}

interface BatchApiOptions {
  concurrency?: number;    // default 4
  outputDir?: string;      // write all outputs here when output not specified per-item
}

interface BatchApiResult {
  input: string;
  output: string;
  inputBytes: number;
  outputBytes: number;
  width: number;
  height: number;
  format: string;
  quality: number;
}
```

**Example:**

```typescript
import { batch } from '@dutchbase/img-convert'

const results = await batch(
  [
    { input: './src/hero.png',   format: 'webp', quality: 90 },
    { input: './src/thumb.jpg',  format: 'avif', width: 200 },
    { input: './src/banner.gif', format: 'webp' },
  ],
  { concurrency: 4 }
)

for (const r of results) {
  const pct = ((1 - r.outputBytes / r.inputBytes) * 100).toFixed(1)
  console.log(`${r.input} → ${r.output} (${pct}% smaller)`)
}
// ./src/hero.png   → ./src/hero.webp   (67.3% smaller)
// ./src/thumb.jpg  → ./src/thumb.avif  (71.0% smaller)
// ./src/banner.gif → ./src/banner.webp (44.2% smaller)
```

---

## REST API

The web application exposes a single endpoint. It can be called directly from any HTTP client.

### `POST /api/convert`

Accepts `multipart/form-data`. Returns the converted image as binary.

**Request fields:**

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `file` | File | Yes | Source image. Max 50 MB. |
| `targetFormat` | string | Yes | `jpeg` `png` `webp` `avif` `gif` `tiff` |
| `quality` | string | No | Integer 1–100, default `85` |
| `resizeWidth` | string | No | Target width in pixels |
| `resizeHeight` | string | No | Target height in pixels |
| `maintainAspectRatio` | `"true"` | No | Default `false` |
| `removeMetadata` | `"true"` | No | Strip EXIF, default `false` |
| `allowUpscaling` | `"true"` | No | Allow enlargement, default `false` |

**Success response:**

- Status: `200`
- Body: raw image bytes
- Headers:
  - `Content-Type` — format MIME type
  - `Content-Disposition: attachment; filename="<name>.<ext>"`
  - `X-Output-Size` — output size in bytes (string)
  - `X-Output-Filename` — sanitized output filename

**Error response shape:**

```typescript
interface ApiErrorResponse {
  error: string;     // machine-readable error code
  message: string;   // human-readable description
  field?: string;    // which form field caused the error, if applicable
}
```

**Error codes:**

| HTTP status | Error code | Cause |
|-------------|-----------|-------|
| `400` | `MISSING_FILE` | No file in request |
| `400` | `MISSING_TARGET_FORMAT` | `targetFormat` not provided |
| `400` | `UNSUPPORTED_TARGET_FORMAT` | Requested output format is input-only |
| `400` | `INVALID_QUALITY` | Quality is not an integer in 1–100 |
| `400` | `INVALID_DIMENSION` | Width or height is not a positive integer |
| `413` | `FILE_TOO_LARGE` | File exceeds 50 MB |
| `415` | `UNSUPPORTED_FORMAT` | Magic-byte check failed (declared MIME ≠ actual content) |
| `422` | `IMAGE_TOO_LARGE` | Pixel dimensions exceed 25 megapixels |
| `422` | `LIVE_PHOTO_NOT_SUPPORTED` | HEIC live photo detected |
| `500` | `CONVERSION_FAILED` | Unhandled Sharp error |

**curl example:**

```bash
curl -s -X POST http://localhost:3000/api/convert \
  -F "file=@photo.jpg" \
  -F "targetFormat=webp" \
  -F "quality=85" \
  -o output.webp

# Check output size from response header
curl -sI -X POST http://localhost:3000/api/convert \
  -F "file=@photo.jpg" \
  -F "targetFormat=webp" \
  | grep X-Output-Size
```

---

## Format Support

### Input formats

| Format | MIME type(s) | Notes |
|--------|-------------|-------|
| JPEG | `image/jpeg` | |
| PNG | `image/png` | Transparency supported |
| WebP | `image/webp` | Animated WebP supported |
| AVIF | `image/avif` | |
| GIF | `image/gif` | Animated GIF supported |
| TIFF | `image/tiff` | |
| HEIC / HEIF | `image/heic`, `image/heif`, `image/heic-sequence`, `image/heif-sequence` | Pre-decoded via `heic-convert`. Adds ~200–500 ms per file. |
| SVG | `image/svg+xml` | Rasterized via librsvg (Sharp built-in). Output size = SVG declared dimensions unless overridden with `--width`/`--height`. |
| BMP | `image/bmp` | Read only. Sharp has no BMP output encoder. |

### Output formats

| Format | Quality flag | Typical use |
|--------|-------------|-------------|
| `jpeg` | Yes | Photos, no transparency requirement |
| `png` | Compression derived | Lossless, transparency, screenshots |
| `webp` | Yes | Web images — best size/quality trade-off for most content |
| `avif` | Yes | Smallest files, highest quality per byte. Slower encoding. |
| `gif` | No | Animated images |
| `tiff` | Yes | Print workflows, archival storage |

### Format conversion notes

**Transparency → JPEG.** JPEG has no alpha channel. Without `--background`, transparent pixels become black. Always pass `--background "#ffffff"` (or your target fill color) when converting PNG/WebP/AVIF with transparency to JPEG.

**Animated GIF to static format.** Converting an animated GIF to JPEG or PNG captures only the first frame. To preserve animation, convert to WebP (which supports animation).

**SVG rasterization.** Sharp uses librsvg to rasterize SVGs. The default raster size is the SVG's declared `width`/`height` attributes. Pass `--width` or `--height` to control the output pixel dimensions.

**HEIC decoding.** Apple's HEIC format cannot be decoded by Sharp directly. `img-convert` uses the `heic-convert` library to decode HEIC to a PNG buffer first, then passes it to Sharp. This adds latency and is single-threaded per file.

**PNG quality.** PNG is lossless, so `--quality` controls Sharp's `compressionLevel` (derived as `Math.round((100 - quality) / 11)`). Higher quality = lower compression = faster encoding + larger files. The image data is identical either way.

---

## Processing Options

The pipeline runs in this fixed order. Each step is opt-in and independent.

```
Input
  → HEIC pre-decode (if source is HEIC)
  → Decompression bomb guard (rejects > 25 megapixels)
  → Metadata handling (strip or preserve)
  → Auto-rotate / Rotate
  → Flip / Flop
  → Crop
  → Resize
  → Grayscale
  → Normalize
  → Blur
  → Sharpen
  → Trim
  → Background flatten (before JPEG encoding)
  → Format encode
  → Output
```

| Option | CLI | API field | Notes |
|--------|-----|-----------|-------|
| Quality | `--quality` | `quality` | 1–100. Applies to JPEG, WebP, AVIF, TIFF. |
| Resize | `--width` / `--height` | `width` / `height` | Fits within dimensions. No upscaling unless `allowUpscaling: true`. |
| Metadata | `--no-metadata` | `removeMetadata` | Strips EXIF/XMP/IPTC. ICC profile always kept. |
| Crop | — | `crop: { left, top, width, height }` | Runs before resize. Pixel coordinates in original image space. |
| Auto-rotate | — | `autoRotate` | Applies EXIF orientation and strips the tag. |
| Rotate | `--rotate <deg>` | `rotate` | Any angle. Empty corners filled with `background` color. |
| Flip | `--flip` | `flip` | Left–right mirror. |
| Flop | `--flop` | `flop` | Top–bottom mirror. |
| Background | `--background <color>` | `background` | CSS color string. Used for rotation corners and JPEG flattening. |
| Grayscale | `--grayscale` | `grayscale` | Desaturates to single luminance channel. |
| Blur | `--blur <sigma>` | `blur` | Gaussian blur, sigma 0.3–1000. |
| Sharpen | `--sharpen` | `sharpen` | Unsharp mask with Sharp defaults. |
| Normalize | `--normalize` | `normalize` | Stretches histogram to full range. |
| Trim | `--trim` | `trim` | Removes uniform-color edge pixels. |

---

## Architecture

```
img-convert/
├── cli/
│   ├── index.ts           # Commander CLI — convert, info, batch, mcp subcommands
│   ├── helpers.ts         # Pure functions: path building, format detection, option mapping
│   └── mcp.ts             # MCP server — registers tools, handles stdio transport
├── lib/
│   ├── imageProcessor.ts  # Core Sharp pipeline — single source of truth for all interfaces
│   ├── api.ts             # Programmatic Node.js API: convert(), getInfo(), batch()
│   ├── heicDecoder.ts     # HEIC → PNG buffer pre-decode step
│   └── processingQueue.ts # Concurrency semaphore for the REST endpoint
├── types/
│   ├── index.ts           # Shared types: ImageFormat, ConvertOptions, API types
│   └── client.ts          # Browser-safe re-export + MIME → ImageFormat detection helper
├── app/
│   ├── api/convert/
│   │   └── route.ts       # Next.js Route Handler: POST /api/convert
│   ├── layout.tsx
│   └── page.tsx
├── components/
│   ├── ImageConverter.tsx # Top-level stateful client component
│   ├── DropZone.tsx       # Drag-and-drop file input
│   ├── ConvertOptions.tsx # Format selector, quality slider, resize controls
│   ├── ConvertResult.tsx  # Download link + size comparison
│   ├── BatchQueue.tsx     # Multi-file batch UI with per-item status
│   └── ImagePreview.tsx   # Source image preview
├── dist/
│   └── cli/               # Compiled CLI output (CommonJS, aliases resolved by tsc-alias)
└── __tests__/
    ├── imageProcessor.test.ts
    ├── cli.test.ts
    ├── route.test.ts
    ├── batchQueue.test.ts
    └── ...
```

### Single pipeline, four interfaces

The `processImage()` function in `lib/imageProcessor.ts` is the canonical Sharp pipeline. It is called by:

1. **CLI** (`cli/index.ts`) — reads files or stdin, writes to disk
2. **Node.js API** (`lib/api.ts`) — wraps processImage with input resolution and structured result objects
3. **REST API** (`app/api/convert/route.ts`) — validates multipart form fields and returns binary HTTP response
4. **MCP server** (`cli/mcp.ts`) — translates tool call arguments into processImage options, writes files, returns JSON

All four interfaces produce identical output for identical inputs. There is no separate code path for any interface.

### Concurrency model

| Interface | Mechanism | Default limit |
|-----------|-----------|---------------|
| CLI | `p-limit` per invocation | `--concurrency 4` |
| Node.js API | `p-limit` per `batch()` call | `options.concurrency ?? 4` |
| REST API | `async-sema` semaphore across all requests | `processingQueue` (1 slot) |
| MCP batch | `p-limit` per `batch_convert` call | `concurrency ?? 4` |

The REST endpoint's semaphore is intentionally conservative (single slot) to prevent memory exhaustion under concurrent browser requests. CLI and API concurrency is user-controlled.

### Build system

| Config | Purpose |
|--------|---------|
| `tsconfig.json` | Next.js app — `moduleResolution: "bundler"`, `noEmit: true` |
| `tsconfig.cli.json` | CLI + API — `moduleResolution: "node"`, `module: "CommonJS"`, emits to `dist/cli/` |
| `tsc-alias` | Post-processes compiled JS to rewrite `@/*` path aliases to relative paths |

The two tsconfig approach is intentional: the Next.js bundler handles module resolution differently from Node.js require(). Sharing one config would require compromises in both directions.

---

## Development

### Setup

```bash
git clone https://github.com/dutchbase/img-convert
cd img-convert
npm install
```

### Commands

```bash
npm run dev            # Start Next.js dev server at http://localhost:3000
npm run build          # Production Next.js build + type-check
npm run build:cli      # Compile CLI + API to dist/cli/ (required before running img-convert locally)
npm run lint           # ESLint
npm test               # Jest unit tests
npm run test:coverage  # Jest with coverage report
npm run test:e2e       # Playwright end-to-end tests
npm run test:all       # Unit + E2E
```

### Adding a new output format

1. Add the format key to the `ImageFormat` union in `types/index.ts`
2. Add entries to `FORMAT_LABELS`, `FORMAT_MIME`, `FORMAT_EXTENSIONS`
3. Add the format to `OUTPUT_FORMATS` (or `INPUT_ONLY_FORMATS` if Sharp cannot encode it)
4. Add a case to `applyFormat()` in `lib/imageProcessor.ts`
5. Add the MIME type to `detectFormat()` in `lib/imageProcessor.ts`
6. Add the MIME type to `detectFormatFromMime()` in `types/client.ts`
7. Add the extension to `EXT_TO_FORMAT` in `cli/helpers.ts`
8. Add the MIME type to the `accept` attribute in `components/DropZone.tsx`

### Adding a new processing option

1. Add the field to `ConvertOptions` in `types/index.ts`
2. Add to `ConvertApiOptions` in `types/index.ts` if it should be part of the public API
3. Apply in `lib/imageProcessor.ts` in the correct pipeline position
4. Add the CLI flag to `program` in `cli/index.ts`
5. Wire through `buildConvertOptions()` in `cli/helpers.ts`
6. Expose in the MCP `convert_image` tool input schema in `cli/mcp.ts`
7. Add a UI control in `components/ConvertOptions.tsx` if it should be in the web UI

### Test structure

Tests live in `__tests__/` and run with Jest + `ts-jest`. The test environment is configured per-file in `jest.config.ts`:

- **Node environment**: `imageProcessor.test.ts`, `route.test.ts`, `cli.test.ts`, `heicDecoder.test.ts`, `animatedGif.test.ts`
- **JSDOM environment**: `imageConverter.test.tsx`, `dropZone.test.ts`, `batchQueue.test.ts`, `processingQueue.test.ts`

Sharp operations use the actual Sharp library in tests (no mocking) with small fixture images in `__tests__/fixtures/`.

### Security considerations

The REST endpoint applies multiple defense layers:

1. **File size limit** — 50 MB hard cap before reading body
2. **MIME allowlist** — source format must be a recognized image type
3. **Magic-byte verification** — `file-type` checks actual file contents, not just the browser-supplied MIME header
4. **Pixel dimension check** — rejects images exceeding 25 megapixels before allocating decode buffers
5. **Sharp decompression limit** — `limitInputPixels: 25_000_000` passed to every Sharp constructor
6. **Filename sanitization** — `Content-Disposition` filename is stripped of all characters except `[a-zA-Z0-9._-]`

### CI/CD

`.github/workflows/ci.yml` runs on every push and PR:
- Tests on Node 18, 20, and 22
- Runs `npm test`, `npm run build`, `npm run build:cli`
- Verifies the compiled CLI binary executes without error

`.github/workflows/release.yml` triggers on `v*` tags:
- Runs full test suite
- Builds CLI
- Publishes to npm with [provenance attestation](https://docs.npmjs.com/generating-provenance-statements)

```bash
# Publish a new release
npm version patch    # or minor / major
git push --follow-tags
# GitHub Actions handles the rest
```

---

## Contributing

Pull requests are welcome.

1. Open an issue first for non-trivial changes.
2. Keep `processImage()` as the single pipeline — don't fork processing logic between CLI, API, REST, and MCP.
3. Maintain the stderr/stdout contract: data on stdout, progress on stderr. `--json` should always produce parseable output.
4. Add tests for new features and bug fixes. The test suite should remain green with `npm test`.
5. Run `npm test && npm run build:cli` before submitting.

---

## License

MIT


<!-- Security scan triggered at 2026-09-05 08:09:43 -->