@happyvertical/smrt-images 0.37.1 → 0.37.3

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 CHANGED
@@ -1,18 +1,18 @@
1
- import { ObjectRegistry, field, smrt, SmrtCollection } from "@happyvertical/smrt-core";
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 { writeFile, readFile, unlink } from "node:fs/promises";
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 { persistMediaBundleInspection } from "@happyvertical/smrt-assets";
9
- import { TenantScoped, getCurrentTenant, isSuperAdminBypass, TenantIsolationError } from "@happyvertical/smrt-tenancy";
10
- ObjectRegistry.registerPackageManifest(
11
- new URL("./manifest.json", import.meta.url)
12
- );
13
- const smrtImagesGenerateAltTextPrompt = definePrompt({
14
- key: "smrtImages.image.generateAltText",
15
- template: `Generate concise accessibility alt text for this image.
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
- editable: {
24
- template: true,
25
- profile: true,
26
- model: true,
27
- params: true
28
- }
23
+ editable: {
24
+ template: true,
25
+ profile: true,
26
+ model: true,
27
+ params: true
28
+ }
29
29
  });
30
30
  function promptMessageOptions(ai) {
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
- };
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
- const start = text.indexOf("{");
40
- if (start === -1) return null;
41
- let depth = 0;
42
- let inString = false;
43
- let escaped = false;
44
- for (let i = start; i < text.length; i++) {
45
- const ch = text[i];
46
- if (inString) {
47
- if (escaped) {
48
- escaped = false;
49
- } else if (ch === "\\") {
50
- escaped = true;
51
- } else if (ch === '"') {
52
- inString = false;
53
- }
54
- continue;
55
- }
56
- if (ch === '"') {
57
- inString = true;
58
- } else if (ch === "{") {
59
- depth++;
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
- const p = parsed ?? {};
71
- return {
72
- tags: Array.isArray(p.tags) ? p.tags : [],
73
- description: typeof p.description === "string" && p.description ? p.description : fallbackDescription,
74
- confidence: typeof p.confidence === "number" ? p.confidence : 0,
75
- subjects: Array.isArray(p.subjects) ? p.subjects : []
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
- class ImageCategorizer {
79
- constructor(options) {
80
- this.options = options;
81
- }
82
- options;
83
- /**
84
- * Categorize an image using AI vision analysis
85
- *
86
- * @param image - The Image instance to categorize
87
- * @param buffer - Optional raw image data for vision analysis
88
- * @returns Categorization results with tags, description, and subjects
89
- */
90
- async categorize(image, buffer) {
91
- const { getAI } = await import("@happyvertical/ai");
92
- const ai = await getAI(this.options.ai);
93
- const prompt = `Analyze this image and provide categorization.
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
- const response = await ai.chat([{ role: "user", content: prompt }]);
107
- const text = response.content;
108
- const fallbackDescription = image.description || image.name;
109
- const jsonText = extractFirstJsonObject(text);
110
- if (jsonText) {
111
- try {
112
- return normalizeCategoryResult(
113
- JSON.parse(jsonText),
114
- fallbackDescription
115
- );
116
- } catch {
117
- }
118
- }
119
- return {
120
- tags: [],
121
- description: fallbackDescription,
122
- confidence: 0,
123
- subjects: []
124
- };
125
- }
126
- /**
127
- * Run categorization and apply results to the image
128
- *
129
- * @param image - The Image to categorize and update
130
- * @param assetCollection - AssetCollection for tag management
131
- */
132
- async autoTag(image, assetCollection) {
133
- const result = await this.categorize(image);
134
- if (result.description && !image.description) {
135
- image.description = result.description;
136
- }
137
- if (!image.alt && result.description) {
138
- image.alt = result.description.slice(0, 125);
139
- }
140
- await image.save();
141
- for (const tag of result.tags ?? []) {
142
- await assetCollection.addTag(image.id, tag);
143
- }
144
- }
145
- }
146
- class ImageDeriver {
147
- constructor(store, collection, options) {
148
- this.store = store;
149
- this.collection = collection;
150
- this.options = options;
151
- }
152
- store;
153
- collection;
154
- options;
155
- /**
156
- * Derive new images from source images and a creative prompt
157
- *
158
- * @param sources - One or more source images
159
- * @param prompt - Creative instructions for generation
160
- * @param deriveOptions - Generation options (count, size, style)
161
- * @returns Array of newly created derivative Images
162
- */
163
- async derive(sources, prompt, deriveOptions = {}) {
164
- if (sources.length === 0) {
165
- throw new Error("At least one source image is required");
166
- }
167
- const { getAI } = await import("@happyvertical/ai");
168
- const ai = await getAI(this.options.ai);
169
- const count = deriveOptions.count ?? 1;
170
- const results = [];
171
- const fullPrompt = [
172
- prompt,
173
- deriveOptions.style ? `Style: ${deriveOptions.style}` : "",
174
- deriveOptions.size ? `Output size: ${deriveOptions.size}` : "",
175
- `Source images: ${sources.map((s) => s.name).join(", ")}`
176
- ].filter(Boolean).join("\n");
177
- for (let i = 0; i < count; i++) {
178
- const response = await ai.generateImage(fullPrompt, {
179
- size: deriveOptions.size
180
- });
181
- const imageData = response.images[0]?.data;
182
- if (!imageData || !(imageData instanceof Buffer)) {
183
- throw new Error("AI did not return image data as Buffer");
184
- }
185
- const result = imageData;
186
- const derived = await this.collection.create({
187
- name: `derived-${sources[0].name}-${i + 1}`,
188
- mimeType: "image/png",
189
- sourceUri: "",
190
- sourceAssetId: sources[0].id,
191
- typeSlug: "image",
192
- description: `Derived: ${prompt}`
193
- });
194
- const sourceUri = await this.store.storeFile(derived, result, {
195
- mimeType: "image/png",
196
- typeSlug: "image"
197
- });
198
- derived.sourceUri = sourceUri;
199
- await derived.save();
200
- results.push(derived);
201
- }
202
- return results;
203
- }
204
- /**
205
- * Derive images and link all sources via AssetAssociation
206
- *
207
- * @param sources - Source images
208
- * @param prompt - Creative instructions
209
- * @param associations - AssetAssociationCollection for linking
210
- * @param deriveOptions - Generation options
211
- * @returns Array of newly created derivative Images
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
- class ImageEditor {
234
- constructor(store, collection, options = {}) {
235
- this.store = store;
236
- this.collection = collection;
237
- this.options = options;
238
- }
239
- store;
240
- collection;
241
- options;
242
- /**
243
- * Resize an image to the specified dimensions
244
- *
245
- * @param image - Source image
246
- * @param width - Target width
247
- * @param height - Target height
248
- * @returns New derivative Image
249
- */
250
- async resize(image, width, height) {
251
- const { resizeImage } = await import("@happyvertical/images");
252
- const sourceData = await this.store.read(image);
253
- const inputPath = join(tmpdir(), `smrt-edit-in-${randomUUID()}.bin`);
254
- const outputPath = join(tmpdir(), `smrt-edit-out-${randomUUID()}.bin`);
255
- try {
256
- await writeFile(inputPath, sourceData);
257
- await resizeImage(inputPath, outputPath, { width, height });
258
- const resized = await readFile(outputPath);
259
- return this.createDerivative(image, resized, {
260
- name: `${image.name}-${width}x${height}`,
261
- width,
262
- height,
263
- description: `Resized from ${image.width}x${image.height} to ${width}x${height}`
264
- });
265
- } finally {
266
- await unlink(inputPath).catch(() => {
267
- });
268
- await unlink(outputPath).catch(() => {
269
- });
270
- }
271
- }
272
- /**
273
- * Crop an image to the specified region
274
- *
275
- * @param image - Source image
276
- * @param x - Left offset
277
- * @param y - Top offset
278
- * @param w - Crop width
279
- * @param h - Crop height
280
- * @returns New derivative Image
281
- */
282
- async crop(image, x, y, w, h) {
283
- const { getImageProcessor } = await import("@happyvertical/images");
284
- const processor = await getImageProcessor();
285
- const sourceData = await this.store.read(image);
286
- const inputPath = join(tmpdir(), `smrt-crop-in-${randomUUID()}.bin`);
287
- const outputPath = join(tmpdir(), `smrt-crop-out-${randomUUID()}.bin`);
288
- try {
289
- await writeFile(inputPath, sourceData);
290
- await processor.resize(inputPath, outputPath, {
291
- width: w,
292
- height: h,
293
- fit: "cover"
294
- });
295
- const cropped = await readFile(outputPath);
296
- return this.createDerivative(image, cropped, {
297
- name: `${image.name}-crop`,
298
- width: w,
299
- height: h,
300
- description: `Cropped region ${x},${y} ${w}x${h}`
301
- });
302
- } finally {
303
- await unlink(inputPath).catch(() => {
304
- });
305
- await unlink(outputPath).catch(() => {
306
- });
307
- }
308
- }
309
- /**
310
- * Convert an image to a different format
311
- *
312
- * @param image - Source image
313
- * @param format - Target format (e.g., 'webp', 'png', 'jpeg')
314
- * @returns New derivative Image
315
- */
316
- async convert(image, format) {
317
- const normalizedFormat = format.trim().toLowerCase();
318
- if (!ALLOWED_CONVERT_FORMATS.has(normalizedFormat)) {
319
- throw new Error(
320
- `Unsupported image format: ${JSON.stringify(format)}. Allowed formats: ${[...ALLOWED_CONVERT_FORMATS].join(", ")}`
321
- );
322
- }
323
- const safeFormat = normalizedFormat;
324
- const { convertFormat } = await import("@happyvertical/images");
325
- const sourceData = await this.store.read(image);
326
- const mimeType = `image/${safeFormat}`;
327
- const inputPath = join(tmpdir(), `smrt-conv-in-${randomUUID()}.bin`);
328
- const outputPath = join(
329
- tmpdir(),
330
- `smrt-conv-out-${randomUUID()}.${safeFormat}`
331
- );
332
- try {
333
- await writeFile(inputPath, sourceData);
334
- await convertFormat(inputPath, outputPath, {
335
- format: safeFormat
336
- });
337
- const converted = await readFile(outputPath);
338
- return this.createDerivative(image, converted, {
339
- name: `${image.name}.${safeFormat}`,
340
- mimeType,
341
- description: `Converted from ${image.mimeType} to ${mimeType}`
342
- });
343
- } finally {
344
- await unlink(inputPath).catch(() => {
345
- });
346
- await unlink(outputPath).catch(() => {
347
- });
348
- }
349
- }
350
- /**
351
- * Generate a square thumbnail of the specified size
352
- *
353
- * @param image - Source image
354
- * @param size - Thumbnail dimension (square)
355
- * @returns New derivative Image
356
- */
357
- async thumbnail(image, size) {
358
- const { generateThumbnail } = await import("@happyvertical/images");
359
- const sourceData = await this.store.read(image);
360
- const inputPath = join(tmpdir(), `smrt-thumb-in-${randomUUID()}.bin`);
361
- const outputPath = join(tmpdir(), `smrt-thumb-out-${randomUUID()}.bin`);
362
- try {
363
- await writeFile(inputPath, sourceData);
364
- await generateThumbnail(inputPath, outputPath, {
365
- maxWidth: size,
366
- maxHeight: size
367
- });
368
- const thumbData = await readFile(outputPath);
369
- return this.createDerivative(image, thumbData, {
370
- name: `${image.name}-thumb-${size}`,
371
- width: size,
372
- height: size,
373
- description: `Thumbnail ${size}x${size}`
374
- });
375
- } finally {
376
- await unlink(inputPath).catch(() => {
377
- });
378
- await unlink(outputPath).catch(() => {
379
- });
380
- }
381
- }
382
- /**
383
- * AI-powered image generation based on a prompt (creates derivative linked to source)
384
- *
385
- * @param image - Source image (used for metadata, linked as parent)
386
- * @param prompt - Generation instructions (e.g., "similar image with sunset colors")
387
- * @returns New derivative Image
388
- */
389
- async edit(image, prompt) {
390
- if (!this.options.ai) {
391
- throw new Error("AI options required for AI-powered editing");
392
- }
393
- const { getAI } = await import("@happyvertical/ai");
394
- const ai = await getAI(this.options.ai);
395
- const response = await ai.generateImage(prompt, {
396
- size: `${image.width}x${image.height}`
397
- });
398
- const imageData = response.images[0]?.data;
399
- if (!imageData || !(imageData instanceof Buffer)) {
400
- throw new Error("AI did not return image data as Buffer");
401
- }
402
- return this.createDerivative(image, imageData, {
403
- name: `${image.name}-edited`,
404
- description: `AI edit: ${prompt}`
405
- });
406
- }
407
- /**
408
- * Generate variations of an image using AI
409
- *
410
- * @param image - Source image
411
- * @param prompt - Variation instructions
412
- * @param options - Number of variations to generate
413
- * @returns Array of new derivative Images
414
- */
415
- async generateVariation(image, prompt, options = {}) {
416
- const count = options.count ?? 1;
417
- const results = [];
418
- for (let i = 0; i < count; i++) {
419
- const variation = await this.edit(
420
- image,
421
- `${prompt} (variation ${i + 1} of ${count})`
422
- );
423
- results.push(variation);
424
- }
425
- return results;
426
- }
427
- /**
428
- * Helper: Create a derivative Image from processed buffer data
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
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
457
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
458
- if (decorator = decorators[i])
459
- result = (kind ? decorator(target, key, result) : decorator(result)) || result;
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
- let Image = class extends Asset {
464
- width = 0;
465
- height = 0;
466
- alt = "";
467
- constructor(options = {}) {
468
- super(options);
469
- if (options.width !== void 0) this.width = options.width;
470
- if (options.height !== void 0) this.height = options.height;
471
- if (options.alt !== void 0) this.alt = options.alt;
472
- }
473
- /**
474
- * Calculate aspect ratio from dimensions
475
- */
476
- get aspectRatio() {
477
- if (this.height === 0) return 0;
478
- return this.width / this.height;
479
- }
480
- /**
481
- * Helper to get URL from sourceUri for frontend components
482
- */
483
- get url() {
484
- return this.sourceUri;
485
- }
486
- /**
487
- * Check if dimensions indicate landscape orientation
488
- */
489
- get isLandscape() {
490
- return this.width > this.height;
491
- }
492
- /**
493
- * Check if dimensions indicate portrait orientation
494
- */
495
- get isPortrait() {
496
- return this.height > this.width;
497
- }
498
- /**
499
- * Check if dimensions indicate square aspect ratio
500
- */
501
- get isSquare() {
502
- return this.width === this.height && this.width > 0;
503
- }
504
- /**
505
- * Validate that the asset is an image based on MIME type
506
- */
507
- isValidImageFormat() {
508
- return this.mimeType.startsWith("image/");
509
- }
510
- /**
511
- * Check if this image is high resolution (4K+)
512
- */
513
- isHighResolution() {
514
- return this.width >= 3840 || this.height >= 2160;
515
- }
516
- /**
517
- * AI-powered: Generate accessibility alt text for this image.
518
- *
519
- * Uses the `smrtImages.image.generateAltText` prompt registered via
520
- * `@happyvertical/smrt-prompts`, allowing tenant- or instance-level
521
- * overrides of the template, model, and parameters at runtime.
522
- *
523
- * Only non-PII metadata fields (name, description) are sent to the AI
524
- * provider. Source URIs, internal foreign-key fields, and the
525
- * extensible `metadata` blob are intentionally excluded — source URIs
526
- * may embed signed/private bucket paths and metadata may contain EXIF
527
- * GPS data or tenant-private configuration.
528
- *
529
- * @returns AI-generated alt text describing the image
530
- */
531
- async generateAltText() {
532
- const db = this.options.db ?? this.options.persistence;
533
- const resolvedPrompt = await resolvePrompt(
534
- smrtImagesGenerateAltTextPrompt.key,
535
- {
536
- db,
537
- tenantId: this.tenantId,
538
- variables: {
539
- imageName: this.name || "",
540
- imageDescription: this.description || ""
541
- }
542
- }
543
- );
544
- const ai = await this.getAiClient();
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
- field()
556
- ], Image.prototype, "width", 2);
557
- __decorateClass([
558
- field()
559
- ], Image.prototype, "height", 2);
560
- __decorateClass([
561
- field()
562
- ], Image.prototype, "alt", 2);
563
- Image = __decorateClass([
564
- TenantScoped({ mode: "optional" }),
565
- smrt({
566
- api: { include: ["list", "get", "create", "update", "delete"] },
567
- mcp: { include: ["list", "get", "create", "update", "generateAltText"] },
568
- cli: true
569
- })
570
- ], Image);
571
- const IMAGE_META_TYPE = "@happyvertical/smrt-images:Image";
572
- class ImageCollection extends SmrtCollection {
573
- static _itemClass = Image;
574
- // ─────────────────────────────────────────────────────────────────────────────
575
- // Tenant-Aware Query Methods
576
- // ─────────────────────────────────────────────────────────────────────────────
577
- /**
578
- * Find all images belonging to a specific tenant
579
- *
580
- * @param tenantId - The tenant ID to filter by
581
- * @returns Array of images belonging to this tenant
582
- */
583
- async findByTenant(tenantId) {
584
- return await this.list({ where: { tenantId } });
585
- }
586
- /**
587
- * Find all global images (images without a tenant)
588
- *
589
- * @returns Array of global images
590
- */
591
- async findGlobal() {
592
- return await this.query(
593
- `SELECT * FROM ${this.tableName}
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
- [IMAGE_META_TYPE],
597
- { allowRawOnTenantScoped: true }
598
- );
599
- }
600
- /**
601
- * Find images belonging to a tenant plus all global images
602
- *
603
- * @param tenantId - The tenant ID to include
604
- * @returns Array of tenant-specific and global images
605
- */
606
- async findWithGlobals(tenantId) {
607
- const tenantContext = getCurrentTenant();
608
- if (tenantContext && !isSuperAdminBypass() && tenantContext.tenantId !== tenantId) {
609
- throw new TenantIsolationError(
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
- [IMAGE_META_TYPE, tenantId],
619
- { allowRawOnTenantScoped: true }
620
- );
621
- }
622
- /**
623
- * Get images by minimum dimensions
624
- *
625
- * @param minWidth - Minimum width in pixels
626
- * @param minHeight - Minimum height in pixels
627
- * @returns Array of images meeting minimum dimension requirements
628
- */
629
- async getByMinDimensions(minWidth, minHeight) {
630
- return await this.list({
631
- where: {
632
- "width >=": minWidth,
633
- "height >=": minHeight
634
- }
635
- });
636
- }
637
- /**
638
- * Get images by maximum dimensions
639
- *
640
- * @param maxWidth - Maximum width in pixels
641
- * @param maxHeight - Maximum height in pixels
642
- * @returns Array of images within maximum dimension limits
643
- */
644
- async getByMaxDimensions(maxWidth, maxHeight) {
645
- return await this.list({
646
- where: {
647
- "width <=": maxWidth,
648
- "height <=": maxHeight
649
- }
650
- });
651
- }
652
- /**
653
- * Get landscape images (width > height)
654
- *
655
- * @returns Array of landscape-oriented images
656
- */
657
- async getLandscape() {
658
- const all = await this.list({});
659
- return all.filter((image) => image.isLandscape);
660
- }
661
- /**
662
- * Get portrait images (height > width)
663
- *
664
- * @returns Array of portrait-oriented images
665
- */
666
- async getPortrait() {
667
- const all = await this.list({});
668
- return all.filter((image) => image.isPortrait);
669
- }
670
- /**
671
- * Get square images (width === height)
672
- *
673
- * @returns Array of square images
674
- */
675
- async getSquare() {
676
- const all = await this.list({});
677
- return all.filter((image) => image.isSquare);
678
- }
679
- /**
680
- * Get images missing alt text
681
- *
682
- * @returns Array of images without accessibility text
683
- */
684
- async getMissingAltText() {
685
- return await this.list({
686
- where: { alt: "" }
687
- });
688
- }
689
- /**
690
- * Get high resolution images (4K+)
691
- *
692
- * @returns Array of high resolution images
693
- */
694
- async getHighResolution() {
695
- const all = await this.list({});
696
- return all.filter((image) => image.isHighResolution());
697
- }
698
- /**
699
- * Get images by aspect ratio range
700
- *
701
- * @param minRatio - Minimum aspect ratio (width/height)
702
- * @param maxRatio - Maximum aspect ratio (width/height)
703
- * @returns Array of images within the aspect ratio range
704
- */
705
- async getByAspectRatio(minRatio, maxRatio) {
706
- const all = await this.list({});
707
- return all.filter(
708
- (image) => image.height > 0 && image.aspectRatio >= minRatio && image.aspectRatio <= maxRatio
709
- );
710
- }
711
- }
712
- class ImageMetadataExtractor {
713
- /**
714
- * Extract metadata from an image buffer
715
- *
716
- * @param buffer - Raw image data
717
- * @returns Extracted metadata including dimensions and format
718
- */
719
- async extract(buffer) {
720
- const { getDimensions, getImageMetadata } = await import("@happyvertical/images");
721
- const dimensions = await getDimensions(buffer);
722
- const metadata = await getImageMetadata(buffer);
723
- return {
724
- width: dimensions.width,
725
- height: dimensions.height,
726
- format: metadata.format ?? "",
727
- mimeType: metadata.format ? `image/${metadata.format}` : "image/unknown",
728
- exif: metadata.exif
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
- //# sourceMappingURL=index.js.map
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