@aglyn/plugins-ai 1.0.0-beta.235 → 1.0.0-beta.237

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.
Files changed (98) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/ai-credits-prompt.component.d.ts +37 -0
  3. package/src/lib/components/ai-credits-prompt.component.js +67 -0
  4. package/src/lib/components/ai-credits-prompt.component.js.map +1 -0
  5. package/src/lib/components/ai-job-plan.component.d.ts +5 -4
  6. package/src/lib/components/ai-job-plan.component.js +37 -12
  7. package/src/lib/components/ai-job-plan.component.js.map +1 -1
  8. package/src/lib/components/ai-job-requests.d.ts +10 -0
  9. package/src/lib/components/ai-job-requests.js +12 -4
  10. package/src/lib/components/ai-job-requests.js.map +1 -1
  11. package/src/lib/components/ai-site-build-page.component.js +26 -7
  12. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  13. package/src/lib/components/ai-site-start-card.component.js +68 -22
  14. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  15. package/src/lib/components/assist-build-card.component.d.ts +3 -1
  16. package/src/lib/components/assist-build-card.component.js +58 -7
  17. package/src/lib/components/assist-build-card.component.js.map +1 -1
  18. package/src/lib/components/assist-jobs-drawer.component.js +54 -6
  19. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  20. package/src/lib/components/assist-panel.component.js +2 -1
  21. package/src/lib/components/assist-panel.component.js.map +1 -1
  22. package/src/lib/jobs/ai-job-admission.d.ts +22 -2
  23. package/src/lib/jobs/ai-job-admission.js.map +1 -1
  24. package/src/lib/jobs/ai-job-build-step.d.ts +24 -11
  25. package/src/lib/jobs/ai-job-build-step.js +50 -34
  26. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  27. package/src/lib/jobs/ai-job-layout-language.js +1 -1
  28. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  29. package/src/lib/jobs/ai-job-page-language.js +4 -0
  30. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  31. package/src/lib/jobs/ai-job-page-step.d.ts +13 -0
  32. package/src/lib/jobs/ai-job-page-step.js +34 -3
  33. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  34. package/src/lib/jobs/ai-job-plan-step.d.ts +23 -0
  35. package/src/lib/jobs/ai-job-plan-step.js +75 -15
  36. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  37. package/src/lib/jobs/ai-job-site-content.d.ts +34 -3
  38. package/src/lib/jobs/ai-job-site-content.js +132 -32
  39. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  40. package/src/lib/jobs/ai-job-site-step.js +45 -15
  41. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  42. package/src/lib/jobs/ai-job-text-step.d.ts +8 -0
  43. package/src/lib/jobs/ai-job-text-step.js.map +1 -1
  44. package/src/lib/jobs/ai-jobs.d.ts +18 -1
  45. package/src/lib/jobs/ai-jobs.js +29 -13
  46. package/src/lib/jobs/ai-jobs.js.map +1 -1
  47. package/src/lib/jobs/ai-layout-stock-photos.d.ts +105 -18
  48. package/src/lib/jobs/ai-layout-stock-photos.js +356 -114
  49. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  50. package/src/lib/layout-language/ai-layout-compiler.d.ts +27 -0
  51. package/src/lib/layout-language/ai-layout-compiler.js +78 -11
  52. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  53. package/src/lib/layout-language/ai-layout-gaps.js +3 -1
  54. package/src/lib/layout-language/ai-layout-gaps.js.map +1 -1
  55. package/src/lib/layout-language/ai-layout-language.js +1 -1
  56. package/src/lib/layout-language/ai-layout-language.js.map +1 -1
  57. package/src/lib/layout-language/ai-layout-listings.d.ts +37 -1
  58. package/src/lib/layout-language/ai-layout-listings.js +66 -11
  59. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  60. package/src/lib/layout-language/ai-layout-pictures.d.ts +2 -1
  61. package/src/lib/layout-language/ai-layout-pictures.js +5 -2
  62. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  63. package/src/lib/model/ai-build-job.d.ts +31 -0
  64. package/src/lib/model/ai-build-job.js +86 -1
  65. package/src/lib/model/ai-build-job.js.map +1 -1
  66. package/src/lib/model/ai-build-progress.d.ts +8 -0
  67. package/src/lib/model/ai-build-progress.js +33 -0
  68. package/src/lib/model/ai-build-progress.js.map +1 -1
  69. package/src/lib/model/ai-credit-estimate.d.ts +182 -0
  70. package/src/lib/model/ai-credit-estimate.js +190 -0
  71. package/src/lib/model/ai-credit-estimate.js.map +1 -0
  72. package/src/lib/model/ai-jobs.types.d.ts +15 -0
  73. package/src/lib/model/ai-jobs.types.js.map +1 -1
  74. package/src/lib/model/ai-site-job.d.ts +59 -22
  75. package/src/lib/model/ai-site-job.js +104 -23
  76. package/src/lib/model/ai-site-job.js.map +1 -1
  77. package/src/lib/model/ai-site-kinds.js +3 -3
  78. package/src/lib/model/ai-site-kinds.js.map +1 -1
  79. package/src/lib/runtime/ai-doctrine-validators.js +10 -1
  80. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  81. package/src/lib/runtime/ai-node-tree.d.ts +11 -0
  82. package/src/lib/runtime/ai-node-tree.js +26 -0
  83. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  84. package/src/lib/runtime/ai-palette.generated.js +539 -7
  85. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  86. package/src/lib/server/ai-jobs-resume.js +93 -35
  87. package/src/lib/server/ai-jobs-resume.js.map +1 -1
  88. package/src/lib/server/ai-jobs-route.js +45 -4
  89. package/src/lib/server/ai-jobs-route.js.map +1 -1
  90. package/src/lib/server/assist-chat.js +4 -1
  91. package/src/lib/server/assist-chat.js.map +1 -1
  92. package/src/lib/server/assist-edit.js +3 -1
  93. package/src/lib/server/assist-edit.js.map +1 -1
  94. package/src/lib/server/assist-music.d.ts +36 -0
  95. package/src/lib/server/assist-music.js +36 -0
  96. package/src/lib/server/assist-music.js.map +1 -0
  97. package/src/lib/server.js +6 -0
  98. package/src/lib/server.js.map +1 -1
@@ -28,17 +28,33 @@ import { aiLayoutSeedNumber } from "../layout-language/ai-layout-pictures.js";
28
28
  *
29
29
  * For each slot, in document order:
30
30
  *
31
- * 1. **Search.** Plain words: the kind of business (the head of the site's
32
- * business type, "yoga studio" from "a family-owned yoga studio in
33
- * Austin") for the hero; the picture's own subject (the content words of
34
- * the alt text the model wrote, else its section's name) with the business
35
- * for the rest, and an about picture asks for people. The orientation and
36
- * a minimum size come from the slot's frame. The provider caches every
37
- * search for a day and keeps its own rate limit.
38
- * 2. **Pick.** Among the first hits not already on the page, by the job's
39
- * seed and the slot, so two sites built from the same words rarely open
40
- * with the same photo while one job always picks the same.
41
- * 3. **Keep.** A photo the site's library already holds (by its source key)
31
+ * 1. **Search.** Plain words. The hero asks for the kind of business (the
32
+ * head of the site's business type, "yoga studio" from "a family-owned
33
+ * yoga studio in Austin"). Any other picture asks for its SUBJECT: the
34
+ * noun phrase that opens the alt text the model wrote, its filler
35
+ * adjectives dropped ("serving bowl" from "A beautiful hand-thrown serving
36
+ * bowl on a linen cloth"), qualified by the site's craft where the phrase
37
+ * does not already say it ("serving bowl ceramic" for a ceramics studio).
38
+ * An about picture asks for people. The orientation and a minimum size
39
+ * come from the slot's frame. The provider caches every search for a day
40
+ * and keeps its own rate limit; one page never asks the same words twice.
41
+ * 2. **Rank.** A picture of a thing must show the thing: every hit is scored
42
+ * by how many of the subject's words its tags, its description and its
43
+ * page's address name (singular and plural alike, the head noun counting
44
+ * double), and a hit naming none of them never fills a picture of a
45
+ * thing. The live Juniper Clay start put crackers under "Serving bowl"
46
+ * and plants under "Espresso cup". Among the best-scored hits the job's
47
+ * seed picks, so two sites built from the same words rarely open with the
48
+ * same photo while one job always picks the same.
49
+ * 3. **Never twice.** A photo is placed once per job: not twice on a page,
50
+ * and not on a page when another page (or another pass, in this process)
51
+ * of the same job already placed it — the maker's portrait in About is
52
+ * never a vase in Selected work. The caller hands in what the job's other
53
+ * pages hold (`avoid`); a pass repeated over its own page keeps its own.
54
+ * 4. **Fall back.** A picture of a thing nothing relevant answered asks once
55
+ * more for the place itself ("ceramics studio interior"), a neutral photo
56
+ * any caption sits under, before it is left to a starter photo.
57
+ * 5. **Keep.** A photo the site's library already holds (by its source key)
42
58
  * is reused; otherwise its bytes are downloaded from the library and
43
59
  * stored in the site's own media library as the member who started the
44
60
  * job, credited to its photographer. The page names the site's asset,
@@ -49,13 +65,42 @@ import { aiLayoutSeedNumber } from "../layout-language/ai-layout-pictures.js";
49
65
  * photo. Nothing here throws, and none of it is an AI model call: photos cost
50
66
  * no AI credits.
51
67
  */ /** Wall clock the whole page's photos may take, inside the step's own signal. */ export const AI_LAYOUT_STOCK_PHOTOS_BUDGET_MS = 20000;
52
- /** Searches one page may ask, cached ones included. */ export const AI_LAYOUT_STOCK_SEARCHES_PER_PAGE = 8;
68
+ /** Searches one page may send to the provider; a repeated query is answered from the page's own memory. */ export const AI_LAYOUT_STOCK_SEARCHES_PER_PAGE = 12;
53
69
  /** The hits a pick is made among. */ export const AI_LAYOUT_STOCK_PICK_WINDOW = 8;
70
+ /** The ranked hits one search may try to place before the slot moves to its next search. */ const AI_LAYOUT_STOCK_TRIES_PER_SEARCH = 3;
54
71
  /** The largest photo copied: inside one direct upload. */ export const AI_LAYOUT_STOCK_MAX_BYTES = 3 * 1024 * 1024;
72
+ /**
73
+ * The unit job input naming the screen drafts the job's other pages were
74
+ * written to, whose photos a page does not place again (AGL-3660).
75
+ */ export const AI_STOCK_PHOTO_PAGES_INPUT = 'stockPhotoPages';
55
76
  /** Words that carry no subject. */ const STOPWORDS = new Set(('a an the and or of in on at to for with from by into onto over under near our your their my his her its ' + 'this that these those is are be being was were who whom which where while as about than then very just ' + 'small local best new top quality premium family-owned family owned independent professional ' + 'business company shop services service site website page photo picture image showing shows show ' + 'someone people person one two three some many several').split(' '));
77
+ /**
78
+ * Words that describe a picture's look, not what it shows, dropped from a
79
+ * subject. A word that is as often a noun ("light", "set") is not one.
80
+ */ const FILLER = new Set(('beautiful beautifully stunning gorgeous lovely pretty elegant modern contemporary rustic cozy cosy warm cool ' + 'soft bright natural simple minimal minimalist delicate unique special artisan artisanal handmade ' + 'hand-made hand-thrown handthrown hand-crafted handcrafted crafted hand-built handbuilt finished styled perfect ' + 'fresh clean calm serene quiet peaceful vibrant colorful colourful sunlit sunny golden glossy matte textured ' + 'close-up closeup up view views detail details detailed shot shots overhead flat lay flatlay flat-lay ' + 'top-down wide angle featuring featured displayed arranged sitting resting placed standing lined ' + 'single pair collection assortment selection group few various array range large tiny big little ' + 'white black grey gray beige cream ivory brown tan blue green red pink yellow orange purple speckled ' + 'gentle airy inviting welcoming cheerful happy smiling friendly').split(' '));
81
+ /** The nouns that name a place of business, not its craft: "studio" in "ceramics studio". */ const VENUES = new Set(('studio studios shop shops store stores boutique company co workshop gallery atelier agency brand practice ' + 'salon house collective lab market services service firm clinic center centre parlor parlour bar ' + 'class classes lessons school academy').split(' '));
56
82
  /** Lowercase words, letters only, without stopwords. */ function contentWords(text) {
57
83
  return text.toLowerCase().replace(/https?:\/\/\S+|\S+@\S+|\+?\d[\d\s().-]{5,}\d/g, ' ').replace(/[^a-zÀ-ɏ\s-]/g, ' ').split(/\s+/).map((word)=>word.replace(/^-+|-+$/g, '')).filter((word)=>word.length > 1 && !STOPWORDS.has(word));
58
84
  }
85
+ /**
86
+ * A word's singular, for matching only: "bowls" and "bowl", "vases" and
87
+ * "vase", "berries" and "berry", "dishes" and "dish" meet. Both sides of a
88
+ * comparison are reduced the same way, so an odd stem still matches itself.
89
+ */ export function aiStockStem(word) {
90
+ const lower = word.toLowerCase();
91
+ if (lower.length > 4 && lower.endsWith('ies')) return `${lower.slice(0, -3)}y`;
92
+ if (lower.length > 4 && /(?:ches|shes|sses|xes|zes)$/.test(lower)) return lower.slice(0, -2);
93
+ if (lower.length > 3 && lower.endsWith('s') && !/(?:ss|us|is)$/.test(lower)) return lower.slice(0, -1);
94
+ return lower;
95
+ }
96
+ /** A text's words as stems; a hyphenated word counts as its parts. */ function stems(text) {
97
+ const out = new Set();
98
+ for (const word of text.toLowerCase().split(/[^a-zÀ-ɏ]+/)){
99
+ if (word.length > 1) out.add(aiStockStem(word));
100
+ }
101
+ return out;
102
+ }
103
+ /** Where a noun phrase ends: punctuation, a preposition, a conjunction. */ const CLAUSE_BREAK = /[,;:.()]|\s[-–—]\s|\b(?:with|on|in|at|against|beside|next|near|under|over|by|from|for|during|while|atop|inside|across|beneath|through|into|onto|amid|among|and|of|that|which|who)\b|&/;
59
104
  /**
60
105
  * The kind of business in a few words: the first clause of the business
61
106
  * type, before a place or an audience, its last three content words (the
@@ -65,10 +110,30 @@ import { aiLayoutSeedNumber } from "../layout-language/ai-layout-pictures.js";
65
110
  const words = contentWords(clause != null ? clause : '');
66
111
  return words.slice(-3).join(' ');
67
112
  }
68
- /** A picture's subject in a few words: its alt text's first content words, else its section's name. */ export function aiStockSubjectWords(alt, sectionName) {
69
- const words = contentWords(alt);
70
- if (words.length) return words.slice(0, 3).join(' ');
71
- return contentWords(sectionName).slice(0, 2).join(' ');
113
+ /**
114
+ * The business's craft in one word: the last of its words that is not the
115
+ * place it is done in, singular — "ceramic" from "small-batch ceramics
116
+ * studio", "yoga" from "yoga studio". Empty when the business is only a
117
+ * place ("a shop").
118
+ */ export function aiStockCraftWords(business) {
119
+ const words = business.split(/\s+/).filter((word)=>word && !VENUES.has(word) && !FILLER.has(word));
120
+ const craft = words[words.length - 1];
121
+ return craft ? aiStockStem(craft) : '';
122
+ }
123
+ /** A phrase's content words without its filler; all filler, and nothing is left. */ function subjectOf(words) {
124
+ return words.filter((word)=>!FILLER.has(word));
125
+ }
126
+ /**
127
+ * A picture's subject in a few words (AGL-3660): the noun phrase its alt
128
+ * text opens with — its first clause holding more than filler, cut where a
129
+ * preposition starts the setting, filler dropped, its last three words (the
130
+ * head noun is the last) — else its section's name.
131
+ */ export function aiStockSubjectWords(alt, sectionName) {
132
+ for (const clause of alt.toLowerCase().split(CLAUSE_BREAK)){
133
+ const words = subjectOf(contentWords(clause != null ? clause : ''));
134
+ if (words.length) return words.slice(-3).join(' ');
135
+ }
136
+ return subjectOf(contentWords(sectionName)).slice(0, 2).join(' ');
72
137
  }
73
138
  const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUERY_MAX_CHARS).trim();
74
139
  /** The frame's orientation: a wide frame wants a landscape photo, a tall one a portrait. */ export function aiStockOrientation(aspect) {
@@ -77,6 +142,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
77
142
  return 'any';
78
143
  }
79
144
  /** The searches a slot tries, in order, most specific first, each distinct. */ export function aiStockSearchesFor(slot, words) {
145
+ var _words_craft;
80
146
  const orientation = aiStockOrientation(slot.aspect);
81
147
  const size = slot.role === 'hero' ? {
82
148
  minWidth: 1600
@@ -85,21 +151,48 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
85
151
  } : {
86
152
  minWidth: 900
87
153
  };
154
+ const craft = (_words_craft = words.craft) != null ? _words_craft : aiStockCraftWords(words.business);
155
+ const subjectStems = stems(words.subject);
156
+ // The craft qualifies a subject that does not already say it.
157
+ const qualified = craft && [
158
+ ...stems(craft)
159
+ ].some((stem)=>!subjectStems.has(stem)) ? `${words.subject} ${craft}` : words.subject;
160
+ const place = words.business.split(/\s+/).some((word)=>VENUES.has(word)) ? `${words.business} interior` : words.business;
88
161
  const queries = slot.role === 'hero' ? [
89
- words.business,
90
- `${words.business} ${words.subject}`
162
+ {
163
+ query: words.business
164
+ },
165
+ {
166
+ query: `${words.business} ${words.subject}`
167
+ }
91
168
  ] : slot.role === 'about' ? [
92
- `${words.business} ${words.subject}`,
93
- words.business
169
+ {
170
+ query: `${words.business} ${words.subject}`
171
+ },
172
+ {
173
+ query: words.business
174
+ }
175
+ ] : words.subject ? [
176
+ {
177
+ query: qualified
178
+ },
179
+ {
180
+ query: words.subject
181
+ },
182
+ {
183
+ query: place,
184
+ neutral: true
185
+ }
94
186
  ] : [
95
- `${words.business} ${words.subject}`,
96
- words.subject,
97
- words.business
187
+ {
188
+ query: place,
189
+ neutral: true
190
+ }
98
191
  ];
99
192
  const seen = new Set();
100
193
  const searches = [];
101
194
  for (const raw of queries){
102
- const query = clip(raw);
195
+ const query = clip(raw.query);
103
196
  if (!query || seen.has(query)) continue;
104
197
  seen.add(query);
105
198
  searches.push(_extends({
@@ -107,15 +200,115 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
107
200
  orientation
108
201
  }, size, slot.role === 'about' ? {
109
202
  people: true
203
+ } : {}, raw.neutral ? {
204
+ neutral: true
110
205
  } : {}));
111
206
  }
112
207
  return searches;
113
208
  }
114
- /** The pick among a search's hits: not on the page yet, among the first few, by the seed. */ export function aiStockPick(photos, used, seed) {
115
- var _fresh_;
116
- const fresh = photos.filter((photo)=>!used.has(stockPhotoSourceKey(photo))).slice(0, AI_LAYOUT_STOCK_PICK_WINDOW);
117
- if (!fresh.length) return null;
118
- return (_fresh_ = fresh[aiLayoutSeedNumber(seed) % fresh.length]) != null ? _fresh_ : null;
209
+ /**
210
+ * How well a hit names a subject (AGL-3660): each subject word its tags, its
211
+ * description or its page's address names counts once, the head noun (the
212
+ * subject's last word) twice, and a craft word a quarter, as a tiebreak.
213
+ * Zero when it names none of the subject.
214
+ */ export function aiStockRelevance(photo, terms) {
215
+ var _photo_tags, _photo_alt, _terms_craft;
216
+ const subject = terms.subject.split(/\s+/).filter(Boolean).map(aiStockStem);
217
+ if (!subject.length) return 0;
218
+ let slug;
219
+ try {
220
+ slug = new URL(photo.pageUrl).pathname;
221
+ } catch (unused) {
222
+ slug = '';
223
+ }
224
+ const named = stems([
225
+ ...(_photo_tags = photo.tags) != null ? _photo_tags : [],
226
+ (_photo_alt = photo.alt) != null ? _photo_alt : '',
227
+ slug
228
+ ].join(' '));
229
+ const head = subject[subject.length - 1];
230
+ let score = 0;
231
+ for (const word of new Set(subject)){
232
+ if (named.has(word)) score += word === head ? 2 : 1;
233
+ }
234
+ if (score === 0) return 0;
235
+ for (const word of new Set(((_terms_craft = terms.craft) != null ? _terms_craft : '').split(/\s+/).filter(Boolean).map(aiStockStem))){
236
+ if (named.has(word)) score += 0.25;
237
+ }
238
+ return score;
239
+ }
240
+ /**
241
+ * A search's hits in the order they are tried (AGL-3660): those not placed
242
+ * yet, among the first few, best-scored first; the seed turns the hits that
243
+ * score alike, so two jobs differ and one job repeats itself.
244
+ */ export function aiStockRank(photos, used, seed, options = {}) {
245
+ var _options_subject;
246
+ const subject = (_options_subject = options.subject) != null ? _options_subject : '';
247
+ const scored = photos.filter((photo)=>!used.has(stockPhotoSourceKey(photo))).slice(0, AI_LAYOUT_STOCK_PICK_WINDOW).map((photo)=>({
248
+ photo,
249
+ score: subject ? aiStockRelevance(photo, _extends({
250
+ subject
251
+ }, options.craft ? {
252
+ craft: options.craft
253
+ } : {})) : 0
254
+ })).filter((entry)=>!options.strict || !subject || entry.score > 0);
255
+ if (!scored.length) return [];
256
+ const turn = aiLayoutSeedNumber(seed);
257
+ const ranked = [];
258
+ for (const score of [
259
+ ...new Set(scored.map((entry)=>entry.score))
260
+ ].sort((a, b)=>b - a)){
261
+ const tier = scored.filter((entry)=>entry.score === score);
262
+ const start = turn % tier.length;
263
+ for (const entry of [
264
+ ...tier.slice(start),
265
+ ...tier.slice(0, start)
266
+ ])ranked.push(entry.photo);
267
+ }
268
+ return ranked;
269
+ }
270
+ /** The pick among a search's hits: the first {@link aiStockRank} would try, or `null`. */ export function aiStockPick(photos, used, seed, options = {}) {
271
+ var _aiStockRank_;
272
+ return (_aiStockRank_ = aiStockRank(photos, used, seed, options)[0]) != null ? _aiStockRank_ : null;
273
+ }
274
+ /**
275
+ * The library photos a stored page already shows: every `image` whose
276
+ * source is the site's media reference. Starter photos are the platform's,
277
+ * not the job's, and are not listed.
278
+ */ export function aiStockPlacedSrcs(nodes) {
279
+ const map = nodes != null ? nodes : {};
280
+ const out = [];
281
+ for (const node of Object.values(map)){
282
+ var _node_props;
283
+ const src = (node == null ? void 0 : node.componentId) === 'image' ? (_node_props = node.props) == null ? void 0 : _node_props['src'] : null;
284
+ if (typeof src === 'string' && src.startsWith('media:')) out.push(src);
285
+ }
286
+ return out;
287
+ }
288
+ /**
289
+ * What this process has placed for each job, by source key and asset src,
290
+ * with the scope (the page's seed) that placed it (AGL-3660). It spans the
291
+ * passes of one job that run here, so a page, the products or a post built
292
+ * later does not place what an earlier one did, while a pass repeated over
293
+ * its own page keeps its own photos. Bounded; the oldest job goes first.
294
+ */ const JOB_PHOTOS = new Map();
295
+ const JOB_PHOTOS_MAX = 200;
296
+ function jobPhotos(key) {
297
+ if (!key) return null;
298
+ let placed = JOB_PHOTOS.get(key);
299
+ if (!placed) {
300
+ placed = new Map();
301
+ JOB_PHOTOS.set(key, placed);
302
+ while(JOB_PHOTOS.size > JOB_PHOTOS_MAX){
303
+ const oldest = JOB_PHOTOS.keys().next().value;
304
+ if (oldest === undefined) break;
305
+ JOB_PHOTOS.delete(oldest);
306
+ }
307
+ }
308
+ return placed;
309
+ }
310
+ /** Forgets every job's placed photos; for specs. */ export function aiStockForgetJobPhotos() {
311
+ JOB_PHOTOS.clear();
119
312
  }
120
313
  const EXTENSIONS = {
121
314
  'image/jpeg': 'jpg',
@@ -131,16 +324,128 @@ const EXTENSIONS = {
131
324
  const ingest = ((_deps_ingest = deps.ingest) != null ? _deps_ingest : pluginMediaIngest)();
132
325
  if (!provider || !ingest || !input.hostId || !input.uid) return null;
133
326
  const business = aiStockBusinessWords(input.business);
327
+ const craft = aiStockCraftWords(business);
134
328
  return async (slots)=>{
135
- var _deps_budgetMs;
329
+ var _deps_budgetMs, _input_avoid;
136
330
  const timeout = AbortSignal.timeout((_deps_budgetMs = deps.budgetMs) != null ? _deps_budgetMs : AI_LAYOUT_STOCK_PHOTOS_BUDGET_MS);
137
331
  const signal = input.signal ? AbortSignal.any([
138
332
  input.signal,
139
333
  timeout
140
334
  ]) : timeout;
141
- const used = new Set();
335
+ const scope = input.seed;
336
+ const job = jobPhotos(input.jobId ? `${input.hostId}:${input.jobId}` : null);
337
+ const avoid = new Set((_input_avoid = input.avoid) != null ? _input_avoid : []);
338
+ /** Placed elsewhere in the job: by another page in this process, or one the caller read. */ const elsewhere = (key)=>avoid.has(key) || (job == null ? void 0 : job.has(key)) === true && job.get(key) !== scope;
339
+ /** Source keys this page has placed or turned down. */ const used = new Set();
340
+ /** Asset srcs this page has placed. */ const placedSrcs = new Set();
341
+ const answers = new Map();
142
342
  let searches = 0;
143
343
  let storing = true;
344
+ const remember = (sourceKey, src)=>{
345
+ placedSrcs.add(src);
346
+ job == null ? void 0 : job.set(sourceKey, scope);
347
+ job == null ? void 0 : job.set(src, scope);
348
+ };
349
+ /** A search's hits; the same words twice are answered from memory, and `null` once the page's searches are spent. */ const search = async (wanted)=>{
350
+ const request = _extends({}, wanted);
351
+ delete request.neutral;
352
+ const key = JSON.stringify([
353
+ request.query,
354
+ request.orientation,
355
+ request.people === true
356
+ ]);
357
+ const known = answers.get(key);
358
+ if (known) return known;
359
+ if (searches >= AI_LAYOUT_STOCK_SEARCHES_PER_PAGE) return null;
360
+ searches += 1;
361
+ let found;
362
+ try {
363
+ var _ref;
364
+ var _this;
365
+ found = (_ref = (_this = await provider.search(request, {
366
+ signal
367
+ })) == null ? void 0 : _this.photos) != null ? _ref : [];
368
+ } catch (unused) {
369
+ found = [];
370
+ }
371
+ answers.set(key, found);
372
+ return found;
373
+ };
374
+ /** The photo placed in the site's library, a copy it holds or a new one; `null` when it cannot be placed. */ const keep = async (photo, slot, query)=>{
375
+ const sourceKey = stockPhotoSourceKey(photo);
376
+ used.add(sourceKey);
377
+ try {
378
+ var _EXTENSIONS_bytes_contentType;
379
+ const kept = await ingest.findStockPhoto({
380
+ hostId: input.hostId,
381
+ sourceKey
382
+ });
383
+ if (kept) {
384
+ var _kept_width, _kept_height;
385
+ // Held because a slot of this page or another page of the job shows it.
386
+ if (placedSrcs.has(kept.src) || elsewhere(kept.src)) return null;
387
+ remember(sourceKey, kept.src);
388
+ return {
389
+ src: kept.src,
390
+ width: (_kept_width = kept.width) != null ? _kept_width : photo.width,
391
+ height: (_kept_height = kept.height) != null ? _kept_height : photo.height
392
+ };
393
+ }
394
+ // A refusal to store (the storage band, a lockdown) holds for the
395
+ // whole page, so the rest of it reuses what it can and copies nothing.
396
+ if (!storing) return null;
397
+ const bytes = await provider.download(photo, {
398
+ maxBytes: AI_LAYOUT_STOCK_MAX_BYTES,
399
+ signal
400
+ });
401
+ if (!bytes || signal.aborted) return null;
402
+ const credit = provider.credit(photo);
403
+ const stored = await ingest.ingest({
404
+ hostId: input.hostId,
405
+ uid: input.uid,
406
+ fileName: `${photo.provider}-${photo.id}.${(_EXTENSIONS_bytes_contentType = EXTENSIONS[bytes.contentType]) != null ? _EXTENSIONS_bytes_contentType : 'jpg'}`,
407
+ contentType: bytes.contentType,
408
+ bytes: bytes.bytes,
409
+ alt: slot.alt,
410
+ description: credit.text,
411
+ stockPhoto: _extends({
412
+ key: sourceKey,
413
+ provider: photo.provider,
414
+ providerLabel: credit.providerLabel,
415
+ id: photo.id,
416
+ pageUrl: photo.pageUrl,
417
+ photographer: photo.photographer
418
+ }, photo.photographerUrl ? {
419
+ photographerUrl: photo.photographerUrl
420
+ } : {}, {
421
+ license: credit.license,
422
+ licenseUrl: credit.licenseUrl,
423
+ attributionRequired: credit.attributionRequired,
424
+ query
425
+ })
426
+ });
427
+ if (stored.ok) {
428
+ var _stored_width, _stored_height;
429
+ remember(sourceKey, stored.src);
430
+ return {
431
+ src: stored.src,
432
+ width: (_stored_width = stored.width) != null ? _stored_width : photo.width,
433
+ height: (_stored_height = stored.height) != null ? _stored_height : photo.height
434
+ };
435
+ }
436
+ storing = false;
437
+ const refusal = stored;
438
+ console.warn('ai layout pictures: the media library refused a stock photo', {
439
+ status: refusal.status,
440
+ reason: refusal.reason
441
+ });
442
+ } catch (error) {
443
+ console.warn('ai layout pictures: a stock photo could not be kept', {
444
+ error: String(error)
445
+ });
446
+ }
447
+ return null;
448
+ };
144
449
  const photos = [];
145
450
  for (const [index, slot] of slots.entries()){
146
451
  var _input_sectionNames_slot_sectionIndex;
@@ -152,91 +457,28 @@ const EXTENSIONS = {
152
457
  let placed = null;
153
458
  for (const request of aiStockSearchesFor(slot, {
154
459
  business,
155
- subject
460
+ subject,
461
+ craft
156
462
  })){
157
- if (placed || signal.aborted || searches >= AI_LAYOUT_STOCK_SEARCHES_PER_PAGE) break;
158
- searches += 1;
159
- let found;
160
- try {
161
- var _ref;
162
- var _this;
163
- found = (_ref = (_this = await provider.search(request, {
164
- signal
165
- })) == null ? void 0 : _this.photos) != null ? _ref : [];
166
- } catch (unused) {
167
- found = [];
463
+ if (placed || signal.aborted) break;
464
+ const found = await search(request);
465
+ if (found === null) break;
466
+ // A gallery picture names a thing, and only a hit naming it fills it;
467
+ // a hero, an about picture and the neutral search rank, unfiltered.
468
+ const blocked = new Set(used);
469
+ for (const hit of found){
470
+ const key = stockPhotoSourceKey(hit);
471
+ if (elsewhere(key)) blocked.add(key);
168
472
  }
169
- const photo = aiStockPick(found, used, `${input.seed}:${index}:${request.query}`);
170
- if (!photo) continue;
171
- const sourceKey = stockPhotoSourceKey(photo);
172
- used.add(sourceKey);
173
- try {
174
- var _EXTENSIONS_bytes_contentType;
175
- const kept = await ingest.findStockPhoto({
176
- hostId: input.hostId,
177
- sourceKey
178
- });
179
- if (kept) {
180
- var _kept_width, _kept_height;
181
- placed = {
182
- src: kept.src,
183
- width: (_kept_width = kept.width) != null ? _kept_width : photo.width,
184
- height: (_kept_height = kept.height) != null ? _kept_height : photo.height
185
- };
186
- break;
187
- }
188
- // A refusal to store (the storage band, a lockdown) holds for the
189
- // whole page, so the rest of it reuses what it can and copies nothing.
190
- if (!storing) continue;
191
- const bytes = await provider.download(photo, {
192
- maxBytes: AI_LAYOUT_STOCK_MAX_BYTES,
193
- signal
194
- });
195
- if (!bytes || signal.aborted) continue;
196
- const credit = provider.credit(photo);
197
- const stored = await ingest.ingest({
198
- hostId: input.hostId,
199
- uid: input.uid,
200
- fileName: `${photo.provider}-${photo.id}.${(_EXTENSIONS_bytes_contentType = EXTENSIONS[bytes.contentType]) != null ? _EXTENSIONS_bytes_contentType : 'jpg'}`,
201
- contentType: bytes.contentType,
202
- bytes: bytes.bytes,
203
- alt: slot.alt,
204
- description: credit.text,
205
- stockPhoto: _extends({
206
- key: sourceKey,
207
- provider: photo.provider,
208
- providerLabel: credit.providerLabel,
209
- id: photo.id,
210
- pageUrl: photo.pageUrl,
211
- photographer: photo.photographer
212
- }, photo.photographerUrl ? {
213
- photographerUrl: photo.photographerUrl
214
- } : {}, {
215
- license: credit.license,
216
- licenseUrl: credit.licenseUrl,
217
- attributionRequired: credit.attributionRequired,
218
- query: request.query
219
- })
220
- });
221
- if (stored.ok) {
222
- var _stored_width, _stored_height;
223
- placed = {
224
- src: stored.src,
225
- width: (_stored_width = stored.width) != null ? _stored_width : photo.width,
226
- height: (_stored_height = stored.height) != null ? _stored_height : photo.height
227
- };
228
- } else {
229
- storing = false;
230
- const refusal = stored;
231
- console.warn('ai layout pictures: the media library refused a stock photo', {
232
- status: refusal.status,
233
- reason: refusal.reason
234
- });
235
- }
236
- } catch (error) {
237
- console.warn('ai layout pictures: a stock photo could not be kept', {
238
- error: String(error)
239
- });
473
+ const ranked = aiStockRank(found, blocked, `${input.seed}:${index}:${request.query}`, {
474
+ subject: request.neutral ? craft || business : subject,
475
+ craft,
476
+ strict: slot.role === 'gallery' && !request.neutral
477
+ });
478
+ for (const photo of ranked.slice(0, AI_LAYOUT_STOCK_TRIES_PER_SEARCH)){
479
+ if (signal.aborted) break;
480
+ placed = await keep(photo, slot, request.query);
481
+ if (placed) break;
240
482
  }
241
483
  }
242
484
  photos.push(placed);