planner.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import { randomUUID } from 'node:crypto';
  2. import { COST_UNIT, GEMINI_IMAGE_MODEL, generationReserveCny, SEEDREAM_IMAGE_MODEL } from '../../providers/new-api-image.mjs';
  3. import { sha256, stableStringify } from '../../core/request-key.mjs';
  4. import { loadPlatforms, loadTemplate } from './catalog.mjs';
  5. import { domesticPromptConstraints, resolveDomesticAssetRule } from './domestic-platforms.mjs';
  6. import { buildIdentityLock, buildStyleLock, NEGATIVE_CONSTRAINTS } from './locks.mjs';
  7. const BANNED_PROMPT_TERMS = /\b(instagram|tiktok|facebook|rednote|xiaohongshu|pinterest|linkedin|twitter|google display|amazon|shopify|sponsored|like button|follow button|comment box|engagement metrics|timestamp badge|audio icon|app ui|platform ui)\b/gi;
  8. const FULL_SLOT_TEMPLATES = {
  9. H1: 'hero-image', H2: 'lifestyle-scene', H3: 'before-after', H4: 'ugc-style', H5: 'poster-banner',
  10. M1: 'lifestyle-scene', M2: 'detail-macro', M3: 'exploded-view', M4: 'ugc-style', M5: 'packaging',
  11. M6: 'size-spec', M7: 'infographic', M8: 'poster-banner', M9: 'social-media'
  12. };
  13. const COMPACT = [
  14. ['S1', 'hero-image', '电商主图'], ['S2', 'lifestyle-scene', '生活方式场景'],
  15. ['S3', 'detail-macro', '材质与细节'], ['S4', 'infographic', '核心卖点'],
  16. ['S5', 'size-spec', '尺寸与规格'], ['S6', 'packaging', '包装与清单'],
  17. ['S7', 'poster-banner', '营销收束图']
  18. ];
  19. const MAIN_IMAGE = [
  20. ['A1', 'hero-image', '合规白底主图'], ['A2', 'infographic', '核心卖点副图'],
  21. ['A3', 'multi-angle-grid', '多角度副图'], ['A4', 'detail-macro', '细节副图'],
  22. ['A5', 'lifestyle-scene', '生活方式副图'], ['A6', 'multi-product', '款式/组合副图'],
  23. ['A7', 'packaging', '包装清单副图'], ['A8', 'size-spec', '尺寸参考副图']
  24. ];
  25. const DETAIL_PAGE = [
  26. ['D1', 'poster-banner', '品牌 Hero Banner'], ['D2', 'before-after', '痛点与结果'],
  27. ['D3', 'infographic', '核心卖点'], ['D4', 'detail-macro', '材质工艺'],
  28. ['D5', 'exploded-view', '技术结构'], ['D6', 'size-spec', '规格/用法'],
  29. ['D7', 'packaging', '包装与系列'], ['D8', 'luxury-atmospherics', '品牌背书收束']
  30. ];
  31. const ANGLES = ['正面', '左前 45 度', '右前 45 度', '左侧', '右侧', '背面', '上视', '细节近景', '动态/穿着展示'];
  32. function detectPreset(request = '', explicit = '') {
  33. if (explicit) return explicit;
  34. const text = String(request).toLowerCase();
  35. if (/14|十四|完整套图|h1|m9/.test(text)) return 'full';
  36. if (/九角|9角|多角度|multi-angle|lookbook/.test(text)) return 'multi-angle';
  37. if (/a\+|详情页|detail page|hero banner/.test(text)) return 'detail-page';
  38. if (/广告|投放|ad creative|campaign|促销/.test(text)) return 'ad-creative';
  39. if (/社媒|小红书|instagram|tiktok|feed|story|carousel|轮播/.test(text)) return 'social';
  40. if (/亚马逊|amazon|主图|副图|listing image/.test(text)) return 'main-image';
  41. return 'compact';
  42. }
  43. function platformKey(value = '') {
  44. const text = String(value).toLowerCase();
  45. if (/amazon|亚马逊/.test(text)) return 'amazon';
  46. if (/shopify/.test(text)) return 'shopify';
  47. if (/google/.test(text)) return 'google_display';
  48. if (/facebook/.test(text)) return 'facebook_ad';
  49. if (/linkedin/.test(text)) return 'linkedin_ad';
  50. if (/story/.test(text)) return 'instagram_story';
  51. if (/instagram/.test(text)) return 'instagram_feed';
  52. if (/tiktok|抖音/.test(text)) return 'tiktok';
  53. if (/pinterest/.test(text)) return 'pinterest';
  54. if (/小红书|rednote|xiaohongshu/.test(text)) return 'rednote';
  55. if (/\bx\b|twitter/.test(text)) return 'x';
  56. return 'general_ecommerce';
  57. }
  58. function interpolate(value, vars) {
  59. if (typeof value === 'string') return value.replace(/\{([A-Za-z0-9_]+)\}/g, (_, key) => vars[key] || '');
  60. if (Array.isArray(value)) return value.map(item => interpolate(item, vars));
  61. if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, interpolate(item, vars)]));
  62. return value;
  63. }
  64. function flattenPrompt(value) {
  65. return Object.entries(value || {}).map(([key, item]) => `${key}: ${Array.isArray(item) ? item.join(', ') : item}`).filter(part => !part.endsWith(': ')).join('; ');
  66. }
  67. export function sanitizeGeneratorPrompt(value) {
  68. return String(value || '').replace(BANNED_PROMPT_TERMS, '').replace(/\s{2,}/g, ' ').replace(/\s+([,;:.])/g, '$1').trim();
  69. }
  70. export function deriveProductName(input = {}, analysis = {}) {
  71. const explicit = String(input.productName || '').trim();
  72. if (explicit) return explicit;
  73. const identity = analysis.product_identity || {};
  74. const identityName = String(identity.product_name || identity.productName || identity.name || '').trim();
  75. if (identityName) return identityName;
  76. const namedFact = (analysis.visible_facts || []).find(fact => /^(商品名|产品名|品名|product[ _-]?name|name)$/i.test(String(fact?.field || '').trim()));
  77. if (String(namedFact?.value || '').trim()) return String(namedFact.value).trim();
  78. const brand = String(identity.logo || '').trim();
  79. const category = String(identity.category || '').trim();
  80. if (brand && category && !category.toLowerCase().includes(brand.toLowerCase())) return `${brand}-${category}`;
  81. return brand || category || '未命名商品';
  82. }
  83. function applyDimensions(item, width, height, ratio) {
  84. item.width = width;
  85. item.height = height;
  86. item.ratio = ratio;
  87. item.prompt = item.prompt.replace(/Canvas\s+\d+x\d+/i, `Canvas ${width}x${height}`);
  88. }
  89. function templatePrompt(template, input, title, analysis, platform) {
  90. const identity = analysis.product_identity || {};
  91. const vars = {
  92. product_description: input.productDescription || [identity.category, identity.shape, identity.packaging].filter(Boolean).join(' ') || 'the exact referenced product',
  93. material_description: (identity.materials || []).join(', '),
  94. platform_style: platform.label,
  95. overlay_elements: input.requestedCopy || 'none',
  96. color: (input.brandColors || [])[0] || (identity.colors || [])[0] || '',
  97. product: input.productDescription || identity.category || 'product'
  98. };
  99. const base = interpolate(template.prompt_template || {}, vars);
  100. const variant = input.style && template.variants?.[input.style]?.overrides ? interpolate(template.variants[input.style].overrides, vars) : {};
  101. const categoryTip = template.category_tips?.[input.category || identity.category] || '';
  102. const textPolicy = platform.text_policy;
  103. const requestedCopy = input.requestedCopy && !/不在生成图中渲染文字|无文字/.test(textPolicy) ? `Render only this exact short copy: “${input.requestedCopy}”.` : 'Do not render text unless explicitly supplied.';
  104. return sanitizeGeneratorPrompt([
  105. `OUTPUT PURPOSE — ${title}.`,
  106. buildIdentityLock(analysis, input), buildStyleLock(analysis, input),
  107. flattenPrompt({ ...base, ...variant }), categoryTip,
  108. `Canvas ${platform.width}x${platform.height}; safe zone: ${platform.safe_zone}; text policy: ${textPolicy}.`,
  109. requestedCopy, NEGATIVE_CONSTRAINTS
  110. ].filter(Boolean).join(' '));
  111. }
  112. function itemFromTuple(tuple, index, input, analysis, locale, platformOverride) {
  113. const [outputId, templateId, title, note] = tuple;
  114. const template = loadTemplate(templateId, locale);
  115. const platforms = loadPlatforms();
  116. const platform = platforms[platformOverride || platformKey(input.platform)] || platforms.general_ecommerce;
  117. const providerModel = input.modelPreference === 'seedream' || input.modelPreference === SEEDREAM_IMAGE_MODEL
  118. ? SEEDREAM_IMAGE_MODEL
  119. : GEMINI_IMAGE_MODEL;
  120. const domesticRule = resolveDomesticAssetRule({
  121. platform: input.platform, preset: input.preset, outputId, templateId
  122. });
  123. if (domesticRule) {
  124. const target = domesticRule.target || {};
  125. const domesticPlatform = {
  126. ...platform,
  127. label: domesticRule.platformLabel,
  128. width: Number(target.width || platform.width),
  129. height: Number(target.height || platform.height),
  130. ratio: target.ratio || platform.ratio,
  131. safe_zone: target.safeZone || platform.safe_zone,
  132. text_policy: target.textPolicy || platform.text_policy
  133. };
  134. const basePrompt = templatePrompt(template, input, note ? `${title};${note}` : title, analysis, domesticPlatform);
  135. return {
  136. outputId, outputKind: templateId, title, reason: note || `套图预设中的${title}`,
  137. templateId, templateName: template.name, width: domesticPlatform.width, height: domesticPlatform.height,
  138. ratio: domesticPlatform.ratio, safeZone: domesticPlatform.safe_zone, textPolicy: domesticPlatform.text_policy,
  139. providerModel, prompt: `${basePrompt} ${domesticPromptConstraints(domesticRule)}`,
  140. estimatedCostCny: generationReserveCny(providerModel, { qualityReview: input.qualityReview !== false }), costUnit: COST_UNIT, sortOrder: index,
  141. platformRule: domesticRule
  142. };
  143. }
  144. return {
  145. outputId, outputKind: templateId, title, reason: note || `套图预设中的${title}`,
  146. templateId, templateName: template.name, width: platform.width, height: platform.height,
  147. ratio: platform.ratio, safeZone: platform.safe_zone, textPolicy: platform.text_policy,
  148. providerModel, prompt: templatePrompt(template, input, note ? `${title};${note}` : title, analysis, platform),
  149. estimatedCostCny: generationReserveCny(providerModel, { qualityReview: input.qualityReview !== false }), costUnit: COST_UNIT, sortOrder: index
  150. };
  151. }
  152. function tuplesForPreset(preset, input) {
  153. if (preset === 'full') return Object.entries(FULL_SLOT_TEMPLATES).map(([id, template]) => [id, template, `${id} 套图模块`]);
  154. if (preset === 'main-image') return MAIN_IMAGE;
  155. if (preset === 'detail-page') return DETAIL_PAGE;
  156. if (preset === 'multi-angle') return ANGLES.map((angle, index) => [`V${index + 1}`, 'multi-angle-grid', `一致性多角度 ${index + 1}`, angle]);
  157. if (preset === 'ad-creative') {
  158. const platforms = Array.isArray(input.platforms) && input.platforms.length ? input.platforms : [input.platform || 'general_ecommerce'];
  159. return platforms.slice(0, 8).map((platform, index) => [`AD${index + 1}`, 'poster-banner', `${platform} 广告创意`, `为 ${platform} 独立设计,不是简单缩放`, platformKey(platform)]);
  160. }
  161. if (preset === 'social') {
  162. const count = /轮播|carousel/i.test(input.request || '') ? Math.min(Number(input.count || 7), 10) : Math.min(Number(input.count || 1), 10);
  163. return Array.from({ length: count }, (_, index) => [`C${index + 1}`, 'social-media', count > 1 ? `社媒轮播第 ${index + 1} 页` : '社媒视觉', index === 0 ? '三秒钩子封面' : index === count - 1 ? '行动引导收束' : '单页一个信息点']);
  164. }
  165. return COMPACT;
  166. }
  167. export function buildImageSetPlan(input = {}) {
  168. const request = String(input.request || input.prompt || '').trim();
  169. if (!request) throw new Error('request 为必填项。');
  170. const locale = input.locale === 'en' ? 'en' : 'zh';
  171. const preset = detectPreset(request, input.preset);
  172. const analysis = input.analysis?.analysis || input.analysis || {};
  173. const productName = deriveProductName(input, analysis);
  174. const tuples = tuplesForPreset(preset, { ...input, request });
  175. if (!tuples.length || tuples.length > 14) throw new Error('输出计划必须包含 1–14 个项目。');
  176. const items = tuples.map((tuple, index) => itemFromTuple(tuple, index, { ...input, request, preset }, analysis, locale, tuple[4]));
  177. if (preset === 'multi-angle') {
  178. for (const item of items) applyDimensions(item, 2048, 2560, '4:5');
  179. }
  180. if (preset === 'detail-page') {
  181. applyDimensions(items[0], 3024, 1296, '21:9');
  182. applyDimensions(items.at(-1), 3024, 1296, '21:9');
  183. }
  184. const estimatedCostCny = Number(items.reduce((sum, item) => sum + (item.estimatedCostCny || 0), 0).toFixed(4));
  185. const planCore = {
  186. schemaVersion: 3, locale, preset, request, platform: input.platform || 'general_ecommerce',
  187. productName, productDescription: input.productDescription || '', analysis,
  188. items, estimatedCostCny, costUnit: COST_UNIT, costBasis: 'trusted_usd_price_and_exchange_rate', qualityReview: input.qualityReview !== false, requiresConfirmation: true,
  189. qualityChecks: ['商品身份一致', '品牌色与商品色一致', 'Logo/文字不变形', '平台尺寸和安全区', '无虚构声明', '无平台 UI/账号/水印', '无明显生成缺陷']
  190. };
  191. const planHash = sha256(stableStringify(planCore));
  192. return {
  193. ...planCore,
  194. planId: input.planId || `plan_${planHash.slice(0, 16)}`,
  195. planHash,
  196. createdAt: input.createdAt || new Date().toISOString(),
  197. confirmationMessage: `计划共 ${items.length} 张,按受信美元价格与汇率预计 ¥${estimatedCostCny.toFixed(2)}。确认张数、模型、提示词和人民币预算后才能生成。`
  198. };
  199. }
  200. export function toPublicPlan(plan = {}) {
  201. const items = (plan.items || []).map(({ estimatedCostCny, costUnit, ...item }) => item);
  202. const {
  203. estimatedCostCny, costUnit, costBasis, confirmationMessage,
  204. ...publicPlan
  205. } = plan;
  206. return {
  207. ...publicPlan,
  208. items,
  209. confirmationMessage: `计划共 ${items.length} 张;确认张数、模型、尺寸、用途和提示词后即可生成。`
  210. };
  211. }
  212. function restoreTrustedPlanCosts(plan = {}) {
  213. const qualityReview = plan.qualityReview !== false;
  214. const items = (plan.items || []).map(item => ({
  215. ...item,
  216. estimatedCostCny: generationReserveCny(item.providerModel, { qualityReview }),
  217. costUnit: COST_UNIT
  218. }));
  219. return {
  220. ...plan,
  221. items,
  222. estimatedCostCny: Number(items.reduce((sum, item) => sum + item.estimatedCostCny, 0).toFixed(4)),
  223. costUnit: COST_UNIT,
  224. costBasis: 'trusted_usd_price_and_exchange_rate'
  225. };
  226. }
  227. export function verifyPlan(plan) {
  228. const { planId, planHash, createdAt, confirmationMessage, ...planCore } = plan || {};
  229. let verified = plan;
  230. let actual = sha256(stableStringify(planCore));
  231. if (planHash && actual !== planHash) {
  232. const restored = restoreTrustedPlanCosts(plan);
  233. const { planId: ignoredPlanId, planHash: ignoredPlanHash, createdAt: ignoredCreatedAt, confirmationMessage: ignoredMessage, ...restoredCore } = restored;
  234. actual = sha256(stableStringify(restoredCore));
  235. if (actual === planHash) verified = restored;
  236. }
  237. if (!planHash || actual !== planHash) throw new Error('计划完整性校验失败,请重新规划。');
  238. if (!planId || !Array.isArray(verified.items) || !verified.items.length || verified.items.length > 14) throw new Error('计划结构无效。');
  239. return verified;
  240. }
  241. export { detectPreset, platformKey };