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.
Files changed (176) hide show
  1. package/.gitattributes +2 -0
  2. package/README.md +41 -0
  3. package/dist/index.d.ts +24 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +24 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/mcp/index.d.ts +3 -0
  8. package/dist/mcp/index.d.ts.map +1 -0
  9. package/dist/mcp/index.js +3 -0
  10. package/dist/mcp/index.js.map +1 -0
  11. package/dist/mcp/server.d.ts +3 -0
  12. package/dist/mcp/server.d.ts.map +1 -0
  13. package/dist/mcp/server.js +15 -0
  14. package/dist/mcp/server.js.map +1 -0
  15. package/dist/mcp/tools.d.ts +4 -0
  16. package/dist/mcp/tools.d.ts.map +1 -0
  17. package/dist/mcp/tools.js +285 -0
  18. package/dist/mcp/tools.js.map +1 -0
  19. package/dist/ops/add-text.d.ts +5 -0
  20. package/dist/ops/add-text.d.ts.map +1 -0
  21. package/dist/ops/add-text.js +129 -0
  22. package/dist/ops/add-text.js.map +1 -0
  23. package/dist/ops/adjust.d.ts +3 -0
  24. package/dist/ops/adjust.d.ts.map +1 -0
  25. package/dist/ops/adjust.js +71 -0
  26. package/dist/ops/adjust.js.map +1 -0
  27. package/dist/ops/batch.d.ts +3 -0
  28. package/dist/ops/batch.d.ts.map +1 -0
  29. package/dist/ops/batch.js +35 -0
  30. package/dist/ops/batch.js.map +1 -0
  31. package/dist/ops/blur-region.d.ts +5 -0
  32. package/dist/ops/blur-region.d.ts.map +1 -0
  33. package/dist/ops/blur-region.js +54 -0
  34. package/dist/ops/blur-region.js.map +1 -0
  35. package/dist/ops/composite.d.ts +5 -0
  36. package/dist/ops/composite.d.ts.map +1 -0
  37. package/dist/ops/composite.js +53 -0
  38. package/dist/ops/composite.js.map +1 -0
  39. package/dist/ops/convert.d.ts +3 -0
  40. package/dist/ops/convert.d.ts.map +1 -0
  41. package/dist/ops/convert.js +45 -0
  42. package/dist/ops/convert.js.map +1 -0
  43. package/dist/ops/crop.d.ts +3 -0
  44. package/dist/ops/crop.d.ts.map +1 -0
  45. package/dist/ops/crop.js +105 -0
  46. package/dist/ops/crop.js.map +1 -0
  47. package/dist/ops/detect-faces.d.ts +3 -0
  48. package/dist/ops/detect-faces.d.ts.map +1 -0
  49. package/dist/ops/detect-faces.js +41 -0
  50. package/dist/ops/detect-faces.js.map +1 -0
  51. package/dist/ops/detect-subject.d.ts +3 -0
  52. package/dist/ops/detect-subject.d.ts.map +1 -0
  53. package/dist/ops/detect-subject.js +78 -0
  54. package/dist/ops/detect-subject.js.map +1 -0
  55. package/dist/ops/extract-text.d.ts +5 -0
  56. package/dist/ops/extract-text.d.ts.map +1 -0
  57. package/dist/ops/extract-text.js +21 -0
  58. package/dist/ops/extract-text.js.map +1 -0
  59. package/dist/ops/filter.d.ts +3 -0
  60. package/dist/ops/filter.d.ts.map +1 -0
  61. package/dist/ops/filter.js +53 -0
  62. package/dist/ops/filter.js.map +1 -0
  63. package/dist/ops/get-dominant-colors.d.ts +3 -0
  64. package/dist/ops/get-dominant-colors.d.ts.map +1 -0
  65. package/dist/ops/get-dominant-colors.js +48 -0
  66. package/dist/ops/get-dominant-colors.js.map +1 -0
  67. package/dist/ops/get-metadata.d.ts +3 -0
  68. package/dist/ops/get-metadata.d.ts.map +1 -0
  69. package/dist/ops/get-metadata.js +30 -0
  70. package/dist/ops/get-metadata.js.map +1 -0
  71. package/dist/ops/optimize.d.ts +3 -0
  72. package/dist/ops/optimize.d.ts.map +1 -0
  73. package/dist/ops/optimize.js +78 -0
  74. package/dist/ops/optimize.js.map +1 -0
  75. package/dist/ops/overlay.d.ts +3 -0
  76. package/dist/ops/overlay.d.ts.map +1 -0
  77. package/dist/ops/overlay.js +52 -0
  78. package/dist/ops/overlay.js.map +1 -0
  79. package/dist/ops/pad.d.ts +3 -0
  80. package/dist/ops/pad.d.ts.map +1 -0
  81. package/dist/ops/pad.js +62 -0
  82. package/dist/ops/pad.js.map +1 -0
  83. package/dist/ops/pipeline.d.ts +5 -0
  84. package/dist/ops/pipeline.d.ts.map +1 -0
  85. package/dist/ops/pipeline.js +81 -0
  86. package/dist/ops/pipeline.js.map +1 -0
  87. package/dist/ops/remove-bg.d.ts +3 -0
  88. package/dist/ops/remove-bg.d.ts.map +1 -0
  89. package/dist/ops/remove-bg.js +79 -0
  90. package/dist/ops/remove-bg.js.map +1 -0
  91. package/dist/ops/resize.d.ts +3 -0
  92. package/dist/ops/resize.d.ts.map +1 -0
  93. package/dist/ops/resize.js +54 -0
  94. package/dist/ops/resize.js.map +1 -0
  95. package/dist/ops/watermark.d.ts +3 -0
  96. package/dist/ops/watermark.d.ts.map +1 -0
  97. package/dist/ops/watermark.js +142 -0
  98. package/dist/ops/watermark.js.map +1 -0
  99. package/dist/types.d.ts +233 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +12 -0
  102. package/dist/types.js.map +1 -0
  103. package/dist/utils/load-image.d.ts +9 -0
  104. package/dist/utils/load-image.d.ts.map +1 -0
  105. package/dist/utils/load-image.js +22 -0
  106. package/dist/utils/load-image.js.map +1 -0
  107. package/dist/utils/result.d.ts +4 -0
  108. package/dist/utils/result.d.ts.map +1 -0
  109. package/dist/utils/result.js +3 -0
  110. package/dist/utils/result.js.map +1 -0
  111. package/dist/utils/validate.d.ts +16 -0
  112. package/dist/utils/validate.d.ts.map +1 -0
  113. package/dist/utils/validate.js +20 -0
  114. package/dist/utils/validate.js.map +1 -0
  115. package/docs/AGENTS.md +18 -0
  116. package/docs/MCP.md +106 -0
  117. package/package.json +52 -0
  118. package/scripts/generate-fixtures.js +33 -0
  119. package/src/index.ts +24 -0
  120. package/src/mcp/index.ts +2 -0
  121. package/src/mcp/server.ts +21 -0
  122. package/src/mcp/tools.ts +276 -0
  123. package/src/ops/add-text.ts +139 -0
  124. package/src/ops/adjust.ts +68 -0
  125. package/src/ops/batch.ts +41 -0
  126. package/src/ops/blur-region.ts +58 -0
  127. package/src/ops/composite.ts +56 -0
  128. package/src/ops/convert.ts +46 -0
  129. package/src/ops/crop.ts +101 -0
  130. package/src/ops/detect-faces.ts +41 -0
  131. package/src/ops/detect-subject.ts +80 -0
  132. package/src/ops/extract-text.ts +19 -0
  133. package/src/ops/filter.ts +51 -0
  134. package/src/ops/get-dominant-colors.ts +41 -0
  135. package/src/ops/get-metadata.ts +28 -0
  136. package/src/ops/optimize.ts +77 -0
  137. package/src/ops/overlay.ts +51 -0
  138. package/src/ops/pad.ts +63 -0
  139. package/src/ops/pipeline.ts +61 -0
  140. package/src/ops/remove-bg.ts +82 -0
  141. package/src/ops/resize.ts +54 -0
  142. package/src/ops/watermark.ts +141 -0
  143. package/src/types/color-thief-node.d.ts +4 -0
  144. package/src/types.ts +267 -0
  145. package/src/utils/load-image.ts +21 -0
  146. package/src/utils/result.ts +4 -0
  147. package/src/utils/validate.ts +21 -0
  148. package/tests/fixtures/logo.png +0 -0
  149. package/tests/fixtures/sample.jpg +0 -0
  150. package/tests/fixtures/sample.png +0 -0
  151. package/tests/fixtures/sample.webp +0 -0
  152. package/tests/integration/error-handling.test.ts +22 -0
  153. package/tests/integration/load-image.test.ts +45 -0
  154. package/tests/unit/add-text.test.ts +56 -0
  155. package/tests/unit/adjust.test.ts +81 -0
  156. package/tests/unit/batch.test.ts +38 -0
  157. package/tests/unit/blur-region.test.ts +52 -0
  158. package/tests/unit/composite.test.ts +58 -0
  159. package/tests/unit/convert.test.ts +55 -0
  160. package/tests/unit/crop.test.ts +100 -0
  161. package/tests/unit/detect-faces.test.ts +32 -0
  162. package/tests/unit/detect-subject.test.ts +37 -0
  163. package/tests/unit/extract-text.test.ts +34 -0
  164. package/tests/unit/filter.test.ts +39 -0
  165. package/tests/unit/get-dominant-colors.test.ts +25 -0
  166. package/tests/unit/get-metadata.test.ts +36 -0
  167. package/tests/unit/mcp.test.ts +104 -0
  168. package/tests/unit/optimize.test.ts +47 -0
  169. package/tests/unit/overlay.test.ts +39 -0
  170. package/tests/unit/pad.test.ts +56 -0
  171. package/tests/unit/pipeline.test.ts +48 -0
  172. package/tests/unit/remove-bg.test.ts +42 -0
  173. package/tests/unit/resize.test.ts +70 -0
  174. package/tests/unit/watermark.test.ts +54 -0
  175. package/tsconfig.json +15 -0
  176. 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
+ })