type LayoutReplaceModelDescriptor = { capabilities?: unknown; displayName?: string | null; healthStatus?: string | null; id: string; imageGenConfig?: unknown; isDefault?: boolean | null; isSubModel?: boolean | null; modelId?: string | null; supportsImageGen?: boolean | null; supportsVision?: boolean | null; }; type LayoutReplaceProviderDescriptor = { type?: string | null; }; export type CanvasLayoutReplaceModelCandidate = { model: LayoutReplaceModelDescriptor; provider: LayoutReplaceProviderDescriptor; }; const MODEL_PREFERENCE_PATTERNS: RegExp[] = [ /^snapgen\/gpt-image-2$/i, /^gpt-image-2(?:-vip)?$/i, /^openai\/gpt-image-2-developer\/edit$/i, /^openai\/gpt-image-2\/edit$/i, /^bytedance\/seedream-v5(?:\.0)?-pro\/edit$/i, /^qwen\/qwen-image-2(?:\.0)?-pro\/edit$/i, /gpt-image.*\/edit/i, /seedream.*\/edit/i, /qwen-image.*\/edit/i, /(?:^|[\s/_-])edit(?:$|[\s/_-])/i, ]; const PROVIDER_PREFERENCE = new Map([ ["vip98k", 30], ["atlascloud", 25], ["openai-image", 20], ["volcano-vision", 15], ["snapgen-image", 10], ]); function readPositiveNumber(source: unknown, keys: string[]) { if (!source || typeof source !== "object") return null; const record = source as Record; for (const key of keys) { const parsed = Number(record[key]); if (Number.isFinite(parsed) && parsed > 0) return parsed; } return null; } function getConfiguredReferenceImageLimit(model: LayoutReplaceModelDescriptor) { return ( readPositiveNumber(model.imageGenConfig, [ "maxReferenceImages", "maxInputImages", ]) || readPositiveNumber(model.capabilities, [ "maxReferenceImages", "maxInputImages", ]) ); } function getModelDescriptor(candidate: CanvasLayoutReplaceModelCandidate) { return JSON.stringify([ candidate.model.modelId, candidate.model.displayName, candidate.model.capabilities, candidate.model.imageGenConfig, ]).toLowerCase(); } export function isCanvasLayoutReplaceModelEligible( candidate: CanvasLayoutReplaceModelCandidate, ) { if (String(candidate.provider.type || "").trim() === "fal-image") return false; if (!candidate.model.supportsImageGen || candidate.model.isSubModel) return false; if (candidate.model.supportsVision === false) return false; if (candidate.model.healthStatus === "auto_disabled") return false; const referenceImageLimit = getConfiguredReferenceImageLimit(candidate.model); // supportsVision alone does not mean that the image-generation invocation // accepts two inputs. Unknown/single-image limits cannot preserve both roles. return referenceImageLimit !== null && referenceImageLimit >= 2; } function getCanvasLayoutReplaceModelScore( candidate: CanvasLayoutReplaceModelCandidate, ) { const modelId = String(candidate.model.modelId || "").trim(); const descriptor = getModelDescriptor(candidate); const preferenceIndex = MODEL_PREFERENCE_PATTERNS.findIndex( (pattern) => pattern.test(modelId) || pattern.test(descriptor), ); const modelScore = preferenceIndex >= 0 ? (MODEL_PREFERENCE_PATTERNS.length - preferenceIndex) * 100 : 0; // A preferred model alias on a degraded account must not outrank a healthy // multi-image editor (e.g. an otherwise preferred but depleted reseller). const healthScore = candidate.model.healthStatus === "healthy" ? 2000 : 0; const providerScore = PROVIDER_PREFERENCE.get(candidate.provider.type || "") || 0; const defaultScore = candidate.model.isDefault ? 5 : 0; return modelScore + healthScore + providerScore + defaultScore; } export function rankCanvasLayoutReplaceModels( models: CanvasLayoutReplaceModelCandidate[], requestedModel?: string | null, ) { const eligible = models.filter(isCanvasLayoutReplaceModelEligible); const requested = String(requestedModel || "").trim(); return eligible.sort((left, right) => { if (requested && requested !== "auto") { const leftRequested = left.model.id === requested || left.model.modelId === requested; const rightRequested = right.model.id === requested || right.model.modelId === requested; if (leftRequested !== rightRequested) return leftRequested ? -1 : 1; } const scoreDifference = getCanvasLayoutReplaceModelScore(right) - getCanvasLayoutReplaceModelScore(left); if (scoreDifference !== 0) return scoreDifference; return left.model.id.localeCompare(right.model.id); }); } export function buildCanvasLayoutReplacePrompt(params: { referenceHeight?: number; referenceWidth?: number; sourceProductAspectRatio?: number; supplementalPrompt?: string; }) { const geometry = params.referenceWidth && params.referenceHeight ? `${params.referenceWidth}x${params.referenceHeight}` : "与图1完全相同"; const supplement = String(params.supplementalPrompt || "").trim(); const sourceProductAspectRatio = Number.isFinite(params.sourceProductAspectRatio) && Number(params.sourceProductAspectRatio) > 0 ? Number(params.sourceProductAspectRatio) : null; const sourceProductRatioConstraint = sourceProductAspectRatio ? `【新商品轮廓】图2透明前景测得的商品外框宽高比约为 ${sourceProductAspectRatio.toFixed(3)}:1,只允许整体等比缩放与平移,不得改变各部件的比例。` : ""; return [ "执行商品模版替换,最终只输出一张完整成图。", "【图片角色与方向】图1是目标模版底图,是最终场景、版式、文字和构图的唯一依据。图2是新商品身份图,只提供需要放入模版的新商品。必须在图1中换入图2商品,绝对不能把图1的旧商品搬入图2的背景。", "【编辑底图】以图1作为实际编辑底图,识别并移除图1的旧商品,在对应位置放入图2的新商品;不使用图2的背景、房间、墙面、地面、装饰、文字或水印。图2即使是有背景的实拍图,也必须结合图1旧商品的类别,定位图2对应的新商品主体,不能把源图整个场景或无关物件当成新商品。", "【商品身份】忠实保留图2商品的外形、颜色、材质、部件数量、分区和相对比例,不得把图1旧商品的款式、配色或结构混入新商品。按实际图片识别商品,不能凭空增加抽屉、门、腿或其他零件。商品完整出现一次,不裁切、不拼接、不复制。", sourceProductRatioConstraint, "【模版版式和原有文字】保留图1原有标题、品牌、价格、标签、卖点、图标和其他可见文案,以及它们的内容、字体风格、位置和层级。只替换商品;不能把电商海报变成无字场景照片。图1没有文字时不新增文字。模版中其他独立物件和背景保留原有位置及遮挡关系。", `【画布与融合】输出画布为 ${geometry},宽高比和图1一致。将新商品整体等比缩放、放置在模版中合理的商品位置,保持真实结构;只调整必要的接触阴影、环境色和光照以融入图1,不重构整个场景。`, supplement ? `【用户补充】${supplement}。补充仅约束商品替换细节,不改变两张图的职责、商品身份或模版版式。` : "", "【输出前检查】确认输出沿用了图1的模版,而商品确实来自图2;检查商品配色、结构和部件,检查原有文案与布局是否保留。只输出最终成图,不输出并排对比、拼图、多方案、说明文字、新增边框或水印。", ].filter(Boolean).join("\n\n"); }