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
package/src/types.ts ADDED
@@ -0,0 +1,267 @@
1
+ // ─── Input ────────────────────────────────────────────────────────────────────
2
+
3
+ /**
4
+ * Accepted image input formats.
5
+ * - Buffer: raw image bytes
6
+ * - string starting with 'http': fetched via HTTP
7
+ * - string starting with 'data:': base64 data URI
8
+ * - string (other): treated as filesystem path
9
+ */
10
+ export type ImageInput = Buffer | string;
11
+
12
+ // ─── Result ───────────────────────────────────────────────────────────────────
13
+
14
+ export type Ok<T> = { ok: true; data: T };
15
+ export type Err = { ok: false; error: string; code: ErrorCode };
16
+ export type Result<T> = Ok<T> | Err;
17
+ export type ImageResult = Result<Buffer>;
18
+
19
+ export enum ErrorCode {
20
+ INVALID_INPUT = 'INVALID_INPUT',
21
+ UNSUPPORTED_FORMAT = 'UNSUPPORTED_FORMAT',
22
+ OUT_OF_BOUNDS = 'OUT_OF_BOUNDS',
23
+ FETCH_FAILED = 'FETCH_FAILED',
24
+ PROCESSING_FAILED = 'PROCESSING_FAILED',
25
+ MODEL_NOT_FOUND = 'MODEL_NOT_FOUND',
26
+ TIMEOUT = 'TIMEOUT',
27
+ }
28
+
29
+ // ─── Crop ─────────────────────────────────────────────────────────────────────
30
+
31
+ export type CropOptions =
32
+ | { mode?: 'absolute'; x: number; y: number; width: number; height: number }
33
+ | { mode: 'ratio'; left: number; top: number; right: number; bottom: number }
34
+ | { mode: 'aspect'; aspectRatio: string; anchor?: 'center' | 'top' | 'bottom' | 'face' }
35
+ | { mode: 'subject' };
36
+
37
+ // ─── Resize ───────────────────────────────────────────────────────────────────
38
+
39
+ export type ResizeFit = 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
40
+ export type ResizeKernel = 'lanczos3' | 'nearest' | 'linear';
41
+
42
+ export interface ResizeOptions {
43
+ width?: number;
44
+ height?: number;
45
+ scale?: number;
46
+ fit?: ResizeFit;
47
+ kernel?: ResizeKernel;
48
+ /** Preserve aspect ratio when only one dimension is given. Default: true */
49
+ withoutEnlargement?: boolean;
50
+ }
51
+
52
+ // ─── Pad ──────────────────────────────────────────────────────────────────────
53
+
54
+ export interface PadOptions {
55
+ top?: number;
56
+ right?: number;
57
+ bottom?: number;
58
+ left?: number;
59
+ /** Square canvas shorthand: centers image and pads to this size */
60
+ size?: number;
61
+ /** CSS hex color or 'transparent'. Default: '#ffffff' */
62
+ color?: string;
63
+ }
64
+
65
+ // ─── Adjust ───────────────────────────────────────────────────────────────────
66
+
67
+ export interface AdjustOptions {
68
+ /** -100 to +100 */
69
+ brightness?: number;
70
+ /** -100 to +100 */
71
+ contrast?: number;
72
+ /** -100 to +100 */
73
+ saturation?: number;
74
+ /** 0 to 360 degrees */
75
+ hue?: number;
76
+ /** 0 to 100 */
77
+ sharpness?: number;
78
+ /** -100 (cool) to +100 (warm) */
79
+ temperature?: number;
80
+ }
81
+
82
+ // ─── Filter ───────────────────────────────────────────────────────────────────
83
+
84
+ export type FilterPreset = 'grayscale' | 'sepia' | 'invert' | 'vintage' | 'unsharp';
85
+
86
+ export type FilterOptions =
87
+ | { preset: Exclude<FilterPreset, 'blur'> }
88
+ | { preset: 'blur'; radius: number };
89
+
90
+ // ─── BlurRegion ───────────────────────────────────────────────────────────────
91
+
92
+ export interface BlurRegion {
93
+ x: number;
94
+ y: number;
95
+ width: number;
96
+ height: number;
97
+ /** Blur strength (sigma). Default: 10 */
98
+ radius?: number;
99
+ }
100
+
101
+ // ─── AddText ──────────────────────────────────────────────────────────────────
102
+
103
+ export type TextAnchor =
104
+ | 'top-left' | 'top-center' | 'top-right'
105
+ | 'middle-left' | 'center' | 'middle-right'
106
+ | 'bottom-left' | 'bottom-center' | 'bottom-right';
107
+
108
+ export interface TextBackground {
109
+ color: string;
110
+ padding?: number;
111
+ opacity?: number;
112
+ borderRadius?: number;
113
+ }
114
+
115
+ export interface TextLayer {
116
+ text: string;
117
+ x: number;
118
+ y: number;
119
+ anchor?: TextAnchor;
120
+ fontSize?: number;
121
+ fontFamily?: string;
122
+ /** Google Fonts URL or local file path */
123
+ fontUrl?: string;
124
+ color?: string;
125
+ opacity?: number;
126
+ align?: 'left' | 'center' | 'right';
127
+ /** Wrap text at this pixel width */
128
+ maxWidth?: number;
129
+ lineHeight?: number;
130
+ background?: TextBackground;
131
+ }
132
+
133
+ // ─── Composite ────────────────────────────────────────────────────────────────
134
+
135
+ export type BlendMode = 'over' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten';
136
+
137
+ export interface CompositeLayer {
138
+ image: ImageInput;
139
+ x?: number;
140
+ y?: number;
141
+ blend?: BlendMode;
142
+ opacity?: number;
143
+ }
144
+
145
+ // ─── Watermark ────────────────────────────────────────────────────────────────
146
+
147
+ export type WatermarkPosition =
148
+ | 'top-left' | 'top-center' | 'top-right'
149
+ | 'center'
150
+ | 'bottom-left' | 'bottom-center' | 'bottom-right'
151
+ | 'tile';
152
+
153
+ export type WatermarkOptions =
154
+ | {
155
+ type: 'text';
156
+ text: string;
157
+ position?: WatermarkPosition;
158
+ fontSize?: number;
159
+ color?: string;
160
+ opacity?: number;
161
+ tileSpacing?: number;
162
+ }
163
+ | {
164
+ type: 'image';
165
+ image: ImageInput;
166
+ position?: WatermarkPosition;
167
+ opacity?: number;
168
+ scale?: number;
169
+ tileSpacing?: number;
170
+ };
171
+
172
+ // ─── Overlay ──────────────────────────────────────────────────────────────────
173
+
174
+ export type Gravity =
175
+ | 'NorthWest' | 'North' | 'NorthEast'
176
+ | 'West' | 'Center' | 'East'
177
+ | 'SouthWest' | 'South' | 'SouthEast';
178
+
179
+ export interface OverlayOptions {
180
+ gravity?: Gravity;
181
+ offsetX?: number;
182
+ offsetY?: number;
183
+ opacity?: number;
184
+ blend?: BlendMode;
185
+ }
186
+
187
+ // ─── RemoveBg ─────────────────────────────────────────────────────────────────
188
+
189
+ export type RemoveBgEngine = 'onnx' | 'webai';
190
+
191
+ export interface RemoveBgOptions {
192
+ engine?: RemoveBgEngine;
193
+ /** Replace transparency with this solid color instead */
194
+ replaceColor?: string;
195
+ /** Replace background with this image */
196
+ replaceImage?: ImageInput;
197
+ }
198
+
199
+ // ─── DetectSubject ────────────────────────────────────────────────────────────
200
+
201
+ export interface BoundingBox {
202
+ x: number;
203
+ y: number;
204
+ width: number;
205
+ height: number;
206
+ confidence: number;
207
+ }
208
+
209
+ // ─── Convert ──────────────────────────────────────────────────────────────────
210
+
211
+ export type OutputFormat = 'jpeg' | 'png' | 'webp' | 'avif' | 'gif';
212
+
213
+ export interface ConvertOptions {
214
+ format: OutputFormat;
215
+ /** 0–100. Default: 80 */
216
+ quality?: number;
217
+ /** PNG only. 0–9. Default: 6 */
218
+ compressionLevel?: number;
219
+ /** Strip all metadata. Default: true */
220
+ stripMetadata?: boolean;
221
+ }
222
+
223
+ // ─── Optimize ─────────────────────────────────────────────────────────────────
224
+
225
+ export interface OptimizeOptions {
226
+ /** Target file size in KB. Quality is adjusted automatically */
227
+ maxSizeKB?: number;
228
+ /** Resize so the longest dimension does not exceed this value */
229
+ maxDimension?: number;
230
+ /** Auto-select format (WebP if alpha, JPEG otherwise). Default: true */
231
+ autoFormat?: boolean;
232
+ }
233
+
234
+ // ─── Metadata ─────────────────────────────────────────────────────────────────
235
+
236
+ export interface ImageMetadata {
237
+ width: number;
238
+ height: number;
239
+ format: string;
240
+ fileSize: number;
241
+ colorSpace?: string;
242
+ hasAlpha: boolean;
243
+ channels: number;
244
+ density?: number;
245
+ exif?: Record<string, unknown>;
246
+ }
247
+
248
+ // ─── Pipeline ─────────────────────────────────────────────────────────────────
249
+
250
+ export type PipelineOperation =
251
+ | ({ op: 'crop' } & CropOptions)
252
+ | ({ op: 'resize' } & ResizeOptions)
253
+ | ({ op: 'pad' } & PadOptions)
254
+ | ({ op: 'adjust' } & AdjustOptions)
255
+ | ({ op: 'filter' } & FilterOptions)
256
+ | ({ op: 'blurRegion'; regions: BlurRegion[] })
257
+ | ({ op: 'addText'; layers: TextLayer[] })
258
+ | ({ op: 'composite'; layers: CompositeLayer[] })
259
+ | ({ op: 'watermark' } & WatermarkOptions)
260
+ | ({ op: 'convert' } & ConvertOptions)
261
+ | ({ op: 'optimize' } & OptimizeOptions)
262
+ | ({ op: 'removeBg' } & RemoveBgOptions);
263
+
264
+ export interface BatchOptions {
265
+ concurrency?: number;
266
+ onProgress?: (done: number, total: number) => void;
267
+ }
@@ -0,0 +1,21 @@
1
+ import { ImageInput } from '../types.js';
2
+ import { readFile } from 'fs/promises';
3
+ import fetch from 'node-fetch';
4
+
5
+ /**
6
+ * Resolves any ImageInput variant to a Buffer.
7
+ * Handles: Buffer (passthrough), URL (fetch), base64 data URI (decode), file path (readFile).
8
+ */
9
+ export async function loadImage(input: ImageInput): Promise<Buffer> {
10
+ if (Buffer.isBuffer(input)) return input;
11
+ if (input.startsWith('data:')) {
12
+ const base64 = input.split(',')[1];
13
+ return Buffer.from(base64, 'base64');
14
+ }
15
+ if (input.startsWith('http://') || input.startsWith('https://')) {
16
+ const res = await fetch(input);
17
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
18
+ return Buffer.from(await res.arrayBuffer());
19
+ }
20
+ return readFile(input);
21
+ }
@@ -0,0 +1,4 @@
1
+ import { Ok, Err, ErrorCode } from '../types.js'
2
+
3
+ export const ok = <T>(data: T): Ok<T> => ({ ok: true, data })
4
+ export const err = (error: string, code: ErrorCode): Err => ({ ok: false, error, code })
@@ -0,0 +1,21 @@
1
+ import sharp from 'sharp';
2
+
3
+ /**
4
+ * Helper to ensure a value is a positive integer.
5
+ */
6
+ export function isPositiveInt(val: any): boolean {
7
+ return typeof val === 'number' && Number.isInteger(val) && val > 0;
8
+ }
9
+
10
+ /**
11
+ * Helper to get basic dimensions securely via sharp metadata
12
+ */
13
+ export async function getImageMetadata(buffer: Buffer) {
14
+ const metadata = await sharp(buffer).metadata();
15
+ return {
16
+ width: metadata.width ?? 0,
17
+ height: metadata.height ?? 0,
18
+ hasAlpha: metadata.hasAlpha ?? false,
19
+ format: metadata.format ?? 'unknown'
20
+ };
21
+ }
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,22 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ok, err } from '../../src/utils/result.js';
3
+ import { ErrorCode } from '../../src/types.js';
4
+
5
+ describe('error handling utilities', () => {
6
+ it('constructs an Ok result', () => {
7
+ const val = { testing: 123 };
8
+ const result = ok(val);
9
+ expect(result).toEqual({ ok: true, data: val });
10
+ });
11
+
12
+ it('constructs an Err result with code', () => {
13
+ const message = "Invalid dimensions provided";
14
+ const result = err(message, ErrorCode.INVALID_INPUT);
15
+
16
+ expect(result.ok).toBe(false);
17
+ if (!result.ok) { // TypeScript narrowing
18
+ expect(result.error).toBe(message);
19
+ expect(result.code).toBe(ErrorCode.INVALID_INPUT);
20
+ }
21
+ });
22
+ });
@@ -0,0 +1,45 @@
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 { loadImage } from '../../src/utils/load-image.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('loadImage', () => {
12
+ let sampleJpeg: Buffer;
13
+
14
+ beforeAll(() => {
15
+ sampleJpeg = fixture('sample.jpg');
16
+ });
17
+
18
+ it('passes through Buffer input directly', async () => {
19
+ const result = await loadImage(sampleJpeg);
20
+ expect(result).toBe(sampleJpeg);
21
+ expect(Buffer.isBuffer(result)).toBe(true);
22
+ });
23
+
24
+ it('loads image from a local file path', async () => {
25
+ const filePath = join(__dirname, '../fixtures/sample.jpg');
26
+ const result = await loadImage(filePath);
27
+ expect(Buffer.isBuffer(result)).toBe(true);
28
+ // Buffer length might be different in deep equal vs identity, but lengths should match
29
+ expect(result.length).toBe(sampleJpeg.length);
30
+ });
31
+
32
+ it('decodes a base64 data URI', async () => {
33
+ const base64 = `data:image/jpeg;base64,${sampleJpeg.toString('base64')}`;
34
+ const result = await loadImage(base64);
35
+ expect(Buffer.isBuffer(result)).toBe(true);
36
+ expect(result.length).toBe(sampleJpeg.length);
37
+ });
38
+
39
+ // Note: We might want to mock node-fetch for HTTP tests to avoid true network reliance,
40
+ // but a simple test checking if it resolves fetching an image can be mocked like so:
41
+ it('throws an error for invalid local paths', async () => {
42
+ await expect(loadImage(join(__dirname, '../fixtures/missing.jpg')))
43
+ .rejects.toThrow();
44
+ });
45
+ });
@@ -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 { addText } from '../../src/ops/add-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
+ describe('addText', () => {
12
+ let sampleJpeg: Buffer
13
+
14
+ beforeAll(() => {
15
+ sampleJpeg = fixture('sample.jpg') // 400x300
16
+ })
17
+
18
+ it('adds a single text layer', async () => {
19
+ const result = await addText(sampleJpeg, {
20
+ layers: [{ text: 'Hello', x: 50, y: 50 }]
21
+ })
22
+ expect(result.ok).toBe(true)
23
+ })
24
+
25
+ it('adds multiple text layers with different anchors', async () => {
26
+ const result = await addText(sampleJpeg, {
27
+ layers: [
28
+ { text: 'Top Left', x: 10, y: 10, anchor: 'top-left' },
29
+ { text: 'Center', x: 200, y: 150, anchor: 'center' },
30
+ { text: 'Bottom Right', x: 390, y: 290, anchor: 'bottom-right' }
31
+ ]
32
+ })
33
+ expect(result.ok).toBe(true)
34
+ })
35
+
36
+ it('wraps text via maxWidth', async () => {
37
+ const result = await addText(sampleJpeg, {
38
+ layers: [{ text: 'This represents a completely long line of text that needs to wrap properly', x: 10, y: 50, maxWidth: 100 }]
39
+ })
40
+ expect(result.ok).toBe(true)
41
+ })
42
+
43
+ it('renders text background box', async () => {
44
+ const result = await addText(sampleJpeg, {
45
+ layers: [{ text: 'Background', x: 50, y: 50, background: { color: '#ff0000', opacity: 0.5, padding: 10, borderRadius: 5 } }]
46
+ })
47
+ expect(result.ok).toBe(true)
48
+ })
49
+
50
+ it('returns error if missing layers array', async () => {
51
+ const result = await addText(sampleJpeg, {} as any)
52
+ expect(result.ok).toBe(false)
53
+ if (result.ok) return
54
+ expect(result.code).toBe('INVALID_INPUT')
55
+ })
56
+ })
@@ -0,0 +1,81 @@
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 { adjust } from '../../src/ops/adjust.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('adjust', () => {
13
+ let sampleJpeg: Buffer
14
+
15
+ beforeAll(() => {
16
+ sampleJpeg = fixture('sample.jpg') // 400x300
17
+ })
18
+
19
+ it('adjusts brightness', async () => {
20
+ const result = await adjust(sampleJpeg, { brightness: 50 })
21
+ expect(result.ok).toBe(true)
22
+ })
23
+
24
+ it('adjusts contrast', async () => {
25
+ const result = await adjust(sampleJpeg, { contrast: 50 })
26
+ expect(result.ok).toBe(true)
27
+ })
28
+
29
+ it('adjusts saturation', async () => {
30
+ const result = await adjust(sampleJpeg, { saturation: -50 })
31
+ expect(result.ok).toBe(true)
32
+ })
33
+
34
+ it('adjusts hue', async () => {
35
+ const result = await adjust(sampleJpeg, { hue: 180 })
36
+ expect(result.ok).toBe(true)
37
+ })
38
+
39
+ it('adjusts sharpness', async () => {
40
+ const result = await adjust(sampleJpeg, { sharpness: 80 })
41
+ expect(result.ok).toBe(true)
42
+ })
43
+
44
+ it('adjusts temperature (warm)', async () => {
45
+ const result = await adjust(sampleJpeg, { temperature: 50 })
46
+ expect(result.ok).toBe(true)
47
+ })
48
+
49
+ it('adjusts temperature (cool)', async () => {
50
+ const result = await adjust(sampleJpeg, { temperature: -50 })
51
+ expect(result.ok).toBe(true)
52
+ })
53
+
54
+ it('applies all adjustments together', async () => {
55
+ const result = await adjust(sampleJpeg, {
56
+ brightness: 10,
57
+ contrast: 10,
58
+ saturation: 20,
59
+ hue: 45,
60
+ sharpness: 50,
61
+ temperature: 10
62
+ })
63
+ expect(result.ok).toBe(true)
64
+ })
65
+
66
+ // ── Error paths ────────────────────────────────────────────────────────────
67
+
68
+ it('returns error for out-of-range brightness', async () => {
69
+ const result = await adjust(sampleJpeg, { brightness: 150 })
70
+ expect(result.ok).toBe(false)
71
+ if (result.ok) return
72
+ expect(result.code).toBe('INVALID_INPUT')
73
+ })
74
+
75
+ it('returns error for out-of-range hue', async () => {
76
+ const result = await adjust(sampleJpeg, { hue: -10 })
77
+ expect(result.ok).toBe(false)
78
+ if (result.ok) return
79
+ expect(result.code).toBe('INVALID_INPUT')
80
+ })
81
+ })
@@ -0,0 +1,38 @@
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 { batch } from '../../src/ops/batch.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('batch', () => {
12
+ let sampleJpeg: Buffer
13
+
14
+ beforeAll(() => {
15
+ sampleJpeg = fixture('sample.jpg')
16
+ })
17
+
18
+ it('processes all images', async () => {
19
+ const inputs = [sampleJpeg, sampleJpeg, sampleJpeg]
20
+ const operations: any[] = [{ op: 'resize', width: 50, height: 50 }]
21
+
22
+ let progressUpdates = 0
23
+ const result = await batch(inputs, operations, {
24
+ concurrency: 2,
25
+ onProgress: (done, total) => {
26
+ progressUpdates++
27
+ expect(total).toBe(3)
28
+ expect(done).toBeLessThanOrEqual(3)
29
+ }
30
+ })
31
+
32
+ expect(result).toHaveLength(3)
33
+ expect(progressUpdates).toBe(3)
34
+ for (const r of result) {
35
+ expect(r.ok).toBe(true)
36
+ }
37
+ })
38
+ })
@@ -0,0 +1,52 @@
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 { blurRegion } from '../../src/ops/blur-region.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('blurRegion', () => {
12
+ let sampleJpeg: Buffer
13
+
14
+ beforeAll(() => {
15
+ sampleJpeg = fixture('sample.jpg') // 400x300
16
+ })
17
+
18
+ it('blurs a single region', async () => {
19
+ const result = await blurRegion(sampleJpeg, {
20
+ regions: [{ x: 50, y: 50, width: 100, height: 100, radius: 10 }]
21
+ })
22
+ expect(result.ok).toBe(true)
23
+ })
24
+
25
+ it('blurs multiple regions', async () => {
26
+ const result = await blurRegion(sampleJpeg, {
27
+ regions: [
28
+ { x: 10, y: 10, width: 50, height: 50 },
29
+ { x: 100, y: 100, width: 80, height: 80, radius: 5 }
30
+ ]
31
+ })
32
+ expect(result.ok).toBe(true)
33
+ })
34
+
35
+ it('fails if region is out of bounds', async () => {
36
+ const result = await blurRegion(sampleJpeg, {
37
+ regions: [{ x: 350, y: 0, width: 100, height: 100 }]
38
+ })
39
+ expect(result.ok).toBe(false)
40
+ if (result.ok) return
41
+ expect(result.code).toBe('OUT_OF_BOUNDS')
42
+ })
43
+
44
+ it('fails if width/height are invalid', async () => {
45
+ const result = await blurRegion(sampleJpeg, {
46
+ regions: [{ x: 0, y: 0, width: -10, height: 50 }]
47
+ })
48
+ expect(result.ok).toBe(false)
49
+ if (result.ok) return
50
+ expect(result.code).toBe('INVALID_INPUT')
51
+ })
52
+ })
@@ -0,0 +1,58 @@
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 { composite } from '../../src/ops/composite.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('composite', () => {
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('composites a single overlay', async () => {
21
+ const result = await composite(sampleJpeg, {
22
+ layers: [{ image: logoPng, x: 50, y: 50 }]
23
+ })
24
+ expect(result.ok).toBe(true)
25
+ })
26
+
27
+ it('composites multiple overlays', async () => {
28
+ const result = await composite(sampleJpeg, {
29
+ layers: [
30
+ { image: logoPng, x: 10, y: 10 },
31
+ { image: logoPng, x: 200, y: 150, blend: 'multiply' }
32
+ ]
33
+ })
34
+ expect(result.ok).toBe(true)
35
+ })
36
+
37
+ it('applies layer opacity', async () => {
38
+ const result = await composite(sampleJpeg, {
39
+ layers: [{ image: logoPng, x: 0, y: 0, opacity: 0.5 }]
40
+ })
41
+ expect(result.ok).toBe(true)
42
+ })
43
+
44
+ it('handles data URL as layer source', async () => {
45
+ const base64 = `data:image/png;base64,${logoPng.toString('base64')}`
46
+ const result = await composite(sampleJpeg, {
47
+ layers: [{ image: base64, x: 0, y: 0 }]
48
+ })
49
+ expect(result.ok).toBe(true)
50
+ })
51
+
52
+ it('returns error if missing layers array', async () => {
53
+ const result = await composite(sampleJpeg, {} as any)
54
+ expect(result.ok).toBe(false)
55
+ if (result.ok) return
56
+ expect(result.code).toBe('INVALID_INPUT')
57
+ })
58
+ })