@happyvertical/smrt-images 0.37.2 → 0.37.4
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/index.js +751 -856
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +2 -2
- package/dist/playground.js +110 -119
- package/dist/playground.js.map +1 -1
- package/dist/smrt-knowledge.json +12 -12
- package/dist/types.js +0 -2
- package/dist/ui.js +40 -40
- package/dist/ui.js.map +1 -1
- package/package.json +23 -23
- package/dist/types.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import { ObjectRegistry, field, smrt
|
|
1
|
+
import { ObjectRegistry, SmrtCollection, field, smrt } from "@happyvertical/smrt-core";
|
|
2
2
|
import { definePrompt, resolvePrompt } from "@happyvertical/smrt-prompts";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import {
|
|
4
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { Asset } from "@happyvertical/smrt-assets";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
ObjectRegistry.registerPackageManifest(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
import { Asset, persistMediaBundleInspection as persistImageMediaBundleInspection } from "@happyvertical/smrt-assets";
|
|
8
|
+
import { TenantIsolationError, TenantScoped, getCurrentTenant, isSuperAdminBypass } from "@happyvertical/smrt-tenancy";
|
|
9
|
+
//#region src/__smrt-register__.ts
|
|
10
|
+
ObjectRegistry.registerPackageManifest(new URL("./manifest.json", "" + import.meta.url));
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/prompts.ts
|
|
13
|
+
var smrtImagesGenerateAltTextPrompt = definePrompt({
|
|
14
|
+
key: "smrtImages.image.generateAltText",
|
|
15
|
+
template: `Generate concise accessibility alt text for this image.
|
|
16
16
|
Consider: subject matter, key visual elements, context.
|
|
17
17
|
Keep it under 125 characters for screen reader compatibility.
|
|
18
18
|
|
|
@@ -20,77 +20,71 @@ Image name: {imageName}
|
|
|
20
20
|
Image description: {imageDescription}
|
|
21
21
|
|
|
22
22
|
Return only the alt text, with no commentary or surrounding quotation marks.`,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
editable: {
|
|
24
|
+
template: true,
|
|
25
|
+
profile: true,
|
|
26
|
+
model: true,
|
|
27
|
+
params: true
|
|
28
|
+
}
|
|
29
29
|
});
|
|
30
30
|
function promptMessageOptions(ai) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
return {
|
|
32
|
+
...ai.params || {},
|
|
33
|
+
...ai.model ? { model: ai.model } : {},
|
|
34
|
+
...typeof ai.temperature === "number" ? { temperature: ai.temperature } : {},
|
|
35
|
+
...typeof ai.maxTokens === "number" ? { maxTokens: ai.maxTokens } : {}
|
|
36
|
+
};
|
|
37
37
|
}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/categorizer.ts
|
|
38
40
|
function extractFirstJsonObject(text) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
} else if (ch === "}") {
|
|
61
|
-
depth--;
|
|
62
|
-
if (depth === 0) {
|
|
63
|
-
return text.slice(start, i + 1);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
41
|
+
const start = text.indexOf("{");
|
|
42
|
+
if (start === -1) return null;
|
|
43
|
+
let depth = 0;
|
|
44
|
+
let inString = false;
|
|
45
|
+
let escaped = false;
|
|
46
|
+
for (let i = start; i < text.length; i++) {
|
|
47
|
+
const ch = text[i];
|
|
48
|
+
if (inString) {
|
|
49
|
+
if (escaped) escaped = false;
|
|
50
|
+
else if (ch === "\\") escaped = true;
|
|
51
|
+
else if (ch === "\"") inString = false;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (ch === "\"") inString = true;
|
|
55
|
+
else if (ch === "{") depth++;
|
|
56
|
+
else if (ch === "}") {
|
|
57
|
+
depth--;
|
|
58
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
68
62
|
}
|
|
69
63
|
function normalizeCategoryResult(parsed, fallbackDescription) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
64
|
+
const p = parsed ?? {};
|
|
65
|
+
return {
|
|
66
|
+
tags: Array.isArray(p.tags) ? p.tags : [],
|
|
67
|
+
description: typeof p.description === "string" && p.description ? p.description : fallbackDescription,
|
|
68
|
+
confidence: typeof p.confidence === "number" ? p.confidence : 0,
|
|
69
|
+
subjects: Array.isArray(p.subjects) ? p.subjects : []
|
|
70
|
+
};
|
|
77
71
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
72
|
+
var ImageCategorizer = class {
|
|
73
|
+
constructor(options) {
|
|
74
|
+
this.options = options;
|
|
75
|
+
}
|
|
76
|
+
options;
|
|
77
|
+
/**
|
|
78
|
+
* Categorize an image using AI vision analysis
|
|
79
|
+
*
|
|
80
|
+
* @param image - The Image instance to categorize
|
|
81
|
+
* @param buffer - Optional raw image data for vision analysis
|
|
82
|
+
* @returns Categorization results with tags, description, and subjects
|
|
83
|
+
*/
|
|
84
|
+
async categorize(image, buffer) {
|
|
85
|
+
const { getAI } = await import("@happyvertical/ai");
|
|
86
|
+
const ai = await getAI(this.options.ai);
|
|
87
|
+
const prompt = `Analyze this image and provide categorization.
|
|
94
88
|
Image name: ${image.name}
|
|
95
89
|
Image description: ${image.description}
|
|
96
90
|
MIME type: ${image.mimeType}
|
|
@@ -103,793 +97,694 @@ Respond in JSON format:
|
|
|
103
97
|
"confidence": 0.0-1.0,
|
|
104
98
|
"subjects": ["subject1", "subject2", ...]
|
|
105
99
|
}`;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
*/
|
|
213
|
-
async deriveWithAssociations(sources, prompt, associations, deriveOptions = {}) {
|
|
214
|
-
const results = await this.derive(sources, prompt, deriveOptions);
|
|
215
|
-
for (const derived of results) {
|
|
216
|
-
for (const source of sources) {
|
|
217
|
-
await associations.attach("Image", derived.id, source.id, {
|
|
218
|
-
role: "derivation-source"
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return results;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
const ALLOWED_CONVERT_FORMATS = /* @__PURE__ */ new Set([
|
|
226
|
-
"jpeg",
|
|
227
|
-
"png",
|
|
228
|
-
"webp",
|
|
229
|
-
"avif",
|
|
230
|
-
"gif",
|
|
231
|
-
"tiff"
|
|
100
|
+
const text = (await ai.chat([{
|
|
101
|
+
role: "user",
|
|
102
|
+
content: prompt
|
|
103
|
+
}])).content;
|
|
104
|
+
const fallbackDescription = image.description || image.name;
|
|
105
|
+
const jsonText = extractFirstJsonObject(text);
|
|
106
|
+
if (jsonText) try {
|
|
107
|
+
return normalizeCategoryResult(JSON.parse(jsonText), fallbackDescription);
|
|
108
|
+
} catch {}
|
|
109
|
+
return {
|
|
110
|
+
tags: [],
|
|
111
|
+
description: fallbackDescription,
|
|
112
|
+
confidence: 0,
|
|
113
|
+
subjects: []
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Run categorization and apply results to the image
|
|
118
|
+
*
|
|
119
|
+
* @param image - The Image to categorize and update
|
|
120
|
+
* @param assetCollection - AssetCollection for tag management
|
|
121
|
+
*/
|
|
122
|
+
async autoTag(image, assetCollection) {
|
|
123
|
+
const result = await this.categorize(image);
|
|
124
|
+
if (result.description && !image.description) image.description = result.description;
|
|
125
|
+
if (!image.alt && result.description) image.alt = result.description.slice(0, 125);
|
|
126
|
+
await image.save();
|
|
127
|
+
for (const tag of result.tags ?? []) await assetCollection.addTag(image.id, tag);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/deriver.ts
|
|
132
|
+
var ImageDeriver = class {
|
|
133
|
+
constructor(store, collection, options) {
|
|
134
|
+
this.store = store;
|
|
135
|
+
this.collection = collection;
|
|
136
|
+
this.options = options;
|
|
137
|
+
}
|
|
138
|
+
store;
|
|
139
|
+
collection;
|
|
140
|
+
options;
|
|
141
|
+
/**
|
|
142
|
+
* Derive new images from source images and a creative prompt
|
|
143
|
+
*
|
|
144
|
+
* @param sources - One or more source images
|
|
145
|
+
* @param prompt - Creative instructions for generation
|
|
146
|
+
* @param deriveOptions - Generation options (count, size, style)
|
|
147
|
+
* @returns Array of newly created derivative Images
|
|
148
|
+
*/
|
|
149
|
+
async derive(sources, prompt, deriveOptions = {}) {
|
|
150
|
+
if (sources.length === 0) throw new Error("At least one source image is required");
|
|
151
|
+
const { getAI } = await import("@happyvertical/ai");
|
|
152
|
+
const ai = await getAI(this.options.ai);
|
|
153
|
+
const count = deriveOptions.count ?? 1;
|
|
154
|
+
const results = [];
|
|
155
|
+
const fullPrompt = [
|
|
156
|
+
prompt,
|
|
157
|
+
deriveOptions.style ? `Style: ${deriveOptions.style}` : "",
|
|
158
|
+
deriveOptions.size ? `Output size: ${deriveOptions.size}` : "",
|
|
159
|
+
`Source images: ${sources.map((s) => s.name).join(", ")}`
|
|
160
|
+
].filter(Boolean).join("\n");
|
|
161
|
+
for (let i = 0; i < count; i++) {
|
|
162
|
+
const imageData = (await ai.generateImage(fullPrompt, { size: deriveOptions.size })).images[0]?.data;
|
|
163
|
+
if (!imageData || !(imageData instanceof Buffer)) throw new Error("AI did not return image data as Buffer");
|
|
164
|
+
const result = imageData;
|
|
165
|
+
const derived = await this.collection.create({
|
|
166
|
+
name: `derived-${sources[0].name}-${i + 1}`,
|
|
167
|
+
mimeType: "image/png",
|
|
168
|
+
sourceUri: "",
|
|
169
|
+
sourceAssetId: sources[0].id,
|
|
170
|
+
typeSlug: "image",
|
|
171
|
+
description: `Derived: ${prompt}`
|
|
172
|
+
});
|
|
173
|
+
derived.sourceUri = await this.store.storeFile(derived, result, {
|
|
174
|
+
mimeType: "image/png",
|
|
175
|
+
typeSlug: "image"
|
|
176
|
+
});
|
|
177
|
+
await derived.save();
|
|
178
|
+
results.push(derived);
|
|
179
|
+
}
|
|
180
|
+
return results;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Derive images and link all sources via AssetAssociation
|
|
184
|
+
*
|
|
185
|
+
* @param sources - Source images
|
|
186
|
+
* @param prompt - Creative instructions
|
|
187
|
+
* @param associations - AssetAssociationCollection for linking
|
|
188
|
+
* @param deriveOptions - Generation options
|
|
189
|
+
* @returns Array of newly created derivative Images
|
|
190
|
+
*/
|
|
191
|
+
async deriveWithAssociations(sources, prompt, associations, deriveOptions = {}) {
|
|
192
|
+
const results = await this.derive(sources, prompt, deriveOptions);
|
|
193
|
+
for (const derived of results) for (const source of sources) await associations.attach("Image", derived.id, source.id, { role: "derivation-source" });
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region src/editor.ts
|
|
199
|
+
var ALLOWED_CONVERT_FORMATS = /* @__PURE__ */ new Set([
|
|
200
|
+
"jpeg",
|
|
201
|
+
"png",
|
|
202
|
+
"webp",
|
|
203
|
+
"avif",
|
|
204
|
+
"gif",
|
|
205
|
+
"tiff"
|
|
232
206
|
]);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
*/
|
|
430
|
-
async createDerivative(source, data, overrides) {
|
|
431
|
-
const mimeType = overrides.mimeType ?? source.mimeType;
|
|
432
|
-
const typeSlug = source.typeSlug || "image";
|
|
433
|
-
const derivative = await this.collection.create({
|
|
434
|
-
name: overrides.name,
|
|
435
|
-
sourceUri: "",
|
|
436
|
-
mimeType,
|
|
437
|
-
width: overrides.width ?? source.width,
|
|
438
|
-
height: overrides.height ?? source.height,
|
|
439
|
-
alt: source.alt,
|
|
440
|
-
sourceAssetId: source.id,
|
|
441
|
-
typeSlug,
|
|
442
|
-
description: overrides.description ?? ""
|
|
443
|
-
});
|
|
444
|
-
const sourceUri = await this.store.storeFile(derivative, data, {
|
|
445
|
-
mimeType,
|
|
446
|
-
typeSlug
|
|
447
|
-
});
|
|
448
|
-
derivative.sourceUri = sourceUri;
|
|
449
|
-
await derivative.save();
|
|
450
|
-
return derivative;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
207
|
+
var ImageEditor = class {
|
|
208
|
+
constructor(store, collection, options = {}) {
|
|
209
|
+
this.store = store;
|
|
210
|
+
this.collection = collection;
|
|
211
|
+
this.options = options;
|
|
212
|
+
}
|
|
213
|
+
store;
|
|
214
|
+
collection;
|
|
215
|
+
options;
|
|
216
|
+
/**
|
|
217
|
+
* Resize an image to the specified dimensions
|
|
218
|
+
*
|
|
219
|
+
* @param image - Source image
|
|
220
|
+
* @param width - Target width
|
|
221
|
+
* @param height - Target height
|
|
222
|
+
* @returns New derivative Image
|
|
223
|
+
*/
|
|
224
|
+
async resize(image, width, height) {
|
|
225
|
+
const { resizeImage } = await import("@happyvertical/images");
|
|
226
|
+
const sourceData = await this.store.read(image);
|
|
227
|
+
const inputPath = join(tmpdir(), `smrt-edit-in-${randomUUID()}.bin`);
|
|
228
|
+
const outputPath = join(tmpdir(), `smrt-edit-out-${randomUUID()}.bin`);
|
|
229
|
+
try {
|
|
230
|
+
await writeFile(inputPath, sourceData);
|
|
231
|
+
await resizeImage(inputPath, outputPath, {
|
|
232
|
+
width,
|
|
233
|
+
height
|
|
234
|
+
});
|
|
235
|
+
const resized = await readFile(outputPath);
|
|
236
|
+
return this.createDerivative(image, resized, {
|
|
237
|
+
name: `${image.name}-${width}x${height}`,
|
|
238
|
+
width,
|
|
239
|
+
height,
|
|
240
|
+
description: `Resized from ${image.width}x${image.height} to ${width}x${height}`
|
|
241
|
+
});
|
|
242
|
+
} finally {
|
|
243
|
+
await unlink(inputPath).catch(() => {});
|
|
244
|
+
await unlink(outputPath).catch(() => {});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Crop an image to the specified region
|
|
249
|
+
*
|
|
250
|
+
* @param image - Source image
|
|
251
|
+
* @param x - Left offset
|
|
252
|
+
* @param y - Top offset
|
|
253
|
+
* @param w - Crop width
|
|
254
|
+
* @param h - Crop height
|
|
255
|
+
* @returns New derivative Image
|
|
256
|
+
*/
|
|
257
|
+
async crop(image, x, y, w, h) {
|
|
258
|
+
const { getImageProcessor } = await import("@happyvertical/images");
|
|
259
|
+
const processor = await getImageProcessor();
|
|
260
|
+
const sourceData = await this.store.read(image);
|
|
261
|
+
const inputPath = join(tmpdir(), `smrt-crop-in-${randomUUID()}.bin`);
|
|
262
|
+
const outputPath = join(tmpdir(), `smrt-crop-out-${randomUUID()}.bin`);
|
|
263
|
+
try {
|
|
264
|
+
await writeFile(inputPath, sourceData);
|
|
265
|
+
await processor.resize(inputPath, outputPath, {
|
|
266
|
+
width: w,
|
|
267
|
+
height: h,
|
|
268
|
+
fit: "cover"
|
|
269
|
+
});
|
|
270
|
+
const cropped = await readFile(outputPath);
|
|
271
|
+
return this.createDerivative(image, cropped, {
|
|
272
|
+
name: `${image.name}-crop`,
|
|
273
|
+
width: w,
|
|
274
|
+
height: h,
|
|
275
|
+
description: `Cropped region ${x},${y} ${w}x${h}`
|
|
276
|
+
});
|
|
277
|
+
} finally {
|
|
278
|
+
await unlink(inputPath).catch(() => {});
|
|
279
|
+
await unlink(outputPath).catch(() => {});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Convert an image to a different format
|
|
284
|
+
*
|
|
285
|
+
* @param image - Source image
|
|
286
|
+
* @param format - Target format (e.g., 'webp', 'png', 'jpeg')
|
|
287
|
+
* @returns New derivative Image
|
|
288
|
+
*/
|
|
289
|
+
async convert(image, format) {
|
|
290
|
+
const normalizedFormat = format.trim().toLowerCase();
|
|
291
|
+
if (!ALLOWED_CONVERT_FORMATS.has(normalizedFormat)) throw new Error(`Unsupported image format: ${JSON.stringify(format)}. Allowed formats: ${[...ALLOWED_CONVERT_FORMATS].join(", ")}`);
|
|
292
|
+
const safeFormat = normalizedFormat;
|
|
293
|
+
const { convertFormat } = await import("@happyvertical/images");
|
|
294
|
+
const sourceData = await this.store.read(image);
|
|
295
|
+
const mimeType = `image/${safeFormat}`;
|
|
296
|
+
const inputPath = join(tmpdir(), `smrt-conv-in-${randomUUID()}.bin`);
|
|
297
|
+
const outputPath = join(tmpdir(), `smrt-conv-out-${randomUUID()}.${safeFormat}`);
|
|
298
|
+
try {
|
|
299
|
+
await writeFile(inputPath, sourceData);
|
|
300
|
+
await convertFormat(inputPath, outputPath, { format: safeFormat });
|
|
301
|
+
const converted = await readFile(outputPath);
|
|
302
|
+
return this.createDerivative(image, converted, {
|
|
303
|
+
name: `${image.name}.${safeFormat}`,
|
|
304
|
+
mimeType,
|
|
305
|
+
description: `Converted from ${image.mimeType} to ${mimeType}`
|
|
306
|
+
});
|
|
307
|
+
} finally {
|
|
308
|
+
await unlink(inputPath).catch(() => {});
|
|
309
|
+
await unlink(outputPath).catch(() => {});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Generate a square thumbnail of the specified size
|
|
314
|
+
*
|
|
315
|
+
* @param image - Source image
|
|
316
|
+
* @param size - Thumbnail dimension (square)
|
|
317
|
+
* @returns New derivative Image
|
|
318
|
+
*/
|
|
319
|
+
async thumbnail(image, size) {
|
|
320
|
+
const { generateThumbnail } = await import("@happyvertical/images");
|
|
321
|
+
const sourceData = await this.store.read(image);
|
|
322
|
+
const inputPath = join(tmpdir(), `smrt-thumb-in-${randomUUID()}.bin`);
|
|
323
|
+
const outputPath = join(tmpdir(), `smrt-thumb-out-${randomUUID()}.bin`);
|
|
324
|
+
try {
|
|
325
|
+
await writeFile(inputPath, sourceData);
|
|
326
|
+
await generateThumbnail(inputPath, outputPath, {
|
|
327
|
+
maxWidth: size,
|
|
328
|
+
maxHeight: size
|
|
329
|
+
});
|
|
330
|
+
const thumbData = await readFile(outputPath);
|
|
331
|
+
return this.createDerivative(image, thumbData, {
|
|
332
|
+
name: `${image.name}-thumb-${size}`,
|
|
333
|
+
width: size,
|
|
334
|
+
height: size,
|
|
335
|
+
description: `Thumbnail ${size}x${size}`
|
|
336
|
+
});
|
|
337
|
+
} finally {
|
|
338
|
+
await unlink(inputPath).catch(() => {});
|
|
339
|
+
await unlink(outputPath).catch(() => {});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* AI-powered image generation based on a prompt (creates derivative linked to source)
|
|
344
|
+
*
|
|
345
|
+
* @param image - Source image (used for metadata, linked as parent)
|
|
346
|
+
* @param prompt - Generation instructions (e.g., "similar image with sunset colors")
|
|
347
|
+
* @returns New derivative Image
|
|
348
|
+
*/
|
|
349
|
+
async edit(image, prompt) {
|
|
350
|
+
if (!this.options.ai) throw new Error("AI options required for AI-powered editing");
|
|
351
|
+
const { getAI } = await import("@happyvertical/ai");
|
|
352
|
+
const imageData = (await (await getAI(this.options.ai)).generateImage(prompt, { size: `${image.width}x${image.height}` })).images[0]?.data;
|
|
353
|
+
if (!imageData || !(imageData instanceof Buffer)) throw new Error("AI did not return image data as Buffer");
|
|
354
|
+
return this.createDerivative(image, imageData, {
|
|
355
|
+
name: `${image.name}-edited`,
|
|
356
|
+
description: `AI edit: ${prompt}`
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Generate variations of an image using AI
|
|
361
|
+
*
|
|
362
|
+
* @param image - Source image
|
|
363
|
+
* @param prompt - Variation instructions
|
|
364
|
+
* @param options - Number of variations to generate
|
|
365
|
+
* @returns Array of new derivative Images
|
|
366
|
+
*/
|
|
367
|
+
async generateVariation(image, prompt, options = {}) {
|
|
368
|
+
const count = options.count ?? 1;
|
|
369
|
+
const results = [];
|
|
370
|
+
for (let i = 0; i < count; i++) {
|
|
371
|
+
const variation = await this.edit(image, `${prompt} (variation ${i + 1} of ${count})`);
|
|
372
|
+
results.push(variation);
|
|
373
|
+
}
|
|
374
|
+
return results;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Helper: Create a derivative Image from processed buffer data
|
|
378
|
+
*/
|
|
379
|
+
async createDerivative(source, data, overrides) {
|
|
380
|
+
const mimeType = overrides.mimeType ?? source.mimeType;
|
|
381
|
+
const typeSlug = source.typeSlug || "image";
|
|
382
|
+
const derivative = await this.collection.create({
|
|
383
|
+
name: overrides.name,
|
|
384
|
+
sourceUri: "",
|
|
385
|
+
mimeType,
|
|
386
|
+
width: overrides.width ?? source.width,
|
|
387
|
+
height: overrides.height ?? source.height,
|
|
388
|
+
alt: source.alt,
|
|
389
|
+
sourceAssetId: source.id,
|
|
390
|
+
typeSlug,
|
|
391
|
+
description: overrides.description ?? ""
|
|
392
|
+
});
|
|
393
|
+
derivative.sourceUri = await this.store.storeFile(derivative, data, {
|
|
394
|
+
mimeType,
|
|
395
|
+
typeSlug
|
|
396
|
+
});
|
|
397
|
+
await derivative.save();
|
|
398
|
+
return derivative;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/image.ts
|
|
453
403
|
var __defProp = Object.defineProperty;
|
|
454
404
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
455
405
|
var __decorateClass = (decorators, target, key, kind) => {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
if (kind && result) __defProp(target, key, result);
|
|
461
|
-
return result;
|
|
406
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
407
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
408
|
+
if (kind && result) __defProp(target, key, result);
|
|
409
|
+
return result;
|
|
462
410
|
};
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const response = await ai.message(
|
|
546
|
-
resolvedPrompt.text,
|
|
547
|
-
promptMessageOptions(resolvedPrompt.ai)
|
|
548
|
-
);
|
|
549
|
-
const altText = response.trim();
|
|
550
|
-
this.alt = altText;
|
|
551
|
-
return altText;
|
|
552
|
-
}
|
|
411
|
+
var Image = class extends Asset {
|
|
412
|
+
width = 0;
|
|
413
|
+
height = 0;
|
|
414
|
+
alt = "";
|
|
415
|
+
constructor(options = {}) {
|
|
416
|
+
super(options);
|
|
417
|
+
if (options.width !== void 0) this.width = options.width;
|
|
418
|
+
if (options.height !== void 0) this.height = options.height;
|
|
419
|
+
if (options.alt !== void 0) this.alt = options.alt;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Calculate aspect ratio from dimensions
|
|
423
|
+
*/
|
|
424
|
+
get aspectRatio() {
|
|
425
|
+
if (this.height === 0) return 0;
|
|
426
|
+
return this.width / this.height;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Helper to get URL from sourceUri for frontend components
|
|
430
|
+
*/
|
|
431
|
+
get url() {
|
|
432
|
+
return this.sourceUri;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Check if dimensions indicate landscape orientation
|
|
436
|
+
*/
|
|
437
|
+
get isLandscape() {
|
|
438
|
+
return this.width > this.height;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Check if dimensions indicate portrait orientation
|
|
442
|
+
*/
|
|
443
|
+
get isPortrait() {
|
|
444
|
+
return this.height > this.width;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Check if dimensions indicate square aspect ratio
|
|
448
|
+
*/
|
|
449
|
+
get isSquare() {
|
|
450
|
+
return this.width === this.height && this.width > 0;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Validate that the asset is an image based on MIME type
|
|
454
|
+
*/
|
|
455
|
+
isValidImageFormat() {
|
|
456
|
+
return this.mimeType.startsWith("image/");
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Check if this image is high resolution (4K+)
|
|
460
|
+
*/
|
|
461
|
+
isHighResolution() {
|
|
462
|
+
return this.width >= 3840 || this.height >= 2160;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* AI-powered: Generate accessibility alt text for this image.
|
|
466
|
+
*
|
|
467
|
+
* Uses the `smrtImages.image.generateAltText` prompt registered via
|
|
468
|
+
* `@happyvertical/smrt-prompts`, allowing tenant- or instance-level
|
|
469
|
+
* overrides of the template, model, and parameters at runtime.
|
|
470
|
+
*
|
|
471
|
+
* Only non-PII metadata fields (name, description) are sent to the AI
|
|
472
|
+
* provider. Source URIs, internal foreign-key fields, and the
|
|
473
|
+
* extensible `metadata` blob are intentionally excluded — source URIs
|
|
474
|
+
* may embed signed/private bucket paths and metadata may contain EXIF
|
|
475
|
+
* GPS data or tenant-private configuration.
|
|
476
|
+
*
|
|
477
|
+
* @returns AI-generated alt text describing the image
|
|
478
|
+
*/
|
|
479
|
+
async generateAltText() {
|
|
480
|
+
const db = this.options.db ?? this.options.persistence;
|
|
481
|
+
const resolvedPrompt = await resolvePrompt(smrtImagesGenerateAltTextPrompt.key, {
|
|
482
|
+
db,
|
|
483
|
+
tenantId: this.tenantId,
|
|
484
|
+
variables: {
|
|
485
|
+
imageName: this.name || "",
|
|
486
|
+
imageDescription: this.description || ""
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
const altText = (await (await this.getAiClient()).message(resolvedPrompt.text, promptMessageOptions(resolvedPrompt.ai))).trim();
|
|
490
|
+
this.alt = altText;
|
|
491
|
+
return altText;
|
|
492
|
+
}
|
|
553
493
|
};
|
|
554
|
-
__decorateClass([
|
|
555
|
-
|
|
556
|
-
], Image.prototype, "
|
|
557
|
-
__decorateClass([
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
494
|
+
__decorateClass([field()], Image.prototype, "width", 2);
|
|
495
|
+
__decorateClass([field()], Image.prototype, "height", 2);
|
|
496
|
+
__decorateClass([field()], Image.prototype, "alt", 2);
|
|
497
|
+
Image = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
|
|
498
|
+
api: { include: [
|
|
499
|
+
"list",
|
|
500
|
+
"get",
|
|
501
|
+
"create",
|
|
502
|
+
"update",
|
|
503
|
+
"delete"
|
|
504
|
+
] },
|
|
505
|
+
mcp: { include: [
|
|
506
|
+
"list",
|
|
507
|
+
"get",
|
|
508
|
+
"create",
|
|
509
|
+
"update",
|
|
510
|
+
"generateAltText"
|
|
511
|
+
] },
|
|
512
|
+
cli: true
|
|
513
|
+
})], Image);
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region src/images.ts
|
|
516
|
+
var IMAGE_META_TYPE = "@happyvertical/smrt-images:Image";
|
|
517
|
+
var ImageCollection = class extends SmrtCollection {
|
|
518
|
+
static _itemClass = Image;
|
|
519
|
+
/**
|
|
520
|
+
* Find all images belonging to a specific tenant
|
|
521
|
+
*
|
|
522
|
+
* @param tenantId - The tenant ID to filter by
|
|
523
|
+
* @returns Array of images belonging to this tenant
|
|
524
|
+
*/
|
|
525
|
+
async findByTenant(tenantId) {
|
|
526
|
+
return await this.list({ where: { tenantId } });
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Find all global images (images without a tenant)
|
|
530
|
+
*
|
|
531
|
+
* @returns Array of global images
|
|
532
|
+
*/
|
|
533
|
+
async findGlobal() {
|
|
534
|
+
return await this.query(`SELECT * FROM ${this.tableName}
|
|
594
535
|
WHERE _meta_type = ?
|
|
595
|
-
AND tenant_id IS NULL`,
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
`Tenant isolation violation in Image.findWithGlobals: context tenant is '${tenantContext.tenantId}' but query requested '${tenantId}'`,
|
|
611
|
-
{ tenantId: tenantContext.tenantId, attemptedTenantId: tenantId }
|
|
612
|
-
);
|
|
613
|
-
}
|
|
614
|
-
return await this.query(
|
|
615
|
-
`SELECT * FROM ${this.tableName}
|
|
536
|
+
AND tenant_id IS NULL`, [IMAGE_META_TYPE], { allowRawOnTenantScoped: true });
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Find images belonging to a tenant plus all global images
|
|
540
|
+
*
|
|
541
|
+
* @param tenantId - The tenant ID to include
|
|
542
|
+
* @returns Array of tenant-specific and global images
|
|
543
|
+
*/
|
|
544
|
+
async findWithGlobals(tenantId) {
|
|
545
|
+
const tenantContext = getCurrentTenant();
|
|
546
|
+
if (tenantContext && !isSuperAdminBypass() && tenantContext.tenantId !== tenantId) throw new TenantIsolationError(`Tenant isolation violation in Image.findWithGlobals: context tenant is '${tenantContext.tenantId}' but query requested '${tenantId}'`, {
|
|
547
|
+
tenantId: tenantContext.tenantId,
|
|
548
|
+
attemptedTenantId: tenantId
|
|
549
|
+
});
|
|
550
|
+
return await this.query(`SELECT * FROM ${this.tableName}
|
|
616
551
|
WHERE _meta_type = ?
|
|
617
|
-
AND (tenant_id = ? OR tenant_id IS NULL)`,
|
|
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
|
-
* Extract metadata and apply it to an Image instance
|
|
733
|
-
*
|
|
734
|
-
* @param image - The Image instance to update
|
|
735
|
-
* @param buffer - Raw image data
|
|
736
|
-
*/
|
|
737
|
-
async extractAndApply(image, buffer) {
|
|
738
|
-
const result = await this.extract(buffer);
|
|
739
|
-
image.width = result.width;
|
|
740
|
-
image.height = result.height;
|
|
741
|
-
if (result.mimeType) image.mimeType = result.mimeType;
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
class ImageSearch {
|
|
745
|
-
constructor(collection, _options = {}) {
|
|
746
|
-
this.collection = collection;
|
|
747
|
-
this._options = _options;
|
|
748
|
-
}
|
|
749
|
-
collection;
|
|
750
|
-
_options;
|
|
751
|
-
/**
|
|
752
|
-
* Search images by text query with optional dimension/orientation filters
|
|
753
|
-
*
|
|
754
|
-
* @param query - Text search query
|
|
755
|
-
* @param searchOptions - Optional filters for dimensions, orientation, etc.
|
|
756
|
-
* @returns Matching images
|
|
757
|
-
*/
|
|
758
|
-
async search(query, searchOptions = {}) {
|
|
759
|
-
const where = {};
|
|
760
|
-
if (searchOptions.minWidth) where["width >="] = searchOptions.minWidth;
|
|
761
|
-
if (searchOptions.minHeight) where["height >="] = searchOptions.minHeight;
|
|
762
|
-
const fetchLimit = query ? (searchOptions.limit ?? 100) * 3 : searchOptions.limit;
|
|
763
|
-
let results = await this.collection.list({
|
|
764
|
-
where,
|
|
765
|
-
limit: fetchLimit,
|
|
766
|
-
offset: searchOptions.offset
|
|
767
|
-
});
|
|
768
|
-
if (query) {
|
|
769
|
-
const lowerQuery = query.toLowerCase();
|
|
770
|
-
results = results.filter(
|
|
771
|
-
(img) => img.name.toLowerCase().includes(lowerQuery) || img.description?.toLowerCase().includes(lowerQuery) || img.alt?.toLowerCase().includes(lowerQuery)
|
|
772
|
-
);
|
|
773
|
-
}
|
|
774
|
-
if (searchOptions.orientation) {
|
|
775
|
-
results = results.filter((img) => {
|
|
776
|
-
switch (searchOptions.orientation) {
|
|
777
|
-
case "landscape":
|
|
778
|
-
return img.isLandscape;
|
|
779
|
-
case "portrait":
|
|
780
|
-
return img.isPortrait;
|
|
781
|
-
case "square":
|
|
782
|
-
return img.isSquare;
|
|
783
|
-
default:
|
|
784
|
-
return true;
|
|
785
|
-
}
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
if (searchOptions.limit && results.length > searchOptions.limit) {
|
|
789
|
-
results = results.slice(0, searchOptions.limit);
|
|
790
|
-
}
|
|
791
|
-
return results;
|
|
792
|
-
}
|
|
793
|
-
/**
|
|
794
|
-
* Find images similar to a given image
|
|
795
|
-
*
|
|
796
|
-
* @param image - The reference image
|
|
797
|
-
* @param options - Search options
|
|
798
|
-
* @returns Similar images
|
|
799
|
-
*/
|
|
800
|
-
async findSimilar(image, options = {}) {
|
|
801
|
-
const limit = options.limit ?? 10;
|
|
802
|
-
const ratio = image.aspectRatio;
|
|
803
|
-
const minRatio = ratio * 0.8;
|
|
804
|
-
const maxRatio = ratio * 1.2;
|
|
805
|
-
const candidates = await this.collection.getByAspectRatio(
|
|
806
|
-
minRatio,
|
|
807
|
-
maxRatio
|
|
808
|
-
);
|
|
809
|
-
return candidates.filter((c) => c.id !== image.id).slice(0, limit);
|
|
810
|
-
}
|
|
811
|
-
/**
|
|
812
|
-
* Find images matching a natural language prompt
|
|
813
|
-
*
|
|
814
|
-
* @param prompt - Natural language description of desired images
|
|
815
|
-
* @param options - Search options
|
|
816
|
-
* @returns Matching images
|
|
817
|
-
*/
|
|
818
|
-
async findByPrompt(prompt, options = {}) {
|
|
819
|
-
return this.search(prompt, { limit: options.limit });
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
class UpstreamManager {
|
|
823
|
-
constructor(sources, store, collection) {
|
|
824
|
-
this.sources = sources;
|
|
825
|
-
this.store = store;
|
|
826
|
-
this.collection = collection;
|
|
827
|
-
}
|
|
828
|
-
sources;
|
|
829
|
-
store;
|
|
830
|
-
collection;
|
|
831
|
-
/**
|
|
832
|
-
* Search across all configured upstream sources
|
|
833
|
-
*
|
|
834
|
-
* @param query - Search query
|
|
835
|
-
* @param options - Search options
|
|
836
|
-
* @returns Merged and ranked results from all sources
|
|
837
|
-
*/
|
|
838
|
-
async search(query, options = {}) {
|
|
839
|
-
const limit = options.limit ?? 20;
|
|
840
|
-
const allResults = [];
|
|
841
|
-
const searches = this.sources.filter((s) => s.capabilities.search).map(
|
|
842
|
-
(source) => source.search(query, { limit }).catch(() => [])
|
|
843
|
-
);
|
|
844
|
-
const results = await Promise.all(searches);
|
|
845
|
-
for (const sourceResults of results) {
|
|
846
|
-
allResults.push(...sourceResults);
|
|
847
|
-
}
|
|
848
|
-
return allResults.slice(0, limit);
|
|
849
|
-
}
|
|
850
|
-
/**
|
|
851
|
-
* Import an asset from an upstream source into the local store
|
|
852
|
-
*
|
|
853
|
-
* @param sourceAsset - The upstream asset to import
|
|
854
|
-
* @returns Locally stored Image with provenance
|
|
855
|
-
*/
|
|
856
|
-
async import(sourceAsset) {
|
|
857
|
-
const adapter = this.sources.find((s) => s.name === sourceAsset.sourceName);
|
|
858
|
-
if (!adapter) {
|
|
859
|
-
throw new Error(`No adapter found for source: ${sourceAsset.sourceName}`);
|
|
860
|
-
}
|
|
861
|
-
const { data, metadata } = await adapter.download(sourceAsset.externalId);
|
|
862
|
-
const image = await this.collection.create({
|
|
863
|
-
name: sourceAsset.name,
|
|
864
|
-
sourceUri: "",
|
|
865
|
-
mimeType: sourceAsset.mimeType,
|
|
866
|
-
width: metadata.width ?? 0,
|
|
867
|
-
height: metadata.height ?? 0,
|
|
868
|
-
alt: metadata.description ?? "",
|
|
869
|
-
description: metadata.attribution ? `${metadata.description ?? ""} (${metadata.attribution})` : metadata.description ?? "",
|
|
870
|
-
sourceType: sourceAsset.sourceName,
|
|
871
|
-
externalId: sourceAsset.externalId,
|
|
872
|
-
typeSlug: "image"
|
|
873
|
-
});
|
|
874
|
-
const sourceUri = await this.store.storeFile(image, data, {
|
|
875
|
-
mimeType: sourceAsset.mimeType,
|
|
876
|
-
typeSlug: "image"
|
|
877
|
-
});
|
|
878
|
-
image.sourceUri = sourceUri;
|
|
879
|
-
await image.save();
|
|
880
|
-
return image;
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
export {
|
|
884
|
-
Image,
|
|
885
|
-
ImageCategorizer,
|
|
886
|
-
ImageCollection,
|
|
887
|
-
ImageDeriver,
|
|
888
|
-
ImageEditor,
|
|
889
|
-
ImageMetadataExtractor,
|
|
890
|
-
ImageSearch,
|
|
891
|
-
UpstreamManager,
|
|
892
|
-
persistMediaBundleInspection as persistImageMediaBundleInspection,
|
|
893
|
-
smrtImagesGenerateAltTextPrompt
|
|
552
|
+
AND (tenant_id = ? OR tenant_id IS NULL)`, [IMAGE_META_TYPE, tenantId], { allowRawOnTenantScoped: true });
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Get images by minimum dimensions
|
|
556
|
+
*
|
|
557
|
+
* @param minWidth - Minimum width in pixels
|
|
558
|
+
* @param minHeight - Minimum height in pixels
|
|
559
|
+
* @returns Array of images meeting minimum dimension requirements
|
|
560
|
+
*/
|
|
561
|
+
async getByMinDimensions(minWidth, minHeight) {
|
|
562
|
+
return await this.list({ where: {
|
|
563
|
+
"width >=": minWidth,
|
|
564
|
+
"height >=": minHeight
|
|
565
|
+
} });
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Get images by maximum dimensions
|
|
569
|
+
*
|
|
570
|
+
* @param maxWidth - Maximum width in pixels
|
|
571
|
+
* @param maxHeight - Maximum height in pixels
|
|
572
|
+
* @returns Array of images within maximum dimension limits
|
|
573
|
+
*/
|
|
574
|
+
async getByMaxDimensions(maxWidth, maxHeight) {
|
|
575
|
+
return await this.list({ where: {
|
|
576
|
+
"width <=": maxWidth,
|
|
577
|
+
"height <=": maxHeight
|
|
578
|
+
} });
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Get landscape images (width > height)
|
|
582
|
+
*
|
|
583
|
+
* @returns Array of landscape-oriented images
|
|
584
|
+
*/
|
|
585
|
+
async getLandscape() {
|
|
586
|
+
return (await this.list({})).filter((image) => image.isLandscape);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Get portrait images (height > width)
|
|
590
|
+
*
|
|
591
|
+
* @returns Array of portrait-oriented images
|
|
592
|
+
*/
|
|
593
|
+
async getPortrait() {
|
|
594
|
+
return (await this.list({})).filter((image) => image.isPortrait);
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Get square images (width === height)
|
|
598
|
+
*
|
|
599
|
+
* @returns Array of square images
|
|
600
|
+
*/
|
|
601
|
+
async getSquare() {
|
|
602
|
+
return (await this.list({})).filter((image) => image.isSquare);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Get images missing alt text
|
|
606
|
+
*
|
|
607
|
+
* @returns Array of images without accessibility text
|
|
608
|
+
*/
|
|
609
|
+
async getMissingAltText() {
|
|
610
|
+
return await this.list({ where: { alt: "" } });
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Get high resolution images (4K+)
|
|
614
|
+
*
|
|
615
|
+
* @returns Array of high resolution images
|
|
616
|
+
*/
|
|
617
|
+
async getHighResolution() {
|
|
618
|
+
return (await this.list({})).filter((image) => image.isHighResolution());
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Get images by aspect ratio range
|
|
622
|
+
*
|
|
623
|
+
* @param minRatio - Minimum aspect ratio (width/height)
|
|
624
|
+
* @param maxRatio - Maximum aspect ratio (width/height)
|
|
625
|
+
* @returns Array of images within the aspect ratio range
|
|
626
|
+
*/
|
|
627
|
+
async getByAspectRatio(minRatio, maxRatio) {
|
|
628
|
+
return (await this.list({})).filter((image) => image.height > 0 && image.aspectRatio >= minRatio && image.aspectRatio <= maxRatio);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
//#endregion
|
|
632
|
+
//#region src/metadata.ts
|
|
633
|
+
var ImageMetadataExtractor = class {
|
|
634
|
+
/**
|
|
635
|
+
* Extract metadata from an image buffer
|
|
636
|
+
*
|
|
637
|
+
* @param buffer - Raw image data
|
|
638
|
+
* @returns Extracted metadata including dimensions and format
|
|
639
|
+
*/
|
|
640
|
+
async extract(buffer) {
|
|
641
|
+
const { getDimensions, getImageMetadata } = await import("@happyvertical/images");
|
|
642
|
+
const dimensions = await getDimensions(buffer);
|
|
643
|
+
const metadata = await getImageMetadata(buffer);
|
|
644
|
+
return {
|
|
645
|
+
width: dimensions.width,
|
|
646
|
+
height: dimensions.height,
|
|
647
|
+
format: metadata.format ?? "",
|
|
648
|
+
mimeType: metadata.format ? `image/${metadata.format}` : "image/unknown",
|
|
649
|
+
exif: metadata.exif
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Extract metadata and apply it to an Image instance
|
|
654
|
+
*
|
|
655
|
+
* @param image - The Image instance to update
|
|
656
|
+
* @param buffer - Raw image data
|
|
657
|
+
*/
|
|
658
|
+
async extractAndApply(image, buffer) {
|
|
659
|
+
const result = await this.extract(buffer);
|
|
660
|
+
image.width = result.width;
|
|
661
|
+
image.height = result.height;
|
|
662
|
+
if (result.mimeType) image.mimeType = result.mimeType;
|
|
663
|
+
}
|
|
894
664
|
};
|
|
895
|
-
//#
|
|
665
|
+
//#endregion
|
|
666
|
+
//#region src/search.ts
|
|
667
|
+
var ImageSearch = class {
|
|
668
|
+
constructor(collection, _options = {}) {
|
|
669
|
+
this.collection = collection;
|
|
670
|
+
this._options = _options;
|
|
671
|
+
}
|
|
672
|
+
collection;
|
|
673
|
+
_options;
|
|
674
|
+
/**
|
|
675
|
+
* Search images by text query with optional dimension/orientation filters
|
|
676
|
+
*
|
|
677
|
+
* @param query - Text search query
|
|
678
|
+
* @param searchOptions - Optional filters for dimensions, orientation, etc.
|
|
679
|
+
* @returns Matching images
|
|
680
|
+
*/
|
|
681
|
+
async search(query, searchOptions = {}) {
|
|
682
|
+
const where = {};
|
|
683
|
+
if (searchOptions.minWidth) where["width >="] = searchOptions.minWidth;
|
|
684
|
+
if (searchOptions.minHeight) where["height >="] = searchOptions.minHeight;
|
|
685
|
+
const fetchLimit = query ? (searchOptions.limit ?? 100) * 3 : searchOptions.limit;
|
|
686
|
+
let results = await this.collection.list({
|
|
687
|
+
where,
|
|
688
|
+
limit: fetchLimit,
|
|
689
|
+
offset: searchOptions.offset
|
|
690
|
+
});
|
|
691
|
+
if (query) {
|
|
692
|
+
const lowerQuery = query.toLowerCase();
|
|
693
|
+
results = results.filter((img) => img.name.toLowerCase().includes(lowerQuery) || img.description?.toLowerCase().includes(lowerQuery) || img.alt?.toLowerCase().includes(lowerQuery));
|
|
694
|
+
}
|
|
695
|
+
if (searchOptions.orientation) results = results.filter((img) => {
|
|
696
|
+
switch (searchOptions.orientation) {
|
|
697
|
+
case "landscape": return img.isLandscape;
|
|
698
|
+
case "portrait": return img.isPortrait;
|
|
699
|
+
case "square": return img.isSquare;
|
|
700
|
+
default: return true;
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
if (searchOptions.limit && results.length > searchOptions.limit) results = results.slice(0, searchOptions.limit);
|
|
704
|
+
return results;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Find images similar to a given image
|
|
708
|
+
*
|
|
709
|
+
* @param image - The reference image
|
|
710
|
+
* @param options - Search options
|
|
711
|
+
* @returns Similar images
|
|
712
|
+
*/
|
|
713
|
+
async findSimilar(image, options = {}) {
|
|
714
|
+
const limit = options.limit ?? 10;
|
|
715
|
+
const ratio = image.aspectRatio;
|
|
716
|
+
const minRatio = ratio * .8;
|
|
717
|
+
const maxRatio = ratio * 1.2;
|
|
718
|
+
return (await this.collection.getByAspectRatio(minRatio, maxRatio)).filter((c) => c.id !== image.id).slice(0, limit);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Find images matching a natural language prompt
|
|
722
|
+
*
|
|
723
|
+
* @param prompt - Natural language description of desired images
|
|
724
|
+
* @param options - Search options
|
|
725
|
+
* @returns Matching images
|
|
726
|
+
*/
|
|
727
|
+
async findByPrompt(prompt, options = {}) {
|
|
728
|
+
return this.search(prompt, { limit: options.limit });
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
//#endregion
|
|
732
|
+
//#region src/upstream.ts
|
|
733
|
+
var UpstreamManager = class {
|
|
734
|
+
constructor(sources, store, collection) {
|
|
735
|
+
this.sources = sources;
|
|
736
|
+
this.store = store;
|
|
737
|
+
this.collection = collection;
|
|
738
|
+
}
|
|
739
|
+
sources;
|
|
740
|
+
store;
|
|
741
|
+
collection;
|
|
742
|
+
/**
|
|
743
|
+
* Search across all configured upstream sources
|
|
744
|
+
*
|
|
745
|
+
* @param query - Search query
|
|
746
|
+
* @param options - Search options
|
|
747
|
+
* @returns Merged and ranked results from all sources
|
|
748
|
+
*/
|
|
749
|
+
async search(query, options = {}) {
|
|
750
|
+
const limit = options.limit ?? 20;
|
|
751
|
+
const allResults = [];
|
|
752
|
+
const searches = this.sources.filter((s) => s.capabilities.search).map((source) => source.search(query, { limit }).catch(() => []));
|
|
753
|
+
const results = await Promise.all(searches);
|
|
754
|
+
for (const sourceResults of results) allResults.push(...sourceResults);
|
|
755
|
+
return allResults.slice(0, limit);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Import an asset from an upstream source into the local store
|
|
759
|
+
*
|
|
760
|
+
* @param sourceAsset - The upstream asset to import
|
|
761
|
+
* @returns Locally stored Image with provenance
|
|
762
|
+
*/
|
|
763
|
+
async import(sourceAsset) {
|
|
764
|
+
const adapter = this.sources.find((s) => s.name === sourceAsset.sourceName);
|
|
765
|
+
if (!adapter) throw new Error(`No adapter found for source: ${sourceAsset.sourceName}`);
|
|
766
|
+
const { data, metadata } = await adapter.download(sourceAsset.externalId);
|
|
767
|
+
const image = await this.collection.create({
|
|
768
|
+
name: sourceAsset.name,
|
|
769
|
+
sourceUri: "",
|
|
770
|
+
mimeType: sourceAsset.mimeType,
|
|
771
|
+
width: metadata.width ?? 0,
|
|
772
|
+
height: metadata.height ?? 0,
|
|
773
|
+
alt: metadata.description ?? "",
|
|
774
|
+
description: metadata.attribution ? `${metadata.description ?? ""} (${metadata.attribution})` : metadata.description ?? "",
|
|
775
|
+
sourceType: sourceAsset.sourceName,
|
|
776
|
+
externalId: sourceAsset.externalId,
|
|
777
|
+
typeSlug: "image"
|
|
778
|
+
});
|
|
779
|
+
image.sourceUri = await this.store.storeFile(image, data, {
|
|
780
|
+
mimeType: sourceAsset.mimeType,
|
|
781
|
+
typeSlug: "image"
|
|
782
|
+
});
|
|
783
|
+
await image.save();
|
|
784
|
+
return image;
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
//#endregion
|
|
788
|
+
export { Image, ImageCategorizer, ImageCollection, ImageDeriver, ImageEditor, ImageMetadataExtractor, ImageSearch, UpstreamManager, persistImageMediaBundleInspection, smrtImagesGenerateAltTextPrompt };
|
|
789
|
+
|
|
790
|
+
//# sourceMappingURL=index.js.map
|