返回 oh-my-ppt
visual-intent.ts
根目录 / src / main / image-generation / visual-intent.ts
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
330 lines TYPESCRIPT