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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { convert } from '../../src/ops/convert.js'
|
|
6
|
+
import sharp from 'sharp'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = dirname(__filename)
|
|
10
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
11
|
+
|
|
12
|
+
describe('convert', () => {
|
|
13
|
+
let sampleJpeg: Buffer
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
sampleJpeg = fixture('sample.jpg')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('converts to jpeg with quality', async () => {
|
|
20
|
+
const result = await convert(sampleJpeg, { format: 'jpeg', quality: 50 })
|
|
21
|
+
expect(result.ok).toBe(true)
|
|
22
|
+
if (!result.ok) return
|
|
23
|
+
const meta = await sharp(result.data).metadata()
|
|
24
|
+
expect(meta.format).toBe('jpeg')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('converts to png with compressionLevel', async () => {
|
|
28
|
+
const result = await convert(sampleJpeg, { format: 'png', compressionLevel: 9 })
|
|
29
|
+
expect(result.ok).toBe(true)
|
|
30
|
+
if (!result.ok) return
|
|
31
|
+
const meta = await sharp(result.data).metadata()
|
|
32
|
+
expect(meta.format).toBe('png')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('converts to webp', async () => {
|
|
36
|
+
const result = await convert(sampleJpeg, { format: 'webp' })
|
|
37
|
+
expect(result.ok).toBe(true)
|
|
38
|
+
if (!result.ok) return
|
|
39
|
+
const meta = await sharp(result.data).metadata()
|
|
40
|
+
expect(meta.format).toBe('webp')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('converts to avif', async () => {
|
|
44
|
+
const result = await convert(sampleJpeg, { format: 'avif' })
|
|
45
|
+
expect(result.ok).toBe(true)
|
|
46
|
+
if (!result.ok) return
|
|
47
|
+
const meta = await sharp(result.data).metadata()
|
|
48
|
+
expect(meta.format).toBe('heif')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('preserves metadata when stripMetadata is false', async () => {
|
|
52
|
+
const result = await convert(sampleJpeg, { format: 'jpeg', stripMetadata: false })
|
|
53
|
+
expect(result.ok).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { crop } from '../../src/ops/crop.js'
|
|
6
|
+
import sharp from 'sharp'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = dirname(__filename)
|
|
10
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
11
|
+
|
|
12
|
+
describe('crop', () => {
|
|
13
|
+
let sampleJpeg: Buffer
|
|
14
|
+
let samplePng: Buffer
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
sampleJpeg = fixture('sample.jpg') // 400×300
|
|
18
|
+
samplePng = fixture('sample.png') // 400×300 with alpha
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// ── Absolute mode ──────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
it('crops to exact pixel coordinates', async () => {
|
|
24
|
+
const result = await crop(sampleJpeg, { mode: 'absolute', x: 10, y: 10, width: 100, height: 80 })
|
|
25
|
+
expect(result.ok).toBe(true)
|
|
26
|
+
if (!result.ok) return
|
|
27
|
+
const meta = await sharp(result.data).metadata()
|
|
28
|
+
expect(meta.width).toBe(100)
|
|
29
|
+
expect(meta.height).toBe(80)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('preserves alpha channel when cropping a PNG', async () => {
|
|
33
|
+
const result = await crop(samplePng, { mode: 'absolute', x: 0, y: 0, width: 200, height: 200 })
|
|
34
|
+
expect(result.ok).toBe(true)
|
|
35
|
+
if (!result.ok) return
|
|
36
|
+
const meta = await sharp(result.data).metadata()
|
|
37
|
+
expect(meta.hasAlpha).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// ── Ratio mode ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
it('crops by ratio from each edge', async () => {
|
|
43
|
+
const result = await crop(sampleJpeg, {
|
|
44
|
+
mode: 'ratio', left: 0.1, top: 0.1, right: 0.1, bottom: 0.1
|
|
45
|
+
})
|
|
46
|
+
expect(result.ok).toBe(true)
|
|
47
|
+
if (!result.ok) return
|
|
48
|
+
// 400 * 0.1 = 40, so width = 400 - 80 = 320
|
|
49
|
+
const meta = await sharp(result.data).metadata()
|
|
50
|
+
expect(meta.width).toBe(320)
|
|
51
|
+
expect(meta.height).toBe(240)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// ── Aspect mode ────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
it('crops to 16:9 aspect ratio centered', async () => {
|
|
57
|
+
const result = await crop(sampleJpeg, {
|
|
58
|
+
mode: 'aspect', aspectRatio: '16:9', anchor: 'center'
|
|
59
|
+
})
|
|
60
|
+
expect(result.ok).toBe(true)
|
|
61
|
+
if (!result.ok) return
|
|
62
|
+
const meta = await sharp(result.data).metadata()
|
|
63
|
+
expect(meta.width! / meta.height!).toBeCloseTo(16 / 9, 1)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ── Error paths ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
it('returns error when crop region exceeds image bounds', async () => {
|
|
69
|
+
const result = await crop(sampleJpeg, { mode: 'absolute', x: 0, y: 0, width: 9999, height: 9999 })
|
|
70
|
+
expect(result.ok).toBe(false)
|
|
71
|
+
if (result.ok) return
|
|
72
|
+
expect(result.code).toBe('OUT_OF_BOUNDS')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns MODEL_NOT_FOUND for unimplemented subject mode', async () => {
|
|
76
|
+
const result = await crop(sampleJpeg, { mode: 'subject' })
|
|
77
|
+
expect(result.ok).toBe(false)
|
|
78
|
+
if (result.ok) return
|
|
79
|
+
expect(result.code).toBe('MODEL_NOT_FOUND')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('returns error for corrupt input buffer', async () => {
|
|
83
|
+
const result = await crop(Buffer.from('not an image'), { mode: 'absolute', x: 0, y: 0, width: 10, height: 10 })
|
|
84
|
+
expect(result.ok).toBe(false)
|
|
85
|
+
if (result.ok) return
|
|
86
|
+
expect(result.code).toBe('INVALID_INPUT')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('accepts file path as input', async () => {
|
|
90
|
+
const path = join(__dirname, '../fixtures/sample.jpg')
|
|
91
|
+
const result = await crop(path, { mode: 'absolute', x: 0, y: 0, width: 100, height: 100 })
|
|
92
|
+
expect(result.ok).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('accepts base64 data URI as input', async () => {
|
|
96
|
+
const base64 = `data:image/jpeg;base64,${sampleJpeg.toString('base64')}`
|
|
97
|
+
const result = await crop(base64, { mode: 'absolute', x: 0, y: 0, width: 100, height: 100 })
|
|
98
|
+
expect(result.ok).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, vi } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { detectFaces } from '../../src/ops/detect-faces.js'
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
8
|
+
const __dirname = dirname(__filename)
|
|
9
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
10
|
+
|
|
11
|
+
vi.mock('@xenova/transformers', async (importOriginal) => {
|
|
12
|
+
return {
|
|
13
|
+
...(await importOriginal<typeof import('@xenova/transformers')>()),
|
|
14
|
+
pipeline: vi.fn().mockRejectedValue(new Error('Mock network failure'))
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('detectFaces', () => {
|
|
19
|
+
let sampleJpeg: Buffer
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
sampleJpeg = fixture('sample.jpg')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns MODEL_NOT_FOUND offline', async () => {
|
|
26
|
+
const result = await detectFaces(sampleJpeg)
|
|
27
|
+
expect(result.ok).toBe(false)
|
|
28
|
+
if (!result.ok) {
|
|
29
|
+
expect(result.code).toBe('MODEL_NOT_FOUND')
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, vi } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { detectSubject } from '../../src/ops/detect-subject.js'
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
8
|
+
const __dirname = dirname(__filename)
|
|
9
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
10
|
+
|
|
11
|
+
vi.mock('@xenova/transformers', async (importOriginal) => {
|
|
12
|
+
return {
|
|
13
|
+
...(await importOriginal<typeof import('@xenova/transformers')>()),
|
|
14
|
+
AutoModel: {
|
|
15
|
+
from_pretrained: vi.fn().mockRejectedValue(new Error('Mock network failure'))
|
|
16
|
+
},
|
|
17
|
+
AutoProcessor: {
|
|
18
|
+
from_pretrained: vi.fn()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('detectSubject', () => {
|
|
24
|
+
let sampleJpeg: Buffer
|
|
25
|
+
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
sampleJpeg = fixture('sample.jpg')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns MODEL_NOT_FOUND when model is unavailable', async () => {
|
|
31
|
+
const result = await detectSubject(sampleJpeg)
|
|
32
|
+
expect(result.ok).toBe(false)
|
|
33
|
+
if (!result.ok) {
|
|
34
|
+
expect(result.code).toBe('MODEL_NOT_FOUND')
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, vi } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { extractText } from '../../src/ops/extract-text.js'
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
8
|
+
const __dirname = dirname(__filename)
|
|
9
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
10
|
+
|
|
11
|
+
vi.mock('tesseract.js', () => {
|
|
12
|
+
return {
|
|
13
|
+
default: {
|
|
14
|
+
recognize: vi.fn().mockResolvedValue({
|
|
15
|
+
data: { text: 'Mocked text' }
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('extractText', () => {
|
|
22
|
+
let sampleJpeg: Buffer
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
sampleJpeg = fixture('sample.jpg')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('extracts text', async () => {
|
|
29
|
+
const result = await extractText(sampleJpeg)
|
|
30
|
+
expect(result.ok).toBe(true)
|
|
31
|
+
if (!result.ok) return
|
|
32
|
+
expect(result.data).toBe('Mocked text')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { filter } from '../../src/ops/filter.js'
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
8
|
+
const __dirname = dirname(__filename)
|
|
9
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
10
|
+
|
|
11
|
+
describe('filter', () => {
|
|
12
|
+
let sampleJpeg: Buffer
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
sampleJpeg = fixture('sample.jpg') // 400x300
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Test all presets
|
|
19
|
+
const presets = ['grayscale', 'sepia', 'invert', 'vintage', 'unsharp'] as const
|
|
20
|
+
for (const preset of presets) {
|
|
21
|
+
it(`applies ${preset} preset`, async () => {
|
|
22
|
+
const result = await filter(sampleJpeg, { preset })
|
|
23
|
+
expect(result.ok).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
it('applies blur preset with radius', async () => {
|
|
28
|
+
const result = await filter(sampleJpeg, { preset: 'blur', radius: 10 })
|
|
29
|
+
expect(result.ok).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('fails blur preset if radius is missing or invalid', async () => {
|
|
33
|
+
// TypeScript should stop missing radius, but casting to verify runtime validation
|
|
34
|
+
const result = await filter(sampleJpeg, { preset: 'blur', radius: -5 } as any)
|
|
35
|
+
expect(result.ok).toBe(false)
|
|
36
|
+
if (result.ok) return
|
|
37
|
+
expect(result.code).toBe('INVALID_INPUT')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { getDominantColors } from '../../src/ops/get-dominant-colors.js'
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
8
|
+
const __dirname = dirname(__filename)
|
|
9
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
10
|
+
|
|
11
|
+
describe('getDominantColors', () => {
|
|
12
|
+
let sampleJpeg: Buffer
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
sampleJpeg = fixture('sample.jpg')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns exactly count dominant colors as hex', async () => {
|
|
19
|
+
const result = await getDominantColors(sampleJpeg, 3)
|
|
20
|
+
expect(result.ok).toBe(true)
|
|
21
|
+
if (!result.ok) return
|
|
22
|
+
expect(result.data).toHaveLength(3)
|
|
23
|
+
expect(result.data[0]).toMatch(/^#[0-9a-fA-F]{6}$/)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { getMetadata } from '../../src/ops/get-metadata.js'
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
8
|
+
const __dirname = dirname(__filename)
|
|
9
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
10
|
+
|
|
11
|
+
describe('getMetadata', () => {
|
|
12
|
+
let sampleJpeg: Buffer
|
|
13
|
+
let samplePng: Buffer
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
sampleJpeg = fixture('sample.jpg')
|
|
17
|
+
samplePng = fixture('sample.png')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns metadata for jpeg', async () => {
|
|
21
|
+
const result = await getMetadata(sampleJpeg)
|
|
22
|
+
expect(result.ok).toBe(true)
|
|
23
|
+
if (!result.ok) return
|
|
24
|
+
expect(result.data.width).toBe(400)
|
|
25
|
+
expect(result.data.height).toBe(300)
|
|
26
|
+
expect(result.data.format).toBe('jpeg')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns metadata for png', async () => {
|
|
30
|
+
const result = await getMetadata(samplePng)
|
|
31
|
+
expect(result.ok).toBe(true)
|
|
32
|
+
if (!result.ok) return
|
|
33
|
+
expect(result.data.format).toBe('png')
|
|
34
|
+
expect(result.data.hasAlpha).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
const serverPath = join(__dirname, '../../src/mcp/index.ts');
|
|
10
|
+
|
|
11
|
+
describe('MCP E2E', () => {
|
|
12
|
+
let client: Client;
|
|
13
|
+
let transport: StdioClientTransport;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
// spawn node with module loader for TS or fallback to npx tsx.
|
|
17
|
+
// Vitest runs within node so we can spawn a child process.
|
|
18
|
+
transport = new StdioClientTransport({
|
|
19
|
+
command: 'npx',
|
|
20
|
+
args: ['tsx', serverPath],
|
|
21
|
+
});
|
|
22
|
+
client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} });
|
|
23
|
+
await client.connect(transport);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(async () => {
|
|
27
|
+
await transport.close();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('lists all expected tools including image_batch', async () => {
|
|
31
|
+
const response = await client.listTools();
|
|
32
|
+
const toolNames = response.tools.map((t: any) => t.name);
|
|
33
|
+
|
|
34
|
+
const expected = [
|
|
35
|
+
'image_crop', 'image_resize', 'image_pad', 'image_adjust', 'image_filter',
|
|
36
|
+
'image_blur_region', 'image_add_text', 'image_composite', 'image_watermark',
|
|
37
|
+
'image_remove_bg', 'image_convert', 'image_optimize', 'image_get_metadata',
|
|
38
|
+
'image_get_dominant_colors', 'image_detect_faces', 'image_extract_text',
|
|
39
|
+
'image_pipeline', 'image_batch'
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const name of expected) {
|
|
43
|
+
expect(toolNames).toContain(name);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('calls image_get_metadata tool successfully and unwraps result', async () => {
|
|
48
|
+
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
49
|
+
const image = `data:image/png;base64,${b64}`;
|
|
50
|
+
|
|
51
|
+
const result = await client.callTool({
|
|
52
|
+
name: 'image_get_metadata',
|
|
53
|
+
arguments: { image }
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.content).toBeDefined();
|
|
57
|
+
const content = result.content as any[];
|
|
58
|
+
expect(content[0].type).toBe('text');
|
|
59
|
+
const res = JSON.parse(content[0].text);
|
|
60
|
+
|
|
61
|
+
expect(res.width).toBe(1);
|
|
62
|
+
expect(res.height).toBe(1);
|
|
63
|
+
expect(res.format).toBe('png');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('calls image_convert tool successfully', async () => {
|
|
67
|
+
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
68
|
+
const image = `data:image/png;base64,${b64}`;
|
|
69
|
+
|
|
70
|
+
const result = await client.callTool({
|
|
71
|
+
name: 'image_convert',
|
|
72
|
+
arguments: { image, format: 'jpeg' }
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const content = result.content as any[];
|
|
76
|
+
const res = JSON.parse(content[0].text);
|
|
77
|
+
expect(res.ok).toBe(true);
|
|
78
|
+
expect(res.data).toMatch(/^data:image\/jpeg;base64,/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('handles unknown tool call gracefully by returning correct JSON error structure', async () => {
|
|
82
|
+
// The image-edit-tools MCP server currently returns standard JSON response with an error property,
|
|
83
|
+
// rather than throwing a protocol-level JSON-RPC error.
|
|
84
|
+
const result = await client.callTool({ name: 'image_unknown_tool', arguments: {} });
|
|
85
|
+
const content = result.content as any[];
|
|
86
|
+
const res = JSON.parse(content[0].text);
|
|
87
|
+
|
|
88
|
+
expect(res.error).toBeDefined();
|
|
89
|
+
expect(res.error).toContain('not implemented');
|
|
90
|
+
expect(res.code).toBe('INVALID_INPUT');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('handles invalid arguments gracefully inside tool execution', async () => {
|
|
94
|
+
const result = await client.callTool({
|
|
95
|
+
name: 'image_crop',
|
|
96
|
+
arguments: { image: 'invalid-image-data', mode: 'absolute', x: 0, y: 0, width: 100, height: 100 }
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const content = result.content as any[];
|
|
100
|
+
const res = JSON.parse(content[0].text);
|
|
101
|
+
expect(res.error).toBeDefined();
|
|
102
|
+
expect(res.code).toBe('INVALID_INPUT'); // file not found or invalid input
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { optimize } from '../../src/ops/optimize.js'
|
|
6
|
+
import sharp from 'sharp'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = dirname(__filename)
|
|
10
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
11
|
+
|
|
12
|
+
describe('optimize', () => {
|
|
13
|
+
let sampleJpeg: Buffer
|
|
14
|
+
let samplePng: Buffer
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
sampleJpeg = fixture('sample.jpg')
|
|
18
|
+
samplePng = fixture('sample.png')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('reduces file size below target maxSizeKB', async () => {
|
|
22
|
+
const startSize = sampleJpeg.length
|
|
23
|
+
|
|
24
|
+
// We expect the dummy jpg from sharp to be very small, maybe < 10KB.
|
|
25
|
+
// If it's already < 10KB, optimization just maintains or matches it.
|
|
26
|
+
const result = await optimize(sampleJpeg, { maxSizeKB: 10, autoFormat: false })
|
|
27
|
+
expect(result.ok).toBe(true)
|
|
28
|
+
if (!result.ok) return
|
|
29
|
+
expect(result.data.length).toBeLessThanOrEqual(10 * 1024 + 1024)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('resizes using maxDimension', async () => {
|
|
33
|
+
const result = await optimize(sampleJpeg, { maxDimension: 100 })
|
|
34
|
+
expect(result.ok).toBe(true)
|
|
35
|
+
if (!result.ok) return
|
|
36
|
+
const meta = await sharp(result.data).metadata()
|
|
37
|
+
expect(Math.max(meta.width!, meta.height!)).toBe(100)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('selects webp for alpha by default', async () => {
|
|
41
|
+
const result = await optimize(samplePng, {})
|
|
42
|
+
expect(result.ok).toBe(true)
|
|
43
|
+
if (!result.ok) return
|
|
44
|
+
const meta = await sharp(result.data).metadata()
|
|
45
|
+
expect(meta.format).toBe('webp')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { overlay } from '../../src/ops/overlay.js'
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
8
|
+
const __dirname = dirname(__filename)
|
|
9
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
10
|
+
|
|
11
|
+
describe('overlay', () => {
|
|
12
|
+
let sampleJpeg: Buffer
|
|
13
|
+
let logoPng: Buffer
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
sampleJpeg = fixture('sample.jpg') // 400x300
|
|
17
|
+
logoPng = fixture('logo.png') // 100x100
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('adds overlay with default options', async () => {
|
|
21
|
+
const result = await overlay(sampleJpeg, logoPng)
|
|
22
|
+
expect(result.ok).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('adds overlay using gravity', async () => {
|
|
26
|
+
const result = await overlay(sampleJpeg, logoPng, { gravity: 'SouthEast' })
|
|
27
|
+
expect(result.ok).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('adds overlay using offsets and opacity', async () => {
|
|
31
|
+
const result = await overlay(sampleJpeg, logoPng, { offsetX: 50, offsetY: 50, opacity: 0.5 })
|
|
32
|
+
expect(result.ok).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('supports blend modes', async () => {
|
|
36
|
+
const result = await overlay(sampleJpeg, logoPng, { blend: 'multiply' })
|
|
37
|
+
expect(result.ok).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { pad } from '../../src/ops/pad.js'
|
|
6
|
+
import sharp from 'sharp'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = dirname(__filename)
|
|
10
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
11
|
+
|
|
12
|
+
describe('pad', () => {
|
|
13
|
+
let sampleJpeg: Buffer
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
sampleJpeg = fixture('sample.jpg') // 400x300
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('pads individual edges', async () => {
|
|
20
|
+
const result = await pad(sampleJpeg, { top: 10, bottom: 20, left: 5, right: 15 })
|
|
21
|
+
expect(result.ok).toBe(true)
|
|
22
|
+
if (!result.ok) return
|
|
23
|
+
const meta = await sharp(result.data).metadata()
|
|
24
|
+
expect(meta.width).toBe(400 + 5 + 15)
|
|
25
|
+
expect(meta.height).toBe(300 + 10 + 20)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('pads to square size', async () => {
|
|
29
|
+
const result = await pad(sampleJpeg, { size: 500 })
|
|
30
|
+
expect(result.ok).toBe(true)
|
|
31
|
+
if (!result.ok) return
|
|
32
|
+
const meta = await sharp(result.data).metadata()
|
|
33
|
+
expect(meta.width).toBe(500)
|
|
34
|
+
expect(meta.height).toBe(500)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('uses transparent background', async () => {
|
|
38
|
+
const result = await pad(sampleJpeg, { top: 10, color: 'transparent' })
|
|
39
|
+
expect(result.ok).toBe(true)
|
|
40
|
+
if (!result.ok) return
|
|
41
|
+
const meta = await sharp(result.data).metadata()
|
|
42
|
+
expect(meta.hasAlpha).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('parses hex color', async () => {
|
|
46
|
+
const result = await pad(sampleJpeg, { top: 10, color: '#ff0000' })
|
|
47
|
+
expect(result.ok).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns error for negative padding', async () => {
|
|
51
|
+
const result = await pad(sampleJpeg, { top: -10 })
|
|
52
|
+
expect(result.ok).toBe(false)
|
|
53
|
+
if (result.ok) return
|
|
54
|
+
expect(result.code).toBe('INVALID_INPUT')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { pipeline } from '../../src/ops/pipeline.js'
|
|
6
|
+
import sharp from 'sharp'
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
9
|
+
const __dirname = dirname(__filename)
|
|
10
|
+
const fixture = (name: string) => readFileSync(join(__dirname, '../fixtures', name))
|
|
11
|
+
|
|
12
|
+
describe('pipeline', () => {
|
|
13
|
+
let sampleJpeg: Buffer
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
sampleJpeg = fixture('sample.jpg')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('runs multiple operations sequentially', async () => {
|
|
20
|
+
const result = await pipeline(sampleJpeg, [
|
|
21
|
+
{ op: 'resize', width: 200, height: 200, fit: 'fill' },
|
|
22
|
+
{ op: 'adjust', brightness: 10 },
|
|
23
|
+
{ op: 'convert', format: 'png' }
|
|
24
|
+
])
|
|
25
|
+
expect(result.ok).toBe(true)
|
|
26
|
+
if (!result.ok) return
|
|
27
|
+
const meta = await sharp(result.data).metadata()
|
|
28
|
+
expect(meta.width).toBe(200)
|
|
29
|
+
expect(meta.height).toBe(200)
|
|
30
|
+
expect(meta.format).toBe('png')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns original loaded buffer for empty pipeline', async () => {
|
|
34
|
+
const result = await pipeline(sampleJpeg, [])
|
|
35
|
+
expect(result.ok).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns error with step index if operation fails', async () => {
|
|
39
|
+
const result = await pipeline(sampleJpeg, [
|
|
40
|
+
{ op: 'resize', width: 200, height: 200 },
|
|
41
|
+
{ op: 'crop', mode: 'absolute', x: 500, y: 500, width: 10, height: 10 }
|
|
42
|
+
])
|
|
43
|
+
expect(result.ok).toBe(false)
|
|
44
|
+
if (result.ok) return
|
|
45
|
+
expect(result.step).toBe(1)
|
|
46
|
+
expect(result.code).toBe('OUT_OF_BOUNDS')
|
|
47
|
+
})
|
|
48
|
+
})
|