@vectoriox/iox-ui 5.83.1 → 5.84.0
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.
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +630 -20
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs +39 -1
- package/fesm2022/vectoriox-iox-ui-harness-core.mjs.map +1 -1
- package/generation-core/index.d.ts +401 -8
- package/harness-core/index.d.ts +81 -3
- package/package.json +1 -1
- package/src/generation-core/ai-run.ts +10 -2
- package/src/generation-core/art-direction.ts +122 -0
- package/src/generation-core/audit-site.ts +14 -0
- package/src/generation-core/pictures.ts +464 -0
- package/src/generation-core/plan-wire.ts +2 -0
- package/src/generation-core/public-api.ts +2 -0
- package/src/generation-core/site-build-run.ts +3 -0
- package/src/generation-core/site-plan.model.ts +4 -0
- package/src/generation-core/site-spec.generated.ts +118 -1
- package/src/generation-core/stock-photos.ts +40 -10
- package/src/generation-core/validate-site-plan.ts +14 -0
- package/src/harness-core/business-types.ts +3 -1
- package/src/harness-core/generation-view.ts +50 -1
- package/src/harness-core/harness-spec.generated.ts +30 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { compositeOver, parseCssColour, relativeLuminance, Rgb, wcagContrastRatio } from '@vectoriox/iox-ui/design-core';
|
|
2
|
+
|
|
3
|
+
import { AiStep, AiTokenPrice, creditsOfUsd, estimateStepCredits } from './ai-run';
|
|
4
|
+
import { ArtDirection, artDirectionPreamble } from './art-direction';
|
|
5
|
+
import { ComposedSite } from './compose-site';
|
|
6
|
+
import { jsonBoolean, jsonClosedObject, JsonSchema, jsonText } from './json-schema';
|
|
7
|
+
import { patternById } from './patterns';
|
|
8
|
+
import { SerializedNode, walkSerialized } from './serialized-node';
|
|
9
|
+
import { SitePlan } from './site-plan.model';
|
|
10
|
+
import { STOCK_INTENT_ORDER, StockIntent } from './stock-match';
|
|
11
|
+
import { OwnPhotoPlacement, planOwnPhotos, stockItemSlots, StockSlotKind, stockSlots, StockTarget } from './stock-photos';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pictures MADE for a site (premium-components M7, AI plan I3, premium sites PS4) — pure and deterministic: which picture
|
|
15
|
+
* slots of a composed site get a made picture, the prompt each is asked with (the site's ONE art direction first,
|
|
16
|
+
* verbatim, then what the slot is about, its frame and — where words sit over it — the part of the frame left calm for
|
|
17
|
+
* them), what the whole set costs before anything is asked, how a vision model's look at each picture is judged, and how
|
|
18
|
+
* dark the scrim under the words must be for the picture actually made. iox-cms makes, reviews and stores the pictures;
|
|
19
|
+
* the wizard shows the count and the cost from the same functions.
|
|
20
|
+
*
|
|
21
|
+
* The order of who fills a slot never changes: the OWNER's photos first (`planOwnPhotos`), then — only when the owner
|
|
22
|
+
* opted in — a made picture where one may stand, then stock where the business type allows it, else the empty box.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Why a slot never gets a made picture — said to the owner, never silently skipped. */
|
|
26
|
+
export enum PictureRefusal {
|
|
27
|
+
/** The business sells its own work (a photographer, a maker): a made picture would pass as it. */
|
|
28
|
+
OwnWork = 'own-work',
|
|
29
|
+
/** A product, a dish, a piece the business offers: a made one would show a thing it does not sell. */
|
|
30
|
+
Product = 'product',
|
|
31
|
+
/** Where to find the business: a made picture would fake a real place. */
|
|
32
|
+
RealPlace = 'real-place',
|
|
33
|
+
/** More slots than one site makes pictures for (`MAX_SITE_PICTURES`): the rest keep stock or the empty box. */
|
|
34
|
+
Limit = 'limit',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The section families whose picture is a real place (the contact / visit section). */
|
|
38
|
+
export const PICTURE_REAL_PLACE_FAMILIES: ReadonlySet<string> = new Set(['contact']);
|
|
39
|
+
|
|
40
|
+
/** The most pictures one site is made — a ceiling on what one build may spend on pictures. */
|
|
41
|
+
export const MAX_SITE_PICTURES = 12;
|
|
42
|
+
|
|
43
|
+
/** Which edge of the frame the words sit at. */
|
|
44
|
+
export enum WordsEdge {
|
|
45
|
+
Top = 'top',
|
|
46
|
+
Bottom = 'bottom',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The part of the frame words sit over — left calm in the picture, measured once it is made. */
|
|
50
|
+
export interface WordsArea {
|
|
51
|
+
edge: `${WordsEdge}`;
|
|
52
|
+
/** The share of the frame's height (0–1). */
|
|
53
|
+
share: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A scrim with no height of its own (it wraps its words) covers about this much of the frame. */
|
|
57
|
+
export const WORDS_AREA_DEFAULT_SHARE = 0.45;
|
|
58
|
+
const MEDIA_SCRIM_STYLE = 'media-scrim';
|
|
59
|
+
const ABSOLUTE = 'absolute';
|
|
60
|
+
const ZERO = /^0(px|%)?$/;
|
|
61
|
+
const PERCENT = /^(\d+(?:\.\d+)?)%$/;
|
|
62
|
+
|
|
63
|
+
/** The frames an image model is asked for (`image_config.aspect_ratio`), as width / height. */
|
|
64
|
+
export const PICTURE_ASPECTS: Readonly<Record<string, number>> = {
|
|
65
|
+
'1:1': 1, '2:3': 2 / 3, '3:2': 3 / 2, '3:4': 3 / 4, '4:3': 4 / 3, '4:5': 4 / 5, '5:4': 5 / 4, '9:16': 9 / 16, '16:9': 16 / 9, '21:9': 21 / 9,
|
|
66
|
+
};
|
|
67
|
+
/** A slot that fills its box (a Scene's picture, a full-bleed band) is made wide: a phone crops its middle. */
|
|
68
|
+
export const FILL_ASPECT = '16:9';
|
|
69
|
+
|
|
70
|
+
/** One picture a site may be made: the slot it fills and everything its prompt and review read. */
|
|
71
|
+
export interface PictureSlot {
|
|
72
|
+
/** Stable across runs: `page:<owner>:<nodeId>` (a section's picture) or `item:<type>:<index>:<field>`. */
|
|
73
|
+
key: string;
|
|
74
|
+
target: StockTarget;
|
|
75
|
+
intent: `${StockIntent}`;
|
|
76
|
+
/** The frame asked of the model (`PICTURE_ASPECTS`). */
|
|
77
|
+
ratio: string;
|
|
78
|
+
/** The section family it is in (`hero`, `story`…) — empty for an item. */
|
|
79
|
+
family: string;
|
|
80
|
+
/** What the section says, in the plan's own words (its slots) — what the picture is about. */
|
|
81
|
+
text: string;
|
|
82
|
+
/** Where the words sit over it, when they do. */
|
|
83
|
+
wordsArea?: WordsArea;
|
|
84
|
+
/** The node holding those words (a `media-scrim` box) — its scrim is chosen for the picture made. */
|
|
85
|
+
scrimNodeId?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What a site's pictures will be. */
|
|
89
|
+
export interface PicturePlan {
|
|
90
|
+
/** The owner's photos and where each goes — always first. */
|
|
91
|
+
own: OwnPhotoPlacement[];
|
|
92
|
+
/** The slots a picture is made for, the hero first. */
|
|
93
|
+
make: PictureSlot[];
|
|
94
|
+
/** The slots no picture is made for, and why (stock or the empty box fills them). */
|
|
95
|
+
refused: Array<{ key: string; reason: PictureRefusal }>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface PicturePlanOptions {
|
|
99
|
+
/** The owner's own photos (urls), in order. */
|
|
100
|
+
ownPhotos?: ReadonlyArray<string>;
|
|
101
|
+
/** The business type's rule (harness-core `BusinessType.stock`): where stock may not stand in for the work, no made
|
|
102
|
+
* picture may either. */
|
|
103
|
+
stockAllowed: boolean;
|
|
104
|
+
max?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The ONE list of picture slots a build fills — its composed sections' empty pictures, then its sample items'. */
|
|
108
|
+
export function pictureTargets(plan: SitePlan, composed: Pick<ComposedSite, 'pages' | 'placeholders' | 'items'>): StockTarget[] {
|
|
109
|
+
return [...stockSlots(plan, composed), ...stockItemSlots(plan, composed)];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A slot's stable key. */
|
|
113
|
+
export function pictureSlotKey(target: StockTarget): string {
|
|
114
|
+
return target.kind === StockSlotKind.Section ? `page:${target.owner}:${target.nodeId}` : `item:${target.contentType}:${target.index}:${target.field}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The nearest frame a model is asked for (by the ratio of ratios); a slot with no shape fills its box. */
|
|
118
|
+
export function pictureAspect(aspect: number | undefined): string {
|
|
119
|
+
if (!aspect || !Number.isFinite(aspect) || aspect <= 0) return FILL_ASPECT;
|
|
120
|
+
let best = FILL_ASPECT;
|
|
121
|
+
let distance = Infinity;
|
|
122
|
+
for (const [ratio, value] of Object.entries(PICTURE_ASPECTS)) {
|
|
123
|
+
const d = Math.abs(Math.log(value / aspect));
|
|
124
|
+
if (d < distance) { best = ratio; distance = d; }
|
|
125
|
+
}
|
|
126
|
+
return best;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface NodeAt { node: SerializedNode; parent?: SerializedNode; section: number; page: number }
|
|
130
|
+
|
|
131
|
+
function indexNodes(composed: Pick<ComposedSite, 'pages'>): Map<string, NodeAt> {
|
|
132
|
+
const at = new Map<string, NodeAt>();
|
|
133
|
+
composed.pages.forEach((page, p) => page.layout.forEach((root, s) => {
|
|
134
|
+
walkSerialized([root], (node, ancestors) => at.set(node.id, { node, parent: ancestors[ancestors.length - 1], section: s, page: p }));
|
|
135
|
+
}));
|
|
136
|
+
return at;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The words over a picture: a picture laid ABSOLUTE in its box whose sibling wears `media-scrim` (a Scene's stage, a
|
|
141
|
+
* full-bleed band) carries the words that box holds. The area is the scrim's: its height when it says one, else
|
|
142
|
+
* `WORDS_AREA_DEFAULT_SHARE`, at the edge it is pinned to.
|
|
143
|
+
*/
|
|
144
|
+
export function wordsOverPicture(node: SerializedNode, parent: SerializedNode | undefined): { area: WordsArea; scrimNodeId: string } | null {
|
|
145
|
+
if (!parent || node.styleProps['position'] !== ABSOLUTE) return null;
|
|
146
|
+
const scrim = parent.children?.find(c => c !== node && c.elementStyle === MEDIA_SCRIM_STYLE);
|
|
147
|
+
if (!scrim) return null;
|
|
148
|
+
const top = String(scrim.styleProps['top'] ?? '');
|
|
149
|
+
const bottom = String(scrim.styleProps['bottom'] ?? '');
|
|
150
|
+
const edge = ZERO.test(top) && !ZERO.test(bottom) ? WordsEdge.Top : WordsEdge.Bottom;
|
|
151
|
+
const height = PERCENT.exec(String(scrim.styleProps['height'] ?? ''));
|
|
152
|
+
const share = height ? Math.min(1, Number(height[1]) / 100) : WORDS_AREA_DEFAULT_SHARE;
|
|
153
|
+
return { area: { edge, share }, scrimNodeId: scrim.id };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SECTION_TEXT_MAX = 160;
|
|
157
|
+
|
|
158
|
+
/** What a section says (its slots, in the plan's words), cut to a prompt's length. */
|
|
159
|
+
function sectionText(plan: SitePlan, page: number, section: number): string {
|
|
160
|
+
const slots = plan.pages[page]?.sections[section]?.slots ?? {};
|
|
161
|
+
const text = Object.values(slots).filter((v): v is string => typeof v === 'string' && !!v.trim()).join(' — ').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
162
|
+
return text.length > SECTION_TEXT_MAX ? `${text.slice(0, SECTION_TEXT_MAX - 1).trimEnd()}…` : text;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Why `target` never gets a made picture, or null. */
|
|
166
|
+
export function pictureRefusalOf(target: StockTarget, stockAllowed: boolean): PictureRefusal | null {
|
|
167
|
+
if (!stockAllowed) return PictureRefusal.OwnWork;
|
|
168
|
+
if (target.kind === StockSlotKind.Item) return PictureRefusal.Product;
|
|
169
|
+
const family = patternById(target.pattern)?.family ?? '';
|
|
170
|
+
return PICTURE_REAL_PLACE_FAMILIES.has(family) ? PictureRefusal.RealPlace : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const intentRank = (intent: string) => STOCK_INTENT_ORDER.indexOf(intent as StockIntent);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Which slots get a made picture: the owner's photos take theirs first (the same `planOwnPhotos` the build places them
|
|
177
|
+
* with), then each slot left — the hero first, page order within — gets one unless it is refused (the work, a product,
|
|
178
|
+
* a real place) or the site already has `max`. Pure: the wizard's count, the generation's run and the build agree.
|
|
179
|
+
*/
|
|
180
|
+
export function planPictures(plan: SitePlan, composed: Pick<ComposedSite, 'pages' | 'placeholders' | 'items'>, opts: PicturePlanOptions): PicturePlan {
|
|
181
|
+
const { placements: own, rest } = planOwnPhotos(pictureTargets(plan, composed), opts.ownPhotos ?? []);
|
|
182
|
+
const nodes = indexNodes(composed);
|
|
183
|
+
const max = opts.max ?? MAX_SITE_PICTURES;
|
|
184
|
+
const ordered = rest.map((t, i) => ({ t, i })).sort((a, b) => intentRank(a.t.intent) - intentRank(b.t.intent) || a.i - b.i).map(x => x.t);
|
|
185
|
+
const make: PictureSlot[] = [];
|
|
186
|
+
const refused: PicturePlan['refused'] = [];
|
|
187
|
+
for (const target of ordered) {
|
|
188
|
+
const key = pictureSlotKey(target);
|
|
189
|
+
const reason = pictureRefusalOf(target, opts.stockAllowed) ?? (make.length >= max ? PictureRefusal.Limit : null);
|
|
190
|
+
if (reason) { refused.push({ key, reason }); continue; }
|
|
191
|
+
make.push(pictureSlotOf(plan, target, key, nodes));
|
|
192
|
+
}
|
|
193
|
+
return { own, make, refused };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function pictureSlotOf(plan: SitePlan, target: StockTarget, key: string, nodes: Map<string, NodeAt>): PictureSlot {
|
|
197
|
+
const base = { key, target, intent: target.intent, ratio: pictureAspect(target.aspect) };
|
|
198
|
+
if (target.kind !== StockSlotKind.Section) return { ...base, family: '', text: '' };
|
|
199
|
+
const at = nodes.get(target.nodeId);
|
|
200
|
+
const over = at ? wordsOverPicture(at.node, at.parent) : null;
|
|
201
|
+
return {
|
|
202
|
+
...base,
|
|
203
|
+
family: patternById(target.pattern)?.family ?? '',
|
|
204
|
+
text: at ? sectionText(plan, at.page, at.section) : '',
|
|
205
|
+
...(over ? { wordsArea: over.area, scrimNodeId: over.scrimNodeId } : {}),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── The prompt ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/** What each kind of slot asks to see (`business` = the kind of business, "a café or bakery"). */
|
|
212
|
+
const INTENT_SUBJECT: Readonly<Record<StockIntent, (business: string) => string>> = {
|
|
213
|
+
[StockIntent.Atmosphere]: b => `A wide, atmospheric picture that sets the mood of ${b}: depth, a sense of the place, nothing staged.`,
|
|
214
|
+
[StockIntent.Place]: b => `The kind of place ${b} is: the room or the setting at eye level, quiet and inviting.`,
|
|
215
|
+
[StockIntent.Detail]: b => `A close detail of the work of ${b}: its materials, its tools, a moment of making.`,
|
|
216
|
+
[StockIntent.Product]: b => `One thing ${b} offers, alone on a plain ground.`,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const EDGE_WORD: Readonly<Record<WordsEdge, string>> = { [WordsEdge.Top]: 'upper', [WordsEdge.Bottom]: 'lower' };
|
|
220
|
+
|
|
221
|
+
/** The empty part a slot's words sit over, said to the model: where, how much, and what it must look like. */
|
|
222
|
+
export function emptyAreaPhrase(area: WordsArea): string {
|
|
223
|
+
const share = Math.round(area.share * 100);
|
|
224
|
+
return `Leave the ${EDGE_WORD[area.edge as WordsEdge]} ${share}% of the frame calm and uncluttered — soft focus, even tone, nothing important there — because the site's words sit over it.`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The frame, said plainly. */
|
|
228
|
+
export function frameWords(ratio: string): string {
|
|
229
|
+
const value = PICTURE_ASPECTS[ratio] ?? 1;
|
|
230
|
+
const shape = value > 1.05 ? 'landscape' : value < 0.95 ? 'portrait' : 'square';
|
|
231
|
+
return `Frame: ${shape}, ${ratio}.`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface PicturePromptContext {
|
|
235
|
+
/** The kind of business, in words a prompt can carry ("a café or bakery") — never its name (it would be drawn). */
|
|
236
|
+
business: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The prompt a picture is asked with: the site's preamble VERBATIM, what the slot is about (its intent, then the
|
|
241
|
+
* section's own words), its frame, the empty part the words need — and nothing else. The same slot of the same plan
|
|
242
|
+
* is always asked the same words.
|
|
243
|
+
*/
|
|
244
|
+
export function picturePrompt(slot: PictureSlot, direction: ArtDirection, context: PicturePromptContext): string {
|
|
245
|
+
return [
|
|
246
|
+
artDirectionPreamble(direction),
|
|
247
|
+
INTENT_SUBJECT[slot.intent as StockIntent](context.business),
|
|
248
|
+
...(slot.text ? [`It goes beside these words of the site: “${slot.text}”.`] : []),
|
|
249
|
+
frameWords(slot.ratio),
|
|
250
|
+
...(slot.wordsArea ? [emptyAreaPhrase(slot.wordsArea)] : []),
|
|
251
|
+
].join('\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── The cost, shown before ──────────────────────────────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
/** One picture in this many may be made a second time (a review said no) — paid for in the estimate shown. */
|
|
257
|
+
export const PICTURE_REROLL_EVERY = 6;
|
|
258
|
+
|
|
259
|
+
/** How many second tries a set of `count` pictures has, shown and paid for up front. */
|
|
260
|
+
export function pictureRerolls(count: number): number {
|
|
261
|
+
return count > 0 ? Math.ceil(count / PICTURE_REROLL_EVERY) : 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface PicturesEstimate {
|
|
265
|
+
count: number;
|
|
266
|
+
/** Second tries included in the total. */
|
|
267
|
+
rerolls: number;
|
|
268
|
+
/** One picture, in credits. */
|
|
269
|
+
picture: number;
|
|
270
|
+
/** One review of a picture, in credits. */
|
|
271
|
+
review: number;
|
|
272
|
+
/** What the owner is shown and the most the run may spend: every picture and its review, plus the second tries. */
|
|
273
|
+
total: number;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The cost shown before the owner says yes (I-D4): each picture and its review at the vetted list's prices, plus one
|
|
278
|
+
* second try (a picture and its review) for every `PICTURE_REROLL_EVERY` pictures. The run never spends past `total` —
|
|
279
|
+
* a second try that would cross it is not made — so the shown cost is a ceiling met exactly when every try is used.
|
|
280
|
+
*/
|
|
281
|
+
export function estimatePictures(count: number, prices: { image: AiTokenPrice; review: AiTokenPrice }): PicturesEstimate {
|
|
282
|
+
const picture = estimateStepCredits(AiStep.MakePictures, prices.image);
|
|
283
|
+
const review = estimateStepCredits(AiStep.CheckPictures, prices.review);
|
|
284
|
+
const rerolls = pictureRerolls(count);
|
|
285
|
+
return { count, rerolls, picture, review, total: creditsOfUsd(((count + rerolls) * (picture + review)) / 100) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── The review ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/** The structured answer's name (`output.name`). */
|
|
291
|
+
export const PICTURE_REVIEW_FORMAT = 'picture_review';
|
|
292
|
+
|
|
293
|
+
/** What a vision model says of one picture against its slot. */
|
|
294
|
+
export interface PictureReview {
|
|
295
|
+
subject: boolean;
|
|
296
|
+
calmArea: boolean;
|
|
297
|
+
clean: boolean;
|
|
298
|
+
anatomy: boolean;
|
|
299
|
+
alt: string;
|
|
300
|
+
note: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The review's shape, each key in the words the model reads (every key required: no optional parameter). */
|
|
304
|
+
export const PICTURE_REVIEW_SHAPE: Readonly<Record<keyof PictureReview, string>> = {
|
|
305
|
+
subject: 'boolean — it shows what the slot asked for, in the art direction asked',
|
|
306
|
+
calmArea: 'boolean — the part of the frame named for words is calm enough to read words over (true when none was named)',
|
|
307
|
+
clean: 'boolean — no text, letters, numbers, logo, watermark, signage or frame anywhere',
|
|
308
|
+
anatomy: 'boolean — no distorted hands, faces, bodies or objects; nothing melted or duplicated',
|
|
309
|
+
alt: 'string — what the picture shows, in at most 12 plain words, in the language asked (the alt text)',
|
|
310
|
+
note: 'string — why, when any answer is false; else empty',
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const BOOLEAN_KEYS: ReadonlyArray<keyof PictureReview> = ['subject', 'calmArea', 'clean', 'anatomy'];
|
|
314
|
+
const TEXT_KEYS: ReadonlyArray<keyof PictureReview> = ['alt', 'note'];
|
|
315
|
+
|
|
316
|
+
/** The review's strict format: 6 properties, all required, closed — well inside every structured-output limit. */
|
|
317
|
+
export function pictureReviewJsonSchema(): JsonSchema {
|
|
318
|
+
const properties: Record<string, JsonSchema> = {};
|
|
319
|
+
for (const key of BOOLEAN_KEYS) properties[key] = { ...jsonBoolean(), description: PICTURE_REVIEW_SHAPE[key] };
|
|
320
|
+
for (const key of TEXT_KEYS) properties[key] = { ...jsonText(), description: PICTURE_REVIEW_SHAPE[key] };
|
|
321
|
+
return jsonClosedObject(properties, [...BOOLEAN_KEYS, ...TEXT_KEYS], 'A look at one picture made for a website, against what its slot asked for.');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** What is wrong with an answer as a review — the gate the call service holds it to. */
|
|
325
|
+
export function pictureReviewIssues(value: unknown): Array<{ code: string; path: string; message: string }> {
|
|
326
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return [{ code: 'shape', path: '', message: 'must be an object' }];
|
|
327
|
+
const v = value as Record<string, unknown>;
|
|
328
|
+
const issues: Array<{ code: string; path: string; message: string }> = [];
|
|
329
|
+
for (const key of BOOLEAN_KEYS) if (typeof v[key] !== 'boolean') issues.push({ code: 'shape', path: key, message: 'must be true or false' });
|
|
330
|
+
for (const key of TEXT_KEYS) if (typeof v[key] !== 'string') issues.push({ code: 'shape', path: key, message: 'must be text' });
|
|
331
|
+
for (const key of Object.keys(v)) if (!(key in PICTURE_REVIEW_SHAPE)) issues.push({ code: 'shape', path: key, message: 'is not a key of the review' });
|
|
332
|
+
return issues;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** A picture is used when it shows the subject, is clean and whole — and, where words sit over it, leaves them room. */
|
|
336
|
+
export function pictureReviewPasses(review: PictureReview, slot: Pick<PictureSlot, 'wordsArea'>): boolean {
|
|
337
|
+
return review.subject && review.clean && review.anatomy && (!slot.wordsArea || review.calmArea);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Words over the picture made ─────────────────────────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/** How strong the scrim under a picture's words is, weakest first. `Soft` is the element style's own gradient. */
|
|
343
|
+
export enum ScrimLevel {
|
|
344
|
+
Soft = 'soft',
|
|
345
|
+
Firm = 'firm',
|
|
346
|
+
Full = 'full',
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export const SCRIM_LEVELS: ReadonlyArray<ScrimLevel> = [ScrimLevel.Soft, ScrimLevel.Firm, ScrimLevel.Full];
|
|
350
|
+
|
|
351
|
+
/** What each level writes on the scrim node (`backgroundColor`, token references only) — `null`: the style's own. */
|
|
352
|
+
export const SCRIM_BACKGROUND: Readonly<Record<ScrimLevel, string | null>> = {
|
|
353
|
+
[ScrimLevel.Soft]: null,
|
|
354
|
+
[ScrimLevel.Firm]: 'linear-gradient(180deg, transparent 0%, var(--iox-media-scrim) 18%)',
|
|
355
|
+
[ScrimLevel.Full]: 'var(--iox-media-scrim)',
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/** The least share of the scrim's own strength under the first line of words, at each level (the gradient's ramp). */
|
|
359
|
+
export const SCRIM_COVER: Readonly<Record<ScrimLevel, number>> = {
|
|
360
|
+
[ScrimLevel.Soft]: 0.5,
|
|
361
|
+
[ScrimLevel.Firm]: 0.85,
|
|
362
|
+
[ScrimLevel.Full]: 1,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
/** WCAG AA for body text — the words over a picture include a lead and a button label, never only a headline. */
|
|
366
|
+
export const PICTURE_TEXT_CONTRAST = 4.5;
|
|
367
|
+
|
|
368
|
+
/** The light of a picture's words area: its darkest and lightest few percent (relative luminance, 0–1), and its mean. */
|
|
369
|
+
export interface AreaLuminance {
|
|
370
|
+
low: number;
|
|
371
|
+
high: number;
|
|
372
|
+
mean: number;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The share of pixels left out at each end, so a single sparkle does not decide the scrim. */
|
|
376
|
+
const AREA_PERCENTILE = 0.05;
|
|
377
|
+
/** The area is read a little past its edge: a Scene scales its picture in from the middle. */
|
|
378
|
+
const AREA_MARGIN = 0.1;
|
|
379
|
+
/** A phone shows the middle of a wide picture: read that column too. */
|
|
380
|
+
const PHONE_COLUMN: readonly [number, number] = [1 / 3, 2 / 3];
|
|
381
|
+
|
|
382
|
+
const srgbToLinear = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
|
383
|
+
const linearToSrgb = (v: number) => (v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055);
|
|
384
|
+
const BYTE = 255;
|
|
385
|
+
const RGBA = 4;
|
|
386
|
+
|
|
387
|
+
function windowLuminance(rgba: ArrayLike<number>, width: number, height: number, rows: readonly [number, number], columns: readonly [number, number]): AreaLuminance {
|
|
388
|
+
const values: number[] = [];
|
|
389
|
+
const [y0, y1] = rows.map(r => Math.round(r * height));
|
|
390
|
+
const [x0, x1] = columns.map(c => Math.round(c * width));
|
|
391
|
+
for (let y = Math.max(0, y0); y < Math.min(height, y1); y++) {
|
|
392
|
+
for (let x = Math.max(0, x0); x < Math.min(width, x1); x++) {
|
|
393
|
+
const i = (y * width + x) * RGBA;
|
|
394
|
+
values.push(0.2126 * srgbToLinear(rgba[i] / BYTE) + 0.7152 * srgbToLinear(rgba[i + 1] / BYTE) + 0.0722 * srgbToLinear(rgba[i + 2] / BYTE));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (!values.length) return { low: 0, high: 0, mean: 0 };
|
|
398
|
+
values.sort((a, b) => a - b);
|
|
399
|
+
const at = (q: number) => values[Math.min(values.length - 1, Math.max(0, Math.round(q * (values.length - 1))))];
|
|
400
|
+
return { low: at(AREA_PERCENTILE), high: at(1 - AREA_PERCENTILE), mean: values.reduce((a, b) => a + b, 0) / values.length };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The light of the part of a picture its words sit over (RGBA bytes, row by row) — read across the whole width (a laptop)
|
|
405
|
+
* and in the middle third (a phone's crop of a wide picture); the darker low and the lighter high of the two.
|
|
406
|
+
*/
|
|
407
|
+
export function areaLuminance(rgba: ArrayLike<number>, width: number, height: number, area: WordsArea): AreaLuminance {
|
|
408
|
+
const reach = Math.min(1, area.share + AREA_MARGIN);
|
|
409
|
+
const rows: readonly [number, number] = area.edge === WordsEdge.Top ? [0, reach] : [1 - reach, 1];
|
|
410
|
+
const laptop = windowLuminance(rgba, width, height, rows, [0, 1]);
|
|
411
|
+
const phone = windowLuminance(rgba, width, height, rows, PHONE_COLUMN);
|
|
412
|
+
return { low: Math.min(laptop.low, phone.low), high: Math.max(laptop.high, phone.high), mean: laptop.mean };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** A grey of this relative luminance. */
|
|
416
|
+
function greyOf(luminance: number): Rgb {
|
|
417
|
+
const v = linearToSrgb(Math.min(1, Math.max(0, luminance)));
|
|
418
|
+
return { r: v, g: v, b: v };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export interface ScrimChoice {
|
|
422
|
+
level: ScrimLevel;
|
|
423
|
+
/** The worst contrast the words have over the picture at that level. */
|
|
424
|
+
contrast: number;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The weakest scrim under which the words reach AA over the picture made: the scrim's own colour (`--iox-media-scrim`,
|
|
429
|
+
* as the look writes it) at its least strength under the words, over the area's darkest and lightest, against the ink
|
|
430
|
+
* (`--iox-media-scrim-ink`). `Full` when nothing weaker does — the contract already holds a full scrim over white to AA.
|
|
431
|
+
*/
|
|
432
|
+
export function chooseScrim(area: AreaLuminance, colours: { scrim: string; ink: string }, minContrast = PICTURE_TEXT_CONTRAST): ScrimChoice {
|
|
433
|
+
const scrim = parseCssColour(colours.scrim);
|
|
434
|
+
const ink = parseCssColour(colours.ink);
|
|
435
|
+
if (!scrim || !ink) return { level: ScrimLevel.Full, contrast: 0 };
|
|
436
|
+
const worstAt = (level: ScrimLevel) => Math.min(...[area.low, area.high].map(y => {
|
|
437
|
+
const under = compositeOver({ ...scrim, a: scrim.a * SCRIM_COVER[level] }, greyOf(y));
|
|
438
|
+
return wcagContrastRatio(ink, under);
|
|
439
|
+
}));
|
|
440
|
+
for (const level of SCRIM_LEVELS) {
|
|
441
|
+
const contrast = worstAt(level);
|
|
442
|
+
if (contrast >= minContrast) return { level, contrast };
|
|
443
|
+
}
|
|
444
|
+
return { level: ScrimLevel.Full, contrast: worstAt(ScrimLevel.Full) };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Set each chosen scrim on its node (`Soft` leaves the element style's own). Returns the nodes changed. */
|
|
448
|
+
export function applyPictureScrims(trees: ReadonlyArray<SerializedNode>, chosen: ReadonlyArray<{ nodeId: string; level: `${ScrimLevel}` }>): number {
|
|
449
|
+
const byId = new Map(chosen.map(c => [c.nodeId, SCRIM_BACKGROUND[c.level as ScrimLevel]]));
|
|
450
|
+
let changed = 0;
|
|
451
|
+
walkSerialized(trees, node => {
|
|
452
|
+
const background = byId.get(node.id);
|
|
453
|
+
if (!background) return;
|
|
454
|
+
node.styleProps['backgroundColor'] = background;
|
|
455
|
+
changed++;
|
|
456
|
+
});
|
|
457
|
+
return changed;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The relative luminance of a CSS colour (for a review sheet or a log), or null. */
|
|
461
|
+
export function luminanceOf(colour: string): number | null {
|
|
462
|
+
const c = parseCssColour(colour);
|
|
463
|
+
return c ? relativeLuminance(c) : null;
|
|
464
|
+
}
|
|
@@ -198,6 +198,8 @@ export const PLAN_WIRE_DERIVED: Readonly<Record<string, string>> = {
|
|
|
198
198
|
design: "the owner's pick (the context)",
|
|
199
199
|
'plan.film': "the owner's film, set by the build (SC6b)",
|
|
200
200
|
film: "the owner's film, set by the build (SC6b)",
|
|
201
|
+
'plan.artDirection': 'the generation, from the business type and the look (M7 — artDirectionFor)',
|
|
202
|
+
artDirection: 'the generation, from the business type and the look (M7 — artDirectionFor)',
|
|
201
203
|
};
|
|
202
204
|
|
|
203
205
|
const wireText = (spec: WireKey): string => {
|
|
@@ -129,6 +129,9 @@ export enum BuildNoticeCode {
|
|
|
129
129
|
/** The stock library was read but holds no photo for this kind of business (never built, or reviewed down to
|
|
130
130
|
* none): the picture slots keep the empty box, and the run says so (I2.1 — never silently). */
|
|
131
131
|
StockMissing = 'stock-missing',
|
|
132
|
+
/** M7: a picture made for the site was refused by its review twice (or its second try did not fit the cost shown):
|
|
133
|
+
* stock or the empty box stands in, and the run says so. */
|
|
134
|
+
PictureFallback = 'picture-fallback',
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
export interface BuildNotice {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FrameSequence } from '@vectoriox/iox-ui/media-core';
|
|
2
2
|
import type { AnimateId, DesignChoices } from '@vectoriox/iox-ui/design-core';
|
|
3
|
+
import type { ArtDirection } from './art-direction';
|
|
3
4
|
|
|
4
5
|
import { SPEC_SITE_ARCHETYPES, SPEC_SITE_FACTS, SPEC_SITE_LOCALES, SPEC_SITE_NEED_IDS, SPEC_SITE_REQUIRED_ROLES, SPEC_SITE_ROLES } from './site-spec.generated';
|
|
5
6
|
|
|
@@ -249,6 +250,9 @@ export interface SitePlan {
|
|
|
249
250
|
readonly globals: Readonly<Record<`${GlobalSlot}`, string>>;
|
|
250
251
|
/** SC6b: the owner's film as pictures, set by the build and placed by `placeFilm` — never a model's. */
|
|
251
252
|
readonly film?: PlanFilm;
|
|
253
|
+
/** M7: what every picture made for the site shares (a world and its five parts) — set by the generation from the
|
|
254
|
+
* business type and the look (`artDirectionFor`), never a model's. */
|
|
255
|
+
readonly artDirection?: ArtDirection;
|
|
252
256
|
}
|
|
253
257
|
|
|
254
258
|
/** SC6b: the owner's film — the numbered pictures the wizard's browser made of their video (media-core). */
|
|
@@ -13,11 +13,20 @@ export const SPEC_SITE_PLAN_SHAPE = {
|
|
|
13
13
|
"dataSources": "(cmsDataSource | relativeDataSource)[] — merged by alias into the org library",
|
|
14
14
|
"pages": "page[]",
|
|
15
15
|
"globals": "globals",
|
|
16
|
-
"film?": "film — the owner's film as pictures (SC6b), set by the build and placed by placeFilm; never a model's"
|
|
16
|
+
"film?": "film — the owner's film as pictures (SC6b), set by the build and placed by placeFilm; never a model's",
|
|
17
|
+
"artDirection?": "artDirection — what every picture made for the site shares (M7): set by the generation from the business type and the look (generation-core artDirectionFor); never a model's"
|
|
17
18
|
},
|
|
18
19
|
"film": {
|
|
19
20
|
"sequence": "object — a media-core FrameSequence: where the numbered pictures live, how many, their size"
|
|
20
21
|
},
|
|
22
|
+
"artDirection": {
|
|
23
|
+
"world": "string — a site.pictureWorlds id",
|
|
24
|
+
"medium": "string — the picture's medium and lens",
|
|
25
|
+
"light": "string — its light",
|
|
26
|
+
"grade": "string — its colour grade",
|
|
27
|
+
"texture": "string — its texture",
|
|
28
|
+
"negative": "string[] — what no picture of the site may show (site.pictureNever, then the world's own)"
|
|
29
|
+
},
|
|
21
30
|
"sourceTemplate": {
|
|
22
31
|
"kind": "'template'",
|
|
23
32
|
"templateId": "string",
|
|
@@ -5782,3 +5791,111 @@ export const SPEC_SITE_LIBRARY = [
|
|
|
5782
5791
|
"composed": true
|
|
5783
5792
|
}
|
|
5784
5793
|
] as const;
|
|
5794
|
+
|
|
5795
|
+
export const SPEC_SITE_PICTURE_WORLDS = {
|
|
5796
|
+
"documentary": {
|
|
5797
|
+
"label": "Documentary",
|
|
5798
|
+
"medium": "an unposed documentary photograph, 35mm lens, natural eye-level framing",
|
|
5799
|
+
"light": "available natural light, soft and directional",
|
|
5800
|
+
"grade": "true-to-life colour with gentle contrast",
|
|
5801
|
+
"texture": "fine natural grain and real, lived-in surfaces",
|
|
5802
|
+
"negative": [
|
|
5803
|
+
"staged poses",
|
|
5804
|
+
"a studio backdrop"
|
|
5805
|
+
],
|
|
5806
|
+
"dark": "low-key"
|
|
5807
|
+
},
|
|
5808
|
+
"high-key": {
|
|
5809
|
+
"label": "High-key",
|
|
5810
|
+
"medium": "a bright editorial photograph, 50mm lens, a clean composition with generous air",
|
|
5811
|
+
"light": "soft, even daylight from a large window; few, pale shadows",
|
|
5812
|
+
"grade": "a light, airy palette with lifted whites and low contrast",
|
|
5813
|
+
"texture": "smooth surfaces and crisp, gentle detail",
|
|
5814
|
+
"negative": [
|
|
5815
|
+
"harsh shadows",
|
|
5816
|
+
"dark corners"
|
|
5817
|
+
],
|
|
5818
|
+
"dark": "low-key"
|
|
5819
|
+
},
|
|
5820
|
+
"low-key": {
|
|
5821
|
+
"label": "Low-key",
|
|
5822
|
+
"medium": "a moody photograph, 50mm lens at a wide aperture, shallow depth of field",
|
|
5823
|
+
"light": "one warm, low light source; most of the frame falls into deep shadow",
|
|
5824
|
+
"grade": "rich blacks with warm, glowing highlights",
|
|
5825
|
+
"texture": "a soft haze in the air and a gentle film grain",
|
|
5826
|
+
"negative": [
|
|
5827
|
+
"flat lighting",
|
|
5828
|
+
"a bright background"
|
|
5829
|
+
],
|
|
5830
|
+
"dark": "low-key"
|
|
5831
|
+
},
|
|
5832
|
+
"hard-light": {
|
|
5833
|
+
"label": "Hard light",
|
|
5834
|
+
"medium": "a graphic photograph, 35mm lens, bold and simple shapes",
|
|
5835
|
+
"light": "hard, direct sunlight with crisp, long cast shadows",
|
|
5836
|
+
"grade": "saturated colour and punchy contrast",
|
|
5837
|
+
"texture": "sharp edges and clean planes",
|
|
5838
|
+
"negative": [
|
|
5839
|
+
"haze",
|
|
5840
|
+
"muddy colour"
|
|
5841
|
+
],
|
|
5842
|
+
"dark": "low-key"
|
|
5843
|
+
},
|
|
5844
|
+
"macro": {
|
|
5845
|
+
"label": "Macro",
|
|
5846
|
+
"medium": "a close-up macro photograph, 100mm macro lens, a very shallow plane of focus",
|
|
5847
|
+
"light": "soft side light raking across the surface",
|
|
5848
|
+
"grade": "natural colour with rich mid-tones",
|
|
5849
|
+
"texture": "tactile surface detail — grain, fibre, glaze, crumb",
|
|
5850
|
+
"negative": [
|
|
5851
|
+
"a wide view",
|
|
5852
|
+
"clutter in focus"
|
|
5853
|
+
],
|
|
5854
|
+
"dark": "low-key"
|
|
5855
|
+
},
|
|
5856
|
+
"architectural": {
|
|
5857
|
+
"label": "Architectural",
|
|
5858
|
+
"medium": "an architectural photograph, 24mm tilt-shift lens, straight verticals and a calm composition",
|
|
5859
|
+
"light": "calm, diffuse daylight",
|
|
5860
|
+
"grade": "neutral, precise colour with balanced contrast",
|
|
5861
|
+
"texture": "clean lines and crisp materials — stone, wood, glass, plaster",
|
|
5862
|
+
"negative": [
|
|
5863
|
+
"converging verticals",
|
|
5864
|
+
"wide-angle distortion"
|
|
5865
|
+
],
|
|
5866
|
+
"dark": "nocturne"
|
|
5867
|
+
},
|
|
5868
|
+
"nocturne": {
|
|
5869
|
+
"label": "Nocturne",
|
|
5870
|
+
"medium": "a night photograph, 35mm lens",
|
|
5871
|
+
"light": "practical lights only — lamps, candles, a warm window — against the dark",
|
|
5872
|
+
"grade": "deep blue-black shadows with warm pools of light",
|
|
5873
|
+
"texture": "soft bokeh and a light haze",
|
|
5874
|
+
"negative": [
|
|
5875
|
+
"daylight",
|
|
5876
|
+
"flash"
|
|
5877
|
+
],
|
|
5878
|
+
"dark": "nocturne"
|
|
5879
|
+
},
|
|
5880
|
+
"drawing": {
|
|
5881
|
+
"label": "Drawing",
|
|
5882
|
+
"medium": "a hand-made illustration, ink and watercolour wash on paper",
|
|
5883
|
+
"light": "flat and even, never photographic",
|
|
5884
|
+
"grade": "a limited palette of two or three colours",
|
|
5885
|
+
"texture": "visible paper grain and brush strokes",
|
|
5886
|
+
"negative": [
|
|
5887
|
+
"photographic realism",
|
|
5888
|
+
"a 3D rendering"
|
|
5889
|
+
],
|
|
5890
|
+
"dark": "drawing"
|
|
5891
|
+
}
|
|
5892
|
+
} as const;
|
|
5893
|
+
|
|
5894
|
+
export const SPEC_SITE_PICTURE_NEVER = [
|
|
5895
|
+
"text, letters or numbers",
|
|
5896
|
+
"logos, brand names or signage",
|
|
5897
|
+
"a watermark",
|
|
5898
|
+
"a recognisable face",
|
|
5899
|
+
"borders or frames",
|
|
5900
|
+
"distorted hands or objects"
|
|
5901
|
+
] as const;
|