image-edit-tools 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +2 -0
- package/README.md +41 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/index.d.ts +3 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +3 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +15 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +4 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +285 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/ops/add-text.d.ts +5 -0
- package/dist/ops/add-text.d.ts.map +1 -0
- package/dist/ops/add-text.js +129 -0
- package/dist/ops/add-text.js.map +1 -0
- package/dist/ops/adjust.d.ts +3 -0
- package/dist/ops/adjust.d.ts.map +1 -0
- package/dist/ops/adjust.js +71 -0
- package/dist/ops/adjust.js.map +1 -0
- package/dist/ops/batch.d.ts +3 -0
- package/dist/ops/batch.d.ts.map +1 -0
- package/dist/ops/batch.js +35 -0
- package/dist/ops/batch.js.map +1 -0
- package/dist/ops/blur-region.d.ts +5 -0
- package/dist/ops/blur-region.d.ts.map +1 -0
- package/dist/ops/blur-region.js +54 -0
- package/dist/ops/blur-region.js.map +1 -0
- package/dist/ops/composite.d.ts +5 -0
- package/dist/ops/composite.d.ts.map +1 -0
- package/dist/ops/composite.js +53 -0
- package/dist/ops/composite.js.map +1 -0
- package/dist/ops/convert.d.ts +3 -0
- package/dist/ops/convert.d.ts.map +1 -0
- package/dist/ops/convert.js +45 -0
- package/dist/ops/convert.js.map +1 -0
- package/dist/ops/crop.d.ts +3 -0
- package/dist/ops/crop.d.ts.map +1 -0
- package/dist/ops/crop.js +105 -0
- package/dist/ops/crop.js.map +1 -0
- package/dist/ops/detect-faces.d.ts +3 -0
- package/dist/ops/detect-faces.d.ts.map +1 -0
- package/dist/ops/detect-faces.js +41 -0
- package/dist/ops/detect-faces.js.map +1 -0
- package/dist/ops/detect-subject.d.ts +3 -0
- package/dist/ops/detect-subject.d.ts.map +1 -0
- package/dist/ops/detect-subject.js +78 -0
- package/dist/ops/detect-subject.js.map +1 -0
- package/dist/ops/extract-text.d.ts +5 -0
- package/dist/ops/extract-text.d.ts.map +1 -0
- package/dist/ops/extract-text.js +21 -0
- package/dist/ops/extract-text.js.map +1 -0
- package/dist/ops/filter.d.ts +3 -0
- package/dist/ops/filter.d.ts.map +1 -0
- package/dist/ops/filter.js +53 -0
- package/dist/ops/filter.js.map +1 -0
- package/dist/ops/get-dominant-colors.d.ts +3 -0
- package/dist/ops/get-dominant-colors.d.ts.map +1 -0
- package/dist/ops/get-dominant-colors.js +48 -0
- package/dist/ops/get-dominant-colors.js.map +1 -0
- package/dist/ops/get-metadata.d.ts +3 -0
- package/dist/ops/get-metadata.d.ts.map +1 -0
- package/dist/ops/get-metadata.js +30 -0
- package/dist/ops/get-metadata.js.map +1 -0
- package/dist/ops/optimize.d.ts +3 -0
- package/dist/ops/optimize.d.ts.map +1 -0
- package/dist/ops/optimize.js +78 -0
- package/dist/ops/optimize.js.map +1 -0
- package/dist/ops/overlay.d.ts +3 -0
- package/dist/ops/overlay.d.ts.map +1 -0
- package/dist/ops/overlay.js +52 -0
- package/dist/ops/overlay.js.map +1 -0
- package/dist/ops/pad.d.ts +3 -0
- package/dist/ops/pad.d.ts.map +1 -0
- package/dist/ops/pad.js +62 -0
- package/dist/ops/pad.js.map +1 -0
- package/dist/ops/pipeline.d.ts +5 -0
- package/dist/ops/pipeline.d.ts.map +1 -0
- package/dist/ops/pipeline.js +81 -0
- package/dist/ops/pipeline.js.map +1 -0
- package/dist/ops/remove-bg.d.ts +3 -0
- package/dist/ops/remove-bg.d.ts.map +1 -0
- package/dist/ops/remove-bg.js +79 -0
- package/dist/ops/remove-bg.js.map +1 -0
- package/dist/ops/resize.d.ts +3 -0
- package/dist/ops/resize.d.ts.map +1 -0
- package/dist/ops/resize.js +54 -0
- package/dist/ops/resize.js.map +1 -0
- package/dist/ops/watermark.d.ts +3 -0
- package/dist/ops/watermark.d.ts.map +1 -0
- package/dist/ops/watermark.js +142 -0
- package/dist/ops/watermark.js.map +1 -0
- package/dist/types.d.ts +233 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/load-image.d.ts +9 -0
- package/dist/utils/load-image.d.ts.map +1 -0
- package/dist/utils/load-image.js +22 -0
- package/dist/utils/load-image.js.map +1 -0
- package/dist/utils/result.d.ts +4 -0
- package/dist/utils/result.d.ts.map +1 -0
- package/dist/utils/result.js +3 -0
- package/dist/utils/result.js.map +1 -0
- package/dist/utils/validate.d.ts +16 -0
- package/dist/utils/validate.d.ts.map +1 -0
- package/dist/utils/validate.js +20 -0
- package/dist/utils/validate.js.map +1 -0
- package/docs/AGENTS.md +18 -0
- package/docs/MCP.md +106 -0
- package/package.json +52 -0
- package/scripts/generate-fixtures.js +33 -0
- package/src/index.ts +24 -0
- package/src/mcp/index.ts +2 -0
- package/src/mcp/server.ts +21 -0
- package/src/mcp/tools.ts +276 -0
- package/src/ops/add-text.ts +139 -0
- package/src/ops/adjust.ts +68 -0
- package/src/ops/batch.ts +41 -0
- package/src/ops/blur-region.ts +58 -0
- package/src/ops/composite.ts +56 -0
- package/src/ops/convert.ts +46 -0
- package/src/ops/crop.ts +101 -0
- package/src/ops/detect-faces.ts +41 -0
- package/src/ops/detect-subject.ts +80 -0
- package/src/ops/extract-text.ts +19 -0
- package/src/ops/filter.ts +51 -0
- package/src/ops/get-dominant-colors.ts +41 -0
- package/src/ops/get-metadata.ts +28 -0
- package/src/ops/optimize.ts +77 -0
- package/src/ops/overlay.ts +51 -0
- package/src/ops/pad.ts +63 -0
- package/src/ops/pipeline.ts +61 -0
- package/src/ops/remove-bg.ts +82 -0
- package/src/ops/resize.ts +54 -0
- package/src/ops/watermark.ts +141 -0
- package/src/types/color-thief-node.d.ts +4 -0
- package/src/types.ts +267 -0
- package/src/utils/load-image.ts +21 -0
- package/src/utils/result.ts +4 -0
- package/src/utils/validate.ts +21 -0
- package/tests/fixtures/logo.png +0 -0
- package/tests/fixtures/sample.jpg +0 -0
- package/tests/fixtures/sample.png +0 -0
- package/tests/fixtures/sample.webp +0 -0
- package/tests/integration/error-handling.test.ts +22 -0
- package/tests/integration/load-image.test.ts +45 -0
- package/tests/unit/add-text.test.ts +56 -0
- package/tests/unit/adjust.test.ts +81 -0
- package/tests/unit/batch.test.ts +38 -0
- package/tests/unit/blur-region.test.ts +52 -0
- package/tests/unit/composite.test.ts +58 -0
- package/tests/unit/convert.test.ts +55 -0
- package/tests/unit/crop.test.ts +100 -0
- package/tests/unit/detect-faces.test.ts +32 -0
- package/tests/unit/detect-subject.test.ts +37 -0
- package/tests/unit/extract-text.test.ts +34 -0
- package/tests/unit/filter.test.ts +39 -0
- package/tests/unit/get-dominant-colors.test.ts +25 -0
- package/tests/unit/get-metadata.test.ts +36 -0
- package/tests/unit/mcp.test.ts +104 -0
- package/tests/unit/optimize.test.ts +47 -0
- package/tests/unit/overlay.test.ts +39 -0
- package/tests/unit/pad.test.ts +56 -0
- package/tests/unit/pipeline.test.ts +48 -0
- package/tests/unit/remove-bg.test.ts +42 -0
- package/tests/unit/resize.test.ts +70 -0
- package/tests/unit/watermark.test.ts +54 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +27 -0
package/docs/MCP.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# MCP Tools Reference
|
|
2
|
+
|
|
3
|
+
This package exposes 17 Model Context Protocol (MCP) tools for AI agents.
|
|
4
|
+
|
|
5
|
+
## Exposed Tools
|
|
6
|
+
|
|
7
|
+
### image_crop
|
|
8
|
+
**Description**: Crops an image. Supports absolute coords, ratio, aspect ratio, or subject mode.
|
|
9
|
+
**Parameters**:
|
|
10
|
+
- `image` (string, required): Base64 data URI, HTTP URL, or local path
|
|
11
|
+
- `mode` (string): 'absolute' | 'ratio' | 'aspect' | 'subject'
|
|
12
|
+
- `x`, `y`, `width`, `height` (number)
|
|
13
|
+
- `left`, `top`, `right`, `bottom` (number)
|
|
14
|
+
- `aspectRatio` (string), `anchor` (string)
|
|
15
|
+
|
|
16
|
+
### image_resize
|
|
17
|
+
**Description**: Resizes an image. Specify width/height or a scale multiplier.
|
|
18
|
+
**Parameters**:
|
|
19
|
+
- `image` (string, required)
|
|
20
|
+
- `width`, `height`, `scale` (number)
|
|
21
|
+
- `fit` (string): 'cover' | 'contain' | 'fill' | 'inside' | 'outside'
|
|
22
|
+
|
|
23
|
+
### image_pad
|
|
24
|
+
**Description**: Pads an image edges. Supports absolute edges or square target size with custom color.
|
|
25
|
+
**Parameters**:
|
|
26
|
+
- `image` (string, required)
|
|
27
|
+
- `top`, `right`, `bottom`, `left` (number)
|
|
28
|
+
- `size` (number)
|
|
29
|
+
- `color` (string)
|
|
30
|
+
|
|
31
|
+
### image_adjust
|
|
32
|
+
**Description**: Adjusts brightness, contrast, saturation, hue, sharpness, and temperature (-100 to 100).
|
|
33
|
+
**Parameters**:
|
|
34
|
+
- `image` (string, required)
|
|
35
|
+
- `brightness`, `contrast`, `saturation`, `hue`, `sharpness`, `temperature` (number)
|
|
36
|
+
|
|
37
|
+
### image_filter
|
|
38
|
+
**Description**: Applies preset filters: grayscale, sepia, invert, vintage, unsharp, or blur with radius.
|
|
39
|
+
**Parameters**:
|
|
40
|
+
- `image` (string, required)
|
|
41
|
+
- `preset` (string, required): 'grayscale' | 'sepia' | 'invert' | 'vintage' | 'unsharp' | 'blur'
|
|
42
|
+
- `radius` (number)
|
|
43
|
+
|
|
44
|
+
### image_blur_region
|
|
45
|
+
**Description**: Blurs specific absolute regions in the image.
|
|
46
|
+
**Parameters**:
|
|
47
|
+
- `image` (string, required)
|
|
48
|
+
- `regions` (array of objects)
|
|
49
|
+
|
|
50
|
+
### image_add_text
|
|
51
|
+
**Description**: Adds text layers. Requires x, y, text, font size, and optional alignment parameters.
|
|
52
|
+
**Parameters**:
|
|
53
|
+
- `image` (string, required)
|
|
54
|
+
- `layers` (array of objects)
|
|
55
|
+
|
|
56
|
+
### image_composite
|
|
57
|
+
**Description**: Composites images together with blend modes.
|
|
58
|
+
**Parameters**:
|
|
59
|
+
- `image` (string, required)
|
|
60
|
+
- `layers` (array of objects)
|
|
61
|
+
|
|
62
|
+
### image_watermark
|
|
63
|
+
**Description**: Applies watermarks either text or image at discrete positions or tiled.
|
|
64
|
+
**Parameters**:
|
|
65
|
+
- `image` (string, required)
|
|
66
|
+
- `type` (string, required): 'text' | 'image'
|
|
67
|
+
- `text`, `imageLayer`, `position`, `opacity`
|
|
68
|
+
|
|
69
|
+
### image_remove_bg
|
|
70
|
+
**Description**: Removes background using AI model RMBG-1.4. Optionally replaces with color or image.
|
|
71
|
+
**Parameters**:
|
|
72
|
+
- `image` (string, required)
|
|
73
|
+
- `replaceColor`, `replaceImage` (string)
|
|
74
|
+
|
|
75
|
+
### image_convert
|
|
76
|
+
**Description**: Converts image to jpeg, png, webp, avif, or gif.
|
|
77
|
+
**Parameters**:
|
|
78
|
+
- `image` (string, required)
|
|
79
|
+
- `format` (string, required)
|
|
80
|
+
- `quality` (number), `stripMetadata` (boolean)
|
|
81
|
+
|
|
82
|
+
### image_optimize
|
|
83
|
+
**Description**: Optimizes an image to fit max size in KB or max dimension.
|
|
84
|
+
**Parameters**:
|
|
85
|
+
- `image` (string, required)
|
|
86
|
+
- `maxSizeKB`, `maxDimension` (number), `autoFormat` (boolean)
|
|
87
|
+
|
|
88
|
+
### image_get_metadata
|
|
89
|
+
**Description**: Returns image metadata.
|
|
90
|
+
**Parameters**: `image` (string, required)
|
|
91
|
+
|
|
92
|
+
### image_get_dominant_colors
|
|
93
|
+
**Description**: Returns hex values of the primary colors.
|
|
94
|
+
**Parameters**: `image` (string, required), `count` (number)
|
|
95
|
+
|
|
96
|
+
### image_detect_faces
|
|
97
|
+
**Description**: Detects bounding boxes around faces.
|
|
98
|
+
**Parameters**: `image` (string, required)
|
|
99
|
+
|
|
100
|
+
### image_extract_text
|
|
101
|
+
**Description**: Runs OCR to extract text.
|
|
102
|
+
**Parameters**: `image` (string, required), `lang` (string)
|
|
103
|
+
|
|
104
|
+
### image_pipeline
|
|
105
|
+
**Description**: Runs a sequence of operations seamlessly.
|
|
106
|
+
**Parameters**: `image` (string, required), `operations` (array of objects)
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "image-edit-tools",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Deterministic image editing SDK for AI agents. Ships with MCP tools.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"image-edit-tools": "./dist/mcp/server.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./dist/index.js",
|
|
13
|
+
"./mcp": "./dist/mcp/server.js"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"test:watch": "vitest",
|
|
19
|
+
"test:coverage": "vitest run --coverage",
|
|
20
|
+
"test:unit": "vitest run tests/unit",
|
|
21
|
+
"test:integration": "vitest run tests/integration",
|
|
22
|
+
"test:e2e": "vitest run tests/e2e",
|
|
23
|
+
"docs": "typedoc --out docs/api src/index.ts",
|
|
24
|
+
"prepublishOnly": "npm run build && npm run test:coverage"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.1.0",
|
|
28
|
+
"@xenova/transformers": "^2.17.2",
|
|
29
|
+
"color-thief-node": "^1.0.4",
|
|
30
|
+
"node-fetch": "^3.3.2",
|
|
31
|
+
"sharp": "^0.33.5",
|
|
32
|
+
"tesseract.js": "^5.1.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^20.14.2",
|
|
36
|
+
"@vitest/coverage-v8": "^1.6.0",
|
|
37
|
+
"tsx": "^4.21.0",
|
|
38
|
+
"typedoc": "^0.25.13",
|
|
39
|
+
"typescript": "^5.4.5",
|
|
40
|
+
"vitest": "^1.6.0"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"image",
|
|
44
|
+
"editing",
|
|
45
|
+
"mcp",
|
|
46
|
+
"ai-agent",
|
|
47
|
+
"claude",
|
|
48
|
+
"sharp",
|
|
49
|
+
"typescript"
|
|
50
|
+
],
|
|
51
|
+
"license": "MIT"
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const fixDir = path.join(__dirname, '../tests/fixtures');
|
|
9
|
+
|
|
10
|
+
fs.mkdirSync(fixDir, { recursive: true });
|
|
11
|
+
|
|
12
|
+
async function run() {
|
|
13
|
+
await sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 255, g: 0, b: 0 } } })
|
|
14
|
+
.jpeg()
|
|
15
|
+
.toFile(path.join(fixDir, 'sample.jpg'));
|
|
16
|
+
|
|
17
|
+
await sharp({ create: { width: 400, height: 300, channels: 4, background: { r: 0, g: 255, b: 0, alpha: 0.5 } } })
|
|
18
|
+
.png()
|
|
19
|
+
.toFile(path.join(fixDir, 'sample.png'));
|
|
20
|
+
|
|
21
|
+
await sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 0, g: 0, b: 255 } } })
|
|
22
|
+
.webp()
|
|
23
|
+
.toFile(path.join(fixDir, 'sample.webp'));
|
|
24
|
+
|
|
25
|
+
await sharp({ create: { width: 100, height: 100, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
|
|
26
|
+
.composite([{ input: Buffer.from('<svg><circle cx="50" cy="50" r="40" fill="red" /></svg>'), left: 0, top: 0 }])
|
|
27
|
+
.png()
|
|
28
|
+
.toFile(path.join(fixDir, 'logo.png'));
|
|
29
|
+
|
|
30
|
+
console.log('Fixtures generated successfully.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
run().catch(console.error);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './utils/result.js';
|
|
3
|
+
export * from './utils/load-image.js';
|
|
4
|
+
|
|
5
|
+
export { crop } from './ops/crop.js';
|
|
6
|
+
export { resize } from './ops/resize.js';
|
|
7
|
+
export { pad } from './ops/pad.js';
|
|
8
|
+
export { adjust } from './ops/adjust.js';
|
|
9
|
+
export { filter } from './ops/filter.js';
|
|
10
|
+
export { blurRegion } from './ops/blur-region.js';
|
|
11
|
+
export { addText } from './ops/add-text.js';
|
|
12
|
+
export { composite } from './ops/composite.js';
|
|
13
|
+
export { watermark } from './ops/watermark.js';
|
|
14
|
+
export { overlay } from './ops/overlay.js';
|
|
15
|
+
export { removeBg } from './ops/remove-bg.js';
|
|
16
|
+
export { detectSubject } from './ops/detect-subject.js';
|
|
17
|
+
export { convert } from './ops/convert.js';
|
|
18
|
+
export { optimize } from './ops/optimize.js';
|
|
19
|
+
export { getMetadata } from './ops/get-metadata.js';
|
|
20
|
+
export { getDominantColors } from './ops/get-dominant-colors.js';
|
|
21
|
+
export { detectFaces } from './ops/detect-faces.js';
|
|
22
|
+
export { extractText } from './ops/extract-text.js';
|
|
23
|
+
export { pipeline } from './ops/pipeline.js';
|
|
24
|
+
export { batch } from './ops/batch.js';
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
5
|
+
import { allTools, handleTool } from './tools.js'
|
|
6
|
+
|
|
7
|
+
const server = new Server(
|
|
8
|
+
{ name: 'image-edit-tools', version: '1.0.0' },
|
|
9
|
+
{ capabilities: { tools: {} } }
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }))
|
|
13
|
+
|
|
14
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
15
|
+
const { name, arguments: args } = req.params
|
|
16
|
+
const text = await handleTool(name, args ?? {})
|
|
17
|
+
return { content: [{ type: 'text', text }] }
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const transport = new StdioServerTransport()
|
|
21
|
+
await server.connect(transport)
|
package/src/mcp/tools.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import * as api from '../index.js';
|
|
3
|
+
import { ImageInput } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export const allTools: Tool[] = [
|
|
6
|
+
{
|
|
7
|
+
name: 'image_crop',
|
|
8
|
+
description: 'Crops an image. Supports absolute coords, ratio, aspect ratio, or subject mode.',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
image: { type: 'string', description: 'Base64 data URI, HTTP URL, or local path' },
|
|
13
|
+
mode: { type: 'string', enum: ['absolute', 'ratio', 'aspect', 'subject'] },
|
|
14
|
+
x: { type: 'number' }, y: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' },
|
|
15
|
+
left: { type: 'number' }, top: { type: 'number' }, right: { type: 'number' }, bottom: { type: 'number' },
|
|
16
|
+
aspectRatio: { type: 'string' }, anchor: { type: 'string' }
|
|
17
|
+
},
|
|
18
|
+
required: ['image']
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'image_resize',
|
|
23
|
+
description: 'Resizes an image. Specify width/height or a scale multiplier.',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
image: { type: 'string' },
|
|
28
|
+
width: { type: 'number' }, height: { type: 'number' }, scale: { type: 'number' },
|
|
29
|
+
fit: { type: 'string', enum: ['cover', 'contain', 'fill', 'inside', 'outside'] },
|
|
30
|
+
kernel: { type: 'string' }
|
|
31
|
+
},
|
|
32
|
+
required: ['image']
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'image_pad',
|
|
37
|
+
description: 'Pads an image edges. Supports absolute edges or square target size with custom color.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
image: { type: 'string' },
|
|
42
|
+
top: { type: 'number' }, right: { type: 'number' }, bottom: { type: 'number' }, left: { type: 'number' },
|
|
43
|
+
size: { type: 'number' }, color: { type: 'string' }
|
|
44
|
+
},
|
|
45
|
+
required: ['image']
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'image_adjust',
|
|
50
|
+
description: 'Adjusts brightness, contrast, saturation, hue, sharpness, and temperature (-100 to 100).',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
image: { type: 'string' },
|
|
55
|
+
brightness: { type: 'number', description: 'Range: -100 to 100' },
|
|
56
|
+
contrast: { type: 'number', description: 'Range: -100 to 100' },
|
|
57
|
+
saturation: { type: 'number', description: 'Range: -100 to 100' },
|
|
58
|
+
hue: { type: 'number', description: 'Range: 0 to 360' },
|
|
59
|
+
sharpness: { type: 'number', description: 'Range: 0 to 100' },
|
|
60
|
+
temperature: { type: 'number', description: 'Range: -100 to 100' }
|
|
61
|
+
},
|
|
62
|
+
required: ['image']
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'image_filter',
|
|
67
|
+
description: 'Applies preset filters: grayscale, sepia, invert, vintage, unsharp, or blur with radius.',
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
image: { type: 'string' },
|
|
72
|
+
preset: { type: 'string', enum: ['grayscale', 'sepia', 'invert', 'vintage', 'unsharp', 'blur'] },
|
|
73
|
+
radius: { type: 'number' }
|
|
74
|
+
},
|
|
75
|
+
required: ['image', 'preset']
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'image_blur_region',
|
|
80
|
+
description: 'Blurs specific absolute regions in the image.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
image: { type: 'string' },
|
|
85
|
+
regions: {
|
|
86
|
+
type: 'array',
|
|
87
|
+
items: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
x: { type: 'number' }, y: { type: 'number' },
|
|
91
|
+
width: { type: 'number' }, height: { type: 'number' },
|
|
92
|
+
radius: { type: 'number', description: 'Blur radius' }
|
|
93
|
+
},
|
|
94
|
+
required: ['x', 'y', 'width', 'height']
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
required: ['image']
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'image_add_text',
|
|
103
|
+
description: 'Adds text layers. Requires x, y, text, font size, and optional alignment parameters.',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
image: { type: 'string' },
|
|
108
|
+
layers: { type: 'array', items: { type: 'object' } }
|
|
109
|
+
},
|
|
110
|
+
required: ['image']
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'image_composite',
|
|
115
|
+
description: 'Composites images together with blend modes.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
image: { type: 'string' },
|
|
120
|
+
layers: { type: 'array', items: { type: 'object' } }
|
|
121
|
+
},
|
|
122
|
+
required: ['image']
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'image_watermark',
|
|
127
|
+
description: 'Applies watermarks either text or image at discrete positions or tiled.',
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: {
|
|
131
|
+
image: { type: 'string' },
|
|
132
|
+
type: { type: 'string', enum: ['text', 'image'] },
|
|
133
|
+
text: { type: 'string' }, imageLayer: { type: 'string' },
|
|
134
|
+
position: { type: 'string' }, opacity: { type: 'number' }
|
|
135
|
+
},
|
|
136
|
+
required: ['image', 'type']
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'image_remove_bg',
|
|
141
|
+
description: 'Removes background using AI model RMBG-1.4. Optionally replaces with color or image.',
|
|
142
|
+
inputSchema: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
image: { type: 'string' },
|
|
146
|
+
replaceColor: { type: 'string' },
|
|
147
|
+
replaceImage: { type: 'string' }
|
|
148
|
+
},
|
|
149
|
+
required: ['image']
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'image_convert',
|
|
154
|
+
description: 'Converts image to jpeg, png, webp, avif, or gif.',
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties: {
|
|
158
|
+
image: { type: 'string' },
|
|
159
|
+
format: { type: 'string', enum: ['jpeg', 'png', 'webp', 'avif', 'gif'] },
|
|
160
|
+
quality: { type: 'number' },
|
|
161
|
+
stripMetadata: { type: 'boolean' }
|
|
162
|
+
},
|
|
163
|
+
required: ['image', 'format']
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'image_optimize',
|
|
168
|
+
description: 'Optimizes an image to fit max size in KB or max dimension.',
|
|
169
|
+
inputSchema: {
|
|
170
|
+
type: 'object',
|
|
171
|
+
properties: {
|
|
172
|
+
image: { type: 'string' },
|
|
173
|
+
maxSizeKB: { type: 'number' }, maxDimension: { type: 'number' }, autoFormat: { type: 'boolean' }
|
|
174
|
+
},
|
|
175
|
+
required: ['image']
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'image_get_metadata',
|
|
180
|
+
description: 'Returns image metadata like dimensions, format, hasAlpha.',
|
|
181
|
+
inputSchema: { type: 'object', properties: { image: { type: 'string' } }, required: ['image'] }
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'image_get_dominant_colors',
|
|
185
|
+
description: 'Returns hex values of the primary colors in the image.',
|
|
186
|
+
inputSchema: { type: 'object', properties: { image: { type: 'string' }, count: { type: 'number' } }, required: ['image'] }
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'image_detect_faces',
|
|
190
|
+
description: 'Detects bounding boxes around faces/people in the image using AI.',
|
|
191
|
+
inputSchema: { type: 'object', properties: { image: { type: 'string' } }, required: ['image'] }
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'image_extract_text',
|
|
195
|
+
description: 'Runs OCR using tesseract.js to extract text from the image.',
|
|
196
|
+
inputSchema: { type: 'object', properties: { image: { type: 'string' }, lang: { type: 'string' } }, required: ['image'] }
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'image_pipeline',
|
|
200
|
+
description: 'Runs a sequence of operations on the image seamlessly in memory.',
|
|
201
|
+
inputSchema: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
properties: {
|
|
204
|
+
image: { type: 'string' },
|
|
205
|
+
operations: { type: 'array', items: { type: 'object' } }
|
|
206
|
+
},
|
|
207
|
+
required: ['image', 'operations']
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'image_batch',
|
|
212
|
+
description: 'Runs a single operation on multiple images.',
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
images: { type: 'array', items: { type: 'string' } },
|
|
217
|
+
operation: { type: 'string' },
|
|
218
|
+
options: { type: 'object' }
|
|
219
|
+
},
|
|
220
|
+
required: ['images', 'operation', 'options']
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
export async function handleTool(name: string, args: Record<string, any>): Promise<string> {
|
|
226
|
+
const image = args.image as ImageInput;
|
|
227
|
+
let result: any;
|
|
228
|
+
|
|
229
|
+
if (name === 'image_pipeline') result = await api.pipeline(image, args.operations);
|
|
230
|
+
else if (name === 'image_batch') result = await api.batch(args.images, args.operation, args.options);
|
|
231
|
+
else if (name === 'image_crop') result = await api.crop(image, args as any);
|
|
232
|
+
else if (name === 'image_resize') result = await api.resize(image, args as any);
|
|
233
|
+
else if (name === 'image_pad') result = await api.pad(image, args as any);
|
|
234
|
+
else if (name === 'image_adjust') result = await api.adjust(image, args as any);
|
|
235
|
+
else if (name === 'image_filter') result = await api.filter(image, args as any);
|
|
236
|
+
else if (name === 'image_blur_region') result = await api.blurRegion(image, args as any);
|
|
237
|
+
else if (name === 'image_add_text') result = await api.addText(image, args as any);
|
|
238
|
+
else if (name === 'image_composite') result = await api.composite(image, args as any);
|
|
239
|
+
else if (name === 'image_watermark') {
|
|
240
|
+
const opts = { ...args };
|
|
241
|
+
if (opts.type === 'image' && opts.imageLayer) opts.image = opts.imageLayer;
|
|
242
|
+
result = await api.watermark(image, opts as any);
|
|
243
|
+
}
|
|
244
|
+
else if (name === 'image_remove_bg') result = await api.removeBg(image, args as any);
|
|
245
|
+
else if (name === 'image_convert') result = await api.convert(image, args as any);
|
|
246
|
+
else if (name === 'image_optimize') result = await api.optimize(image, args as any);
|
|
247
|
+
else if (name === 'image_get_metadata') {
|
|
248
|
+
const meta = await api.getMetadata(image);
|
|
249
|
+
return JSON.stringify(meta.ok ? meta.data : { error: meta.error, code: meta.code });
|
|
250
|
+
}
|
|
251
|
+
else if (name === 'image_get_dominant_colors') {
|
|
252
|
+
const cols = await api.getDominantColors(image, args.count);
|
|
253
|
+
return JSON.stringify(cols);
|
|
254
|
+
}
|
|
255
|
+
else if (name === 'image_detect_faces') {
|
|
256
|
+
const faces = await api.detectFaces(image);
|
|
257
|
+
return JSON.stringify(faces);
|
|
258
|
+
}
|
|
259
|
+
else if (name === 'image_extract_text') {
|
|
260
|
+
const txt = await api.extractText(image, args);
|
|
261
|
+
return JSON.stringify(txt);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
return JSON.stringify({ error: `Tool ${name} not implemented`, code: 'INVALID_INPUT' });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (result && !result.ok) {
|
|
268
|
+
return JSON.stringify({ error: result.error, code: result.code });
|
|
269
|
+
} else if (result && Buffer.isBuffer(result.data)) {
|
|
270
|
+
const fmt = args.format || 'png';
|
|
271
|
+
const b64 = result.data.toString('base64');
|
|
272
|
+
return JSON.stringify({ ok: true, data: `data:image/${fmt};base64,${b64}` });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return JSON.stringify(result);
|
|
276
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { TextLayer, ImageInput, ImageResult, ErrorCode, TextAnchor } from '../types.js';
|
|
3
|
+
import { loadImage } from '../utils/load-image.js';
|
|
4
|
+
import { err, ok } from '../utils/result.js';
|
|
5
|
+
import { getImageMetadata } from '../utils/validate.js';
|
|
6
|
+
|
|
7
|
+
function wrapText(text: string, fontSize: number, maxWidth?: number): string[] {
|
|
8
|
+
if (!maxWidth) return [text];
|
|
9
|
+
const charWidth = fontSize * 0.6; // Approximation
|
|
10
|
+
const maxChars = Math.max(1, Math.floor(maxWidth / charWidth));
|
|
11
|
+
const words = text.split(' ');
|
|
12
|
+
const lines: string[] = [];
|
|
13
|
+
let currentLine = '';
|
|
14
|
+
|
|
15
|
+
for (const word of words) {
|
|
16
|
+
if ((currentLine + ' ' + word).trim().length <= maxChars) {
|
|
17
|
+
currentLine = (currentLine + ' ' + word).trim();
|
|
18
|
+
} else {
|
|
19
|
+
if (currentLine) lines.push(currentLine);
|
|
20
|
+
currentLine = word;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (currentLine) lines.push(currentLine);
|
|
24
|
+
return lines;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getAnchorProps(anchor: TextAnchor = 'top-left'): { textAnchor: string, dominantBaseline: string } {
|
|
28
|
+
const parts = anchor.split('-');
|
|
29
|
+
const yAlign = parts.length === 2 ? parts[0] : parts[0] === 'center' ? 'middle' : parts[0];
|
|
30
|
+
const xAlign = parts.length === 2 ? parts[1] : parts[0] === 'center' ? 'center' : 'left';
|
|
31
|
+
|
|
32
|
+
let dominantBaseline = 'hanging'; // top
|
|
33
|
+
if (yAlign === 'bottom') dominantBaseline = 'auto'; // bottom is harder, usually means using y directly on baseline, but we can do mathematical offset. We'll rely on SVG baselines.
|
|
34
|
+
else if (yAlign === 'middle' || yAlign === 'center') dominantBaseline = 'middle';
|
|
35
|
+
else if (yAlign === 'auto') dominantBaseline = 'auto';
|
|
36
|
+
// Sharp's librsvg supports dominant-baseline: text-before-edge (top), middle, alphabetic (bottom)
|
|
37
|
+
const baselineMap: Record<string, string> = { top: 'text-before-edge', middle: 'middle', bottom: 'alphabetic', center: 'middle' };
|
|
38
|
+
|
|
39
|
+
let textAnchor = 'start';
|
|
40
|
+
if (xAlign === 'center') textAnchor = 'middle';
|
|
41
|
+
else if (xAlign === 'right') textAnchor = 'end';
|
|
42
|
+
|
|
43
|
+
return { textAnchor, dominantBaseline: baselineMap[yAlign] || 'text-before-edge' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function addText(input: ImageInput, options: { layers: TextLayer[] }): Promise<ImageResult> {
|
|
47
|
+
try {
|
|
48
|
+
const buffer = await loadImage(input);
|
|
49
|
+
const meta = await getImageMetadata(buffer);
|
|
50
|
+
|
|
51
|
+
if (!options.layers || !Array.isArray(options.layers)) {
|
|
52
|
+
return err('Layers array required', ErrorCode.INVALID_INPUT);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { width, height } = meta;
|
|
56
|
+
|
|
57
|
+
let defs = '';
|
|
58
|
+
let svgBody = '';
|
|
59
|
+
let fontImports = new Set<string>();
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < options.layers.length; i++) {
|
|
62
|
+
const layer = options.layers[i];
|
|
63
|
+
const fontSize = layer.fontSize ?? 24;
|
|
64
|
+
const color = layer.color ?? '#000000';
|
|
65
|
+
const opacity = layer.opacity ?? 1.0;
|
|
66
|
+
const fontFamily = layer.fontFamily ?? 'sans-serif';
|
|
67
|
+
if (layer.fontUrl) fontImports.add(`@import url('${layer.fontUrl}');`);
|
|
68
|
+
|
|
69
|
+
const lines = wrapText(layer.text, fontSize, layer.maxWidth);
|
|
70
|
+
const lineHeight = layer.lineHeight ?? 1.2;
|
|
71
|
+
const totalHeight = lines.length * fontSize * lineHeight;
|
|
72
|
+
const approxMaxWidth = Math.max(...lines.map(l => l.length * fontSize * 0.6));
|
|
73
|
+
|
|
74
|
+
const { textAnchor, dominantBaseline } = getAnchorProps(layer.anchor);
|
|
75
|
+
|
|
76
|
+
let align = textAnchor;
|
|
77
|
+
if (layer.align) {
|
|
78
|
+
align = layer.align === 'left' ? 'start' : layer.align === 'right' ? 'end' : 'middle';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const style = `font-family: ${fontFamily}; font-size: ${fontSize}px; fill: ${color}; opacity: ${opacity}; text-anchor: ${align}; dominant-baseline: ${dominantBaseline};`;
|
|
82
|
+
|
|
83
|
+
let layerSvg = '';
|
|
84
|
+
|
|
85
|
+
if (layer.background) {
|
|
86
|
+
const bg = layer.background;
|
|
87
|
+
const pad = bg.padding ?? 0;
|
|
88
|
+
const bgOpacity = bg.opacity ?? 1.0;
|
|
89
|
+
const radius = bg.borderRadius ?? 0;
|
|
90
|
+
|
|
91
|
+
let rectX = layer.x - pad;
|
|
92
|
+
let rectY = layer.y - pad;
|
|
93
|
+
|
|
94
|
+
if (textAnchor === 'middle') {
|
|
95
|
+
rectX = layer.x - (approxMaxWidth / 2) - pad;
|
|
96
|
+
} else if (textAnchor === 'end') {
|
|
97
|
+
rectX = layer.x - approxMaxWidth - pad;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (dominantBaseline === 'middle') {
|
|
101
|
+
rectY = layer.y - (totalHeight / 2) - pad;
|
|
102
|
+
} else if (dominantBaseline === 'alphabetic') { // bottom
|
|
103
|
+
rectY = layer.y - totalHeight - pad + fontSize;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
layerSvg += `<rect x="${rectX}" y="${rectY}" width="${approxMaxWidth + pad * 2}" height="${totalHeight + pad * 2}" fill="${bg.color}" opacity="${bgOpacity}" rx="${radius}" ry="${radius}" />`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
layerSvg += `<text x="${layer.x}" y="${layer.y}" style="${style}">`;
|
|
110
|
+
lines.forEach((line, idx) => {
|
|
111
|
+
let dy = idx === 0 ? 0 : fontSize * lineHeight;
|
|
112
|
+
layerSvg += `<tspan x="${layer.x}" dy="${dy}">${line}</tspan>`;
|
|
113
|
+
});
|
|
114
|
+
layerSvg += `</text>`;
|
|
115
|
+
|
|
116
|
+
svgBody += `<g style="isolation: isolate">${layerSvg}</g>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const fontStyle = fontImports.size > 0 ? `<style>${Array.from(fontImports).join('\n')}</style>` : '';
|
|
120
|
+
|
|
121
|
+
const svgString = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
122
|
+
${fontStyle}
|
|
123
|
+
${defs}
|
|
124
|
+
${svgBody}
|
|
125
|
+
</svg>`;
|
|
126
|
+
|
|
127
|
+
const output = await sharp(buffer)
|
|
128
|
+
.composite([{ input: Buffer.from(svgString), blend: 'over' }])
|
|
129
|
+
.toBuffer();
|
|
130
|
+
|
|
131
|
+
return ok(output);
|
|
132
|
+
} catch (e: any) {
|
|
133
|
+
const msg = e.message || '';
|
|
134
|
+
if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
|
|
135
|
+
if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
|
|
136
|
+
if (msg.includes('unsupported image format')) return err('Corrupt or unsupported input', ErrorCode.INVALID_INPUT);
|
|
137
|
+
return err(msg, ErrorCode.PROCESSING_FAILED);
|
|
138
|
+
}
|
|
139
|
+
}
|