191 lines
9.7 KiB
TypeScript
191 lines
9.7 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,
|
||
/^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");
|
||
}
|