wc-img-ai 0.2.2 → 0.3.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.
package/README.md CHANGED
@@ -1,21 +1,31 @@
1
- # AI based img-tags
1
+ # wc-img-ai
2
2
 
3
- By simply providing a src to an api that does the AI for you, and you can simply get ai-rendered images anywhere you need based on the prompt-attribute.
3
+ AI-generated images as a web component. Drop an `<ai-img>` anywhere, give it a
4
+ `prompt` and the URL of your own server endpoint, and it renders an
5
+ AI-generated image — while the API key, image generation and storage all stay
6
+ on the server.
4
7
 
5
8
  ```html
6
9
  <ai-img
7
- src="http://localhost:4321/api/openai/img"
10
+ endpoint="/api/img"
8
11
  width="256"
9
12
  height="256"
10
- prompt="funny dolphin up to no good"
11
- fallback="https://placekitten.com/200/300"
13
+ prompt="a funny dolphin up to no good"
14
+ fallback="https://placehold.co/256x256"
12
15
  ></ai-img>
13
16
  ```
14
17
 
15
- ## installation
18
+ ## Why a server endpoint?
19
+
20
+ The component is deliberately dumb: it never holds an API key, never picks a
21
+ provider, and never talks to OpenAI/Gemini directly. It sends a single POST to
22
+ **your** endpoint, and your server decides what to do. That keeps tokens
23
+ server-side and lets you store, cache and bill generation however you like.
24
+
25
+ ## Install
16
26
 
17
27
  ```bash
18
- pnpm i wc-img-ai
28
+ pnpm add wc-img-ai
19
29
  ```
20
30
 
21
31
  ```html
@@ -24,36 +34,134 @@ pnpm i wc-img-ai
24
34
  </script>
25
35
  ```
26
36
 
27
- The api server itself needs to receive this body and return a string with the url of the image
37
+ ## Attributes
38
+
39
+ | Attribute | Reflected | Description |
40
+ | ----------- | --------- | --------------------------------------------------------------------------- |
41
+ | `src` | — | A ready image URL (or data URL). When set, the component acts as a plain `<img>` and never calls the endpoint. Use it when you already have the image. Highest priority. |
42
+ | `endpoint` | — | Your server route. Receives the POST below. |
43
+ | `prompt` | — | Description used to generate the image. |
44
+ | `image-id` | on mint | Storage handle. Provide a known id to fetch a stored image; the server sets it on the element when a new image is minted. |
45
+ | `llm` | — | Provider/model hint forwarded to the endpoint (e.g. `gemini`, `openai`). |
46
+ | `ratio` | — | Aspect ratio forwarded to the endpoint (e.g. `16:9`, `4:1`). With `width`, it derives the effective height. |
47
+ | `fallback` | — | Image URL shown if nothing resolves. If omitted, a 1×1 transparent PNG is used. |
48
+ | `width` | ✅ yes | Intrinsic width (like `<img width>`) — used for the box aspect-ratio and sent to the endpoint. |
49
+ | `height` | — | Optional intrinsic height (like `<img height>`); derived when omitted and `width`/`ratio` are set. |
50
+ | `alt` | ✅ yes | Alt text, passed to the inner `<img>`. |
51
+
52
+ ### Sizing & styling — just like a native `<img>`
53
+
54
+ Set `width` plus `ratio`, or the native-image-compatible `width`/`height` pair,
55
+ and style with `class`/CSS; you rarely need inline `style`. The effective
56
+ dimensions become the inner image's content attributes, so the browser reserves
57
+ the box from their aspect-ratio (no layout shift) while CSS controls the
58
+ displayed size:
59
+
60
+ ```html
61
+ <!-- full-width 3:1 banner, rounded, no layout shift -->
62
+ <ai-img endpoint="/api/img" prompt="…" width="1536" height="512"
63
+ class="block w-full rounded-xl"></ai-img>
64
+ ```
65
+
66
+ When `ratio` and `width` are set, `height` is optional and the component derives
67
+ it. An explicit `height` remains authoritative for callers that need an exact
68
+ output box:
69
+
70
+ ```html
71
+ <ai-img endpoint="/api/img" prompt="…" width="1536" ratio="3:1"
72
+ class="block w-full rounded-xl"></ai-img>
73
+ ```
74
+
75
+ Visual properties bridge the shadow boundary via `inherit`, so utility classes
76
+ like `rounded-xl`, `object-cover`, `shadow-lg` on `<ai-img>` style the image.
77
+ Any other attribute (e.g. `loading`) is passed through to the inner `<img>`.
78
+
79
+ ## How it resolves
80
+
81
+ ```
82
+ src set → render it as a plain <img> (no endpoint call)
83
+ else no prompt, no image-id → fallback → 1×1 transparent PNG (nothing to ask)
84
+ else → POST endpoint once { prompt, imageId, width, height, llm, ratio }
85
+ 200 {id,url} → render url, reflect image-id, fire `ai-image` event
86
+ 404 / error → fallback → 1×1 transparent PNG
87
+ ```
88
+
89
+ `src` is the cheapest path: if you already have the image (a precomputed or
90
+ returning one), set `src` and the component skips the AI entirely. Only when
91
+ `src` is empty does it fall back to the `prompt`/`image-id` flow.
92
+
93
+ > **Performance note.** For an image you _already have the URL for_, a plain
94
+ > `<img src>` will paint faster than `<ai-img src>` — the web component can't
95
+ > render until its own script has loaded and defined the element, whereas a
96
+ > native `<img>` loads with the document. Use `src` for a single unified tag;
97
+ > reach for a native `<img>` (and load `<ai-img>` only when you need to
98
+ > generate) when first paint of a known image is critical.
99
+
100
+ The component never branches on whether an image exists — the **server** owns
101
+ that decision (see the contract below). If the returned `url` itself fails to
102
+ load in the `<img>` (a transient 404, slow propagation, a stale url), the
103
+ component retries once and then drops to the fallback chain.
104
+
105
+ ## The `ai-image` event
106
+
107
+ Fired after a successful resolve. Use it to persist the id (and url) to a
108
+ database so you can render the same image again later without re-generating.
109
+
110
+ ```js
111
+ el.addEventListener("ai-image", (e) => {
112
+ // e.detail = { id, url, prompt }
113
+ db.save(e.detail)
114
+ })
115
+ ```
28
116
 
29
- ### Function: POST
117
+ The minted id is also reflected onto the `image-id` attribute.
30
118
 
31
- The web-component sends a POST request to the API to generate an image and expects an image in return.
119
+ ## Server contract
32
120
 
33
- #### Request Body Format
121
+ The component talks to a single endpoint with one POST. Your server is the
122
+ "smart" side that decides between fetching a stored image and generating a new
123
+ one.
34
124
 
35
- - **prompt** (string): The description or prompt based on which the image will be generated.
36
- - **width** (number): The width of the desired image in pixels.
37
- - **height** (number): The height of the desired image in pixels.
125
+ ### `POST {endpoint}`
38
126
 
39
- #### Example Request Body
127
+ Request body:
40
128
 
41
129
  ```json
42
- {
43
- "prompt": "a sleeping little kitten",
44
- "width": 256,
45
- "height": 256
46
- }
130
+ { "prompt": "a funny dolphin", "imageId": "V1StGXR8_Z5", "width": 256, "height": 256, "llm": "gemini", "ratio": "16:9" }
47
131
  ```
48
132
 
49
- #### Server Return
133
+ `imageId`, `width`, `height`, `llm` and `ratio` are optional. Use `llm`/`ratio`
134
+ to let the server pick a provider and aspect ratio. Expected server behaviour:
50
135
 
51
- it is expected to return a simple string
136
+ | Condition | Response |
137
+ | ------------------------------------------- | ----------------- |
138
+ | `imageId` given and stored | `200 {id,url}` — return it, no AI |
139
+ | `imageId` given but missing, `prompt` given | `200 {id,url}` — generate (mint a new id) |
140
+ | no `imageId`, `prompt` given | `200 {id,url}` — generate |
141
+ | nothing to do | `404` |
142
+
143
+ Response body on success:
52
144
 
53
145
  ```json
54
- "https://example.com/path/to/generated/image.jpg"
146
+ { "id": "V1StGXR8_Z5", "url": "/images/V1StGXR8_Z5.png" }
147
+ ```
148
+
149
+ `url` can be anything the browser can load (a hosted/CDN URL, a route on your
150
+ server, or a data URL).
151
+
152
+ ## Reference demo server
153
+
154
+ `demo/server.mjs` is a complete, dependency-light reference implementation:
155
+ OpenAI `gpt-image-2`, nanoid ids, filesystem storage under `images/`, served
156
+ back as stable URLs.
157
+
158
+ ```bash
159
+ cp .env.example .env # add OPENAI_API_KEY
160
+ pnpm install
161
+ pnpm build
162
+ pnpm demo # http://localhost:3000
55
163
  ```
56
164
 
57
- #### Demo
165
+ ## License
58
166
 
59
- Check it out [running](https://john.ro/lab/img-ai) inside an MDX/Astro framework
167
+ MIT
package/dist/wc-img-ai.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * SPDX-License-Identifier: BSD-3-Clause
5
5
  */
6
6
  var _a;
7
- const t$3 = globalThis, e$3 = t$3.ShadowRoot && (void 0 === t$3.ShadyCSS || t$3.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, s$3 = Symbol(), o$4 = /* @__PURE__ */ new WeakMap();
7
+ const t$2 = globalThis, e$3 = t$2.ShadowRoot && (void 0 === t$2.ShadyCSS || t$2.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, s$3 = Symbol(), o$4 = /* @__PURE__ */ new WeakMap();
8
8
  let n$4 = class n {
9
9
  constructor(t2, e2, o2) {
10
10
  if (this._$cssResult$ = true, o2 !== s$3)
@@ -24,7 +24,7 @@ let n$4 = class n {
24
24
  return this.cssText;
25
25
  }
26
26
  };
27
- const r$5 = (t2) => new n$4("string" == typeof t2 ? t2 : t2 + "", void 0, s$3), i$3 = (t2, ...e2) => {
27
+ const r$6 = (t2) => new n$4("string" == typeof t2 ? t2 : t2 + "", void 0, s$3), i$3 = (t2, ...e2) => {
28
28
  const o2 = 1 === t2.length ? t2[0] : e2.reduce((e3, s3, o3) => e3 + ((t3) => {
29
29
  if (true === t3._$cssResult$)
30
30
  return t3.cssText;
@@ -38,21 +38,21 @@ const r$5 = (t2) => new n$4("string" == typeof t2 ? t2 : t2 + "", void 0, s$3),
38
38
  s3.adoptedStyleSheets = o2.map((t2) => t2 instanceof CSSStyleSheet ? t2 : t2.styleSheet);
39
39
  else
40
40
  for (const e2 of o2) {
41
- const o3 = document.createElement("style"), n3 = t$3.litNonce;
41
+ const o3 = document.createElement("style"), n3 = t$2.litNonce;
42
42
  void 0 !== n3 && o3.setAttribute("nonce", n3), o3.textContent = e2.cssText, s3.appendChild(o3);
43
43
  }
44
44
  }, c$3 = e$3 ? (t2) => t2 : (t2) => t2 instanceof CSSStyleSheet ? ((t3) => {
45
45
  let e2 = "";
46
46
  for (const s3 of t3.cssRules)
47
47
  e2 += s3.cssText;
48
- return r$5(e2);
48
+ return r$6(e2);
49
49
  })(t2) : t2;
50
50
  /**
51
51
  * @license
52
52
  * Copyright 2017 Google LLC
53
53
  * SPDX-License-Identifier: BSD-3-Clause
54
54
  */
55
- const { is: i$2, defineProperty: e$2, getOwnPropertyDescriptor: r$4, getOwnPropertyNames: h$2, getOwnPropertySymbols: o$3, getPrototypeOf: n$3 } = Object, a$1 = globalThis, c$2 = a$1.trustedTypes, l$1 = c$2 ? c$2.emptyScript : "", p$1 = a$1.reactiveElementPolyfillSupport, d$1 = (t2, s3) => t2, u$1 = { toAttribute(t2, s3) {
55
+ const { is: i$2, defineProperty: e$2, getOwnPropertyDescriptor: r$5, getOwnPropertyNames: h$2, getOwnPropertySymbols: o$3, getPrototypeOf: n$3 } = Object, a$1 = globalThis, c$2 = a$1.trustedTypes, l$1 = c$2 ? c$2.emptyScript : "", p$1 = a$1.reactiveElementPolyfillSupport, d$1 = (t2, s3) => t2, u$1 = { toAttribute(t2, s3) {
56
56
  switch (s3) {
57
57
  case Boolean:
58
58
  t2 = t2 ? l$1 : null;
@@ -96,7 +96,7 @@ class b extends HTMLElement {
96
96
  }
97
97
  }
98
98
  static getPropertyDescriptor(t2, s3, i2) {
99
- const { get: e2, set: h2 } = r$4(this.prototype, t2) ?? { get() {
99
+ const { get: e2, set: h2 } = r$5(this.prototype, t2) ?? { get() {
100
100
  return this[s3];
101
101
  }, set(t3) {
102
102
  this[s3] = t3;
@@ -299,8 +299,8 @@ b.elementStyles = [], b.shadowRootOptions = { mode: "open" }, b[d$1("elementProp
299
299
  * Copyright 2017 Google LLC
300
300
  * SPDX-License-Identifier: BSD-3-Clause
301
301
  */
302
- const t$2 = globalThis, i$1 = t$2.trustedTypes, s$2 = i$1 ? i$1.createPolicy("lit-html", { createHTML: (t2) => t2 }) : void 0, e$1 = "$lit$", h$1 = `lit$${(Math.random() + "").slice(9)}$`, o$2 = "?" + h$1, n$2 = `<${o$2}>`, r$3 = document, l = () => r$3.createComment(""), c$1 = (t2) => null === t2 || "object" != typeof t2 && "function" != typeof t2, a = Array.isArray, u = (t2) => a(t2) || "function" == typeof (t2 == null ? void 0 : t2[Symbol.iterator]), d = "[ \n\f\r]", f$2 = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, v = /-->/g, _ = />/g, m = RegExp(`>|${d}(?:([^\\s"'>=/]+)(${d}*=${d}*(?:[^
303
- \f\r"'\`<>=]|("|')|))|$)`, "g"), p = /'/g, g = /"/g, $ = /^(?:script|style|textarea|title)$/i, y = (t2) => (i2, ...s3) => ({ _$litType$: t2, strings: i2, values: s3 }), x = y(1), w = Symbol.for("lit-noChange"), T = Symbol.for("lit-nothing"), A = /* @__PURE__ */ new WeakMap(), E = r$3.createTreeWalker(r$3, 129);
302
+ const t$1 = globalThis, i$1 = t$1.trustedTypes, s$2 = i$1 ? i$1.createPolicy("lit-html", { createHTML: (t2) => t2 }) : void 0, e$1 = "$lit$", h$1 = `lit$${(Math.random() + "").slice(9)}$`, o$2 = "?" + h$1, n$2 = `<${o$2}>`, r$4 = document, l = () => r$4.createComment(""), c$1 = (t2) => null === t2 || "object" != typeof t2 && "function" != typeof t2, a = Array.isArray, u = (t2) => a(t2) || "function" == typeof (t2 == null ? void 0 : t2[Symbol.iterator]), d = "[ \n\f\r]", f$2 = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, v = /-->/g, _ = />/g, m = RegExp(`>|${d}(?:([^\\s"'>=/]+)(${d}*=${d}*(?:[^
303
+ \f\r"'\`<>=]|("|')|))|$)`, "g"), p = /'/g, g = /"/g, $ = /^(?:script|style|textarea|title)$/i, y = (t2) => (i2, ...s3) => ({ _$litType$: t2, strings: i2, values: s3 }), x = y(1), w = Symbol.for("lit-noChange"), T = Symbol.for("lit-nothing"), A = /* @__PURE__ */ new WeakMap(), E = r$4.createTreeWalker(r$4, 129);
304
304
  function C(t2, i2) {
305
305
  if (!Array.isArray(t2) || !t2.hasOwnProperty("raw"))
306
306
  throw Error("invalid template strings array");
@@ -359,7 +359,7 @@ class V {
359
359
  }
360
360
  }
361
361
  static createElement(t2, i2) {
362
- const s3 = r$3.createElement("template");
362
+ const s3 = r$4.createElement("template");
363
363
  return s3.innerHTML = t2, s3;
364
364
  }
365
365
  }
@@ -382,7 +382,7 @@ class S {
382
382
  return this._$AM._$AU;
383
383
  }
384
384
  u(t2) {
385
- const { el: { content: i2 }, parts: s3 } = this._$AD, e2 = ((t2 == null ? void 0 : t2.creationScope) ?? r$3).importNode(i2, true);
385
+ const { el: { content: i2 }, parts: s3 } = this._$AD, e2 = ((t2 == null ? void 0 : t2.creationScope) ?? r$4).importNode(i2, true);
386
386
  E.currentNode = e2;
387
387
  let h2 = E.nextNode(), o2 = 0, n3 = 0, l2 = s3[0];
388
388
  for (; void 0 !== l2; ) {
@@ -392,7 +392,7 @@ class S {
392
392
  }
393
393
  o2 !== (l2 == null ? void 0 : l2.index) && (h2 = E.nextNode(), o2++);
394
394
  }
395
- return E.currentNode = r$3, e2;
395
+ return E.currentNode = r$4, e2;
396
396
  }
397
397
  p(t2) {
398
398
  let i2 = 0;
@@ -429,7 +429,7 @@ class M {
429
429
  this._$AH !== t2 && (this._$AR(), this._$AH = this.k(t2));
430
430
  }
431
431
  _(t2) {
432
- this._$AH !== T && c$1(this._$AH) ? this._$AA.nextSibling.data = t2 : this.$(r$3.createTextNode(t2)), this._$AH = t2;
432
+ this._$AH !== T && c$1(this._$AH) ? this._$AA.nextSibling.data = t2 : this.$(r$4.createTextNode(t2)), this._$AH = t2;
433
433
  }
434
434
  g(t2) {
435
435
  var _a2;
@@ -534,8 +534,8 @@ class L {
534
534
  N(this, t2);
535
535
  }
536
536
  }
537
- const Z = t$2.litHtmlPolyfillSupport;
538
- Z == null ? void 0 : Z(V, M), (t$2.litHtmlVersions ?? (t$2.litHtmlVersions = [])).push("3.1.0");
537
+ const Z = t$1.litHtmlPolyfillSupport;
538
+ Z == null ? void 0 : Z(V, M), (t$1.litHtmlVersions ?? (t$1.litHtmlVersions = [])).push("3.1.0");
539
539
  const j = (t2, i2, s3) => {
540
540
  const e2 = (s3 == null ? void 0 : s3.renderBefore) ?? i2;
541
541
  let h2 = e2._$litPart$;
@@ -576,25 +576,15 @@ let s$1 = class s extends b {
576
576
  }
577
577
  };
578
578
  s$1._$litElement$ = true, s$1["finalized"] = true, (_a = globalThis.litElementHydrateSupport) == null ? void 0 : _a.call(globalThis, { LitElement: s$1 });
579
- const r$2 = globalThis.litElementPolyfillSupport;
580
- r$2 == null ? void 0 : r$2({ LitElement: s$1 });
579
+ const r$3 = globalThis.litElementPolyfillSupport;
580
+ r$3 == null ? void 0 : r$3({ LitElement: s$1 });
581
581
  (globalThis.litElementVersions ?? (globalThis.litElementVersions = [])).push("4.0.2");
582
582
  /**
583
583
  * @license
584
584
  * Copyright 2017 Google LLC
585
585
  * SPDX-License-Identifier: BSD-3-Clause
586
586
  */
587
- const t$1 = (t2) => (e2, o2) => {
588
- void 0 !== o2 ? o2.addInitializer(() => {
589
- customElements.define(t2, e2);
590
- }) : customElements.define(t2, e2);
591
- };
592
- /**
593
- * @license
594
- * Copyright 2017 Google LLC
595
- * SPDX-License-Identifier: BSD-3-Clause
596
- */
597
- const o$1 = { attribute: true, type: String, converter: u$1, reflect: false, hasChanged: f$3 }, r$1 = (t2 = o$1, e2, r2) => {
587
+ const o$1 = { attribute: true, type: String, converter: u$1, reflect: false, hasChanged: f$3 }, r$2 = (t2 = o$1, e2, r2) => {
598
588
  const { kind: n3, metadata: i2 } = r2;
599
589
  let s3 = globalThis.litPropertyMetadata.get(i2);
600
590
  if (void 0 === s3 && globalThis.litPropertyMetadata.set(i2, s3 = /* @__PURE__ */ new Map()), s3.set(r2.name, t2), "accessor" === n3) {
@@ -616,11 +606,19 @@ const o$1 = { attribute: true, type: String, converter: u$1, reflect: false, has
616
606
  throw Error("Unsupported decorator location: " + n3);
617
607
  };
618
608
  function n$1(t2) {
619
- return (e2, o2) => "object" == typeof o2 ? r$1(t2, e2, o2) : ((t3, e3, o3) => {
609
+ return (e2, o2) => "object" == typeof o2 ? r$2(t2, e2, o2) : ((t3, e3, o3) => {
620
610
  const r2 = e3.hasOwnProperty(o3);
621
611
  return e3.constructor.createProperty(o3, r2 ? { ...t3, wrapped: true } : t3), r2 ? Object.getOwnPropertyDescriptor(e3, o3) : void 0;
622
612
  })(t2, e2, o2);
623
613
  }
614
+ /**
615
+ * @license
616
+ * Copyright 2017 Google LLC
617
+ * SPDX-License-Identifier: BSD-3-Clause
618
+ */
619
+ function r$1(r2) {
620
+ return n$1({ ...r2, state: true, attribute: false });
621
+ }
624
622
  /**
625
623
  * @license
626
624
  * Copyright 2017 Google LLC
@@ -890,28 +888,34 @@ class SpreadDirective extends SpreadEventsDirective {
890
888
  }
891
889
  const spread = e(SpreadDirective);
892
890
  const spinner = `<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.a{animation:b .8s linear infinite;fill:#888}.c{animation-delay:-.65s}.d{animation-delay:-.5s}@keyframes b{93.75%,100%{r:3px}46.875%{r:.2px}}</style><circle class="a" cx="4" cy="12" r="3"/><circle class="a c" cx="12" cy="12" r="3"/><circle class="a d" cx="20" cy="12" r="3"/></svg>`;
893
- const getGeneratedImage = async (endpoint, prompt, width, height) => {
894
- let response;
891
+ const TRANSPARENT_PIXEL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
892
+ const resolveImage = async (endpoint, req) => {
893
+ if (!endpoint)
894
+ return null;
895
895
  try {
896
896
  const r2 = await fetch(endpoint, {
897
897
  method: "POST",
898
- headers: {
899
- "Content-Type": "application/json"
900
- },
898
+ headers: { "Content-Type": "application/json" },
901
899
  body: JSON.stringify({
902
- prompt,
903
- width,
904
- height
900
+ prompt: req.prompt || void 0,
901
+ imageId: req.imageId || void 0,
902
+ width: req.width || void 0,
903
+ height: req.height || void 0,
904
+ llm: req.llm || void 0,
905
+ ratio: req.ratio || void 0
905
906
  })
906
907
  });
907
- if (!r2.ok) {
908
- throw new Error("Network response was not ok");
908
+ if (!r2.ok)
909
+ return null;
910
+ const data = await r2.json();
911
+ if (!data || typeof data.url !== "string" || data.url.length === 0) {
912
+ return null;
909
913
  }
910
- response = await r2.text();
914
+ return { id: typeof data.id === "string" ? data.id : "", url: data.url };
911
915
  } catch (error) {
912
- console.error("There has been a problem with your fetch operation:", error);
916
+ console.error("ai-img: image request failed:", error);
917
+ return null;
913
918
  }
914
- return response;
915
919
  };
916
920
  var __defProp = Object.defineProperty;
917
921
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -924,118 +928,167 @@ var __decorateClass = (decorators, target, key, kind) => {
924
928
  __defProp(target, key, result);
925
929
  return result;
926
930
  };
927
- let AiImg = class extends s$1 {
931
+ const SPINNER_BG = r$6(encodeURIComponent(spinner));
932
+ const RESERVED_ATTRS = /* @__PURE__ */ new Set([
933
+ "endpoint",
934
+ "prompt",
935
+ "image-id",
936
+ "fallback",
937
+ "width",
938
+ "height",
939
+ "llm",
940
+ "ratio",
941
+ "class",
942
+ "style",
943
+ "loading",
944
+ "decoding",
945
+ "src"
946
+ ]);
947
+ const placeholder = (width, height) => "data:image/svg+xml;utf8," + encodeURIComponent(
948
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="#ddd"/></svg>`
949
+ );
950
+ const dimensionsFor = (width, height, ratio) => {
951
+ const ratioMatch = ratio.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
952
+ const ratioWidth = Number(ratioMatch == null ? void 0 : ratioMatch[1]);
953
+ const ratioHeight = Number(ratioMatch == null ? void 0 : ratioMatch[2]);
954
+ const numericWidth = Number(width);
955
+ if (!height && Number.isFinite(numericWidth) && numericWidth > 0 && Number.isFinite(ratioWidth) && ratioWidth > 0 && Number.isFinite(ratioHeight) && ratioHeight > 0) {
956
+ return {
957
+ width,
958
+ height: String(Math.round(numericWidth * (ratioHeight / ratioWidth)))
959
+ };
960
+ }
961
+ return { width, height };
962
+ };
963
+ const _AiImg = class _AiImg extends s$1 {
928
964
  constructor() {
929
965
  super(...arguments);
966
+ this.src = "";
967
+ this.endpoint = "";
968
+ this.prompt = "";
969
+ this.imageId = "";
970
+ this.llm = "";
971
+ this.ratio = "";
930
972
  this.fallback = "";
931
973
  this.width = "";
932
974
  this.height = "";
933
- this.src = "";
934
975
  this.alt = "";
935
- this.imgsrc = "";
936
- this.prompt = "";
976
+ this.imgsrc = TRANSPARENT_PIXEL;
937
977
  this.imgAttributes = {};
978
+ this.onFallback = false;
979
+ this.retried = false;
980
+ this.resolvedUrl = "";
981
+ this.onImgError = () => {
982
+ if (this.onFallback || !this.resolvedUrl)
983
+ return;
984
+ if (!this.retried) {
985
+ this.retried = true;
986
+ const sep = this.resolvedUrl.includes("?") ? "&" : "?";
987
+ const url = `${this.resolvedUrl}${sep}retry=${Date.now()}`;
988
+ setTimeout(() => {
989
+ this.imgsrc = url;
990
+ }, 800);
991
+ return;
992
+ }
993
+ this.resolvedUrl = "";
994
+ this.settleFallback();
995
+ };
938
996
  }
939
997
  connectedCallback() {
940
998
  super.connectedCallback();
941
- this.initAttributes();
942
- this.fetchImage();
943
- }
944
- async fetchImage() {
945
- if (!this.prompt) {
946
- this.imgsrc = this.fallback;
999
+ queueMicrotask(() => this.start());
1000
+ }
1001
+ start() {
1002
+ this.collectPassThroughAttributes();
1003
+ const dimensions = dimensionsFor(this.width, this.height, this.ratio);
1004
+ if (this.src) {
1005
+ this.resolvedUrl = this.src;
1006
+ this.settle(this.src);
947
1007
  return;
948
1008
  }
949
- try {
950
- const openAiResponse = await getGeneratedImage(
951
- this.src,
952
- this.prompt,
953
- Number(this.width),
954
- Number(this.height)
955
- );
956
- this.imgsrc = openAiResponse || this.fallback;
957
- this.classList.remove("spin");
958
- } catch (error) {
959
- console.error("Error fetching AI image:", error);
960
- this.imgsrc = this.fallback;
961
- this.classList.remove("spin");
1009
+ if (!this.prompt && !this.imageId) {
1010
+ this.settleFallback();
1011
+ return;
962
1012
  }
1013
+ this.imgsrc = placeholder(dimensions.width || "1", dimensions.height || "1");
1014
+ this.classList.add("spin");
1015
+ void this.resolve();
963
1016
  }
964
- initAttributes() {
965
- Array.from(this.attributes).forEach((attr) => {
966
- if (![
967
- "loading",
968
- "decoding",
969
- "src",
970
- "fallback",
971
- "prompt",
972
- "style",
973
- "width",
974
- "height"
975
- ].includes(attr.name)) {
1017
+ collectPassThroughAttributes() {
1018
+ for (const attr of Array.from(this.attributes)) {
1019
+ if (!RESERVED_ATTRS.has(attr.name)) {
976
1020
  this.imgAttributes[attr.name] = attr.value;
977
1021
  }
978
- if (attr.name === "width") {
979
- this.width = attr.value;
980
- }
981
- if (attr.name === "height") {
982
- this.height = attr.value;
983
- }
1022
+ }
1023
+ }
1024
+ async resolve() {
1025
+ const dimensions = dimensionsFor(this.width, this.height, this.ratio);
1026
+ const result = await resolveImage(this.endpoint, {
1027
+ prompt: this.prompt,
1028
+ imageId: this.imageId,
1029
+ width: Number(dimensions.width) || void 0,
1030
+ height: Number(dimensions.height) || void 0,
1031
+ llm: this.llm,
1032
+ ratio: this.ratio
984
1033
  });
985
- this.imgsrc = "data:image/svg+xml;utf8," + encodeURIComponent(
986
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${this.width} ${this.height}"><rect width="${this.width}" height="${this.height}" fill="#ddd"/></svg>`
1034
+ if (!result) {
1035
+ this.settleFallback();
1036
+ return;
1037
+ }
1038
+ if (result.id && result.id !== this.imageId) {
1039
+ this.imageId = result.id;
1040
+ this.setAttribute("image-id", result.id);
1041
+ }
1042
+ this.dispatchEvent(
1043
+ new CustomEvent("ai-image", {
1044
+ detail: { id: result.id, url: result.url, prompt: this.prompt },
1045
+ bubbles: true,
1046
+ composed: true
1047
+ })
987
1048
  );
988
- this.classList.add("spin");
1049
+ this.resolvedUrl = result.url;
1050
+ this.retried = false;
1051
+ this.onFallback = false;
1052
+ this.settle(result.url);
1053
+ }
1054
+ settle(src) {
1055
+ this.imgsrc = src;
1056
+ this.classList.remove("spin");
1057
+ }
1058
+ settleFallback() {
1059
+ this.onFallback = true;
1060
+ this.settle(this.fallback || TRANSPARENT_PIXEL);
989
1061
  }
990
1062
  render() {
1063
+ const dimensions = dimensionsFor(this.width, this.height, this.ratio);
991
1064
  return x`
992
- <style>
993
- :host(.spin) {
994
- position: relative;
995
- }
996
-
997
- :host(.spin):before {
998
- content: "";
999
- position: absolute;
1000
- margin: auto 0;
1001
- width: 100%;
1002
- height: 100%;
1003
- display: inline-flex;
1004
- justify-content: center;
1005
- align-items: center;
1006
- color: red;
1007
- background-image: url("data:image/svg+xml;utf8,${encodeURIComponent(
1008
- spinner
1009
- )}");
1010
- background-repeat: no-repeat;
1011
- background-position: center;
1012
- background-size: 25%;
1013
- }
1014
- </style>
1015
-
1016
- ${this.imgsrc.length > 0 ? x`
1017
- <img
1018
- src=${this.imgsrc}
1019
- decoding="async"
1020
- loading="eager"
1021
- ${spread(this.imgAttributes)}
1022
- />
1023
- ` : x`<div>fail</div>`}
1065
+ <img
1066
+ src=${this.imgsrc}
1067
+ alt=${this.alt}
1068
+ width=${dimensions.width || T}
1069
+ height=${dimensions.height || T}
1070
+ decoding="async"
1071
+ @error=${this.onImgError}
1072
+ ${spread(this.imgAttributes)}
1073
+ />
1024
1074
  `;
1025
1075
  }
1026
1076
  };
1027
- AiImg.styles = i$3`
1077
+ _AiImg.styles = i$3`
1028
1078
  :host {
1029
1079
  display: inline-block;
1080
+ position: relative;
1081
+ line-height: 0;
1030
1082
  }
1031
1083
 
1032
1084
  img {
1033
1085
  display: block;
1034
- -webkit-user-select: none;
1035
1086
  width: 100%;
1036
- height: 100%;
1087
+ height: auto;
1088
+ -webkit-user-select: none;
1037
1089
  object-fit: inherit;
1038
1090
  object-position: inherit;
1091
+ aspect-ratio: inherit;
1039
1092
  filter: inherit;
1040
1093
  transform: inherit;
1041
1094
  transition: inherit;
@@ -1043,7 +1096,37 @@ AiImg.styles = i$3`
1043
1096
  box-shadow: inherit;
1044
1097
  clip-path: inherit;
1045
1098
  }
1099
+
1100
+ :host(.spin)::before {
1101
+ content: "";
1102
+ position: absolute;
1103
+ inset: 0;
1104
+ margin: auto;
1105
+ background-image: url("data:image/svg+xml;utf8,${SPINNER_BG}");
1106
+ background-repeat: no-repeat;
1107
+ background-position: center;
1108
+ background-size: 25%;
1109
+ }
1046
1110
  `;
1111
+ let AiImg = _AiImg;
1112
+ __decorateClass([
1113
+ n$1({ type: String })
1114
+ ], AiImg.prototype, "src", 2);
1115
+ __decorateClass([
1116
+ n$1({ type: String })
1117
+ ], AiImg.prototype, "endpoint", 2);
1118
+ __decorateClass([
1119
+ n$1({ type: String })
1120
+ ], AiImg.prototype, "prompt", 2);
1121
+ __decorateClass([
1122
+ n$1({ type: String, attribute: "image-id" })
1123
+ ], AiImg.prototype, "imageId", 2);
1124
+ __decorateClass([
1125
+ n$1({ type: String })
1126
+ ], AiImg.prototype, "llm", 2);
1127
+ __decorateClass([
1128
+ n$1({ type: String })
1129
+ ], AiImg.prototype, "ratio", 2);
1047
1130
  __decorateClass([
1048
1131
  n$1({ type: String })
1049
1132
  ], AiImg.prototype, "fallback", 2);
@@ -1051,23 +1134,17 @@ __decorateClass([
1051
1134
  n$1({ type: String, reflect: true })
1052
1135
  ], AiImg.prototype, "width", 2);
1053
1136
  __decorateClass([
1054
- n$1({ type: String, reflect: true })
1137
+ n$1({ type: String })
1055
1138
  ], AiImg.prototype, "height", 2);
1056
- __decorateClass([
1057
- n$1({ type: String, reflect: true })
1058
- ], AiImg.prototype, "src", 2);
1059
1139
  __decorateClass([
1060
1140
  n$1({ type: String, reflect: true })
1061
1141
  ], AiImg.prototype, "alt", 2);
1062
1142
  __decorateClass([
1063
- n$1({ type: String, reflect: false })
1143
+ r$1()
1064
1144
  ], AiImg.prototype, "imgsrc", 2);
1065
- __decorateClass([
1066
- n$1({ type: String, reflect: true })
1067
- ], AiImg.prototype, "prompt", 2);
1068
- AiImg = __decorateClass([
1069
- t$1("ai-img")
1070
- ], AiImg);
1145
+ if (typeof customElements !== "undefined" && !customElements.get("ai-img")) {
1146
+ customElements.define("ai-img", AiImg);
1147
+ }
1071
1148
  export {
1072
1149
  AiImg
1073
1150
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wc-img-ai",
3
3
  "private": false,
4
- "version": "0.2.2",
4
+ "version": "0.3.1",
5
5
  "description": "Use AI to generate images for your img tags.",
6
6
  "author": {
7
7
  "name": "John Romani",
@@ -22,15 +22,26 @@
22
22
  "types"
23
23
  ],
24
24
  "main": "./dist/wc-img-ai.js",
25
+ "types": "./types/ai-img.d.ts",
25
26
  "exports": {
26
- ".": "./dist/wc-img-ai.js",
27
- "./wc-img-ai.d.ts": "./types/wc-img-ai.d.ts"
27
+ ".": {
28
+ "types": "./types/ai-img.d.ts",
29
+ "default": "./dist/wc-img-ai.js"
30
+ },
31
+ "./types": "./types/ai-img.d.ts"
32
+ },
33
+ "scripts": {
34
+ "dev": "vite",
35
+ "build": "tsc && vite build",
36
+ "preview": "vite preview",
37
+ "demo": "node demo/server.mjs"
28
38
  },
29
39
  "devDependencies": {
30
40
  "@typescript-eslint/eslint-plugin": "^6.17.0",
31
41
  "@typescript-eslint/parser": "^6.17.0",
32
42
  "eslint": "^8.56.0",
33
43
  "eslint-plugin-wc": "^2.0.4",
44
+ "nanoid": "^5.0.9",
34
45
  "typescript": "^5.2.2",
35
46
  "vite": "^5.0.8"
36
47
  },
@@ -42,10 +53,5 @@
42
53
  },
43
54
  "peerDependencies": {
44
55
  "lit": "^3.0.0"
45
- },
46
- "scripts": {
47
- "dev": "vite",
48
- "build": "tsc && vite build",
49
- "preview": "vite preview"
50
56
  }
51
- }
57
+ }
package/types/ai-img.d.ts CHANGED
@@ -1,18 +1,38 @@
1
1
  import { LitElement } from "lit";
2
2
  export declare class AiImg extends LitElement {
3
+ /**
4
+ * A ready image URL (or data URL). When set, the component acts as a plain
5
+ * <img> and never calls the AI endpoint — use it when you already have the
6
+ * image (e.g. a precomputed/returning one). Highest priority.
7
+ */
8
+ src: string;
9
+ /** Server route that owns the API key, generation, storage and lookup. */
10
+ endpoint: string;
11
+ /** Description used to generate the image (omit when fetching a known id). */
12
+ prompt: string;
13
+ /** Storage handle. Reflected after the server mints a new image. */
14
+ imageId: string;
15
+ /** Provider/model hint forwarded to the endpoint (e.g. "gemini", "openai"). */
16
+ llm: string;
17
+ /** Aspect ratio forwarded to the endpoint and used to derive an omitted height. */
18
+ ratio: string;
19
+ /** Shown when the image cannot be resolved (otherwise a 1x1 transparent PNG). */
3
20
  fallback: string;
4
21
  width: string;
5
22
  height: string;
6
- src: string;
7
23
  alt: string;
8
- imgsrc: string;
9
- prompt: string;
10
- imgAttributes: {
11
- [key: string]: string;
12
- };
24
+ private imgsrc;
25
+ private imgAttributes;
26
+ private onFallback;
27
+ private retried;
28
+ private resolvedUrl;
13
29
  connectedCallback(): void;
14
- fetchImage(): Promise<void>;
15
- initAttributes(): void;
30
+ private start;
31
+ private collectPassThroughAttributes;
32
+ private resolve;
33
+ private settle;
34
+ private settleFallback;
35
+ private onImgError;
16
36
  protected render(): import("lit").TemplateResult<1>;
17
37
  static styles: import("lit").CSSResult;
18
38
  }
@@ -1,2 +1,26 @@
1
1
  export declare const spinner = "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><style>.a{animation:b .8s linear infinite;fill:#888}.c{animation-delay:-.65s}.d{animation-delay:-.5s}@keyframes b{93.75%,100%{r:3px}46.875%{r:.2px}}</style><circle class=\"a\" cx=\"4\" cy=\"12\" r=\"3\"/><circle class=\"a c\" cx=\"12\" cy=\"12\" r=\"3\"/><circle class=\"a d\" cx=\"20\" cy=\"12\" r=\"3\"/></svg>";
2
- export declare const getGeneratedImage: (endpoint: string, prompt: string, width: number, height: number) => Promise<string | undefined>;
2
+ export declare const TRANSPARENT_PIXEL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
3
+ export interface ResolveImageRequest {
4
+ prompt?: string;
5
+ imageId?: string;
6
+ width?: number;
7
+ height?: number;
8
+ /** Provider/model hint forwarded to the endpoint (e.g. "gemini", "openai"). */
9
+ llm?: string;
10
+ /** Aspect ratio forwarded to the endpoint (e.g. "16:9", "4:1"). */
11
+ ratio?: string;
12
+ }
13
+ export interface ResolvedImage {
14
+ id: string;
15
+ url: string;
16
+ }
17
+ /**
18
+ * Sends a single POST to the endpoint and lets the server decide whether to
19
+ * return an already-stored image (looked up by `imageId`) or generate a new
20
+ * one. The component never branches on existence — it just trusts the result.
21
+ *
22
+ * Resolves to `{ id, url }` on success, or `null` when the endpoint is missing,
23
+ * the request fails, or the server reports the image could not be resolved
24
+ * (e.g. 404). A `null` tells the component to fall back.
25
+ */
26
+ export declare const resolveImage: (endpoint: string, req: ResolveImageRequest) => Promise<ResolvedImage | null>;