ai-chatbot/lib/canvas/layout-replace-model-selection.ts

191 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
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");
}