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,68 @@
1
+ import { AdjustOptions, ImageInput, ImageResult, ErrorCode } from '../types.js';
2
+ import { loadImage } from '../utils/load-image.js';
3
+ import { err, ok } from '../utils/result.js';
4
+ import sharp from 'sharp';
5
+
6
+ export async function adjust(input: ImageInput, options: AdjustOptions): Promise<ImageResult> {
7
+ try {
8
+ const buffer = await loadImage(input);
9
+ let pipeline = sharp(buffer);
10
+
11
+ // Bounds checking
12
+ if (options.brightness !== undefined && (options.brightness < -100 || options.brightness > 100)) {
13
+ return err('Brightness must be between -100 and +100', ErrorCode.INVALID_INPUT);
14
+ }
15
+ if (options.contrast !== undefined && (options.contrast < -100 || options.contrast > 100)) {
16
+ return err('Contrast must be between -100 and +100', ErrorCode.INVALID_INPUT);
17
+ }
18
+ if (options.saturation !== undefined && (options.saturation < -100 || options.saturation > 100)) {
19
+ return err('Saturation must be between -100 and +100', ErrorCode.INVALID_INPUT);
20
+ }
21
+ if (options.hue !== undefined && (options.hue < 0 || options.hue > 360)) {
22
+ return err('Hue must be between 0 and 360', ErrorCode.INVALID_INPUT);
23
+ }
24
+ if (options.sharpness !== undefined && (options.sharpness < 0 || options.sharpness > 100)) {
25
+ return err('Sharpness must be between 0 and 100', ErrorCode.INVALID_INPUT);
26
+ }
27
+ if (options.temperature !== undefined && (options.temperature < -100 || options.temperature > 100)) {
28
+ return err('Temperature must be between -100 and +100', ErrorCode.INVALID_INPUT);
29
+ }
30
+
31
+ const mod: Record<string, number> = {};
32
+ if (options.brightness !== undefined) mod.brightness = 1 + (options.brightness / 100);
33
+ if (options.saturation !== undefined) mod.saturation = 1 + (options.saturation / 100);
34
+ if (options.hue !== undefined) mod.hue = options.hue;
35
+
36
+ if (Object.keys(mod).length > 0) pipeline = pipeline.modulate(mod);
37
+
38
+ if (options.contrast !== undefined) {
39
+ const c = 1 + (options.contrast / 100);
40
+ pipeline = pipeline.linear(c, -(128 * c) + 128);
41
+ }
42
+
43
+ if (options.sharpness !== undefined) {
44
+ const sigma = (options.sharpness / 100) * 3;
45
+ if (sigma > 0) pipeline = pipeline.sharpen({ sigma });
46
+ }
47
+
48
+ if (options.temperature !== undefined && options.temperature !== 0) {
49
+ const t = options.temperature;
50
+ let r = 255, g = 255, b = 255;
51
+ if (t > 0) {
52
+ b = Math.max(0, 255 - Math.round((t / 100) * 255));
53
+ } else if (t < 0) {
54
+ r = Math.max(0, 255 - Math.round((Math.abs(t) / 100) * 255));
55
+ }
56
+ pipeline = pipeline.tint({ r, g, b });
57
+ }
58
+
59
+ const output = await pipeline.toBuffer();
60
+ return ok(output);
61
+ } catch (e: any) {
62
+ const msg = e.message || '';
63
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
64
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
65
+ if (msg.includes('unsupported image format')) return err('Corrupt or unsupported input', ErrorCode.INVALID_INPUT);
66
+ return err(msg, ErrorCode.PROCESSING_FAILED);
67
+ }
68
+ }
@@ -0,0 +1,41 @@
1
+ import { ImageInput, ImageResult, PipelineOperation, BatchOptions } from '../types.js';
2
+ import { pipeline } from './pipeline.js';
3
+
4
+ export async function batch(inputs: ImageInput[], operations: PipelineOperation[], options: BatchOptions = {}): Promise<ImageResult[]> {
5
+ const concurrency = options.concurrency ?? 4;
6
+ const results: ImageResult[] = new Array(inputs.length);
7
+ const queue = inputs.map((input, index) => ({ input, index }));
8
+
9
+ let active = 0;
10
+ let doneCount = 0;
11
+ let currentIndex = 0;
12
+
13
+ return new Promise((resolve) => {
14
+ const checkQueue = () => {
15
+ if (doneCount === inputs.length) {
16
+ resolve(results);
17
+ return;
18
+ }
19
+
20
+ while (active < concurrency && currentIndex < queue.length) {
21
+ active++;
22
+ const item = queue[currentIndex++];
23
+
24
+ pipeline(item.input, operations).then((res) => {
25
+ results[item.index] = res;
26
+ }).catch((err) => {
27
+ results[item.index] = { ok: false, error: err.message, code: 'PROCESSING_FAILED' as any };
28
+ }).finally(() => {
29
+ active--;
30
+ doneCount++;
31
+ if (options.onProgress) {
32
+ options.onProgress(doneCount, inputs.length);
33
+ }
34
+ checkQueue();
35
+ });
36
+ }
37
+ };
38
+
39
+ checkQueue();
40
+ });
41
+ }
@@ -0,0 +1,58 @@
1
+ import sharp from 'sharp';
2
+ import { BlurRegion, ImageInput, ImageResult, ErrorCode } from '../types.js';
3
+ import { loadImage } from '../utils/load-image.js';
4
+ import { err, ok } from '../utils/result.js';
5
+ import { getImageMetadata, isPositiveInt } from '../utils/validate.js';
6
+
7
+ export async function blurRegion(input: ImageInput, options: { regions: BlurRegion[] }): Promise<ImageResult> {
8
+ try {
9
+ const buffer = await loadImage(input);
10
+ const meta = await getImageMetadata(buffer);
11
+
12
+ if (!options.regions || !Array.isArray(options.regions)) {
13
+ return err('Regions array required', ErrorCode.INVALID_INPUT);
14
+ }
15
+
16
+ const composites: sharp.OverlayOptions[] = [];
17
+
18
+ for (const r of options.regions) {
19
+ if (!isPositiveInt(r.width) || !isPositiveInt(r.height)) {
20
+ return err('Region dimensions must be positive integers', ErrorCode.INVALID_INPUT);
21
+ }
22
+ if (r.x < 0 || r.y < 0 || r.x + r.width > meta.width || r.y + r.height > meta.height) {
23
+ return err('Blur region out of bounds', ErrorCode.OUT_OF_BOUNDS);
24
+ }
25
+
26
+ const radius = r.radius ?? 10;
27
+ if (radius <= 0) return err('Blur radius must be positive', ErrorCode.INVALID_INPUT);
28
+
29
+ const blurredRegion = await sharp(buffer)
30
+ .extract({ left: Math.floor(r.x), top: Math.floor(r.y), width: Math.floor(r.width), height: Math.floor(r.height) })
31
+ .blur(radius)
32
+ .toBuffer();
33
+
34
+ composites.push({
35
+ input: blurredRegion,
36
+ left: Math.floor(r.x),
37
+ top: Math.floor(r.y),
38
+ blend: 'over'
39
+ });
40
+ }
41
+
42
+ if (composites.length === 0) {
43
+ return ok(buffer);
44
+ }
45
+
46
+ const output = await sharp(buffer)
47
+ .composite(composites)
48
+ .toBuffer();
49
+
50
+ return ok(output);
51
+ } catch (e: any) {
52
+ const msg = e.message || '';
53
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
54
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
55
+ if (msg.includes('unsupported image format')) return err('Corrupt or unsupported input', ErrorCode.INVALID_INPUT);
56
+ return err(msg, ErrorCode.PROCESSING_FAILED);
57
+ }
58
+ }
@@ -0,0 +1,56 @@
1
+ import sharp from 'sharp';
2
+ import { CompositeLayer, ImageInput, ImageResult, ErrorCode } from '../types.js';
3
+ import { loadImage } from '../utils/load-image.js';
4
+ import { err, ok } from '../utils/result.js';
5
+
6
+ export async function composite(input: ImageInput, options: { layers: CompositeLayer[] }): Promise<ImageResult> {
7
+ try {
8
+ const buffer = await loadImage(input);
9
+
10
+ if (!options.layers || !Array.isArray(options.layers)) {
11
+ return err('Layers array required', ErrorCode.INVALID_INPUT);
12
+ }
13
+
14
+ const loadLayer = async (layer: CompositeLayer): Promise<sharp.OverlayOptions> => {
15
+ let layerBuf = await loadImage(layer.image);
16
+
17
+ if (layer.opacity !== undefined && layer.opacity >= 0 && layer.opacity < 1) {
18
+ layerBuf = await sharp(layerBuf)
19
+ .ensureAlpha()
20
+ .composite([
21
+ {
22
+ input: Buffer.from([255, 255, 255, Math.round(layer.opacity * 255)]),
23
+ raw: { width: 1, height: 1, channels: 4 },
24
+ tile: true,
25
+ blend: 'dest-in'
26
+ }
27
+ ])
28
+ .toBuffer();
29
+ }
30
+
31
+ return {
32
+ input: layerBuf,
33
+ left: layer.x !== undefined ? Math.floor(layer.x) : 0,
34
+ top: layer.y !== undefined ? Math.floor(layer.y) : 0,
35
+ blend: layer.blend || 'over'
36
+ };
37
+ };
38
+
39
+ const overlays = await Promise.all(options.layers.map(loadLayer));
40
+
41
+ let output = buffer;
42
+ if (overlays.length > 0) {
43
+ output = await sharp(buffer)
44
+ .composite(overlays)
45
+ .toBuffer();
46
+ }
47
+
48
+ return ok(output);
49
+ } catch (e: any) {
50
+ const msg = e.message || '';
51
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
52
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
53
+ if (msg.includes('unsupported image format')) return err('Corrupt or unsupported input', ErrorCode.INVALID_INPUT);
54
+ return err(msg, ErrorCode.PROCESSING_FAILED);
55
+ }
56
+ }
@@ -0,0 +1,46 @@
1
+ import sharp from 'sharp';
2
+ import { ConvertOptions, ImageInput, ImageResult, ErrorCode } from '../types.js';
3
+ import { loadImage } from '../utils/load-image.js';
4
+ import { err, ok } from '../utils/result.js';
5
+
6
+ export async function convert(input: ImageInput, options: ConvertOptions): Promise<ImageResult> {
7
+ try {
8
+ const buffer = await loadImage(input);
9
+ let pipeline = sharp(buffer);
10
+
11
+ const quality = options.quality ?? 80;
12
+ const stripMetadata = options.stripMetadata ?? true;
13
+
14
+ if (!stripMetadata) {
15
+ pipeline = pipeline.keepMetadata();
16
+ }
17
+
18
+ switch (options.format) {
19
+ case 'jpeg':
20
+ pipeline = pipeline.jpeg({ quality });
21
+ break;
22
+ case 'png':
23
+ pipeline = pipeline.png({ quality, compressionLevel: options.compressionLevel ?? 6 });
24
+ break;
25
+ case 'webp':
26
+ pipeline = pipeline.webp({ quality });
27
+ break;
28
+ case 'avif':
29
+ pipeline = pipeline.avif({ quality });
30
+ break;
31
+ case 'gif':
32
+ pipeline = pipeline.gif();
33
+ break;
34
+ default:
35
+ return err('Unsupported format', ErrorCode.UNSUPPORTED_FORMAT);
36
+ }
37
+
38
+ const output = await pipeline.toBuffer();
39
+ return ok(output);
40
+ } catch (e: any) {
41
+ const msg = e.message || '';
42
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
43
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
44
+ return err(msg, ErrorCode.PROCESSING_FAILED);
45
+ }
46
+ }
@@ -0,0 +1,101 @@
1
+ import sharp from 'sharp';
2
+ import { CropOptions, ImageInput, ImageResult, ErrorCode } from '../types.js';
3
+ import { loadImage } from '../utils/load-image.js';
4
+ import { err, ok } from '../utils/result.js';
5
+ import { getImageMetadata, isPositiveInt } from '../utils/validate.js';
6
+ // import { detectSubject } from './detect-subject.js'; // Deferred for now
7
+
8
+ export async function crop(input: ImageInput, options: CropOptions): Promise<ImageResult> {
9
+ try {
10
+ const buffer = await loadImage(input);
11
+ let meta;
12
+ try {
13
+ meta = await getImageMetadata(buffer);
14
+ } catch {
15
+ return err('Corrupt or unsupported input', ErrorCode.INVALID_INPUT);
16
+ }
17
+
18
+ let left = 0, top = 0, width = meta.width, height = meta.height;
19
+
20
+ const mode = options.mode || 'absolute';
21
+
22
+ if (mode === 'absolute') {
23
+ const opt = options as Extract<CropOptions, { mode?: 'absolute' }>;
24
+ // Provide defaults if missing, theoretically
25
+ const optX = opt.x ?? 0;
26
+ const optY = opt.y ?? 0;
27
+ if (!isPositiveInt(opt.width) || !isPositiveInt(opt.height)) {
28
+ return err('Invalid dimensions', ErrorCode.INVALID_INPUT);
29
+ }
30
+ if (optX < 0 || optY < 0 || optX + opt.width > meta.width || optY + opt.height > meta.height) {
31
+ return err('Crop region out of bounds', ErrorCode.OUT_OF_BOUNDS);
32
+ }
33
+ left = optX;
34
+ top = optY;
35
+ width = opt.width;
36
+ height = opt.height;
37
+ } else if (mode === 'ratio') {
38
+ const opt = options as Extract<CropOptions, { mode: 'ratio' }>;
39
+ left = Math.round(meta.width * opt.left);
40
+ top = Math.round(meta.height * opt.top);
41
+ const right = Math.round(meta.width * opt.right);
42
+ const bottom = Math.round(meta.height * opt.bottom);
43
+ width = meta.width - left - right;
44
+ height = meta.height - top - bottom;
45
+ if (width <= 0 || height <= 0 || left < 0 || top < 0 || left + width > meta.width || top + height > meta.height) {
46
+ return err('Invalid ratio calculation out of bounds', ErrorCode.OUT_OF_BOUNDS);
47
+ }
48
+ } else if (mode === 'aspect') {
49
+ const opt = options as Extract<CropOptions, { mode: 'aspect' }>;
50
+ const [wRatio, hRatio] = opt.aspectRatio.split(':').map(Number);
51
+ if (!wRatio || !hRatio || wRatio <= 0 || hRatio <= 0) {
52
+ return err('Invalid aspect ratio format', ErrorCode.INVALID_INPUT);
53
+ }
54
+
55
+ const imageRatio = meta.width / meta.height;
56
+ const targetRatio = wRatio / hRatio;
57
+
58
+ if (imageRatio > targetRatio) {
59
+ // Target is taller/narrower: Height bounded by image height
60
+ height = meta.height;
61
+ width = Math.round(height * targetRatio);
62
+ } else {
63
+ // Target is wider/shorter: Width bounded by image width
64
+ width = meta.width;
65
+ height = Math.round(width / targetRatio);
66
+ }
67
+
68
+ // Handle anchor
69
+ const anchor = opt.anchor || 'center';
70
+ if (anchor === 'center') {
71
+ left = Math.floor((meta.width - width) / 2);
72
+ top = Math.floor((meta.height - height) / 2);
73
+ } else if (anchor === 'top') {
74
+ left = Math.floor((meta.width - width) / 2);
75
+ top = 0;
76
+ } else if (anchor === 'bottom') {
77
+ left = Math.floor((meta.width - width) / 2);
78
+ top = meta.height - height;
79
+ } else if (anchor === 'face') {
80
+ // Fallback for now without detectFaces
81
+ left = Math.floor((meta.width - width) / 2);
82
+ top = Math.floor((meta.height - height) / 2);
83
+ }
84
+ } else if (mode === 'subject') {
85
+ return err('Subject crop not currently implemented in this pass', ErrorCode.MODEL_NOT_FOUND);
86
+ } else {
87
+ return err('Invalid crop mode', ErrorCode.INVALID_INPUT);
88
+ }
89
+
90
+ const output = await sharp(buffer)
91
+ .extract({ left, top, width, height })
92
+ .toBuffer();
93
+
94
+ return ok(output);
95
+ } catch (e: any) {
96
+ const msg = e.message || '';
97
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
98
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
99
+ return err(msg, ErrorCode.PROCESSING_FAILED);
100
+ }
101
+ }
@@ -0,0 +1,41 @@
1
+ import { BoundingBox, ImageInput, Result, ErrorCode } from '../types.js';
2
+ import { loadImage } from '../utils/load-image.js';
3
+ import { ok, err } from '../utils/result.js';
4
+ import { pipeline, RawImage } from '@xenova/transformers';
5
+ import sharp from 'sharp';
6
+
7
+ export async function detectFaces(input: ImageInput): Promise<Result<BoundingBox[]>> {
8
+ try {
9
+ const buffer = await loadImage(input);
10
+ let detector: any;
11
+ try {
12
+ detector = await pipeline('object-detection', 'Xenova/detr-resnet-50', {
13
+ quantized: true,
14
+ });
15
+ } catch (e) {
16
+ return err('Model unavailable. Run: npx image-edit-tools download-models', ErrorCode.MODEL_NOT_FOUND);
17
+ }
18
+
19
+ const rawRgb = await sharp(buffer).ensureAlpha().raw().toBuffer();
20
+ const meta = await sharp(buffer).metadata();
21
+ const img = new RawImage(new Uint8ClampedArray(rawRgb), meta.width!, meta.height!, 4);
22
+
23
+ const results = await detector(img, { threshold: 0.5 });
24
+ const faces = results.filter((r: any) => r.label === 'person' || r.label === 'face');
25
+
26
+ const boxes = faces.map((f: any) => ({
27
+ x: Math.round(f.box.xmin),
28
+ y: Math.round(f.box.ymin),
29
+ width: Math.round(f.box.xmax - f.box.xmin),
30
+ height: Math.round(f.box.ymax - f.box.ymin),
31
+ confidence: f.score
32
+ }));
33
+
34
+ return ok(boxes);
35
+ } catch (e: any) {
36
+ const msg = e.message || '';
37
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
38
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
39
+ return err(msg, ErrorCode.PROCESSING_FAILED);
40
+ }
41
+ }
@@ -0,0 +1,80 @@
1
+ import { BoundingBox, ImageInput, Result, ErrorCode } from '../types.js';
2
+ import { loadImage } from '../utils/load-image.js';
3
+ import { err, ok } from '../utils/result.js';
4
+ import { getImageMetadata } from '../utils/validate.js';
5
+ import sharp from 'sharp';
6
+ import { AutoModel, AutoProcessor, RawImage } from '@xenova/transformers';
7
+
8
+ export async function detectSubject(input: ImageInput): Promise<Result<BoundingBox[]>> {
9
+ try {
10
+ const buffer = await loadImage(input);
11
+ const meta = await getImageMetadata(buffer);
12
+
13
+ let model: any, processor: any;
14
+ try {
15
+ model = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
16
+ config: { model_type: 'custom' },
17
+ quantized: true
18
+ });
19
+ processor = await AutoProcessor.from_pretrained('briaai/RMBG-1.4', {
20
+ config: {
21
+ do_normalize: true, do_pad: false, do_rescale: true, do_resize: true,
22
+ image_mean: [0.5, 0.5, 0.5], image_std: [1, 1, 1], resample: 2, size: { width: 1024, height: 1024 },
23
+ }
24
+ });
25
+ } catch (e: any) {
26
+ return err('Model unavailable. Run: npx image-edit-tools download-models', ErrorCode.MODEL_NOT_FOUND);
27
+ }
28
+
29
+ const rawRgb = await sharp(buffer).ensureAlpha().raw().toBuffer();
30
+ const img = new RawImage(new Uint8ClampedArray(rawRgb), meta.width, meta.height, 4);
31
+
32
+ const { pixel_values } = await processor(img);
33
+ const { output } = await model({ input: pixel_values });
34
+
35
+ const maskData = new Uint8Array(output.data.length);
36
+ for (let i = 0; i < output.data.length; ++i) {
37
+ maskData[i] = Math.max(0, Math.min(255, Math.round(output.data[i] * 255)));
38
+ }
39
+
40
+ const maskBuffer = await sharp(Buffer.from(maskData), { raw: { width: 1024, height: 1024, channels: 1 } })
41
+ .resize(meta.width, meta.height, { fit: 'fill' })
42
+ .raw()
43
+ .toBuffer();
44
+
45
+ let minX = meta.width, minY = meta.height, maxX = 0, maxY = 0;
46
+ let found = false;
47
+
48
+ // maskBuffer has 1 channel, size meta.width * meta.height
49
+ for (let y = 0; y < meta.height; y++) {
50
+ for (let x = 0; x < meta.width; x++) {
51
+ const val = maskBuffer[y * meta.width + x];
52
+ if (val > 128) { // Threshold for subject
53
+ found = true;
54
+ if (x < minX) minX = x;
55
+ if (x > maxX) maxX = x;
56
+ if (y < minY) minY = y;
57
+ if (y > maxY) maxY = y;
58
+ }
59
+ }
60
+ }
61
+
62
+ if (!found) {
63
+ return ok([]);
64
+ }
65
+
66
+ return ok([{
67
+ x: minX,
68
+ y: minY,
69
+ width: maxX - minX + 1,
70
+ height: maxY - minY + 1,
71
+ confidence: 1.0
72
+ }]);
73
+
74
+ } catch (e: any) {
75
+ const msg = e.message || '';
76
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
77
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
78
+ return err(msg, ErrorCode.PROCESSING_FAILED);
79
+ }
80
+ }
@@ -0,0 +1,19 @@
1
+ import { ImageInput, Result, ErrorCode } from '../types.js';
2
+ import { loadImage } from '../utils/load-image.js';
3
+ import { err, ok } from '../utils/result.js';
4
+ import Tesseract from 'tesseract.js';
5
+
6
+ export async function extractText(input: ImageInput, options: { lang?: string } = {}): Promise<Result<string>> {
7
+ try {
8
+ const buffer = await loadImage(input);
9
+ const lang = options.lang || 'eng';
10
+
11
+ const result = await Tesseract.recognize(buffer, lang);
12
+ return ok(result.data.text);
13
+ } catch (e: any) {
14
+ const msg = e.message || '';
15
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
16
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
17
+ return err(msg, ErrorCode.PROCESSING_FAILED);
18
+ }
19
+ }
@@ -0,0 +1,51 @@
1
+ import { FilterOptions, ImageInput, ImageResult, ErrorCode } from '../types.js';
2
+ import { loadImage } from '../utils/load-image.js';
3
+ import { err, ok } from '../utils/result.js';
4
+ import sharp from 'sharp';
5
+
6
+ export async function filter(input: ImageInput, options: FilterOptions): Promise<ImageResult> {
7
+ try {
8
+ const buffer = await loadImage(input);
9
+ let pipeline = sharp(buffer);
10
+
11
+ switch (options.preset) {
12
+ case 'grayscale':
13
+ pipeline = pipeline.grayscale();
14
+ break;
15
+ case 'sepia':
16
+ pipeline = pipeline.tint({ r: 112, g: 66, b: 20 }).modulate({ saturation: 0.5 });
17
+ break;
18
+ case 'invert':
19
+ pipeline = pipeline.negate();
20
+ break;
21
+ case 'vintage':
22
+ // vintage: grayscale -> sepia -> slight contrast reduction -> add film grain (skip grain as composite noise is complex, but we can do a slight tint instead)
23
+ pipeline = pipeline
24
+ .grayscale()
25
+ .tint({ r: 112, g: 66, b: 20 })
26
+ .modulate({ saturation: 0.8, brightness: 1.1 })
27
+ .linear(0.9, -(128 * 0.9) + 128);
28
+ break;
29
+ case 'unsharp':
30
+ pipeline = pipeline.sharpen({ sigma: 1.5, m1: 0.5, m2: 0.1 });
31
+ break;
32
+ case 'blur':
33
+ if (options.radius === undefined || options.radius <= 0) {
34
+ return err('Blur radius must be a positive number', ErrorCode.INVALID_INPUT);
35
+ }
36
+ pipeline = pipeline.blur(options.radius);
37
+ break;
38
+ default:
39
+ return err('Invalid filter preset', ErrorCode.INVALID_INPUT);
40
+ }
41
+
42
+ const output = await pipeline.toBuffer();
43
+ return ok(output);
44
+ } catch (e: any) {
45
+ const msg = e.message || '';
46
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
47
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
48
+ if (msg.includes('unsupported image format')) return err('Corrupt or unsupported input', ErrorCode.INVALID_INPUT);
49
+ return err(msg, ErrorCode.PROCESSING_FAILED);
50
+ }
51
+ }
@@ -0,0 +1,41 @@
1
+ import { ImageInput, Result, ErrorCode } from '../types.js';
2
+ import { loadImage } from '../utils/load-image.js';
3
+ import { err, ok } from '../utils/result.js';
4
+ import { getPaletteFromURL } from 'color-thief-node';
5
+ import { writeFileSync, unlinkSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { tmpdir } from 'os';
8
+
9
+ export async function getDominantColors(input: ImageInput, count: number = 5): Promise<Result<string[]>> {
10
+ let tmpFile = '';
11
+ try {
12
+ const buffer = await loadImage(input);
13
+ tmpFile = join(tmpdir(), `ct-${Date.now()}-${Math.random()}.jpg`);
14
+ writeFileSync(tmpFile, buffer);
15
+
16
+ const palette = await getPaletteFromURL(tmpFile, count);
17
+ if (tmpFile) {
18
+ try { unlinkSync(tmpFile) } catch(e){}
19
+ }
20
+
21
+ const hexColors = palette.map((rgb: number[]) => {
22
+ return '#' + rgb.map((x: number) => x.toString(16).padStart(2, '0')).join('');
23
+ });
24
+
25
+ while (hexColors.length > count) hexColors.pop();
26
+ while (hexColors.length > 0 && hexColors.length < count) hexColors.push(hexColors[hexColors.length - 1]);
27
+
28
+ // If image has only 1 color and getPaletteFromURL completely fails to return array,
29
+ if (hexColors.length === 0) hexColors.push('#000000');
30
+
31
+ return ok(hexColors);
32
+ } catch (e: any) {
33
+ if (tmpFile) {
34
+ try { unlinkSync(tmpFile) } catch(e){}
35
+ }
36
+ const msg = e.message || '';
37
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
38
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
39
+ return err(msg, ErrorCode.PROCESSING_FAILED);
40
+ }
41
+ }
@@ -0,0 +1,28 @@
1
+ import sharp from 'sharp';
2
+ import { ImageMetadata, ImageInput, Result, ErrorCode } from '../types.js';
3
+ import { loadImage } from '../utils/load-image.js';
4
+ import { err, ok } from '../utils/result.js';
5
+
6
+ export async function getMetadata(input: ImageInput): Promise<Result<ImageMetadata>> {
7
+ try {
8
+ const buffer = await loadImage(input);
9
+ const meta = await sharp(buffer).metadata();
10
+
11
+ return ok({
12
+ width: meta.width || 0,
13
+ height: meta.height || 0,
14
+ format: meta.format || 'unknown',
15
+ fileSize: meta.size || buffer.length,
16
+ colorSpace: meta.space,
17
+ hasAlpha: meta.hasAlpha || false,
18
+ channels: meta.channels || 3,
19
+ density: meta.density,
20
+ exif: meta.exif ? { bufferLength: meta.exif.length } : undefined
21
+ });
22
+ } catch (e: any) {
23
+ const msg = e.message || '';
24
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
25
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
26
+ return err(msg, ErrorCode.PROCESSING_FAILED);
27
+ }
28
+ }