@happyvertical/images 0.74.8
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/AGENT.md +33 -0
- package/LICENSE +7 -0
- package/dist/adapters/imgproxy.d.ts +176 -0
- package/dist/adapters/imgproxy.d.ts.map +1 -0
- package/dist/adapters/jimp.d.ts +66 -0
- package/dist/adapters/jimp.d.ts.map +1 -0
- package/dist/adapters/sharp.d.ts +56 -0
- package/dist/adapters/sharp.d.ts.map +1 -0
- package/dist/cli/claude-context.d.ts +3 -0
- package/dist/cli/claude-context.d.ts.map +1 -0
- package/dist/cli/claude-context.js +21 -0
- package/dist/cli/claude-context.js.map +1 -0
- package/dist/headline-card.d.ts +136 -0
- package/dist/headline-card.d.ts.map +1 -0
- package/dist/index.d.ts +218 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1302 -0
- package/dist/index.js.map +1 -0
- package/dist/shared/errors.d.ts +50 -0
- package/dist/shared/errors.d.ts.map +1 -0
- package/dist/shared/factory.d.ts +42 -0
- package/dist/shared/factory.d.ts.map +1 -0
- package/dist/shared/types.d.ts +170 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/metadata.json +33 -0
- package/package.json +73 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1302 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
class ImageError extends Error {
|
|
3
|
+
constructor(message, code, adapter) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.adapter = adapter;
|
|
7
|
+
this.name = "ImageError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
class ImageNotFoundError extends ImageError {
|
|
11
|
+
constructor(path, adapter) {
|
|
12
|
+
super(`Image not found: ${path}`, "IMAGE_NOT_FOUND", adapter);
|
|
13
|
+
this.name = "ImageNotFoundError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
class UnsupportedFormatError extends ImageError {
|
|
17
|
+
constructor(format, adapter) {
|
|
18
|
+
super(`Unsupported image format: ${format}`, "UNSUPPORTED_FORMAT", adapter);
|
|
19
|
+
this.name = "UnsupportedFormatError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
class OperationNotSupportedError extends ImageError {
|
|
23
|
+
constructor(operation, adapter) {
|
|
24
|
+
super(
|
|
25
|
+
`Operation not supported: ${operation}`,
|
|
26
|
+
"OPERATION_NOT_SUPPORTED",
|
|
27
|
+
adapter
|
|
28
|
+
);
|
|
29
|
+
this.name = "OperationNotSupportedError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
class ProcessingError extends ImageError {
|
|
33
|
+
constructor(message, adapter, cause) {
|
|
34
|
+
super(message, "PROCESSING_ERROR", adapter);
|
|
35
|
+
this.cause = cause;
|
|
36
|
+
this.name = "ProcessingError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
class InvalidAdapterError extends ImageError {
|
|
40
|
+
constructor(type) {
|
|
41
|
+
super(`Invalid adapter type: ${type}`, "INVALID_ADAPTER");
|
|
42
|
+
this.name = "InvalidAdapterError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
class RemoteServiceError extends ImageError {
|
|
46
|
+
constructor(message, adapter, statusCode) {
|
|
47
|
+
super(message, "REMOTE_SERVICE_ERROR", adapter);
|
|
48
|
+
this.statusCode = statusCode;
|
|
49
|
+
this.name = "RemoteServiceError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function signImgproxyUrl(source, config, options = {}) {
|
|
53
|
+
const adapter = new ImgproxyAdapter({
|
|
54
|
+
type: "imgproxy",
|
|
55
|
+
baseUrl: config.baseUrl,
|
|
56
|
+
key: config.key,
|
|
57
|
+
salt: config.salt
|
|
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
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const jimp = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
612
|
+
__proto__: null,
|
|
613
|
+
JimpAdapter
|
|
614
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
615
|
+
class SharpAdapter {
|
|
616
|
+
sharp = null;
|
|
617
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: Stored for API compatibility and future extensibility
|
|
618
|
+
options;
|
|
619
|
+
/**
|
|
620
|
+
* Create a new Sharp adapter
|
|
621
|
+
* @param options - Sharp adapter options
|
|
622
|
+
*/
|
|
623
|
+
constructor(options = {}) {
|
|
624
|
+
this.options = options;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Lazily load sharp module
|
|
628
|
+
*/
|
|
629
|
+
async getSharp() {
|
|
630
|
+
if (!this.sharp) {
|
|
631
|
+
try {
|
|
632
|
+
this.sharp = (await import("sharp")).default;
|
|
633
|
+
} catch (error) {
|
|
634
|
+
throw new ProcessingError(
|
|
635
|
+
"Sharp library is not installed. Install it with: npm install sharp",
|
|
636
|
+
"sharp",
|
|
637
|
+
error instanceof Error ? error : void 0
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return this.sharp;
|
|
642
|
+
}
|
|
643
|
+
async getDimensions(input) {
|
|
644
|
+
const sharp2 = await this.getSharp();
|
|
645
|
+
try {
|
|
646
|
+
const metadata = await sharp2(input).metadata();
|
|
647
|
+
if (!metadata.width || !metadata.height) {
|
|
648
|
+
throw new ProcessingError(
|
|
649
|
+
"Could not determine image dimensions",
|
|
650
|
+
"sharp"
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
return { width: metadata.width, height: metadata.height };
|
|
654
|
+
} catch (error) {
|
|
655
|
+
if (error instanceof ProcessingError) throw error;
|
|
656
|
+
throw this.mapError(error, input);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
async thumbnail(input, output, options = {}) {
|
|
660
|
+
const sharp2 = await this.getSharp();
|
|
661
|
+
try {
|
|
662
|
+
let pipeline = sharp2(input).resize({
|
|
663
|
+
width: options.maxWidth,
|
|
664
|
+
height: options.maxHeight,
|
|
665
|
+
fit: options.fit || "inside",
|
|
666
|
+
withoutEnlargement: true
|
|
667
|
+
});
|
|
668
|
+
if (options.format) {
|
|
669
|
+
pipeline = pipeline.toFormat(options.format, {
|
|
670
|
+
quality: options.quality
|
|
671
|
+
});
|
|
672
|
+
} else if (options.quality) {
|
|
673
|
+
const format = this.inferFormat(output);
|
|
674
|
+
pipeline = pipeline.toFormat(format, { quality: options.quality });
|
|
675
|
+
}
|
|
676
|
+
await pipeline.toFile(output);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
throw this.mapError(error, input);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
async convert(input, output, options = {}) {
|
|
682
|
+
const sharp2 = await this.getSharp();
|
|
683
|
+
try {
|
|
684
|
+
const format = options.format || this.inferFormat(output);
|
|
685
|
+
await sharp2(input).toFormat(format, { quality: options.quality }).toFile(output);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
throw this.mapError(error, input);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async getMetadata(input) {
|
|
691
|
+
const sharp2 = await this.getSharp();
|
|
692
|
+
try {
|
|
693
|
+
const meta = await sharp2(input).metadata();
|
|
694
|
+
if (!meta.width || !meta.height || !meta.format) {
|
|
695
|
+
throw new ProcessingError("Could not extract image metadata", "sharp");
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
width: meta.width,
|
|
699
|
+
height: meta.height,
|
|
700
|
+
format: meta.format,
|
|
701
|
+
space: meta.space,
|
|
702
|
+
channels: meta.channels,
|
|
703
|
+
depth: meta.depth,
|
|
704
|
+
density: meta.density,
|
|
705
|
+
hasAlpha: meta.hasAlpha,
|
|
706
|
+
orientation: meta.orientation,
|
|
707
|
+
exif: meta.exif ? this.parseExifBuffer(meta.exif) : void 0,
|
|
708
|
+
iptc: meta.iptc ? this.parseIptcBuffer(meta.iptc) : void 0,
|
|
709
|
+
xmp: meta.xmp ? this.parseXmpBuffer(meta.xmp) : void 0
|
|
710
|
+
};
|
|
711
|
+
} catch (error) {
|
|
712
|
+
if (error instanceof ProcessingError) throw error;
|
|
713
|
+
throw this.mapError(error, input);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async hash(input, algorithm = "perceptual") {
|
|
717
|
+
const sharp2 = await this.getSharp();
|
|
718
|
+
try {
|
|
719
|
+
if (algorithm === "md5" || algorithm === "sha256") {
|
|
720
|
+
const { createHash } = await import("node:crypto");
|
|
721
|
+
const buffer = await sharp2(input).toBuffer();
|
|
722
|
+
return createHash(algorithm).update(buffer).digest("hex");
|
|
723
|
+
}
|
|
724
|
+
const { data } = await sharp2(input).resize(8, 8, { fit: "fill" }).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
725
|
+
const avg = data.reduce((sum, val) => sum + val, 0) / data.length;
|
|
726
|
+
let hash = "";
|
|
727
|
+
for (const val of data) {
|
|
728
|
+
hash += val >= avg ? "1" : "0";
|
|
729
|
+
}
|
|
730
|
+
return BigInt(`0b${hash}`).toString(16).padStart(16, "0");
|
|
731
|
+
} catch (error) {
|
|
732
|
+
throw this.mapError(error, input);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async resize(input, output, options) {
|
|
736
|
+
const sharp2 = await this.getSharp();
|
|
737
|
+
try {
|
|
738
|
+
let pipeline = sharp2(input).resize({
|
|
739
|
+
width: options.width,
|
|
740
|
+
height: options.height,
|
|
741
|
+
fit: options.fit || "cover"
|
|
742
|
+
});
|
|
743
|
+
if (options.format) {
|
|
744
|
+
pipeline = pipeline.toFormat(options.format, {
|
|
745
|
+
quality: options.quality
|
|
746
|
+
});
|
|
747
|
+
} else if (options.quality) {
|
|
748
|
+
const format = this.inferFormat(output);
|
|
749
|
+
pipeline = pipeline.toFormat(format, { quality: options.quality });
|
|
750
|
+
}
|
|
751
|
+
await pipeline.toFile(output);
|
|
752
|
+
} catch (error) {
|
|
753
|
+
throw this.mapError(error, input);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Infer image format from file extension
|
|
758
|
+
*/
|
|
759
|
+
inferFormat(path) {
|
|
760
|
+
const ext = path.split(".").pop()?.toLowerCase();
|
|
761
|
+
const map = {
|
|
762
|
+
jpg: "jpeg",
|
|
763
|
+
jpeg: "jpeg",
|
|
764
|
+
png: "png",
|
|
765
|
+
webp: "webp",
|
|
766
|
+
avif: "avif",
|
|
767
|
+
gif: "gif",
|
|
768
|
+
tiff: "tiff",
|
|
769
|
+
tif: "tiff"
|
|
770
|
+
};
|
|
771
|
+
return map[ext || ""] || "jpeg";
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Parse EXIF buffer to object (basic parsing)
|
|
775
|
+
*/
|
|
776
|
+
parseExifBuffer(buffer) {
|
|
777
|
+
return { raw: `${buffer.toString("base64").slice(0, 100)}...` };
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Parse IPTC buffer to object (basic parsing)
|
|
781
|
+
*/
|
|
782
|
+
parseIptcBuffer(buffer) {
|
|
783
|
+
return { raw: `${buffer.toString("base64").slice(0, 100)}...` };
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Parse XMP buffer to object (basic parsing)
|
|
787
|
+
*/
|
|
788
|
+
parseXmpBuffer(buffer) {
|
|
789
|
+
try {
|
|
790
|
+
const xmpString = buffer.toString("utf8");
|
|
791
|
+
return { raw: `${xmpString.slice(0, 500)}...` };
|
|
792
|
+
} catch {
|
|
793
|
+
return { raw: `${buffer.toString("base64").slice(0, 100)}...` };
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Map sharp errors to our error types
|
|
798
|
+
*/
|
|
799
|
+
mapError(error, input) {
|
|
800
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
801
|
+
if (message.includes("Input file is missing")) {
|
|
802
|
+
const path = typeof input === "string" ? input : "<buffer>";
|
|
803
|
+
throw new ImageNotFoundError(path, "sharp");
|
|
804
|
+
}
|
|
805
|
+
if (message.includes("Input buffer contains unsupported image format")) {
|
|
806
|
+
throw new ProcessingError("Unsupported image format", "sharp");
|
|
807
|
+
}
|
|
808
|
+
return new ProcessingError(
|
|
809
|
+
`Image processing failed: ${message}`,
|
|
810
|
+
"sharp",
|
|
811
|
+
error instanceof Error ? error : void 0
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const sharp = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
816
|
+
__proto__: null,
|
|
817
|
+
SharpAdapter
|
|
818
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
819
|
+
let cachedFontData = null;
|
|
820
|
+
let cachedFontName = null;
|
|
821
|
+
async function loadGoogleFont(fontName) {
|
|
822
|
+
if (cachedFontData && cachedFontName === fontName) {
|
|
823
|
+
return cachedFontData;
|
|
824
|
+
}
|
|
825
|
+
const fontUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontName)}:wght@400;600;700&display=swap`;
|
|
826
|
+
const cssResponse = await fetch(fontUrl, {
|
|
827
|
+
headers: {
|
|
828
|
+
// Use an old user agent to get WOFF format (Satori doesn't support woff2)
|
|
829
|
+
"User-Agent": "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)"
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
if (!cssResponse.ok) {
|
|
833
|
+
throw new Error(`Failed to fetch font CSS: ${cssResponse.statusText}`);
|
|
834
|
+
}
|
|
835
|
+
const css = await cssResponse.text();
|
|
836
|
+
let urlMatch = css.match(
|
|
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;
|
|
858
|
+
}
|
|
859
|
+
function renderDefaultTemplate({
|
|
860
|
+
title,
|
|
861
|
+
options
|
|
862
|
+
}) {
|
|
863
|
+
const {
|
|
864
|
+
width,
|
|
865
|
+
brandColor,
|
|
866
|
+
backgroundColor,
|
|
867
|
+
subtitle,
|
|
868
|
+
fontFamily
|
|
869
|
+
} = options;
|
|
870
|
+
return {
|
|
871
|
+
type: "div",
|
|
872
|
+
props: {
|
|
873
|
+
style: {
|
|
874
|
+
display: "flex",
|
|
875
|
+
flexDirection: "column",
|
|
876
|
+
justifyContent: "center",
|
|
877
|
+
width: "100%",
|
|
878
|
+
height: "100%",
|
|
879
|
+
backgroundColor,
|
|
880
|
+
padding: "60px",
|
|
881
|
+
fontFamily
|
|
882
|
+
},
|
|
883
|
+
children: [
|
|
884
|
+
// Top accent bar
|
|
885
|
+
{
|
|
886
|
+
type: "div",
|
|
887
|
+
props: {
|
|
888
|
+
style: {
|
|
889
|
+
position: "absolute",
|
|
890
|
+
top: 0,
|
|
891
|
+
left: 0,
|
|
892
|
+
right: 0,
|
|
893
|
+
height: "8px",
|
|
894
|
+
backgroundColor: brandColor
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
// Subtitle
|
|
899
|
+
subtitle ? {
|
|
900
|
+
type: "div",
|
|
901
|
+
props: {
|
|
902
|
+
style: {
|
|
903
|
+
fontSize: "24px",
|
|
904
|
+
fontWeight: 600,
|
|
905
|
+
color: brandColor,
|
|
906
|
+
marginBottom: "20px",
|
|
907
|
+
textTransform: "uppercase",
|
|
908
|
+
letterSpacing: "2px"
|
|
909
|
+
},
|
|
910
|
+
children: subtitle
|
|
911
|
+
}
|
|
912
|
+
} : null,
|
|
913
|
+
// Title
|
|
914
|
+
{
|
|
915
|
+
type: "div",
|
|
916
|
+
props: {
|
|
917
|
+
style: {
|
|
918
|
+
fontSize: Math.min(72, Math.floor(width / (title.length * 0.6))),
|
|
919
|
+
fontWeight: 700,
|
|
920
|
+
color: "#1e293b",
|
|
921
|
+
lineHeight: 1.2,
|
|
922
|
+
maxWidth: "90%"
|
|
923
|
+
},
|
|
924
|
+
children: title
|
|
925
|
+
}
|
|
926
|
+
},
|
|
927
|
+
// Bottom accent
|
|
928
|
+
{
|
|
929
|
+
type: "div",
|
|
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
|
+
};
|
|
944
|
+
}
|
|
945
|
+
function renderNewsTemplate({ title, options }) {
|
|
946
|
+
const {
|
|
947
|
+
width,
|
|
948
|
+
brandColor,
|
|
949
|
+
backgroundColor,
|
|
950
|
+
subtitle,
|
|
951
|
+
fontFamily
|
|
952
|
+
} = options;
|
|
953
|
+
return {
|
|
954
|
+
type: "div",
|
|
955
|
+
props: {
|
|
956
|
+
style: {
|
|
957
|
+
display: "flex",
|
|
958
|
+
flexDirection: "row",
|
|
959
|
+
width: "100%",
|
|
960
|
+
height: "100%",
|
|
961
|
+
backgroundColor,
|
|
962
|
+
fontFamily
|
|
963
|
+
},
|
|
964
|
+
children: [
|
|
965
|
+
// Left accent bar
|
|
966
|
+
{
|
|
967
|
+
type: "div",
|
|
968
|
+
props: {
|
|
969
|
+
style: {
|
|
970
|
+
width: "16px",
|
|
971
|
+
height: "100%",
|
|
972
|
+
backgroundColor: brandColor
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
},
|
|
976
|
+
// Content area
|
|
977
|
+
{
|
|
978
|
+
type: "div",
|
|
979
|
+
props: {
|
|
980
|
+
style: {
|
|
981
|
+
display: "flex",
|
|
982
|
+
flexDirection: "column",
|
|
983
|
+
justifyContent: "center",
|
|
984
|
+
flex: 1,
|
|
985
|
+
padding: "60px"
|
|
986
|
+
},
|
|
987
|
+
children: [
|
|
988
|
+
// Category badge (using flex with alignSelf since Satori doesn't support inline-flex)
|
|
989
|
+
subtitle ? {
|
|
990
|
+
type: "div",
|
|
991
|
+
props: {
|
|
992
|
+
style: {
|
|
993
|
+
display: "flex",
|
|
994
|
+
alignSelf: "flex-start",
|
|
995
|
+
backgroundColor: brandColor,
|
|
996
|
+
color: "#ffffff",
|
|
997
|
+
fontSize: "18px",
|
|
998
|
+
fontWeight: 600,
|
|
999
|
+
padding: "8px 16px",
|
|
1000
|
+
borderRadius: "4px",
|
|
1001
|
+
marginBottom: "24px",
|
|
1002
|
+
textTransform: "uppercase",
|
|
1003
|
+
letterSpacing: "1px"
|
|
1004
|
+
},
|
|
1005
|
+
children: subtitle
|
|
1006
|
+
}
|
|
1007
|
+
} : null,
|
|
1008
|
+
// Title
|
|
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
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
function renderMinimalTemplate({
|
|
1032
|
+
title,
|
|
1033
|
+
options
|
|
1034
|
+
}) {
|
|
1035
|
+
const { width, brandColor, backgroundColor, fontFamily } = options;
|
|
1036
|
+
return {
|
|
1037
|
+
type: "div",
|
|
1038
|
+
props: {
|
|
1039
|
+
style: {
|
|
1040
|
+
display: "flex",
|
|
1041
|
+
flexDirection: "column",
|
|
1042
|
+
alignItems: "center",
|
|
1043
|
+
justifyContent: "center",
|
|
1044
|
+
width: "100%",
|
|
1045
|
+
height: "100%",
|
|
1046
|
+
backgroundColor,
|
|
1047
|
+
padding: "80px",
|
|
1048
|
+
fontFamily
|
|
1049
|
+
},
|
|
1050
|
+
children: [
|
|
1051
|
+
// Decorative line
|
|
1052
|
+
{
|
|
1053
|
+
type: "div",
|
|
1054
|
+
props: {
|
|
1055
|
+
style: {
|
|
1056
|
+
width: "60px",
|
|
1057
|
+
height: "3px",
|
|
1058
|
+
backgroundColor: brandColor,
|
|
1059
|
+
marginBottom: "40px"
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
},
|
|
1063
|
+
// Title
|
|
1064
|
+
{
|
|
1065
|
+
type: "div",
|
|
1066
|
+
props: {
|
|
1067
|
+
style: {
|
|
1068
|
+
fontSize: Math.min(56, Math.floor(width / (title.length * 0.55))),
|
|
1069
|
+
fontWeight: 600,
|
|
1070
|
+
color: "#334155",
|
|
1071
|
+
lineHeight: 1.3,
|
|
1072
|
+
textAlign: "center",
|
|
1073
|
+
maxWidth: "85%"
|
|
1074
|
+
},
|
|
1075
|
+
children: title
|
|
1076
|
+
}
|
|
1077
|
+
},
|
|
1078
|
+
// Decorative line
|
|
1079
|
+
{
|
|
1080
|
+
type: "div",
|
|
1081
|
+
props: {
|
|
1082
|
+
style: {
|
|
1083
|
+
width: "60px",
|
|
1084
|
+
height: "3px",
|
|
1085
|
+
backgroundColor: brandColor,
|
|
1086
|
+
marginTop: "40px"
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
]
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
async function generateHeadlineCard(title, options = {}) {
|
|
1095
|
+
const width = options.width ?? 1200;
|
|
1096
|
+
const height = options.height ?? 630;
|
|
1097
|
+
const brandColor = options.brandColor ?? "#3b82f6";
|
|
1098
|
+
const backgroundColor = options.backgroundColor ?? "#ffffff";
|
|
1099
|
+
options.textColor ?? "#64748b";
|
|
1100
|
+
const template = options.template ?? "default";
|
|
1101
|
+
const fontFamily = options.fontFamily ?? "Inter";
|
|
1102
|
+
const resolvedOptions = {
|
|
1103
|
+
...options,
|
|
1104
|
+
width,
|
|
1105
|
+
brandColor,
|
|
1106
|
+
backgroundColor,
|
|
1107
|
+
fontFamily
|
|
1108
|
+
};
|
|
1109
|
+
const [{ default: satori }, { Resvg }] = await Promise.all([
|
|
1110
|
+
import("satori"),
|
|
1111
|
+
import("@resvg/resvg-js")
|
|
1112
|
+
]);
|
|
1113
|
+
const fontData = options.fontData ?? await loadGoogleFont(fontFamily);
|
|
1114
|
+
let content;
|
|
1115
|
+
switch (template) {
|
|
1116
|
+
case "news":
|
|
1117
|
+
content = renderNewsTemplate({ title, options: resolvedOptions });
|
|
1118
|
+
break;
|
|
1119
|
+
case "minimal":
|
|
1120
|
+
content = renderMinimalTemplate({ title, options: resolvedOptions });
|
|
1121
|
+
break;
|
|
1122
|
+
default:
|
|
1123
|
+
content = renderDefaultTemplate({ title, options: resolvedOptions });
|
|
1124
|
+
}
|
|
1125
|
+
const svg = await satori(content, {
|
|
1126
|
+
width,
|
|
1127
|
+
height,
|
|
1128
|
+
fonts: [
|
|
1129
|
+
{
|
|
1130
|
+
name: fontFamily,
|
|
1131
|
+
data: fontData,
|
|
1132
|
+
weight: 400,
|
|
1133
|
+
style: "normal"
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
name: fontFamily,
|
|
1137
|
+
data: fontData,
|
|
1138
|
+
weight: 600,
|
|
1139
|
+
style: "normal"
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
name: fontFamily,
|
|
1143
|
+
data: fontData,
|
|
1144
|
+
weight: 700,
|
|
1145
|
+
style: "normal"
|
|
1146
|
+
}
|
|
1147
|
+
]
|
|
1148
|
+
});
|
|
1149
|
+
const resvg = new Resvg(svg, {
|
|
1150
|
+
fitTo: {
|
|
1151
|
+
mode: "width",
|
|
1152
|
+
value: width
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
const pngData = resvg.render();
|
|
1156
|
+
const buffer = Buffer.from(pngData.asPng());
|
|
1157
|
+
return {
|
|
1158
|
+
buffer,
|
|
1159
|
+
width,
|
|
1160
|
+
height,
|
|
1161
|
+
mimeType: "image/png"
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
function resetFontCache() {
|
|
1165
|
+
cachedFontData = null;
|
|
1166
|
+
cachedFontName = null;
|
|
1167
|
+
}
|
|
1168
|
+
function isSharpOptions(options) {
|
|
1169
|
+
return !options.type || options.type === "sharp";
|
|
1170
|
+
}
|
|
1171
|
+
function isJimpOptions(options) {
|
|
1172
|
+
return options.type === "jimp";
|
|
1173
|
+
}
|
|
1174
|
+
function isImgproxyOptions(options) {
|
|
1175
|
+
return options.type === "imgproxy";
|
|
1176
|
+
}
|
|
1177
|
+
function loadEnvConfig(options) {
|
|
1178
|
+
if (typeof process === "undefined" || !process.env) {
|
|
1179
|
+
return options;
|
|
1180
|
+
}
|
|
1181
|
+
const env = process.env;
|
|
1182
|
+
const envDefaults = {};
|
|
1183
|
+
const typeEnv = env.HAVE_IMAGES_TYPE || env.HAVE_IMAGES_ADAPTER;
|
|
1184
|
+
if (typeEnv && !options.type) {
|
|
1185
|
+
envDefaults.type = typeEnv;
|
|
1186
|
+
}
|
|
1187
|
+
if (env.HAVE_IMAGES_BASE_URL) {
|
|
1188
|
+
envDefaults.baseUrl = env.HAVE_IMAGES_BASE_URL;
|
|
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 };
|
|
1197
|
+
}
|
|
1198
|
+
async function getImageProcessor(options = {}) {
|
|
1199
|
+
options = loadEnvConfig(options);
|
|
1200
|
+
if (isSharpOptions(options)) {
|
|
1201
|
+
const { SharpAdapter: SharpAdapter2 } = await Promise.resolve().then(() => sharp);
|
|
1202
|
+
return new SharpAdapter2(options);
|
|
1203
|
+
}
|
|
1204
|
+
if (isJimpOptions(options)) {
|
|
1205
|
+
const { JimpAdapter: JimpAdapter2 } = await Promise.resolve().then(() => jimp);
|
|
1206
|
+
return new JimpAdapter2(options);
|
|
1207
|
+
}
|
|
1208
|
+
if (isImgproxyOptions(options)) {
|
|
1209
|
+
const { ImgproxyAdapter: ImgproxyAdapter2 } = await Promise.resolve().then(() => imgproxy);
|
|
1210
|
+
return new ImgproxyAdapter2(options);
|
|
1211
|
+
}
|
|
1212
|
+
throw new InvalidAdapterError(
|
|
1213
|
+
options.type || "unknown"
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
function getAvailableAdapters() {
|
|
1217
|
+
return ["sharp", "jimp", "imgproxy"];
|
|
1218
|
+
}
|
|
1219
|
+
const factory = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
1220
|
+
__proto__: null,
|
|
1221
|
+
getAvailableAdapters,
|
|
1222
|
+
getImageProcessor
|
|
1223
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
1224
|
+
let cachedProcessor = null;
|
|
1225
|
+
let detectionAttempted = false;
|
|
1226
|
+
async function getDefaultProcessor(adapterOptions) {
|
|
1227
|
+
if (adapterOptions?.type) {
|
|
1228
|
+
const { getImageProcessor: getImageProcessor2 } = await Promise.resolve().then(() => factory);
|
|
1229
|
+
return getImageProcessor2(adapterOptions);
|
|
1230
|
+
}
|
|
1231
|
+
if (cachedProcessor && !adapterOptions) {
|
|
1232
|
+
return cachedProcessor;
|
|
1233
|
+
}
|
|
1234
|
+
if (!detectionAttempted) {
|
|
1235
|
+
detectionAttempted = true;
|
|
1236
|
+
try {
|
|
1237
|
+
await import("sharp");
|
|
1238
|
+
const { SharpAdapter: SharpAdapter2 } = await Promise.resolve().then(() => sharp);
|
|
1239
|
+
cachedProcessor = new SharpAdapter2({});
|
|
1240
|
+
} catch {
|
|
1241
|
+
const { JimpAdapter: JimpAdapter2 } = await Promise.resolve().then(() => jimp);
|
|
1242
|
+
cachedProcessor = new JimpAdapter2({ type: "jimp" });
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
if (!cachedProcessor) {
|
|
1246
|
+
throw new Error("No image processor available. Install sharp or jimp.");
|
|
1247
|
+
}
|
|
1248
|
+
return cachedProcessor;
|
|
1249
|
+
}
|
|
1250
|
+
function resetProcessor() {
|
|
1251
|
+
cachedProcessor = null;
|
|
1252
|
+
detectionAttempted = false;
|
|
1253
|
+
}
|
|
1254
|
+
async function getDimensions(input, adapterOptions) {
|
|
1255
|
+
const processor = await getDefaultProcessor(adapterOptions);
|
|
1256
|
+
return processor.getDimensions(input);
|
|
1257
|
+
}
|
|
1258
|
+
async function generateThumbnail(input, output, options, adapterOptions) {
|
|
1259
|
+
const processor = await getDefaultProcessor(adapterOptions);
|
|
1260
|
+
return processor.thumbnail(input, output, options);
|
|
1261
|
+
}
|
|
1262
|
+
async function convertFormat(input, output, options, adapterOptions) {
|
|
1263
|
+
const processor = await getDefaultProcessor(adapterOptions);
|
|
1264
|
+
return processor.convert(input, output, options);
|
|
1265
|
+
}
|
|
1266
|
+
async function getImageMetadata(input, adapterOptions) {
|
|
1267
|
+
const processor = await getDefaultProcessor(adapterOptions);
|
|
1268
|
+
return processor.getMetadata(input);
|
|
1269
|
+
}
|
|
1270
|
+
async function getImageHash(input, algorithm = "perceptual", adapterOptions) {
|
|
1271
|
+
const processor = await getDefaultProcessor(adapterOptions);
|
|
1272
|
+
return processor.hash(input, algorithm);
|
|
1273
|
+
}
|
|
1274
|
+
async function resizeImage(input, output, options, adapterOptions) {
|
|
1275
|
+
const processor = await getDefaultProcessor(adapterOptions);
|
|
1276
|
+
return processor.resize(input, output, options);
|
|
1277
|
+
}
|
|
1278
|
+
export {
|
|
1279
|
+
ImageError,
|
|
1280
|
+
ImageNotFoundError,
|
|
1281
|
+
ImgproxyAdapter,
|
|
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
|