@aglyn/plugins-ai 1.0.0-beta.236 → 1.0.0-beta.238

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/ai-credits-prompt.component.d.ts +44 -0
  3. package/src/lib/components/ai-credits-prompt.component.js +117 -0
  4. package/src/lib/components/ai-credits-prompt.component.js.map +1 -0
  5. package/src/lib/components/ai-job-cancel.component.d.ts +38 -0
  6. package/src/lib/components/ai-job-cancel.component.js +156 -0
  7. package/src/lib/components/ai-job-cancel.component.js.map +1 -0
  8. package/src/lib/components/ai-job-plan.component.d.ts +5 -4
  9. package/src/lib/components/ai-job-plan.component.js +48 -12
  10. package/src/lib/components/ai-job-plan.component.js.map +1 -1
  11. package/src/lib/components/ai-job-requests.d.ts +10 -0
  12. package/src/lib/components/ai-job-requests.js +12 -4
  13. package/src/lib/components/ai-job-requests.js.map +1 -1
  14. package/src/lib/components/ai-jobs-list-page.component.js +141 -72
  15. package/src/lib/components/ai-jobs-list-page.component.js.map +1 -1
  16. package/src/lib/components/ai-site-build-page.component.js +48 -9
  17. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  18. package/src/lib/components/ai-site-start-card.component.js +68 -22
  19. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  20. package/src/lib/components/ai-usage-strip.component.d.ts +19 -2
  21. package/src/lib/components/ai-usage-strip.component.js +40 -9
  22. package/src/lib/components/ai-usage-strip.component.js.map +1 -1
  23. package/src/lib/components/assist-build-card.component.d.ts +3 -1
  24. package/src/lib/components/assist-build-card.component.js +58 -7
  25. package/src/lib/components/assist-build-card.component.js.map +1 -1
  26. package/src/lib/components/assist-jobs-drawer.component.js +66 -22
  27. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  28. package/src/lib/components/assist-panel.component.js +89 -52
  29. package/src/lib/components/assist-panel.component.js.map +1 -1
  30. package/src/lib/jobs/ai-job-admission.d.ts +22 -2
  31. package/src/lib/jobs/ai-job-admission.js.map +1 -1
  32. package/src/lib/jobs/ai-job-build-step.d.ts +24 -11
  33. package/src/lib/jobs/ai-job-build-step.js +54 -35
  34. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  35. package/src/lib/jobs/ai-job-cancel.d.ts +71 -0
  36. package/src/lib/jobs/ai-job-cancel.js +102 -0
  37. package/src/lib/jobs/ai-job-cancel.js.map +1 -0
  38. package/src/lib/jobs/ai-job-component-step.js +4 -1
  39. package/src/lib/jobs/ai-job-component-step.js.map +1 -1
  40. package/src/lib/jobs/ai-job-drafts.js +2 -0
  41. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  42. package/src/lib/jobs/ai-job-form-step.js +4 -1
  43. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  44. package/src/lib/jobs/ai-job-layout-language.d.ts +2 -0
  45. package/src/lib/jobs/ai-job-layout-language.js +13 -1
  46. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  47. package/src/lib/jobs/ai-job-layout-step.js +6 -1
  48. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  49. package/src/lib/jobs/ai-job-page-language.js +12 -0
  50. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  51. package/src/lib/jobs/ai-job-page-step.d.ts +13 -0
  52. package/src/lib/jobs/ai-job-page-step.js +46 -4
  53. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  54. package/src/lib/jobs/ai-job-plan-step.d.ts +42 -0
  55. package/src/lib/jobs/ai-job-plan-step.js +118 -18
  56. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  57. package/src/lib/jobs/ai-job-site-content.d.ts +49 -3
  58. package/src/lib/jobs/ai-job-site-content.js +196 -37
  59. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  60. package/src/lib/jobs/ai-job-site-step.js +49 -16
  61. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  62. package/src/lib/jobs/ai-job-template-step.js +4 -1
  63. package/src/lib/jobs/ai-job-template-step.js.map +1 -1
  64. package/src/lib/jobs/ai-job-text-step.d.ts +8 -0
  65. package/src/lib/jobs/ai-job-text-step.js.map +1 -1
  66. package/src/lib/jobs/ai-jobs.d.ts +55 -13
  67. package/src/lib/jobs/ai-jobs.js +213 -41
  68. package/src/lib/jobs/ai-jobs.js.map +1 -1
  69. package/src/lib/jobs/ai-layout-stock-photos.d.ts +194 -34
  70. package/src/lib/jobs/ai-layout-stock-photos.js +861 -146
  71. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  72. package/src/lib/jobs/fixtures/ai-ceramics-portfolio-site-plan.d.ts +30 -0
  73. package/src/lib/jobs/fixtures/ai-ceramics-portfolio-site-plan.js +220 -0
  74. package/src/lib/jobs/fixtures/ai-ceramics-portfolio-site-plan.js.map +1 -0
  75. package/src/lib/jobs/fixtures/ai-stock-photo-pixabay-recordings.d.ts +27 -0
  76. package/src/lib/jobs/fixtures/ai-stock-photo-pixabay-recordings.js +151 -0
  77. package/src/lib/jobs/fixtures/ai-stock-photo-pixabay-recordings.js.map +1 -0
  78. package/src/lib/layout-language/ai-layout-compiler.d.ts +33 -0
  79. package/src/lib/layout-language/ai-layout-compiler.js +381 -18
  80. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  81. package/src/lib/layout-language/ai-layout-design.d.ts +6 -3
  82. package/src/lib/layout-language/ai-layout-design.js +5 -3
  83. package/src/lib/layout-language/ai-layout-design.js.map +1 -1
  84. package/src/lib/layout-language/ai-layout-frame.d.ts +12 -0
  85. package/src/lib/layout-language/ai-layout-frame.js +58 -3
  86. package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
  87. package/src/lib/layout-language/ai-layout-gaps.js +3 -1
  88. package/src/lib/layout-language/ai-layout-gaps.js.map +1 -1
  89. package/src/lib/layout-language/ai-layout-language.js +1 -1
  90. package/src/lib/layout-language/ai-layout-language.js.map +1 -1
  91. package/src/lib/layout-language/ai-layout-listings.d.ts +49 -1
  92. package/src/lib/layout-language/ai-layout-listings.js +117 -47
  93. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  94. package/src/lib/layout-language/ai-layout-pictures.d.ts +10 -1
  95. package/src/lib/layout-language/ai-layout-pictures.js +5 -2
  96. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  97. package/src/lib/layout-language/ai-layout-storefront.d.ts +82 -0
  98. package/src/lib/layout-language/ai-layout-storefront.js +223 -0
  99. package/src/lib/layout-language/ai-layout-storefront.js.map +1 -0
  100. package/src/lib/model/ai-build-job.d.ts +39 -0
  101. package/src/lib/model/ai-build-job.js +100 -1
  102. package/src/lib/model/ai-build-job.js.map +1 -1
  103. package/src/lib/model/ai-build-progress.d.ts +8 -0
  104. package/src/lib/model/ai-build-progress.js +35 -0
  105. package/src/lib/model/ai-build-progress.js.map +1 -1
  106. package/src/lib/model/ai-credit-estimate.d.ts +249 -0
  107. package/src/lib/model/ai-credit-estimate.js +278 -0
  108. package/src/lib/model/ai-credit-estimate.js.map +1 -0
  109. package/src/lib/model/ai-job-cancel-copy.d.ts +44 -0
  110. package/src/lib/model/ai-job-cancel-copy.js +53 -0
  111. package/src/lib/model/ai-job-cancel-copy.js.map +1 -0
  112. package/src/lib/model/ai-job-failure-copy.d.ts +4 -1
  113. package/src/lib/model/ai-job-failure-copy.js +8 -3
  114. package/src/lib/model/ai-job-failure-copy.js.map +1 -1
  115. package/src/lib/model/ai-jobs.types.d.ts +35 -0
  116. package/src/lib/model/ai-jobs.types.js.map +1 -1
  117. package/src/lib/model/ai-site-build-progress.js +7 -4
  118. package/src/lib/model/ai-site-build-progress.js.map +1 -1
  119. package/src/lib/model/ai-site-job.d.ts +76 -22
  120. package/src/lib/model/ai-site-job.js +125 -23
  121. package/src/lib/model/ai-site-job.js.map +1 -1
  122. package/src/lib/model/ai-site-kinds.d.ts +4 -1
  123. package/src/lib/model/ai-site-kinds.js +35 -7
  124. package/src/lib/model/ai-site-kinds.js.map +1 -1
  125. package/src/lib/runtime/ai-doctrine-validators.d.ts +37 -1
  126. package/src/lib/runtime/ai-doctrine-validators.js +161 -7
  127. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  128. package/src/lib/runtime/ai-doctrine.js +4 -3
  129. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  130. package/src/lib/runtime/ai-node-tree.d.ts +11 -0
  131. package/src/lib/runtime/ai-node-tree.js +26 -0
  132. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  133. package/src/lib/runtime/ai-palette.generated.js +592 -14
  134. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  135. package/src/lib/server/ai-jobs-cancel.d.ts +13 -5
  136. package/src/lib/server/ai-jobs-cancel.js +27 -9
  137. package/src/lib/server/ai-jobs-cancel.js.map +1 -1
  138. package/src/lib/server/ai-jobs-gate.d.ts +12 -2
  139. package/src/lib/server/ai-jobs-gate.js +20 -9
  140. package/src/lib/server/ai-jobs-gate.js.map +1 -1
  141. package/src/lib/server/ai-jobs-resume.js +93 -35
  142. package/src/lib/server/ai-jobs-resume.js.map +1 -1
  143. package/src/lib/server/ai-jobs-route.js +45 -4
  144. package/src/lib/server/ai-jobs-route.js.map +1 -1
  145. package/src/lib/server/assist-chat.js +4 -1
  146. package/src/lib/server/assist-chat.js.map +1 -1
  147. package/src/lib/server/assist-edit.js +3 -1
  148. package/src/lib/server/assist-edit.js.map +1 -1
  149. package/src/lib/server/assist-music.d.ts +36 -0
  150. package/src/lib/server/assist-music.js +36 -0
  151. package/src/lib/server/assist-music.js.map +1 -0
  152. package/src/lib/server.js +6 -0
  153. package/src/lib/server.js.map +1 -1
  154. package/src/lib/usage/ai-usage-meter.d.ts +2 -1
  155. package/src/lib/usage/ai-usage-meter.js +17 -1
  156. package/src/lib/usage/ai-usage-meter.js.map +1 -1
  157. package/src/lib/usage/ai-usage-wire.d.ts +6 -0
  158. package/src/lib/usage/ai-usage-wire.js.map +1 -1
  159. package/src/lib/usage/assist-usage.d.ts +7 -0
  160. package/src/lib/usage/assist-usage.js +14 -4
  161. package/src/lib/usage/assist-usage.js.map +1 -1
  162. package/src/lib/usage/free-ai-credits-left.d.ts +7 -0
  163. package/src/lib/usage/free-ai-credits-left.js +4 -2
  164. package/src/lib/usage/free-ai-credits-left.js.map +1 -1
@@ -26,49 +26,472 @@ import { aiLayoutSeedNumber } from "../layout-language/ai-layout-pictures.js";
26
26
  * server door (`core.media-ingest`), and builds no source at all when either
27
27
  * is missing, so a deployment without a key behaves exactly as before.
28
28
  *
29
- * For each slot, in document order:
29
+ * What the live starts of 2026-10-10 taught (Juniper Clay Works, a ceramic
30
+ * artist's portfolio, and Willow Wick Candles, a candle store), each photo
31
+ * traced through the library's cached answers:
30
32
  *
31
- * 1. **Search.** Plain words: the kind of business (the head of the site's
32
- * business type, "yoga studio" from "a family-owned yoga studio in
33
- * Austin") for the hero; the picture's own subject (the content words of
34
- * the alt text the model wrote, else its section's name) with the business
35
- * for the rest, and an about picture asks for people. The orientation and
36
- * a minimum size come from the slot's frame. The provider caches every
37
- * search for a day and keeps its own rate limit.
38
- * 2. **Pick.** Among the first hits not already on the page, by the job's
39
- * seed and the slot, so two sites built from the same words rarely open
40
- * with the same photo while one job always picks the same.
41
- * 3. **Keep.** A photo the site's library already holds (by its source key)
42
- * is reused; otherwise its bytes are downloaded from the library and
43
- * stored in the site's own media library as the member who started the
44
- * job, credited to its photographer. The page names the site's asset,
45
- * never the library's address.
33
+ * - The site's words were read as "portfolio" and "online" — the kind of
34
+ * SITE, not the craft — so every search said "serving bowl portfolio" and
35
+ * the hero asked for "portfolio": a wallet, fashion models, and a blank
36
+ * paper mockup that read as an empty panel beside the brushes.
37
+ * - A hit naming the object anywhere won: a robin tagged "food bowl" filled
38
+ * "Speckled serving bowl", a lilac branch tagged "bud, vase" filled "Tall
39
+ * bud vase", letterpress type tagged "set" filled "Nesting bowl set" (its
40
+ * head noun was read as "set"), and an antique tea set tagged "brown
41
+ * candle" filled "Candle Gift Set".
42
+ * - The page's 20 seconds ran out after four photos (each copy took ~5s, one
43
+ * after another), and the slots left took the starter photos: the laptop
44
+ * desk with pink roses under "Candle Wick Trimmer" and a work card, and the
45
+ * windowsill plants under another.
46
46
  *
47
- * A slot that finds nothing, any error, a refusal from the library (its
48
- * storage band, say) or the time running out leaves the slot to a starter
49
- * photo. Nothing here throws, and none of it is an AI model call: photos cost
50
- * no AI credits.
51
- */ /** Wall clock the whole page's photos may take, inside the step's own signal. */ export const AI_LAYOUT_STOCK_PHOTOS_BUDGET_MS = 20000;
52
- /** Searches one page may ask, cached ones included. */ export const AI_LAYOUT_STOCK_SEARCHES_PER_PAGE = 8;
53
- /** The hits a pick is made among. */ export const AI_LAYOUT_STOCK_PICK_WINDOW = 8;
47
+ * So, for each slot:
48
+ *
49
+ * 1. **The site's terms** ({@link aiStockSiteTerms}): the business is the
50
+ * first clause of its type that says more than what kind of site it is
51
+ * ("ceramic artist" from "a portfolio for a ceramic artist who makes
52
+ * stoneware bowls"), its craft one word ("ceramic"), and its DOMAIN the
53
+ * words a photo of its world names: the craft, the business type's own
54
+ * nouns ("stoneware", "soy") and, for common crafts, their kin ("pottery",
55
+ * "clay", "wax"). Its broad searches ask for the world itself ("handmade
56
+ * ceramics", "pottery").
57
+ * 2. **Search.** A picture of a thing asks for its subject — the noun phrase
58
+ * of the caption, filler dropped — qualified by the craft where it does
59
+ * not already name the domain ("ceramic serving bowl"), then the craft and
60
+ * the object alone ("ceramic bowl"), then the broad searches. A hero asks
61
+ * for the business, then broad; an about picture for the business's
62
+ * people, then broad. The orientation and a minimum size come from the
63
+ * slot's frame.
64
+ * 3. **Accept, or reject.** A hit tagged as a mockup, a blank or a template
65
+ * never fills anything. A picture of a thing takes only a hit naming its
66
+ * OBJECT (the head noun, past "set", "gift", "kit": "bowl" in "nesting
67
+ * bowl set", "candle" in "candle gift set") AND either the site's domain
68
+ * or the subject's own two-word phrase ("bud vase"). A broad, hero or
69
+ * about search takes only a hit naming the domain. The accepted are
70
+ * ranked: the object in the hit's first tags (what the photo is OF) over
71
+ * a mention in its last, the subject's other words, the domain. Among
72
+ * equals the job's seed picks, so two sites differ and one job repeats.
73
+ * 4. **Never twice.** A photo is placed once per job: not twice on a page,
74
+ * and not on a page when another page (or another pass, in this process)
75
+ * of the same job already placed it. The caller hands in what the job's
76
+ * other pages hold (`avoid`); a pass repeated over its own page keeps its
77
+ * own.
78
+ * 5. **Keep, all at once.** The choosing is done first, slot by slot; the
79
+ * copies then run side by side, a few at a time, so a page of eight
80
+ * pictures takes about as long as two. A photo the site's library already
81
+ * holds (by its source key) is reused; otherwise its bytes are copied
82
+ * into the site's own library as the member who started the job, credited
83
+ * to its photographer. A copy that fails tries the slot's next accepted
84
+ * hit.
85
+ *
86
+ * A slot nothing acceptable answered — the library down, refusing, or out
87
+ * of time — is left `null` for the starter photos. A named object is never
88
+ * given one while the library answers anything of the site's world: the
89
+ * broad searches come first. Nothing here throws, and none of it is an AI
90
+ * model call: photos cost no AI credits.
91
+ */ /** Wall clock the whole page's photos may take, inside the step's own signal. */ export const AI_LAYOUT_STOCK_PHOTOS_BUDGET_MS = 30000;
92
+ /** Searches one page may send to the provider; a repeated query is answered from the page's own memory. */ export const AI_LAYOUT_STOCK_SEARCHES_PER_PAGE = 18;
93
+ /** The hits a pick is made among. */ export const AI_LAYOUT_STOCK_PICK_WINDOW = 40;
94
+ /** The accepted hits a slot keeps in hand, in case a copy fails. */ const AI_LAYOUT_STOCK_TRIES_PER_SLOT = 3;
95
+ /** Copies made side by side. */ const AI_LAYOUT_STOCK_COPIES_AT_ONCE = 4;
54
96
  /** The largest photo copied: inside one direct upload. */ export const AI_LAYOUT_STOCK_MAX_BYTES = 3 * 1024 * 1024;
55
- /** Words that carry no subject. */ const STOPWORDS = new Set(('a an the and or of in on at to for with from by into onto over under near our your their my his her its ' + 'this that these those is are be being was were who whom which where while as about than then very just ' + 'small local best new top quality premium family-owned family owned independent professional ' + 'business company shop services service site website page photo picture image showing shows show ' + 'someone people person one two three some many several').split(' '));
97
+ /**
98
+ * The unit job input naming the screen drafts the job's other pages were
99
+ * written to, whose photos a page does not place again (AGL-3660).
100
+ */ export const AI_STOCK_PHOTO_PAGES_INPUT = 'stockPhotoPages';
101
+ const words = (list)=>new Set(list.trim().split(/\s+/));
102
+ /** Words that carry no subject. */ const STOPWORDS = words('a an the and or of in on at to for with from by into onto over under near our your their my his her its ' + 'this that these those is are be being was were who whom which where while as about than then very just ' + 'small local best new top quality premium family-owned family owned independent professional ' + 'business company services service page photo picture image showing shows show ' + 'someone people person one two three some many several');
103
+ /**
104
+ * Words that describe a picture's look, not what it shows, dropped from a
105
+ * subject. A word that is as often a noun ("light", "set") is not one.
106
+ */ 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 ' + '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');
107
+ /** 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');
108
+ /** 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');
109
+ /** 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');
110
+ /** Verbs a business type uses to say what it does; what follows them is its domain, never its business. */ const DOING = /(?<![\w-])(?:makes?|making|made|sells?|selling|sold|offers?|offering|creates?|creating|specializ(?:es|ing)|specialis(?:es|ing)|provides?|providing|featuring|showcasing)(?![\w-])/g;
111
+ /** Where a business type's own clause ends. */ const BUSINESS_BREAK = /[,;:.()]|\s[-–—]\s|\b(?:in|for|near|serving|based|located|that|which|who|with|from|since|and|&)\b/;
112
+ /** A clause after one of these says where or for whom, never what: "in Austin", "for busy parents". */ const PLACE_OR_AUDIENCE = /^(?:in|near|serving|based|located|across|around|throughout|for)$/;
113
+ /**
114
+ * Words naming a set or a wrapper of a thing, never the thing: the OBJECT
115
+ * of "nesting bowl set" is the bowl, of "candle gift set" the candle.
116
+ */ const WRAPPERS = words('set sets kit kits gift gifts bundle bundles pack packs box boxes trio duo pair collection sampler assortment ' + 'series range line group lot bunch');
117
+ /**
118
+ * A hit tagged with any of these is never placed: a mockup's blank panel is
119
+ * an empty frame on the page (the Juniper Clay hero), a template or a
120
+ * graphic is not a photo of anything.
121
+ */ const REJECTED_TAGS = words('mockup mock-up mock blank template templates clipart clip-art illustration vector logo icon ' + 'ai-generated generated render rendering 3d');
122
+ /**
123
+ * The kin of a few common crafts: the words a photo of that world is
124
+ * tagged with, and the broad searches that find it. A craft not listed
125
+ * still has its own words; this only widens what counts as its world.
126
+ */ const CRAFT_KIN = [
127
+ {
128
+ match: [
129
+ 'ceramic',
130
+ 'ceramics',
131
+ 'pottery',
132
+ 'potter',
133
+ 'stoneware',
134
+ 'porcelain',
135
+ 'earthenware',
136
+ 'clay'
137
+ ],
138
+ kin: [
139
+ 'ceramic',
140
+ 'ceramics',
141
+ 'pottery',
142
+ 'potter',
143
+ 'stoneware',
144
+ 'porcelain',
145
+ 'earthenware',
146
+ 'clay',
147
+ 'glaze',
148
+ 'kiln'
149
+ ],
150
+ broad: [
151
+ 'handmade ceramics',
152
+ 'pottery'
153
+ ]
154
+ },
155
+ {
156
+ match: [
157
+ 'candle',
158
+ 'candlemaker',
159
+ 'candlemaking'
160
+ ],
161
+ kin: [
162
+ 'candle',
163
+ 'candlelight',
164
+ 'wax',
165
+ 'soy',
166
+ 'wick',
167
+ 'scented',
168
+ 'votive',
169
+ 'candlestick'
170
+ ],
171
+ broad: [
172
+ 'handmade candles',
173
+ 'scented candles'
174
+ ]
175
+ },
176
+ {
177
+ match: [
178
+ 'jewelry',
179
+ 'jewellery',
180
+ 'jeweler',
181
+ 'jeweller',
182
+ 'silversmith',
183
+ 'goldsmith'
184
+ ],
185
+ kin: [
186
+ 'jewelry',
187
+ 'jewellery',
188
+ 'ring',
189
+ 'necklace',
190
+ 'earring',
191
+ 'bracelet',
192
+ 'silver',
193
+ 'gold',
194
+ 'gemstone'
195
+ ],
196
+ broad: [
197
+ 'handmade jewelry',
198
+ 'jewelry'
199
+ ]
200
+ },
201
+ {
202
+ match: [
203
+ 'soap',
204
+ 'soapmaker',
205
+ 'skincare'
206
+ ],
207
+ kin: [
208
+ 'soap',
209
+ 'skincare',
210
+ 'lotion',
211
+ 'bath',
212
+ 'spa',
213
+ 'natural',
214
+ 'cosmetic'
215
+ ],
216
+ broad: [
217
+ 'handmade soap',
218
+ 'natural skincare'
219
+ ]
220
+ },
221
+ {
222
+ match: [
223
+ 'wood',
224
+ 'woodwork',
225
+ 'woodworking',
226
+ 'woodworker',
227
+ 'carpenter',
228
+ 'carpentry',
229
+ 'furniture',
230
+ 'joinery'
231
+ ],
232
+ kin: [
233
+ 'wood',
234
+ 'wooden',
235
+ 'woodwork',
236
+ 'timber',
237
+ 'furniture',
238
+ 'carpentry',
239
+ 'oak',
240
+ 'walnut'
241
+ ],
242
+ broad: [
243
+ 'woodworking',
244
+ 'wooden furniture'
245
+ ]
246
+ },
247
+ {
248
+ match: [
249
+ 'bakery',
250
+ 'baker',
251
+ 'bread',
252
+ 'pastry',
253
+ 'cake',
254
+ 'patisserie'
255
+ ],
256
+ kin: [
257
+ 'bakery',
258
+ 'bread',
259
+ 'pastry',
260
+ 'cake',
261
+ 'baking',
262
+ 'baked',
263
+ 'dough',
264
+ 'croissant'
265
+ ],
266
+ broad: [
267
+ 'bakery',
268
+ 'fresh bread'
269
+ ]
270
+ },
271
+ {
272
+ match: [
273
+ 'coffee',
274
+ 'cafe',
275
+ 'café',
276
+ 'roaster',
277
+ 'espresso'
278
+ ],
279
+ kin: [
280
+ 'coffee',
281
+ 'cafe',
282
+ 'espresso',
283
+ 'latte',
284
+ 'cappuccino',
285
+ 'barista',
286
+ 'beans'
287
+ ],
288
+ broad: [
289
+ 'coffee shop',
290
+ 'coffee'
291
+ ]
292
+ },
293
+ {
294
+ match: [
295
+ 'florist',
296
+ 'flower',
297
+ 'floral'
298
+ ],
299
+ kin: [
300
+ 'flower',
301
+ 'floral',
302
+ 'bouquet',
303
+ 'florist',
304
+ 'bloom',
305
+ 'blossom'
306
+ ],
307
+ broad: [
308
+ 'florist',
309
+ 'flower bouquet'
310
+ ]
311
+ },
312
+ {
313
+ match: [
314
+ 'knit',
315
+ 'knitting',
316
+ 'yarn',
317
+ 'weaving',
318
+ 'weaver',
319
+ 'textile',
320
+ 'textiles',
321
+ 'quilt',
322
+ 'quilting',
323
+ 'sewing'
324
+ ],
325
+ kin: [
326
+ 'knit',
327
+ 'knitting',
328
+ 'yarn',
329
+ 'wool',
330
+ 'weaving',
331
+ 'textile',
332
+ 'fabric',
333
+ 'quilt',
334
+ 'sewing'
335
+ ],
336
+ broad: [
337
+ 'handmade textiles',
338
+ 'yarn'
339
+ ]
340
+ },
341
+ {
342
+ match: [
343
+ 'leather',
344
+ 'leatherwork',
345
+ 'leathercraft'
346
+ ],
347
+ kin: [
348
+ 'leather',
349
+ 'wallet',
350
+ 'bag',
351
+ 'belt',
352
+ 'stitching'
353
+ ],
354
+ broad: [
355
+ 'leather craft',
356
+ 'leather goods'
357
+ ]
358
+ }
359
+ ];
56
360
  /** Lowercase words, letters only, without stopwords. */ function contentWords(text) {
57
361
  return text.toLowerCase().replace(/https?:\/\/\S+|\S+@\S+|\+?\d[\d\s().-]{5,}\d/g, ' ').replace(/[^a-zÀ-ɏ\s-]/g, ' ').split(/\s+/).map((word)=>word.replace(/^-+|-+$/g, '')).filter((word)=>word.length > 1 && !STOPWORDS.has(word));
58
362
  }
363
+ /**
364
+ * A word's singular, for matching only: "bowls" and "bowl", "vases" and
365
+ * "vase", "berries" and "berry", "dishes" and "dish" meet. Both sides of a
366
+ * comparison are reduced the same way, so an odd stem still matches itself.
367
+ */ export function aiStockStem(word) {
368
+ const lower = word.toLowerCase();
369
+ if (lower.length > 4 && lower.endsWith('ies')) return `${lower.slice(0, -3)}y`;
370
+ if (lower.length > 4 && /(?:ches|shes|sses|xes|zes)$/.test(lower)) return lower.slice(0, -2);
371
+ if (lower.length > 3 && lower.endsWith('s') && !/(?:ss|us|is)$/.test(lower)) return lower.slice(0, -1);
372
+ return lower;
373
+ }
374
+ /** A text's words as stems, in order; a hyphenated word counts as its parts. */ function stemList(text) {
375
+ return text.toLowerCase().split(/[^a-zÀ-ɏ]+/).filter((word)=>word.length > 1).map(aiStockStem);
376
+ }
377
+ /** Where a noun phrase ends: punctuation, a preposition, a conjunction. */ const CLAUSE_BREAK = /[,;:.()]|\s[-–—]\s|\b(?:with|on|in|at|against|beside|next|near|under|over|by|from|for|during|while|atop|inside|across|beneath|through|into|onto|amid|among|and|of|that|which|who)\b|&/;
378
+ /** A business type's clauses, each with the word that opened it. */ function businessClauses(businessType) {
379
+ const text = businessType.toLowerCase().replace(DOING, ' | ');
380
+ const clauses = [];
381
+ const breaker = new RegExp(`${BUSINESS_BREAK.source}|\\|`, 'g');
382
+ let at = 0;
383
+ let opener = '';
384
+ for (const match of text.matchAll(breaker)){
385
+ var _match_index;
386
+ clauses.push({
387
+ opener,
388
+ words: contentWords(text.slice(at, match.index))
389
+ });
390
+ opener = match[0].trim();
391
+ at = ((_match_index = match.index) != null ? _match_index : 0) + match[0].length;
392
+ }
393
+ clauses.push({
394
+ opener,
395
+ words: contentWords(text.slice(at))
396
+ });
397
+ return clauses;
398
+ }
399
+ /** A clause's words that name the business: no site kind, no filler. */ const businessOf = (clause)=>clause.filter((word)=>!SITE_KINDS.has(word) && !FILLER.has(word));
59
400
  /**
60
401
  * The kind of business in a few words: the first clause of the business
61
- * type, before a place or an audience, its last three content words (the
62
- * head of an English noun phrase is at its end).
402
+ * type that says more than what kind of site it is, before a place, an
403
+ * audience or what it makes, filler dropped, its last three words (the head
404
+ * of an English noun phrase is at its end). "ceramic artist" from "a
405
+ * portfolio for a ceramic artist who makes stoneware bowls"; "candle shop"
406
+ * from "a small-batch candle shop selling hand-poured soy candles online".
63
407
  */ export function aiStockBusinessWords(businessType) {
64
- const clause = businessType.toLowerCase().split(/[,;:.()]|\s[-–—]\s|\b(?:in|for|near|serving|based|located|that|which|who|with|from|since|and|&)\b/)[0];
65
- const words = contentWords(clause != null ? clause : '');
66
- return words.slice(-3).join(' ');
408
+ for (const { words: clause } of businessClauses(businessType)){
409
+ const named = businessOf(clause);
410
+ if (named.length) return named.slice(-3).join(' ');
411
+ }
412
+ return '';
413
+ }
414
+ /**
415
+ * The business's craft in one word: the last of its words that is not the
416
+ * place it is done in nor the person doing it, singular — "ceramic" from
417
+ * "ceramics studio" or "ceramic artist", "yoga" from "yoga studio". Empty
418
+ * when the business is only a place ("a shop").
419
+ */ export function aiStockCraftWords(business) {
420
+ const named = business.split(/\s+/).filter((word)=>word && !VENUES.has(word) && !ROLES.has(word) && !FILLER.has(word) && !SITE_KINDS.has(word));
421
+ const craft = named[named.length - 1];
422
+ return craft ? aiStockStem(craft) : '';
67
423
  }
68
- /** A picture's subject in a few words: its alt text's first content words, else its section's name. */ export function aiStockSubjectWords(alt, sectionName) {
69
- const words = contentWords(alt);
70
- if (words.length) return words.slice(0, 3).join(' ');
71
- return contentWords(sectionName).slice(0, 2).join(' ');
424
+ /** The site's terms, read from its business type. */ export function aiStockSiteTerms(businessType) {
425
+ const business = aiStockBusinessWords(businessType);
426
+ const craft = aiStockCraftWords(business);
427
+ const domain = new Set();
428
+ /** The things it makes, by their head nouns. */ const made = new Set();
429
+ let named = false;
430
+ let making = false;
431
+ for (const { opener, words: clause } of businessClauses(businessType)){
432
+ if (opener === '|') making = true;
433
+ // Where it is, and who it is for, are not its world — once something has named it.
434
+ if (named && PLACE_OR_AUDIENCE.test(opener)) {
435
+ making = false;
436
+ continue;
437
+ }
438
+ const kept = businessOf(clause).filter((word)=>!VENUES.has(word) && !ROLES.has(word));
439
+ if (kept.length) named = true;
440
+ // What it makes names its world by what the things are made of, never
441
+ // the things: "stoneware" of "stoneware bowls", "soy" of "soy candles".
442
+ // A bowl is not a ceramic world; a robin's food bowl is not pottery.
443
+ const world = making ? kept.slice(0, -1) : kept;
444
+ for (const word of world)for (const stem of stemList(word))domain.add(stem);
445
+ if (making) for (const word of kept.slice(-1))for (const stem of stemList(word))made.add(stem);
446
+ }
447
+ if (craft) domain.add(craft);
448
+ // A business named only by what it sells ("a shop selling pottery") is of that world.
449
+ if (!domain.size) for (const stem of made)domain.add(stem);
450
+ const broad = [];
451
+ for (const group of CRAFT_KIN){
452
+ if (!group.match.some((word)=>domain.has(aiStockStem(word)) || made.has(aiStockStem(word)))) continue;
453
+ for (const word of group.kin)domain.add(aiStockStem(word));
454
+ broad.push(...group.broad);
455
+ }
456
+ if (!broad.length) broad.push(business, craft);
457
+ return {
458
+ business,
459
+ craft,
460
+ domain: [
461
+ ...domain
462
+ ].filter((stem)=>stem.length > 1),
463
+ broad: [
464
+ ...new Set(broad.map((query)=>query.trim()).filter(Boolean))
465
+ ]
466
+ };
467
+ }
468
+ /** A phrase's content words without its filler; all filler, and nothing is left. */ function subjectOf(list) {
469
+ return list.filter((word)=>!FILLER.has(word));
470
+ }
471
+ /**
472
+ * A picture's subject in a few words (AGL-3660): the noun phrase its alt
473
+ * text opens with — its first clause holding more than filler, cut where a
474
+ * preposition starts the setting, filler dropped, its last three words (the
475
+ * head noun is the last) — else its section's name.
476
+ */ export function aiStockSubjectWords(alt, sectionName) {
477
+ for (const clause of alt.toLowerCase().split(CLAUSE_BREAK)){
478
+ const found = subjectOf(contentWords(clause != null ? clause : ''));
479
+ if (found.length) return found.slice(-3).join(' ');
480
+ }
481
+ return subjectOf(contentWords(sectionName)).slice(0, 2).join(' ');
482
+ }
483
+ /**
484
+ * The thing a subject shows, one singular word: its last word that is not
485
+ * a set or a wrapper of it — "bowl" from "nesting bowl set", "candle" from
486
+ * "candle gift set", "vase" from "bud vase". Its last word when every word
487
+ * is one ("gift set").
488
+ */ export function aiStockObjectWord(subject) {
489
+ var _ref, _reverse_find;
490
+ const list = subject.split(/\s+/).filter(Boolean);
491
+ const object = (_ref = (_reverse_find = [
492
+ ...list
493
+ ].reverse().find((word)=>!WRAPPERS.has(word))) != null ? _reverse_find : list[list.length - 1]) != null ? _ref : '';
494
+ return object ? aiStockStem(object) : '';
72
495
  }
73
496
  const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUERY_MAX_CHARS).trim();
74
497
  /** The frame's orientation: a wide frame wants a landscape photo, a tall one a portrait. */ export function aiStockOrientation(aspect) {
@@ -76,7 +499,56 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
76
499
  if (aspect < 0.87) return 'vertical';
77
500
  return 'any';
78
501
  }
79
- /** The searches a slot tries, in order, most specific first, each distinct. */ export function aiStockSearchesFor(slot, words) {
502
+ /**
503
+ * A product's searches (AGL-3676), most specific first: each of its subjects
504
+ * — its name's noun phrase, then its photo's — with the shop's category
505
+ * where the phrase does not already say it ("candle gift box"), then the
506
+ * category alone, a plain photo of what the shop sells. Every one must find a
507
+ * hit naming the category, so no product is ever filled with a lifestyle shot
508
+ * of something else: the beta.237 Willow Wick start put a laptop and roses
509
+ * under "Candle Wick Trimmer" and an antique tea set under "Candle Gift Set".
510
+ */ export function aiStockProductSearches(slot, craft) {
511
+ var _ref;
512
+ var _slot_product;
513
+ const orientation = aiStockOrientation(slot.aspect);
514
+ const size = orientation === 'vertical' ? {
515
+ minHeight: 900
516
+ } : {
517
+ minWidth: 900
518
+ };
519
+ const craftStems = stemList(craft);
520
+ const queries = [];
521
+ for (const raw of (_ref = (_slot_product = slot.product) == null ? void 0 : _slot_product.subjects) != null ? _ref : []){
522
+ const subject = aiStockSubjectWords(raw, '');
523
+ if (!subject) continue;
524
+ const says = craftStems.every((stem)=>stemList(subject).includes(stem));
525
+ queries.push({
526
+ query: craft && !says ? `${craft} ${subject}` : subject,
527
+ subject
528
+ });
529
+ }
530
+ if (craft) queries.push({
531
+ query: craft,
532
+ subject: craft
533
+ });
534
+ const seen = new Set();
535
+ const searches = [];
536
+ for (const entry of queries){
537
+ const query = clip(entry.query);
538
+ if (!query || seen.has(query)) continue;
539
+ seen.add(query);
540
+ searches.push(_extends({
541
+ query,
542
+ orientation
543
+ }, size, {
544
+ subject: entry.subject
545
+ }, craft ? {
546
+ requires: craft
547
+ } : {}));
548
+ }
549
+ return searches;
550
+ }
551
+ /** The searches a slot tries, in order, most specific first, each distinct. */ export function aiStockSearchesFor(slot, terms, subject) {
80
552
  const orientation = aiStockOrientation(slot.aspect);
81
553
  const size = slot.role === 'hero' ? {
82
554
  minWidth: 1600
@@ -85,37 +557,200 @@ const clip = (query)=>query.replace(/\s+/g, ' ').trim().slice(0, STOCK_PHOTO_QUE
85
557
  } : {
86
558
  minWidth: 900
87
559
  };
560
+ const domain = new Set(terms.domain);
561
+ const object = aiStockObjectWord(subject);
562
+ const subjectStems = stemList(subject);
563
+ // The craft qualifies a subject that does not already name the site's world.
564
+ const namesWorld = subjectStems.some((stem)=>domain.has(stem));
565
+ const qualify = (phrase)=>terms.craft && !namesWorld && !stemList(phrase).includes(terms.craft) ? `${terms.craft} ${phrase}` : phrase;
566
+ const broad = terms.broad.map((query)=>({
567
+ query,
568
+ broad: true
569
+ }));
88
570
  const queries = slot.role === 'hero' ? [
89
- words.business,
90
- `${words.business} ${words.subject}`
571
+ {
572
+ query: terms.business,
573
+ broad: true
574
+ },
575
+ ...broad
91
576
  ] : slot.role === 'about' ? [
92
- `${words.business} ${words.subject}`,
93
- words.business
94
- ] : [
95
- `${words.business} ${words.subject}`,
96
- words.subject,
97
- words.business
98
- ];
577
+ {
578
+ query: `${terms.business} ${subject}`,
579
+ broad: true,
580
+ people: true
581
+ },
582
+ {
583
+ query: terms.business,
584
+ broad: true,
585
+ people: true
586
+ },
587
+ ...broad
588
+ ] : subject ? [
589
+ {
590
+ query: qualify(subject)
591
+ },
592
+ {
593
+ query: qualify(object)
594
+ },
595
+ ...broad
596
+ ] : broad;
99
597
  const seen = new Set();
100
598
  const searches = [];
101
599
  for (const raw of queries){
102
- const query = clip(raw);
103
- if (!query || seen.has(query)) continue;
104
- seen.add(query);
600
+ const query = clip(raw.query);
601
+ const key = `${query}|${raw.people === true}`;
602
+ if (!query || seen.has(key)) continue;
603
+ seen.add(key);
105
604
  searches.push(_extends({
106
605
  query,
107
606
  orientation
108
- }, size, slot.role === 'about' ? {
607
+ }, size, raw.people ? {
109
608
  people: true
609
+ } : {}, raw.broad ? {
610
+ broad: true
110
611
  } : {}));
111
612
  }
112
613
  return searches;
113
614
  }
114
- /** The pick among a search's hits: not on the page yet, among the first few, by the seed. */ export function aiStockPick(photos, used, seed) {
115
- var _fresh_;
116
- const fresh = photos.filter((photo)=>!used.has(stockPhotoSourceKey(photo))).slice(0, AI_LAYOUT_STOCK_PICK_WINDOW);
117
- if (!fresh.length) return null;
118
- return (_fresh_ = fresh[aiLayoutSeedNumber(seed) % fresh.length]) != null ? _fresh_ : null;
615
+ /** The words of a photo's page address at its library, its id dropped: "robin-bird-songbird-garden-winter". */ function pageSlug(pageUrl) {
616
+ try {
617
+ var _ref;
618
+ const last = decodeURIComponent(new URL(pageUrl).pathname).split('/').filter(Boolean).pop();
619
+ return (_ref = last == null ? void 0 : last.replace(/-?\d+$/, '')) != null ? _ref : '';
620
+ } catch (unused) {
621
+ return '';
622
+ }
623
+ }
624
+ /** A hit's words: the first few (what it is OF), every one, and each tag's phrase. */ function hitWords(photo) {
625
+ var _photo_tags, _photo_alt, _photo_alt1;
626
+ const slug = pageSlug(photo.pageUrl);
627
+ const tags = ((_photo_tags = photo.tags) != null ? _photo_tags : []).map((tag)=>tag.toLowerCase().trim()).filter(Boolean);
628
+ const lead = new Set(stemList([
629
+ ...tags.slice(0, 3),
630
+ slug
631
+ ].join(' ')));
632
+ const all = new Set(stemList([
633
+ ...tags,
634
+ (_photo_alt = photo.alt) != null ? _photo_alt : '',
635
+ slug
636
+ ].join(' ')));
637
+ const phrases = [
638
+ ...tags.map((tag)=>stemList(tag).join(' ')),
639
+ stemList((_photo_alt1 = photo.alt) != null ? _photo_alt1 : '').join(' '),
640
+ stemList(slug).join(' ')
641
+ ];
642
+ const rejected = tags.some((tag)=>REJECTED_TAGS.has(tag) || tag.split(/\s+/).some((word)=>REJECTED_TAGS.has(word)));
643
+ return {
644
+ lead,
645
+ all,
646
+ phrases,
647
+ rejected
648
+ };
649
+ }
650
+ /**
651
+ * How well a hit shows what a slot needs (AGL-3660), or 0 when it must not
652
+ * fill it: a mockup or a blank never; a picture of a thing only when the hit
653
+ * names its object AND either the site's world or the subject's own
654
+ * two-word phrase ("bud vase", "wick trimmer"); anything else only when it
655
+ * names the site's world (when the site names one). Among the accepted, the
656
+ * object among the hit's first tags counts 4 (a mention among its last, 2),
657
+ * the subject's two-word phrase 3, each of its other words 1, and the
658
+ * site's world 1, plus a quarter when it is among the first tags.
659
+ */ export function aiStockRelevance(photo, judgement) {
660
+ var _judgement_requires, _judgement_subject, _judgement_domain;
661
+ const hit = hitWords(photo);
662
+ if (hit.rejected) return 0;
663
+ const required = stemList((_judgement_requires = judgement.requires) != null ? _judgement_requires : '');
664
+ if (required.some((stem)=>!hit.all.has(stem))) return 0;
665
+ const subject = stemList((_judgement_subject = judgement.subject) != null ? _judgement_subject : '');
666
+ const object = judgement.subject ? aiStockObjectWord(judgement.subject) : '';
667
+ const domain = ((_judgement_domain = judgement.domain) != null ? _judgement_domain : []).filter((stem)=>stem !== object);
668
+ // A hit naming the word it must name is of the site's world: a product's
669
+ // category is that world even where it is the product's own object.
670
+ const world = required.length > 0 || domain.some((stem)=>hit.all.has(stem));
671
+ const worldLead = required.length > 0 && required.every((stem)=>hit.lead.has(stem)) || domain.some((stem)=>hit.lead.has(stem));
672
+ const pair = subject.length >= 2 ? subject.slice(-2).join(' ') : '';
673
+ const phrased = Boolean(pair) && hit.phrases.some((phrase)=>` ${phrase} `.includes(` ${pair} `));
674
+ let score = 0;
675
+ if (judgement.strict && object) {
676
+ if (!hit.all.has(object)) return 0;
677
+ if (domain.length && !world && !phrased) return 0;
678
+ score += hit.lead.has(object) ? 4 : 2;
679
+ } else if (domain.length) {
680
+ if (!world) return 0;
681
+ }
682
+ if (phrased) score += 3;
683
+ for (const word of new Set(subject))if (word !== object && hit.all.has(word)) score += 1;
684
+ if (world) score += worldLead ? 1.25 : 1;
685
+ return Math.max(score, 0.5);
686
+ }
687
+ /**
688
+ * A search's acceptable hits in the order they are tried (AGL-3660): those
689
+ * not placed yet, among the first few the library returned, those it must
690
+ * not take dropped ({@link aiStockRelevance}), best first; the seed turns
691
+ * the hits that score alike, so two jobs differ and one job repeats itself.
692
+ */ export function aiStockRank(photos, used, seed, judgement = {}) {
693
+ const scored = photos.filter((photo)=>!used.has(stockPhotoSourceKey(photo))).slice(0, AI_LAYOUT_STOCK_PICK_WINDOW).map((photo)=>({
694
+ photo,
695
+ score: aiStockRelevance(photo, judgement)
696
+ })).filter((entry)=>entry.score > 0);
697
+ if (!scored.length) return [];
698
+ const turn = aiLayoutSeedNumber(seed);
699
+ const ranked = [];
700
+ for (const score of [
701
+ ...new Set(scored.map((entry)=>entry.score))
702
+ ].sort((a, b)=>b - a)){
703
+ const tier = scored.filter((entry)=>entry.score === score);
704
+ const start = turn % tier.length;
705
+ for (const entry of [
706
+ ...tier.slice(start),
707
+ ...tier.slice(0, start)
708
+ ])ranked.push(entry.photo);
709
+ }
710
+ return ranked;
711
+ }
712
+ /** The pick among a search's hits: the first {@link aiStockRank} would try, or `null`. */ export function aiStockPick(photos, used, seed, judgement = {}) {
713
+ var _aiStockRank_;
714
+ return (_aiStockRank_ = aiStockRank(photos, used, seed, judgement)[0]) != null ? _aiStockRank_ : null;
715
+ }
716
+ /**
717
+ * The library photos a stored page already shows: every `image` whose
718
+ * source is the site's media reference. Starter photos are the platform's,
719
+ * not the job's, and are not listed.
720
+ */ export function aiStockPlacedSrcs(nodes) {
721
+ const map = nodes != null ? nodes : {};
722
+ const out = [];
723
+ for (const node of Object.values(map)){
724
+ var _node_props;
725
+ const src = (node == null ? void 0 : node.componentId) === 'image' ? (_node_props = node.props) == null ? void 0 : _node_props['src'] : null;
726
+ if (typeof src === 'string' && src.startsWith('media:')) out.push(src);
727
+ }
728
+ return out;
729
+ }
730
+ /**
731
+ * What this process has placed for each job, by source key and asset src,
732
+ * with the scope (the page's seed) that placed it (AGL-3660). It spans the
733
+ * passes of one job that run here, so a page, the products or a post built
734
+ * later does not place what an earlier one did, while a pass repeated over
735
+ * its own page keeps its own photos. Bounded; the oldest job goes first.
736
+ */ const JOB_PHOTOS = new Map();
737
+ const JOB_PHOTOS_MAX = 200;
738
+ function jobPhotos(key) {
739
+ if (!key) return null;
740
+ let placed = JOB_PHOTOS.get(key);
741
+ if (!placed) {
742
+ placed = new Map();
743
+ JOB_PHOTOS.set(key, placed);
744
+ while(JOB_PHOTOS.size > JOB_PHOTOS_MAX){
745
+ const oldest = JOB_PHOTOS.keys().next().value;
746
+ if (oldest === undefined) break;
747
+ JOB_PHOTOS.delete(oldest);
748
+ }
749
+ }
750
+ return placed;
751
+ }
752
+ /** Forgets every job's placed photos; for specs. */ export function aiStockForgetJobPhotos() {
753
+ JOB_PHOTOS.clear();
119
754
  }
120
755
  const EXTENSIONS = {
121
756
  'image/jpeg': 'jpg',
@@ -130,117 +765,197 @@ const EXTENSIONS = {
130
765
  const provider = ((_deps_provider = deps.provider) != null ? _deps_provider : stockPhotoProvider)();
131
766
  const ingest = ((_deps_ingest = deps.ingest) != null ? _deps_ingest : pluginMediaIngest)();
132
767
  if (!provider || !ingest || !input.hostId || !input.uid) return null;
133
- const business = aiStockBusinessWords(input.business);
768
+ const terms = aiStockSiteTerms(input.business);
134
769
  return async (slots)=>{
135
- var _deps_budgetMs;
770
+ var _deps_budgetMs, _input_avoid;
136
771
  const timeout = AbortSignal.timeout((_deps_budgetMs = deps.budgetMs) != null ? _deps_budgetMs : AI_LAYOUT_STOCK_PHOTOS_BUDGET_MS);
137
772
  const signal = input.signal ? AbortSignal.any([
138
773
  input.signal,
139
774
  timeout
140
775
  ]) : timeout;
141
- const used = new Set();
776
+ const scope = input.seed;
777
+ const job = jobPhotos(input.jobId ? `${input.hostId}:${input.jobId}` : null);
778
+ const avoid = new Set((_input_avoid = input.avoid) != null ? _input_avoid : []);
779
+ /** Placed elsewhere in the job: by another page in this process, or one the caller read. */ const elsewhere = (key)=>avoid.has(key) || (job == null ? void 0 : job.has(key)) === true && job.get(key) !== scope;
780
+ /** Source keys this page has claimed for a slot, or turned down. */ const claimed = new Set();
781
+ /** Asset srcs this page has placed. */ const placedSrcs = new Set();
782
+ const answers = new Map();
142
783
  let searches = 0;
143
784
  let storing = true;
144
- const photos = [];
785
+ const remember = (sourceKey, src)=>{
786
+ placedSrcs.add(src);
787
+ job == null ? void 0 : job.set(sourceKey, scope);
788
+ job == null ? void 0 : job.set(src, scope);
789
+ };
790
+ /** A search's hits; the same words twice are answered from memory, and `null` once the page's searches are spent. */ const search = (wanted)=>{
791
+ var _input_searches;
792
+ const request = _extends({}, wanted);
793
+ delete request.broad;
794
+ delete request.subject;
795
+ delete request.requires;
796
+ const key = JSON.stringify([
797
+ request.query,
798
+ request.orientation,
799
+ request.people === true
800
+ ]);
801
+ const known = answers.get(key);
802
+ if (known) return known;
803
+ if (searches >= ((_input_searches = input.searches) != null ? _input_searches : AI_LAYOUT_STOCK_SEARCHES_PER_PAGE)) return Promise.resolve(null);
804
+ searches += 1;
805
+ const asked = provider.search(request, {
806
+ signal
807
+ }).then((found)=>{
808
+ var _ref;
809
+ return (_ref = found == null ? void 0 : found.photos) != null ? _ref : [];
810
+ }).catch(()=>[]);
811
+ answers.set(key, asked);
812
+ return asked;
813
+ };
814
+ /** 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)=>{
815
+ const sourceKey = stockPhotoSourceKey(photo);
816
+ try {
817
+ var _EXTENSIONS_bytes_contentType;
818
+ const kept = await ingest.findStockPhoto({
819
+ hostId: input.hostId,
820
+ sourceKey
821
+ });
822
+ if (kept) {
823
+ var _kept_width, _kept_height;
824
+ // Held because a slot of this page or another page of the job shows it.
825
+ if (placedSrcs.has(kept.src) || elsewhere(kept.src)) return null;
826
+ remember(sourceKey, kept.src);
827
+ return {
828
+ src: kept.src,
829
+ width: (_kept_width = kept.width) != null ? _kept_width : photo.width,
830
+ height: (_kept_height = kept.height) != null ? _kept_height : photo.height
831
+ };
832
+ }
833
+ // A refusal to store (the storage band, a lockdown) holds for the
834
+ // whole page, so the rest of it reuses what it can and copies nothing.
835
+ if (!storing || signal.aborted) return null;
836
+ const bytes = await provider.download(photo, {
837
+ maxBytes: AI_LAYOUT_STOCK_MAX_BYTES,
838
+ signal
839
+ });
840
+ if (!bytes || signal.aborted || !storing) return null;
841
+ const credit = provider.credit(photo);
842
+ const stored = await ingest.ingest({
843
+ hostId: input.hostId,
844
+ uid: input.uid,
845
+ fileName: `${photo.provider}-${photo.id}.${(_EXTENSIONS_bytes_contentType = EXTENSIONS[bytes.contentType]) != null ? _EXTENSIONS_bytes_contentType : 'jpg'}`,
846
+ contentType: bytes.contentType,
847
+ bytes: bytes.bytes,
848
+ alt: slot.alt,
849
+ description: credit.text,
850
+ stockPhoto: _extends({
851
+ key: sourceKey,
852
+ provider: photo.provider,
853
+ providerLabel: credit.providerLabel,
854
+ id: photo.id,
855
+ pageUrl: photo.pageUrl,
856
+ photographer: photo.photographer
857
+ }, photo.photographerUrl ? {
858
+ photographerUrl: photo.photographerUrl
859
+ } : {}, {
860
+ license: credit.license,
861
+ licenseUrl: credit.licenseUrl,
862
+ attributionRequired: credit.attributionRequired,
863
+ query
864
+ })
865
+ });
866
+ if (stored.ok) {
867
+ var _stored_width, _stored_height;
868
+ remember(sourceKey, stored.src);
869
+ return {
870
+ src: stored.src,
871
+ width: (_stored_width = stored.width) != null ? _stored_width : photo.width,
872
+ height: (_stored_height = stored.height) != null ? _stored_height : photo.height
873
+ };
874
+ }
875
+ storing = false;
876
+ const refusal = stored;
877
+ console.warn('ai layout pictures: the media library refused a stock photo', {
878
+ status: refusal.status,
879
+ reason: refusal.reason
880
+ });
881
+ } catch (error) {
882
+ console.warn('ai layout pictures: a stock photo could not be kept', {
883
+ error: String(error)
884
+ });
885
+ }
886
+ return null;
887
+ };
888
+ // 1. Choose, slot by slot, so the page's choices never collide: each
889
+ // slot claims its best accepted hit before the next slot looks.
890
+ const choices = [];
145
891
  for (const [index, slot] of slots.entries()){
146
892
  var _input_sectionNames_slot_sectionIndex;
147
893
  if (signal.aborted) {
148
- photos.push(null);
894
+ choices.push(null);
149
895
  continue;
150
896
  }
151
897
  const subject = aiStockSubjectWords(slot.alt, (_input_sectionNames_slot_sectionIndex = input.sectionNames[slot.sectionIndex]) != null ? _input_sectionNames_slot_sectionIndex : '');
152
- let placed = null;
153
- for (const request of aiStockSearchesFor(slot, {
154
- business,
155
- subject
156
- })){
157
- if (placed || signal.aborted || searches >= AI_LAYOUT_STOCK_SEARCHES_PER_PAGE) break;
158
- searches += 1;
159
- let found;
160
- try {
161
- var _ref;
162
- var _this;
163
- found = (_ref = (_this = await provider.search(request, {
164
- signal
165
- })) == null ? void 0 : _this.photos) != null ? _ref : [];
166
- } catch (unused) {
167
- found = [];
168
- }
169
- const photo = aiStockPick(found, used, `${input.seed}:${index}:${request.query}`);
170
- if (!photo) continue;
171
- const sourceKey = stockPhotoSourceKey(photo);
172
- used.add(sourceKey);
173
- try {
174
- var _EXTENSIONS_bytes_contentType;
175
- const kept = await ingest.findStockPhoto({
176
- hostId: input.hostId,
177
- sourceKey
178
- });
179
- if (kept) {
180
- var _kept_width, _kept_height;
181
- placed = {
182
- src: kept.src,
183
- width: (_kept_width = kept.width) != null ? _kept_width : photo.width,
184
- height: (_kept_height = kept.height) != null ? _kept_height : photo.height
185
- };
186
- break;
187
- }
188
- // A refusal to store (the storage band, a lockdown) holds for the
189
- // whole page, so the rest of it reuses what it can and copies nothing.
190
- if (!storing) continue;
191
- const bytes = await provider.download(photo, {
192
- maxBytes: AI_LAYOUT_STOCK_MAX_BYTES,
193
- signal
194
- });
195
- if (!bytes || signal.aborted) continue;
196
- const credit = provider.credit(photo);
197
- const stored = await ingest.ingest({
198
- hostId: input.hostId,
199
- uid: input.uid,
200
- fileName: `${photo.provider}-${photo.id}.${(_EXTENSIONS_bytes_contentType = EXTENSIONS[bytes.contentType]) != null ? _EXTENSIONS_bytes_contentType : 'jpg'}`,
201
- contentType: bytes.contentType,
202
- bytes: bytes.bytes,
203
- alt: slot.alt,
204
- description: credit.text,
205
- stockPhoto: _extends({
206
- key: sourceKey,
207
- provider: photo.provider,
208
- providerLabel: credit.providerLabel,
209
- id: photo.id,
210
- pageUrl: photo.pageUrl,
211
- photographer: photo.photographer
212
- }, photo.photographerUrl ? {
213
- photographerUrl: photo.photographerUrl
214
- } : {}, {
215
- license: credit.license,
216
- licenseUrl: credit.licenseUrl,
217
- attributionRequired: credit.attributionRequired,
218
- query: request.query
219
- })
220
- });
221
- if (stored.ok) {
222
- var _stored_width, _stored_height;
223
- placed = {
224
- src: stored.src,
225
- width: (_stored_width = stored.width) != null ? _stored_width : photo.width,
226
- height: (_stored_height = stored.height) != null ? _stored_height : photo.height
227
- };
228
- } else {
229
- storing = false;
230
- const refusal = stored;
231
- console.warn('ai layout pictures: the media library refused a stock photo', {
232
- status: refusal.status,
233
- reason: refusal.reason
234
- });
235
- }
236
- } catch (error) {
237
- console.warn('ai layout pictures: a stock photo could not be kept', {
238
- error: String(error)
239
- });
898
+ let choice = null;
899
+ const requests = slot.product ? aiStockProductSearches(slot, terms.craft) : aiStockSearchesFor(slot, terms, subject);
900
+ for (const request of requests){
901
+ var _request_subject;
902
+ if (choice || signal.aborted) break;
903
+ const found = await search(request);
904
+ if (found === null) break;
905
+ const blocked = new Set(claimed);
906
+ for (const hit of found){
907
+ const key = stockPhotoSourceKey(hit);
908
+ if (elsewhere(key)) blocked.add(key);
240
909
  }
910
+ const ranked = aiStockRank(found, blocked, `${input.seed}:${index}:${request.query}`, _extends({
911
+ subject: (_request_subject = request.subject) != null ? _request_subject : request.broad ? '' : subject,
912
+ domain: terms.domain,
913
+ strict: slot.role === 'gallery' && !request.broad || !!request.requires
914
+ }, request.requires ? {
915
+ requires: request.requires
916
+ } : {}));
917
+ if (!ranked.length) continue;
918
+ const first = ranked[0];
919
+ claimed.add(stockPhotoSourceKey(first));
920
+ choice = {
921
+ candidates: ranked.slice(0, AI_LAYOUT_STOCK_TRIES_PER_SLOT),
922
+ query: request.query
923
+ };
241
924
  }
242
- photos.push(placed);
925
+ choices.push(choice);
243
926
  }
927
+ // 2. Keep, a few at a time; a copy that fails tries the slot's next
928
+ // accepted hit no other slot has claimed.
929
+ const photos = slots.map(()=>null);
930
+ const fill = async (index)=>{
931
+ const choice = choices[index];
932
+ const slot = slots[index];
933
+ if (!choice || !slot) return;
934
+ for (const [rank, candidate] of choice.candidates.entries()){
935
+ if (signal.aborted) return;
936
+ const key = stockPhotoSourceKey(candidate);
937
+ if (rank > 0) {
938
+ if (claimed.has(key)) continue;
939
+ claimed.add(key);
940
+ }
941
+ const placed = await keep(candidate, slot, choice.query);
942
+ if (placed) {
943
+ photos[index] = placed;
944
+ return;
945
+ }
946
+ }
947
+ };
948
+ let next = 0;
949
+ const worker = async ()=>{
950
+ while(next < slots.length){
951
+ const index = next;
952
+ next += 1;
953
+ await fill(index);
954
+ }
955
+ };
956
+ await Promise.all(Array.from({
957
+ length: Math.min(AI_LAYOUT_STOCK_COPIES_AT_ONCE, slots.length)
958
+ }, worker));
244
959
  return photos;
245
960
  };
246
961
  }