| 1 | import * as cheerio from 'cheerio' |
| 2 | import type { Element } from 'domhandler' |
| 3 | import { getLayoutMasterTemplate, type LayoutMasterTemplate } from '@shared/layout-master' |
| 4 | import type { LayoutIntent } from '@shared/layout-intent' |
| 5 | |
| 6 | const MAX_SUBJECT_LENGTH = 1200 |
| 7 | const DEFAULT_IMAGE_AVOID = [ |
| 8 | 'invented, garbled, partial, or illegible text', |
| 9 | 'pseudo-text, random glyph-like marks, or lettering-like textures', |
| 10 | 'unrelated captions, labels, slogans, or signs', |
| 11 | 'logos', |
| 12 | 'watermarks', |
| 13 | 'UI screenshots' |
| 14 | ] |
| 15 | const IMAGE_DRAFT_ATTRIBUTE_PREFIX = 'data-img-' |
| 16 | const ALLOWED_DRAFT_ATTRIBUTES = new Set(['data-img-request', 'data-img-placement']) |
| 17 | const LEGACY_DRAFT_ATTRIBUTES = new Set([ |
| 18 | 'data-img-slot', |
| 19 | 'data-img-placeholder', |
| 20 | 'data-img-finalization', |
| 21 | 'data-img-intent' |
| 22 | ]) |
| 23 | |
| 24 | export type ParsedVisualIntent = { |
| 25 | slotId: string |
| 26 | layoutSlotId: string |
| 27 | role: 'hero-image' | 'product-visual' | 'spot-illustration' | 'data-visual' |
| 28 | layer: 'background' | 'visual' |
| 29 | /** Filled by the dedicated image director after the page parser resolves the slot. */ |
| 30 | subject: string |
| 31 | textZone?: string |
| 32 | subjectZone?: string |
| 33 | negativeSpace?: string |
| 34 | avoid: string[] |
| 35 | requestJson: string |
| 36 | } |
| 37 | |
| 38 | export type InvalidVisualIntent = { |
| 39 | slotId: string | null |
| 40 | layoutSlotId: string | null |
| 41 | role: string | null |
| 42 | requestJson: string |
| 43 | errors: string[] |
| 44 | } |
| 45 | |
| 46 | export type VisualIntentParseResult = { |
| 47 | status: 'none' | 'valid' | 'invalid' | 'forbidden' |
| 48 | intents: ParsedVisualIntent[] |
| 49 | invalidIntents: InvalidVisualIntent[] |
| 50 | errors: string[] |
| 51 | diagnostic?: 'layout-contract-incompatible' | 'layout-source-missing' |
| 52 | } |
| 53 | |
| 54 | type VisualIntentInput = { |
| 55 | html: string |
| 56 | visualEnabled: boolean |
| 57 | layoutIntent?: LayoutIntent | null |
| 58 | layoutId?: string | null |
| 59 | layoutContractVersion?: number | null |
| 60 | } |
| 61 | |
| 62 | export const hasImageIntentDrafts = (html: string): boolean => { |
| 63 | const $ = cheerio.load(html, { scriptingEnabled: false }) |
| 64 | return findDraftElements($).length > 0 |
| 65 | } |
| 66 | |
| 67 | const imageDraftAttributes = (node: Element): string[] => |
| 68 | Object.keys(node.attribs || {}).filter((name) => |
| 69 | name.toLowerCase().startsWith(IMAGE_DRAFT_ATTRIBUTE_PREFIX) |
| 70 | ) |
| 71 | |
| 72 | const findDraftElements = ($: cheerio.CheerioAPI): cheerio.Cheerio<Element> => |
| 73 | $('*').filter((_index, node) => imageDraftAttributes(node as Element).length > 0) as cheerio.Cheerio<Element> |
| 74 | |
| 75 | const cleanString = (value: unknown, maxLength?: number): string => { |
| 76 | const text = typeof value === 'string' ? value.trim() : '' |
| 77 | return maxLength === undefined ? text : text.slice(0, maxLength) |
| 78 | } |
| 79 | |
| 80 | const resolveTemplate = ( |
| 81 | input: VisualIntentInput |
| 82 | ): { |
| 83 | template: LayoutMasterTemplate | null |
| 84 | diagnostic?: VisualIntentParseResult['diagnostic'] |
| 85 | } => { |
| 86 | if (!input.layoutId || !input.layoutIntent) { |
| 87 | return { template: null, diagnostic: 'layout-source-missing' } |
| 88 | } |
| 89 | const template = getLayoutMasterTemplate(input.layoutId) |
| 90 | if ( |
| 91 | !template || |
| 92 | template.intent !== input.layoutIntent || |
| 93 | input.layoutContractVersion !== template.layoutContractVersion |
| 94 | ) { |
| 95 | return { template: null, diagnostic: 'layout-contract-incompatible' } |
| 96 | } |
| 97 | return { template } |
| 98 | } |
| 99 | |
| 100 | const isInsideContentLayer = ($: cheerio.CheerioAPI, node: Element): boolean => |
| 101 | $(node).closest('main[data-role="content"]').length > 0 |
| 102 | |
| 103 | const inlineStyle = ($: cheerio.CheerioAPI, node: Element, property: string): string => { |
| 104 | const style = $(node).attr('style') || '' |
| 105 | for (const declaration of style.split(';')) { |
| 106 | const [rawProperty, ...rawValue] = declaration.split(':') |
| 107 | if (rawProperty?.trim().toLowerCase() === property) return rawValue.join(':').trim() |
| 108 | } |
| 109 | return '' |
| 110 | } |
| 111 | |
| 112 | const hasZeroDimension = (value: string): boolean => |
| 113 | /^0(?:px|rem|%|vh|vw)?(?:\s*!important)?$/i.test(value) |
| 114 | |
| 115 | const hasZeroDimensionUtility = ($: cheerio.CheerioAPI, node: Element): boolean => |
| 116 | ($(node).attr('class') || '') |
| 117 | .trim() |
| 118 | .split(/\s+/) |
| 119 | .some((token) => |
| 120 | /^(?:(?:max-)?[wh]|size)-0$|^(?:(?:max-)?[wh]|size)-\[0(?:px|rem|%|vh|vw)?\]$/i.test( |
| 121 | token |
| 122 | ) |
| 123 | ) |
| 124 | |
| 125 | const hasTailwindImageGeometry = ($: cheerio.CheerioAPI, node: Element): boolean => |
| 126 | ($(node).attr('class') || '') |
| 127 | .trim() |
| 128 | .split(/\s+/) |
| 129 | .some((token) => { |
| 130 | if (/^aspect-(?:square|video)$/i.test(token)) return true |
| 131 | if (/^aspect-\[(?!0(?:[/.]|$))[^\]]+\]$/i.test(token)) return true |
| 132 | if (/^(?:min-)?h-(?!0(?:$|\[0(?:px|rem|%|vh|vw)?\]$)|auto$|fit$)/i.test(token)) { |
| 133 | return true |
| 134 | } |
| 135 | return /^size-(?!0(?:$|\[0(?:px|rem|%|vh|vw)?\]$)|auto$|fit$)/i.test(token) |
| 136 | }) |
| 137 | |
| 138 | const hasImageGeometry = ($: cheerio.CheerioAPI, node: Element): boolean => { |
| 139 | let current: cheerio.Cheerio<Element> = $(node) |
| 140 | while (current.length > 0) { |
| 141 | const element = current.get(0) |
| 142 | if (!element) break |
| 143 | const width = inlineStyle($, element, 'width') |
| 144 | const height = inlineStyle($, element, 'height') |
| 145 | const aspectRatio = inlineStyle($, element, 'aspect-ratio') |
| 146 | const minHeight = inlineStyle($, element, 'min-height') |
| 147 | const position = inlineStyle($, element, 'position').toLowerCase() |
| 148 | const top = inlineStyle($, element, 'top') |
| 149 | const right = inlineStyle($, element, 'right') |
| 150 | const bottom = inlineStyle($, element, 'bottom') |
| 151 | const left = inlineStyle($, element, 'left') |
| 152 | const className = ($(element).attr('class') || '').toLowerCase() |
| 153 | if (hasZeroDimension(width) || hasZeroDimension(height) || hasZeroDimensionUtility($, element)) { |
| 154 | return false |
| 155 | } |
| 156 | const hasDimension = Boolean(width) && Boolean(height) |
| 157 | const hasBoundedAbsolutePosition = |
| 158 | (position === 'absolute' || /(?:^|\s)absolute(?:\s|$)/.test(className)) && |
| 159 | (top || bottom) && |
| 160 | (left || right) |
| 161 | if ( |
| 162 | hasDimension || |
| 163 | aspectRatio || |
| 164 | minHeight || |
| 165 | hasTailwindImageGeometry($, element) || |
| 166 | /(?:^|[\s_-])(grid|flex)(?:$|[\s_-])/.test(className) || |
| 167 | hasBoundedAbsolutePosition |
| 168 | ) { |
| 169 | return true |
| 170 | } |
| 171 | current = current.parent() |
| 172 | } |
| 173 | return false |
| 174 | } |
| 175 | |
| 176 | const requestSnapshot = (layoutSlotId: string, layer: 'background' | 'visual'): string => |
| 177 | JSON.stringify({ protocol: 'm3b-image-request-v2', layoutSlotId, layer }) |
| 178 | |
| 179 | export const parseVisualIntents = (input: VisualIntentInput): VisualIntentParseResult => { |
| 180 | const $ = cheerio.load(input.html, { scriptingEnabled: false }) |
| 181 | const requestContainers = $('[data-img-request]') |
| 182 | const draftElements = findDraftElements($) |
| 183 | const draftAttributeNames = draftElements |
| 184 | .toArray() |
| 185 | .flatMap((node) => imageDraftAttributes(node as Element)) |
| 186 | const legacyDrafts = draftAttributeNames.filter((name) => LEGACY_DRAFT_ATTRIBUTES.has(name)) |
| 187 | const unknownDrafts = draftAttributeNames.filter((name) => !ALLOWED_DRAFT_ATTRIBUTES.has(name) && !LEGACY_DRAFT_ATTRIBUTES.has(name)) |
| 188 | const orphanPlacements = $('[data-img-placement]').filter((_index, node) => !$(node).is('[data-img-request]')) |
| 189 | if (draftElements.length === 0) { |
| 190 | return { status: 'none', intents: [], invalidIntents: [], errors: [] } |
| 191 | } |
| 192 | if (!input.visualEnabled) { |
| 193 | return { |
| 194 | status: 'forbidden', |
| 195 | intents: [], |
| 196 | invalidIntents: [], |
| 197 | errors: ['Image request markers are forbidden when visualEnabled is false.'] |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | const { template, diagnostic } = resolveTemplate(input) |
| 202 | if (!template) { |
| 203 | return { |
| 204 | status: 'invalid', |
| 205 | intents: [], |
| 206 | invalidIntents: [], |
| 207 | errors: ['Image requests require a compatible page layout source.'], |
| 208 | diagnostic |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | const errors: string[] = [] |
| 213 | const invalidIntents: InvalidVisualIntent[] = [] |
| 214 | const intents: ParsedVisualIntent[] = [] |
| 215 | const seenSlotIds = new Set<string>() |
| 216 | if (legacyDrafts.length > 0) { |
| 217 | const message = |
| 218 | 'Automatic image requests must use data-img-request only; do not emit data-img-slot, data-img-intent, data-img-placeholder, or data-img-finalization.' |
| 219 | errors.push(message) |
| 220 | invalidIntents.push({ |
| 221 | slotId: null, |
| 222 | layoutSlotId: null, |
| 223 | role: null, |
| 224 | requestJson: '', |
| 225 | errors: [message] |
| 226 | }) |
| 227 | } |
| 228 | if (unknownDrafts.length > 0) { |
| 229 | const attributes = [...new Set(unknownDrafts)].join(', ') |
| 230 | const message = `Unknown data-img-* draft attributes are not allowed: ${attributes}.` |
| 231 | errors.push(message) |
| 232 | invalidIntents.push({ |
| 233 | slotId: null, |
| 234 | layoutSlotId: null, |
| 235 | role: null, |
| 236 | requestJson: '', |
| 237 | errors: [message] |
| 238 | }) |
| 239 | } |
| 240 | if (orphanPlacements.length > 0) { |
| 241 | const message = 'data-img-placement is only allowed on an element with data-img-request.' |
| 242 | errors.push(message) |
| 243 | invalidIntents.push({ |
| 244 | slotId: null, |
| 245 | layoutSlotId: null, |
| 246 | role: null, |
| 247 | requestJson: '', |
| 248 | errors: [message] |
| 249 | }) |
| 250 | } |
| 251 | |
| 252 | requestContainers.each((_index, node) => { |
| 253 | const container = $(node) |
| 254 | const layoutSlotId = cleanString(container.attr('data-ppt-slot')) |
| 255 | const layoutSlot = template.slots.find((slot) => slot.id === layoutSlotId) |
| 256 | const placement = cleanString(container.attr('data-img-placement')).toLowerCase() |
| 257 | const requestErrors: string[] = [] |
| 258 | if (!layoutSlotId) requestErrors.push('data-img-request must be on an element with data-ppt-slot.') |
| 259 | if (!isInsideContentLayer($, node as Element)) { |
| 260 | requestErrors.push('data-img-request must be inside main[data-role="content"].') |
| 261 | } |
| 262 | if (!layoutSlot || layoutSlot.role !== 'visual' || !layoutSlot.image) { |
| 263 | requestErrors.push(`Layout slot ${layoutSlotId || '(missing)'} does not allow images.`) |
| 264 | } else if (layoutSlot.image.policy === 'forbidden') { |
| 265 | requestErrors.push(`Layout slot ${layoutSlot.id} forbids image generation.`) |
| 266 | } |
| 267 | if (placement && placement !== 'background' && placement !== 'visual') { |
| 268 | requestErrors.push('data-img-placement must be either "background" or "visual".') |
| 269 | } |
| 270 | if (!hasImageGeometry($, node as Element)) { |
| 271 | requestErrors.push( |
| 272 | 'Image request container must expose width and height, aspect-ratio, a grid/flex area, or bounded absolute positioning.' |
| 273 | ) |
| 274 | } |
| 275 | if (layoutSlotId && seenSlotIds.has(layoutSlotId)) { |
| 276 | requestErrors.push(`Image request layout slot ${layoutSlotId} appears more than once.`) |
| 277 | } |
| 278 | if (layoutSlotId) seenSlotIds.add(layoutSlotId) |
| 279 | |
| 280 | const layer = placement === 'background' || placement === 'visual' ? placement : layoutSlot?.image?.layer || 'visual' |
| 281 | const requestJson = requestSnapshot(layoutSlotId || 'invalid', layer) |
| 282 | if (requestErrors.length > 0) { |
| 283 | invalidIntents.push({ |
| 284 | slotId: layoutSlotId || null, |
| 285 | layoutSlotId: layoutSlotId || null, |
| 286 | role: layoutSlot?.image?.role || null, |
| 287 | requestJson, |
| 288 | errors: requestErrors |
| 289 | }) |
| 290 | errors.push(...requestErrors) |
| 291 | return |
| 292 | } |
| 293 | intents.push({ |
| 294 | slotId: layoutSlotId, |
| 295 | layoutSlotId, |
| 296 | role: layoutSlot!.image!.role, |
| 297 | layer, |
| 298 | subject: '', |
| 299 | avoid: [...DEFAULT_IMAGE_AVOID], |
| 300 | requestJson |
| 301 | }) |
| 302 | }) |
| 303 | |
| 304 | if (requestContainers.length > 1) { |
| 305 | const message = 'Only one automatic image request is allowed per page.' |
| 306 | errors.push(message) |
| 307 | for (const intent of intents) { |
| 308 | invalidIntents.push({ |
| 309 | slotId: intent.slotId, |
| 310 | layoutSlotId: intent.layoutSlotId, |
| 311 | role: intent.role, |
| 312 | requestJson: intent.requestJson, |
| 313 | errors: [message] |
| 314 | }) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | return { |
| 319 | status: errors.length > 0 ? 'invalid' : 'valid', |
| 320 | intents: errors.length > 0 ? [] : intents, |
| 321 | invalidIntents, |
| 322 | errors |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | export const isValidImagePrompt = (value: string): boolean => { |
| 327 | const subject = cleanString(value, MAX_SUBJECT_LENGTH) |
| 328 | return Boolean(subject) |
| 329 | } |
| 330 |