wc-img-ai 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,124 @@ 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` | ✅ yes | 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`). |
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` | ✅ yes | Intrinsic height (like `<img height>`). |
50
+ | `alt` | ✅ yes | Alt text, passed to the inner `<img>`. |
51
+
52
+ ### Sizing & styling — just like a native `<img>`
53
+
54
+ Set `width`/`height` and style with `class`/CSS; you rarely need inline
55
+ `style`. `width`/`height` become the inner image's content attributes, so the
56
+ browser reserves the box from their aspect-ratio (no layout shift) while CSS
57
+ controls the displayed size:
58
+
59
+ ```html
60
+ <!-- full-width 3:1 banner, rounded, no layout shift -->
61
+ <ai-img endpoint="/api/img" prompt="…" width="1536" height="512"
62
+ class="block w-full rounded-xl"></ai-img>
63
+ ```
64
+
65
+ Visual properties bridge the shadow boundary via `inherit`, so utility classes
66
+ like `rounded-xl`, `object-cover`, `shadow-lg` on `<ai-img>` style the image.
67
+ Any other attribute (e.g. `loading`) is passed through to the inner `<img>`.
28
68
 
29
- ### Function: POST
69
+ ## How it resolves
30
70
 
31
- The web-component sends a POST request to the API to generate an image and expects an image in return.
71
+ ```
72
+ src set → render it as a plain <img> (no endpoint call)
73
+ else no prompt, no image-id → fallback → 1×1 transparent PNG (nothing to ask)
74
+ else → POST endpoint once { prompt, imageId, width, height, llm, ratio }
75
+ 200 {id,url} → render url, reflect image-id, fire `ai-image` event
76
+ 404 / error → fallback → 1×1 transparent PNG
77
+ ```
32
78
 
33
- #### Request Body Format
79
+ `src` is the cheapest path: if you already have the image (a precomputed or
80
+ returning one), set `src` and the component skips the AI entirely. Only when
81
+ `src` is empty does it fall back to the `prompt`/`image-id` flow.
34
82
 
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.
83
+ > **Performance note.** For an image you _already have the URL for_, a plain
84
+ > `<img src>` will paint faster than `<ai-img src>` — the web component can't
85
+ > render until its own script has loaded and defined the element, whereas a
86
+ > native `<img>` loads with the document. Use `src` for a single unified tag;
87
+ > reach for a native `<img>` (and load `<ai-img>` only when you need to
88
+ > generate) when first paint of a known image is critical.
38
89
 
39
- #### Example Request Body
90
+ The component never branches on whether an image exists — the **server** owns
91
+ that decision (see the contract below). If the returned `url` itself fails to
92
+ load in the `<img>` (a transient 404, slow propagation, a stale url), the
93
+ component retries once and then drops to the fallback chain.
94
+
95
+ ## The `ai-image` event
96
+
97
+ Fired after a successful resolve. Use it to persist the id (and url) to a
98
+ database so you can render the same image again later without re-generating.
99
+
100
+ ```js
101
+ el.addEventListener("ai-image", (e) => {
102
+ // e.detail = { id, url, prompt }
103
+ db.save(e.detail)
104
+ })
105
+ ```
106
+
107
+ The minted id is also reflected onto the `image-id` attribute.
108
+
109
+ ## Server contract
110
+
111
+ The component talks to a single endpoint with one POST. Your server is the
112
+ "smart" side that decides between fetching a stored image and generating a new
113
+ one.
114
+
115
+ ### `POST {endpoint}`
116
+
117
+ Request body:
40
118
 
41
119
  ```json
42
- {
43
- "prompt": "a sleeping little kitten",
44
- "width": 256,
45
- "height": 256
46
- }
120
+ { "prompt": "a funny dolphin", "imageId": "V1StGXR8_Z5", "width": 256, "height": 256, "llm": "gemini", "ratio": "16:9" }
47
121
  ```
48
122
 
49
- #### Server Return
123
+ `imageId`, `width`, `height`, `llm` and `ratio` are optional. Use `llm`/`ratio`
124
+ to let the server pick a provider and aspect ratio. Expected server behaviour:
125
+
126
+ | Condition | Response |
127
+ | ------------------------------------------- | ----------------- |
128
+ | `imageId` given and stored | `200 {id,url}` — return it, no AI |
129
+ | `imageId` given but missing, `prompt` given | `200 {id,url}` — generate (mint a new id) |
130
+ | no `imageId`, `prompt` given | `200 {id,url}` — generate |
131
+ | nothing to do | `404` |
50
132
 
51
- it is expected to return a simple string
133
+ Response body on success:
52
134
 
53
135
  ```json
54
- "https://example.com/path/to/generated/image.jpg"
136
+ { "id": "V1StGXR8_Z5", "url": "/images/V1StGXR8_Z5.png" }
137
+ ```
138
+
139
+ `url` can be anything the browser can load (a hosted/CDN URL, a route on your
140
+ server, or a data URL).
141
+
142
+ ## Reference demo server
143
+
144
+ `demo/server.mjs` is a complete, dependency-light reference implementation:
145
+ OpenAI `gpt-image-2`, nanoid ids, filesystem storage under `images/`, served
146
+ back as stable URLs.
147
+
148
+ ```bash
149
+ cp .env.example .env # add OPENAI_API_KEY
150
+ pnpm install
151
+ pnpm build
152
+ pnpm demo # http://localhost:3000
55
153
  ```
56
154
 
57
- #### Demo
155
+ ## License
58
156
 
59
- Check it out [running](https://john.ro/lab/img-ai) inside an MDX/Astro framework
157
+ 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,150 @@ 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 _AiImg = class _AiImg extends s$1 {
928
951
  constructor() {
929
952
  super(...arguments);
953
+ this.src = "";
954
+ this.endpoint = "";
955
+ this.prompt = "";
956
+ this.imageId = "";
957
+ this.llm = "";
958
+ this.ratio = "";
930
959
  this.fallback = "";
931
960
  this.width = "";
932
961
  this.height = "";
933
- this.src = "";
934
962
  this.alt = "";
935
- this.imgsrc = "";
936
- this.prompt = "";
963
+ this.imgsrc = TRANSPARENT_PIXEL;
937
964
  this.imgAttributes = {};
965
+ this.onFallback = false;
966
+ this.retried = false;
967
+ this.resolvedUrl = "";
968
+ this.onImgError = () => {
969
+ if (this.onFallback || !this.resolvedUrl)
970
+ return;
971
+ if (!this.retried) {
972
+ this.retried = true;
973
+ const sep = this.resolvedUrl.includes("?") ? "&" : "?";
974
+ const url = `${this.resolvedUrl}${sep}retry=${Date.now()}`;
975
+ setTimeout(() => {
976
+ this.imgsrc = url;
977
+ }, 800);
978
+ return;
979
+ }
980
+ this.resolvedUrl = "";
981
+ this.settleFallback();
982
+ };
938
983
  }
939
984
  connectedCallback() {
940
985
  super.connectedCallback();
941
- this.initAttributes();
942
- this.fetchImage();
986
+ queueMicrotask(() => this.start());
943
987
  }
944
- async fetchImage() {
945
- if (!this.prompt) {
946
- this.imgsrc = this.fallback;
988
+ start() {
989
+ this.collectPassThroughAttributes();
990
+ if (this.src) {
991
+ this.resolvedUrl = this.src;
992
+ this.settle(this.src);
947
993
  return;
948
994
  }
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");
995
+ if (!this.prompt && !this.imageId) {
996
+ this.settleFallback();
997
+ return;
962
998
  }
999
+ this.imgsrc = placeholder(this.width, this.height);
1000
+ this.classList.add("spin");
1001
+ void this.resolve();
963
1002
  }
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)) {
1003
+ collectPassThroughAttributes() {
1004
+ for (const attr of Array.from(this.attributes)) {
1005
+ if (!RESERVED_ATTRS.has(attr.name)) {
976
1006
  this.imgAttributes[attr.name] = attr.value;
977
1007
  }
978
- if (attr.name === "width") {
979
- this.width = attr.value;
980
- }
981
- if (attr.name === "height") {
982
- this.height = attr.value;
983
- }
1008
+ }
1009
+ }
1010
+ async resolve() {
1011
+ const result = await resolveImage(this.endpoint, {
1012
+ prompt: this.prompt,
1013
+ imageId: this.imageId,
1014
+ width: Number(this.width) || void 0,
1015
+ height: Number(this.height) || void 0,
1016
+ llm: this.llm,
1017
+ ratio: this.ratio
984
1018
  });
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>`
1019
+ if (!result) {
1020
+ this.settleFallback();
1021
+ return;
1022
+ }
1023
+ if (result.id && result.id !== this.imageId) {
1024
+ this.imageId = result.id;
1025
+ }
1026
+ this.dispatchEvent(
1027
+ new CustomEvent("ai-image", {
1028
+ detail: { id: result.id, url: result.url, prompt: this.prompt },
1029
+ bubbles: true,
1030
+ composed: true
1031
+ })
987
1032
  );
988
- this.classList.add("spin");
1033
+ this.resolvedUrl = result.url;
1034
+ this.retried = false;
1035
+ this.onFallback = false;
1036
+ this.settle(result.url);
1037
+ }
1038
+ settle(src) {
1039
+ this.imgsrc = src;
1040
+ this.classList.remove("spin");
1041
+ }
1042
+ settleFallback() {
1043
+ this.onFallback = true;
1044
+ this.settle(this.fallback || TRANSPARENT_PIXEL);
989
1045
  }
990
1046
  render() {
991
1047
  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>`}
1048
+ <img
1049
+ src=${this.imgsrc}
1050
+ alt=${this.alt}
1051
+ width=${this.width || T}
1052
+ height=${this.height || T}
1053
+ decoding="async"
1054
+ @error=${this.onImgError}
1055
+ ${spread(this.imgAttributes)}
1056
+ />
1024
1057
  `;
1025
1058
  }
1026
1059
  };
1027
- AiImg.styles = i$3`
1060
+ _AiImg.styles = i$3`
1028
1061
  :host {
1029
1062
  display: inline-block;
1063
+ position: relative;
1064
+ line-height: 0;
1030
1065
  }
1031
1066
 
1032
1067
  img {
1033
1068
  display: block;
1034
- -webkit-user-select: none;
1035
1069
  width: 100%;
1036
- height: 100%;
1070
+ height: auto;
1071
+ -webkit-user-select: none;
1037
1072
  object-fit: inherit;
1038
1073
  object-position: inherit;
1074
+ aspect-ratio: inherit;
1039
1075
  filter: inherit;
1040
1076
  transform: inherit;
1041
1077
  transition: inherit;
@@ -1043,7 +1079,37 @@ AiImg.styles = i$3`
1043
1079
  box-shadow: inherit;
1044
1080
  clip-path: inherit;
1045
1081
  }
1082
+
1083
+ :host(.spin)::before {
1084
+ content: "";
1085
+ position: absolute;
1086
+ inset: 0;
1087
+ margin: auto;
1088
+ background-image: url("data:image/svg+xml;utf8,${SPINNER_BG}");
1089
+ background-repeat: no-repeat;
1090
+ background-position: center;
1091
+ background-size: 25%;
1092
+ }
1046
1093
  `;
1094
+ let AiImg = _AiImg;
1095
+ __decorateClass([
1096
+ n$1({ type: String })
1097
+ ], AiImg.prototype, "src", 2);
1098
+ __decorateClass([
1099
+ n$1({ type: String })
1100
+ ], AiImg.prototype, "endpoint", 2);
1101
+ __decorateClass([
1102
+ n$1({ type: String })
1103
+ ], AiImg.prototype, "prompt", 2);
1104
+ __decorateClass([
1105
+ n$1({ type: String, attribute: "image-id", reflect: true })
1106
+ ], AiImg.prototype, "imageId", 2);
1107
+ __decorateClass([
1108
+ n$1({ type: String })
1109
+ ], AiImg.prototype, "llm", 2);
1110
+ __decorateClass([
1111
+ n$1({ type: String })
1112
+ ], AiImg.prototype, "ratio", 2);
1047
1113
  __decorateClass([
1048
1114
  n$1({ type: String })
1049
1115
  ], AiImg.prototype, "fallback", 2);
@@ -1053,21 +1119,15 @@ __decorateClass([
1053
1119
  __decorateClass([
1054
1120
  n$1({ type: String, reflect: true })
1055
1121
  ], AiImg.prototype, "height", 2);
1056
- __decorateClass([
1057
- n$1({ type: String, reflect: true })
1058
- ], AiImg.prototype, "src", 2);
1059
1122
  __decorateClass([
1060
1123
  n$1({ type: String, reflect: true })
1061
1124
  ], AiImg.prototype, "alt", 2);
1062
1125
  __decorateClass([
1063
- n$1({ type: String, reflect: false })
1126
+ r$1()
1064
1127
  ], 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);
1128
+ if (typeof customElements !== "undefined" && !customElements.get("ai-img")) {
1129
+ customElements.define("ai-img", AiImg);
1130
+ }
1071
1131
  export {
1072
1132
  AiImg
1073
1133
  };
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.0",
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: set by the server when a new image is minted. */
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 (e.g. "16:9", "4:1"). */
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>;