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