@aglyn/plugins-ai 1.0.0-beta.242 → 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.
@@ -106,6 +106,16 @@ import { aiStorefrontSays } from "../layout-language/ai-layout-storefront.js";
106
106
  * searches, and then — a product or a hero only — reuses an on-topic photo
107
107
  * the job already shows rather than take a starter. Every slot of a
108
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.
109
119
  *
110
120
  * A slot nothing acceptable answered — the library down, refusing, or out
111
121
  * of time — is left `null` for the starter photos. A named object is never
@@ -149,6 +159,47 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
149
159
  * an empty frame on the page (the Juniper Clay hero), a template or a
150
160
  * graphic is not a photo of anything.
151
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));
152
203
  /**
153
204
  * Topics a business's photos never carry unless its own words name them
154
205
  * (AGL-3660, the beta.239 Ember & Oak start, whose "Gifts for melt fans" tile
@@ -316,6 +367,7 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
316
367
  },
317
368
  {
318
369
  // Tobacco and drugs.
370
+ // "cbd" and "cannabinoid": a hemp cream tube was the next "body lotion" answer (Tidewater, 2026-10-11).
319
371
  words: [
320
372
  'cigarette',
321
373
  'smoking',
@@ -323,7 +375,10 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
323
375
  'cannabis',
324
376
  'marijuana',
325
377
  'drug',
326
- 'drugs'
378
+ 'drugs',
379
+ 'cbd',
380
+ 'cannabinoid',
381
+ 'thc'
327
382
  ],
328
383
  allow: [
329
384
  'dispensary',
@@ -510,6 +565,100 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
510
565
  queries: [
511
566
  'soap bars',
512
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'
513
662
  ]
514
663
  }
515
664
  ];
@@ -683,7 +832,11 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
683
832
  'spa',
684
833
  'artisan'
685
834
  ]
686
- }
835
+ },
836
+ shun: [
837
+ 'dispenser',
838
+ 'dispensers'
839
+ ]
687
840
  },
688
841
  {
689
842
  match: [
@@ -958,7 +1111,10 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
958
1111
  const core = new Set(craft ? [
959
1112
  craft
960
1113
  ] : []);
1114
+ const shun = new Set();
961
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));
962
1118
  for (const word of group.match)core.add(aiStockStem(word));
963
1119
  for (const word of group.kin)domain.add(aiStockStem(word));
964
1120
  broad.push(...group.broad);
@@ -987,6 +1143,9 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
987
1143
  rivals,
988
1144
  core: [
989
1145
  ...core
1146
+ ],
1147
+ shun: [
1148
+ ...shun
990
1149
  ]
991
1150
  };
992
1151
  }
@@ -1038,6 +1197,11 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1038
1197
  /** An object's kin phrases as stems, or none. */ const kinStems = (entry)=>entry ? [
1039
1198
  ...new Set(entry.kin.map((phrase)=>stemList(phrase).join(' ')))
1040
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;
1041
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');
1042
1206
  /**
1043
1207
  * A product's searches (AGL-3676), most specific first: each of its subjects
@@ -1061,18 +1225,31 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1061
1225
  * back to its own craft (AGL-3660). The Saltmarsh Soap Co start (2026-10-10)
1062
1226
  * found no hit naming "bar" or "box" among the first tags, and four of its
1063
1227
  * five products took the starter photos — a laptop and roses, a living room
1064
- * of plants. So, in order:
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}):
1065
1234
  *
1066
- * 1. The same searches again, the head noun named anywhere among the tags
1067
- * (no extra search: the page remembers its answers), and a gift's own
1068
- * wrapper with the craft ("soap gift box").
1069
- * 2. The site's broad searches ("handmade soap", "natural skincare"), any hit
1070
- * naming the craft itself ({@link AiStockSiteTerms.core}).
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}).
1071
1246
  *
1072
- * The source then reuses an on-topic photo another slot of the job already
1073
- * shows before it gives up ({@link aiLayoutStockPhotoSource}).
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.
1074
1251
  */ export function aiStockProductSearches(slot, craft, domain = [], fallback = {}) {
1075
- var _ref, _subjects_, _fallback_broad, _fallback_core;
1252
+ var _ref, _subjects_, _fallback_core, _ref1, _fallback_broad, _fallback_broad1;
1076
1253
  var _slot_product;
1077
1254
  const orientation = aiStockOrientation(slot.aspect);
1078
1255
  const size = orientation === 'vertical' ? {
@@ -1085,7 +1262,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1085
1262
  ...domain,
1086
1263
  ...craftStems
1087
1264
  ]);
1088
- const subjects = ((_ref = (_slot_product = slot.product) == null ? void 0 : _slot_product.subjects) != null ? _ref : []).map((raw)=>aiStockSubjectWords(raw, '')).filter(Boolean);
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);
1089
1266
  // The product's head noun is its name's: "candle" of "Hand-Poured Soy Candle".
1090
1267
  const own = (_subjects_ = subjects[0]) != null ? _subjects_ : '';
1091
1268
  const object = aiStockObjectWord(own);
@@ -1102,10 +1279,19 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1102
1279
  ...craftStems,
1103
1280
  ...ownWorld
1104
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);
1105
1289
  const queries = [];
1106
1290
  for (const subject of subjects){
1291
+ var _ref2;
1107
1292
  const stems = stemList(subject);
1108
- const query = kin ? stems.includes(aiStockStem(kin.qualifier)) ? subject : `${kin.qualifier} ${subject}` : craft && !stems.some((stem)=>world.has(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;
1109
1295
  queries.push({
1110
1296
  query,
1111
1297
  subject,
@@ -1133,13 +1319,25 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1133
1319
  query: craft,
1134
1320
  subject: own || craft,
1135
1321
  object: craft,
1136
- lead: true
1322
+ lead: true,
1323
+ tier: 'borrow'
1137
1324
  });
1138
- // 1. The head noun anywhere among the tags, by the same searches.
1139
- for (const entry of [
1325
+ const held = [
1140
1326
  ...queries
1141
- ])queries.push(_extends({}, entry, {
1142
- lead: false
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'
1143
1341
  }));
1144
1342
  // A gift is its craft's: "bath gift box" asks for a "soap gift box".
1145
1343
  const wrapped = own.split(/\s+/).filter((word)=>WRAPPERS.has(word) && aiStockStem(word) !== object);
@@ -1148,47 +1346,60 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1148
1346
  query: `${craft} ${wrapped.join(' ')}`,
1149
1347
  subject: own,
1150
1348
  object: aiStockStem(gift),
1151
- lead: false
1349
+ lead: false,
1350
+ tier: 'weak'
1152
1351
  });
1153
- // 2. The craft's own broad searches, any hit naming the craft.
1154
- for (const query of (_fallback_broad = fallback.broad) != null ? _fallback_broad : [])queries.push({
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({
1155
1354
  query,
1156
1355
  subject: own,
1157
1356
  object: '',
1158
1357
  lead: false,
1159
- broad: true
1358
+ broad: true,
1359
+ tier: 'weak'
1160
1360
  });
1161
- const core = [
1162
- ...new Set([
1163
- ...craftStems,
1164
- ...(_fallback_core = fallback.core) != null ? _fallback_core : []
1165
- ])
1166
- ];
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;
1167
1363
  const seen = new Set();
1168
1364
  const searches = [];
1169
1365
  for (const entry of queries){
1366
+ var _entry_tier;
1170
1367
  const query = clip(entry.query);
1171
- const key = `${query}|${entry.lead}|${entry.object}|${entry.broad === true}`;
1368
+ const key = `${query}|${entry.lead}|${entry.object}|${entry.broad === true}|${(_entry_tier = entry.tier) != null ? _entry_tier : ''}`;
1172
1369
  if (!query || seen.has(key)) continue;
1173
1370
  seen.add(key);
1371
+ const common = _extends({
1372
+ query,
1373
+ orientation
1374
+ }, size, {
1375
+ subject: entry.subject,
1376
+ product: true
1377
+ }, entry.tier ? {
1378
+ tier: entry.tier
1379
+ } : {}, shun.length ? {
1380
+ shun
1381
+ } : {});
1174
1382
  if (entry.broad) {
1175
- searches.push(_extends({
1176
- query,
1177
- orientation
1178
- }, size, {
1179
- broad: true,
1180
- subject: entry.subject
1181
- }, core.length ? {
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 ? {
1182
1398
  category: core
1183
1399
  } : {}));
1184
1400
  continue;
1185
1401
  }
1186
- searches.push(_extends({
1187
- query,
1188
- orientation
1189
- }, size, {
1190
- subject: entry.subject
1191
- }, entry.lead ? {
1402
+ searches.push(_extends({}, common, entry.lead ? {
1192
1403
  lead: true
1193
1404
  } : {
1194
1405
  leadWorld: true
@@ -1328,7 +1539,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1328
1539
  }
1329
1540
  }
1330
1541
  /** A hit's words: the first few (what it is OF), every one, and each tag's phrase. */ function hitWords(photo) {
1331
- var _photo_tags, _photo_alt, _photo_alt1, _photo_alt2;
1542
+ var _photo_tags, _photo_alt, _photo_alt1, _tags_, _photo_alt2;
1332
1543
  const slug = pageSlug(photo.pageUrl);
1333
1544
  const tags = ((_photo_tags = photo.tags) != null ? _photo_tags : []).map((tag)=>tag.toLowerCase().trim()).filter(Boolean);
1334
1545
  const lead = new Set(stemList([
@@ -1349,6 +1560,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1349
1560
  ...tags.slice(0, 3).map((tag)=>stemList(tag).join(' ')),
1350
1561
  stemList(slug).join(' ')
1351
1562
  ];
1563
+ /** The first tag: what the photo is most of all. */ const firstPhrase = stemList((_tags_ = tags[0]) != null ? _tags_ : '').join(' ');
1352
1564
  const rejected = tags.some((tag)=>REJECTED_TAGS.has(tag) || tag.split(/\s+/).some((word)=>REJECTED_TAGS.has(word)));
1353
1565
  // Whole words only, a hyphenated one kept whole: "cross-stitch" is not a cross.
1354
1566
  const words = new Set([
@@ -1360,6 +1572,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1360
1572
  all,
1361
1573
  phrases,
1362
1574
  leadPhrases,
1575
+ firstPhrase,
1363
1576
  rejected,
1364
1577
  words
1365
1578
  };
@@ -1388,12 +1601,33 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1388
1601
  * the subject's two-word phrase 3, each of its other words 1, and the
1389
1602
  * site's world 1, plus a quarter when it is among the first tags.
1390
1603
  */ export function aiStockRelevance(photo, judgement) {
1391
- var _judgement_rivals, _judgement_kin, _judgement_requires, _judgement_category, _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;
1392
1605
  const hit = hitWords(photo);
1393
1606
  if (hit.rejected) return 0;
1394
1607
  if (aiStockSensitive(photo, judgement.named)) return 0;
1395
1608
  // Glass art is not pottery: a rival craft's photo names this craft's own words.
1396
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;
1397
1631
  const kin = (_judgement_kin = judgement.kin) != null ? _judgement_kin : [];
1398
1632
  const kinNamed = kin.some((phrase)=>among(hit.phrases, phrase));
1399
1633
  const kinLead = kin.some((phrase)=>among(hit.leadPhrases, phrase));
@@ -1409,7 +1643,6 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1409
1643
  const meets = anyOf.length ? anyOf.some((stem)=>hit.all.has(stem)) : required.every((stem)=>hit.all.has(stem));
1410
1644
  // An object's kin stands for the category: a wax melt is the candle shop's.
1411
1645
  if (demanded && !kinNamed && !meets) return 0;
1412
- const subject = stemList((_judgement_subject = judgement.subject) != null ? _judgement_subject : '');
1413
1646
  const object = (_judgement_object = judgement.object) != null ? _judgement_object : judgement.subject ? aiStockObjectWord(judgement.subject) : '';
1414
1647
  const domain = ((_judgement_domain = judgement.domain) != null ? _judgement_domain : []).filter((stem)=>stem !== object);
1415
1648
  // A hit naming the word it must name is of the site's world: a product's
@@ -1417,6 +1650,10 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1417
1650
  const world = demanded || kinNamed || domain.some((stem)=>hit.all.has(stem));
1418
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));
1419
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));
1420
1657
  const pair = subject.length >= 2 ? subject.slice(-2).join(' ') : '';
1421
1658
  const phrased = Boolean(pair) && among(hit.phrases, pair);
1422
1659
  let score = 0;
@@ -1430,9 +1667,13 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1430
1667
  if (domain.length && !world && !phrased) return 0;
1431
1668
  score += leading ? 4 : 2;
1432
1669
  if (kinNamed) score += 3;
1670
+ if (firstNamed(kin.length ? kin : [
1671
+ object
1672
+ ])) score += 1;
1433
1673
  } else if (domain.length) {
1434
1674
  if (!world) return 0;
1435
1675
  }
1676
+ if (leadAny.length && firstNamed(leadAny)) score += 1;
1436
1677
  if (phrased) score += 3;
1437
1678
  for (const word of new Set(subject))if (word !== object && hit.all.has(word)) score += 1;
1438
1679
  if (world) score += worldLead ? 1.25 : 1;
@@ -1645,14 +1886,26 @@ const EXTENSIONS = {
1645
1886
  return null;
1646
1887
  };
1647
1888
  /** How a search's hits are judged for a slot. */ const judgementFor = (slot, request, subject)=>{
1648
- var _request_subject;
1889
+ var _request_subject, _request_shun;
1890
+ var _request_shun1, _request_leadAny;
1649
1891
  return _extends({
1650
1892
  subject: (_request_subject = request.subject) != null ? _request_subject : request.broad ? '' : subject,
1651
1893
  domain: terms.domain,
1652
1894
  strict: slot.role === 'gallery' && !request.broad || !!request.requires,
1653
1895
  named: terms.named,
1654
1896
  rivals: terms.rivals
1655
- }, request.requires ? {
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 ? {
1656
1909
  requires: request.requires
1657
1910
  } : {}, request.category ? {
1658
1911
  category: request.category
@@ -1686,7 +1939,10 @@ const EXTENSIONS = {
1686
1939
  }, size, {
1687
1940
  broad: true,
1688
1941
  category: terms.core
1689
- }));
1942
+ }, slot.product ? {
1943
+ product: true,
1944
+ tier: 'weak'
1945
+ } : {}));
1690
1946
  };
1691
1947
  // 1. Choose, slot by slot, so the page's choices never collide: each
1692
1948
  // slot claims its best accepted hit before the next slot looks.
@@ -1720,34 +1976,57 @@ const EXTENSIONS = {
1720
1976
  }
1721
1977
  return aiStockRank(found, blocked, `${input.seed}:${index}:${request.query}`, judgementFor(slot, request, subject));
1722
1978
  };
1723
- for (const request of requests){
1724
- if (choice || signal.aborted) break;
1725
- const ranked = await accepted(request, false);
1726
- if (ranked === null) break;
1727
- if (!ranked.length) continue;
1728
- const first = ranked[0];
1729
- claimed.add(stockPhotoSourceKey(first));
1730
- choice = {
1731
- candidates: ranked.slice(0, AI_LAYOUT_STOCK_TRIES_PER_SLOT),
1732
- query: request.query
1733
- };
1734
- }
1735
- // 2. Nothing new of its own: a product or a hero reuses an on-topic
1736
- // photo another slot of the job already shows (a product may repeat
1737
- // a collection tile's photo) before the starters' desk or living
1738
- // room fills it (AGL-3660). Only the searches already answered are
1739
- // asked again, from memory. Any other picture keeps "never twice".
1740
- if (!choice && (slot.product || slot.role === 'hero')) {
1741
- for (const request of requests){
1742
- if (choice || signal.aborted) break;
1743
- const ranked = await accepted(request, true);
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);
1744
1984
  if (!(ranked == null ? void 0 : ranked.length)) continue;
1745
- choice = {
1985
+ if (!repeat) claimed.add(stockPhotoSourceKey(ranked[0]));
1986
+ return _extends({
1746
1987
  candidates: ranked.slice(0, AI_LAYOUT_STOCK_TRIES_PER_SLOT),
1747
- query: request.query,
1988
+ query: request.query
1989
+ }, repeat ? {
1748
1990
  repeat: true
1749
- };
1991
+ } : {});
1750
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);
1751
2030
  }
1752
2031
  choices.push(choice);
1753
2032
  }