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

165 lines
7.4 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,
/^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<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);
// 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");
}