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,77 @@
1
+ import sharp from 'sharp';
2
+ import { OptimizeOptions, ImageInput, ImageResult, ErrorCode } from '../types.js';
3
+ import { loadImage } from '../utils/load-image.js';
4
+ import { getImageMetadata } from '../utils/validate.js';
5
+ import { err, ok } from '../utils/result.js';
6
+
7
+ export async function optimize(input: ImageInput, options: OptimizeOptions): Promise<ImageResult> {
8
+ try {
9
+ const buffer = await loadImage(input);
10
+ const meta = await getImageMetadata(buffer);
11
+
12
+ let pipeline = sharp(buffer);
13
+
14
+ let format = meta.format;
15
+ const autoFormat = options.autoFormat ?? true;
16
+ if (autoFormat) {
17
+ format = meta.hasAlpha ? 'webp' : 'jpeg';
18
+ }
19
+
20
+ if (options.maxDimension) {
21
+ if (options.maxDimension <= 0) return err('maxDimension must be positive', ErrorCode.INVALID_INPUT);
22
+ if (meta.width > options.maxDimension || meta.height > options.maxDimension) {
23
+ if (meta.width > meta.height) {
24
+ pipeline = pipeline.resize({ width: options.maxDimension, withoutEnlargement: true });
25
+ } else {
26
+ pipeline = pipeline.resize({ height: options.maxDimension, withoutEnlargement: true });
27
+ }
28
+ }
29
+ }
30
+
31
+ const applyFormat = (p: sharp.Sharp, f: string, q: number) => {
32
+ if (f === 'webp') return p.webp({ quality: q });
33
+ if (f === 'png') return p.png({ quality: q, compressionLevel: 9 });
34
+ if (f === 'avif') return p.avif({ quality: q });
35
+ return p.jpeg({ quality: q });
36
+ };
37
+
38
+ if (options.maxSizeKB) {
39
+ if (options.maxSizeKB <= 0) return err('maxSizeKB must be positive', ErrorCode.INVALID_INPUT);
40
+ const targetBytes = options.maxSizeKB * 1024;
41
+
42
+ let minQ = 10, maxQ = 95;
43
+ let bestBuffer: Buffer | null = null;
44
+ let bestDiff = Infinity;
45
+
46
+ for (let i = 0; i < 7; i++) {
47
+ const midQ = Math.floor((minQ + maxQ) / 2);
48
+ const testBuf = await applyFormat(pipeline.clone(), format, midQ).toBuffer();
49
+ const diff = targetBytes - testBuf.length;
50
+
51
+ if (testBuf.length <= targetBytes && diff < bestDiff && diff >= 0) {
52
+ bestBuffer = testBuf;
53
+ bestDiff = diff;
54
+ }
55
+
56
+ if (testBuf.length > targetBytes) {
57
+ maxQ = midQ - 1;
58
+ } else {
59
+ minQ = midQ + 1;
60
+ }
61
+ }
62
+
63
+ if (bestBuffer) return ok(bestBuffer);
64
+
65
+ const fallback = await applyFormat(pipeline.clone(), format, 10).toBuffer();
66
+ return ok(fallback);
67
+ } else {
68
+ const output = await applyFormat(pipeline, format, 80).toBuffer();
69
+ return ok(output);
70
+ }
71
+ } catch (e: any) {
72
+ const msg = e.message || '';
73
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
74
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
75
+ return err(msg, ErrorCode.PROCESSING_FAILED);
76
+ }
77
+ }
@@ -0,0 +1,51 @@
1
+ import sharp from 'sharp';
2
+ import { OverlayOptions, 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 overlay(input: ImageInput, overlayImage: ImageInput, options: OverlayOptions = {}): Promise<ImageResult> {
7
+ try {
8
+ const buffer = await loadImage(input);
9
+ let layerBuf = await loadImage(overlayImage);
10
+
11
+ if (options.opacity !== undefined && options.opacity >= 0 && options.opacity < 1) {
12
+ layerBuf = await sharp(layerBuf)
13
+ .ensureAlpha()
14
+ .composite([
15
+ {
16
+ input: Buffer.from([255, 255, 255, Math.round(options.opacity * 255)]),
17
+ raw: { width: 1, height: 1, channels: 4 },
18
+ tile: true,
19
+ blend: 'dest-in'
20
+ }
21
+ ])
22
+ .toBuffer();
23
+ }
24
+
25
+ let gravity = options.gravity ? options.gravity.toLowerCase() : undefined;
26
+
27
+ const compositeOpts: sharp.OverlayOptions = {
28
+ input: layerBuf,
29
+ blend: options.blend || 'over'
30
+ };
31
+
32
+ if (gravity) {
33
+ compositeOpts.gravity = gravity;
34
+ } else {
35
+ if (options.offsetX !== undefined) compositeOpts.left = Math.floor(options.offsetX);
36
+ if (options.offsetY !== undefined) compositeOpts.top = Math.floor(options.offsetY);
37
+ }
38
+
39
+ const output = await sharp(buffer)
40
+ .composite([compositeOpts])
41
+ .toBuffer();
42
+
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
+ }
package/src/ops/pad.ts ADDED
@@ -0,0 +1,63 @@
1
+ import sharp from 'sharp';
2
+ import { PadOptions, 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 } from '../utils/validate.js';
6
+
7
+ export async function pad(input: ImageInput, options: PadOptions): Promise<ImageResult> {
8
+ try {
9
+ const buffer = await loadImage(input);
10
+ let top = 0, right = 0, bottom = 0, left = 0;
11
+
12
+ if (options.size !== undefined) {
13
+ if (options.size <= 0) return err('Size must be positive', ErrorCode.INVALID_INPUT);
14
+ const meta = await getImageMetadata(buffer);
15
+ const targetSize = options.size;
16
+
17
+ if (targetSize < meta.width || targetSize < meta.height) {
18
+ return err('Target size must be larger than image dimensions', ErrorCode.INVALID_INPUT);
19
+ }
20
+
21
+ const missingTotalW = targetSize - meta.width;
22
+ const missingTotalH = targetSize - meta.height;
23
+ left = Math.floor(missingTotalW / 2);
24
+ right = missingTotalW - left;
25
+ top = Math.floor(missingTotalH / 2);
26
+ bottom = missingTotalH - top;
27
+ } else {
28
+ top = options.top || 0;
29
+ right = options.right || 0;
30
+ bottom = options.bottom || 0;
31
+ left = options.left || 0;
32
+ }
33
+
34
+ if (top < 0 || right < 0 || bottom < 0 || left < 0) {
35
+ return err('Padding values cannot be negative', ErrorCode.INVALID_INPUT);
36
+ }
37
+
38
+ let background: string | sharp.RGBA = options.color || '#ffffff';
39
+ let pipeline = sharp(buffer);
40
+ if (options.color?.toLowerCase() === 'transparent') {
41
+ background = { r: 0, g: 0, b: 0, alpha: 0 };
42
+ pipeline = pipeline.png();
43
+ }
44
+
45
+ const output = await pipeline
46
+ .extend({
47
+ top,
48
+ bottom,
49
+ left,
50
+ right,
51
+ background
52
+ })
53
+ .toBuffer();
54
+
55
+ return ok(output);
56
+ } catch (e: any) {
57
+ const msg = e.message || '';
58
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
59
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
60
+ if (msg.includes('unsupported image format')) return err('Corrupt or unsupported input', ErrorCode.INVALID_INPUT);
61
+ return err(msg, ErrorCode.PROCESSING_FAILED);
62
+ }
63
+ }
@@ -0,0 +1,61 @@
1
+ import { ImageInput, ImageResult, PipelineOperation, ErrorCode } from '../types.js';
2
+ import { crop } from './crop.js';
3
+ import { resize } from './resize.js';
4
+ import { pad } from './pad.js';
5
+ import { adjust } from './adjust.js';
6
+ import { filter } from './filter.js';
7
+ import { blurRegion } from './blur-region.js';
8
+ import { addText } from './add-text.js';
9
+ import { composite } from './composite.js';
10
+ import { watermark } from './watermark.js';
11
+ import { convert } from './convert.js';
12
+ import { optimize } from './optimize.js';
13
+ import { removeBg } from './remove-bg.js';
14
+
15
+ export async function pipeline(input: ImageInput, operations: PipelineOperation[]): Promise<ImageResult & { step?: number }> {
16
+ let currentImage = input;
17
+
18
+ for (let i = 0; i < operations.length; i++) {
19
+ const op = operations[i];
20
+ let result: ImageResult;
21
+
22
+ try {
23
+ switch (op.op) {
24
+ case 'crop': result = await crop(currentImage, op); break;
25
+ case 'resize': result = await resize(currentImage, op); break;
26
+ case 'pad': result = await pad(currentImage, op); break;
27
+ case 'adjust': result = await adjust(currentImage, op); break;
28
+ case 'filter': result = await filter(currentImage, op); break;
29
+ case 'blurRegion': result = await blurRegion(currentImage, { regions: op.regions }); break;
30
+ case 'addText': result = await addText(currentImage, { layers: op.layers }); break;
31
+ case 'composite': result = await composite(currentImage, { layers: op.layers }); break;
32
+ case 'watermark': result = await watermark(currentImage, op); break;
33
+ case 'convert': result = await convert(currentImage, op); break;
34
+ case 'optimize': result = await optimize(currentImage, op); break;
35
+ case 'removeBg': result = await removeBg(currentImage, op); break;
36
+ default:
37
+ return { ok: false, error: `Unknown operation`, code: ErrorCode.INVALID_INPUT, step: i };
38
+ }
39
+
40
+ if (!result.ok) {
41
+ return { ...result, step: i };
42
+ }
43
+
44
+ currentImage = result.data;
45
+ } catch (e: any) {
46
+ return { ok: false, error: e.message, code: ErrorCode.PROCESSING_FAILED, step: i };
47
+ }
48
+ }
49
+
50
+ if (operations.length === 0) {
51
+ const { loadImage } = await import('../utils/load-image.js');
52
+ try {
53
+ const buf = await loadImage(input);
54
+ return { ok: true, data: buf };
55
+ } catch(e:any) {
56
+ return { ok: false, error: 'Failed to load initial image', code: ErrorCode.INVALID_INPUT, step: 0 };
57
+ }
58
+ }
59
+
60
+ return { ok: true, data: currentImage as Buffer };
61
+ }
@@ -0,0 +1,82 @@
1
+ import sharp from 'sharp';
2
+ import { RemoveBgOptions, 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 } from '../utils/validate.js';
6
+ import { AutoModel, AutoProcessor, RawImage } from '@xenova/transformers';
7
+
8
+ export async function removeBg(input: ImageInput, options: RemoveBgOptions = {}): Promise<ImageResult> {
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 // Use quantized for speed and avoiding OOM
18
+ });
19
+ processor = await AutoProcessor.from_pretrained('briaai/RMBG-1.4', {
20
+ config: {
21
+ do_normalize: true,
22
+ do_pad: false,
23
+ do_rescale: true,
24
+ do_resize: true,
25
+ image_mean: [0.5, 0.5, 0.5],
26
+ image_std: [1, 1, 1],
27
+ resample: 2,
28
+ size: { width: 1024, height: 1024 },
29
+ }
30
+ });
31
+ } catch (e: any) {
32
+ return err('Model unavailable. Run: npx image-edit-tools download-models', ErrorCode.MODEL_NOT_FOUND);
33
+ }
34
+
35
+ // Process image
36
+ const rawImage = await RawImage.fromURL(URL.createObjectURL(new Blob([buffer]))); // Alternative for node:
37
+ // RawImage from buffer is easier using sharp. We need uint8 array of RGB or RGBA.
38
+ const rawRgb = await sharp(buffer).ensureAlpha().raw().toBuffer();
39
+ const img = new RawImage(new Uint8ClampedArray(rawRgb), meta.width, meta.height, 4);
40
+
41
+ const { pixel_values } = await processor(img);
42
+ const { output } = await model({ input: pixel_values });
43
+
44
+ // Output is a mask tensor. Resize mask back to original dimensions using sharp.
45
+ // The model outputs size 1024x1024.
46
+ const maskData = new Uint8Array(output.data.length);
47
+ for (let i = 0; i < output.data.length; ++i) {
48
+ maskData[i] = Math.max(0, Math.min(255, Math.round(output.data[i] * 255))); // Assume output is 0.0 to 1.0 floats
49
+ }
50
+
51
+ const maskBuffer = await sharp(Buffer.from(maskData), { raw: { width: 1024, height: 1024, channels: 1 } })
52
+ .resize(meta.width, meta.height, { fit: 'fill' }) // match original size
53
+ .toBuffer();
54
+
55
+ let outputBuf = await sharp(buffer)
56
+ .ensureAlpha()
57
+ .joinChannel(maskBuffer) // join mask as alpha channel
58
+ .png()
59
+ .toBuffer();
60
+
61
+ // Replace color or image
62
+ if (options.replaceColor) {
63
+ outputBuf = await sharp(outputBuf)
64
+ .flatten({ background: options.replaceColor })
65
+ .toBuffer();
66
+ } else if (options.replaceImage) {
67
+ const bgBuf = await loadImage(options.replaceImage);
68
+ // Resize background to match image if needed, or simply composite subject over background
69
+ const sizedBgBuf = await sharp(bgBuf).resize(meta.width, meta.height, { fit: 'cover' }).toBuffer();
70
+ outputBuf = await sharp(sizedBgBuf)
71
+ .composite([{ input: outputBuf, blend: 'over' }])
72
+ .toBuffer();
73
+ }
74
+
75
+ return ok(outputBuf);
76
+ } catch (e: any) {
77
+ const msg = e.message || '';
78
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
79
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
80
+ return err(msg, ErrorCode.PROCESSING_FAILED);
81
+ }
82
+ }
@@ -0,0 +1,54 @@
1
+ import sharp from 'sharp';
2
+ import { ResizeOptions, 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 resize(input: ImageInput, options: ResizeOptions): Promise<ImageResult> {
8
+ try {
9
+ const buffer = await loadImage(input);
10
+
11
+ let width = options.width;
12
+ let height = options.height;
13
+
14
+ if (options.scale) {
15
+ if (options.scale <= 0) return err('Scale must be positive', ErrorCode.INVALID_INPUT);
16
+ const meta = await getImageMetadata(buffer);
17
+ width = Math.round(meta.width * options.scale);
18
+ height = Math.round(meta.height * options.scale);
19
+ }
20
+
21
+ if (width !== undefined && !isPositiveInt(width)) {
22
+ return err('Width must be a positive integer', ErrorCode.INVALID_INPUT);
23
+ }
24
+ if (height !== undefined && !isPositiveInt(height)) {
25
+ return err('Height must be a positive integer', ErrorCode.INVALID_INPUT);
26
+ }
27
+
28
+ if (width === undefined && height === undefined) {
29
+ return err('Must provide width, height, or scale', ErrorCode.INVALID_INPUT);
30
+ }
31
+
32
+ let kernel: keyof sharp.KernelEnum | undefined;
33
+ if (options.kernel === 'linear') kernel = 'mitchell';
34
+ else if (options.kernel) kernel = options.kernel;
35
+
36
+ const output = await sharp(buffer)
37
+ .resize({
38
+ width,
39
+ height,
40
+ fit: options.fit,
41
+ kernel,
42
+ withoutEnlargement: options.withoutEnlargement
43
+ })
44
+ .toBuffer();
45
+
46
+ return ok(output);
47
+ } catch (e: any) {
48
+ const msg = e.message || '';
49
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
50
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
51
+ if (msg.includes('unsupported image format')) return err('Corrupt or unsupported input', ErrorCode.INVALID_INPUT);
52
+ return err(msg, ErrorCode.PROCESSING_FAILED);
53
+ }
54
+ }
@@ -0,0 +1,141 @@
1
+ import sharp from 'sharp';
2
+ import { WatermarkOptions, ImageInput, ImageResult, ErrorCode, TextAnchor } from '../types.js';
3
+ import { loadImage } from '../utils/load-image.js';
4
+ import { err, ok } from '../utils/result.js';
5
+ import { getImageMetadata } from '../utils/validate.js';
6
+ import { addText } from './add-text.js';
7
+ import { resize } from './resize.js';
8
+
9
+ const posToGravity: Record<string, string> = {
10
+ 'top-left': 'northwest',
11
+ 'top-center': 'north',
12
+ 'top-right': 'northeast',
13
+ 'center': 'center',
14
+ 'bottom-left': 'southwest',
15
+ 'bottom-center': 'south',
16
+ 'bottom-right': 'southeast'
17
+ };
18
+
19
+ const posToAnchor: Record<string, TextAnchor> = {
20
+ 'top-left': 'top-left',
21
+ 'top-center': 'top-center',
22
+ 'top-right': 'top-right',
23
+ 'center': 'center',
24
+ 'bottom-left': 'bottom-left',
25
+ 'bottom-center': 'bottom-center',
26
+ 'bottom-right': 'bottom-right'
27
+ };
28
+
29
+ export async function watermark(input: ImageInput, options: WatermarkOptions): Promise<ImageResult> {
30
+ try {
31
+ const buffer = await loadImage(input);
32
+ const meta = await getImageMetadata(buffer);
33
+ const position = options.position || 'bottom-right';
34
+
35
+ if (options.type === 'text') {
36
+ if (position === 'tile') {
37
+ const spacing = options.tileSpacing ?? 50;
38
+ const fontSize = options.fontSize ?? 24;
39
+ const color = options.color ?? '#ffffff';
40
+ const opacity = options.opacity ?? 0.5;
41
+
42
+ const charWidth = fontSize * 0.6;
43
+ const w = options.text.length * charWidth;
44
+ const h = fontSize;
45
+
46
+ const cols = Math.ceil(meta.width / Math.max(1, (w + spacing)));
47
+ const rows = Math.ceil(meta.height / Math.max(1, (h + spacing)));
48
+
49
+ const layers: any[] = [];
50
+ for (let r = 0; r < rows; r++) {
51
+ for (let c = 0; c < cols; c++) {
52
+ layers.push({
53
+ text: options.text,
54
+ x: c * (w + spacing),
55
+ y: r * (h + spacing),
56
+ fontSize,
57
+ color,
58
+ opacity,
59
+ anchor: 'top-left'
60
+ });
61
+ }
62
+ }
63
+ return addText(buffer, { layers });
64
+ } else {
65
+ const anchor = posToAnchor[position] || 'bottom-right';
66
+ let x = 0, y = 0;
67
+ const pad = 10;
68
+ const fontSize = options.fontSize ?? 24;
69
+
70
+ if (position.includes('left')) x = pad;
71
+ else if (position.includes('right')) x = meta.width - pad;
72
+ else x = meta.width / 2;
73
+
74
+ if (position.includes('top')) y = pad;
75
+ else if (position.includes('bottom')) y = meta.height - pad;
76
+ else y = meta.height / 2;
77
+
78
+ return addText(buffer, {
79
+ layers: [{
80
+ text: options.text,
81
+ x, y, anchor,
82
+ fontSize,
83
+ color: options.color ?? '#ffffff',
84
+ opacity: options.opacity ?? 0.5
85
+ }]
86
+ });
87
+ }
88
+ } else if (options.type === 'image') {
89
+ let wmBuf = await loadImage(options.image);
90
+ const wmMeta = await getImageMetadata(wmBuf);
91
+
92
+ if (options.scale && options.scale !== 1.0) {
93
+ const r = await resize(wmBuf, { scale: options.scale });
94
+ if (!r.ok) return r as ImageResult;
95
+ wmBuf = r.data;
96
+ }
97
+
98
+ if (options.opacity !== undefined && options.opacity >= 0 && options.opacity < 1) {
99
+ wmBuf = await sharp(wmBuf)
100
+ .ensureAlpha()
101
+ .composite([
102
+ {
103
+ input: Buffer.from([255, 255, 255, Math.round(options.opacity * 255)]),
104
+ raw: { width: 1, height: 1, channels: 4 },
105
+ tile: true,
106
+ blend: 'dest-in'
107
+ }
108
+ ])
109
+ .toBuffer();
110
+ }
111
+
112
+ if (position === 'tile') {
113
+ const spacing = options.tileSpacing ?? 0;
114
+ if (spacing > 0) {
115
+ wmBuf = await sharp(wmBuf)
116
+ .ensureAlpha()
117
+ .extend({ bottom: spacing, right: spacing, background: {r:0,g:0,b:0,alpha:0} })
118
+ .toBuffer();
119
+ }
120
+ const output = await sharp(buffer)
121
+ .composite([{ input: wmBuf, tile: true, blend: 'over' }])
122
+ .toBuffer();
123
+ return ok(output);
124
+ } else {
125
+ const gravity = posToGravity[position] || 'southeast';
126
+ const output = await sharp(buffer)
127
+ .composite([{ input: wmBuf, gravity, blend: 'over' }])
128
+ .toBuffer();
129
+ return ok(output);
130
+ }
131
+ }
132
+
133
+ return err('Invalid watermark type', ErrorCode.INVALID_INPUT);
134
+ } catch (e: any) {
135
+ const msg = e.message || '';
136
+ if (msg.includes('HTTP')) return err(msg, ErrorCode.FETCH_FAILED);
137
+ if (msg.includes('ENOENT')) return err('File not found', ErrorCode.INVALID_INPUT);
138
+ if (msg.includes('unsupported image format')) return err('Corrupt or unsupported input', ErrorCode.INVALID_INPUT);
139
+ return err(msg, ErrorCode.PROCESSING_FAILED);
140
+ }
141
+ }
@@ -0,0 +1,4 @@
1
+ declare module 'color-thief-node' {
2
+ export function getPaletteFromURL(url: string, count?: number, quality?: number): Promise<number[][]>;
3
+ export function getColorFromURL(url: string, quality?: number): Promise<number[]>;
4
+ }