@happyvertical/smrt-images 0.31.1 → 0.32.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"categorizer.d.ts","sourceRoot":"","sources":["../src/categorizer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,qBAAa,gBAAgB;IACf,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE;QAAE,EAAE,EAAE,eAAe,CAAA;KAAE;IAE7D;;;;;;OAMG;IACG,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAyCxE;;;;;OAKG;IACG,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,eAAe,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;CAkB7E"}
1
+ {"version":3,"file":"categorizer.d.ts","sourceRoot":"","sources":["../src/categorizer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAqE9C,qBAAa,gBAAgB;IACf,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE;QAAE,EAAE,EAAE,eAAe,CAAA;KAAE;IAE7D;;;;;;OAMG;IACG,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IA8CxE;;;;;OAKG;IACG,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,eAAe,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;CAoB7E"}
@@ -1 +1 @@
1
- {"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../src/image.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,4BAA4B,CAAC;AAOnD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,qBAKa,KAAM,SAAQ,KAAK;IAG9B,KAAK,EAAE,MAAM,CAAK;IAElB,MAAM,EAAE,MAAM,CAAK;IAInB,GAAG,EAAE,MAAM,CAAM;gBAEL,OAAO,GAAE,YAAiB;IAOtC;;OAEG;IACH,IAAI,WAAW,IAAI,MAAM,CAGxB;IAED;;OAEG;IACH,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED;;OAEG;IACH,IAAI,WAAW,IAAI,OAAO,CAEzB;IAED;;OAEG;IACH,IAAI,UAAU,IAAI,OAAO,CAExB;IAED;;OAEG;IACH,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED;;OAEG;IACH,kBAAkB,IAAI,OAAO;IAI7B;;OAEG;IACH,gBAAgB,IAAI,OAAO;IAI3B;;;;;;;;;;;;;;OAcG;IACG,eAAe,IAAI,OAAO,CAAC,MAAM,CAAC;CAgCzC"}
1
+ {"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../src/image.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,4BAA4B,CAAC;AAQnD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAS5C,qBAMa,KAAM,SAAQ,KAAK;IAG9B,KAAK,EAAE,MAAM,CAAK;IAElB,MAAM,EAAE,MAAM,CAAK;IAInB,GAAG,EAAE,MAAM,CAAM;gBAEL,OAAO,GAAE,YAAiB;IAOtC;;OAEG;IACH,IAAI,WAAW,IAAI,MAAM,CAGxB;IAED;;OAEG;IACH,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED;;OAEG;IACH,IAAI,WAAW,IAAI,OAAO,CAEzB;IAED;;OAEG;IACH,IAAI,UAAU,IAAI,OAAO,CAExB;IAED;;OAEG;IACH,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED;;OAEG;IACH,kBAAkB,IAAI,OAAO;IAI7B;;OAEG;IACH,gBAAgB,IAAI,OAAO;IAI3B;;;;;;;;;;;;;;OAcG;IACG,eAAe,IAAI,OAAO,CAAC,MAAM,CAAC;CAgCzC"}
@@ -1 +1 @@
1
- {"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../src/images.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAEhC,qBAAa,eAAgB,SAAQ,cAAc,CAAC,KAAK,CAAC;IACxD,MAAM,CAAC,QAAQ,CAAC,UAAU,eAAS;IAMnC;;;;;OAKG;IACG,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;IAItD;;;;OAIG;IACG,UAAU,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAIpC;;;;;OAKG;IACG,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;IAOzD;;;;;;OAMG;IACG,kBAAkB,CACtB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,KAAK,EAAE,CAAC;IASnB;;;;;;OAMG;IACG,kBAAkB,CACtB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,KAAK,EAAE,CAAC;IASnB;;;;OAIG;IACG,YAAY,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAMtC;;;;OAIG;IACG,WAAW,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAMrC;;;;OAIG;IACG,SAAS,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAMnC;;;;OAIG;IACG,iBAAiB,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAM3C;;;;OAIG;IACG,iBAAiB,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAM3C;;;;;;OAMG;IACG,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;CAU7E"}
1
+ {"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../src/images.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAM1D,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAkBhC,qBAAa,eAAgB,SAAQ,cAAc,CAAC,KAAK,CAAC;IACxD,MAAM,CAAC,QAAQ,CAAC,UAAU,eAAS;IAMnC;;;;;OAKG;IACG,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;IAItD;;;;OAIG;IACG,UAAU,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAgBpC;;;;;OAKG;IACG,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;IAmCzD;;;;;;OAMG;IACG,kBAAkB,CACtB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,KAAK,EAAE,CAAC;IASnB;;;;;;OAMG;IACG,kBAAkB,CACtB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,KAAK,EAAE,CAAC;IASnB;;;;OAIG;IACG,YAAY,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAQtC;;;;OAIG;IACG,WAAW,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAOrC;;;;OAIG;IACG,SAAS,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAQnC;;;;OAIG;IACG,iBAAiB,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAM3C;;;;OAIG;IACG,iBAAiB,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;IAQ3C;;;;;;OAMG;IACG,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;CAc7E"}
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { Asset } from "@happyvertical/smrt-assets";
8
8
  import { persistMediaBundleInspection } from "@happyvertical/smrt-assets";
9
+ import { TenantScoped, getCurrentTenant, isSuperAdminBypass, TenantIsolationError } from "@happyvertical/smrt-tenancy";
9
10
  ObjectRegistry.registerPackageManifest(
10
11
  new URL("./manifest.json", import.meta.url)
11
12
  );
@@ -34,6 +35,46 @@ function promptMessageOptions(ai) {
34
35
  ...typeof ai.maxTokens === "number" ? { maxTokens: ai.maxTokens } : {}
35
36
  };
36
37
  }
38
+ 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;
68
+ }
69
+ 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
+ };
77
+ }
37
78
  class ImageCategorizer {
38
79
  constructor(options) {
39
80
  this.options = options;
@@ -64,16 +105,20 @@ Respond in JSON format:
64
105
  }`;
65
106
  const response = await ai.chat([{ role: "user", content: prompt }]);
66
107
  const text = response.content;
67
- try {
68
- const jsonMatch = text.match(/\{[\s\S]*\}/);
69
- if (jsonMatch) {
70
- return JSON.parse(jsonMatch[0]);
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 {
71
117
  }
72
- } catch {
73
118
  }
74
119
  return {
75
120
  tags: [],
76
- description: image.description || image.name,
121
+ description: fallbackDescription,
77
122
  confidence: 0,
78
123
  subjects: []
79
124
  };
@@ -93,7 +138,7 @@ Respond in JSON format:
93
138
  image.alt = result.description.slice(0, 125);
94
139
  }
95
140
  await image.save();
96
- for (const tag of result.tags) {
141
+ for (const tag of result.tags ?? []) {
97
142
  await assetCollection.addTag(image.id, tag);
98
143
  }
99
144
  }
@@ -516,12 +561,14 @@ __decorateClass([
516
561
  field()
517
562
  ], Image.prototype, "alt", 2);
518
563
  Image = __decorateClass([
564
+ TenantScoped({ mode: "optional" }),
519
565
  smrt({
520
566
  api: { include: ["list", "get", "create", "update", "delete"] },
521
567
  mcp: { include: ["list", "get", "create", "update", "generateAltText"] },
522
568
  cli: true
523
569
  })
524
570
  ], Image);
571
+ const IMAGE_META_TYPE = "@happyvertical/smrt-images:Image";
525
572
  class ImageCollection extends SmrtCollection {
526
573
  static _itemClass = Image;
527
574
  // ─────────────────────────────────────────────────────────────────────────────
@@ -542,7 +589,13 @@ class ImageCollection extends SmrtCollection {
542
589
  * @returns Array of global images
543
590
  */
544
591
  async findGlobal() {
545
- return await this.list({ where: { tenantId: null } });
592
+ return await this.query(
593
+ `SELECT * FROM ${this.tableName}
594
+ WHERE _meta_type = ?
595
+ AND tenant_id IS NULL`,
596
+ [IMAGE_META_TYPE],
597
+ { allowRawOnTenantScoped: true }
598
+ );
546
599
  }
547
600
  /**
548
601
  * Find images belonging to a tenant plus all global images
@@ -551,9 +604,19 @@ class ImageCollection extends SmrtCollection {
551
604
  * @returns Array of tenant-specific and global images
552
605
  */
553
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
+ }
554
614
  return await this.query(
555
- `SELECT * FROM ${this.tableName} WHERE tenant_id = ? OR tenant_id IS NULL`,
556
- [tenantId]
615
+ `SELECT * FROM ${this.tableName}
616
+ WHERE _meta_type = ?
617
+ AND (tenant_id = ? OR tenant_id IS NULL)`,
618
+ [IMAGE_META_TYPE, tenantId],
619
+ { allowRawOnTenantScoped: true }
557
620
  );
558
621
  }
559
622
  /**
@@ -592,9 +655,8 @@ class ImageCollection extends SmrtCollection {
592
655
  * @returns Array of landscape-oriented images
593
656
  */
594
657
  async getLandscape() {
595
- return await this.query(
596
- `SELECT * FROM ${this.tableName} WHERE width > height`
597
- );
658
+ const all = await this.list({});
659
+ return all.filter((image) => image.isLandscape);
598
660
  }
599
661
  /**
600
662
  * Get portrait images (height > width)
@@ -602,9 +664,8 @@ class ImageCollection extends SmrtCollection {
602
664
  * @returns Array of portrait-oriented images
603
665
  */
604
666
  async getPortrait() {
605
- return await this.query(
606
- `SELECT * FROM ${this.tableName} WHERE height > width`
607
- );
667
+ const all = await this.list({});
668
+ return all.filter((image) => image.isPortrait);
608
669
  }
609
670
  /**
610
671
  * Get square images (width === height)
@@ -612,9 +673,8 @@ class ImageCollection extends SmrtCollection {
612
673
  * @returns Array of square images
613
674
  */
614
675
  async getSquare() {
615
- return await this.query(
616
- `SELECT * FROM ${this.tableName} WHERE width = height AND width > 0`
617
- );
676
+ const all = await this.list({});
677
+ return all.filter((image) => image.isSquare);
618
678
  }
619
679
  /**
620
680
  * Get images missing alt text
@@ -632,9 +692,8 @@ class ImageCollection extends SmrtCollection {
632
692
  * @returns Array of high resolution images
633
693
  */
634
694
  async getHighResolution() {
635
- return await this.query(
636
- `SELECT * FROM ${this.tableName} WHERE width >= 3840 OR height >= 2160`
637
- );
695
+ const all = await this.list({});
696
+ return all.filter((image) => image.isHighResolution());
638
697
  }
639
698
  /**
640
699
  * Get images by aspect ratio range
@@ -644,12 +703,9 @@ class ImageCollection extends SmrtCollection {
644
703
  * @returns Array of images within the aspect ratio range
645
704
  */
646
705
  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]
706
+ const all = await this.list({});
707
+ return all.filter(
708
+ (image) => image.height > 0 && image.aspectRatio >= minRatio && image.aspectRatio <= maxRatio
653
709
  );
654
710
  }
655
711
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../src/__smrt-register__.ts","../src/prompts.ts","../src/categorizer.ts","../src/deriver.ts","../src/editor.ts","../src/image.ts","../src/images.ts","../src/metadata.ts","../src/search.ts","../src/upstream.ts"],"sourcesContent":["/**\n * Self-registers this package's build-time manifest before any @smrt() decorator\n * in the package fires. Fixes issue #1132: in consumer runtimes (tsx, SvelteKit\n * SSR, plain `vite dev`) the decorator's synchronous manifest lookup previously\n * missed because no step populated the global manifest cache — classes got\n * registered with zero fields and `save()` / `toJSON()` silently dropped every\n * declared property.\n *\n * Import this module as the first statement in `src/index.ts` so its top-level\n * side effect runs ahead of any class module's @smrt() decorator.\n *\n * Silent no-op in dev/test, where the vitest plugin already populates manifests\n * via a different path. Only needs to succeed in the published dist output.\n *\n * @see https://github.com/happyvertical/smrt/issues/1132\n */\nimport { ObjectRegistry } from '@happyvertical/smrt-core';\n\n// `new URL('./manifest.json', import.meta.url)` resolves at runtime to the\n// manifest sitting next to this module's compiled output. Vite warns at build\n// time that it cannot pre-resolve the URL; that is the intended behavior —\n// the URL must resolve to dist/manifest.json at runtime, not be inlined.\nObjectRegistry.registerPackageManifest(\n new URL('./manifest.json', import.meta.url),\n);\n","/**\n * Prompt registrations for the @happyvertical/smrt-images package.\n *\n * Prompts are registered at module-load time via `definePrompt()` so that\n * tenant-aware overrides can be applied at call time via `resolvePrompt()`.\n *\n * Mirrors the pattern used by `@happyvertical/smrt-profiles` (see\n * `prompts.ts`) and `@happyvertical/smrt-properties` (see `prompts.ts`).\n */\n\nimport {\n definePrompt,\n type ResolvedPromptAI,\n} from '@happyvertical/smrt-prompts';\n\n// Alt text generation only uses non-PII metadata describing the image\n// itself (name and description). Source URIs, internal foreign-key\n// fields (e.g. sourceAssetId), and the extensible `metadata` blob are\n// intentionally NOT passed to the AI provider — source URIs may embed\n// signed/private bucket paths, and metadata may contain EXIF GPS data\n// or tenant-private configuration. If a downstream tenant needs richer\n// context they can override the template via PromptOverride.\nexport const smrtImagesGenerateAltTextPrompt = definePrompt({\n key: 'smrtImages.image.generateAltText',\n template: `Generate concise accessibility alt text for this image.\nConsider: subject matter, key visual elements, context.\nKeep it under 125 characters for screen reader compatibility.\n\nImage name: {imageName}\nImage description: {imageDescription}\n\nReturn only the alt text, with no commentary or surrounding quotation marks.`,\n editable: {\n template: true,\n profile: true,\n model: true,\n params: true,\n },\n});\n\nexport function promptMessageOptions(ai: ResolvedPromptAI) {\n return {\n ...(ai.params || {}),\n ...(ai.model ? { model: ai.model } : {}),\n ...(typeof ai.temperature === 'number'\n ? { temperature: ai.temperature }\n : {}),\n ...(typeof ai.maxTokens === 'number' ? { maxTokens: ai.maxTokens } : {}),\n };\n}\n","/**\n * ImageCategorizer - AI-powered image categorization\n *\n * Uses @happyvertical/ai to analyze image content and suggest\n * tags, descriptions, and subject classifications.\n */\n\nimport type { AIClientOptions } from '@happyvertical/ai';\nimport type { AssetCollection } from '@happyvertical/smrt-assets';\nimport type { Image } from './image';\nimport type { CategoryResult } from './types';\n\nexport class ImageCategorizer {\n constructor(private readonly options: { ai: AIClientOptions }) {}\n\n /**\n * Categorize an image using AI vision analysis\n *\n * @param image - The Image instance to categorize\n * @param buffer - Optional raw image data for vision analysis\n * @returns Categorization results with tags, description, and subjects\n */\n async categorize(image: Image, buffer?: Buffer): Promise<CategoryResult> {\n const { getAI } = await import('@happyvertical/ai');\n const ai = await getAI(this.options.ai);\n\n // TODO: When AI vision API is available, pass buffer for visual analysis\n void buffer;\n\n const prompt = `Analyze this image and provide categorization.\nImage name: ${image.name}\nImage description: ${image.description}\nMIME type: ${image.mimeType}\nDimensions: ${image.width}x${image.height}\n\nRespond in JSON format:\n{\n \"tags\": [\"tag1\", \"tag2\", ...],\n \"description\": \"Brief description of the image content\",\n \"confidence\": 0.0-1.0,\n \"subjects\": [\"subject1\", \"subject2\", ...]\n}`;\n\n const response = await ai.chat([{ role: 'user', content: prompt }]);\n const text = response.content;\n\n try {\n const jsonMatch = text.match(/\\{[\\s\\S]*\\}/);\n if (jsonMatch) {\n return JSON.parse(jsonMatch[0]) as CategoryResult;\n }\n } catch {\n // Fall through to default\n }\n\n return {\n tags: [],\n description: image.description || image.name,\n confidence: 0,\n subjects: [],\n };\n }\n\n /**\n * Run categorization and apply results to the image\n *\n * @param image - The Image to categorize and update\n * @param assetCollection - AssetCollection for tag management\n */\n async autoTag(image: Image, assetCollection: AssetCollection): Promise<void> {\n const result = await this.categorize(image);\n\n if (result.description && !image.description) {\n image.description = result.description;\n }\n\n if (!image.alt && result.description) {\n image.alt = result.description.slice(0, 125);\n }\n\n await image.save();\n\n // Add tags via the asset collection\n for (const tag of result.tags) {\n await assetCollection.addTag(image.id!, tag);\n }\n }\n}\n","/**\n * ImageDeriver - Creates new images from source images + AI prompts\n *\n * Combines multiple source images with creative prompts to generate\n * new derivative images, tracking provenance via AssetAssociation.\n */\n\nimport type { AIClientOptions } from '@happyvertical/ai';\nimport type {\n AssetAssociationCollection,\n AssetStore,\n} from '@happyvertical/smrt-assets';\nimport type { Image } from './image';\nimport type { ImageCollection } from './images';\nimport type { DeriveOptions } from './types';\n\nexport class ImageDeriver {\n constructor(\n private readonly store: AssetStore,\n private readonly collection: ImageCollection,\n private readonly options: { ai: AIClientOptions },\n ) {}\n\n /**\n * Derive new images from source images and a creative prompt\n *\n * @param sources - One or more source images\n * @param prompt - Creative instructions for generation\n * @param deriveOptions - Generation options (count, size, style)\n * @returns Array of newly created derivative Images\n */\n async derive(\n sources: Image[],\n prompt: string,\n deriveOptions: DeriveOptions = {},\n ): Promise<Image[]> {\n if (sources.length === 0) {\n throw new Error('At least one source image is required');\n }\n\n const { getAI } = await import('@happyvertical/ai');\n const ai = await getAI(this.options.ai);\n\n const count = deriveOptions.count ?? 1;\n const results: Image[] = [];\n\n const fullPrompt = [\n prompt,\n deriveOptions.style ? `Style: ${deriveOptions.style}` : '',\n deriveOptions.size ? `Output size: ${deriveOptions.size}` : '',\n `Source images: ${sources.map((s) => s.name).join(', ')}`,\n ]\n .filter(Boolean)\n .join('\\n');\n\n for (let i = 0; i < count; i++) {\n const response = await ai.generateImage(fullPrompt, {\n size: deriveOptions.size,\n });\n\n const imageData = response.images[0]?.data;\n if (!imageData || !(imageData instanceof Buffer)) {\n throw new Error('AI did not return image data as Buffer');\n }\n const result = imageData;\n\n const derived = (await this.collection.create({\n name: `derived-${sources[0].name}-${i + 1}`,\n mimeType: 'image/png',\n sourceUri: '',\n sourceAssetId: sources[0].id,\n typeSlug: 'image',\n description: `Derived: ${prompt}`,\n })) as Image;\n\n const sourceUri = await this.store.storeFile(derived, result, {\n mimeType: 'image/png',\n typeSlug: 'image',\n });\n derived.sourceUri = sourceUri;\n await derived.save();\n\n results.push(derived);\n }\n\n return results;\n }\n\n /**\n * Derive images and link all sources via AssetAssociation\n *\n * @param sources - Source images\n * @param prompt - Creative instructions\n * @param associations - AssetAssociationCollection for linking\n * @param deriveOptions - Generation options\n * @returns Array of newly created derivative Images\n */\n async deriveWithAssociations(\n sources: Image[],\n prompt: string,\n associations: AssetAssociationCollection,\n deriveOptions: DeriveOptions = {},\n ): Promise<Image[]> {\n const results = await this.derive(sources, prompt, deriveOptions);\n\n // Link all source images to each derived image\n for (const derived of results) {\n for (const source of sources) {\n await associations.attach('Image', derived.id!, source.id!, {\n role: 'derivation-source',\n });\n }\n }\n\n return results;\n }\n}\n","/**\n * ImageEditor - Standard and AI-powered image editing\n *\n * Creates derivative assets for each edit operation, preserving\n * the original image and linking via sourceAssetId (the derivation\n * pointer; renamed from `parentId` in R3-D).\n *\n * Standard operations use @happyvertical/images (file-based API).\n * AI operations use @happyvertical/ai.\n */\n\nimport { randomUUID } from 'node:crypto';\nimport { readFile, unlink, writeFile } from 'node:fs/promises';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport type { AIClientOptions } from '@happyvertical/ai';\nimport type { ImageFormat } from '@happyvertical/images';\nimport type { AssetStore } from '@happyvertical/smrt-assets';\nimport type { Image } from './image';\nimport type { ImageCollection } from './images';\n\n/**\n * Output formats `convert()` accepts. Mirrors the `ImageFormat` union from\n * `@happyvertical/images`. Used as a runtime allowlist so a caller-supplied\n * `format` string can never escape this set.\n */\nconst ALLOWED_CONVERT_FORMATS = new Set<ImageFormat>([\n 'jpeg',\n 'png',\n 'webp',\n 'avif',\n 'gif',\n 'tiff',\n]);\n\nexport class ImageEditor {\n constructor(\n private readonly store: AssetStore,\n private readonly collection: ImageCollection,\n private readonly options: { ai?: AIClientOptions } = {},\n ) {}\n\n /**\n * Resize an image to the specified dimensions\n *\n * @param image - Source image\n * @param width - Target width\n * @param height - Target height\n * @returns New derivative Image\n */\n async resize(image: Image, width: number, height: number): Promise<Image> {\n const { resizeImage } = await import('@happyvertical/images');\n const sourceData = await this.store.read(image);\n\n const inputPath = join(tmpdir(), `smrt-edit-in-${randomUUID()}.bin`);\n const outputPath = join(tmpdir(), `smrt-edit-out-${randomUUID()}.bin`);\n\n try {\n await writeFile(inputPath, sourceData);\n await resizeImage(inputPath, outputPath, { width, height });\n const resized = await readFile(outputPath);\n\n return this.createDerivative(image, resized, {\n name: `${image.name}-${width}x${height}`,\n width,\n height,\n description: `Resized from ${image.width}x${image.height} to ${width}x${height}`,\n });\n } finally {\n await unlink(inputPath).catch(() => {});\n await unlink(outputPath).catch(() => {});\n }\n }\n\n /**\n * Crop an image to the specified region\n *\n * @param image - Source image\n * @param x - Left offset\n * @param y - Top offset\n * @param w - Crop width\n * @param h - Crop height\n * @returns New derivative Image\n */\n async crop(\n image: Image,\n x: number,\n y: number,\n w: number,\n h: number,\n ): Promise<Image> {\n const { getImageProcessor } = await import('@happyvertical/images');\n const processor = await getImageProcessor();\n const sourceData = await this.store.read(image);\n\n const inputPath = join(tmpdir(), `smrt-crop-in-${randomUUID()}.bin`);\n const outputPath = join(tmpdir(), `smrt-crop-out-${randomUUID()}.bin`);\n\n try {\n await writeFile(inputPath, sourceData);\n // TODO: @happyvertical/images does not yet expose a direct x,y extract API.\n // The x/y offset params are accepted but unused — this is a resize-with-cover-fit,\n // not a true x,y crop. When the `extract(x, y, w, h)` API is available, replace this.\n // Tracked in: https://github.com/happyvertical/smrt/issues/TODO\n await processor.resize(inputPath, outputPath, {\n width: w,\n height: h,\n fit: 'cover',\n });\n const cropped = await readFile(outputPath);\n\n return this.createDerivative(image, cropped, {\n name: `${image.name}-crop`,\n width: w,\n height: h,\n description: `Cropped region ${x},${y} ${w}x${h}`,\n });\n } finally {\n await unlink(inputPath).catch(() => {});\n await unlink(outputPath).catch(() => {});\n }\n }\n\n /**\n * Convert an image to a different format\n *\n * @param image - Source image\n * @param format - Target format (e.g., 'webp', 'png', 'jpeg')\n * @returns New derivative Image\n */\n async convert(image: Image, format: string): Promise<Image> {\n // Validate against a fixed allowlist BEFORE the value is interpolated into\n // a filesystem path. `format` is caller-controlled (e.g. an HTTP request\n // body via `ImageConvertRequest`) and is appended as the output temp\n // file's extension; an unchecked value like `../../../etc/cron.d/x` would\n // escape `tmpdir()` and let an attacker write the converted bytes to an\n // arbitrary location (path-traversal write). The static `as ImageFormat`\n // cast below gives zero runtime protection, so guard explicitly here.\n const normalizedFormat = format.trim().toLowerCase();\n if (!ALLOWED_CONVERT_FORMATS.has(normalizedFormat as ImageFormat)) {\n throw new Error(\n `Unsupported image format: ${JSON.stringify(format)}. ` +\n `Allowed formats: ${[...ALLOWED_CONVERT_FORMATS].join(', ')}`,\n );\n }\n const safeFormat = normalizedFormat as ImageFormat;\n\n const { convertFormat } = await import('@happyvertical/images');\n const sourceData = await this.store.read(image);\n const mimeType = `image/${safeFormat}`;\n\n const inputPath = join(tmpdir(), `smrt-conv-in-${randomUUID()}.bin`);\n const outputPath = join(\n tmpdir(),\n `smrt-conv-out-${randomUUID()}.${safeFormat}`,\n );\n\n try {\n await writeFile(inputPath, sourceData);\n await convertFormat(inputPath, outputPath, {\n format: safeFormat,\n });\n const converted = await readFile(outputPath);\n\n return this.createDerivative(image, converted, {\n name: `${image.name}.${safeFormat}`,\n mimeType,\n description: `Converted from ${image.mimeType} to ${mimeType}`,\n });\n } finally {\n await unlink(inputPath).catch(() => {});\n await unlink(outputPath).catch(() => {});\n }\n }\n\n /**\n * Generate a square thumbnail of the specified size\n *\n * @param image - Source image\n * @param size - Thumbnail dimension (square)\n * @returns New derivative Image\n */\n async thumbnail(image: Image, size: number): Promise<Image> {\n const { generateThumbnail } = await import('@happyvertical/images');\n const sourceData = await this.store.read(image);\n\n const inputPath = join(tmpdir(), `smrt-thumb-in-${randomUUID()}.bin`);\n const outputPath = join(tmpdir(), `smrt-thumb-out-${randomUUID()}.bin`);\n\n try {\n await writeFile(inputPath, sourceData);\n await generateThumbnail(inputPath, outputPath, {\n maxWidth: size,\n maxHeight: size,\n });\n const thumbData = await readFile(outputPath);\n\n return this.createDerivative(image, thumbData, {\n name: `${image.name}-thumb-${size}`,\n width: size,\n height: size,\n description: `Thumbnail ${size}x${size}`,\n });\n } finally {\n await unlink(inputPath).catch(() => {});\n await unlink(outputPath).catch(() => {});\n }\n }\n\n /**\n * AI-powered image generation based on a prompt (creates derivative linked to source)\n *\n * @param image - Source image (used for metadata, linked as parent)\n * @param prompt - Generation instructions (e.g., \"similar image with sunset colors\")\n * @returns New derivative Image\n */\n async edit(image: Image, prompt: string): Promise<Image> {\n if (!this.options.ai) {\n throw new Error('AI options required for AI-powered editing');\n }\n\n const { getAI } = await import('@happyvertical/ai');\n const ai = await getAI(this.options.ai);\n\n const response = await ai.generateImage(prompt, {\n size: `${image.width}x${image.height}`,\n });\n\n const imageData = response.images[0]?.data;\n if (!imageData || !(imageData instanceof Buffer)) {\n throw new Error('AI did not return image data as Buffer');\n }\n\n return this.createDerivative(image, imageData, {\n name: `${image.name}-edited`,\n description: `AI edit: ${prompt}`,\n });\n }\n\n /**\n * Generate variations of an image using AI\n *\n * @param image - Source image\n * @param prompt - Variation instructions\n * @param options - Number of variations to generate\n * @returns Array of new derivative Images\n */\n async generateVariation(\n image: Image,\n prompt: string,\n options: { count?: number } = {},\n ): Promise<Image[]> {\n const count = options.count ?? 1;\n const results: Image[] = [];\n\n for (let i = 0; i < count; i++) {\n const variation = await this.edit(\n image,\n `${prompt} (variation ${i + 1} of ${count})`,\n );\n results.push(variation);\n }\n\n return results;\n }\n\n /**\n * Helper: Create a derivative Image from processed buffer data\n */\n private async createDerivative(\n source: Image,\n data: Buffer,\n overrides: {\n name: string;\n width?: number;\n height?: number;\n mimeType?: string;\n description?: string;\n },\n ): Promise<Image> {\n const mimeType = overrides.mimeType ?? source.mimeType;\n const typeSlug = source.typeSlug || 'image';\n\n // Create only the Image record (not a plain Asset via store.store())\n const derivative = (await this.collection.create({\n name: overrides.name,\n sourceUri: '',\n mimeType,\n width: overrides.width ?? source.width,\n height: overrides.height ?? source.height,\n alt: source.alt,\n sourceAssetId: source.id,\n typeSlug,\n description: overrides.description ?? '',\n })) as Image;\n\n // Write file data for the existing record\n const sourceUri = await this.store.storeFile(derivative, data, {\n mimeType,\n typeSlug,\n });\n derivative.sourceUri = sourceUri;\n await derivative.save();\n\n return derivative;\n }\n}\n","/**\n * Image model - Asset subclass for image files\n *\n * Represents an image asset with dimensions and accessibility text.\n * Uses STI (Single Table Inheritance) - stored in the assets table with _meta_type='Image'.\n *\n * For semantic search on images, use the centralized embedding system:\n * @smrt({ embeddings: { fields: ['alt', 'description'] } })\n */\n\nimport { Asset } from '@happyvertical/smrt-assets';\nimport { field, smrt } from '@happyvertical/smrt-core';\nimport { resolvePrompt } from '@happyvertical/smrt-prompts';\nimport {\n promptMessageOptions,\n smrtImagesGenerateAltTextPrompt,\n} from './prompts';\nimport type { ImageOptions } from './types';\n\n@smrt({\n api: { include: ['list', 'get', 'create', 'update', 'delete'] },\n mcp: { include: ['list', 'get', 'create', 'update', 'generateAltText'] },\n cli: true,\n})\nexport class Image extends Asset {\n // Core image dimensions (regular columns for querying)\n @field()\n width: number = 0;\n @field()\n height: number = 0;\n\n // Accessibility text\n @field()\n alt: string = '';\n\n constructor(options: ImageOptions = {}) {\n super(options);\n if (options.width !== undefined) this.width = options.width;\n if (options.height !== undefined) this.height = options.height;\n if (options.alt !== undefined) this.alt = options.alt;\n }\n\n /**\n * Calculate aspect ratio from dimensions\n */\n get aspectRatio(): number {\n if (this.height === 0) return 0;\n return this.width / this.height;\n }\n\n /**\n * Helper to get URL from sourceUri for frontend components\n */\n get url(): string {\n return this.sourceUri;\n }\n\n /**\n * Check if dimensions indicate landscape orientation\n */\n get isLandscape(): boolean {\n return this.width > this.height;\n }\n\n /**\n * Check if dimensions indicate portrait orientation\n */\n get isPortrait(): boolean {\n return this.height > this.width;\n }\n\n /**\n * Check if dimensions indicate square aspect ratio\n */\n get isSquare(): boolean {\n return this.width === this.height && this.width > 0;\n }\n\n /**\n * Validate that the asset is an image based on MIME type\n */\n isValidImageFormat(): boolean {\n return this.mimeType.startsWith('image/');\n }\n\n /**\n * Check if this image is high resolution (4K+)\n */\n isHighResolution(): boolean {\n return this.width >= 3840 || this.height >= 2160;\n }\n\n /**\n * AI-powered: Generate accessibility alt text for this image.\n *\n * Uses the `smrtImages.image.generateAltText` prompt registered via\n * `@happyvertical/smrt-prompts`, allowing tenant- or instance-level\n * overrides of the template, model, and parameters at runtime.\n *\n * Only non-PII metadata fields (name, description) are sent to the AI\n * provider. Source URIs, internal foreign-key fields, and the\n * extensible `metadata` blob are intentionally excluded — source URIs\n * may embed signed/private bucket paths and metadata may contain EXIF\n * GPS data or tenant-private configuration.\n *\n * @returns AI-generated alt text describing the image\n */\n async generateAltText(): Promise<string> {\n // Resolve `db` from either the canonical `db` option or its `persistence`\n // alias. SmrtClass maps `persistence → db` lazily during `initialize()`,\n // so on a freshly-constructed Image that has not yet been initialized,\n // `this.options.db` may be undefined while `this.options.persistence` is\n // set. Falling back here ensures stored app- and tenant-level prompt\n // overrides in `_smrt_prompt_overrides` are honored on the first call —\n // before `getAiClient()` triggers full initialization further below.\n const db = this.options.db ?? this.options.persistence;\n\n const resolvedPrompt = await resolvePrompt(\n smrtImagesGenerateAltTextPrompt.key,\n {\n db,\n tenantId: this.tenantId,\n variables: {\n imageName: this.name || '',\n imageDescription: this.description || '',\n },\n },\n );\n\n const ai = await this.getAiClient();\n const response = await ai.message(\n resolvedPrompt.text,\n promptMessageOptions(resolvedPrompt.ai),\n );\n\n const altText = response.trim();\n this.alt = altText;\n return altText;\n }\n}\n","/**\n * ImageCollection - Collection manager for Image instances\n *\n * Provides image-specific query operations, tenant-aware queries, and bulk operations\n */\n\nimport { SmrtCollection } from '@happyvertical/smrt-core';\nimport { Image } from './image';\n\nexport class ImageCollection extends SmrtCollection<Image> {\n static readonly _itemClass = Image;\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Tenant-Aware Query Methods\n // ─────────────────────────────────────────────────────────────────────────────\n\n /**\n * Find all images belonging to a specific tenant\n *\n * @param tenantId - The tenant ID to filter by\n * @returns Array of images belonging to this tenant\n */\n async findByTenant(tenantId: string): Promise<Image[]> {\n return (await this.list({ where: { tenantId } })) as Image[];\n }\n\n /**\n * Find all global images (images without a tenant)\n *\n * @returns Array of global images\n */\n async findGlobal(): Promise<Image[]> {\n return (await this.list({ where: { tenantId: null } })) as Image[];\n }\n\n /**\n * Find images belonging to a tenant plus all global images\n *\n * @param tenantId - The tenant ID to include\n * @returns Array of tenant-specific and global images\n */\n async findWithGlobals(tenantId: string): Promise<Image[]> {\n return (await this.query(\n `SELECT * FROM ${this.tableName} WHERE tenant_id = ? OR tenant_id IS NULL`,\n [tenantId],\n )) as Image[];\n }\n\n /**\n * Get images by minimum dimensions\n *\n * @param minWidth - Minimum width in pixels\n * @param minHeight - Minimum height in pixels\n * @returns Array of images meeting minimum dimension requirements\n */\n async getByMinDimensions(\n minWidth: number,\n minHeight: number,\n ): Promise<Image[]> {\n return (await this.list({\n where: {\n 'width >=': minWidth,\n 'height >=': minHeight,\n },\n })) as Image[];\n }\n\n /**\n * Get images by maximum dimensions\n *\n * @param maxWidth - Maximum width in pixels\n * @param maxHeight - Maximum height in pixels\n * @returns Array of images within maximum dimension limits\n */\n async getByMaxDimensions(\n maxWidth: number,\n maxHeight: number,\n ): Promise<Image[]> {\n return (await this.list({\n where: {\n 'width <=': maxWidth,\n 'height <=': maxHeight,\n },\n })) as Image[];\n }\n\n /**\n * Get landscape images (width > height)\n *\n * @returns Array of landscape-oriented images\n */\n async getLandscape(): Promise<Image[]> {\n return (await this.query(\n `SELECT * FROM ${this.tableName} WHERE width > height`,\n )) as Image[];\n }\n\n /**\n * Get portrait images (height > width)\n *\n * @returns Array of portrait-oriented images\n */\n async getPortrait(): Promise<Image[]> {\n return (await this.query(\n `SELECT * FROM ${this.tableName} WHERE height > width`,\n )) as Image[];\n }\n\n /**\n * Get square images (width === height)\n *\n * @returns Array of square images\n */\n async getSquare(): Promise<Image[]> {\n return (await this.query(\n `SELECT * FROM ${this.tableName} WHERE width = height AND width > 0`,\n )) as Image[];\n }\n\n /**\n * Get images missing alt text\n *\n * @returns Array of images without accessibility text\n */\n async getMissingAltText(): Promise<Image[]> {\n return (await this.list({\n where: { alt: '' },\n })) as Image[];\n }\n\n /**\n * Get high resolution images (4K+)\n *\n * @returns Array of high resolution images\n */\n async getHighResolution(): Promise<Image[]> {\n return (await this.query(\n `SELECT * FROM ${this.tableName} WHERE width >= 3840 OR height >= 2160`,\n )) as Image[];\n }\n\n /**\n * Get images by aspect ratio range\n *\n * @param minRatio - Minimum aspect ratio (width/height)\n * @param maxRatio - Maximum aspect ratio (width/height)\n * @returns Array of images within the aspect ratio range\n */\n async getByAspectRatio(minRatio: number, maxRatio: number): Promise<Image[]> {\n // Use computed ratio in SQL: (CAST(width AS REAL) / NULLIF(height, 0))\n return (await this.query(\n `SELECT * FROM ${this.tableName}\n WHERE height > 0\n AND (CAST(width AS REAL) / height) >= ?\n AND (CAST(width AS REAL) / height) <= ?`,\n [minRatio, maxRatio],\n )) as Image[];\n }\n}\n","/**\n * ImageMetadataExtractor - Extracts metadata from image buffers\n *\n * Uses @happyvertical/images for dimension and format extraction.\n */\n\nimport type { Image } from './image';\nimport type { ImageMetadataResult } from './types';\n\nexport class ImageMetadataExtractor {\n /**\n * Extract metadata from an image buffer\n *\n * @param buffer - Raw image data\n * @returns Extracted metadata including dimensions and format\n */\n async extract(buffer: Buffer): Promise<ImageMetadataResult> {\n const { getDimensions, getImageMetadata } = await import(\n '@happyvertical/images'\n );\n\n const dimensions = await getDimensions(buffer);\n const metadata = await getImageMetadata(buffer);\n\n return {\n width: dimensions.width,\n height: dimensions.height,\n format: metadata.format ?? '',\n mimeType: metadata.format ? `image/${metadata.format}` : 'image/unknown',\n exif: metadata.exif as Record<string, unknown> | undefined,\n };\n }\n\n /**\n * Extract metadata and apply it to an Image instance\n *\n * @param image - The Image instance to update\n * @param buffer - Raw image data\n */\n async extractAndApply(image: Image, buffer: Buffer): Promise<void> {\n const result = await this.extract(buffer);\n image.width = result.width;\n image.height = result.height;\n if (result.mimeType) image.mimeType = result.mimeType;\n }\n}\n","/**\n * ImageSearch - AI-powered image search\n *\n * Provides text-based and semantic search across image collections\n * with optional AI-powered similarity matching.\n */\n\nimport type { AIClientOptions } from '@happyvertical/ai';\nimport type { Image } from './image';\nimport type { ImageCollection } from './images';\nimport type { ImageSearchOptions } from './types';\n\nexport class ImageSearch {\n constructor(\n private readonly collection: ImageCollection,\n readonly _options: { ai?: AIClientOptions } = {},\n ) {}\n\n /**\n * Search images by text query with optional dimension/orientation filters\n *\n * @param query - Text search query\n * @param searchOptions - Optional filters for dimensions, orientation, etc.\n * @returns Matching images\n */\n async search(\n query: string,\n searchOptions: ImageSearchOptions = {},\n ): Promise<Image[]> {\n // Build where clause from dimension filters\n const where: Record<string, unknown> = {};\n\n if (searchOptions.minWidth) where['width >='] = searchOptions.minWidth;\n if (searchOptions.minHeight) where['height >='] = searchOptions.minHeight;\n\n // Single DB query with dimension filters; text matching done in-memory\n // to avoid multiple queries and dedup issues\n const fetchLimit = query\n ? (searchOptions.limit ?? 100) * 3\n : searchOptions.limit;\n\n let results = (await this.collection.list({\n where,\n limit: fetchLimit,\n offset: searchOptions.offset,\n })) as Image[];\n\n // Text filter across name, description, and alt\n if (query) {\n const lowerQuery = query.toLowerCase();\n results = results.filter(\n (img) =>\n img.name.toLowerCase().includes(lowerQuery) ||\n img.description?.toLowerCase().includes(lowerQuery) ||\n img.alt?.toLowerCase().includes(lowerQuery),\n );\n }\n\n // Apply orientation filter\n if (searchOptions.orientation) {\n results = results.filter((img) => {\n switch (searchOptions.orientation) {\n case 'landscape':\n return img.isLandscape;\n case 'portrait':\n return img.isPortrait;\n case 'square':\n return img.isSquare;\n default:\n return true;\n }\n });\n }\n\n // Apply final limit\n if (searchOptions.limit && results.length > searchOptions.limit) {\n results = results.slice(0, searchOptions.limit);\n }\n\n return results;\n }\n\n /**\n * Find images similar to a given image\n *\n * @param image - The reference image\n * @param options - Search options\n * @returns Similar images\n */\n async findSimilar(\n image: Image,\n options: { limit?: number } = {},\n ): Promise<Image[]> {\n const limit = options.limit ?? 10;\n\n // Use basic heuristic: same aspect ratio range + similar dimensions\n const ratio = image.aspectRatio;\n const minRatio = ratio * 0.8;\n const maxRatio = ratio * 1.2;\n\n const candidates = await this.collection.getByAspectRatio(\n minRatio,\n maxRatio,\n );\n\n // Exclude the source image and limit results\n return candidates.filter((c) => c.id !== image.id).slice(0, limit);\n }\n\n /**\n * Find images matching a natural language prompt\n *\n * @param prompt - Natural language description of desired images\n * @param options - Search options\n * @returns Matching images\n */\n async findByPrompt(\n prompt: string,\n options: { limit?: number } = {},\n ): Promise<Image[]> {\n // Default to text search; AI enhancement can be added later\n return this.search(prompt, { limit: options.limit });\n }\n}\n","/**\n * UpstreamManager - Manages importing images from upstream sources\n *\n * Searches across configured upstream source adapters and imports\n * assets into the local store with provenance tracking.\n */\n\nimport type { AssetStore } from '@happyvertical/smrt-assets';\nimport type { Image } from './image';\nimport type { ImageCollection } from './images';\n\n/**\n * Minimal adapter interface for upstream asset sources.\n * Full implementation lives in @happyvertical/assets (SDK package).\n */\nexport interface AssetSourceAdapter {\n readonly name: string;\n readonly capabilities: {\n search: boolean;\n download: boolean;\n browse: boolean;\n };\n search(\n query: string,\n options?: { limit?: number; offset?: number },\n ): Promise<SourceAsset[]>;\n get(externalId: string): Promise<SourceAsset | null>;\n download(\n externalId: string,\n ): Promise<{ data: Buffer; metadata: SourceAssetMetadata }>;\n}\n\nexport interface SourceAsset {\n externalId: string;\n sourceName: string;\n name: string;\n mimeType: string;\n previewUrl?: string;\n thumbnailUrl?: string;\n metadata: SourceAssetMetadata;\n}\n\nexport interface SourceAssetMetadata {\n width?: number;\n height?: number;\n description?: string;\n tags?: string[];\n license?: string;\n attribution?: string;\n}\n\nexport class UpstreamManager {\n constructor(\n private readonly sources: AssetSourceAdapter[],\n private readonly store: AssetStore,\n private readonly collection: ImageCollection,\n ) {}\n\n /**\n * Search across all configured upstream sources\n *\n * @param query - Search query\n * @param options - Search options\n * @returns Merged and ranked results from all sources\n */\n async search(\n query: string,\n options: { limit?: number } = {},\n ): Promise<SourceAsset[]> {\n const limit = options.limit ?? 20;\n const allResults: SourceAsset[] = [];\n\n const searches = this.sources\n .filter((s) => s.capabilities.search)\n .map((source) =>\n source.search(query, { limit }).catch(() => [] as SourceAsset[]),\n );\n\n const results = await Promise.all(searches);\n for (const sourceResults of results) {\n allResults.push(...sourceResults);\n }\n\n return allResults.slice(0, limit);\n }\n\n /**\n * Import an asset from an upstream source into the local store\n *\n * @param sourceAsset - The upstream asset to import\n * @returns Locally stored Image with provenance\n */\n async import(sourceAsset: SourceAsset): Promise<Image> {\n // Find the source adapter\n const adapter = this.sources.find((s) => s.name === sourceAsset.sourceName);\n if (!adapter) {\n throw new Error(`No adapter found for source: ${sourceAsset.sourceName}`);\n }\n\n // Download from upstream\n const { data, metadata } = await adapter.download(sourceAsset.externalId);\n\n // Create the Image record with provenance\n const image = (await this.collection.create({\n name: sourceAsset.name,\n sourceUri: '',\n mimeType: sourceAsset.mimeType,\n width: metadata.width ?? 0,\n height: metadata.height ?? 0,\n alt: metadata.description ?? '',\n description: metadata.attribution\n ? `${metadata.description ?? ''} (${metadata.attribution})`\n : (metadata.description ?? ''),\n sourceType: sourceAsset.sourceName,\n externalId: sourceAsset.externalId,\n typeSlug: 'image',\n })) as Image;\n\n // Write file data for the existing record\n const sourceUri = await this.store.storeFile(image, data, {\n mimeType: sourceAsset.mimeType,\n typeSlug: 'image',\n });\n image.sourceUri = sourceUri;\n await image.save();\n\n return image;\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sources":["../src/__smrt-register__.ts","../src/prompts.ts","../src/categorizer.ts","../src/deriver.ts","../src/editor.ts","../src/image.ts","../src/images.ts","../src/metadata.ts","../src/search.ts","../src/upstream.ts"],"sourcesContent":["/**\n * Self-registers this package's build-time manifest before any @smrt() decorator\n * in the package fires. Fixes issue #1132: in consumer runtimes (tsx, SvelteKit\n * SSR, plain `vite dev`) the decorator's synchronous manifest lookup previously\n * missed because no step populated the global manifest cache — classes got\n * registered with zero fields and `save()` / `toJSON()` silently dropped every\n * declared property.\n *\n * Import this module as the first statement in `src/index.ts` so its top-level\n * side effect runs ahead of any class module's @smrt() decorator.\n *\n * Silent no-op in dev/test, where the vitest plugin already populates manifests\n * via a different path. Only needs to succeed in the published dist output.\n *\n * @see https://github.com/happyvertical/smrt/issues/1132\n */\nimport { ObjectRegistry } from '@happyvertical/smrt-core';\n\n// `new URL('./manifest.json', import.meta.url)` resolves at runtime to the\n// manifest sitting next to this module's compiled output. Vite warns at build\n// time that it cannot pre-resolve the URL; that is the intended behavior —\n// the URL must resolve to dist/manifest.json at runtime, not be inlined.\nObjectRegistry.registerPackageManifest(\n new URL('./manifest.json', import.meta.url),\n);\n","/**\n * Prompt registrations for the @happyvertical/smrt-images package.\n *\n * Prompts are registered at module-load time via `definePrompt()` so that\n * tenant-aware overrides can be applied at call time via `resolvePrompt()`.\n *\n * Mirrors the pattern used by `@happyvertical/smrt-profiles` (see\n * `prompts.ts`) and `@happyvertical/smrt-properties` (see `prompts.ts`).\n */\n\nimport {\n definePrompt,\n type ResolvedPromptAI,\n} from '@happyvertical/smrt-prompts';\n\n// Alt text generation only uses non-PII metadata describing the image\n// itself (name and description). Source URIs, internal foreign-key\n// fields (e.g. sourceAssetId), and the extensible `metadata` blob are\n// intentionally NOT passed to the AI provider — source URIs may embed\n// signed/private bucket paths, and metadata may contain EXIF GPS data\n// or tenant-private configuration. If a downstream tenant needs richer\n// context they can override the template via PromptOverride.\nexport const smrtImagesGenerateAltTextPrompt = definePrompt({\n key: 'smrtImages.image.generateAltText',\n template: `Generate concise accessibility alt text for this image.\nConsider: subject matter, key visual elements, context.\nKeep it under 125 characters for screen reader compatibility.\n\nImage name: {imageName}\nImage description: {imageDescription}\n\nReturn only the alt text, with no commentary or surrounding quotation marks.`,\n editable: {\n template: true,\n profile: true,\n model: true,\n params: true,\n },\n});\n\nexport function promptMessageOptions(ai: ResolvedPromptAI) {\n return {\n ...(ai.params || {}),\n ...(ai.model ? { model: ai.model } : {}),\n ...(typeof ai.temperature === 'number'\n ? { temperature: ai.temperature }\n : {}),\n ...(typeof ai.maxTokens === 'number' ? { maxTokens: ai.maxTokens } : {}),\n };\n}\n","/**\n * ImageCategorizer - AI-powered image categorization\n *\n * Uses @happyvertical/ai to analyze image content and suggest\n * tags, descriptions, and subject classifications.\n */\n\nimport type { AIClientOptions } from '@happyvertical/ai';\nimport type { AssetCollection } from '@happyvertical/smrt-assets';\nimport type { Image } from './image';\nimport type { CategoryResult } from './types';\n\n/**\n * Extract the first balanced top-level JSON object substring from arbitrary\n * model output. Unlike a greedy `/\\{[\\s\\S]*\\}/` (which spans from the first\n * `{` to the *last* `}` and so swallows trailing prose or a second object,\n * producing invalid JSON), this scans brace depth from the first `{` and stops\n * at its matching `}`, skipping braces that appear inside string literals.\n *\n * @returns the balanced `{...}` substring, or `null` if none is found.\n */\nfunction extractFirstJsonObject(text: string): string | null {\n const start = text.indexOf('{');\n if (start === -1) return null;\n\n let depth = 0;\n let inString = false;\n let escaped = false;\n\n for (let i = start; i < text.length; i++) {\n const ch = text[i];\n\n if (inString) {\n if (escaped) {\n escaped = false;\n } else if (ch === '\\\\') {\n escaped = true;\n } else if (ch === '\"') {\n inString = false;\n }\n continue;\n }\n\n if (ch === '\"') {\n inString = true;\n } else if (ch === '{') {\n depth++;\n } else if (ch === '}') {\n depth--;\n if (depth === 0) {\n return text.slice(start, i + 1);\n }\n }\n }\n\n return null;\n}\n\n/**\n * Coerce an arbitrary parsed value into a well-formed `CategoryResult`,\n * defaulting each field so a malformed or partial AI response can never\n * produce a non-iterable `tags`/`subjects` or a missing `description`.\n */\nfunction normalizeCategoryResult(\n parsed: unknown,\n fallbackDescription: string,\n): CategoryResult {\n const p = (parsed ?? {}) as Record<string, unknown>;\n return {\n tags: Array.isArray(p.tags) ? (p.tags as string[]) : [],\n description:\n typeof p.description === 'string' && p.description\n ? p.description\n : fallbackDescription,\n confidence: typeof p.confidence === 'number' ? p.confidence : 0,\n subjects: Array.isArray(p.subjects) ? (p.subjects as string[]) : [],\n };\n}\n\nexport class ImageCategorizer {\n constructor(private readonly options: { ai: AIClientOptions }) {}\n\n /**\n * Categorize an image using AI vision analysis\n *\n * @param image - The Image instance to categorize\n * @param buffer - Optional raw image data for vision analysis\n * @returns Categorization results with tags, description, and subjects\n */\n async categorize(image: Image, buffer?: Buffer): Promise<CategoryResult> {\n const { getAI } = await import('@happyvertical/ai');\n const ai = await getAI(this.options.ai);\n\n // TODO: When AI vision API is available, pass buffer for visual analysis\n void buffer;\n\n const prompt = `Analyze this image and provide categorization.\nImage name: ${image.name}\nImage description: ${image.description}\nMIME type: ${image.mimeType}\nDimensions: ${image.width}x${image.height}\n\nRespond in JSON format:\n{\n \"tags\": [\"tag1\", \"tag2\", ...],\n \"description\": \"Brief description of the image content\",\n \"confidence\": 0.0-1.0,\n \"subjects\": [\"subject1\", \"subject2\", ...]\n}`;\n\n const response = await ai.chat([{ role: 'user', content: prompt }]);\n const text = response.content;\n\n const fallbackDescription = image.description || image.name;\n\n const jsonText = extractFirstJsonObject(text);\n if (jsonText) {\n try {\n return normalizeCategoryResult(\n JSON.parse(jsonText),\n fallbackDescription,\n );\n } catch {\n // Malformed JSON — fall through to default below.\n }\n }\n\n return {\n tags: [],\n description: fallbackDescription,\n confidence: 0,\n subjects: [],\n };\n }\n\n /**\n * Run categorization and apply results to the image\n *\n * @param image - The Image to categorize and update\n * @param assetCollection - AssetCollection for tag management\n */\n async autoTag(image: Image, assetCollection: AssetCollection): Promise<void> {\n const result = await this.categorize(image);\n\n if (result.description && !image.description) {\n image.description = result.description;\n }\n\n if (!image.alt && result.description) {\n image.alt = result.description.slice(0, 125);\n }\n\n await image.save();\n\n // Add tags via the asset collection. Guard against a non-array `tags`\n // (e.g. a hand-built CategoryResult or future code path that bypasses\n // `normalizeCategoryResult`) so the loop never throws \"not iterable\".\n for (const tag of result.tags ?? []) {\n await assetCollection.addTag(image.id!, tag);\n }\n }\n}\n","/**\n * ImageDeriver - Creates new images from source images + AI prompts\n *\n * Combines multiple source images with creative prompts to generate\n * new derivative images, tracking provenance via AssetAssociation.\n */\n\nimport type { AIClientOptions } from '@happyvertical/ai';\nimport type {\n AssetAssociationCollection,\n AssetStore,\n} from '@happyvertical/smrt-assets';\nimport type { Image } from './image';\nimport type { ImageCollection } from './images';\nimport type { DeriveOptions } from './types';\n\nexport class ImageDeriver {\n constructor(\n private readonly store: AssetStore,\n private readonly collection: ImageCollection,\n private readonly options: { ai: AIClientOptions },\n ) {}\n\n /**\n * Derive new images from source images and a creative prompt\n *\n * @param sources - One or more source images\n * @param prompt - Creative instructions for generation\n * @param deriveOptions - Generation options (count, size, style)\n * @returns Array of newly created derivative Images\n */\n async derive(\n sources: Image[],\n prompt: string,\n deriveOptions: DeriveOptions = {},\n ): Promise<Image[]> {\n if (sources.length === 0) {\n throw new Error('At least one source image is required');\n }\n\n const { getAI } = await import('@happyvertical/ai');\n const ai = await getAI(this.options.ai);\n\n const count = deriveOptions.count ?? 1;\n const results: Image[] = [];\n\n const fullPrompt = [\n prompt,\n deriveOptions.style ? `Style: ${deriveOptions.style}` : '',\n deriveOptions.size ? `Output size: ${deriveOptions.size}` : '',\n `Source images: ${sources.map((s) => s.name).join(', ')}`,\n ]\n .filter(Boolean)\n .join('\\n');\n\n for (let i = 0; i < count; i++) {\n const response = await ai.generateImage(fullPrompt, {\n size: deriveOptions.size,\n });\n\n const imageData = response.images[0]?.data;\n if (!imageData || !(imageData instanceof Buffer)) {\n throw new Error('AI did not return image data as Buffer');\n }\n const result = imageData;\n\n const derived = (await this.collection.create({\n name: `derived-${sources[0].name}-${i + 1}`,\n mimeType: 'image/png',\n sourceUri: '',\n sourceAssetId: sources[0].id,\n typeSlug: 'image',\n description: `Derived: ${prompt}`,\n })) as Image;\n\n const sourceUri = await this.store.storeFile(derived, result, {\n mimeType: 'image/png',\n typeSlug: 'image',\n });\n derived.sourceUri = sourceUri;\n await derived.save();\n\n results.push(derived);\n }\n\n return results;\n }\n\n /**\n * Derive images and link all sources via AssetAssociation\n *\n * @param sources - Source images\n * @param prompt - Creative instructions\n * @param associations - AssetAssociationCollection for linking\n * @param deriveOptions - Generation options\n * @returns Array of newly created derivative Images\n */\n async deriveWithAssociations(\n sources: Image[],\n prompt: string,\n associations: AssetAssociationCollection,\n deriveOptions: DeriveOptions = {},\n ): Promise<Image[]> {\n const results = await this.derive(sources, prompt, deriveOptions);\n\n // Link all source images to each derived image\n for (const derived of results) {\n for (const source of sources) {\n await associations.attach('Image', derived.id!, source.id!, {\n role: 'derivation-source',\n });\n }\n }\n\n return results;\n }\n}\n","/**\n * ImageEditor - Standard and AI-powered image editing\n *\n * Creates derivative assets for each edit operation, preserving\n * the original image and linking via sourceAssetId (the derivation\n * pointer; renamed from `parentId` in R3-D).\n *\n * Standard operations use @happyvertical/images (file-based API).\n * AI operations use @happyvertical/ai.\n */\n\nimport { randomUUID } from 'node:crypto';\nimport { readFile, unlink, writeFile } from 'node:fs/promises';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport type { AIClientOptions } from '@happyvertical/ai';\nimport type { ImageFormat } from '@happyvertical/images';\nimport type { AssetStore } from '@happyvertical/smrt-assets';\nimport type { Image } from './image';\nimport type { ImageCollection } from './images';\n\n/**\n * Output formats `convert()` accepts. Mirrors the `ImageFormat` union from\n * `@happyvertical/images`. Used as a runtime allowlist so a caller-supplied\n * `format` string can never escape this set.\n */\nconst ALLOWED_CONVERT_FORMATS = new Set<ImageFormat>([\n 'jpeg',\n 'png',\n 'webp',\n 'avif',\n 'gif',\n 'tiff',\n]);\n\nexport class ImageEditor {\n constructor(\n private readonly store: AssetStore,\n private readonly collection: ImageCollection,\n private readonly options: { ai?: AIClientOptions } = {},\n ) {}\n\n /**\n * Resize an image to the specified dimensions\n *\n * @param image - Source image\n * @param width - Target width\n * @param height - Target height\n * @returns New derivative Image\n */\n async resize(image: Image, width: number, height: number): Promise<Image> {\n const { resizeImage } = await import('@happyvertical/images');\n const sourceData = await this.store.read(image);\n\n const inputPath = join(tmpdir(), `smrt-edit-in-${randomUUID()}.bin`);\n const outputPath = join(tmpdir(), `smrt-edit-out-${randomUUID()}.bin`);\n\n try {\n await writeFile(inputPath, sourceData);\n await resizeImage(inputPath, outputPath, { width, height });\n const resized = await readFile(outputPath);\n\n return this.createDerivative(image, resized, {\n name: `${image.name}-${width}x${height}`,\n width,\n height,\n description: `Resized from ${image.width}x${image.height} to ${width}x${height}`,\n });\n } finally {\n await unlink(inputPath).catch(() => {});\n await unlink(outputPath).catch(() => {});\n }\n }\n\n /**\n * Crop an image to the specified region\n *\n * @param image - Source image\n * @param x - Left offset\n * @param y - Top offset\n * @param w - Crop width\n * @param h - Crop height\n * @returns New derivative Image\n */\n async crop(\n image: Image,\n x: number,\n y: number,\n w: number,\n h: number,\n ): Promise<Image> {\n const { getImageProcessor } = await import('@happyvertical/images');\n const processor = await getImageProcessor();\n const sourceData = await this.store.read(image);\n\n const inputPath = join(tmpdir(), `smrt-crop-in-${randomUUID()}.bin`);\n const outputPath = join(tmpdir(), `smrt-crop-out-${randomUUID()}.bin`);\n\n try {\n await writeFile(inputPath, sourceData);\n // TODO: @happyvertical/images does not yet expose a direct x,y extract API.\n // The x/y offset params are accepted but unused — this is a resize-with-cover-fit,\n // not a true x,y crop. When the `extract(x, y, w, h)` API is available, replace this.\n // Tracked in: https://github.com/happyvertical/smrt/issues/TODO\n await processor.resize(inputPath, outputPath, {\n width: w,\n height: h,\n fit: 'cover',\n });\n const cropped = await readFile(outputPath);\n\n return this.createDerivative(image, cropped, {\n name: `${image.name}-crop`,\n width: w,\n height: h,\n description: `Cropped region ${x},${y} ${w}x${h}`,\n });\n } finally {\n await unlink(inputPath).catch(() => {});\n await unlink(outputPath).catch(() => {});\n }\n }\n\n /**\n * Convert an image to a different format\n *\n * @param image - Source image\n * @param format - Target format (e.g., 'webp', 'png', 'jpeg')\n * @returns New derivative Image\n */\n async convert(image: Image, format: string): Promise<Image> {\n // Validate against a fixed allowlist BEFORE the value is interpolated into\n // a filesystem path. `format` is caller-controlled (e.g. an HTTP request\n // body via `ImageConvertRequest`) and is appended as the output temp\n // file's extension; an unchecked value like `../../../etc/cron.d/x` would\n // escape `tmpdir()` and let an attacker write the converted bytes to an\n // arbitrary location (path-traversal write). The static `as ImageFormat`\n // cast below gives zero runtime protection, so guard explicitly here.\n const normalizedFormat = format.trim().toLowerCase();\n if (!ALLOWED_CONVERT_FORMATS.has(normalizedFormat as ImageFormat)) {\n throw new Error(\n `Unsupported image format: ${JSON.stringify(format)}. ` +\n `Allowed formats: ${[...ALLOWED_CONVERT_FORMATS].join(', ')}`,\n );\n }\n const safeFormat = normalizedFormat as ImageFormat;\n\n const { convertFormat } = await import('@happyvertical/images');\n const sourceData = await this.store.read(image);\n const mimeType = `image/${safeFormat}`;\n\n const inputPath = join(tmpdir(), `smrt-conv-in-${randomUUID()}.bin`);\n const outputPath = join(\n tmpdir(),\n `smrt-conv-out-${randomUUID()}.${safeFormat}`,\n );\n\n try {\n await writeFile(inputPath, sourceData);\n await convertFormat(inputPath, outputPath, {\n format: safeFormat,\n });\n const converted = await readFile(outputPath);\n\n return this.createDerivative(image, converted, {\n name: `${image.name}.${safeFormat}`,\n mimeType,\n description: `Converted from ${image.mimeType} to ${mimeType}`,\n });\n } finally {\n await unlink(inputPath).catch(() => {});\n await unlink(outputPath).catch(() => {});\n }\n }\n\n /**\n * Generate a square thumbnail of the specified size\n *\n * @param image - Source image\n * @param size - Thumbnail dimension (square)\n * @returns New derivative Image\n */\n async thumbnail(image: Image, size: number): Promise<Image> {\n const { generateThumbnail } = await import('@happyvertical/images');\n const sourceData = await this.store.read(image);\n\n const inputPath = join(tmpdir(), `smrt-thumb-in-${randomUUID()}.bin`);\n const outputPath = join(tmpdir(), `smrt-thumb-out-${randomUUID()}.bin`);\n\n try {\n await writeFile(inputPath, sourceData);\n await generateThumbnail(inputPath, outputPath, {\n maxWidth: size,\n maxHeight: size,\n });\n const thumbData = await readFile(outputPath);\n\n return this.createDerivative(image, thumbData, {\n name: `${image.name}-thumb-${size}`,\n width: size,\n height: size,\n description: `Thumbnail ${size}x${size}`,\n });\n } finally {\n await unlink(inputPath).catch(() => {});\n await unlink(outputPath).catch(() => {});\n }\n }\n\n /**\n * AI-powered image generation based on a prompt (creates derivative linked to source)\n *\n * @param image - Source image (used for metadata, linked as parent)\n * @param prompt - Generation instructions (e.g., \"similar image with sunset colors\")\n * @returns New derivative Image\n */\n async edit(image: Image, prompt: string): Promise<Image> {\n if (!this.options.ai) {\n throw new Error('AI options required for AI-powered editing');\n }\n\n const { getAI } = await import('@happyvertical/ai');\n const ai = await getAI(this.options.ai);\n\n const response = await ai.generateImage(prompt, {\n size: `${image.width}x${image.height}`,\n });\n\n const imageData = response.images[0]?.data;\n if (!imageData || !(imageData instanceof Buffer)) {\n throw new Error('AI did not return image data as Buffer');\n }\n\n return this.createDerivative(image, imageData, {\n name: `${image.name}-edited`,\n description: `AI edit: ${prompt}`,\n });\n }\n\n /**\n * Generate variations of an image using AI\n *\n * @param image - Source image\n * @param prompt - Variation instructions\n * @param options - Number of variations to generate\n * @returns Array of new derivative Images\n */\n async generateVariation(\n image: Image,\n prompt: string,\n options: { count?: number } = {},\n ): Promise<Image[]> {\n const count = options.count ?? 1;\n const results: Image[] = [];\n\n for (let i = 0; i < count; i++) {\n const variation = await this.edit(\n image,\n `${prompt} (variation ${i + 1} of ${count})`,\n );\n results.push(variation);\n }\n\n return results;\n }\n\n /**\n * Helper: Create a derivative Image from processed buffer data\n */\n private async createDerivative(\n source: Image,\n data: Buffer,\n overrides: {\n name: string;\n width?: number;\n height?: number;\n mimeType?: string;\n description?: string;\n },\n ): Promise<Image> {\n const mimeType = overrides.mimeType ?? source.mimeType;\n const typeSlug = source.typeSlug || 'image';\n\n // Create only the Image record (not a plain Asset via store.store())\n const derivative = (await this.collection.create({\n name: overrides.name,\n sourceUri: '',\n mimeType,\n width: overrides.width ?? source.width,\n height: overrides.height ?? source.height,\n alt: source.alt,\n sourceAssetId: source.id,\n typeSlug,\n description: overrides.description ?? '',\n })) as Image;\n\n // Write file data for the existing record\n const sourceUri = await this.store.storeFile(derivative, data, {\n mimeType,\n typeSlug,\n });\n derivative.sourceUri = sourceUri;\n await derivative.save();\n\n return derivative;\n }\n}\n","/**\n * Image model - Asset subclass for image files\n *\n * Represents an image asset with dimensions and accessibility text.\n * Uses STI (Single Table Inheritance) - stored in the assets table with _meta_type='Image'.\n *\n * For semantic search on images, use the centralized embedding system:\n * @smrt({ embeddings: { fields: ['alt', 'description'] } })\n */\n\nimport { Asset } from '@happyvertical/smrt-assets';\nimport { field, smrt } from '@happyvertical/smrt-core';\nimport { resolvePrompt } from '@happyvertical/smrt-prompts';\nimport { TenantScoped } from '@happyvertical/smrt-tenancy';\nimport {\n promptMessageOptions,\n smrtImagesGenerateAltTextPrompt,\n} from './prompts';\nimport type { ImageOptions } from './types';\n\n// The @TenantScoped decorator registers only the exact class name it's applied\n// to — it does NOT propagate to STI subclasses. Asset is `@TenantScoped`, but\n// without re-declaring it here `isTenantScopedClass('Image')` is false, so the\n// tenancy interceptor would neither tenant-filter Image's `list()` queries nor\n// guard its raw SQL. Re-declaring it (matching Asset's `mode: 'optional'`)\n// registers Image too, so the interceptor scopes Image collection reads by\n// tenant. The inherited `tenantId` field needs no re-declaration. (#1407)\n@TenantScoped({ mode: 'optional' })\n@smrt({\n api: { include: ['list', 'get', 'create', 'update', 'delete'] },\n mcp: { include: ['list', 'get', 'create', 'update', 'generateAltText'] },\n cli: true,\n})\nexport class Image extends Asset {\n // Core image dimensions (regular columns for querying)\n @field()\n width: number = 0;\n @field()\n height: number = 0;\n\n // Accessibility text\n @field()\n alt: string = '';\n\n constructor(options: ImageOptions = {}) {\n super(options);\n if (options.width !== undefined) this.width = options.width;\n if (options.height !== undefined) this.height = options.height;\n if (options.alt !== undefined) this.alt = options.alt;\n }\n\n /**\n * Calculate aspect ratio from dimensions\n */\n get aspectRatio(): number {\n if (this.height === 0) return 0;\n return this.width / this.height;\n }\n\n /**\n * Helper to get URL from sourceUri for frontend components\n */\n get url(): string {\n return this.sourceUri;\n }\n\n /**\n * Check if dimensions indicate landscape orientation\n */\n get isLandscape(): boolean {\n return this.width > this.height;\n }\n\n /**\n * Check if dimensions indicate portrait orientation\n */\n get isPortrait(): boolean {\n return this.height > this.width;\n }\n\n /**\n * Check if dimensions indicate square aspect ratio\n */\n get isSquare(): boolean {\n return this.width === this.height && this.width > 0;\n }\n\n /**\n * Validate that the asset is an image based on MIME type\n */\n isValidImageFormat(): boolean {\n return this.mimeType.startsWith('image/');\n }\n\n /**\n * Check if this image is high resolution (4K+)\n */\n isHighResolution(): boolean {\n return this.width >= 3840 || this.height >= 2160;\n }\n\n /**\n * AI-powered: Generate accessibility alt text for this image.\n *\n * Uses the `smrtImages.image.generateAltText` prompt registered via\n * `@happyvertical/smrt-prompts`, allowing tenant- or instance-level\n * overrides of the template, model, and parameters at runtime.\n *\n * Only non-PII metadata fields (name, description) are sent to the AI\n * provider. Source URIs, internal foreign-key fields, and the\n * extensible `metadata` blob are intentionally excluded — source URIs\n * may embed signed/private bucket paths and metadata may contain EXIF\n * GPS data or tenant-private configuration.\n *\n * @returns AI-generated alt text describing the image\n */\n async generateAltText(): Promise<string> {\n // Resolve `db` from either the canonical `db` option or its `persistence`\n // alias. SmrtClass maps `persistence → db` lazily during `initialize()`,\n // so on a freshly-constructed Image that has not yet been initialized,\n // `this.options.db` may be undefined while `this.options.persistence` is\n // set. Falling back here ensures stored app- and tenant-level prompt\n // overrides in `_smrt_prompt_overrides` are honored on the first call —\n // before `getAiClient()` triggers full initialization further below.\n const db = this.options.db ?? this.options.persistence;\n\n const resolvedPrompt = await resolvePrompt(\n smrtImagesGenerateAltTextPrompt.key,\n {\n db,\n tenantId: this.tenantId,\n variables: {\n imageName: this.name || '',\n imageDescription: this.description || '',\n },\n },\n );\n\n const ai = await this.getAiClient();\n const response = await ai.message(\n resolvedPrompt.text,\n promptMessageOptions(resolvedPrompt.ai),\n );\n\n const altText = response.trim();\n this.alt = altText;\n return altText;\n }\n}\n","/**\n * ImageCollection - Collection manager for Image instances\n *\n * Provides image-specific query operations, tenant-aware queries, and bulk operations\n */\n\nimport { SmrtCollection } from '@happyvertical/smrt-core';\nimport {\n getCurrentTenant,\n isSuperAdminBypass,\n TenantIsolationError,\n} from '@happyvertical/smrt-tenancy';\nimport { Image } from './image';\n\n/**\n * Qualified STI discriminator for Image rows in the shared `assets` table.\n *\n * Raw SQL on this tenant-scoped collection must scope by `_meta_type` so it\n * never returns sibling Asset subclasses. The orientation/resolution helpers\n * below compare two columns (e.g. `width > height`), which `list()`'s\n * value-based WHERE clause cannot express — so they either filter in-memory\n * after a tenant-/STI-scoped `list()` (mirroring `ImageSearch.search()`) or,\n * for the global-image lookups (`findGlobal`/`findWithGlobals`), run raw SQL\n * with `{ allowRawOnTenantScoped: true }` after manually injecting both the\n * tenant predicate and this `_meta_type`. A literal `tenant_id IS NULL` filter\n * can't go through `list()` either — the interceptor rejects an explicit null\n * tenant filter as an isolation violation.\n */\nconst IMAGE_META_TYPE = '@happyvertical/smrt-images:Image';\n\nexport class ImageCollection extends SmrtCollection<Image> {\n static readonly _itemClass = Image;\n\n // ─────────────────────────────────────────────────────────────────────────────\n // Tenant-Aware Query Methods\n // ─────────────────────────────────────────────────────────────────────────────\n\n /**\n * Find all images belonging to a specific tenant\n *\n * @param tenantId - The tenant ID to filter by\n * @returns Array of images belonging to this tenant\n */\n async findByTenant(tenantId: string): Promise<Image[]> {\n return (await this.list({ where: { tenantId } })) as Image[];\n }\n\n /**\n * Find all global images (images without a tenant)\n *\n * @returns Array of global images\n */\n async findGlobal(): Promise<Image[]> {\n // `list({ where: { tenantId: null } })` throws under an active tenant\n // context: the interceptor flags an explicit null tenant filter as an\n // isolation violation (it cannot tell \"give me shared rows\" from \"give me\n // another tenant's rows\"). Raw SQL with the bypass flag — manually scoped\n // to the Image STI discriminator and `tenant_id IS NULL` — returns global\n // images under any context, mirroring `findWithGlobals`. (#1407)\n return (await this.query(\n `SELECT * FROM ${this.tableName}\n WHERE _meta_type = ?\n AND tenant_id IS NULL`,\n [IMAGE_META_TYPE],\n { allowRawOnTenantScoped: true },\n )) as Image[];\n }\n\n /**\n * Find images belonging to a tenant plus all global images\n *\n * @param tenantId - The tenant ID to include\n * @returns Array of tenant-specific and global images\n */\n async findWithGlobals(tenantId: string): Promise<Image[]> {\n // Intentionally cross-tenant: returns the given tenant's images plus all\n // global (tenant-less) images. `list()` would let the interceptor inject\n // `tenant_id = <currentContext>` and strip the globals, so this stays raw —\n // but we manually scope to the Image STI discriminator and pass the bypass\n // flag since tenant filtering is handled by the explicit predicate here.\n //\n // The raw bypass disables the interceptor's isolation guard, so replicate\n // it: `findByTenant`/`list()` throw when asked for another tenant's rows\n // under an active context, and this must too — otherwise a caller could\n // read another tenant's images by passing an arbitrary `tenantId` (e.g.\n // straight from untrusted params). A system / super-admin-bypass context\n // (no enforced tenant) keeps the deliberate cross-tenant capability for\n // admin callers. (#1400 fail-closed)\n const tenantContext = getCurrentTenant();\n if (\n tenantContext &&\n !isSuperAdminBypass() &&\n tenantContext.tenantId !== tenantId\n ) {\n throw new TenantIsolationError(\n `Tenant isolation violation in Image.findWithGlobals: context tenant ` +\n `is '${tenantContext.tenantId}' but query requested '${tenantId}'`,\n { tenantId: tenantContext.tenantId, attemptedTenantId: tenantId },\n );\n }\n return (await this.query(\n `SELECT * FROM ${this.tableName}\n WHERE _meta_type = ?\n AND (tenant_id = ? OR tenant_id IS NULL)`,\n [IMAGE_META_TYPE, tenantId],\n { allowRawOnTenantScoped: true },\n )) as Image[];\n }\n\n /**\n * Get images by minimum dimensions\n *\n * @param minWidth - Minimum width in pixels\n * @param minHeight - Minimum height in pixels\n * @returns Array of images meeting minimum dimension requirements\n */\n async getByMinDimensions(\n minWidth: number,\n minHeight: number,\n ): Promise<Image[]> {\n return (await this.list({\n where: {\n 'width >=': minWidth,\n 'height >=': minHeight,\n },\n })) as Image[];\n }\n\n /**\n * Get images by maximum dimensions\n *\n * @param maxWidth - Maximum width in pixels\n * @param maxHeight - Maximum height in pixels\n * @returns Array of images within maximum dimension limits\n */\n async getByMaxDimensions(\n maxWidth: number,\n maxHeight: number,\n ): Promise<Image[]> {\n return (await this.list({\n where: {\n 'width <=': maxWidth,\n 'height <=': maxHeight,\n },\n })) as Image[];\n }\n\n /**\n * Get landscape images (width > height)\n *\n * @returns Array of landscape-oriented images\n */\n async getLandscape(): Promise<Image[]> {\n // `width > height` is a column-to-column comparison `list()`'s WHERE can't\n // express, so list (tenant- + STI-scoped) then filter in-memory via the\n // `isLandscape` computed property — same pattern as ImageSearch.search().\n const all = (await this.list({})) as Image[];\n return all.filter((image) => image.isLandscape);\n }\n\n /**\n * Get portrait images (height > width)\n *\n * @returns Array of portrait-oriented images\n */\n async getPortrait(): Promise<Image[]> {\n // Column-to-column (`height > width`): list (tenant-/STI-scoped) then\n // filter in-memory via the `isPortrait` computed property.\n const all = (await this.list({})) as Image[];\n return all.filter((image) => image.isPortrait);\n }\n\n /**\n * Get square images (width === height)\n *\n * @returns Array of square images\n */\n async getSquare(): Promise<Image[]> {\n // Column-to-column (`width = height AND width > 0`): list (tenant-/STI-\n // scoped) then filter in-memory. `isSquare` already encodes the `width > 0`\n // guard, so zero-dimension placeholders are excluded.\n const all = (await this.list({})) as Image[];\n return all.filter((image) => image.isSquare);\n }\n\n /**\n * Get images missing alt text\n *\n * @returns Array of images without accessibility text\n */\n async getMissingAltText(): Promise<Image[]> {\n return (await this.list({\n where: { alt: '' },\n })) as Image[];\n }\n\n /**\n * Get high resolution images (4K+)\n *\n * @returns Array of high resolution images\n */\n async getHighResolution(): Promise<Image[]> {\n // `width >= 3840 OR height >= 2160` needs an OR across two columns, which\n // `list()`'s AND-only WHERE can't express. List (tenant-/STI-scoped) then\n // filter in-memory via the `isHighResolution()` helper.\n const all = (await this.list({})) as Image[];\n return all.filter((image) => image.isHighResolution());\n }\n\n /**\n * Get images by aspect ratio range\n *\n * @param minRatio - Minimum aspect ratio (width/height)\n * @param maxRatio - Maximum aspect ratio (width/height)\n * @returns Array of images within the aspect ratio range\n */\n async getByAspectRatio(minRatio: number, maxRatio: number): Promise<Image[]> {\n // The aspect-ratio predicate is computed from two columns, which `list()`'s\n // WHERE can't express. List (tenant-/STI-scoped) then filter in-memory via\n // the `aspectRatio` computed property. `aspectRatio` returns 0 when height\n // is 0, so the `height > 0` guard preserves the original semantics for any\n // sensible positive ratio range.\n const all = (await this.list({})) as Image[];\n return all.filter(\n (image) =>\n image.height > 0 &&\n image.aspectRatio >= minRatio &&\n image.aspectRatio <= maxRatio,\n );\n }\n}\n","/**\n * ImageMetadataExtractor - Extracts metadata from image buffers\n *\n * Uses @happyvertical/images for dimension and format extraction.\n */\n\nimport type { Image } from './image';\nimport type { ImageMetadataResult } from './types';\n\nexport class ImageMetadataExtractor {\n /**\n * Extract metadata from an image buffer\n *\n * @param buffer - Raw image data\n * @returns Extracted metadata including dimensions and format\n */\n async extract(buffer: Buffer): Promise<ImageMetadataResult> {\n const { getDimensions, getImageMetadata } = await import(\n '@happyvertical/images'\n );\n\n const dimensions = await getDimensions(buffer);\n const metadata = await getImageMetadata(buffer);\n\n return {\n width: dimensions.width,\n height: dimensions.height,\n format: metadata.format ?? '',\n mimeType: metadata.format ? `image/${metadata.format}` : 'image/unknown',\n exif: metadata.exif as Record<string, unknown> | undefined,\n };\n }\n\n /**\n * Extract metadata and apply it to an Image instance\n *\n * @param image - The Image instance to update\n * @param buffer - Raw image data\n */\n async extractAndApply(image: Image, buffer: Buffer): Promise<void> {\n const result = await this.extract(buffer);\n image.width = result.width;\n image.height = result.height;\n if (result.mimeType) image.mimeType = result.mimeType;\n }\n}\n","/**\n * ImageSearch - AI-powered image search\n *\n * Provides text-based and semantic search across image collections\n * with optional AI-powered similarity matching.\n */\n\nimport type { AIClientOptions } from '@happyvertical/ai';\nimport type { Image } from './image';\nimport type { ImageCollection } from './images';\nimport type { ImageSearchOptions } from './types';\n\nexport class ImageSearch {\n constructor(\n private readonly collection: ImageCollection,\n readonly _options: { ai?: AIClientOptions } = {},\n ) {}\n\n /**\n * Search images by text query with optional dimension/orientation filters\n *\n * @param query - Text search query\n * @param searchOptions - Optional filters for dimensions, orientation, etc.\n * @returns Matching images\n */\n async search(\n query: string,\n searchOptions: ImageSearchOptions = {},\n ): Promise<Image[]> {\n // Build where clause from dimension filters\n const where: Record<string, unknown> = {};\n\n if (searchOptions.minWidth) where['width >='] = searchOptions.minWidth;\n if (searchOptions.minHeight) where['height >='] = searchOptions.minHeight;\n\n // Single DB query with dimension filters; text matching done in-memory\n // to avoid multiple queries and dedup issues\n const fetchLimit = query\n ? (searchOptions.limit ?? 100) * 3\n : searchOptions.limit;\n\n let results = (await this.collection.list({\n where,\n limit: fetchLimit,\n offset: searchOptions.offset,\n })) as Image[];\n\n // Text filter across name, description, and alt\n if (query) {\n const lowerQuery = query.toLowerCase();\n results = results.filter(\n (img) =>\n img.name.toLowerCase().includes(lowerQuery) ||\n img.description?.toLowerCase().includes(lowerQuery) ||\n img.alt?.toLowerCase().includes(lowerQuery),\n );\n }\n\n // Apply orientation filter\n if (searchOptions.orientation) {\n results = results.filter((img) => {\n switch (searchOptions.orientation) {\n case 'landscape':\n return img.isLandscape;\n case 'portrait':\n return img.isPortrait;\n case 'square':\n return img.isSquare;\n default:\n return true;\n }\n });\n }\n\n // Apply final limit\n if (searchOptions.limit && results.length > searchOptions.limit) {\n results = results.slice(0, searchOptions.limit);\n }\n\n return results;\n }\n\n /**\n * Find images similar to a given image\n *\n * @param image - The reference image\n * @param options - Search options\n * @returns Similar images\n */\n async findSimilar(\n image: Image,\n options: { limit?: number } = {},\n ): Promise<Image[]> {\n const limit = options.limit ?? 10;\n\n // Use basic heuristic: same aspect ratio range + similar dimensions\n const ratio = image.aspectRatio;\n const minRatio = ratio * 0.8;\n const maxRatio = ratio * 1.2;\n\n const candidates = await this.collection.getByAspectRatio(\n minRatio,\n maxRatio,\n );\n\n // Exclude the source image and limit results\n return candidates.filter((c) => c.id !== image.id).slice(0, limit);\n }\n\n /**\n * Find images matching a natural language prompt\n *\n * @param prompt - Natural language description of desired images\n * @param options - Search options\n * @returns Matching images\n */\n async findByPrompt(\n prompt: string,\n options: { limit?: number } = {},\n ): Promise<Image[]> {\n // Default to text search; AI enhancement can be added later\n return this.search(prompt, { limit: options.limit });\n }\n}\n","/**\n * UpstreamManager - Manages importing images from upstream sources\n *\n * Searches across configured upstream source adapters and imports\n * assets into the local store with provenance tracking.\n */\n\nimport type { AssetStore } from '@happyvertical/smrt-assets';\nimport type { Image } from './image';\nimport type { ImageCollection } from './images';\n\n/**\n * Minimal adapter interface for upstream asset sources.\n * Full implementation lives in @happyvertical/assets (SDK package).\n */\nexport interface AssetSourceAdapter {\n readonly name: string;\n readonly capabilities: {\n search: boolean;\n download: boolean;\n browse: boolean;\n };\n search(\n query: string,\n options?: { limit?: number; offset?: number },\n ): Promise<SourceAsset[]>;\n get(externalId: string): Promise<SourceAsset | null>;\n download(\n externalId: string,\n ): Promise<{ data: Buffer; metadata: SourceAssetMetadata }>;\n}\n\nexport interface SourceAsset {\n externalId: string;\n sourceName: string;\n name: string;\n mimeType: string;\n previewUrl?: string;\n thumbnailUrl?: string;\n metadata: SourceAssetMetadata;\n}\n\nexport interface SourceAssetMetadata {\n width?: number;\n height?: number;\n description?: string;\n tags?: string[];\n license?: string;\n attribution?: string;\n}\n\nexport class UpstreamManager {\n constructor(\n private readonly sources: AssetSourceAdapter[],\n private readonly store: AssetStore,\n private readonly collection: ImageCollection,\n ) {}\n\n /**\n * Search across all configured upstream sources\n *\n * @param query - Search query\n * @param options - Search options\n * @returns Merged and ranked results from all sources\n */\n async search(\n query: string,\n options: { limit?: number } = {},\n ): Promise<SourceAsset[]> {\n const limit = options.limit ?? 20;\n const allResults: SourceAsset[] = [];\n\n const searches = this.sources\n .filter((s) => s.capabilities.search)\n .map((source) =>\n source.search(query, { limit }).catch(() => [] as SourceAsset[]),\n );\n\n const results = await Promise.all(searches);\n for (const sourceResults of results) {\n allResults.push(...sourceResults);\n }\n\n return allResults.slice(0, limit);\n }\n\n /**\n * Import an asset from an upstream source into the local store\n *\n * @param sourceAsset - The upstream asset to import\n * @returns Locally stored Image with provenance\n */\n async import(sourceAsset: SourceAsset): Promise<Image> {\n // Find the source adapter\n const adapter = this.sources.find((s) => s.name === sourceAsset.sourceName);\n if (!adapter) {\n throw new Error(`No adapter found for source: ${sourceAsset.sourceName}`);\n }\n\n // Download from upstream\n const { data, metadata } = await adapter.download(sourceAsset.externalId);\n\n // Create the Image record with provenance\n const image = (await this.collection.create({\n name: sourceAsset.name,\n sourceUri: '',\n mimeType: sourceAsset.mimeType,\n width: metadata.width ?? 0,\n height: metadata.height ?? 0,\n alt: metadata.description ?? '',\n description: metadata.attribution\n ? `${metadata.description ?? ''} (${metadata.attribution})`\n : (metadata.description ?? ''),\n sourceType: sourceAsset.sourceName,\n externalId: sourceAsset.externalId,\n typeSlug: 'image',\n })) as Image;\n\n // Write file data for the existing record\n const sourceUri = await this.store.storeFile(image, data, {\n mimeType: sourceAsset.mimeType,\n typeSlug: 'image',\n });\n image.sourceUri = sourceUri;\n await image.save();\n\n return image;\n }\n}\n"],"names":[],"mappings":"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"}
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "version": "1.0.0",
3
- "timestamp": 1782192586159,
3
+ "timestamp": 1782206294036,
4
4
  "packageName": "@happyvertical/smrt-images",
5
- "packageVersion": "0.31.1",
5
+ "packageVersion": "0.32.1",
6
6
  "objects": {
7
7
  "@happyvertical/smrt-images:Image": {
8
8
  "name": "image",
@@ -24,16 +24,15 @@
24
24
  "type": "text",
25
25
  "required": false,
26
26
  "_meta": {
27
+ "generated": true,
28
+ "source": "tenantScoped_decorator",
27
29
  "sqlType": "UUID",
28
- "nullable": true,
29
30
  "__tenancy": {
30
31
  "isTenantIdField": true,
31
- "autoFilter": true,
32
- "required": false,
33
- "autoPopulate": true,
34
- "nullable": true,
35
32
  "mode": "optional",
36
33
  "field": "tenantId",
34
+ "autoFilter": true,
35
+ "autoPopulate": true,
37
36
  "allowSuperAdminBypass": false
38
37
  }
39
38
  }
@@ -807,6 +806,9 @@
807
806
  ]
808
807
  },
809
808
  "cli": true,
809
+ "tenantScoped": {
810
+ "mode": "optional"
811
+ },
810
812
  "tableName": "assets",
811
813
  "tableStrategy": "sti"
812
814
  },
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-06-23T05:29:46.475Z",
3
+ "generatedAt": "2026-06-23T09:18:14.365Z",
4
4
  "packageName": "@happyvertical/smrt-images",
5
- "packageVersion": "0.31.1",
5
+ "packageVersion": "0.32.1",
6
6
  "sourceManifestPath": "dist/manifest.json",
7
7
  "agentDocPath": "AGENTS.md",
8
8
  "sourceHashes": {
9
- "manifest": "68168ece415db75b19501fd06ca40fe19a47a9af87417387f5e21046922b6ecc",
10
- "packageJson": "f2e142a39fbaebf2d998ac3e5080dd26cfcbad9477b0b88f2000f2adbe2e160b",
9
+ "manifest": "8f1106f7a4664dd44e803812d1a5a971636e8efe510262d9f627c71e8df6c1ea",
10
+ "packageJson": "0f080675bcc4e67d80df9ee054030103c7eebee343e15e760d0881d0081ad00c",
11
11
  "agents": "b0cf63bd78f00cc1729ea7f1425291f8e23ab5591161cbbf0d08f4b16ace07fd"
12
12
  },
13
13
  "exports": [
@@ -109,18 +109,25 @@ async function loadImages(reset = false) {
109
109
  // Initial load — must be in onMount to avoid SSR fetch errors
110
110
  onMount(() => loadImages(true));
111
111
 
112
- // Debounce search on filter changes using idiomatic Svelte 5 $effect cleanup
113
- let searchTimeout: ReturnType<typeof setTimeout> | undefined;
112
+ // Debounce search on filter changes. Schedule the timer in the effect *body*
113
+ // (not the cleanup) and clear it in the returned cleanup, so a pending reload
114
+ // is cancelled — never fired — when the component unmounts. Skip the very first
115
+ // run because onMount() already performs the initial load.
116
+ let didInitialFilterRun = false;
114
117
  $effect(() => {
115
118
  // Register reactive deps
116
119
  const _q = searchQuery;
117
120
  const _o = orientationFilter;
118
121
  const _w = minWidth;
119
122
  const _h = minHeight;
120
- return () => {
121
- clearTimeout(searchTimeout);
122
- searchTimeout = setTimeout(() => loadImages(true), 500);
123
- };
123
+
124
+ if (!didInitialFilterRun) {
125
+ didInitialFilterRun = true;
126
+ return;
127
+ }
128
+
129
+ const id = setTimeout(() => loadImages(true), 500);
130
+ return () => clearTimeout(id);
124
131
  });
125
132
 
126
133
  function handleSelect(image: ImageLike) {
@@ -1 +1 @@
1
- {"version":3,"file":"AssetsGallery.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/AssetsGallery.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,SAAS,EACT,mBAAmB,EAEpB,MAAM,kBAAkB,CAAC;AAEzB,KAAK,gBAAgB,GAAI;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAC7B,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACtC,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAkNF,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"AssetsGallery.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/AssetsGallery.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,SAAS,EACT,mBAAmB,EAEpB,MAAM,kBAAkB,CAAC;AAEzB,KAAK,gBAAgB,GAAI;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAC7B,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACtC,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAyNF,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -56,6 +56,11 @@ let canvasElement: HTMLCanvasElement | undefined = $state();
56
56
  let stream: MediaStream | null = $state(null);
57
57
  let cameraError: string | null = $state(null);
58
58
  let isCameraActive = $state(false);
59
+ // Set once the component tears down. `getUserMedia()` may still be resolving
60
+ // (permission prompt) after unmount; this lets startCamera() detect that and
61
+ // stop the just-granted tracks instead of leaking the camera (light stays on).
62
+ // Not reactive — it only gates an async cleanup decision.
63
+ let isDestroyed = false;
59
64
 
60
65
  // External state
61
66
  let externalUrl = $state('');
@@ -114,13 +119,28 @@ async function startCamera() {
114
119
  cameraError = null;
115
120
  isCameraActive = false;
116
121
  try {
117
- stream = await navigator.mediaDevices.getUserMedia({
122
+ const acquired = await navigator.mediaDevices.getUserMedia({
118
123
  video: { facingMode: 'environment' },
119
124
  audio: false,
120
125
  });
126
+
127
+ // The permission prompt may have resolved after the user switched tabs or
128
+ // the component unmounted. If the camera is no longer the intended target,
129
+ // immediately stop the just-granted tracks so the camera light turns off,
130
+ // and don't attach the (now orphaned) stream.
131
+ if (isDestroyed || activeTab !== 'camera') {
132
+ for (const track of acquired.getTracks()) {
133
+ track.stop();
134
+ }
135
+ return;
136
+ }
137
+
138
+ stream = acquired;
121
139
  if (videoElement) {
122
140
  videoElement.srcObject = stream;
123
- videoElement.play();
141
+ // play() can reject if autoplay is blocked; swallow it so it doesn't
142
+ // surface as an unhandled rejection (the user can still capture frames).
143
+ videoElement.play().catch(() => {});
124
144
  isCameraActive = true;
125
145
  }
126
146
  } catch (err: any) {
@@ -247,6 +267,7 @@ async function handleGenerateVariation() {
247
267
  }
248
268
 
249
269
  onDestroy(() => {
270
+ isDestroyed = true;
250
271
  stopCamera();
251
272
  });
252
273
  </script>
@@ -1 +1 @@
1
- {"version":3,"file":"ImageUploader.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/ImageUploader.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,iBAAiB,EACjB,SAAS,EACT,mBAAmB,EACpB,MAAM,kBAAkB,CAAC;AAGzB,KAAK,gBAAgB,GAAI;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC,mDAAmD;IACnD,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,GAAG,IAAI,GAAG,MAAM,KAAK,IAAI,CAAC;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,WAAW,CAAC,EAAE,CAAC,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,CAAC,EAAE,CAAC;IAC/D,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAqYF,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"ImageUploader.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/ImageUploader.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EACV,iBAAiB,EACjB,SAAS,EACT,mBAAmB,EACpB,MAAM,kBAAkB,CAAC;AAGzB,KAAK,gBAAgB,GAAI;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC,mDAAmD;IACnD,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,GAAG,IAAI,GAAG,MAAM,KAAK,IAAI,CAAC;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,WAAW,CAAC,EAAE,CAAC,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,CAAC,EAAE,CAAC;IAC/D,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AA0ZF,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-images",
3
- "version": "0.31.1",
3
+ "version": "0.32.1",
4
4
  "description": "Image asset management with AI-powered categorization, search, editing, and metadata extraction for SMRT framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -38,12 +38,12 @@
38
38
  "@happyvertical/utils": "^0.74.7",
39
39
  "jimp": "^1.6.1",
40
40
  "sharp": "^0.34.5",
41
- "@happyvertical/smrt-assets": "0.31.1",
42
- "@happyvertical/smrt-core": "0.31.1",
43
- "@happyvertical/smrt-tenancy": "0.31.1",
44
- "@happyvertical/smrt-prompts": "0.31.1",
45
- "@happyvertical/smrt-types": "0.31.1",
46
- "@happyvertical/smrt-ui": "0.31.1"
41
+ "@happyvertical/smrt-assets": "0.32.1",
42
+ "@happyvertical/smrt-core": "0.32.1",
43
+ "@happyvertical/smrt-prompts": "0.32.1",
44
+ "@happyvertical/smrt-types": "0.32.1",
45
+ "@happyvertical/smrt-tenancy": "0.32.1",
46
+ "@happyvertical/smrt-ui": "0.32.1"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "svelte": "^5.18.0"
@@ -59,8 +59,8 @@
59
59
  "typescript": "^5.9.3",
60
60
  "vite": "^7.3.1",
61
61
  "vitest": "^4.0.17",
62
- "@happyvertical/smrt-playground": "0.31.1",
63
- "@happyvertical/smrt-vitest": "0.31.1"
62
+ "@happyvertical/smrt-playground": "0.32.1",
63
+ "@happyvertical/smrt-vitest": "0.32.1"
64
64
  },
65
65
  "keywords": [
66
66
  "ai",