167 lines
7.9 KiB
TypeScript
167 lines
7.9 KiB
TypeScript
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;
|
||
sourceProductIdentity?: string;
|
||
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,
|
||
params.sourceProductIdentity ? `【新商品结构核对线索】${params.sourceProductIdentity.slice(0, 600)}。这是对图2的辅助识别,不是额外的改款要求;任何描述或数量与图2冲突时,始终以图2原图为准,不得按描述重做商品。不能继承图1的腿、底座、抽屉或其他部件;图2无腿脚时绝不能新增腿脚,保留原有贴地或悬挂方式。` : "",
|
||
"【模版版式和原有文字】保留图1原有标题、品牌、价格、标签、卖点、图标和其他可见文案,以及它们的内容、字体风格、位置和层级。只替换商品;不能把电商海报变成无字场景照片。图1没有文字时不新增文字。模版中其他独立物件和背景保留原有位置及遮挡关系。",
|
||
`【画布与融合】输出画布为 ${geometry},宽高比和图1一致。将新商品整体等比缩放、放置在模版中合理的商品位置,保持真实结构;只调整必要的接触阴影、环境色和光照以融入图1,不重构整个场景。`,
|
||
supplement ? `【用户补充】${supplement}。补充仅约束商品替换细节,不改变两张图的职责、商品身份或模版版式。` : "",
|
||
"【输出前检查】确认输出沿用了图1的模版,而商品确实来自图2;检查商品配色、结构和部件,检查原有文案与布局是否保留。只输出最终成图,不输出并排对比、拼图、多方案、说明文字、新增边框或水印。",
|
||
].filter(Boolean).join("\n\n");
|
||
}
|