@happyvertical/images 0.80.0 → 0.80.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/sharp.d.ts.map +1 -1
- package/dist/cli/claude-context.js +17 -17
- package/dist/cli/claude-context.js.map +1 -1
- package/dist/index.js +1456 -1261
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,1302 +1,1497 @@
|
|
|
1
1
|
import { createHmac } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __exportAll = (all, no_symbols) => {
|
|
5
|
+
let target = {};
|
|
6
|
+
for (var name in all) __defProp(target, name, {
|
|
7
|
+
get: all[name],
|
|
8
|
+
enumerable: true
|
|
9
|
+
});
|
|
10
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
11
|
+
return target;
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/shared/errors.ts
|
|
15
|
+
/**
|
|
16
|
+
* Error classes for the images library
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Base error class for image processing operations
|
|
20
|
+
*/
|
|
21
|
+
var ImageError = class extends Error {
|
|
22
|
+
code;
|
|
23
|
+
adapter;
|
|
24
|
+
constructor(message, code, adapter) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.adapter = adapter;
|
|
28
|
+
this.name = "ImageError";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Error thrown when an image file is not found
|
|
33
|
+
*/
|
|
34
|
+
var ImageNotFoundError = class extends ImageError {
|
|
35
|
+
constructor(path, adapter) {
|
|
36
|
+
super(`Image not found: ${path}`, "IMAGE_NOT_FOUND", adapter);
|
|
37
|
+
this.name = "ImageNotFoundError";
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Error thrown when image format is not supported
|
|
42
|
+
*/
|
|
43
|
+
var UnsupportedFormatError = class extends ImageError {
|
|
44
|
+
constructor(format, adapter) {
|
|
45
|
+
super(`Unsupported image format: ${format}`, "UNSUPPORTED_FORMAT", adapter);
|
|
46
|
+
this.name = "UnsupportedFormatError";
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Error thrown when an operation is not supported by the adapter
|
|
51
|
+
*/
|
|
52
|
+
var OperationNotSupportedError = class extends ImageError {
|
|
53
|
+
constructor(operation, adapter) {
|
|
54
|
+
super(`Operation not supported: ${operation}`, "OPERATION_NOT_SUPPORTED", adapter);
|
|
55
|
+
this.name = "OperationNotSupportedError";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Error thrown when image processing fails
|
|
60
|
+
*/
|
|
61
|
+
var ProcessingError = class extends ImageError {
|
|
62
|
+
cause;
|
|
63
|
+
constructor(message, adapter, cause) {
|
|
64
|
+
super(message, "PROCESSING_ERROR", adapter);
|
|
65
|
+
this.cause = cause;
|
|
66
|
+
this.name = "ProcessingError";
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Error thrown when adapter type is invalid
|
|
71
|
+
*/
|
|
72
|
+
var InvalidAdapterError = class extends ImageError {
|
|
73
|
+
constructor(type) {
|
|
74
|
+
super(`Invalid adapter type: ${type}`, "INVALID_ADAPTER");
|
|
75
|
+
this.name = "InvalidAdapterError";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Error thrown when remote service (e.g., imgproxy) fails
|
|
80
|
+
*/
|
|
81
|
+
var RemoteServiceError = class extends ImageError {
|
|
82
|
+
statusCode;
|
|
83
|
+
constructor(message, adapter, statusCode) {
|
|
84
|
+
super(message, "REMOTE_SERVICE_ERROR", adapter);
|
|
85
|
+
this.statusCode = statusCode;
|
|
86
|
+
this.name = "RemoteServiceError";
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/adapters/imgproxy.ts
|
|
91
|
+
/**
|
|
92
|
+
* imgproxy adapter for remote image processing
|
|
93
|
+
*
|
|
94
|
+
* Uses a self-hosted imgproxy server for scalable image processing.
|
|
95
|
+
* Ideal for enterprise deployments where processing is offloaded to dedicated servers.
|
|
96
|
+
*
|
|
97
|
+
* @see https://imgproxy.net/
|
|
98
|
+
*/
|
|
99
|
+
var imgproxy_exports = /* @__PURE__ */ __exportAll({
|
|
100
|
+
ImgproxyAdapter: () => ImgproxyAdapter,
|
|
101
|
+
signImgproxyUrl: () => signImgproxyUrl
|
|
102
|
+
});
|
|
103
|
+
/**
|
|
104
|
+
* Generate a signed imgproxy URL
|
|
105
|
+
*
|
|
106
|
+
* Standalone function for generating signed URLs without creating an adapter instance.
|
|
107
|
+
* Useful for client-side usage or when you only need URL generation.
|
|
108
|
+
*
|
|
109
|
+
* @param source - Source image URL (must be http/https)
|
|
110
|
+
* @param config - imgproxy server configuration
|
|
111
|
+
* @param options - Processing options
|
|
112
|
+
* @returns Signed imgproxy URL
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* ```typescript
|
|
116
|
+
* import { signImgproxyUrl } from '@happyvertical/images';
|
|
117
|
+
*
|
|
118
|
+
* const url = signImgproxyUrl(
|
|
119
|
+
* 'https://example.com/image.jpg',
|
|
120
|
+
* {
|
|
121
|
+
* baseUrl: 'https://imgproxy.example.com',
|
|
122
|
+
* key: 'hex-encoded-key',
|
|
123
|
+
* salt: 'hex-encoded-salt'
|
|
124
|
+
* },
|
|
125
|
+
* { width: 300, height: 200, format: 'webp' }
|
|
126
|
+
* );
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
52
129
|
function signImgproxyUrl(source, config, options = {}) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return adapter.getSignedUrl(source, options);
|
|
60
|
-
}
|
|
61
|
-
class ImgproxyAdapter {
|
|
62
|
-
baseUrl;
|
|
63
|
-
key;
|
|
64
|
-
salt;
|
|
65
|
-
constructor(options) {
|
|
66
|
-
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
67
|
-
if (options.key && options.salt) {
|
|
68
|
-
this.key = Buffer.from(options.key, "hex");
|
|
69
|
-
this.salt = Buffer.from(options.salt, "hex");
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Generate a signed imgproxy URL without fetching the image
|
|
74
|
-
*
|
|
75
|
-
* This is useful for:
|
|
76
|
-
* - Client-side usage where you want to display images directly
|
|
77
|
-
* - Debugging URL signing issues
|
|
78
|
-
* - Pre-generating URLs for batch processing
|
|
79
|
-
*
|
|
80
|
-
* @param source - Source image URL (must be http/https)
|
|
81
|
-
* @param options - Processing options
|
|
82
|
-
* @returns Signed imgproxy URL
|
|
83
|
-
*
|
|
84
|
-
* @example
|
|
85
|
-
* ```typescript
|
|
86
|
-
* const adapter = new ImgproxyAdapter({
|
|
87
|
-
* type: 'imgproxy',
|
|
88
|
-
* baseUrl: 'https://imgproxy.example.com',
|
|
89
|
-
* key: 'hex-key',
|
|
90
|
-
* salt: 'hex-salt'
|
|
91
|
-
* });
|
|
92
|
-
*
|
|
93
|
-
* // Generate thumbnail URL
|
|
94
|
-
* const url = adapter.getSignedUrl('https://example.com/image.jpg', {
|
|
95
|
-
* width: 300,
|
|
96
|
-
* height: 200,
|
|
97
|
-
* fit: 'cover',
|
|
98
|
-
* format: 'webp'
|
|
99
|
-
* });
|
|
100
|
-
* // Returns: https://imgproxy.example.com/SIGNATURE/rs:fill:300:200/aHR0cHM6Ly9.../image.webp
|
|
101
|
-
* ```
|
|
102
|
-
*/
|
|
103
|
-
getSignedUrl(source, options = {}) {
|
|
104
|
-
if (!source.startsWith("http://") && !source.startsWith("https://")) {
|
|
105
|
-
throw new ProcessingError(
|
|
106
|
-
`imgproxy requires HTTP/HTTPS URLs as source. Got: ${source.substring(0, 50)}...`,
|
|
107
|
-
"imgproxy"
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
const resizeType = this.mapFitToResizeType(options.fit || "cover");
|
|
111
|
-
const processing = this.buildProcessingString({
|
|
112
|
-
resize: resizeType,
|
|
113
|
-
width: options.width || 0,
|
|
114
|
-
height: options.height || 0,
|
|
115
|
-
quality: options.quality,
|
|
116
|
-
format: options.format
|
|
117
|
-
});
|
|
118
|
-
return this.buildUrl(source, processing, options.format);
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Sign a path using HMAC-SHA256
|
|
122
|
-
*/
|
|
123
|
-
sign(path) {
|
|
124
|
-
if (!this.key || !this.salt) {
|
|
125
|
-
return "unsafe";
|
|
126
|
-
}
|
|
127
|
-
const hmac = createHmac("sha256", this.key);
|
|
128
|
-
hmac.update(this.salt);
|
|
129
|
-
hmac.update(path);
|
|
130
|
-
return hmac.digest("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "").substring(0, 32);
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* Encode source URL for imgproxy
|
|
134
|
-
*/
|
|
135
|
-
encodeSource(source) {
|
|
136
|
-
return Buffer.from(source).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* Build imgproxy URL
|
|
140
|
-
*/
|
|
141
|
-
buildUrl(source, processing, extension) {
|
|
142
|
-
const encodedSource = this.encodeSource(source);
|
|
143
|
-
const ext = extension ? `.${extension}` : "";
|
|
144
|
-
const path = `/${processing}/${encodedSource}${ext}`;
|
|
145
|
-
const signature = this.sign(path);
|
|
146
|
-
return `${this.baseUrl}/${signature}${path}`;
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* Convert ImageInput to URL string
|
|
150
|
-
*/
|
|
151
|
-
toUrl(input) {
|
|
152
|
-
if (Buffer.isBuffer(input)) {
|
|
153
|
-
throw new ProcessingError(
|
|
154
|
-
"imgproxy adapter requires URL input, not Buffer. Upload the buffer to a storage service first.",
|
|
155
|
-
"imgproxy"
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
if (!input.startsWith("http://") && !input.startsWith("https://")) {
|
|
159
|
-
throw new ProcessingError(
|
|
160
|
-
`imgproxy adapter requires HTTP/HTTPS URLs as input. Got: ${input.substring(0, 50)}...`,
|
|
161
|
-
"imgproxy"
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
return input;
|
|
165
|
-
}
|
|
166
|
-
async getDimensions(input) {
|
|
167
|
-
const url = this.toUrl(input);
|
|
168
|
-
try {
|
|
169
|
-
const infoUrl = this.buildUrl(url, "info:1");
|
|
170
|
-
const response = await fetch(infoUrl);
|
|
171
|
-
if (!response.ok) {
|
|
172
|
-
throw new RemoteServiceError(
|
|
173
|
-
`imgproxy info request failed: ${response.statusText}`,
|
|
174
|
-
"imgproxy",
|
|
175
|
-
response.status
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
const info = await response.json();
|
|
179
|
-
return {
|
|
180
|
-
width: info.width,
|
|
181
|
-
height: info.height
|
|
182
|
-
};
|
|
183
|
-
} catch (error) {
|
|
184
|
-
if (error instanceof ProcessingError || error instanceof RemoteServiceError) {
|
|
185
|
-
throw error;
|
|
186
|
-
}
|
|
187
|
-
throw new ProcessingError(
|
|
188
|
-
`Failed to get dimensions: ${error instanceof Error ? error.message : String(error)}`,
|
|
189
|
-
"imgproxy",
|
|
190
|
-
error instanceof Error ? error : void 0
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
async thumbnail(input, output, options = {}) {
|
|
195
|
-
const url = this.toUrl(input);
|
|
196
|
-
try {
|
|
197
|
-
const processing = this.buildProcessingString({
|
|
198
|
-
resize: "fit",
|
|
199
|
-
width: options.maxWidth || 0,
|
|
200
|
-
height: options.maxHeight || 0,
|
|
201
|
-
quality: options.quality,
|
|
202
|
-
format: options.format
|
|
203
|
-
});
|
|
204
|
-
const ext = options.format || this.inferFormat(output);
|
|
205
|
-
const imgUrl = this.buildUrl(url, processing, ext);
|
|
206
|
-
await this.fetchAndSave(imgUrl, output);
|
|
207
|
-
} catch (error) {
|
|
208
|
-
if (error instanceof ProcessingError || error instanceof RemoteServiceError) {
|
|
209
|
-
throw error;
|
|
210
|
-
}
|
|
211
|
-
throw new ProcessingError(
|
|
212
|
-
`Thumbnail generation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
213
|
-
"imgproxy",
|
|
214
|
-
error instanceof Error ? error : void 0
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
async convert(input, output, options = {}) {
|
|
219
|
-
const url = this.toUrl(input);
|
|
220
|
-
try {
|
|
221
|
-
const format = options.format || this.inferFormat(output);
|
|
222
|
-
const processing = this.buildProcessingString({
|
|
223
|
-
quality: options.quality,
|
|
224
|
-
format
|
|
225
|
-
});
|
|
226
|
-
const imgUrl = this.buildUrl(url, processing, format);
|
|
227
|
-
await this.fetchAndSave(imgUrl, output);
|
|
228
|
-
} catch (error) {
|
|
229
|
-
if (error instanceof ProcessingError || error instanceof RemoteServiceError) {
|
|
230
|
-
throw error;
|
|
231
|
-
}
|
|
232
|
-
throw new ProcessingError(
|
|
233
|
-
`Format conversion failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
234
|
-
"imgproxy",
|
|
235
|
-
error instanceof Error ? error : void 0
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
async getMetadata(input) {
|
|
240
|
-
const url = this.toUrl(input);
|
|
241
|
-
try {
|
|
242
|
-
const infoUrl = this.buildUrl(url, "info:1");
|
|
243
|
-
const response = await fetch(infoUrl);
|
|
244
|
-
if (!response.ok) {
|
|
245
|
-
throw new RemoteServiceError(
|
|
246
|
-
`imgproxy info request failed: ${response.statusText}`,
|
|
247
|
-
"imgproxy",
|
|
248
|
-
response.status
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
const info = await response.json();
|
|
252
|
-
return {
|
|
253
|
-
width: info.width,
|
|
254
|
-
height: info.height,
|
|
255
|
-
format: info.type || "unknown"
|
|
256
|
-
// imgproxy returns limited metadata
|
|
257
|
-
// Additional EXIF/IPTC/XMP not typically available
|
|
258
|
-
};
|
|
259
|
-
} catch (error) {
|
|
260
|
-
if (error instanceof ProcessingError || error instanceof RemoteServiceError) {
|
|
261
|
-
throw error;
|
|
262
|
-
}
|
|
263
|
-
throw new ProcessingError(
|
|
264
|
-
`Failed to get metadata: ${error instanceof Error ? error.message : String(error)}`,
|
|
265
|
-
"imgproxy",
|
|
266
|
-
error instanceof Error ? error : void 0
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
async hash(_input, _algorithm = "perceptual") {
|
|
271
|
-
throw new OperationNotSupportedError(
|
|
272
|
-
"hash (imgproxy does not support direct hashing - fetch image first)",
|
|
273
|
-
"imgproxy"
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
async resize(input, output, options) {
|
|
277
|
-
const url = this.toUrl(input);
|
|
278
|
-
try {
|
|
279
|
-
const resizeType = this.mapFitToResizeType(options.fit || "cover");
|
|
280
|
-
const processing = this.buildProcessingString({
|
|
281
|
-
resize: resizeType,
|
|
282
|
-
width: options.width || 0,
|
|
283
|
-
height: options.height || 0,
|
|
284
|
-
quality: options.quality,
|
|
285
|
-
format: options.format
|
|
286
|
-
});
|
|
287
|
-
const ext = options.format || this.inferFormat(output);
|
|
288
|
-
const imgUrl = this.buildUrl(url, processing, ext);
|
|
289
|
-
await this.fetchAndSave(imgUrl, output);
|
|
290
|
-
} catch (error) {
|
|
291
|
-
if (error instanceof ProcessingError || error instanceof RemoteServiceError) {
|
|
292
|
-
throw error;
|
|
293
|
-
}
|
|
294
|
-
throw new ProcessingError(
|
|
295
|
-
`Resize failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
296
|
-
"imgproxy",
|
|
297
|
-
error instanceof Error ? error : void 0
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* Build imgproxy processing string
|
|
303
|
-
*/
|
|
304
|
-
buildProcessingString(opts) {
|
|
305
|
-
const parts = [];
|
|
306
|
-
if (opts.resize) {
|
|
307
|
-
const w = opts.width || 0;
|
|
308
|
-
const h = opts.height || 0;
|
|
309
|
-
parts.push(`rs:${opts.resize}:${w}:${h}`);
|
|
310
|
-
}
|
|
311
|
-
if (opts.quality) {
|
|
312
|
-
parts.push(`q:${opts.quality}`);
|
|
313
|
-
}
|
|
314
|
-
return parts.length > 0 ? parts.join("/") : "raw:1";
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Map fit mode to imgproxy resize type
|
|
318
|
-
*/
|
|
319
|
-
mapFitToResizeType(fit) {
|
|
320
|
-
const map = {
|
|
321
|
-
cover: "fill",
|
|
322
|
-
// Fill and crop
|
|
323
|
-
contain: "fit",
|
|
324
|
-
// Fit within
|
|
325
|
-
fill: "force",
|
|
326
|
-
// Force exact size
|
|
327
|
-
inside: "fit",
|
|
328
|
-
// Same as contain
|
|
329
|
-
outside: "fill"
|
|
330
|
-
// Same as cover
|
|
331
|
-
};
|
|
332
|
-
return map[fit] || "fit";
|
|
333
|
-
}
|
|
334
|
-
/**
|
|
335
|
-
* Fetch image from imgproxy and save to file
|
|
336
|
-
*/
|
|
337
|
-
async fetchAndSave(url, output) {
|
|
338
|
-
const response = await fetch(url);
|
|
339
|
-
if (!response.ok) {
|
|
340
|
-
throw new RemoteServiceError(
|
|
341
|
-
`imgproxy request failed: ${response.statusText}`,
|
|
342
|
-
"imgproxy",
|
|
343
|
-
response.status
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
347
|
-
const { writeFile } = await import("node:fs/promises");
|
|
348
|
-
await writeFile(output, buffer);
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* Infer image format from file extension
|
|
352
|
-
*/
|
|
353
|
-
inferFormat(path) {
|
|
354
|
-
const ext = path.split(".").pop()?.toLowerCase();
|
|
355
|
-
const map = {
|
|
356
|
-
jpg: "jpeg",
|
|
357
|
-
jpeg: "jpeg",
|
|
358
|
-
png: "png",
|
|
359
|
-
webp: "webp",
|
|
360
|
-
avif: "avif",
|
|
361
|
-
gif: "gif",
|
|
362
|
-
tiff: "tiff",
|
|
363
|
-
tif: "tiff"
|
|
364
|
-
};
|
|
365
|
-
return map[ext || ""] || "jpeg";
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
const imgproxy = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
369
|
-
__proto__: null,
|
|
370
|
-
ImgproxyAdapter,
|
|
371
|
-
signImgproxyUrl
|
|
372
|
-
}, Symbol.toStringTag, { value: "Module" }));
|
|
373
|
-
class JimpAdapter {
|
|
374
|
-
jimpStatic = null;
|
|
375
|
-
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: Stored for API compatibility and future extensibility
|
|
376
|
-
options;
|
|
377
|
-
/**
|
|
378
|
-
* Create a new Jimp adapter
|
|
379
|
-
* @param options - Jimp adapter options
|
|
380
|
-
*/
|
|
381
|
-
constructor(options = { type: "jimp" }) {
|
|
382
|
-
this.options = options;
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* Lazily load Jimp module
|
|
386
|
-
*/
|
|
387
|
-
async getJimp() {
|
|
388
|
-
if (!this.jimpStatic) {
|
|
389
|
-
try {
|
|
390
|
-
const jimpModule = await import("jimp");
|
|
391
|
-
this.jimpStatic = jimpModule.Jimp;
|
|
392
|
-
} catch (error) {
|
|
393
|
-
throw new ProcessingError(
|
|
394
|
-
"Jimp library is not installed. Install it with: npm install jimp",
|
|
395
|
-
"jimp",
|
|
396
|
-
error instanceof Error ? error : void 0
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
return this.jimpStatic;
|
|
401
|
-
}
|
|
402
|
-
async getDimensions(input) {
|
|
403
|
-
const Jimp = await this.getJimp();
|
|
404
|
-
try {
|
|
405
|
-
const image = await Jimp.read(input);
|
|
406
|
-
return { width: image.width, height: image.height };
|
|
407
|
-
} catch (error) {
|
|
408
|
-
throw this.mapError(error, input);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
async thumbnail(input, output, options = {}) {
|
|
412
|
-
const Jimp = await this.getJimp();
|
|
413
|
-
try {
|
|
414
|
-
const image = await Jimp.read(input);
|
|
415
|
-
if (options.maxWidth && options.maxHeight) {
|
|
416
|
-
image.scaleToFit({ w: options.maxWidth, h: options.maxHeight });
|
|
417
|
-
} else if (options.maxWidth) {
|
|
418
|
-
const scale = options.maxWidth / image.width;
|
|
419
|
-
if (scale < 1) {
|
|
420
|
-
image.scale(scale);
|
|
421
|
-
}
|
|
422
|
-
} else if (options.maxHeight) {
|
|
423
|
-
const scale = options.maxHeight / image.height;
|
|
424
|
-
if (scale < 1) {
|
|
425
|
-
image.scale(scale);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
await this.writeWithOptions(image, output, options.quality);
|
|
429
|
-
} catch (error) {
|
|
430
|
-
if (error instanceof ProcessingError || error instanceof ImageNotFoundError) {
|
|
431
|
-
throw error;
|
|
432
|
-
}
|
|
433
|
-
throw this.mapError(error, input);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
async convert(input, output, options = {}) {
|
|
437
|
-
const Jimp = await this.getJimp();
|
|
438
|
-
try {
|
|
439
|
-
const image = await Jimp.read(input);
|
|
440
|
-
await this.writeWithOptions(image, output, options.quality);
|
|
441
|
-
} catch (error) {
|
|
442
|
-
throw this.mapError(error, input);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
async getMetadata(input) {
|
|
446
|
-
const Jimp = await this.getJimp();
|
|
447
|
-
try {
|
|
448
|
-
const image = await Jimp.read(input);
|
|
449
|
-
const mime = this.getMimeFromInput(input);
|
|
450
|
-
const format = this.formatFromMime(mime);
|
|
451
|
-
return {
|
|
452
|
-
width: image.width,
|
|
453
|
-
height: image.height,
|
|
454
|
-
format,
|
|
455
|
-
channels: 4,
|
|
456
|
-
// Jimp uses RGBA internally
|
|
457
|
-
hasAlpha: true
|
|
458
|
-
// Jimp always works with alpha channel
|
|
459
|
-
// Jimp has limited metadata extraction
|
|
460
|
-
// EXIF/IPTC/XMP not directly supported
|
|
461
|
-
};
|
|
462
|
-
} catch (error) {
|
|
463
|
-
throw this.mapError(error, input);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
async hash(input, algorithm = "perceptual") {
|
|
467
|
-
const Jimp = await this.getJimp();
|
|
468
|
-
try {
|
|
469
|
-
if (algorithm === "md5" || algorithm === "sha256") {
|
|
470
|
-
const { createHash } = await import("node:crypto");
|
|
471
|
-
const image2 = await Jimp.read(input);
|
|
472
|
-
const buffer = await image2.getBuffer("image/png");
|
|
473
|
-
return createHash(algorithm).update(buffer).digest("hex");
|
|
474
|
-
}
|
|
475
|
-
const image = await Jimp.read(input);
|
|
476
|
-
const resized = image.clone().resize({ w: 8, h: 8 }).greyscale();
|
|
477
|
-
const data = resized.bitmap.data;
|
|
478
|
-
let sum = 0;
|
|
479
|
-
const pixels = [];
|
|
480
|
-
for (let i = 0; i < data.length; i += 4) {
|
|
481
|
-
const gray = data[i];
|
|
482
|
-
pixels.push(gray);
|
|
483
|
-
sum += gray;
|
|
484
|
-
}
|
|
485
|
-
const avg = sum / pixels.length;
|
|
486
|
-
let hash = "";
|
|
487
|
-
for (const val of pixels) {
|
|
488
|
-
hash += val >= avg ? "1" : "0";
|
|
489
|
-
}
|
|
490
|
-
return BigInt(`0b${hash}`).toString(16).padStart(16, "0");
|
|
491
|
-
} catch (error) {
|
|
492
|
-
throw this.mapError(error, input);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
async resize(input, output, options) {
|
|
496
|
-
const Jimp = await this.getJimp();
|
|
497
|
-
try {
|
|
498
|
-
const image = await Jimp.read(input);
|
|
499
|
-
const fit = options.fit || "cover";
|
|
500
|
-
if (options.width && options.height) {
|
|
501
|
-
if (fit === "cover") {
|
|
502
|
-
image.cover({ w: options.width, h: options.height });
|
|
503
|
-
} else if (fit === "contain" || fit === "inside") {
|
|
504
|
-
image.contain({ w: options.width, h: options.height });
|
|
505
|
-
} else {
|
|
506
|
-
image.resize({ w: options.width, h: options.height });
|
|
507
|
-
}
|
|
508
|
-
} else if (options.width) {
|
|
509
|
-
const scale = options.width / image.width;
|
|
510
|
-
image.scale(scale);
|
|
511
|
-
} else if (options.height) {
|
|
512
|
-
const scale = options.height / image.height;
|
|
513
|
-
image.scale(scale);
|
|
514
|
-
}
|
|
515
|
-
await this.writeWithOptions(image, output, options.quality);
|
|
516
|
-
} catch (error) {
|
|
517
|
-
throw this.mapError(error, input);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
/**
|
|
521
|
-
* Write image with quality settings based on output format
|
|
522
|
-
*/
|
|
523
|
-
async writeWithOptions(image, output, quality) {
|
|
524
|
-
const format = this.inferFormat(output);
|
|
525
|
-
const mime = this.mimeFromFormat(format);
|
|
526
|
-
if (format === "avif") {
|
|
527
|
-
throw new UnsupportedFormatError("avif", "jimp");
|
|
528
|
-
}
|
|
529
|
-
const buffer = await image.getBuffer(mime, {
|
|
530
|
-
quality: quality ?? 80
|
|
531
|
-
});
|
|
532
|
-
const { writeFile } = await import("node:fs/promises");
|
|
533
|
-
await writeFile(output, buffer);
|
|
534
|
-
}
|
|
535
|
-
/**
|
|
536
|
-
* Infer image format from file extension
|
|
537
|
-
*/
|
|
538
|
-
inferFormat(path) {
|
|
539
|
-
const ext = path.split(".").pop()?.toLowerCase();
|
|
540
|
-
const map = {
|
|
541
|
-
jpg: "jpeg",
|
|
542
|
-
jpeg: "jpeg",
|
|
543
|
-
png: "png",
|
|
544
|
-
webp: "webp",
|
|
545
|
-
avif: "avif",
|
|
546
|
-
gif: "gif",
|
|
547
|
-
tiff: "tiff",
|
|
548
|
-
tif: "tiff",
|
|
549
|
-
bmp: "png"
|
|
550
|
-
// Convert BMP to PNG
|
|
551
|
-
};
|
|
552
|
-
return map[ext || ""] || "jpeg";
|
|
553
|
-
}
|
|
554
|
-
/**
|
|
555
|
-
* Get MIME type from format
|
|
556
|
-
*/
|
|
557
|
-
mimeFromFormat(format) {
|
|
558
|
-
const map = {
|
|
559
|
-
jpeg: "image/jpeg",
|
|
560
|
-
png: "image/png",
|
|
561
|
-
webp: "image/webp",
|
|
562
|
-
avif: "image/avif",
|
|
563
|
-
gif: "image/gif",
|
|
564
|
-
tiff: "image/tiff"
|
|
565
|
-
};
|
|
566
|
-
return map[format] || "image/jpeg";
|
|
567
|
-
}
|
|
568
|
-
/**
|
|
569
|
-
* Get format from MIME type
|
|
570
|
-
*/
|
|
571
|
-
formatFromMime(mime) {
|
|
572
|
-
const map = {
|
|
573
|
-
"image/jpeg": "jpeg",
|
|
574
|
-
"image/png": "png",
|
|
575
|
-
"image/webp": "webp",
|
|
576
|
-
"image/gif": "gif",
|
|
577
|
-
"image/tiff": "tiff",
|
|
578
|
-
"image/bmp": "bmp"
|
|
579
|
-
};
|
|
580
|
-
return map[mime] || "unknown";
|
|
581
|
-
}
|
|
582
|
-
/**
|
|
583
|
-
* Try to determine MIME type from input
|
|
584
|
-
*/
|
|
585
|
-
getMimeFromInput(input) {
|
|
586
|
-
if (typeof input === "string") {
|
|
587
|
-
const format = this.inferFormat(input);
|
|
588
|
-
return this.mimeFromFormat(format);
|
|
589
|
-
}
|
|
590
|
-
return "image/png";
|
|
591
|
-
}
|
|
592
|
-
/**
|
|
593
|
-
* Map Jimp errors to our error types
|
|
594
|
-
*/
|
|
595
|
-
mapError(error, input) {
|
|
596
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
597
|
-
if (message.includes("no such file") || message.includes("ENOENT") || message.includes("Could not find")) {
|
|
598
|
-
const path = typeof input === "string" ? input : "<buffer>";
|
|
599
|
-
throw new ImageNotFoundError(path, "jimp");
|
|
600
|
-
}
|
|
601
|
-
if (message.includes("not supported") || message.includes("Could not find MIME")) {
|
|
602
|
-
throw new UnsupportedFormatError("unknown", "jimp");
|
|
603
|
-
}
|
|
604
|
-
return new ProcessingError(
|
|
605
|
-
`Image processing failed: ${message}`,
|
|
606
|
-
"jimp",
|
|
607
|
-
error instanceof Error ? error : void 0
|
|
608
|
-
);
|
|
609
|
-
}
|
|
130
|
+
return new ImgproxyAdapter({
|
|
131
|
+
type: "imgproxy",
|
|
132
|
+
baseUrl: config.baseUrl,
|
|
133
|
+
key: config.key,
|
|
134
|
+
salt: config.salt
|
|
135
|
+
}).getSignedUrl(source, options);
|
|
610
136
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
137
|
+
/**
|
|
138
|
+
* imgproxy adapter for remote image processing
|
|
139
|
+
*
|
|
140
|
+
* Offloads image processing to a self-hosted imgproxy server.
|
|
141
|
+
* Supports URL signing for secure access.
|
|
142
|
+
*
|
|
143
|
+
* Features:
|
|
144
|
+
* - Scalable remote processing
|
|
145
|
+
* - URL-based API
|
|
146
|
+
* - Signed URLs for security
|
|
147
|
+
* - CDN-friendly outputs
|
|
148
|
+
*
|
|
149
|
+
* Limitations:
|
|
150
|
+
* - Requires network access to imgproxy server
|
|
151
|
+
* - Input must be accessible via URL (not local paths)
|
|
152
|
+
* - Hash computation not directly supported (requires fetching image)
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```typescript
|
|
156
|
+
* const processor = new ImgproxyAdapter({
|
|
157
|
+
* type: 'imgproxy',
|
|
158
|
+
* baseUrl: 'https://imgproxy.example.com',
|
|
159
|
+
* key: 'hex-encoded-key',
|
|
160
|
+
* salt: 'hex-encoded-salt'
|
|
161
|
+
* });
|
|
162
|
+
*
|
|
163
|
+
* await processor.thumbnail(
|
|
164
|
+
* 'https://example.com/image.jpg',
|
|
165
|
+
* '/tmp/thumb.jpg',
|
|
166
|
+
* { maxWidth: 300 }
|
|
167
|
+
* );
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
var ImgproxyAdapter = class {
|
|
171
|
+
baseUrl;
|
|
172
|
+
key;
|
|
173
|
+
salt;
|
|
174
|
+
constructor(options) {
|
|
175
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
176
|
+
if (options.key && options.salt) {
|
|
177
|
+
this.key = Buffer.from(options.key, "hex");
|
|
178
|
+
this.salt = Buffer.from(options.salt, "hex");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Generate a signed imgproxy URL without fetching the image
|
|
183
|
+
*
|
|
184
|
+
* This is useful for:
|
|
185
|
+
* - Client-side usage where you want to display images directly
|
|
186
|
+
* - Debugging URL signing issues
|
|
187
|
+
* - Pre-generating URLs for batch processing
|
|
188
|
+
*
|
|
189
|
+
* @param source - Source image URL (must be http/https)
|
|
190
|
+
* @param options - Processing options
|
|
191
|
+
* @returns Signed imgproxy URL
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```typescript
|
|
195
|
+
* const adapter = new ImgproxyAdapter({
|
|
196
|
+
* type: 'imgproxy',
|
|
197
|
+
* baseUrl: 'https://imgproxy.example.com',
|
|
198
|
+
* key: 'hex-key',
|
|
199
|
+
* salt: 'hex-salt'
|
|
200
|
+
* });
|
|
201
|
+
*
|
|
202
|
+
* // Generate thumbnail URL
|
|
203
|
+
* const url = adapter.getSignedUrl('https://example.com/image.jpg', {
|
|
204
|
+
* width: 300,
|
|
205
|
+
* height: 200,
|
|
206
|
+
* fit: 'cover',
|
|
207
|
+
* format: 'webp'
|
|
208
|
+
* });
|
|
209
|
+
* // Returns: https://imgproxy.example.com/SIGNATURE/rs:fill:300:200/aHR0cHM6Ly9.../image.webp
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
getSignedUrl(source, options = {}) {
|
|
213
|
+
if (!source.startsWith("http://") && !source.startsWith("https://")) throw new ProcessingError(`imgproxy requires HTTP/HTTPS URLs as source. Got: ${source.substring(0, 50)}...`, "imgproxy");
|
|
214
|
+
const resizeType = this.mapFitToResizeType(options.fit || "cover");
|
|
215
|
+
const processing = this.buildProcessingString({
|
|
216
|
+
resize: resizeType,
|
|
217
|
+
width: options.width || 0,
|
|
218
|
+
height: options.height || 0,
|
|
219
|
+
quality: options.quality,
|
|
220
|
+
format: options.format
|
|
221
|
+
});
|
|
222
|
+
return this.buildUrl(source, processing, options.format);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Sign a path using HMAC-SHA256
|
|
226
|
+
*/
|
|
227
|
+
sign(path) {
|
|
228
|
+
if (!this.key || !this.salt) return "unsafe";
|
|
229
|
+
const hmac = createHmac("sha256", this.key);
|
|
230
|
+
hmac.update(this.salt);
|
|
231
|
+
hmac.update(path);
|
|
232
|
+
return hmac.digest("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "").substring(0, 32);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Encode source URL for imgproxy
|
|
236
|
+
*/
|
|
237
|
+
encodeSource(source) {
|
|
238
|
+
return Buffer.from(source).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Build imgproxy URL
|
|
242
|
+
*/
|
|
243
|
+
buildUrl(source, processing, extension) {
|
|
244
|
+
const path = `/${processing}/${this.encodeSource(source)}${extension ? `.${extension}` : ""}`;
|
|
245
|
+
const signature = this.sign(path);
|
|
246
|
+
return `${this.baseUrl}/${signature}${path}`;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Convert ImageInput to URL string
|
|
250
|
+
*/
|
|
251
|
+
toUrl(input) {
|
|
252
|
+
if (Buffer.isBuffer(input)) throw new ProcessingError("imgproxy adapter requires URL input, not Buffer. Upload the buffer to a storage service first.", "imgproxy");
|
|
253
|
+
if (!input.startsWith("http://") && !input.startsWith("https://")) throw new ProcessingError(`imgproxy adapter requires HTTP/HTTPS URLs as input. Got: ${input.substring(0, 50)}...`, "imgproxy");
|
|
254
|
+
return input;
|
|
255
|
+
}
|
|
256
|
+
async getDimensions(input) {
|
|
257
|
+
const url = this.toUrl(input);
|
|
258
|
+
try {
|
|
259
|
+
const infoUrl = this.buildUrl(url, "info:1");
|
|
260
|
+
const response = await fetch(infoUrl);
|
|
261
|
+
if (!response.ok) throw new RemoteServiceError(`imgproxy info request failed: ${response.statusText}`, "imgproxy", response.status);
|
|
262
|
+
const info = await response.json();
|
|
263
|
+
return {
|
|
264
|
+
width: info.width,
|
|
265
|
+
height: info.height
|
|
266
|
+
};
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error instanceof ProcessingError || error instanceof RemoteServiceError) throw error;
|
|
269
|
+
throw new ProcessingError(`Failed to get dimensions: ${error instanceof Error ? error.message : String(error)}`, "imgproxy", error instanceof Error ? error : void 0);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async thumbnail(input, output, options = {}) {
|
|
273
|
+
const url = this.toUrl(input);
|
|
274
|
+
try {
|
|
275
|
+
const processing = this.buildProcessingString({
|
|
276
|
+
resize: "fit",
|
|
277
|
+
width: options.maxWidth || 0,
|
|
278
|
+
height: options.maxHeight || 0,
|
|
279
|
+
quality: options.quality,
|
|
280
|
+
format: options.format
|
|
281
|
+
});
|
|
282
|
+
const ext = options.format || this.inferFormat(output);
|
|
283
|
+
const imgUrl = this.buildUrl(url, processing, ext);
|
|
284
|
+
await this.fetchAndSave(imgUrl, output);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error instanceof ProcessingError || error instanceof RemoteServiceError) throw error;
|
|
287
|
+
throw new ProcessingError(`Thumbnail generation failed: ${error instanceof Error ? error.message : String(error)}`, "imgproxy", error instanceof Error ? error : void 0);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async convert(input, output, options = {}) {
|
|
291
|
+
const url = this.toUrl(input);
|
|
292
|
+
try {
|
|
293
|
+
const format = options.format || this.inferFormat(output);
|
|
294
|
+
const processing = this.buildProcessingString({
|
|
295
|
+
quality: options.quality,
|
|
296
|
+
format
|
|
297
|
+
});
|
|
298
|
+
const imgUrl = this.buildUrl(url, processing, format);
|
|
299
|
+
await this.fetchAndSave(imgUrl, output);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (error instanceof ProcessingError || error instanceof RemoteServiceError) throw error;
|
|
302
|
+
throw new ProcessingError(`Format conversion failed: ${error instanceof Error ? error.message : String(error)}`, "imgproxy", error instanceof Error ? error : void 0);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async getMetadata(input) {
|
|
306
|
+
const url = this.toUrl(input);
|
|
307
|
+
try {
|
|
308
|
+
const infoUrl = this.buildUrl(url, "info:1");
|
|
309
|
+
const response = await fetch(infoUrl);
|
|
310
|
+
if (!response.ok) throw new RemoteServiceError(`imgproxy info request failed: ${response.statusText}`, "imgproxy", response.status);
|
|
311
|
+
const info = await response.json();
|
|
312
|
+
return {
|
|
313
|
+
width: info.width,
|
|
314
|
+
height: info.height,
|
|
315
|
+
format: info.type || "unknown"
|
|
316
|
+
};
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error instanceof ProcessingError || error instanceof RemoteServiceError) throw error;
|
|
319
|
+
throw new ProcessingError(`Failed to get metadata: ${error instanceof Error ? error.message : String(error)}`, "imgproxy", error instanceof Error ? error : void 0);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async hash(_input, _algorithm = "perceptual") {
|
|
323
|
+
throw new OperationNotSupportedError("hash (imgproxy does not support direct hashing - fetch image first)", "imgproxy");
|
|
324
|
+
}
|
|
325
|
+
async resize(input, output, options) {
|
|
326
|
+
const url = this.toUrl(input);
|
|
327
|
+
try {
|
|
328
|
+
const resizeType = this.mapFitToResizeType(options.fit || "cover");
|
|
329
|
+
const processing = this.buildProcessingString({
|
|
330
|
+
resize: resizeType,
|
|
331
|
+
width: options.width || 0,
|
|
332
|
+
height: options.height || 0,
|
|
333
|
+
quality: options.quality,
|
|
334
|
+
format: options.format
|
|
335
|
+
});
|
|
336
|
+
const ext = options.format || this.inferFormat(output);
|
|
337
|
+
const imgUrl = this.buildUrl(url, processing, ext);
|
|
338
|
+
await this.fetchAndSave(imgUrl, output);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error instanceof ProcessingError || error instanceof RemoteServiceError) throw error;
|
|
341
|
+
throw new ProcessingError(`Resize failed: ${error instanceof Error ? error.message : String(error)}`, "imgproxy", error instanceof Error ? error : void 0);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Build imgproxy processing string
|
|
346
|
+
*/
|
|
347
|
+
buildProcessingString(opts) {
|
|
348
|
+
const parts = [];
|
|
349
|
+
if (opts.resize) {
|
|
350
|
+
const w = opts.width || 0;
|
|
351
|
+
const h = opts.height || 0;
|
|
352
|
+
parts.push(`rs:${opts.resize}:${w}:${h}`);
|
|
353
|
+
}
|
|
354
|
+
if (opts.quality) parts.push(`q:${opts.quality}`);
|
|
355
|
+
return parts.length > 0 ? parts.join("/") : "raw:1";
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Map fit mode to imgproxy resize type
|
|
359
|
+
*/
|
|
360
|
+
mapFitToResizeType(fit) {
|
|
361
|
+
return {
|
|
362
|
+
cover: "fill",
|
|
363
|
+
contain: "fit",
|
|
364
|
+
fill: "force",
|
|
365
|
+
inside: "fit",
|
|
366
|
+
outside: "fill"
|
|
367
|
+
}[fit] || "fit";
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Fetch image from imgproxy and save to file
|
|
371
|
+
*/
|
|
372
|
+
async fetchAndSave(url, output) {
|
|
373
|
+
const response = await fetch(url);
|
|
374
|
+
if (!response.ok) throw new RemoteServiceError(`imgproxy request failed: ${response.statusText}`, "imgproxy", response.status);
|
|
375
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
376
|
+
const { writeFile } = await import("node:fs/promises");
|
|
377
|
+
await writeFile(output, buffer);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Infer image format from file extension
|
|
381
|
+
*/
|
|
382
|
+
inferFormat(path) {
|
|
383
|
+
return {
|
|
384
|
+
jpg: "jpeg",
|
|
385
|
+
jpeg: "jpeg",
|
|
386
|
+
png: "png",
|
|
387
|
+
webp: "webp",
|
|
388
|
+
avif: "avif",
|
|
389
|
+
gif: "gif",
|
|
390
|
+
tiff: "tiff",
|
|
391
|
+
tif: "tiff"
|
|
392
|
+
}[path.split(".").pop()?.toLowerCase() || ""] || "jpeg";
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
//#endregion
|
|
396
|
+
//#region src/adapters/jimp.ts
|
|
397
|
+
/**
|
|
398
|
+
* Jimp adapter for image processing
|
|
399
|
+
* Uses pure JavaScript for image processing (no native dependencies)
|
|
400
|
+
* Ideal for serverless/edge environments where Sharp cannot be used
|
|
401
|
+
*/
|
|
402
|
+
var jimp_exports = /* @__PURE__ */ __exportAll({ JimpAdapter: () => JimpAdapter });
|
|
403
|
+
/**
|
|
404
|
+
* Jimp adapter for pure JavaScript image processing
|
|
405
|
+
*
|
|
406
|
+
* Provides a fallback option when Sharp (native library) is not available.
|
|
407
|
+
* Useful for:
|
|
408
|
+
* - Serverless environments (AWS Lambda, Vercel Edge)
|
|
409
|
+
* - Environments without native compilation support
|
|
410
|
+
* - Cross-platform compatibility
|
|
411
|
+
*
|
|
412
|
+
* Limitations compared to Sharp:
|
|
413
|
+
* - Slower processing speed
|
|
414
|
+
* - Limited EXIF/metadata extraction
|
|
415
|
+
* - No AVIF support
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* ```typescript
|
|
419
|
+
* const processor = new JimpAdapter();
|
|
420
|
+
* const dims = await processor.getDimensions('/path/to/image.jpg');
|
|
421
|
+
* console.log(dims); // { width: 1920, height: 1080 }
|
|
422
|
+
* ```
|
|
423
|
+
*/
|
|
424
|
+
var JimpAdapter = class {
|
|
425
|
+
jimpStatic = null;
|
|
426
|
+
options;
|
|
427
|
+
/**
|
|
428
|
+
* Create a new Jimp adapter
|
|
429
|
+
* @param options - Jimp adapter options
|
|
430
|
+
*/
|
|
431
|
+
constructor(options = { type: "jimp" }) {
|
|
432
|
+
this.options = options;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Lazily load Jimp module
|
|
436
|
+
*/
|
|
437
|
+
async getJimp() {
|
|
438
|
+
if (!this.jimpStatic) try {
|
|
439
|
+
const jimpModule = await import("jimp");
|
|
440
|
+
this.jimpStatic = jimpModule.Jimp;
|
|
441
|
+
} catch (error) {
|
|
442
|
+
throw new ProcessingError("Jimp library is not installed. Install it with: npm install jimp", "jimp", error instanceof Error ? error : void 0);
|
|
443
|
+
}
|
|
444
|
+
return this.jimpStatic;
|
|
445
|
+
}
|
|
446
|
+
async getDimensions(input) {
|
|
447
|
+
const Jimp = await this.getJimp();
|
|
448
|
+
try {
|
|
449
|
+
const image = await Jimp.read(input);
|
|
450
|
+
return {
|
|
451
|
+
width: image.width,
|
|
452
|
+
height: image.height
|
|
453
|
+
};
|
|
454
|
+
} catch (error) {
|
|
455
|
+
throw this.mapError(error, input);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async thumbnail(input, output, options = {}) {
|
|
459
|
+
const Jimp = await this.getJimp();
|
|
460
|
+
try {
|
|
461
|
+
const image = await Jimp.read(input);
|
|
462
|
+
if (options.maxWidth && options.maxHeight) image.scaleToFit({
|
|
463
|
+
w: options.maxWidth,
|
|
464
|
+
h: options.maxHeight
|
|
465
|
+
});
|
|
466
|
+
else if (options.maxWidth) {
|
|
467
|
+
const scale = options.maxWidth / image.width;
|
|
468
|
+
if (scale < 1) image.scale(scale);
|
|
469
|
+
} else if (options.maxHeight) {
|
|
470
|
+
const scale = options.maxHeight / image.height;
|
|
471
|
+
if (scale < 1) image.scale(scale);
|
|
472
|
+
}
|
|
473
|
+
await this.writeWithOptions(image, output, options.quality);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (error instanceof ProcessingError || error instanceof ImageNotFoundError) throw error;
|
|
476
|
+
throw this.mapError(error, input);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
async convert(input, output, options = {}) {
|
|
480
|
+
const Jimp = await this.getJimp();
|
|
481
|
+
try {
|
|
482
|
+
const image = await Jimp.read(input);
|
|
483
|
+
await this.writeWithOptions(image, output, options.quality);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
throw this.mapError(error, input);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
async getMetadata(input) {
|
|
489
|
+
const Jimp = await this.getJimp();
|
|
490
|
+
try {
|
|
491
|
+
const image = await Jimp.read(input);
|
|
492
|
+
const mime = this.getMimeFromInput(input);
|
|
493
|
+
const format = this.formatFromMime(mime);
|
|
494
|
+
return {
|
|
495
|
+
width: image.width,
|
|
496
|
+
height: image.height,
|
|
497
|
+
format,
|
|
498
|
+
channels: 4,
|
|
499
|
+
hasAlpha: true
|
|
500
|
+
};
|
|
501
|
+
} catch (error) {
|
|
502
|
+
throw this.mapError(error, input);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async hash(input, algorithm = "perceptual") {
|
|
506
|
+
const Jimp = await this.getJimp();
|
|
507
|
+
try {
|
|
508
|
+
if (algorithm === "md5" || algorithm === "sha256") {
|
|
509
|
+
const { createHash } = await import("node:crypto");
|
|
510
|
+
const buffer = await (await Jimp.read(input)).getBuffer("image/png");
|
|
511
|
+
return createHash(algorithm).update(buffer).digest("hex");
|
|
512
|
+
}
|
|
513
|
+
const data = (await Jimp.read(input)).clone().resize({
|
|
514
|
+
w: 8,
|
|
515
|
+
h: 8
|
|
516
|
+
}).greyscale().bitmap.data;
|
|
517
|
+
let sum = 0;
|
|
518
|
+
const pixels = [];
|
|
519
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
520
|
+
const gray = data[i];
|
|
521
|
+
pixels.push(gray);
|
|
522
|
+
sum += gray;
|
|
523
|
+
}
|
|
524
|
+
const avg = sum / pixels.length;
|
|
525
|
+
let hash = "";
|
|
526
|
+
for (const val of pixels) hash += val >= avg ? "1" : "0";
|
|
527
|
+
return BigInt(`0b${hash}`).toString(16).padStart(16, "0");
|
|
528
|
+
} catch (error) {
|
|
529
|
+
throw this.mapError(error, input);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
async resize(input, output, options) {
|
|
533
|
+
const Jimp = await this.getJimp();
|
|
534
|
+
try {
|
|
535
|
+
const image = await Jimp.read(input);
|
|
536
|
+
const fit = options.fit || "cover";
|
|
537
|
+
if (options.width && options.height) if (fit === "cover") image.cover({
|
|
538
|
+
w: options.width,
|
|
539
|
+
h: options.height
|
|
540
|
+
});
|
|
541
|
+
else if (fit === "contain" || fit === "inside") image.contain({
|
|
542
|
+
w: options.width,
|
|
543
|
+
h: options.height
|
|
544
|
+
});
|
|
545
|
+
else image.resize({
|
|
546
|
+
w: options.width,
|
|
547
|
+
h: options.height
|
|
548
|
+
});
|
|
549
|
+
else if (options.width) {
|
|
550
|
+
const scale = options.width / image.width;
|
|
551
|
+
image.scale(scale);
|
|
552
|
+
} else if (options.height) {
|
|
553
|
+
const scale = options.height / image.height;
|
|
554
|
+
image.scale(scale);
|
|
555
|
+
}
|
|
556
|
+
await this.writeWithOptions(image, output, options.quality);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
throw this.mapError(error, input);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Write image with quality settings based on output format
|
|
563
|
+
*/
|
|
564
|
+
async writeWithOptions(image, output, quality) {
|
|
565
|
+
const format = this.inferFormat(output);
|
|
566
|
+
const mime = this.mimeFromFormat(format);
|
|
567
|
+
if (format === "avif") throw new UnsupportedFormatError("avif", "jimp");
|
|
568
|
+
const buffer = await image.getBuffer(mime, { quality: quality ?? 80 });
|
|
569
|
+
const { writeFile } = await import("node:fs/promises");
|
|
570
|
+
await writeFile(output, buffer);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Infer image format from file extension
|
|
574
|
+
*/
|
|
575
|
+
inferFormat(path) {
|
|
576
|
+
return {
|
|
577
|
+
jpg: "jpeg",
|
|
578
|
+
jpeg: "jpeg",
|
|
579
|
+
png: "png",
|
|
580
|
+
webp: "webp",
|
|
581
|
+
avif: "avif",
|
|
582
|
+
gif: "gif",
|
|
583
|
+
tiff: "tiff",
|
|
584
|
+
tif: "tiff",
|
|
585
|
+
bmp: "png"
|
|
586
|
+
}[path.split(".").pop()?.toLowerCase() || ""] || "jpeg";
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Get MIME type from format
|
|
590
|
+
*/
|
|
591
|
+
mimeFromFormat(format) {
|
|
592
|
+
return {
|
|
593
|
+
jpeg: "image/jpeg",
|
|
594
|
+
png: "image/png",
|
|
595
|
+
webp: "image/webp",
|
|
596
|
+
avif: "image/avif",
|
|
597
|
+
gif: "image/gif",
|
|
598
|
+
tiff: "image/tiff"
|
|
599
|
+
}[format] || "image/jpeg";
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Get format from MIME type
|
|
603
|
+
*/
|
|
604
|
+
formatFromMime(mime) {
|
|
605
|
+
return {
|
|
606
|
+
"image/jpeg": "jpeg",
|
|
607
|
+
"image/png": "png",
|
|
608
|
+
"image/webp": "webp",
|
|
609
|
+
"image/gif": "gif",
|
|
610
|
+
"image/tiff": "tiff",
|
|
611
|
+
"image/bmp": "bmp"
|
|
612
|
+
}[mime] || "unknown";
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Try to determine MIME type from input
|
|
616
|
+
*/
|
|
617
|
+
getMimeFromInput(input) {
|
|
618
|
+
if (typeof input === "string") {
|
|
619
|
+
const format = this.inferFormat(input);
|
|
620
|
+
return this.mimeFromFormat(format);
|
|
621
|
+
}
|
|
622
|
+
return "image/png";
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Map Jimp errors to our error types
|
|
626
|
+
*/
|
|
627
|
+
mapError(error, input) {
|
|
628
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
629
|
+
if (message.includes("no such file") || message.includes("ENOENT") || message.includes("Could not find")) throw new ImageNotFoundError(typeof input === "string" ? input : "<buffer>", "jimp");
|
|
630
|
+
if (message.includes("not supported") || message.includes("Could not find MIME")) throw new UnsupportedFormatError("unknown", "jimp");
|
|
631
|
+
return new ProcessingError(`Image processing failed: ${message}`, "jimp", error instanceof Error ? error : void 0);
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/adapters/sharp.ts
|
|
636
|
+
/**
|
|
637
|
+
* Sharp adapter for image processing
|
|
638
|
+
* Uses the native sharp library for fast image processing
|
|
639
|
+
*/
|
|
640
|
+
var sharp_exports = /* @__PURE__ */ __exportAll({ SharpAdapter: () => SharpAdapter });
|
|
641
|
+
/**
|
|
642
|
+
* Sharp adapter for high-performance image processing
|
|
643
|
+
*
|
|
644
|
+
* Uses the native sharp library which provides:
|
|
645
|
+
* - Fast image resizing and format conversion
|
|
646
|
+
* - Rich metadata extraction including EXIF, IPTC, XMP
|
|
647
|
+
* - Perceptual hashing for image deduplication
|
|
648
|
+
*
|
|
649
|
+
* @example
|
|
650
|
+
* ```typescript
|
|
651
|
+
* const processor = new SharpAdapter();
|
|
652
|
+
* const dims = await processor.getDimensions('/path/to/image.jpg');
|
|
653
|
+
* console.log(dims); // { width: 1920, height: 1080 }
|
|
654
|
+
* ```
|
|
655
|
+
*/
|
|
656
|
+
var SharpAdapter = class {
|
|
657
|
+
sharp = null;
|
|
658
|
+
options;
|
|
659
|
+
/**
|
|
660
|
+
* Create a new Sharp adapter
|
|
661
|
+
* @param options - Sharp adapter options
|
|
662
|
+
*/
|
|
663
|
+
constructor(options = {}) {
|
|
664
|
+
this.options = options;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Lazily load sharp module
|
|
668
|
+
*/
|
|
669
|
+
async getSharp() {
|
|
670
|
+
if (!this.sharp) try {
|
|
671
|
+
this.sharp = (await import("sharp")).default;
|
|
672
|
+
} catch (error) {
|
|
673
|
+
throw new ProcessingError("Sharp library is not installed. Install it with: npm install sharp", "sharp", error instanceof Error ? error : void 0);
|
|
674
|
+
}
|
|
675
|
+
return this.sharp;
|
|
676
|
+
}
|
|
677
|
+
async getDimensions(input) {
|
|
678
|
+
const sharp = await this.getSharp();
|
|
679
|
+
try {
|
|
680
|
+
const metadata = await sharp(input).metadata();
|
|
681
|
+
if (!metadata.width || !metadata.height) throw new ProcessingError("Could not determine image dimensions", "sharp");
|
|
682
|
+
return {
|
|
683
|
+
width: metadata.width,
|
|
684
|
+
height: metadata.height
|
|
685
|
+
};
|
|
686
|
+
} catch (error) {
|
|
687
|
+
if (error instanceof ProcessingError) throw error;
|
|
688
|
+
throw this.mapError(error, input);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async thumbnail(input, output, options = {}) {
|
|
692
|
+
const sharp = await this.getSharp();
|
|
693
|
+
try {
|
|
694
|
+
let pipeline = sharp(input).resize({
|
|
695
|
+
width: options.maxWidth,
|
|
696
|
+
height: options.maxHeight,
|
|
697
|
+
fit: options.fit || "inside",
|
|
698
|
+
withoutEnlargement: true
|
|
699
|
+
});
|
|
700
|
+
if (options.format) pipeline = pipeline.toFormat(options.format, { quality: options.quality });
|
|
701
|
+
else if (options.quality) {
|
|
702
|
+
const format = this.inferFormat(output);
|
|
703
|
+
pipeline = pipeline.toFormat(format, { quality: options.quality });
|
|
704
|
+
}
|
|
705
|
+
await pipeline.toFile(output);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
throw this.mapError(error, input);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
async convert(input, output, options = {}) {
|
|
711
|
+
const sharp = await this.getSharp();
|
|
712
|
+
try {
|
|
713
|
+
const format = options.format || this.inferFormat(output);
|
|
714
|
+
await sharp(input).toFormat(format, { quality: options.quality }).toFile(output);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
throw this.mapError(error, input);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
async getMetadata(input) {
|
|
720
|
+
const sharp = await this.getSharp();
|
|
721
|
+
try {
|
|
722
|
+
const meta = await sharp(input).metadata();
|
|
723
|
+
if (!meta.width || !meta.height || !meta.format) throw new ProcessingError("Could not extract image metadata", "sharp");
|
|
724
|
+
return {
|
|
725
|
+
width: meta.width,
|
|
726
|
+
height: meta.height,
|
|
727
|
+
format: meta.format,
|
|
728
|
+
space: meta.space,
|
|
729
|
+
channels: meta.channels,
|
|
730
|
+
depth: meta.depth,
|
|
731
|
+
density: meta.density,
|
|
732
|
+
hasAlpha: meta.hasAlpha,
|
|
733
|
+
orientation: meta.orientation,
|
|
734
|
+
exif: meta.exif ? this.parseExifBuffer(meta.exif) : void 0,
|
|
735
|
+
iptc: meta.iptc ? this.parseIptcBuffer(meta.iptc) : void 0,
|
|
736
|
+
xmp: meta.xmp ? this.parseXmpBuffer(meta.xmp) : void 0
|
|
737
|
+
};
|
|
738
|
+
} catch (error) {
|
|
739
|
+
if (error instanceof ProcessingError) throw error;
|
|
740
|
+
throw this.mapError(error, input);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
async hash(input, algorithm = "perceptual") {
|
|
744
|
+
const sharp = await this.getSharp();
|
|
745
|
+
try {
|
|
746
|
+
if (algorithm === "md5" || algorithm === "sha256") {
|
|
747
|
+
const { createHash } = await import("node:crypto");
|
|
748
|
+
const buffer = await sharp(input).toBuffer();
|
|
749
|
+
return createHash(algorithm).update(buffer).digest("hex");
|
|
750
|
+
}
|
|
751
|
+
const { data } = await sharp(input).resize(8, 8, { fit: "fill" }).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
752
|
+
const avg = data.reduce((sum, val) => sum + val, 0) / data.length;
|
|
753
|
+
let hash = "";
|
|
754
|
+
for (const val of data) hash += val >= avg ? "1" : "0";
|
|
755
|
+
return BigInt(`0b${hash}`).toString(16).padStart(16, "0");
|
|
756
|
+
} catch (error) {
|
|
757
|
+
throw this.mapError(error, input);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
async resize(input, output, options) {
|
|
761
|
+
const sharp = await this.getSharp();
|
|
762
|
+
try {
|
|
763
|
+
let pipeline = sharp(input).resize({
|
|
764
|
+
width: options.width,
|
|
765
|
+
height: options.height,
|
|
766
|
+
fit: options.fit || "cover"
|
|
767
|
+
});
|
|
768
|
+
if (options.format) pipeline = pipeline.toFormat(options.format, { quality: options.quality });
|
|
769
|
+
else if (options.quality) {
|
|
770
|
+
const format = this.inferFormat(output);
|
|
771
|
+
pipeline = pipeline.toFormat(format, { quality: options.quality });
|
|
772
|
+
}
|
|
773
|
+
await pipeline.toFile(output);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
throw this.mapError(error, input);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Infer image format from file extension
|
|
780
|
+
*/
|
|
781
|
+
inferFormat(path) {
|
|
782
|
+
return {
|
|
783
|
+
jpg: "jpeg",
|
|
784
|
+
jpeg: "jpeg",
|
|
785
|
+
png: "png",
|
|
786
|
+
webp: "webp",
|
|
787
|
+
avif: "avif",
|
|
788
|
+
gif: "gif",
|
|
789
|
+
tiff: "tiff",
|
|
790
|
+
tif: "tiff"
|
|
791
|
+
}[path.split(".").pop()?.toLowerCase() || ""] || "jpeg";
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Parse EXIF buffer to object (basic parsing)
|
|
795
|
+
*/
|
|
796
|
+
parseExifBuffer(buffer) {
|
|
797
|
+
return { raw: `${buffer.toString("base64").slice(0, 100)}...` };
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Parse IPTC buffer to object (basic parsing)
|
|
801
|
+
*/
|
|
802
|
+
parseIptcBuffer(buffer) {
|
|
803
|
+
return { raw: `${buffer.toString("base64").slice(0, 100)}...` };
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Parse XMP buffer to object (basic parsing)
|
|
807
|
+
*/
|
|
808
|
+
parseXmpBuffer(buffer) {
|
|
809
|
+
try {
|
|
810
|
+
return { raw: `${buffer.toString("utf8").slice(0, 500)}...` };
|
|
811
|
+
} catch {
|
|
812
|
+
return { raw: `${buffer.toString("base64").slice(0, 100)}...` };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Map sharp errors to our error types
|
|
817
|
+
*/
|
|
818
|
+
mapError(error, input) {
|
|
819
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
820
|
+
if (message.includes("Input file is missing")) throw new ImageNotFoundError(typeof input === "string" ? input : "<buffer>", "sharp");
|
|
821
|
+
if (message.includes("Input buffer contains unsupported image format")) throw new ProcessingError("Unsupported image format", "sharp");
|
|
822
|
+
return new ProcessingError(`Image processing failed: ${message}`, "sharp", error instanceof Error ? error : void 0);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
//#endregion
|
|
826
|
+
//#region src/headline-card.ts
|
|
827
|
+
/**
|
|
828
|
+
* Cached font data
|
|
829
|
+
*/
|
|
830
|
+
var cachedFontData = null;
|
|
831
|
+
var cachedFontName = null;
|
|
832
|
+
/**
|
|
833
|
+
* Load font from Google Fonts
|
|
834
|
+
*
|
|
835
|
+
* Note: Satori requires TTF/OTF/WOFF format, NOT WOFF2.
|
|
836
|
+
* We use an old browser User-Agent to get WOFF format from Google Fonts.
|
|
837
|
+
*/
|
|
821
838
|
async function loadGoogleFont(fontName) {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
/src:\s*url\(([^)]+)\)\s*format\(['"]truetype['"]\)/
|
|
838
|
-
);
|
|
839
|
-
if (!urlMatch) {
|
|
840
|
-
urlMatch = css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]woff['"]\)/);
|
|
841
|
-
}
|
|
842
|
-
if (!urlMatch) {
|
|
843
|
-
urlMatch = css.match(/src:\s*url\(([^)]+)\)(?!\s*format\(['"]woff2['"]\))/);
|
|
844
|
-
}
|
|
845
|
-
if (!urlMatch) {
|
|
846
|
-
throw new Error(
|
|
847
|
-
`Could not find compatible font URL in CSS for ${fontName}. CSS: ${css.slice(0, 300)}...`
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
|
-
const fontFileUrl = urlMatch[1];
|
|
851
|
-
const fontResponse = await fetch(fontFileUrl);
|
|
852
|
-
if (!fontResponse.ok) {
|
|
853
|
-
throw new Error(`Failed to fetch font file: ${fontResponse.statusText}`);
|
|
854
|
-
}
|
|
855
|
-
cachedFontData = await fontResponse.arrayBuffer();
|
|
856
|
-
cachedFontName = fontName;
|
|
857
|
-
return cachedFontData;
|
|
839
|
+
if (cachedFontData && cachedFontName === fontName) return cachedFontData;
|
|
840
|
+
const fontUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;600;700&display=swap`;
|
|
841
|
+
const cssResponse = await fetch(fontUrl, { headers: { "User-Agent": "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)" } });
|
|
842
|
+
if (!cssResponse.ok) throw new Error(`Failed to fetch font CSS: ${cssResponse.statusText}`);
|
|
843
|
+
const css = await cssResponse.text();
|
|
844
|
+
let urlMatch = css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]truetype['"]\)/);
|
|
845
|
+
if (!urlMatch) urlMatch = css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]woff['"]\)/);
|
|
846
|
+
if (!urlMatch) urlMatch = css.match(/src:\s*url\(([^)]+)\)(?!\s*format\(['"]woff2['"]\))/);
|
|
847
|
+
if (!urlMatch) throw new Error(`Could not find compatible font URL in CSS for ${fontName}. CSS: ${css.slice(0, 300)}...`);
|
|
848
|
+
const fontFileUrl = urlMatch[1];
|
|
849
|
+
const fontResponse = await fetch(fontFileUrl);
|
|
850
|
+
if (!fontResponse.ok) throw new Error(`Failed to fetch font file: ${fontResponse.statusText}`);
|
|
851
|
+
cachedFontData = await fontResponse.arrayBuffer();
|
|
852
|
+
cachedFontName = fontName;
|
|
853
|
+
return cachedFontData;
|
|
858
854
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
}) {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
props: {
|
|
931
|
-
style: {
|
|
932
|
-
position: "absolute",
|
|
933
|
-
bottom: "60px",
|
|
934
|
-
left: "60px",
|
|
935
|
-
width: "80px",
|
|
936
|
-
height: "4px",
|
|
937
|
-
backgroundColor: brandColor
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
].filter(Boolean)
|
|
942
|
-
}
|
|
943
|
-
};
|
|
855
|
+
/**
|
|
856
|
+
* Default template - clean with accent bar
|
|
857
|
+
*/
|
|
858
|
+
function renderDefaultTemplate({ title, options }) {
|
|
859
|
+
const { width, height, brandColor, backgroundColor, textColor, subtitle, fontFamily } = options;
|
|
860
|
+
return {
|
|
861
|
+
type: "div",
|
|
862
|
+
props: {
|
|
863
|
+
style: {
|
|
864
|
+
display: "flex",
|
|
865
|
+
flexDirection: "column",
|
|
866
|
+
justifyContent: "center",
|
|
867
|
+
width: "100%",
|
|
868
|
+
height: "100%",
|
|
869
|
+
backgroundColor,
|
|
870
|
+
padding: "60px",
|
|
871
|
+
fontFamily
|
|
872
|
+
},
|
|
873
|
+
children: [
|
|
874
|
+
{
|
|
875
|
+
type: "div",
|
|
876
|
+
props: { style: {
|
|
877
|
+
position: "absolute",
|
|
878
|
+
top: 0,
|
|
879
|
+
left: 0,
|
|
880
|
+
right: 0,
|
|
881
|
+
height: "8px",
|
|
882
|
+
backgroundColor: brandColor
|
|
883
|
+
} }
|
|
884
|
+
},
|
|
885
|
+
subtitle ? {
|
|
886
|
+
type: "div",
|
|
887
|
+
props: {
|
|
888
|
+
style: {
|
|
889
|
+
fontSize: "24px",
|
|
890
|
+
fontWeight: 600,
|
|
891
|
+
color: brandColor,
|
|
892
|
+
marginBottom: "20px",
|
|
893
|
+
textTransform: "uppercase",
|
|
894
|
+
letterSpacing: "2px"
|
|
895
|
+
},
|
|
896
|
+
children: subtitle
|
|
897
|
+
}
|
|
898
|
+
} : null,
|
|
899
|
+
{
|
|
900
|
+
type: "div",
|
|
901
|
+
props: {
|
|
902
|
+
style: {
|
|
903
|
+
fontSize: Math.min(72, Math.floor(width / (title.length * .6))),
|
|
904
|
+
fontWeight: 700,
|
|
905
|
+
color: "#1e293b",
|
|
906
|
+
lineHeight: 1.2,
|
|
907
|
+
maxWidth: "90%"
|
|
908
|
+
},
|
|
909
|
+
children: title
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
{
|
|
913
|
+
type: "div",
|
|
914
|
+
props: { style: {
|
|
915
|
+
position: "absolute",
|
|
916
|
+
bottom: "60px",
|
|
917
|
+
left: "60px",
|
|
918
|
+
width: "80px",
|
|
919
|
+
height: "4px",
|
|
920
|
+
backgroundColor: brandColor
|
|
921
|
+
} }
|
|
922
|
+
}
|
|
923
|
+
].filter(Boolean)
|
|
924
|
+
}
|
|
925
|
+
};
|
|
944
926
|
}
|
|
927
|
+
/**
|
|
928
|
+
* News template - bold with side accent
|
|
929
|
+
*/
|
|
945
930
|
function renderNewsTemplate({ title, options }) {
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
{
|
|
1010
|
-
type: "div",
|
|
1011
|
-
props: {
|
|
1012
|
-
style: {
|
|
1013
|
-
fontSize: Math.min(
|
|
1014
|
-
64,
|
|
1015
|
-
Math.floor((width - 100) / (title.length * 0.5))
|
|
1016
|
-
),
|
|
1017
|
-
fontWeight: 700,
|
|
1018
|
-
color: "#0f172a",
|
|
1019
|
-
lineHeight: 1.15
|
|
1020
|
-
},
|
|
1021
|
-
children: title
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
].filter(Boolean)
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
]
|
|
1028
|
-
}
|
|
1029
|
-
};
|
|
931
|
+
const { width, height, brandColor, backgroundColor, textColor, subtitle, fontFamily } = options;
|
|
932
|
+
return {
|
|
933
|
+
type: "div",
|
|
934
|
+
props: {
|
|
935
|
+
style: {
|
|
936
|
+
display: "flex",
|
|
937
|
+
flexDirection: "row",
|
|
938
|
+
width: "100%",
|
|
939
|
+
height: "100%",
|
|
940
|
+
backgroundColor,
|
|
941
|
+
fontFamily
|
|
942
|
+
},
|
|
943
|
+
children: [{
|
|
944
|
+
type: "div",
|
|
945
|
+
props: { style: {
|
|
946
|
+
width: "16px",
|
|
947
|
+
height: "100%",
|
|
948
|
+
backgroundColor: brandColor
|
|
949
|
+
} }
|
|
950
|
+
}, {
|
|
951
|
+
type: "div",
|
|
952
|
+
props: {
|
|
953
|
+
style: {
|
|
954
|
+
display: "flex",
|
|
955
|
+
flexDirection: "column",
|
|
956
|
+
justifyContent: "center",
|
|
957
|
+
flex: 1,
|
|
958
|
+
padding: "60px"
|
|
959
|
+
},
|
|
960
|
+
children: [subtitle ? {
|
|
961
|
+
type: "div",
|
|
962
|
+
props: {
|
|
963
|
+
style: {
|
|
964
|
+
display: "flex",
|
|
965
|
+
alignSelf: "flex-start",
|
|
966
|
+
backgroundColor: brandColor,
|
|
967
|
+
color: "#ffffff",
|
|
968
|
+
fontSize: "18px",
|
|
969
|
+
fontWeight: 600,
|
|
970
|
+
padding: "8px 16px",
|
|
971
|
+
borderRadius: "4px",
|
|
972
|
+
marginBottom: "24px",
|
|
973
|
+
textTransform: "uppercase",
|
|
974
|
+
letterSpacing: "1px"
|
|
975
|
+
},
|
|
976
|
+
children: subtitle
|
|
977
|
+
}
|
|
978
|
+
} : null, {
|
|
979
|
+
type: "div",
|
|
980
|
+
props: {
|
|
981
|
+
style: {
|
|
982
|
+
fontSize: Math.min(64, Math.floor((width - 100) / (title.length * .5))),
|
|
983
|
+
fontWeight: 700,
|
|
984
|
+
color: "#0f172a",
|
|
985
|
+
lineHeight: 1.15
|
|
986
|
+
},
|
|
987
|
+
children: title
|
|
988
|
+
}
|
|
989
|
+
}].filter(Boolean)
|
|
990
|
+
}
|
|
991
|
+
}]
|
|
992
|
+
}
|
|
993
|
+
};
|
|
1030
994
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
}) {
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
marginTop: "40px"
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
]
|
|
1091
|
-
}
|
|
1092
|
-
};
|
|
995
|
+
/**
|
|
996
|
+
* Minimal template - simple and elegant
|
|
997
|
+
*/
|
|
998
|
+
function renderMinimalTemplate({ title, options }) {
|
|
999
|
+
const { width, height, brandColor, backgroundColor, textColor, fontFamily } = options;
|
|
1000
|
+
return {
|
|
1001
|
+
type: "div",
|
|
1002
|
+
props: {
|
|
1003
|
+
style: {
|
|
1004
|
+
display: "flex",
|
|
1005
|
+
flexDirection: "column",
|
|
1006
|
+
alignItems: "center",
|
|
1007
|
+
justifyContent: "center",
|
|
1008
|
+
width: "100%",
|
|
1009
|
+
height: "100%",
|
|
1010
|
+
backgroundColor,
|
|
1011
|
+
padding: "80px",
|
|
1012
|
+
fontFamily
|
|
1013
|
+
},
|
|
1014
|
+
children: [
|
|
1015
|
+
{
|
|
1016
|
+
type: "div",
|
|
1017
|
+
props: { style: {
|
|
1018
|
+
width: "60px",
|
|
1019
|
+
height: "3px",
|
|
1020
|
+
backgroundColor: brandColor,
|
|
1021
|
+
marginBottom: "40px"
|
|
1022
|
+
} }
|
|
1023
|
+
},
|
|
1024
|
+
{
|
|
1025
|
+
type: "div",
|
|
1026
|
+
props: {
|
|
1027
|
+
style: {
|
|
1028
|
+
fontSize: Math.min(56, Math.floor(width / (title.length * .55))),
|
|
1029
|
+
fontWeight: 600,
|
|
1030
|
+
color: "#334155",
|
|
1031
|
+
lineHeight: 1.3,
|
|
1032
|
+
textAlign: "center",
|
|
1033
|
+
maxWidth: "85%"
|
|
1034
|
+
},
|
|
1035
|
+
children: title
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
type: "div",
|
|
1040
|
+
props: { style: {
|
|
1041
|
+
width: "60px",
|
|
1042
|
+
height: "3px",
|
|
1043
|
+
backgroundColor: brandColor,
|
|
1044
|
+
marginTop: "40px"
|
|
1045
|
+
} }
|
|
1046
|
+
}
|
|
1047
|
+
]
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1093
1050
|
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Generate a headline card image
|
|
1053
|
+
*
|
|
1054
|
+
* Creates a branded social media / OG image with the article title.
|
|
1055
|
+
* Supports multiple templates and customization options.
|
|
1056
|
+
*
|
|
1057
|
+
* @param title - The headline text to display
|
|
1058
|
+
* @param options - Customization options
|
|
1059
|
+
* @returns Promise resolving to PNG image result
|
|
1060
|
+
*
|
|
1061
|
+
* @example Basic usage
|
|
1062
|
+
* ```typescript
|
|
1063
|
+
* const result = await generateHeadlineCard('Breaking News: AI Advances');
|
|
1064
|
+
* await fs.writeFile('og-image.png', result.buffer);
|
|
1065
|
+
* ```
|
|
1066
|
+
*
|
|
1067
|
+
* @example With branding
|
|
1068
|
+
* ```typescript
|
|
1069
|
+
* const result = await generateHeadlineCard('Local Council Approves Budget', {
|
|
1070
|
+
* brandColor: '#1a56db',
|
|
1071
|
+
* subtitle: 'Town News',
|
|
1072
|
+
* template: 'news'
|
|
1073
|
+
* });
|
|
1074
|
+
* ```
|
|
1075
|
+
*
|
|
1076
|
+
* @example Full customization
|
|
1077
|
+
* ```typescript
|
|
1078
|
+
* const result = await generateHeadlineCard('The Future of Technology', {
|
|
1079
|
+
* width: 1200,
|
|
1080
|
+
* height: 630,
|
|
1081
|
+
* brandColor: '#059669',
|
|
1082
|
+
* backgroundColor: '#f8fafc',
|
|
1083
|
+
* subtitle: 'Technology',
|
|
1084
|
+
* template: 'minimal',
|
|
1085
|
+
* fontFamily: 'Roboto'
|
|
1086
|
+
* });
|
|
1087
|
+
* ```
|
|
1088
|
+
*/
|
|
1094
1089
|
async function generateHeadlineCard(title, options = {}) {
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1090
|
+
const width = options.width ?? 1200;
|
|
1091
|
+
const height = options.height ?? 630;
|
|
1092
|
+
const brandColor = options.brandColor ?? "#3b82f6";
|
|
1093
|
+
const backgroundColor = options.backgroundColor ?? "#ffffff";
|
|
1094
|
+
const textColor = options.textColor ?? "#64748b";
|
|
1095
|
+
const template = options.template ?? "default";
|
|
1096
|
+
const fontFamily = options.fontFamily ?? "Inter";
|
|
1097
|
+
const resolvedOptions = {
|
|
1098
|
+
...options,
|
|
1099
|
+
width,
|
|
1100
|
+
height,
|
|
1101
|
+
brandColor,
|
|
1102
|
+
backgroundColor,
|
|
1103
|
+
textColor,
|
|
1104
|
+
fontFamily
|
|
1105
|
+
};
|
|
1106
|
+
const [{ default: satori }, { Resvg }] = await Promise.all([import("satori"), import("@resvg/resvg-js")]);
|
|
1107
|
+
const fontData = options.fontData ?? await loadGoogleFont(fontFamily);
|
|
1108
|
+
let content;
|
|
1109
|
+
switch (template) {
|
|
1110
|
+
case "news":
|
|
1111
|
+
content = renderNewsTemplate({
|
|
1112
|
+
title,
|
|
1113
|
+
options: resolvedOptions
|
|
1114
|
+
});
|
|
1115
|
+
break;
|
|
1116
|
+
case "minimal":
|
|
1117
|
+
content = renderMinimalTemplate({
|
|
1118
|
+
title,
|
|
1119
|
+
options: resolvedOptions
|
|
1120
|
+
});
|
|
1121
|
+
break;
|
|
1122
|
+
default: content = renderDefaultTemplate({
|
|
1123
|
+
title,
|
|
1124
|
+
options: resolvedOptions
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
const pngData = new Resvg(await satori(content, {
|
|
1128
|
+
width,
|
|
1129
|
+
height,
|
|
1130
|
+
fonts: [
|
|
1131
|
+
{
|
|
1132
|
+
name: fontFamily,
|
|
1133
|
+
data: fontData,
|
|
1134
|
+
weight: 400,
|
|
1135
|
+
style: "normal"
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
name: fontFamily,
|
|
1139
|
+
data: fontData,
|
|
1140
|
+
weight: 600,
|
|
1141
|
+
style: "normal"
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
name: fontFamily,
|
|
1145
|
+
data: fontData,
|
|
1146
|
+
weight: 700,
|
|
1147
|
+
style: "normal"
|
|
1148
|
+
}
|
|
1149
|
+
]
|
|
1150
|
+
}), { fitTo: {
|
|
1151
|
+
mode: "width",
|
|
1152
|
+
value: width
|
|
1153
|
+
} }).render();
|
|
1154
|
+
return {
|
|
1155
|
+
buffer: Buffer.from(pngData.asPng()),
|
|
1156
|
+
width,
|
|
1157
|
+
height,
|
|
1158
|
+
mimeType: "image/png"
|
|
1159
|
+
};
|
|
1163
1160
|
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Reset cached font data (useful for testing)
|
|
1163
|
+
*/
|
|
1164
1164
|
function resetFontCache() {
|
|
1165
|
-
|
|
1166
|
-
|
|
1165
|
+
cachedFontData = null;
|
|
1166
|
+
cachedFontName = null;
|
|
1167
1167
|
}
|
|
1168
|
+
//#endregion
|
|
1169
|
+
//#region src/shared/factory.ts
|
|
1170
|
+
/**
|
|
1171
|
+
* Factory function for creating image processor instances
|
|
1172
|
+
*/
|
|
1173
|
+
var factory_exports = /* @__PURE__ */ __exportAll({
|
|
1174
|
+
getAvailableAdapters: () => getAvailableAdapters,
|
|
1175
|
+
getImageProcessor: () => getImageProcessor
|
|
1176
|
+
});
|
|
1177
|
+
/**
|
|
1178
|
+
* Type guards for processor options
|
|
1179
|
+
*/
|
|
1180
|
+
/**
|
|
1181
|
+
* Checks if the options are for Sharp adapter
|
|
1182
|
+
*/
|
|
1168
1183
|
function isSharpOptions(options) {
|
|
1169
|
-
|
|
1184
|
+
return !options.type || options.type === "sharp";
|
|
1170
1185
|
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Checks if the options are for Jimp adapter
|
|
1188
|
+
*/
|
|
1171
1189
|
function isJimpOptions(options) {
|
|
1172
|
-
|
|
1190
|
+
return options.type === "jimp";
|
|
1173
1191
|
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Checks if the options are for imgproxy adapter
|
|
1194
|
+
*/
|
|
1174
1195
|
function isImgproxyOptions(options) {
|
|
1175
|
-
|
|
1196
|
+
return options.type === "imgproxy";
|
|
1176
1197
|
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Load environment variables for configuration
|
|
1200
|
+
* Pattern: HAVE_IMAGES_*
|
|
1201
|
+
*/
|
|
1177
1202
|
function loadEnvConfig(options) {
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
if (env.HAVE_IMAGES_KEY) {
|
|
1191
|
-
envDefaults.key = env.HAVE_IMAGES_KEY;
|
|
1192
|
-
}
|
|
1193
|
-
if (env.HAVE_IMAGES_SALT) {
|
|
1194
|
-
envDefaults.salt = env.HAVE_IMAGES_SALT;
|
|
1195
|
-
}
|
|
1196
|
-
return { ...envDefaults, ...options };
|
|
1203
|
+
if (typeof process === "undefined" || !process.env) return options;
|
|
1204
|
+
const env = process.env;
|
|
1205
|
+
const envDefaults = {};
|
|
1206
|
+
const typeEnv = env.HAVE_IMAGES_TYPE || env.HAVE_IMAGES_ADAPTER;
|
|
1207
|
+
if (typeEnv && !options.type) envDefaults.type = typeEnv;
|
|
1208
|
+
if (env.HAVE_IMAGES_BASE_URL) envDefaults.baseUrl = env.HAVE_IMAGES_BASE_URL;
|
|
1209
|
+
if (env.HAVE_IMAGES_KEY) envDefaults.key = env.HAVE_IMAGES_KEY;
|
|
1210
|
+
if (env.HAVE_IMAGES_SALT) envDefaults.salt = env.HAVE_IMAGES_SALT;
|
|
1211
|
+
return {
|
|
1212
|
+
...envDefaults,
|
|
1213
|
+
...options
|
|
1214
|
+
};
|
|
1197
1215
|
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Creates an image processor instance based on the provided options.
|
|
1218
|
+
*
|
|
1219
|
+
* Supports environment variable configuration using the pattern:
|
|
1220
|
+
* - HAVE_IMAGES_TYPE → adapter type ('sharp', 'jimp', 'imgproxy')
|
|
1221
|
+
* - HAVE_IMAGES_BASE_URL → imgproxy base URL
|
|
1222
|
+
* - HAVE_IMAGES_KEY → imgproxy signing key (hex)
|
|
1223
|
+
* - HAVE_IMAGES_SALT → imgproxy signing salt (hex)
|
|
1224
|
+
*
|
|
1225
|
+
* User-provided options always take precedence over environment variables.
|
|
1226
|
+
*
|
|
1227
|
+
* @param options - Configuration options for the image processor
|
|
1228
|
+
* @returns Promise resolving to an image processor instance
|
|
1229
|
+
* @throws {InvalidAdapterError} When the adapter type is unsupported
|
|
1230
|
+
*
|
|
1231
|
+
* @example
|
|
1232
|
+
* ```typescript
|
|
1233
|
+
* // Create Sharp processor (default)
|
|
1234
|
+
* const processor = await getImageProcessor();
|
|
1235
|
+
*
|
|
1236
|
+
* // Create Jimp processor
|
|
1237
|
+
* const jimpProcessor = await getImageProcessor({ type: 'jimp' });
|
|
1238
|
+
*
|
|
1239
|
+
* // Create imgproxy processor
|
|
1240
|
+
* const imgproxyProcessor = await getImageProcessor({
|
|
1241
|
+
* type: 'imgproxy',
|
|
1242
|
+
* baseUrl: 'https://imgproxy.example.com',
|
|
1243
|
+
* key: 'abcd1234...',
|
|
1244
|
+
* salt: 'efgh5678...'
|
|
1245
|
+
* });
|
|
1246
|
+
*
|
|
1247
|
+
* // Use environment variables (HAVE_IMAGES_TYPE=sharp)
|
|
1248
|
+
* const autoProcessor = await getImageProcessor({});
|
|
1249
|
+
* ```
|
|
1250
|
+
*/
|
|
1198
1251
|
async function getImageProcessor(options = {}) {
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
options.type || "unknown"
|
|
1214
|
-
);
|
|
1252
|
+
options = loadEnvConfig(options);
|
|
1253
|
+
if (isSharpOptions(options)) {
|
|
1254
|
+
const { SharpAdapter } = await Promise.resolve().then(() => sharp_exports);
|
|
1255
|
+
return new SharpAdapter(options);
|
|
1256
|
+
}
|
|
1257
|
+
if (isJimpOptions(options)) {
|
|
1258
|
+
const { JimpAdapter } = await Promise.resolve().then(() => jimp_exports);
|
|
1259
|
+
return new JimpAdapter(options);
|
|
1260
|
+
}
|
|
1261
|
+
if (isImgproxyOptions(options)) {
|
|
1262
|
+
const { ImgproxyAdapter } = await Promise.resolve().then(() => imgproxy_exports);
|
|
1263
|
+
return new ImgproxyAdapter(options);
|
|
1264
|
+
}
|
|
1265
|
+
throw new InvalidAdapterError(options.type || "unknown");
|
|
1215
1266
|
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Get a list of available adapter types
|
|
1269
|
+
*/
|
|
1216
1270
|
function getAvailableAdapters() {
|
|
1217
|
-
|
|
1271
|
+
return [
|
|
1272
|
+
"sharp",
|
|
1273
|
+
"jimp",
|
|
1274
|
+
"imgproxy"
|
|
1275
|
+
];
|
|
1218
1276
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1277
|
+
//#endregion
|
|
1278
|
+
//#region src/index.ts
|
|
1279
|
+
/**
|
|
1280
|
+
* Cached processor instance for auto-detection
|
|
1281
|
+
*/
|
|
1282
|
+
var cachedProcessor = null;
|
|
1283
|
+
var detectionAttempted = false;
|
|
1284
|
+
/**
|
|
1285
|
+
* Get a default processor with auto-detection
|
|
1286
|
+
*
|
|
1287
|
+
* Auto-detection priority:
|
|
1288
|
+
* 1. If explicit adapter options provided, use those
|
|
1289
|
+
* 2. Try to load Sharp (fast native processing)
|
|
1290
|
+
* 3. Fall back to Jimp (pure JavaScript)
|
|
1291
|
+
*
|
|
1292
|
+
* The processor is cached for subsequent calls to avoid repeated detection.
|
|
1293
|
+
*/
|
|
1226
1294
|
async function getDefaultProcessor(adapterOptions) {
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
if (!cachedProcessor) {
|
|
1246
|
-
throw new Error("No image processor available. Install sharp or jimp.");
|
|
1247
|
-
}
|
|
1248
|
-
return cachedProcessor;
|
|
1295
|
+
if (adapterOptions?.type) {
|
|
1296
|
+
const { getImageProcessor } = await Promise.resolve().then(() => factory_exports);
|
|
1297
|
+
return getImageProcessor(adapterOptions);
|
|
1298
|
+
}
|
|
1299
|
+
if (cachedProcessor && !adapterOptions) return cachedProcessor;
|
|
1300
|
+
if (!detectionAttempted) {
|
|
1301
|
+
detectionAttempted = true;
|
|
1302
|
+
try {
|
|
1303
|
+
await import("sharp");
|
|
1304
|
+
const { SharpAdapter } = await Promise.resolve().then(() => sharp_exports);
|
|
1305
|
+
cachedProcessor = new SharpAdapter({});
|
|
1306
|
+
} catch {
|
|
1307
|
+
const { JimpAdapter } = await Promise.resolve().then(() => jimp_exports);
|
|
1308
|
+
cachedProcessor = new JimpAdapter({ type: "jimp" });
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
if (!cachedProcessor) throw new Error("No image processor available. Install sharp or jimp.");
|
|
1312
|
+
return cachedProcessor;
|
|
1249
1313
|
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Reset the cached processor (useful for testing)
|
|
1316
|
+
*/
|
|
1250
1317
|
function resetProcessor() {
|
|
1251
|
-
|
|
1252
|
-
|
|
1318
|
+
cachedProcessor = null;
|
|
1319
|
+
detectionAttempted = false;
|
|
1253
1320
|
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Get image dimensions
|
|
1323
|
+
*
|
|
1324
|
+
* Uses auto-detection to select the best available processor.
|
|
1325
|
+
*
|
|
1326
|
+
* @param input - Image path or Buffer
|
|
1327
|
+
* @param adapterOptions - Optional adapter configuration to override auto-detection
|
|
1328
|
+
* @returns Promise resolving to image dimensions
|
|
1329
|
+
*
|
|
1330
|
+
* @example
|
|
1331
|
+
* ```typescript
|
|
1332
|
+
* import { getDimensions } from '@happyvertical/images';
|
|
1333
|
+
*
|
|
1334
|
+
* // Auto-detect adapter
|
|
1335
|
+
* const dims = await getDimensions('/path/to/image.jpg');
|
|
1336
|
+
* console.log(`${dims.width}x${dims.height}`);
|
|
1337
|
+
*
|
|
1338
|
+
* // Force specific adapter
|
|
1339
|
+
* const jimp = await getDimensions(buffer, { type: 'jimp' });
|
|
1340
|
+
* ```
|
|
1341
|
+
*/
|
|
1254
1342
|
async function getDimensions(input, adapterOptions) {
|
|
1255
|
-
|
|
1256
|
-
return processor.getDimensions(input);
|
|
1343
|
+
return (await getDefaultProcessor(adapterOptions)).getDimensions(input);
|
|
1257
1344
|
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Generate a thumbnail image
|
|
1347
|
+
*
|
|
1348
|
+
* Creates a smaller version of the image while maintaining aspect ratio.
|
|
1349
|
+
* Uses auto-detection to select the best available processor.
|
|
1350
|
+
*
|
|
1351
|
+
* @param input - Source image path or Buffer
|
|
1352
|
+
* @param output - Output file path
|
|
1353
|
+
* @param options - Thumbnail options (maxWidth, maxHeight, quality, format)
|
|
1354
|
+
* @param adapterOptions - Optional adapter configuration to override auto-detection
|
|
1355
|
+
*
|
|
1356
|
+
* @example
|
|
1357
|
+
* ```typescript
|
|
1358
|
+
* import { generateThumbnail } from '@happyvertical/images';
|
|
1359
|
+
*
|
|
1360
|
+
* // Create 300x300 max thumbnail
|
|
1361
|
+
* await generateThumbnail('/path/to/image.jpg', '/path/to/thumb.jpg', {
|
|
1362
|
+
* maxWidth: 300,
|
|
1363
|
+
* maxHeight: 300,
|
|
1364
|
+
* quality: 85
|
|
1365
|
+
* });
|
|
1366
|
+
*
|
|
1367
|
+
* // Convert to WebP while thumbnailing
|
|
1368
|
+
* await generateThumbnail('/path/to/image.jpg', '/path/to/thumb.webp', {
|
|
1369
|
+
* maxWidth: 200,
|
|
1370
|
+
* format: 'webp'
|
|
1371
|
+
* });
|
|
1372
|
+
* ```
|
|
1373
|
+
*/
|
|
1258
1374
|
async function generateThumbnail(input, output, options, adapterOptions) {
|
|
1259
|
-
|
|
1260
|
-
return processor.thumbnail(input, output, options);
|
|
1375
|
+
return (await getDefaultProcessor(adapterOptions)).thumbnail(input, output, options);
|
|
1261
1376
|
}
|
|
1377
|
+
/**
|
|
1378
|
+
* Convert image format
|
|
1379
|
+
*
|
|
1380
|
+
* Converts an image to a different format with optional quality settings.
|
|
1381
|
+
* Uses auto-detection to select the best available processor.
|
|
1382
|
+
*
|
|
1383
|
+
* @param input - Source image path or Buffer
|
|
1384
|
+
* @param output - Output file path (format inferred from extension)
|
|
1385
|
+
* @param options - Conversion options (format, quality)
|
|
1386
|
+
* @param adapterOptions - Optional adapter configuration to override auto-detection
|
|
1387
|
+
*
|
|
1388
|
+
* @example
|
|
1389
|
+
* ```typescript
|
|
1390
|
+
* import { convertFormat } from '@happyvertical/images';
|
|
1391
|
+
*
|
|
1392
|
+
* // Convert PNG to JPEG
|
|
1393
|
+
* await convertFormat('/path/to/image.png', '/path/to/image.jpg', {
|
|
1394
|
+
* quality: 90
|
|
1395
|
+
* });
|
|
1396
|
+
*
|
|
1397
|
+
* // Convert to WebP with explicit format
|
|
1398
|
+
* await convertFormat('/path/to/image.jpg', '/path/to/image.webp', {
|
|
1399
|
+
* format: 'webp',
|
|
1400
|
+
* quality: 80
|
|
1401
|
+
* });
|
|
1402
|
+
* ```
|
|
1403
|
+
*/
|
|
1262
1404
|
async function convertFormat(input, output, options, adapterOptions) {
|
|
1263
|
-
|
|
1264
|
-
return processor.convert(input, output, options);
|
|
1405
|
+
return (await getDefaultProcessor(adapterOptions)).convert(input, output, options);
|
|
1265
1406
|
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Get image metadata
|
|
1409
|
+
*
|
|
1410
|
+
* Extracts comprehensive metadata including dimensions, format, color space,
|
|
1411
|
+
* and optionally EXIF/IPTC/XMP data.
|
|
1412
|
+
* Uses auto-detection to select the best available processor.
|
|
1413
|
+
*
|
|
1414
|
+
* @param input - Image path or Buffer
|
|
1415
|
+
* @param adapterOptions - Optional adapter configuration to override auto-detection
|
|
1416
|
+
* @returns Promise resolving to metadata object
|
|
1417
|
+
*
|
|
1418
|
+
* @example
|
|
1419
|
+
* ```typescript
|
|
1420
|
+
* import { getImageMetadata } from '@happyvertical/images';
|
|
1421
|
+
*
|
|
1422
|
+
* const metadata = await getImageMetadata('/path/to/photo.jpg');
|
|
1423
|
+
* console.log(`Format: ${metadata.format}`);
|
|
1424
|
+
* console.log(`Dimensions: ${metadata.width}x${metadata.height}`);
|
|
1425
|
+
* console.log(`Has alpha: ${metadata.hasAlpha}`);
|
|
1426
|
+
* if (metadata.exif) {
|
|
1427
|
+
* console.log('EXIF data:', metadata.exif);
|
|
1428
|
+
* }
|
|
1429
|
+
* ```
|
|
1430
|
+
*/
|
|
1266
1431
|
async function getImageMetadata(input, adapterOptions) {
|
|
1267
|
-
|
|
1268
|
-
return processor.getMetadata(input);
|
|
1432
|
+
return (await getDefaultProcessor(adapterOptions)).getMetadata(input);
|
|
1269
1433
|
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Compute image hash
|
|
1436
|
+
*
|
|
1437
|
+
* Generates a hash of the image for deduplication or comparison.
|
|
1438
|
+
* Uses auto-detection to select the best available processor.
|
|
1439
|
+
*
|
|
1440
|
+
* @param input - Image path or Buffer
|
|
1441
|
+
* @param algorithm - Hash algorithm: 'perceptual' (default), 'md5', 'sha256'
|
|
1442
|
+
* @param adapterOptions - Optional adapter configuration to override auto-detection
|
|
1443
|
+
* @returns Promise resolving to hash string
|
|
1444
|
+
*
|
|
1445
|
+
* @example
|
|
1446
|
+
* ```typescript
|
|
1447
|
+
* import { getImageHash } from '@happyvertical/images';
|
|
1448
|
+
*
|
|
1449
|
+
* // Perceptual hash for finding similar images
|
|
1450
|
+
* const pHash = await getImageHash('/path/to/image.jpg');
|
|
1451
|
+
* console.log(`Perceptual hash: ${pHash}`);
|
|
1452
|
+
*
|
|
1453
|
+
* // Cryptographic hash for exact matching
|
|
1454
|
+
* const sha256 = await getImageHash('/path/to/image.jpg', 'sha256');
|
|
1455
|
+
* console.log(`SHA-256: ${sha256}`);
|
|
1456
|
+
* ```
|
|
1457
|
+
*/
|
|
1270
1458
|
async function getImageHash(input, algorithm = "perceptual", adapterOptions) {
|
|
1271
|
-
|
|
1272
|
-
return processor.hash(input, algorithm);
|
|
1459
|
+
return (await getDefaultProcessor(adapterOptions)).hash(input, algorithm);
|
|
1273
1460
|
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Resize image with full control
|
|
1463
|
+
*
|
|
1464
|
+
* Resizes an image to specific dimensions with control over fit mode.
|
|
1465
|
+
* Uses auto-detection to select the best available processor.
|
|
1466
|
+
*
|
|
1467
|
+
* @param input - Source image path or Buffer
|
|
1468
|
+
* @param output - Output file path
|
|
1469
|
+
* @param options - Resize options (width, height, fit, quality, format)
|
|
1470
|
+
* @param adapterOptions - Optional adapter configuration to override auto-detection
|
|
1471
|
+
*
|
|
1472
|
+
* @example
|
|
1473
|
+
* ```typescript
|
|
1474
|
+
* import { resizeImage } from '@happyvertical/images';
|
|
1475
|
+
*
|
|
1476
|
+
* // Resize to exact dimensions (may crop)
|
|
1477
|
+
* await resizeImage('/path/to/image.jpg', '/path/to/resized.jpg', {
|
|
1478
|
+
* width: 800,
|
|
1479
|
+
* height: 600,
|
|
1480
|
+
* fit: 'cover' // Crop to fill
|
|
1481
|
+
* });
|
|
1482
|
+
*
|
|
1483
|
+
* // Resize to fit within dimensions (no crop)
|
|
1484
|
+
* await resizeImage('/path/to/image.jpg', '/path/to/resized.jpg', {
|
|
1485
|
+
* width: 800,
|
|
1486
|
+
* height: 600,
|
|
1487
|
+
* fit: 'inside' // Fit within, may be smaller
|
|
1488
|
+
* });
|
|
1489
|
+
* ```
|
|
1490
|
+
*/
|
|
1274
1491
|
async function resizeImage(input, output, options, adapterOptions) {
|
|
1275
|
-
|
|
1276
|
-
return processor.resize(input, output, options);
|
|
1492
|
+
return (await getDefaultProcessor(adapterOptions)).resize(input, output, options);
|
|
1277
1493
|
}
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
InvalidAdapterError,
|
|
1283
|
-
JimpAdapter,
|
|
1284
|
-
OperationNotSupportedError,
|
|
1285
|
-
ProcessingError,
|
|
1286
|
-
RemoteServiceError,
|
|
1287
|
-
SharpAdapter,
|
|
1288
|
-
UnsupportedFormatError,
|
|
1289
|
-
convertFormat,
|
|
1290
|
-
generateHeadlineCard,
|
|
1291
|
-
generateThumbnail,
|
|
1292
|
-
getAvailableAdapters,
|
|
1293
|
-
getDimensions,
|
|
1294
|
-
getImageHash,
|
|
1295
|
-
getImageMetadata,
|
|
1296
|
-
getImageProcessor,
|
|
1297
|
-
resetFontCache,
|
|
1298
|
-
resetProcessor,
|
|
1299
|
-
resizeImage,
|
|
1300
|
-
signImgproxyUrl
|
|
1301
|
-
};
|
|
1302
|
-
//# sourceMappingURL=index.js.map
|
|
1494
|
+
//#endregion
|
|
1495
|
+
export { ImageError, ImageNotFoundError, ImgproxyAdapter, InvalidAdapterError, JimpAdapter, OperationNotSupportedError, ProcessingError, RemoteServiceError, SharpAdapter, UnsupportedFormatError, convertFormat, generateHeadlineCard, generateThumbnail, getAvailableAdapters, getDimensions, getImageHash, getImageMetadata, getImageProcessor, resetFontCache, resetProcessor, resizeImage, signImgproxyUrl };
|
|
1496
|
+
|
|
1497
|
+
//# sourceMappingURL=index.js.map
|