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, /^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); 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; const healthScore = candidate.model.healthStatus === "healthy" ? 50 : 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}` : "与图2完全相同"; const supplement = String(params.supplementalPrompt || "").trim(); const sourceProductAspectRatio = Number.isFinite(params.sourceProductAspectRatio) && Number(params.sourceProductAspectRatio) > 0 ? Number(params.sourceProductAspectRatio) : null; const sourceProductRatioConstraint = sourceProductAspectRatio ? `【商品外框比例硬约束】图1透明轮廓测得商品有效外框宽高比为 ${sourceProductAspectRatio.toFixed(3)}:1。结果中完整商品外框的宽高比必须保持在 ${(sourceProductAspectRatio * 0.96).toFixed(3)} 至 ${(sourceProductAspectRatio * 1.04).toFixed(3)} 之间。若与图2原商品占位冲突,应缩短商品整体高度或扩大整体宽度,绝对不能压窄商品宽度或纵向拉长。生成前必须测量外框复核。` : ""; const targetCanvasAspectRatio = params.referenceWidth && params.referenceHeight ? params.referenceWidth / params.referenceHeight : null; const targetProductHeightCoverage = 0.76; const targetProductWidthCoverage = sourceProductAspectRatio && targetCanvasAspectRatio ? Math.max( 0.52, Math.min( 0.86, (sourceProductAspectRatio / targetCanvasAspectRatio) * targetProductHeightCoverage, ), ) : null; const targetProductLeftEdge = targetProductWidthCoverage ? (1 - targetProductWidthCoverage) / 2 : null; const targetProductRightEdge = targetProductLeftEdge ? 1 - targetProductLeftEdge : null; const targetProductCoverageConstraint = targetProductWidthCoverage ? `【商品画布占比硬约束】新商品完整外框应占最终画布宽度约 ${Math.round(targetProductWidthCoverage * 100)}%,允许范围 ${Math.round((targetProductWidthCoverage - 0.03) * 100)}% 至 ${Math.round((targetProductWidthCoverage + 0.03) * 100)}%;左外沿应到达画布 x≈${Math.round(Number(targetProductLeftEdge) * 100)}% 处,右外沿应到达 x≈${Math.round(Number(targetProductRightEdge) * 100)}% 处,两侧都不得向中间收窄;外框高度约占画布 ${Math.round(targetProductHeightCoverage * 100)}%。图2旧商品原本偏窄的占位不得继承,这是唯一允许突破图2原商品边界的地方。扩大新商品时,边柜、台灯、花瓶、摆件和椅子保持原位,并继自然遮挡在新商品前方。` : ""; return [ "执行单一商品换模版任务。图1是新商品身份图,图2是目标场景与构图模版,最终只输出一张完整成图。", "【两图职责】以图2为最终画面底图和唯一场景真值;识别并完整移除图2中的原商品,只在原商品占据的合理空间内放入图1的新商品。图1只定义新商品本身,不得把图1的纯色/透明背景带入结果。", "【商品刚性身份锁定,最高优先级】把图1商品视为已经抠好的真实商品像素层,优先完整复用,不要重新绘制或重新设计。只允许对整个商品做等比缩放和整体平移;禁止透视拉伸、视角重构、横向或纵向拉伸、压扁、加宽、缩短、局部缩放、局部重绘、拼接、融合旧商品、改款、简化或增删部件。宁可保留图1的原始轻微侧视角,也不得把它强行改成正视图。", "【结构逐项核对】结果必须保持图1商品的完整外轮廓和长宽高比例,以及每块板件、柜门、玻璃、滑轨、把手、层板、洞洞板、抽屉、圆形拉手、桌面、横梁和四条桌腿的数量、形状、位置、尺寸关系、颜色及材质。尤其必须保留桌面下左侧带圆形拉手的窄抽屉及其右侧抽屉分区,不得合并成两个大抽屉。商品完整出现一次,不得裁切、复制或被旧商品残影穿插。", sourceProductRatioConstraint, targetProductCoverageConstraint, "【场景与前景物件锁定】逐像素保留图2的相机机位、画幅、墙面、地面、边柜、台灯、花瓶、椅子、光影和空间关系,也必须保留兔子摆件、书、玩偶、相框、杯子及桌面小物的原样、数量和位置。先把这些独立前景物件与椅子视为不可编辑覆盖层,只移除其后方的旧商品,再让它们按原遮挡关系覆盖到新商品前方;不得把摆件当作旧商品的一部分清除。", `【尺寸与放置】输出画布为 ${geometry}。根据图2原商品的地面接触点和整体占位,对图1商品只做统一等比缩放与整体平移;不做局部透视变形。宁可让边缘占位略有差异,也要优先保持图1商品的真实比例和结构。`, "【融合要求】清除图2旧商品后补全被遮挡背景,再放入图1商品;添加与图2一致的自然接触阴影、环境反射、景深和色温,但这些融合不得改变商品结构、材质纹理或深浅配色。", supplement ? `【用户补充】${supplement}。补充要求只能约束替换细节,不得放宽上述商品刚性身份锁定与场景锁定。` : "", "【生成前自检】逐项比较图1与结果中的商品轮廓、比例、柜体分区、孔洞、门轨、抽屉和腿;任一结构发生拉伸、缺失、增生、错位或与旧商品融合,都必须先修正再输出。不要输出对比图、拼图、说明文字、边框、水印或多个方案。", ] .filter(Boolean) .join("\n\n"); }