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

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,18 @@ 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
+ *
98
110
  * A slot nothing acceptable answered — the library down, refusing, or out
99
111
  * of time — is left `null` for the starter photos. A named object is never
100
112
  * given one while the library answers anything of the site's world: the
@@ -120,7 +132,8 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
120
132
  /**
121
133
  * Words that describe a picture's look, not what it shows, dropped from a
122
134
  * 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');
135
+ */ 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).
136
+ '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
137
  /** 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
138
  /** 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
139
  /** 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');
@@ -469,6 +482,35 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
469
482
  'wax melt warmer',
470
483
  'scented wax cubes'
471
484
  ]
485
+ },
486
+ {
487
+ // A "bar" is a pub's before it is a soap's (the Saltmarsh "cold-process
488
+ // soap bar" search answered a pint of beer second): a soap bar is named
489
+ // by soap, and searched by it.
490
+ objects: [
491
+ 'bar'
492
+ ],
493
+ within: [
494
+ 'soap',
495
+ 'soapmaker',
496
+ 'skincare'
497
+ ],
498
+ qualifier: 'soap',
499
+ kin: [
500
+ 'soap bar',
501
+ 'soap bars',
502
+ 'bar of soap',
503
+ 'bars of soap',
504
+ 'soap',
505
+ 'soaps',
506
+ 'handmade soap',
507
+ 'soap block',
508
+ 'soap cubes'
509
+ ],
510
+ queries: [
511
+ 'soap bars',
512
+ 'handmade soap bar'
513
+ ]
472
514
  }
473
515
  ];
474
516
  /** An object's kin where the site or the subject is of its world, else `null`. */ function objectKin(object, world) {
@@ -595,22 +637,53 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
595
637
  'soapmaker',
596
638
  'skincare'
597
639
  ],
640
+ // Never "natural": a florist's bouquet tagged "natural" was the
641
+ // Saltmarsh Soap Co hero (2026-10-10).
598
642
  kin: [
599
643
  'soap',
600
644
  'skincare',
601
645
  'lotion',
646
+ 'cream',
602
647
  'bath',
603
648
  'spa',
604
- 'natural',
605
649
  'cosmetic'
606
650
  ],
607
651
  broad: [
608
652
  'handmade soap',
609
653
  'natural skincare'
610
654
  ],
655
+ // "Soap making" is answered with soap bubbles; a bar is what a soapmaker makes.
611
656
  making: [
612
- 'soap making'
613
- ]
657
+ 'soap making',
658
+ 'handmade soap'
659
+ ],
660
+ // Soap bubbles, a dispenser and detergent are soap, never a soapmaker's:
661
+ // the same start's story band showed bubbles in flight.
662
+ rivals: {
663
+ words: [
664
+ 'bubble',
665
+ 'bubbles',
666
+ 'soapy',
667
+ 'dispenser',
668
+ 'detergent',
669
+ 'dishwashing',
670
+ 'laundry'
671
+ ],
672
+ core: [
673
+ 'bar',
674
+ 'bars',
675
+ 'handmade',
676
+ 'homemade',
677
+ 'loaf',
678
+ 'lye',
679
+ 'glycerin',
680
+ 'skincare',
681
+ 'lotion',
682
+ 'cosmetic',
683
+ 'spa',
684
+ 'artisan'
685
+ ]
686
+ }
614
687
  },
615
688
  {
616
689
  match: [
@@ -837,10 +910,11 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
837
910
  return craft ? aiStockStem(craft) : '';
838
911
  }
839
912
  /** The site's terms, read from its business type. */ export function aiStockSiteTerms(businessType) {
840
- const business = aiStockBusinessWords(businessType);
841
- const craft = aiStockCraftWords(business);
913
+ let business = aiStockBusinessWords(businessType);
914
+ let craft = aiStockCraftWords(business);
842
915
  const domain = new Set();
843
916
  /** The things it makes, by their head nouns. */ const made = new Set();
917
+ /** The last word of the first clause that names the world: "soap" of "for handmade cold-process soap". */ let firstWorld = '';
844
918
  let named = false;
845
919
  let making = false;
846
920
  for (const { opener, words: clause } of businessClauses(businessType)){
@@ -858,16 +932,34 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
858
932
  // A set or a gift is never a world: "gift" of "gift sets" is not one.
859
933
  const world = (making ? kept.slice(0, -1) : kept).filter((word)=>!WRAPPERS.has(word));
860
934
  for (const word of world)for (const stem of stemList(word))domain.add(stem);
935
+ const last = world[world.length - 1];
936
+ if (!firstWorld && last) firstWorld = aiStockStem(last);
861
937
  if (making) for (const word of kept.slice(-1))for (const stem of stemList(word))made.add(stem);
862
938
  }
863
- if (craft) domain.add(craft);
864
939
  // A business named only by what it sells ("a shop selling pottery") is of that world.
865
940
  if (!domain.size) for (const stem of made)domain.add(stem);
941
+ const groups = CRAFT_KIN.filter((group)=>group.match.some((word)=>domain.has(aiStockStem(word)) || made.has(aiStockStem(word)) || aiStockStem(word) === craft));
942
+ // A business that is only a place ("an online shop for handmade
943
+ // cold-process soap") takes its craft from what it is for: the Saltmarsh
944
+ // Soap Co start (2026-10-10) read its craft as nothing, so its hero searched
945
+ // "shop" (a florist's bouquet) and no product was held to soap.
946
+ if (!craft) {
947
+ var _groups_;
948
+ const listed = (_groups_ = groups[0]) == null ? void 0 : _groups_.match.map(aiStockStem).find((stem)=>domain.has(stem) || made.has(stem));
949
+ craft = listed || firstWorld || [
950
+ ...made
951
+ ][0] || '';
952
+ if (craft && !stemList(business).includes(craft)) business = `${craft} ${business}`.trim();
953
+ }
954
+ if (craft) domain.add(craft);
866
955
  const broad = [];
867
956
  const makingSearches = [];
868
957
  const rivals = [];
869
- for (const group of CRAFT_KIN){
870
- if (!group.match.some((word)=>domain.has(aiStockStem(word)) || made.has(aiStockStem(word)))) continue;
958
+ const core = new Set(craft ? [
959
+ craft
960
+ ] : []);
961
+ for (const group of groups){
962
+ for (const word of group.match)core.add(aiStockStem(word));
871
963
  for (const word of group.kin)domain.add(aiStockStem(word));
872
964
  broad.push(...group.broad);
873
965
  makingSearches.push(...group.making);
@@ -892,7 +984,10 @@ const words = (list)=>new Set(list.trim().split(/\s+/));
892
984
  named: [
893
985
  ...new Set(stemList(businessType))
894
986
  ],
895
- rivals
987
+ rivals,
988
+ core: [
989
+ ...core
990
+ ]
896
991
  };
897
992
  }
898
993
  /** A phrase's content words without its filler; all filler, and nothing is left. */ function subjectOf(list) {
@@ -943,23 +1038,41 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
943
1038
  /** An object's kin phrases as stems, or none. */ const kinStems = (entry)=>entry ? [
944
1039
  ...new Set(entry.kin.map((phrase)=>stemList(phrase).join(' ')))
945
1040
  ] : [];
1041
+ /** 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
1042
  /**
947
1043
  * A product's searches (AGL-3676), most specific first: each of its subjects
948
1044
  * — 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".
1045
+ * where the phrase does not already name the shop's world ("candle gift
1046
+ * box"), then the category alone, a plain photo of what the shop sells. Every
1047
+ * one must find a hit naming the category — or the product's own word of the
1048
+ * shop's world, "lotion" in a soap shop — so no product is ever filled with a
1049
+ * lifestyle shot of something else: the beta.237 Willow Wick start put a
1050
+ * laptop and roses under "Candle Wick Trimmer" and an antique tea set under
1051
+ * "Candle Gift Set".
954
1052
  *
955
- * And every one must find a hit naming the product's own head noun among its
1053
+ * And each must find a hit naming the product's own head noun among its
956
1054
  * first tags (AGL-3660): the beta.239 Ember & Oak start put a bed with a book,
957
1055
  * a mug and a camera (its candle a last tag) under "Hand-Poured Soy Candle".
958
1056
  * An object whose own word is not enough ({@link OBJECT_KIN}) is searched by
959
1057
  * its kin instead of the category alone, and named by them: a single taper
960
1058
  * candle filled "Wax Melt Gift Set".
961
- */ export function aiStockProductSearches(slot, craft, domain = []) {
962
- var _ref, _subjects_, _subjects_1;
1059
+ *
1060
+ * Then, before a product is ever given a photo of something else, it falls
1061
+ * back to its own craft (AGL-3660). The Saltmarsh Soap Co start (2026-10-10)
1062
+ * found no hit naming "bar" or "box" among the first tags, and four of its
1063
+ * five products took the starter photos — a laptop and roses, a living room
1064
+ * of plants. So, in order:
1065
+ *
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}).
1071
+ *
1072
+ * The source then reuses an on-topic photo another slot of the job already
1073
+ * shows before it gives up ({@link aiLayoutStockPhotoSource}).
1074
+ */ export function aiStockProductSearches(slot, craft, domain = [], fallback = {}) {
1075
+ var _ref, _subjects_, _fallback_broad, _fallback_core;
963
1076
  var _slot_product;
964
1077
  const orientation = aiStockOrientation(slot.aspect);
965
1078
  const size = orientation === 'vertical' ? {
@@ -968,62 +1081,131 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
968
1081
  minWidth: 900
969
1082
  };
970
1083
  const craftStems = stemList(craft);
1084
+ const world = new Set([
1085
+ ...domain,
1086
+ ...craftStems
1087
+ ]);
971
1088
  const subjects = ((_ref = (_slot_product = slot.product) == null ? void 0 : _slot_product.subjects) != null ? _ref : []).map((raw)=>aiStockSubjectWords(raw, '')).filter(Boolean);
972
1089
  // The product's head noun is its name's: "candle" of "Hand-Poured Soy Candle".
973
- const object = aiStockObjectWord((_subjects_ = subjects[0]) != null ? _subjects_ : '');
1090
+ const own = (_subjects_ = subjects[0]) != null ? _subjects_ : '';
1091
+ const object = aiStockObjectWord(own);
974
1092
  const kin = object ? objectKin(object, [
975
1093
  ...craftStems,
976
1094
  ...domain,
977
1095
  ...stemList(subjects.join(' '))
978
1096
  ]) : null;
979
1097
  const kinPhrases = kinStems(kin);
1098
+ // A product of the shop's world beside its category is held to either:
1099
+ // a soap shop's body lotion names "lotion", never "soap".
1100
+ const ownWorld = stemList(own).filter((stem)=>world.has(stem) && !WRAPPERS.has(stem) && !craftStems.includes(stem));
1101
+ const category = craft && ownWorld.length ? [
1102
+ ...craftStems,
1103
+ ...ownWorld
1104
+ ] : [];
980
1105
  const queries = [];
981
1106
  for (const subject of subjects){
982
1107
  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;
1108
+ const query = kin ? stems.includes(aiStockStem(kin.qualifier)) ? subject : `${kin.qualifier} ${subject}` : craft && !stems.some((stem)=>world.has(stem)) ? `${craft} ${subject}` : subject;
984
1109
  queries.push({
985
1110
  query,
986
1111
  subject,
987
- object
1112
+ object,
1113
+ lead: true
988
1114
  });
989
1115
  }
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 : '';
1116
+ // The category alone stands in only for a thing that is no more than of
1117
+ // it; a thing that is its own word of the shop's world is searched by that
1118
+ // word ("lotion"). Its hits are still scored by the product's own words: a
1119
+ // soy candle over a taper.
993
1120
  if (kin) for (const query of kin.queries)queries.push({
994
1121
  query,
995
1122
  subject: own || query,
996
- object
1123
+ object,
1124
+ lead: true
1125
+ });
1126
+ else if (object && ownWorld.includes(object)) queries.push({
1127
+ query: object,
1128
+ subject: own,
1129
+ object,
1130
+ lead: true
997
1131
  });
998
1132
  else if (craft) queries.push({
999
1133
  query: craft,
1000
1134
  subject: own || craft,
1001
- object: craft
1135
+ object: craft,
1136
+ lead: true
1137
+ });
1138
+ // 1. The head noun anywhere among the tags, by the same searches.
1139
+ for (const entry of [
1140
+ ...queries
1141
+ ])queries.push(_extends({}, entry, {
1142
+ lead: false
1143
+ }));
1144
+ // A gift is its craft's: "bath gift box" asks for a "soap gift box".
1145
+ const wrapped = own.split(/\s+/).filter((word)=>WRAPPERS.has(word) && aiStockStem(word) !== object);
1146
+ const gift = wrapped.find((word)=>GIFT_WRAPPERS.has(word));
1147
+ if (craft && gift && !kin) queries.push({
1148
+ query: `${craft} ${wrapped.join(' ')}`,
1149
+ subject: own,
1150
+ object: aiStockStem(gift),
1151
+ lead: false
1002
1152
  });
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({
1155
+ query,
1156
+ subject: own,
1157
+ object: '',
1158
+ lead: false,
1159
+ broad: true
1160
+ });
1161
+ const core = [
1162
+ ...new Set([
1163
+ ...craftStems,
1164
+ ...(_fallback_core = fallback.core) != null ? _fallback_core : []
1165
+ ])
1166
+ ];
1003
1167
  const seen = new Set();
1004
1168
  const searches = [];
1005
1169
  for (const entry of queries){
1006
1170
  const query = clip(entry.query);
1007
- if (!query || seen.has(query)) continue;
1008
- seen.add(query);
1171
+ const key = `${query}|${entry.lead}|${entry.object}|${entry.broad === true}`;
1172
+ if (!query || seen.has(key)) continue;
1173
+ seen.add(key);
1174
+ if (entry.broad) {
1175
+ searches.push(_extends({
1176
+ query,
1177
+ orientation
1178
+ }, size, {
1179
+ broad: true,
1180
+ subject: entry.subject
1181
+ }, core.length ? {
1182
+ category: core
1183
+ } : {}));
1184
+ continue;
1185
+ }
1009
1186
  searches.push(_extends({
1010
1187
  query,
1011
1188
  orientation
1012
1189
  }, size, {
1013
- subject: entry.subject,
1190
+ subject: entry.subject
1191
+ }, entry.lead ? {
1014
1192
  lead: true
1193
+ } : {
1194
+ leadWorld: true
1015
1195
  }, entry.object ? {
1016
1196
  object: entry.object
1017
- } : {}, kinPhrases.length ? {
1197
+ } : {}, kinPhrases.length && entry.object === object ? {
1018
1198
  kin: kinPhrases
1019
1199
  } : {}, craft ? {
1020
1200
  requires: craft
1201
+ } : {}, category.length ? {
1202
+ category
1021
1203
  } : {}));
1022
1204
  }
1023
1205
  return searches;
1024
1206
  }
1025
1207
  /** The searches a slot tries, in order, most specific first, each distinct. */ export function aiStockSearchesFor(slot, terms, subject, part) {
1026
- var _terms_making;
1208
+ var _terms_core, _terms_making;
1027
1209
  const orientation = aiStockOrientation(slot.aspect);
1028
1210
  const size = slot.role === 'hero' ? {
1029
1211
  minWidth: 1600
@@ -1042,6 +1224,12 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1042
1224
  const kinPhrases = kinStems(kin);
1043
1225
  // The craft qualifies a subject that does not already name the site's world.
1044
1226
  const namesWorld = subjectStems.some((stem)=>domain.has(stem));
1227
+ // A tile of the shop's world beside its category is held to either: a soap
1228
+ // shop's "Body care" tile names "body", never "soap" (AGL-3660).
1229
+ const ownWorld = subjectStems.filter((stem)=>domain.has(stem) && !WRAPPERS.has(stem) && stem !== terms.craft);
1230
+ // A hero of a craft names the craft itself: the Saltmarsh Soap Co hero
1231
+ // (2026-10-10) was a florist's bouquet tagged "natural" (AGL-3660).
1232
+ const core = (_terms_core = terms.core) != null ? _terms_core : [];
1045
1233
  const qualify = (phrase)=>terms.craft && !namesWorld && !stemList(phrase).includes(terms.craft) ? `${terms.craft} ${phrase}` : phrase;
1046
1234
  // A collection tile is product-like: held to the shop's category and its
1047
1235
  // own object among the first tags, and searched by its object's kin, never
@@ -1117,8 +1305,15 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1117
1305
  kin: kinPhrases
1118
1306
  } : {}, held && collection ? {
1119
1307
  lead: true
1120
- } : {}, terms.craft && (raw.category || raw.held && collection) ? {
1308
+ } : {}, terms.craft && (raw.category || raw.held && collection) ? _extends({
1121
1309
  requires: terms.craft
1310
+ }, ownWorld.length ? {
1311
+ category: [
1312
+ terms.craft,
1313
+ ...ownWorld
1314
+ ]
1315
+ } : {}) : {}, slot.role === 'hero' && core.length ? {
1316
+ category: core
1122
1317
  } : {}));
1123
1318
  }
1124
1319
  return searches;
@@ -1193,7 +1388,7 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1193
1388
  * the subject's two-word phrase 3, each of its other words 1, and the
1194
1389
  * site's world 1, plus a quarter when it is among the first tags.
1195
1390
  */ export function aiStockRelevance(photo, judgement) {
1196
- var _judgement_rivals, _judgement_kin, _judgement_requires, _judgement_subject, _judgement_object, _judgement_domain;
1391
+ var _judgement_rivals, _judgement_kin, _judgement_requires, _judgement_category, _judgement_subject, _judgement_object, _judgement_domain;
1197
1392
  const hit = hitWords(photo);
1198
1393
  if (hit.rejected) return 0;
1199
1394
  if (aiStockSensitive(photo, judgement.named)) return 0;
@@ -1203,15 +1398,25 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
1203
1398
  const kinNamed = kin.some((phrase)=>among(hit.phrases, phrase));
1204
1399
  const kinLead = kin.some((phrase)=>among(hit.leadPhrases, phrase));
1205
1400
  const required = stemList((_judgement_requires = judgement.requires) != null ? _judgement_requires : '');
1401
+ const category = ((_judgement_category = judgement.category) != null ? _judgement_category : []).flatMap(stemList);
1402
+ const anyOf = category.length ? [
1403
+ ...new Set([
1404
+ ...required,
1405
+ ...category
1406
+ ])
1407
+ ] : [];
1408
+ const demanded = required.length > 0 || anyOf.length > 0;
1409
+ const meets = anyOf.length ? anyOf.some((stem)=>hit.all.has(stem)) : required.every((stem)=>hit.all.has(stem));
1206
1410
  // 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;
1411
+ if (demanded && !kinNamed && !meets) return 0;
1208
1412
  const subject = stemList((_judgement_subject = judgement.subject) != null ? _judgement_subject : '');
1209
1413
  const object = (_judgement_object = judgement.object) != null ? _judgement_object : judgement.subject ? aiStockObjectWord(judgement.subject) : '';
1210
1414
  const domain = ((_judgement_domain = judgement.domain) != null ? _judgement_domain : []).filter((stem)=>stem !== object);
1211
1415
  // A hit naming the word it must name is of the site's world: a product's
1212
1416
  // 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));
1417
+ const world = demanded || kinNamed || domain.some((stem)=>hit.all.has(stem));
1418
+ 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
+ if (judgement.leadWorld && !worldLead) return 0;
1215
1420
  const pair = subject.length >= 2 ? subject.slice(-2).join(' ') : '';
1216
1421
  const phrased = Boolean(pair) && among(hit.phrases, pair);
1217
1422
  let score = 0;
@@ -1345,6 +1550,8 @@ const EXTENSIONS = {
1345
1550
  delete request.object;
1346
1551
  delete request.kin;
1347
1552
  delete request.lead;
1553
+ delete request.leadWorld;
1554
+ delete request.category;
1348
1555
  const key = JSON.stringify([
1349
1556
  request.query,
1350
1557
  request.orientation,
@@ -1363,7 +1570,7 @@ const EXTENSIONS = {
1363
1570
  answers.set(key, asked);
1364
1571
  return asked;
1365
1572
  };
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)=>{
1573
+ /** 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
1574
  const sourceKey = stockPhotoSourceKey(photo);
1368
1575
  try {
1369
1576
  var _EXTENSIONS_bytes_contentType;
@@ -1374,7 +1581,7 @@ const EXTENSIONS = {
1374
1581
  if (kept) {
1375
1582
  var _kept_width, _kept_height;
1376
1583
  // 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;
1584
+ if (!repeat && (placedSrcs.has(kept.src) || elsewhere(kept.src))) return null;
1378
1585
  remember(sourceKey, kept.src);
1379
1586
  return {
1380
1587
  src: kept.src,
@@ -1437,6 +1644,50 @@ const EXTENSIONS = {
1437
1644
  }
1438
1645
  return null;
1439
1646
  };
1647
+ /** How a search's hits are judged for a slot. */ const judgementFor = (slot, request, subject)=>{
1648
+ var _request_subject;
1649
+ return _extends({
1650
+ subject: (_request_subject = request.subject) != null ? _request_subject : request.broad ? '' : subject,
1651
+ domain: terms.domain,
1652
+ strict: slot.role === 'gallery' && !request.broad || !!request.requires,
1653
+ named: terms.named,
1654
+ rivals: terms.rivals
1655
+ }, request.requires ? {
1656
+ requires: request.requires
1657
+ } : {}, request.category ? {
1658
+ category: request.category
1659
+ } : {}, request.object ? {
1660
+ object: request.object
1661
+ } : {}, request.kin ? {
1662
+ kin: request.kin
1663
+ } : {}, request.lead ? {
1664
+ lead: true
1665
+ } : {}, request.leadWorld ? {
1666
+ leadWorld: true
1667
+ } : {});
1668
+ };
1669
+ /**
1670
+ * The craft's own photos, the last thing a slot of a craft's site is
1671
+ * filled from (AGL-3660): its broad searches, any hit naming the craft
1672
+ * itself. Never a desk, a laptop or a living room for a soap shop.
1673
+ */ const craftSearches = (slot)=>{
1674
+ if (!terms.core.length) return [];
1675
+ const orientation = aiStockOrientation(slot.aspect);
1676
+ const size = slot.role === 'hero' ? {
1677
+ minWidth: 1600
1678
+ } : orientation === 'vertical' ? {
1679
+ minHeight: 900
1680
+ } : {
1681
+ minWidth: 900
1682
+ };
1683
+ return terms.broad.map((query)=>_extends({
1684
+ query: clip(query),
1685
+ orientation
1686
+ }, size, {
1687
+ broad: true,
1688
+ category: terms.core
1689
+ }));
1690
+ };
1440
1691
  // 1. Choose, slot by slot, so the page's choices never collide: each
1441
1692
  // slot claims its best accepted hit before the next slot looks.
1442
1693
  const choices = [];
@@ -1449,32 +1700,30 @@ const EXTENSIONS = {
1449
1700
  const sectionName = (_input_sectionNames_slot_sectionIndex = input.sectionNames[slot.sectionIndex]) != null ? _input_sectionNames_slot_sectionIndex : '';
1450
1701
  const subject = aiStockSubjectWords(slot.alt, sectionName);
1451
1702
  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;
1703
+ const requests = [
1704
+ ...slot.product ? aiStockProductSearches(slot, terms.craft, terms.domain, {
1705
+ broad: terms.broad,
1706
+ core: terms.core
1707
+ }) : aiStockSearchesFor(slot, terms, subject, aiStockSlotPart(slot, sectionName)),
1708
+ ...craftSearches(slot)
1709
+ ];
1710
+ /** The slot's accepted hits for a search; `null` once the page's searches are spent. */ const accepted = async (request, repeat)=>{
1456
1711
  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);
1712
+ if (found === null) return null;
1713
+ const blocked = new Set();
1714
+ if (!repeat) {
1715
+ for (const key of claimed)blocked.add(key);
1716
+ for (const hit of found){
1717
+ const key = stockPhotoSourceKey(hit);
1718
+ if (elsewhere(key)) blocked.add(key);
1719
+ }
1462
1720
  }
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
- } : {}));
1721
+ return aiStockRank(found, blocked, `${input.seed}:${index}:${request.query}`, judgementFor(slot, request, subject));
1722
+ };
1723
+ for (const request of requests){
1724
+ if (choice || signal.aborted) break;
1725
+ const ranked = await accepted(request, false);
1726
+ if (ranked === null) break;
1478
1727
  if (!ranked.length) continue;
1479
1728
  const first = ranked[0];
1480
1729
  claimed.add(stockPhotoSourceKey(first));
@@ -1483,11 +1732,30 @@ const EXTENSIONS = {
1483
1732
  query: request.query
1484
1733
  };
1485
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);
1744
+ if (!(ranked == null ? void 0 : ranked.length)) continue;
1745
+ choice = {
1746
+ candidates: ranked.slice(0, AI_LAYOUT_STOCK_TRIES_PER_SLOT),
1747
+ query: request.query,
1748
+ repeat: true
1749
+ };
1750
+ }
1751
+ }
1486
1752
  choices.push(choice);
1487
1753
  }
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.
1754
+ // 3. Keep, a few at a time; a copy that fails tries the slot's next
1755
+ // accepted hit no other slot has claimed. A repeat waits for the
1756
+ // slot it repeats, so the library never holds a photo twice.
1490
1757
  const photos = slots.map(()=>null);
1758
+ /** What this pass placed, by source key. */ const placedByKey = new Map();
1491
1759
  const fill = async (index)=>{
1492
1760
  const choice = choices[index];
1493
1761
  const slot = slots[index];
@@ -1495,28 +1763,43 @@ const EXTENSIONS = {
1495
1763
  for (const [rank, candidate] of choice.candidates.entries()){
1496
1764
  if (signal.aborted) return;
1497
1765
  const key = stockPhotoSourceKey(candidate);
1766
+ if (choice.repeat) {
1767
+ var _placedByKey_get;
1768
+ const placed = (_placedByKey_get = placedByKey.get(key)) != null ? _placedByKey_get : await keep(candidate, slot, choice.query, true);
1769
+ if (placed) {
1770
+ photos[index] = placed;
1771
+ return;
1772
+ }
1773
+ continue;
1774
+ }
1498
1775
  if (rank > 0) {
1499
1776
  if (claimed.has(key)) continue;
1500
1777
  claimed.add(key);
1501
1778
  }
1502
1779
  const placed = await keep(candidate, slot, choice.query);
1503
1780
  if (placed) {
1781
+ placedByKey.set(key, placed);
1504
1782
  photos[index] = placed;
1505
1783
  return;
1506
1784
  }
1507
1785
  }
1508
1786
  };
1787
+ const firsts = slots.map((_slot, index)=>index).filter((index)=>{
1788
+ var _choices_index;
1789
+ return !((_choices_index = choices[index]) == null ? void 0 : _choices_index.repeat);
1790
+ });
1509
1791
  let next = 0;
1510
1792
  const worker = async ()=>{
1511
- while(next < slots.length){
1512
- const index = next;
1793
+ while(next < firsts.length){
1794
+ const index = firsts[next];
1513
1795
  next += 1;
1514
1796
  await fill(index);
1515
1797
  }
1516
1798
  };
1517
1799
  await Promise.all(Array.from({
1518
- length: Math.min(AI_LAYOUT_STOCK_COPIES_AT_ONCE, slots.length)
1800
+ length: Math.min(AI_LAYOUT_STOCK_COPIES_AT_ONCE, firsts.length)
1519
1801
  }, worker));
1802
+ for (const [index, choice] of choices.entries())if (choice == null ? void 0 : choice.repeat) await fill(index);
1520
1803
  return photos;
1521
1804
  };
1522
1805
  }