@aglyn/plugins-ai 1.0.0-beta.241 → 1.0.0-beta.243

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.
@@ -95,6 +95,28 @@ import { aiStorefrontSays } from "../layout-language/ai-layout-storefront.js";
95
95
  * to its photographer. A copy that fails tries the slot's next accepted
96
96
  * hit.
97
97
  *
98
+ * 6. **Fall back to the craft, never off it** (the beta.241 Saltmarsh Soap
99
+ * Co start, a store "for handmade cold-process soap": its craft read as
100
+ * nothing, its hero searched "shop" and took a florist's bouquet, and four
101
+ * of five products took the starters' laptop desk and living room). A
102
+ * business that is only a place takes its craft from what it is for
103
+ * ("soap shop"); a hero names the craft itself ({@link AiStockSiteTerms.core});
104
+ * a product, after its own searches, tries its head noun anywhere among
105
+ * the tags, its gift wrapper with the craft and the craft's broad
106
+ * searches, and then — a product or a hero only — reuses an on-topic photo
107
+ * the job already shows rather than take a starter. Every slot of a
108
+ * craft's site tries the craft's broad searches last.
109
+ * 7. **A product takes the surest photo, even a borrowed one** (the beta.242
110
+ * Tidewater Soap Works start: a sauna for "Bath Gift Set", colognes for
111
+ * "Natural Body Lotion", a bathroom shelf for "Soap Bar Trio" and a soap
112
+ * dispenser for its starter size, while the collection tiles beside them
113
+ * were good). A product's searches are ranked by how sure they are: its
114
+ * own photo (the head noun or its kin among the first tags), then a photo
115
+ * OF its category, new or one the job already shows, then a weak hit of
116
+ * its own. No product shows a room or a scene, a fragrance bottle, or what
117
+ * its kin shuns (a pump for a bar) unless it names it; "bath" in a bath
118
+ * gift set is what the set is for; a size is never the product.
119
+ *
98
120
  * A slot nothing acceptable answered — the library down, refusing, or out
99
121
  * of time — is left `null` for the starter photos. A named object is never
100
122
  * given one while the library answers anything of the site's world: the
@@ -120,7 +142,8 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
120
142
  /**
121
143
  * Words that describe a picture's look, not what it shows, dropped from a
122
144
  * subject. A word that is as often a noun ("light", "set") is not one.
123
- */ const FILLER = words('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 hand-poured ' + 'hand-painted hand-dyed hand-stitched poured finished styled perfect wheel-thrown wheelthrown thrown ' + '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 ' + 'tall short wide narrow everyday classic signature original favorite favourite bestselling best-selling ' + 'small-batch batch limited edition custom bespoke ' + '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');
145
+ */ const FILLER = words('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 hand-poured ' + 'hand-painted hand-dyed hand-stitched poured finished styled perfect wheel-thrown wheelthrown thrown ' + '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 ' + 'tall short wide narrow everyday classic signature original favorite favourite bestselling best-selling ' + 'small-batch batch limited edition custom bespoke ' + // How a soap is made, never what it shows: "cold" and "process" named a supermarket fridge (Saltmarsh, 2026-10-10).
146
+ 'cold-process cold-processed hot-process hot-processed melt-and-pour ' + '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');
124
147
  /** The nouns that name a place of business, not its craft: "studio" in "ceramics studio". */ const VENUES = words('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');
125
148
  /** The person who does the craft, not the craft: "artist" in "ceramic artist". */ const ROLES = words('artist artists maker makers artisan artisans owner owners founder founders creator creators');
126
149
  /** Words naming what kind of SITE it is, never what it shows: "portfolio" in "a portfolio for a ceramic artist". */ const SITE_KINDS = words('portfolio portfolios website websites site sites blog blogs online web webshop ecommerce e-commerce ' + 'landing personal homepage storefront');
@@ -136,6 +159,47 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
136
159
  * an empty frame on the page (the Juniper Clay hero), a template or a
137
160
  * graphic is not a photo of anything.
138
161
  */ const REJECTED_TAGS = words('mockup mock-up mock blank template templates clipart clip-art illustration vector logo icon ' + 'ai-generated generated render rendering 3d');
162
+ /**
163
+ * Rooms and scenes, never a product (AGL-3660): the beta.242 Tidewater Soap
164
+ * Works start put a wooden sauna under "Bath Gift Set" and a bathroom shelf of
165
+ * baskets under "Soap Bar Trio". A product's hit whose first tags name one of
166
+ * these is turned down unless the product or the brief names it too.
167
+ */ const PRODUCT_SCENES = new Set([
168
+ 'sauna',
169
+ 'bathroom',
170
+ 'bathtub',
171
+ 'shower',
172
+ 'toilet',
173
+ 'sink',
174
+ 'washbasin',
175
+ 'faucet',
176
+ 'tap',
177
+ 'shelf',
178
+ 'shelves',
179
+ 'interior',
180
+ 'bedroom',
181
+ 'kitchen',
182
+ 'room',
183
+ 'livingroom',
184
+ 'hotel',
185
+ 'lobby'
186
+ ].map(aiStockStem));
187
+ /**
188
+ * A fragrance's bottle, never a lotion, a cream or a soap (AGL-3660): the same
189
+ * start's "Natural Body Lotion" showed blue cologne bottles tagged "perfume,
190
+ * bottle, lotion". Turned down among a product's first tags unless the
191
+ * product or the brief is a fragrance.
192
+ */ const FRAGRANCE_BOTTLES = new Set([
193
+ 'perfume',
194
+ 'perfumes',
195
+ 'perfumery',
196
+ 'cologne',
197
+ 'colognes',
198
+ 'parfum',
199
+ 'eau',
200
+ 'atomizer',
201
+ 'fragrance'
202
+ ].map(aiStockStem));
139
203
  /**
140
204
  * Topics a business's photos never carry unless its own words name them
141
205
  * (AGL-3660, the beta.239 Ember & Oak start, whose "Gifts for melt fans" tile
@@ -303,6 +367,7 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
303
367
  },
304
368
  {
305
369
  // Tobacco and drugs.
370
+ // "cbd" and "cannabinoid": a hemp cream tube was the next "body lotion" answer (Tidewater, 2026-10-11).
306
371
  words: [
307
372
  'cigarette',
308
373
  'smoking',
@@ -310,7 +375,10 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
310
375
  'cannabis',
311
376
  'marijuana',
312
377
  'drug',
313
- 'drugs'
378
+ 'drugs',
379
+ 'cbd',
380
+ 'cannabinoid',
381
+ 'thc'
314
382
  ],
315
383
  allow: [
316
384
  'dispensary',
@@ -469,6 +537,129 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
469
537
  'wax melt warmer',
470
538
  'scented wax cubes'
471
539
  ]
540
+ },
541
+ {
542
+ // A "bar" is a pub's before it is a soap's (the Saltmarsh "cold-process
543
+ // soap bar" search answered a pint of beer second): a soap bar is named
544
+ // by soap, and searched by it.
545
+ objects: [
546
+ 'bar'
547
+ ],
548
+ within: [
549
+ 'soap',
550
+ 'soapmaker',
551
+ 'skincare'
552
+ ],
553
+ qualifier: 'soap',
554
+ kin: [
555
+ 'soap bar',
556
+ 'soap bars',
557
+ 'bar of soap',
558
+ 'bars of soap',
559
+ 'soap',
560
+ 'soaps',
561
+ 'handmade soap',
562
+ 'soap block',
563
+ 'soap cubes'
564
+ ],
565
+ queries: [
566
+ 'soap bars',
567
+ 'handmade soap bar'
568
+ ],
569
+ // A bar is never a pump or a liquid soap (the beta.242 Tidewater start).
570
+ shun: [
571
+ 'pump',
572
+ 'pumps',
573
+ 'liquid',
574
+ 'dispenser',
575
+ 'dispensers'
576
+ ]
577
+ },
578
+ {
579
+ // "Bath" in "bath gift set", "bath bomb" or "bath salts" is what the
580
+ // products are for, never the room: the beta.242 Tidewater Soap Works
581
+ // start (2026-10-11) put a wooden sauna under "Bath Gift Set", its tags
582
+ // "sauna, steam, bath". A bath set is named by soap and bath products,
583
+ // and searched with soap.
584
+ objects: [
585
+ 'bath'
586
+ ],
587
+ within: [
588
+ 'soap',
589
+ 'soapmaker',
590
+ 'skincare',
591
+ 'spa',
592
+ 'cosmetic'
593
+ ],
594
+ qualifier: 'soap',
595
+ after: true,
596
+ kin: [
597
+ 'soap',
598
+ 'soaps',
599
+ 'soap bar',
600
+ 'soap bars',
601
+ 'handmade soap',
602
+ 'bath bomb',
603
+ 'bath bombs',
604
+ 'bath salt',
605
+ 'bath salts',
606
+ 'bath product',
607
+ 'bath products',
608
+ 'bath set',
609
+ 'toiletries'
610
+ ],
611
+ queries: [
612
+ 'soap gift set',
613
+ 'bath products',
614
+ 'bath bombs'
615
+ ],
616
+ shun: [
617
+ 'pump',
618
+ 'pumps',
619
+ 'liquid',
620
+ 'dispenser',
621
+ 'dispensers'
622
+ ]
623
+ },
624
+ {
625
+ // A lotion is any cream for the skin; a perfume bottle tagged "lotion"
626
+ // is not one (the same start's "Natural Body Lotion" showed colognes).
627
+ objects: [
628
+ 'lotion',
629
+ 'moisturizer',
630
+ 'moisturiser'
631
+ ],
632
+ within: [
633
+ 'soap',
634
+ 'soapmaker',
635
+ 'skincare',
636
+ 'lotion',
637
+ 'cosmetic',
638
+ 'beauty',
639
+ 'spa'
640
+ ],
641
+ kin: [
642
+ 'lotion',
643
+ 'lotions',
644
+ 'body lotion',
645
+ 'hand lotion',
646
+ 'cream',
647
+ 'creams',
648
+ 'hand cream',
649
+ 'body cream',
650
+ 'skin cream',
651
+ 'face cream',
652
+ 'cream jar',
653
+ 'body butter',
654
+ 'moisturizer',
655
+ 'moisturiser'
656
+ ],
657
+ queries: [
658
+ 'body lotion',
659
+ 'hand cream',
660
+ 'cream jar',
661
+ 'moisturizer'
662
+ ]
472
663
  }
473
664
  ];
474
665
  /** An object's kin where the site or the subject is of its world, else `null`. */ function objectKin(object, world) {
@@ -595,21 +786,56 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
595
786
  'soapmaker',
596
787
  'skincare'
597
788
  ],
789
+ // Never "natural": a florist's bouquet tagged "natural" was the
790
+ // Saltmarsh Soap Co hero (2026-10-10).
598
791
  kin: [
599
792
  'soap',
600
793
  'skincare',
601
794
  'lotion',
795
+ 'cream',
602
796
  'bath',
603
797
  'spa',
604
- 'natural',
605
798
  'cosmetic'
606
799
  ],
607
800
  broad: [
608
801
  'handmade soap',
609
802
  'natural skincare'
610
803
  ],
804
+ // "Soap making" is answered with soap bubbles; a bar is what a soapmaker makes.
611
805
  making: [
612
- 'soap making'
806
+ 'soap making',
807
+ 'handmade soap'
808
+ ],
809
+ // Soap bubbles, a dispenser and detergent are soap, never a soapmaker's:
810
+ // the same start's story band showed bubbles in flight.
811
+ rivals: {
812
+ words: [
813
+ 'bubble',
814
+ 'bubbles',
815
+ 'soapy',
816
+ 'dispenser',
817
+ 'detergent',
818
+ 'dishwashing',
819
+ 'laundry'
820
+ ],
821
+ core: [
822
+ 'bar',
823
+ 'bars',
824
+ 'handmade',
825
+ 'homemade',
826
+ 'loaf',
827
+ 'lye',
828
+ 'glycerin',
829
+ 'skincare',
830
+ 'lotion',
831
+ 'cosmetic',
832
+ 'spa',
833
+ 'artisan'
834
+ ]
835
+ },
836
+ shun: [
837
+ 'dispenser',
838
+ 'dispensers'
613
839
  ]
614
840
  },
615
841
  {
@@ -837,10 +1063,11 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
837
1063
  return craft ? aiStockStem(craft) : '';
838
1064
  }
839
1065
  /** The site's terms, read from its business type. */ export function aiStockSiteTerms(businessType) {
840
- const business = aiStockBusinessWords(businessType);
841
- const craft = aiStockCraftWords(business);
1066
+ let business = aiStockBusinessWords(businessType);
1067
+ let craft = aiStockCraftWords(business);
842
1068
  const domain = new Set();
843
1069
  /** The things it makes, by their head nouns. */ const made = new Set();
1070
+ /** The last word of the first clause that names the world: "soap" of "for handmade cold-process soap". */ let firstWorld = '';
844
1071
  let named = false;
845
1072
  let making = false;
846
1073
  for (const { opener, words: clause } of businessClauses(businessType)){
@@ -858,16 +1085,37 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
858
1085
  // A set or a gift is never a world: "gift" of "gift sets" is not one.
859
1086
  const world = (making ? kept.slice(0, -1) : kept).filter((word)=>!WRAPPERS.has(word));
860
1087
  for (const word of world)for (const stem of stemList(word))domain.add(stem);
1088
+ const last = world[world.length - 1];
1089
+ if (!firstWorld && last) firstWorld = aiStockStem(last);
861
1090
  if (making) for (const word of kept.slice(-1))for (const stem of stemList(word))made.add(stem);
862
1091
  }
863
- if (craft) domain.add(craft);
864
1092
  // A business named only by what it sells ("a shop selling pottery") is of that world.
865
1093
  if (!domain.size) for (const stem of made)domain.add(stem);
1094
+ const groups = CRAFT_KIN.filter((group)=>group.match.some((word)=>domain.has(aiStockStem(word)) || made.has(aiStockStem(word)) || aiStockStem(word) === craft));
1095
+ // A business that is only a place ("an online shop for handmade
1096
+ // cold-process soap") takes its craft from what it is for: the Saltmarsh
1097
+ // Soap Co start (2026-10-10) read its craft as nothing, so its hero searched
1098
+ // "shop" (a florist's bouquet) and no product was held to soap.
1099
+ if (!craft) {
1100
+ var _groups_;
1101
+ const listed = (_groups_ = groups[0]) == null ? void 0 : _groups_.match.map(aiStockStem).find((stem)=>domain.has(stem) || made.has(stem));
1102
+ craft = listed || firstWorld || [
1103
+ ...made
1104
+ ][0] || '';
1105
+ if (craft && !stemList(business).includes(craft)) business = `${craft} ${business}`.trim();
1106
+ }
1107
+ if (craft) domain.add(craft);
866
1108
  const broad = [];
867
1109
  const makingSearches = [];
868
1110
  const rivals = [];
869
- for (const group of CRAFT_KIN){
870
- if (!group.match.some((word)=>domain.has(aiStockStem(word)) || made.has(aiStockStem(word)))) continue;
1111
+ const core = new Set(craft ? [
1112
+ craft
1113
+ ] : []);
1114
+ const shun = new Set();
1115
+ for (const group of groups){
1116
+ var _group_shun;
1117
+ for (const word of (_group_shun = group.shun) != null ? _group_shun : [])shun.add(aiStockStem(word));
1118
+ for (const word of group.match)core.add(aiStockStem(word));
871
1119
  for (const word of group.kin)domain.add(aiStockStem(word));
872
1120
  broad.push(...group.broad);
873
1121
  makingSearches.push(...group.making);
@@ -892,7 +1140,13 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
892
1140
  named: [
893
1141
  ...new Set(stemList(businessType))
894
1142
  ],
895
- rivals
1143
+ rivals,
1144
+ core: [
1145
+ ...core
1146
+ ],
1147
+ shun: [
1148
+ ...shun
1149
+ ]
896
1150
  };
897
1151
  }
898
1152
  /** A phrase's content words without its filler; all filler, and nothing is left. */ function subjectOf(list) {
@@ -943,23 +1197,59 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
943
1197
  /** An object's kin phrases as stems, or none. */ const kinStems = (entry)=>entry ? [
944
1198
  ...new Set(entry.kin.map((phrase)=>stemList(phrase).join(' ')))
945
1199
  ] : [];
1200
+ /**
1201
+ * A size never names a product (AGL-3660): the Tidewater start searched
1202
+ * "soap set starter size" for "Bath Gift Set Starter Size", its head noun
1203
+ * read as "size", and took a soap dispenser.
1204
+ */ const PRODUCT_SIZES = /\b(?:(?:starter|travel|trial|sample|mini|full|half|family|value|jumbo|large|small|regular|standard|big)[- ])?sized?s?\b/gi;
1205
+ /** The wrapper words a gift is named by, searched with the craft: "soap gift box". */ const GIFT_WRAPPERS = words('gift gifts box boxes basket baskets hamper hampers');
946
1206
  /**
947
1207
  * A product's searches (AGL-3676), most specific first: each of its subjects
948
1208
  * — its name's noun phrase, then its photo's — with the shop's category
949
- * where the phrase does not already say it ("candle gift box"), then the
950
- * category alone, a plain photo of what the shop sells. Every one must find a
951
- * hit naming the category, so no product is ever filled with a lifestyle shot
952
- * of something else: the beta.237 Willow Wick start put a laptop and roses
953
- * under "Candle Wick Trimmer" and an antique tea set under "Candle Gift Set".
1209
+ * where the phrase does not already name the shop's world ("candle gift
1210
+ * box"), then the category alone, a plain photo of what the shop sells. Every
1211
+ * one must find a hit naming the category — or the product's own word of the
1212
+ * shop's world, "lotion" in a soap shop — so no product is ever filled with a
1213
+ * lifestyle shot of something else: the beta.237 Willow Wick start put a
1214
+ * laptop and roses under "Candle Wick Trimmer" and an antique tea set under
1215
+ * "Candle Gift Set".
954
1216
  *
955
- * And every one must find a hit naming the product's own head noun among its
1217
+ * And each must find a hit naming the product's own head noun among its
956
1218
  * first tags (AGL-3660): the beta.239 Ember & Oak start put a bed with a book,
957
1219
  * a mug and a camera (its candle a last tag) under "Hand-Poured Soy Candle".
958
1220
  * An object whose own word is not enough ({@link OBJECT_KIN}) is searched by
959
1221
  * its kin instead of the category alone, and named by them: a single taper
960
1222
  * candle filled "Wax Melt Gift Set".
961
- */ export function aiStockProductSearches(slot, craft, domain = []) {
962
- var _ref, _subjects_, _subjects_1;
1223
+ *
1224
+ * Then, before a product is ever given a photo of something else, it falls
1225
+ * back to its own craft (AGL-3660). The Saltmarsh Soap Co start (2026-10-10)
1226
+ * found no hit naming "bar" or "box" among the first tags, and four of its
1227
+ * five products took the starter photos — a laptop and roses, a living room
1228
+ * of plants. And the beta.242 Tidewater Soap Works start (2026-10-11) showed
1229
+ * that the head noun anywhere is not enough: its own weak hits put a sauna
1230
+ * under "Bath Gift Set", colognes under "Natural Body Lotion", a bathroom
1231
+ * shelf under "Soap Bar Trio" and a soap dispenser under its starter size,
1232
+ * while the collection tiles beside them, searched for the category, were
1233
+ * good. So the searches are ranked by how sure they are ({@link AiStockSearch.tier}):
1234
+ *
1235
+ * 1. Its own: the head noun (or its kin) among the first tags, as above.
1236
+ * 2. `borrow`: a photo OF its category — the category alone, then the craft's
1237
+ * broad searches ("handmade soap", "natural skincare"), one of the
1238
+ * product's kin or the craft's own names among the first tags. A product
1239
+ * may repeat one the job already shows, a collection tile's photo or
1240
+ * another product's ({@link aiLayoutStockPhotoSource}): a borrowed
1241
+ * on-topic photo beats a weak specific one.
1242
+ * 3. `weak`: the same searches again, the head noun named anywhere among the
1243
+ * tags (no extra search: the page remembers its answers), a gift's own
1244
+ * wrapper with the craft ("soap gift box"), and the craft's broad searches,
1245
+ * any hit naming the craft itself ({@link AiStockSiteTerms.core}).
1246
+ *
1247
+ * Every one of them turns down a room or a scene, a fragrance bottle, and
1248
+ * what the product's kin shuns (a pump for a bar of soap), unless the
1249
+ * product names it. A size never names the product: "Bath Gift Set Starter
1250
+ * Size" is a bath gift set.
1251
+ */ export function aiStockProductSearches(slot, craft, domain = [], fallback = {}) {
1252
+ var _ref, _subjects_, _fallback_core, _ref1, _fallback_broad, _fallback_broad1;
963
1253
  var _slot_product;
964
1254
  const orientation = aiStockOrientation(slot.aspect);
965
1255
  const size = orientation === 'vertical' ? {
@@ -968,62 +1258,165 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
968
1258
  minWidth: 900
969
1259
  };
970
1260
  const craftStems = stemList(craft);
971
- const subjects = ((_ref = (_slot_product = slot.product) == null ? void 0 : _slot_product.subjects) != null ? _ref : []).map((raw)=>aiStockSubjectWords(raw, '')).filter(Boolean);
1261
+ const world = new Set([
1262
+ ...domain,
1263
+ ...craftStems
1264
+ ]);
1265
+ const subjects = ((_ref = (_slot_product = slot.product) == null ? void 0 : _slot_product.subjects) != null ? _ref : []).map((raw)=>aiStockSubjectWords(raw.replace(PRODUCT_SIZES, ' '), '')).filter(Boolean);
972
1266
  // The product's head noun is its name's: "candle" of "Hand-Poured Soy Candle".
973
- const object = aiStockObjectWord((_subjects_ = subjects[0]) != null ? _subjects_ : '');
1267
+ const own = (_subjects_ = subjects[0]) != null ? _subjects_ : '';
1268
+ const object = aiStockObjectWord(own);
974
1269
  const kin = object ? objectKin(object, [
975
1270
  ...craftStems,
976
1271
  ...domain,
977
1272
  ...stemList(subjects.join(' '))
978
1273
  ]) : null;
979
1274
  const kinPhrases = kinStems(kin);
1275
+ // A product of the shop's world beside its category is held to either:
1276
+ // a soap shop's body lotion names "lotion", never "soap".
1277
+ const ownWorld = stemList(own).filter((stem)=>world.has(stem) && !WRAPPERS.has(stem) && !craftStems.includes(stem));
1278
+ const category = craft && ownWorld.length ? [
1279
+ ...craftStems,
1280
+ ...ownWorld
1281
+ ] : [];
1282
+ const core = [
1283
+ ...new Set([
1284
+ ...craftStems,
1285
+ ...(_fallback_core = fallback.core) != null ? _fallback_core : []
1286
+ ])
1287
+ ];
1288
+ const shun = ((_ref1 = kin == null ? void 0 : kin.shun) != null ? _ref1 : []).map(aiStockStem);
980
1289
  const queries = [];
981
1290
  for (const subject of subjects){
1291
+ var _ref2;
982
1292
  const stems = stemList(subject);
983
- const query = kin ? stems.includes(aiStockStem(kin.qualifier)) ? subject : `${kin.qualifier} ${subject}` : craft && !craftStems.every((stem)=>stems.includes(stem)) ? `${craft} ${subject}` : subject;
1293
+ const qualifier = (_ref2 = kin == null ? void 0 : kin.qualifier) != null ? _ref2 : '';
1294
+ const query = kin ? !qualifier || stems.includes(aiStockStem(qualifier)) ? subject : kin.after ? `${subject} ${qualifier}` : `${qualifier} ${subject}` : craft && !stems.some((stem)=>world.has(stem)) ? `${craft} ${subject}` : subject;
984
1295
  queries.push({
985
1296
  query,
986
1297
  subject,
987
- object
1298
+ object,
1299
+ lead: true
988
1300
  });
989
1301
  }
990
- // The category alone stands in only for a thing that is no more than of it.
991
- // Its hits are still scored by the product's own words: a soy candle over a taper.
992
- const own = (_subjects_1 = subjects[0]) != null ? _subjects_1 : '';
1302
+ // The category alone stands in only for a thing that is no more than of
1303
+ // it; a thing that is its own word of the shop's world is searched by that
1304
+ // word ("lotion"). Its hits are still scored by the product's own words: a
1305
+ // soy candle over a taper.
993
1306
  if (kin) for (const query of kin.queries)queries.push({
994
1307
  query,
995
1308
  subject: own || query,
996
- object
1309
+ object,
1310
+ lead: true
1311
+ });
1312
+ else if (object && ownWorld.includes(object)) queries.push({
1313
+ query: object,
1314
+ subject: own,
1315
+ object,
1316
+ lead: true
997
1317
  });
998
1318
  else if (craft) queries.push({
999
1319
  query: craft,
1000
1320
  subject: own || craft,
1001
- object: craft
1321
+ object: craft,
1322
+ lead: true,
1323
+ tier: 'borrow'
1002
1324
  });
1325
+ const held = [
1326
+ ...queries
1327
+ ];
1328
+ // 2. A photo OF its category, which may be one the job already shows.
1329
+ for (const query of (_fallback_broad = fallback.broad) != null ? _fallback_broad : [])queries.push({
1330
+ query,
1331
+ subject: own,
1332
+ object: '',
1333
+ lead: true,
1334
+ broad: true,
1335
+ tier: 'borrow'
1336
+ });
1337
+ // 3. The head noun anywhere among the tags, by the same searches.
1338
+ for (const entry of held)queries.push(_extends({}, entry, {
1339
+ lead: false,
1340
+ tier: 'weak'
1341
+ }));
1342
+ // A gift is its craft's: "bath gift box" asks for a "soap gift box".
1343
+ const wrapped = own.split(/\s+/).filter((word)=>WRAPPERS.has(word) && aiStockStem(word) !== object);
1344
+ const gift = wrapped.find((word)=>GIFT_WRAPPERS.has(word));
1345
+ if (craft && gift && !kin) queries.push({
1346
+ query: `${craft} ${wrapped.join(' ')}`,
1347
+ subject: own,
1348
+ object: aiStockStem(gift),
1349
+ lead: false,
1350
+ tier: 'weak'
1351
+ });
1352
+ // The craft's own broad searches, any hit naming the craft.
1353
+ for (const query of (_fallback_broad1 = fallback.broad) != null ? _fallback_broad1 : [])queries.push({
1354
+ query,
1355
+ subject: own,
1356
+ object: '',
1357
+ lead: false,
1358
+ broad: true,
1359
+ tier: 'weak'
1360
+ });
1361
+ // What a borrowed photo is OF: the product's kin, its own word of the shop's world, else the craft.
1362
+ const borrowed = kinPhrases.length ? kinPhrases : ownWorld.length ? ownWorld : core;
1003
1363
  const seen = new Set();
1004
1364
  const searches = [];
1005
1365
  for (const entry of queries){
1366
+ var _entry_tier;
1006
1367
  const query = clip(entry.query);
1007
- if (!query || seen.has(query)) continue;
1008
- seen.add(query);
1009
- searches.push(_extends({
1368
+ const key = `${query}|${entry.lead}|${entry.object}|${entry.broad === true}|${(_entry_tier = entry.tier) != null ? _entry_tier : ''}`;
1369
+ if (!query || seen.has(key)) continue;
1370
+ seen.add(key);
1371
+ const common = _extends({
1010
1372
  query,
1011
1373
  orientation
1012
1374
  }, size, {
1013
1375
  subject: entry.subject,
1376
+ product: true
1377
+ }, entry.tier ? {
1378
+ tier: entry.tier
1379
+ } : {}, shun.length ? {
1380
+ shun
1381
+ } : {});
1382
+ if (entry.broad) {
1383
+ const heldTo = [
1384
+ ...new Set([
1385
+ ...core,
1386
+ ...ownWorld
1387
+ ])
1388
+ ];
1389
+ searches.push(_extends({}, common, {
1390
+ broad: true
1391
+ }, entry.lead ? _extends({
1392
+ leadAny: borrowed
1393
+ }, kinPhrases.length ? {
1394
+ kin: kinPhrases
1395
+ } : {}, heldTo.length ? {
1396
+ category: heldTo
1397
+ } : {}) : core.length ? {
1398
+ category: core
1399
+ } : {}));
1400
+ continue;
1401
+ }
1402
+ searches.push(_extends({}, common, entry.lead ? {
1014
1403
  lead: true
1404
+ } : {
1405
+ leadWorld: true
1015
1406
  }, entry.object ? {
1016
1407
  object: entry.object
1017
- } : {}, kinPhrases.length ? {
1408
+ } : {}, kinPhrases.length && entry.object === object ? {
1018
1409
  kin: kinPhrases
1019
1410
  } : {}, craft ? {
1020
1411
  requires: craft
1412
+ } : {}, category.length ? {
1413
+ category
1021
1414
  } : {}));
1022
1415
  }
1023
1416
  return searches;
1024
1417
  }
1025
1418
  /** The searches a slot tries, in order, most specific first, each distinct. */ export function aiStockSearchesFor(slot, terms, subject, part) {
1026
- var _terms_making;
1419
+ var _terms_core, _terms_making;
1027
1420
  const orientation = aiStockOrientation(slot.aspect);
1028
1421
  const size = slot.role === 'hero' ? {
1029
1422
  minWidth: 1600
@@ -1042,6 +1435,12 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1042
1435
  const kinPhrases = kinStems(kin);
1043
1436
  // The craft qualifies a subject that does not already name the site's world.
1044
1437
  const namesWorld = subjectStems.some((stem)=>domain.has(stem));
1438
+ // A tile of the shop's world beside its category is held to either: a soap
1439
+ // shop's "Body care" tile names "body", never "soap" (AGL-3660).
1440
+ const ownWorld = subjectStems.filter((stem)=>domain.has(stem) && !WRAPPERS.has(stem) && stem !== terms.craft);
1441
+ // A hero of a craft names the craft itself: the Saltmarsh Soap Co hero
1442
+ // (2026-10-10) was a florist's bouquet tagged "natural" (AGL-3660).
1443
+ const core = (_terms_core = terms.core) != null ? _terms_core : [];
1045
1444
  const qualify = (phrase)=>terms.craft && !namesWorld && !stemList(phrase).includes(terms.craft) ? `${terms.craft} ${phrase}` : phrase;
1046
1445
  // A collection tile is product-like: held to the shop's category and its
1047
1446
  // own object among the first tags, and searched by its object's kin, never
@@ -1117,8 +1516,15 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1117
1516
  kin: kinPhrases
1118
1517
  } : {}, held && collection ? {
1119
1518
  lead: true
1120
- } : {}, terms.craft && (raw.category || raw.held && collection) ? {
1519
+ } : {}, terms.craft && (raw.category || raw.held && collection) ? _extends({
1121
1520
  requires: terms.craft
1521
+ }, ownWorld.length ? {
1522
+ category: [
1523
+ terms.craft,
1524
+ ...ownWorld
1525
+ ]
1526
+ } : {}) : {}, slot.role === 'hero' && core.length ? {
1527
+ category: core
1122
1528
  } : {}));
1123
1529
  }
1124
1530
  return searches;
@@ -1133,7 +1539,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1133
1539
  }
1134
1540
  }
1135
1541
  /** A hit's words: the first few (what it is OF), every one, and each tag's phrase. */ function hitWords(photo) {
1136
- var _photo_tags, _photo_alt, _photo_alt1, _photo_alt2;
1542
+ var _photo_tags, _photo_alt, _photo_alt1, _tags_, _photo_alt2;
1137
1543
  const slug = pageSlug(photo.pageUrl);
1138
1544
  const tags = ((_photo_tags = photo.tags) != null ? _photo_tags : []).map((tag)=>tag.toLowerCase().trim()).filter(Boolean);
1139
1545
  const lead = new Set(stemList([
@@ -1154,6 +1560,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1154
1560
  ...tags.slice(0, 3).map((tag)=>stemList(tag).join(' ')),
1155
1561
  stemList(slug).join(' ')
1156
1562
  ];
1563
+ /** The first tag: what the photo is most of all. */ const firstPhrase = stemList((_tags_ = tags[0]) != null ? _tags_ : '').join(' ');
1157
1564
  const rejected = tags.some((tag)=>REJECTED_TAGS.has(tag) || tag.split(/\s+/).some((word)=>REJECTED_TAGS.has(word)));
1158
1565
  // Whole words only, a hyphenated one kept whole: "cross-stitch" is not a cross.
1159
1566
  const words = new Set([
@@ -1165,6 +1572,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1165
1572
  all,
1166
1573
  phrases,
1167
1574
  leadPhrases,
1575
+ firstPhrase,
1168
1576
  rejected,
1169
1577
  words
1170
1578
  };
@@ -1193,25 +1601,59 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1193
1601
  * the subject's two-word phrase 3, each of its other words 1, and the
1194
1602
  * site's world 1, plus a quarter when it is among the first tags.
1195
1603
  */ export function aiStockRelevance(photo, judgement) {
1196
- var _judgement_rivals, _judgement_kin, _judgement_requires, _judgement_subject, _judgement_object, _judgement_domain;
1604
+ var _judgement_rivals, _judgement_subject, _judgement_named, _judgement_shun, _judgement_leadAny, _judgement_kin, _judgement_requires, _judgement_category, _judgement_object, _judgement_domain;
1197
1605
  const hit = hitWords(photo);
1198
1606
  if (hit.rejected) return 0;
1199
1607
  if (aiStockSensitive(photo, judgement.named)) return 0;
1200
1608
  // Glass art is not pottery: a rival craft's photo names this craft's own words.
1201
1609
  for (const rival of (_judgement_rivals = judgement.rivals) != null ? _judgement_rivals : [])if (rival.words.some((word)=>hit.all.has(word)) && !rival.core.some((word)=>hit.all.has(word))) return 0;
1610
+ const subject = stemList((_judgement_subject = judgement.subject) != null ? _judgement_subject : '');
1611
+ // What the slot or the brief names lets a shunned word, a room or a fragrance in.
1612
+ const owned = new Set([
1613
+ ...subject,
1614
+ ...(_judgement_named = judgement.named) != null ? _judgement_named : []
1615
+ ]);
1616
+ // A dispenser is not a soapmaker's, nor a pump a bar's (AGL-3660).
1617
+ if (((_judgement_shun = judgement.shun) != null ? _judgement_shun : []).some((word)=>hit.all.has(word) && !owned.has(word))) return 0;
1618
+ if (judgement.product) {
1619
+ // A sauna, a bathroom shelf: a room is never the product.
1620
+ for (const word of hit.lead)if (PRODUCT_SCENES.has(word) && !owned.has(word)) return 0;
1621
+ // Colognes are not a lotion: a fragrance's bottle only for a fragrance.
1622
+ if (![
1623
+ ...FRAGRANCE_BOTTLES
1624
+ ].some((word)=>owned.has(word)) && [
1625
+ ...hit.lead
1626
+ ].some((word)=>FRAGRANCE_BOTTLES.has(word))) return 0;
1627
+ }
1628
+ // A borrowed photo is OF the product's category.
1629
+ const leadAny = (_judgement_leadAny = judgement.leadAny) != null ? _judgement_leadAny : [];
1630
+ if (leadAny.length && !leadAny.some((phrase)=>among(hit.leadPhrases, phrase))) return 0;
1202
1631
  const kin = (_judgement_kin = judgement.kin) != null ? _judgement_kin : [];
1203
1632
  const kinNamed = kin.some((phrase)=>among(hit.phrases, phrase));
1204
1633
  const kinLead = kin.some((phrase)=>among(hit.leadPhrases, phrase));
1205
1634
  const required = stemList((_judgement_requires = judgement.requires) != null ? _judgement_requires : '');
1635
+ const category = ((_judgement_category = judgement.category) != null ? _judgement_category : []).flatMap(stemList);
1636
+ const anyOf = category.length ? [
1637
+ ...new Set([
1638
+ ...required,
1639
+ ...category
1640
+ ])
1641
+ ] : [];
1642
+ const demanded = required.length > 0 || anyOf.length > 0;
1643
+ const meets = anyOf.length ? anyOf.some((stem)=>hit.all.has(stem)) : required.every((stem)=>hit.all.has(stem));
1206
1644
  // An object's kin stands for the category: a wax melt is the candle shop's.
1207
- if (!kinNamed && required.some((stem)=>!hit.all.has(stem))) return 0;
1208
- const subject = stemList((_judgement_subject = judgement.subject) != null ? _judgement_subject : '');
1645
+ if (demanded && !kinNamed && !meets) return 0;
1209
1646
  const object = (_judgement_object = judgement.object) != null ? _judgement_object : judgement.subject ? aiStockObjectWord(judgement.subject) : '';
1210
1647
  const domain = ((_judgement_domain = judgement.domain) != null ? _judgement_domain : []).filter((stem)=>stem !== object);
1211
1648
  // A hit naming the word it must name is of the site's world: a product's
1212
1649
  // category is that world even where it is the product's own object.
1213
- const world = required.length > 0 || kinNamed || domain.some((stem)=>hit.all.has(stem));
1214
- const worldLead = required.length > 0 && required.every((stem)=>hit.lead.has(stem)) || kinLead || domain.some((stem)=>hit.lead.has(stem));
1650
+ const world = demanded || kinNamed || domain.some((stem)=>hit.all.has(stem));
1651
+ const worldLead = (anyOf.length ? anyOf.some((stem)=>hit.lead.has(stem)) : required.length > 0 && required.every((stem)=>hit.lead.has(stem))) || kinLead || domain.some((stem)=>hit.lead.has(stem));
1652
+ if (judgement.leadWorld && !worldLead) return 0;
1653
+ // What the photo is most of all: its first tag names the object, its kin or what it must be OF.
1654
+ const firstNamed = (phrases)=>phrases.some((phrase)=>Boolean(phrase) && among([
1655
+ hit.firstPhrase
1656
+ ], phrase));
1215
1657
  const pair = subject.length >= 2 ? subject.slice(-2).join(' ') : '';
1216
1658
  const phrased = Boolean(pair) && among(hit.phrases, pair);
1217
1659
  let score = 0;
@@ -1225,9 +1667,13 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1225
1667
  if (domain.length && !world && !phrased) return 0;
1226
1668
  score += leading ? 4 : 2;
1227
1669
  if (kinNamed) score += 3;
1670
+ if (firstNamed(kin.length ? kin : [
1671
+ object
1672
+ ])) score += 1;
1228
1673
  } else if (domain.length) {
1229
1674
  if (!world) return 0;
1230
1675
  }
1676
+ if (leadAny.length && firstNamed(leadAny)) score += 1;
1231
1677
  if (phrased) score += 3;
1232
1678
  for (const word of new Set(subject))if (word !== object && hit.all.has(word)) score += 1;
1233
1679
  if (world) score += worldLead ? 1.25 : 1;
@@ -1345,6 +1791,8 @@ const EXTENSIONS = {
1345
1791
  delete request.object;
1346
1792
  delete request.kin;
1347
1793
  delete request.lead;
1794
+ delete request.leadWorld;
1795
+ delete request.category;
1348
1796
  const key = JSON.stringify([
1349
1797
  request.query,
1350
1798
  request.orientation,
@@ -1363,7 +1811,7 @@ const EXTENSIONS = {
1363
1811
  answers.set(key, asked);
1364
1812
  return asked;
1365
1813
  };
1366
- /** 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)=>{
1814
+ /** 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, repeat = false)=>{
1367
1815
  const sourceKey = stockPhotoSourceKey(photo);
1368
1816
  try {
1369
1817
  var _EXTENSIONS_bytes_contentType;
@@ -1374,7 +1822,7 @@ const EXTENSIONS = {
1374
1822
  if (kept) {
1375
1823
  var _kept_width, _kept_height;
1376
1824
  // Held because a slot of this page or another page of the job shows it.
1377
- if (placedSrcs.has(kept.src) || elsewhere(kept.src)) return null;
1825
+ if (!repeat && (placedSrcs.has(kept.src) || elsewhere(kept.src))) return null;
1378
1826
  remember(sourceKey, kept.src);
1379
1827
  return {
1380
1828
  src: kept.src,
@@ -1437,6 +1885,65 @@ const EXTENSIONS = {
1437
1885
  }
1438
1886
  return null;
1439
1887
  };
1888
+ /** How a search's hits are judged for a slot. */ const judgementFor = (slot, request, subject)=>{
1889
+ var _request_subject, _request_shun;
1890
+ var _request_shun1, _request_leadAny;
1891
+ return _extends({
1892
+ subject: (_request_subject = request.subject) != null ? _request_subject : request.broad ? '' : subject,
1893
+ domain: terms.domain,
1894
+ strict: slot.role === 'gallery' && !request.broad || !!request.requires,
1895
+ named: terms.named,
1896
+ rivals: terms.rivals
1897
+ }, terms.shun.length || ((_request_shun1 = request.shun) == null ? void 0 : _request_shun1.length) ? {
1898
+ shun: [
1899
+ ...new Set([
1900
+ ...terms.shun,
1901
+ ...(_request_shun = request.shun) != null ? _request_shun : []
1902
+ ])
1903
+ ]
1904
+ } : {}, request.product ? {
1905
+ product: true
1906
+ } : {}, ((_request_leadAny = request.leadAny) == null ? void 0 : _request_leadAny.length) ? {
1907
+ leadAny: request.leadAny
1908
+ } : {}, request.requires ? {
1909
+ requires: request.requires
1910
+ } : {}, request.category ? {
1911
+ category: request.category
1912
+ } : {}, request.object ? {
1913
+ object: request.object
1914
+ } : {}, request.kin ? {
1915
+ kin: request.kin
1916
+ } : {}, request.lead ? {
1917
+ lead: true
1918
+ } : {}, request.leadWorld ? {
1919
+ leadWorld: true
1920
+ } : {});
1921
+ };
1922
+ /**
1923
+ * The craft's own photos, the last thing a slot of a craft's site is
1924
+ * filled from (AGL-3660): its broad searches, any hit naming the craft
1925
+ * itself. Never a desk, a laptop or a living room for a soap shop.
1926
+ */ const craftSearches = (slot)=>{
1927
+ if (!terms.core.length) return [];
1928
+ const orientation = aiStockOrientation(slot.aspect);
1929
+ const size = slot.role === 'hero' ? {
1930
+ minWidth: 1600
1931
+ } : orientation === 'vertical' ? {
1932
+ minHeight: 900
1933
+ } : {
1934
+ minWidth: 900
1935
+ };
1936
+ return terms.broad.map((query)=>_extends({
1937
+ query: clip(query),
1938
+ orientation
1939
+ }, size, {
1940
+ broad: true,
1941
+ category: terms.core
1942
+ }, slot.product ? {
1943
+ product: true,
1944
+ tier: 'weak'
1945
+ } : {}));
1946
+ };
1440
1947
  // 1. Choose, slot by slot, so the page's choices never collide: each
1441
1948
  // slot claims its best accepted hit before the next slot looks.
1442
1949
  const choices = [];
@@ -1449,45 +1956,85 @@ const EXTENSIONS = {
1449
1956
  const sectionName = (_input_sectionNames_slot_sectionIndex = input.sectionNames[slot.sectionIndex]) != null ? _input_sectionNames_slot_sectionIndex : '';
1450
1957
  const subject = aiStockSubjectWords(slot.alt, sectionName);
1451
1958
  let choice = null;
1452
- const requests = slot.product ? aiStockProductSearches(slot, terms.craft, terms.domain) : aiStockSearchesFor(slot, terms, subject, aiStockSlotPart(slot, sectionName));
1453
- for (const request of requests){
1454
- var _request_subject;
1455
- if (choice || signal.aborted) break;
1959
+ const requests = [
1960
+ ...slot.product ? aiStockProductSearches(slot, terms.craft, terms.domain, {
1961
+ broad: terms.broad,
1962
+ core: terms.core
1963
+ }) : aiStockSearchesFor(slot, terms, subject, aiStockSlotPart(slot, sectionName)),
1964
+ ...craftSearches(slot)
1965
+ ];
1966
+ /** The slot's accepted hits for a search; `null` once the page's searches are spent. */ const accepted = async (request, repeat)=>{
1456
1967
  const found = await search(request);
1457
- if (found === null) break;
1458
- const blocked = new Set(claimed);
1459
- for (const hit of found){
1460
- const key = stockPhotoSourceKey(hit);
1461
- if (elsewhere(key)) blocked.add(key);
1968
+ if (found === null) return null;
1969
+ const blocked = new Set();
1970
+ if (!repeat) {
1971
+ for (const key of claimed)blocked.add(key);
1972
+ for (const hit of found){
1973
+ const key = stockPhotoSourceKey(hit);
1974
+ if (elsewhere(key)) blocked.add(key);
1975
+ }
1462
1976
  }
1463
- const ranked = aiStockRank(found, blocked, `${input.seed}:${index}:${request.query}`, _extends({
1464
- subject: (_request_subject = request.subject) != null ? _request_subject : request.broad ? '' : subject,
1465
- domain: terms.domain,
1466
- strict: slot.role === 'gallery' && !request.broad || !!request.requires,
1467
- named: terms.named,
1468
- rivals: terms.rivals
1469
- }, request.requires ? {
1470
- requires: request.requires
1471
- } : {}, request.object ? {
1472
- object: request.object
1473
- } : {}, request.kin ? {
1474
- kin: request.kin
1475
- } : {}, request.lead ? {
1476
- lead: true
1477
- } : {}));
1478
- if (!ranked.length) continue;
1479
- const first = ranked[0];
1480
- claimed.add(stockPhotoSourceKey(first));
1481
- choice = {
1482
- candidates: ranked.slice(0, AI_LAYOUT_STOCK_TRIES_PER_SLOT),
1483
- query: request.query
1484
- };
1977
+ return aiStockRank(found, blocked, `${input.seed}:${index}:${request.query}`, judgementFor(slot, request, subject));
1978
+ };
1979
+ /** The first of these searches with a hit to take: a new one, or one the job already shows. */ const choose = async (tried, repeat)=>{
1980
+ for (const request of tried){
1981
+ if (signal.aborted) return null;
1982
+ // Spent searches answer nothing; those already asked still answer from memory.
1983
+ const ranked = await accepted(request, repeat);
1984
+ if (!(ranked == null ? void 0 : ranked.length)) continue;
1985
+ if (!repeat) claimed.add(stockPhotoSourceKey(ranked[0]));
1986
+ return _extends({
1987
+ candidates: ranked.slice(0, AI_LAYOUT_STOCK_TRIES_PER_SLOT),
1988
+ query: request.query
1989
+ }, repeat ? {
1990
+ repeat: true
1991
+ } : {});
1992
+ }
1993
+ return null;
1994
+ };
1995
+ // 2. Nothing new of its own: a product or a hero reuses an on-topic
1996
+ // photo another slot of the job already shows (a product may repeat
1997
+ // a collection tile's photo, or another product's) before the
1998
+ // starters' desk or living room fills it (AGL-3660). Any other
1999
+ // picture keeps "never twice". A product goes by how sure each
2000
+ // search is: its own and its category's photos, new and then
2001
+ // repeated, before a weak hit of its own (the Tidewater sauna).
2002
+ const repeats = Boolean(slot.product) || slot.role === 'hero';
2003
+ const sure = slot.product ? requests.filter((request)=>request.tier !== 'weak') : requests;
2004
+ const unsure = slot.product ? requests.filter((request)=>request.tier === 'weak') : [];
2005
+ const phases = [
2006
+ [
2007
+ sure,
2008
+ false
2009
+ ],
2010
+ ...repeats ? [
2011
+ [
2012
+ sure,
2013
+ true
2014
+ ]
2015
+ ] : [],
2016
+ ...unsure.length ? [
2017
+ [
2018
+ unsure,
2019
+ false
2020
+ ],
2021
+ [
2022
+ unsure,
2023
+ true
2024
+ ]
2025
+ ] : []
2026
+ ];
2027
+ for (const [tried, repeat] of phases){
2028
+ if (choice || signal.aborted) break;
2029
+ choice = await choose(tried, repeat);
1485
2030
  }
1486
2031
  choices.push(choice);
1487
2032
  }
1488
- // 2. Keep, a few at a time; a copy that fails tries the slot's next
1489
- // accepted hit no other slot has claimed.
2033
+ // 3. Keep, a few at a time; a copy that fails tries the slot's next
2034
+ // accepted hit no other slot has claimed. A repeat waits for the
2035
+ // slot it repeats, so the library never holds a photo twice.
1490
2036
  const photos = slots.map(()=>null);
2037
+ /** What this pass placed, by source key. */ const placedByKey = new Map();
1491
2038
  const fill = async (index)=>{
1492
2039
  const choice = choices[index];
1493
2040
  const slot = slots[index];
@@ -1495,28 +2042,43 @@ const EXTENSIONS = {
1495
2042
  for (const [rank, candidate] of choice.candidates.entries()){
1496
2043
  if (signal.aborted) return;
1497
2044
  const key = stockPhotoSourceKey(candidate);
2045
+ if (choice.repeat) {
2046
+ var _placedByKey_get;
2047
+ const placed = (_placedByKey_get = placedByKey.get(key)) != null ? _placedByKey_get : await keep(candidate, slot, choice.query, true);
2048
+ if (placed) {
2049
+ photos[index] = placed;
2050
+ return;
2051
+ }
2052
+ continue;
2053
+ }
1498
2054
  if (rank > 0) {
1499
2055
  if (claimed.has(key)) continue;
1500
2056
  claimed.add(key);
1501
2057
  }
1502
2058
  const placed = await keep(candidate, slot, choice.query);
1503
2059
  if (placed) {
2060
+ placedByKey.set(key, placed);
1504
2061
  photos[index] = placed;
1505
2062
  return;
1506
2063
  }
1507
2064
  }
1508
2065
  };
2066
+ const firsts = slots.map((_slot, index)=>index).filter((index)=>{
2067
+ var _choices_index;
2068
+ return !((_choices_index = choices[index]) == null ? void 0 : _choices_index.repeat);
2069
+ });
1509
2070
  let next = 0;
1510
2071
  const worker = async ()=>{
1511
- while(next < slots.length){
1512
- const index = next;
2072
+ while(next < firsts.length){
2073
+ const index = firsts[next];
1513
2074
  next += 1;
1514
2075
  await fill(index);
1515
2076
  }
1516
2077
  };
1517
2078
  await Promise.all(Array.from({
1518
- length: Math.min(AI_LAYOUT_STOCK_COPIES_AT_ONCE, slots.length)
2079
+ length: Math.min(AI_LAYOUT_STOCK_COPIES_AT_ONCE, firsts.length)
1519
2080
  }, worker));
2081
+ for (const [index, choice] of choices.entries())if (choice == null ? void 0 : choice.repeat) await fill(index);
1520
2082
  return photos;
1521
2083
  };
1522
2084
  }