| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- import { randomUUID } from 'node:crypto';
- import { COST_UNIT, GEMINI_IMAGE_MODEL, generationReserveCny, SEEDREAM_IMAGE_MODEL } from '../../providers/new-api-image.mjs';
- import { sha256, stableStringify } from '../../core/request-key.mjs';
- import { loadPlatforms, loadTemplate } from './catalog.mjs';
- import { domesticPromptConstraints, resolveDomesticAssetRule } from './domestic-platforms.mjs';
- import { buildIdentityLock, buildStyleLock, NEGATIVE_CONSTRAINTS } from './locks.mjs';
- 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;
- const FULL_SLOT_TEMPLATES = {
- H1: 'hero-image', H2: 'lifestyle-scene', H3: 'before-after', H4: 'ugc-style', H5: 'poster-banner',
- M1: 'lifestyle-scene', M2: 'detail-macro', M3: 'exploded-view', M4: 'ugc-style', M5: 'packaging',
- M6: 'size-spec', M7: 'infographic', M8: 'poster-banner', M9: 'social-media'
- };
- const COMPACT = [
- ['S1', 'hero-image', '电商主图'], ['S2', 'lifestyle-scene', '生活方式场景'],
- ['S3', 'detail-macro', '材质与细节'], ['S4', 'infographic', '核心卖点'],
- ['S5', 'size-spec', '尺寸与规格'], ['S6', 'packaging', '包装与清单'],
- ['S7', 'poster-banner', '营销收束图']
- ];
- const MAIN_IMAGE = [
- ['A1', 'hero-image', '合规白底主图'], ['A2', 'infographic', '核心卖点副图'],
- ['A3', 'multi-angle-grid', '多角度副图'], ['A4', 'detail-macro', '细节副图'],
- ['A5', 'lifestyle-scene', '生活方式副图'], ['A6', 'multi-product', '款式/组合副图'],
- ['A7', 'packaging', '包装清单副图'], ['A8', 'size-spec', '尺寸参考副图']
- ];
- const DETAIL_PAGE = [
- ['D1', 'poster-banner', '品牌 Hero Banner'], ['D2', 'before-after', '痛点与结果'],
- ['D3', 'infographic', '核心卖点'], ['D4', 'detail-macro', '材质工艺'],
- ['D5', 'exploded-view', '技术结构'], ['D6', 'size-spec', '规格/用法'],
- ['D7', 'packaging', '包装与系列'], ['D8', 'luxury-atmospherics', '品牌背书收束']
- ];
- const ANGLES = ['正面', '左前 45 度', '右前 45 度', '左侧', '右侧', '背面', '上视', '细节近景', '动态/穿着展示'];
- function detectPreset(request = '', explicit = '') {
- if (explicit) return explicit;
- const text = String(request).toLowerCase();
- if (/14|十四|完整套图|h1|m9/.test(text)) return 'full';
- if (/九角|9角|多角度|multi-angle|lookbook/.test(text)) return 'multi-angle';
- if (/a\+|详情页|detail page|hero banner/.test(text)) return 'detail-page';
- if (/广告|投放|ad creative|campaign|促销/.test(text)) return 'ad-creative';
- if (/社媒|小红书|instagram|tiktok|feed|story|carousel|轮播/.test(text)) return 'social';
- if (/亚马逊|amazon|主图|副图|listing image/.test(text)) return 'main-image';
- return 'compact';
- }
- function platformKey(value = '') {
- const text = String(value).toLowerCase();
- if (/amazon|亚马逊/.test(text)) return 'amazon';
- if (/shopify/.test(text)) return 'shopify';
- if (/google/.test(text)) return 'google_display';
- if (/facebook/.test(text)) return 'facebook_ad';
- if (/linkedin/.test(text)) return 'linkedin_ad';
- if (/story/.test(text)) return 'instagram_story';
- if (/instagram/.test(text)) return 'instagram_feed';
- if (/tiktok|抖音/.test(text)) return 'tiktok';
- if (/pinterest/.test(text)) return 'pinterest';
- if (/小红书|rednote|xiaohongshu/.test(text)) return 'rednote';
- if (/\bx\b|twitter/.test(text)) return 'x';
- return 'general_ecommerce';
- }
- function interpolate(value, vars) {
- if (typeof value === 'string') return value.replace(/\{([A-Za-z0-9_]+)\}/g, (_, key) => vars[key] || '');
- if (Array.isArray(value)) return value.map(item => interpolate(item, vars));
- if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, interpolate(item, vars)]));
- return value;
- }
- function flattenPrompt(value) {
- return Object.entries(value || {}).map(([key, item]) => `${key}: ${Array.isArray(item) ? item.join(', ') : item}`).filter(part => !part.endsWith(': ')).join('; ');
- }
- export function sanitizeGeneratorPrompt(value) {
- return String(value || '').replace(BANNED_PROMPT_TERMS, '').replace(/\s{2,}/g, ' ').replace(/\s+([,;:.])/g, '$1').trim();
- }
- export function deriveProductName(input = {}, analysis = {}) {
- const explicit = String(input.productName || '').trim();
- if (explicit) return explicit;
- const identity = analysis.product_identity || {};
- const identityName = String(identity.product_name || identity.productName || identity.name || '').trim();
- if (identityName) return identityName;
- const namedFact = (analysis.visible_facts || []).find(fact => /^(商品名|产品名|品名|product[ _-]?name|name)$/i.test(String(fact?.field || '').trim()));
- if (String(namedFact?.value || '').trim()) return String(namedFact.value).trim();
- const brand = String(identity.logo || '').trim();
- const category = String(identity.category || '').trim();
- if (brand && category && !category.toLowerCase().includes(brand.toLowerCase())) return `${brand}-${category}`;
- return brand || category || '未命名商品';
- }
- function applyDimensions(item, width, height, ratio) {
- item.width = width;
- item.height = height;
- item.ratio = ratio;
- item.prompt = item.prompt.replace(/Canvas\s+\d+x\d+/i, `Canvas ${width}x${height}`);
- }
- function templatePrompt(template, input, title, analysis, platform) {
- const identity = analysis.product_identity || {};
- const vars = {
- product_description: input.productDescription || [identity.category, identity.shape, identity.packaging].filter(Boolean).join(' ') || 'the exact referenced product',
- material_description: (identity.materials || []).join(', '),
- platform_style: platform.label,
- overlay_elements: input.requestedCopy || 'none',
- color: (input.brandColors || [])[0] || (identity.colors || [])[0] || '',
- product: input.productDescription || identity.category || 'product'
- };
- const base = interpolate(template.prompt_template || {}, vars);
- const variant = input.style && template.variants?.[input.style]?.overrides ? interpolate(template.variants[input.style].overrides, vars) : {};
- const categoryTip = template.category_tips?.[input.category || identity.category] || '';
- const textPolicy = platform.text_policy;
- const requestedCopy = input.requestedCopy && !/不在生成图中渲染文字|无文字/.test(textPolicy) ? `Render only this exact short copy: “${input.requestedCopy}”.` : 'Do not render text unless explicitly supplied.';
- return sanitizeGeneratorPrompt([
- `OUTPUT PURPOSE — ${title}.`,
- buildIdentityLock(analysis, input), buildStyleLock(analysis, input),
- flattenPrompt({ ...base, ...variant }), categoryTip,
- `Canvas ${platform.width}x${platform.height}; safe zone: ${platform.safe_zone}; text policy: ${textPolicy}.`,
- requestedCopy, NEGATIVE_CONSTRAINTS
- ].filter(Boolean).join(' '));
- }
- function itemFromTuple(tuple, index, input, analysis, locale, platformOverride) {
- const [outputId, templateId, title, note] = tuple;
- const template = loadTemplate(templateId, locale);
- const platforms = loadPlatforms();
- const platform = platforms[platformOverride || platformKey(input.platform)] || platforms.general_ecommerce;
- const providerModel = input.modelPreference === 'seedream' || input.modelPreference === SEEDREAM_IMAGE_MODEL
- ? SEEDREAM_IMAGE_MODEL
- : GEMINI_IMAGE_MODEL;
- const domesticRule = resolveDomesticAssetRule({
- platform: input.platform, preset: input.preset, outputId, templateId
- });
- if (domesticRule) {
- const target = domesticRule.target || {};
- const domesticPlatform = {
- ...platform,
- label: domesticRule.platformLabel,
- width: Number(target.width || platform.width),
- height: Number(target.height || platform.height),
- ratio: target.ratio || platform.ratio,
- safe_zone: target.safeZone || platform.safe_zone,
- text_policy: target.textPolicy || platform.text_policy
- };
- const basePrompt = templatePrompt(template, input, note ? `${title};${note}` : title, analysis, domesticPlatform);
- return {
- outputId, outputKind: templateId, title, reason: note || `套图预设中的${title}`,
- templateId, templateName: template.name, width: domesticPlatform.width, height: domesticPlatform.height,
- ratio: domesticPlatform.ratio, safeZone: domesticPlatform.safe_zone, textPolicy: domesticPlatform.text_policy,
- providerModel, prompt: `${basePrompt} ${domesticPromptConstraints(domesticRule)}`,
- estimatedCostCny: generationReserveCny(providerModel, { qualityReview: input.qualityReview !== false }), costUnit: COST_UNIT, sortOrder: index,
- platformRule: domesticRule
- };
- }
- return {
- outputId, outputKind: templateId, title, reason: note || `套图预设中的${title}`,
- templateId, templateName: template.name, width: platform.width, height: platform.height,
- ratio: platform.ratio, safeZone: platform.safe_zone, textPolicy: platform.text_policy,
- providerModel, prompt: templatePrompt(template, input, note ? `${title};${note}` : title, analysis, platform),
- estimatedCostCny: generationReserveCny(providerModel, { qualityReview: input.qualityReview !== false }), costUnit: COST_UNIT, sortOrder: index
- };
- }
- function tuplesForPreset(preset, input) {
- if (preset === 'full') return Object.entries(FULL_SLOT_TEMPLATES).map(([id, template]) => [id, template, `${id} 套图模块`]);
- if (preset === 'main-image') return MAIN_IMAGE;
- if (preset === 'detail-page') return DETAIL_PAGE;
- if (preset === 'multi-angle') return ANGLES.map((angle, index) => [`V${index + 1}`, 'multi-angle-grid', `一致性多角度 ${index + 1}`, angle]);
- if (preset === 'ad-creative') {
- const platforms = Array.isArray(input.platforms) && input.platforms.length ? input.platforms : [input.platform || 'general_ecommerce'];
- return platforms.slice(0, 8).map((platform, index) => [`AD${index + 1}`, 'poster-banner', `${platform} 广告创意`, `为 ${platform} 独立设计,不是简单缩放`, platformKey(platform)]);
- }
- if (preset === 'social') {
- const count = /轮播|carousel/i.test(input.request || '') ? Math.min(Number(input.count || 7), 10) : Math.min(Number(input.count || 1), 10);
- return Array.from({ length: count }, (_, index) => [`C${index + 1}`, 'social-media', count > 1 ? `社媒轮播第 ${index + 1} 页` : '社媒视觉', index === 0 ? '三秒钩子封面' : index === count - 1 ? '行动引导收束' : '单页一个信息点']);
- }
- return COMPACT;
- }
- export function buildImageSetPlan(input = {}) {
- const request = String(input.request || input.prompt || '').trim();
- if (!request) throw new Error('request 为必填项。');
- const locale = input.locale === 'en' ? 'en' : 'zh';
- const preset = detectPreset(request, input.preset);
- const analysis = input.analysis?.analysis || input.analysis || {};
- const productName = deriveProductName(input, analysis);
- const tuples = tuplesForPreset(preset, { ...input, request });
- if (!tuples.length || tuples.length > 14) throw new Error('输出计划必须包含 1–14 个项目。');
- const items = tuples.map((tuple, index) => itemFromTuple(tuple, index, { ...input, request, preset }, analysis, locale, tuple[4]));
- if (preset === 'multi-angle') {
- for (const item of items) applyDimensions(item, 2048, 2560, '4:5');
- }
- if (preset === 'detail-page') {
- applyDimensions(items[0], 3024, 1296, '21:9');
- applyDimensions(items.at(-1), 3024, 1296, '21:9');
- }
- const estimatedCostCny = Number(items.reduce((sum, item) => sum + (item.estimatedCostCny || 0), 0).toFixed(4));
- const planCore = {
- schemaVersion: 3, locale, preset, request, platform: input.platform || 'general_ecommerce',
- productName, productDescription: input.productDescription || '', analysis,
- items, estimatedCostCny, costUnit: COST_UNIT, costBasis: 'trusted_usd_price_and_exchange_rate', qualityReview: input.qualityReview !== false, requiresConfirmation: true,
- qualityChecks: ['商品身份一致', '品牌色与商品色一致', 'Logo/文字不变形', '平台尺寸和安全区', '无虚构声明', '无平台 UI/账号/水印', '无明显生成缺陷']
- };
- const planHash = sha256(stableStringify(planCore));
- return {
- ...planCore,
- planId: input.planId || `plan_${planHash.slice(0, 16)}`,
- planHash,
- createdAt: input.createdAt || new Date().toISOString(),
- confirmationMessage: `计划共 ${items.length} 张,按受信美元价格与汇率预计 ¥${estimatedCostCny.toFixed(2)}。确认张数、模型、提示词和人民币预算后才能生成。`
- };
- }
- export function toPublicPlan(plan = {}) {
- const items = (plan.items || []).map(({ estimatedCostCny, costUnit, ...item }) => item);
- const {
- estimatedCostCny, costUnit, costBasis, confirmationMessage,
- ...publicPlan
- } = plan;
- return {
- ...publicPlan,
- items,
- confirmationMessage: `计划共 ${items.length} 张;确认张数、模型、尺寸、用途和提示词后即可生成。`
- };
- }
- function restoreTrustedPlanCosts(plan = {}) {
- const qualityReview = plan.qualityReview !== false;
- const items = (plan.items || []).map(item => ({
- ...item,
- estimatedCostCny: generationReserveCny(item.providerModel, { qualityReview }),
- costUnit: COST_UNIT
- }));
- return {
- ...plan,
- items,
- estimatedCostCny: Number(items.reduce((sum, item) => sum + item.estimatedCostCny, 0).toFixed(4)),
- costUnit: COST_UNIT,
- costBasis: 'trusted_usd_price_and_exchange_rate'
- };
- }
- export function verifyPlan(plan) {
- const { planId, planHash, createdAt, confirmationMessage, ...planCore } = plan || {};
- let verified = plan;
- let actual = sha256(stableStringify(planCore));
- if (planHash && actual !== planHash) {
- const restored = restoreTrustedPlanCosts(plan);
- const { planId: ignoredPlanId, planHash: ignoredPlanHash, createdAt: ignoredCreatedAt, confirmationMessage: ignoredMessage, ...restoredCore } = restored;
- actual = sha256(stableStringify(restoredCore));
- if (actual === planHash) verified = restored;
- }
- if (!planHash || actual !== planHash) throw new Error('计划完整性校验失败,请重新规划。');
- if (!planId || !Array.isArray(verified.items) || !verified.items.length || verified.items.length > 14) throw new Error('计划结构无效。');
- return verified;
- }
- export { detectPreset, platformKey };
|