fix: prefer low-cost scale adjust providers

This commit is contained in:
134 2026-07-02 00:11:25 +08:00
parent f5a409fdde
commit 0c919e851a
2 changed files with 229 additions and 31 deletions

View File

@ -232,10 +232,110 @@ function isScaleAdjustDedicatedDetailModel(
return label.includes("详情专线"); return label.includes("详情专线");
} }
function isFalImageModelEntry( function normalizeScaleAdjustModelText(input: unknown) {
return String(input || "")
.trim()
.toLowerCase()
.normalize("NFKC")
.replace(/[「」『』【】[\]()(){}]/g, " ")
.replace(/[_/|::,,.。+]+/g, "-")
.replace(/\s+/g, " ");
}
function resolveScaleAdjustModelFamilyFromText(input: unknown) {
const normalized = normalizeScaleAdjustModelText(input);
const compact = normalized.replace(/[^a-z0-9]+/g, "");
if (!normalized && !compact) return "";
if (
normalized.includes("gemini-3.1-flash-image-preview") ||
normalized.includes("nano-banana-2") ||
compact.includes("nanobanana2")
) {
return "nano-banana-2";
}
if (
normalized.includes("gemini-3-pro-image-preview") ||
normalized.includes("nano-banana-pro") ||
compact.includes("nanobananapro")
) {
return "nano-banana-pro";
}
if (normalized.includes("gpt-image-2") || compact.includes("gptimage2")) {
return "gpt-image-2";
}
return "";
}
function getScaleAdjustModelFamily(
entry: AiModelWithProviderForSelection | null | undefined entry: AiModelWithProviderForSelection | null | undefined
) { ) {
return entry?.provider?.type === "fal-image"; if (!entry) return "";
return (
resolveScaleAdjustModelFamilyFromText(entry.model.modelId) ||
resolveScaleAdjustModelFamilyFromText(entry.model.displayName) ||
resolveScaleAdjustModelFamilyFromText(entry.model.id)
);
}
function isScaleAdjustCoordinateGuideModel(
entry: AiModelWithProviderForSelection | null | undefined
) {
return getScaleAdjustModelFamily(entry) === "nano-banana-2";
}
function getScaleAdjustProviderPriority(providerType: string) {
switch (providerType) {
case "ruxa-image":
return 10;
case "snapgen-image":
return 20;
case "krea":
return 30;
case "gemini":
case "grsai-gpt-image":
return 40;
case "alibaba-image":
case "bfl-image":
case "openai-image":
case "stepfun-image":
case "volcano-vision":
return 60;
case "fal-image":
return 1000;
default:
return 80;
}
}
function isScaleAdjustUsableImageEditModel(
entry: AiModelWithProviderForSelection | null | undefined
) {
return Boolean(
entry?.model?.supportsImageGen &&
!modelLacksReferenceImageInputSupport(entry.model, entry.provider)
);
}
function compareScaleAdjustProviderCandidates(
left: AiModelWithProviderForSelection,
right: AiModelWithProviderForSelection,
preferredModelRef: string
) {
const leftPriority = getScaleAdjustProviderPriority(left.provider.type);
const rightPriority = getScaleAdjustProviderPriority(right.provider.type);
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
const leftDedicated = isScaleAdjustDedicatedDetailModel(left);
const rightDedicated = isScaleAdjustDedicatedDetailModel(right);
if (leftDedicated !== rightDedicated) return leftDedicated ? -1 : 1;
const leftExact =
left.model.id === preferredModelRef || left.model.modelId === preferredModelRef;
const rightExact =
right.model.id === preferredModelRef || right.model.modelId === preferredModelRef;
if (leftExact !== rightExact) return leftExact ? -1 : 1;
return 0;
} }
function resolveScaleAdjustPreferredModelId(params: { function resolveScaleAdjustPreferredModelId(params: {
@ -247,19 +347,48 @@ function resolveScaleAdjustPreferredModelId(params: {
params.models, params.models,
params.requestedModelId params.requestedModelId
); );
if (isScaleAdjustDedicatedDetailModel(requestedModel)) {
return requestedModel?.model.id || params.requestedModelId;
}
const configuredModel = findModelSelectionEntry( const configuredModel = findModelSelectionEntry(
params.models, params.models,
params.configuredModelId params.configuredModelId
); );
const autoDetailModel = params.models.find(isScaleAdjustDedicatedDetailModel);
const preferredModel =
(isScaleAdjustDedicatedDetailModel(requestedModel) ? requestedModel : null) ||
(isScaleAdjustDedicatedDetailModel(configuredModel) ? configuredModel : null) ||
autoDetailModel ||
configuredModel ||
requestedModel ||
null;
const preferredModelRef =
preferredModel?.model.id ||
preferredModel?.model.modelId ||
params.configuredModelId ||
params.requestedModelId;
const preferredFamily =
getScaleAdjustModelFamily(preferredModel) ||
resolveScaleAdjustModelFamilyFromText(params.configuredModelId) ||
resolveScaleAdjustModelFamilyFromText(params.requestedModelId);
if (preferredFamily) {
const sameFamilyCandidates = params.models
.filter((entry) => isScaleAdjustUsableImageEditModel(entry))
.filter((entry) => getScaleAdjustModelFamily(entry) === preferredFamily)
.sort((left, right) =>
compareScaleAdjustProviderCandidates(left, right, preferredModelRef)
);
if (sameFamilyCandidates[0]) {
return sameFamilyCandidates[0].model.id;
}
}
if (isScaleAdjustDedicatedDetailModel(requestedModel)) {
return requestedModel?.model.id || params.requestedModelId;
}
if (isScaleAdjustDedicatedDetailModel(configuredModel)) { if (isScaleAdjustDedicatedDetailModel(configuredModel)) {
return configuredModel?.model.id || params.configuredModelId; return configuredModel?.model.id || params.configuredModelId;
} }
const autoDetailModel = params.models.find(isScaleAdjustDedicatedDetailModel);
if (autoDetailModel) { if (autoDetailModel) {
return autoDetailModel.model.id; return autoDetailModel.model.id;
} }
@ -969,7 +1098,7 @@ function buildScaleAdjustPrompt(
].join("\n"); ].join("\n");
} }
function buildFalScaleAdjustCoordinatePrompt( function buildScaleAdjustCoordinatePrompt(
regions: NormalizedLocalEditRegion[], regions: NormalizedLocalEditRegion[],
scalePercent: ScaleAdjustPercent | null | undefined, scalePercent: ScaleAdjustPercent | null | undefined,
sourceSize: { width: number; height: number }, sourceSize: { width: number; height: number },
@ -1042,7 +1171,7 @@ function buildFalScaleAdjustCoordinatePrompt(
].join("\n"); ].join("\n");
} }
function buildFalScaleAdjustSystemPrompt() { function buildScaleAdjustCoordinateSystemPrompt() {
return [ return [
"You are an image editing model for local furniture scale adjustment.", "You are an image editing model for local furniture scale adjustment.",
"Input image 1 is the only final photo base. Input image 2 is a coordinate instruction map, not a photo layer.", "Input image 1 is the only final photo base. Input image 2 is a coordinate instruction map, not a photo layer.",
@ -1213,8 +1342,12 @@ export async function POST(request: NextRequest) {
const resolvedScaleAdjustModelEntry = isScaleAdjustOperation const resolvedScaleAdjustModelEntry = isScaleAdjustOperation
? findModelSelectionEntry(scaleAdjustModelCandidates, resolvedModel || "") ? findModelSelectionEntry(scaleAdjustModelCandidates, resolvedModel || "")
: null; : null;
const useFalScaleAdjustCoordinateGuide = const useScaleAdjustCoordinateGuide =
isScaleAdjustOperation && isFalImageModelEntry(resolvedScaleAdjustModelEntry); isScaleAdjustOperation &&
isScaleAdjustCoordinateGuideModel(resolvedScaleAdjustModelEntry);
const scaleAdjustModelFamily = getScaleAdjustModelFamily(
resolvedScaleAdjustModelEntry
);
const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" }) const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" })
.composite([ .composite([
{ {
@ -1252,8 +1385,8 @@ export async function POST(request: NextRequest) {
}) })
: []; : [];
const prompt = isScaleAdjustOperation const prompt = isScaleAdjustOperation
? useFalScaleAdjustCoordinateGuide ? useScaleAdjustCoordinateGuide
? buildFalScaleAdjustCoordinatePrompt( ? buildScaleAdjustCoordinatePrompt(
regions, regions,
scalePercent, scalePercent,
{ {
@ -1269,7 +1402,7 @@ export async function POST(request: NextRequest) {
{ width: sourceWidth, height: sourceHeight } { width: sourceWidth, height: sourceHeight }
) )
: buildLocalEditPrompt(regions); : buildLocalEditPrompt(regions);
const falScaleAdjustNegativePrompt = const scaleAdjustCoordinateNegativePrompt =
"visible annotation, guide overlay, translucent overlay, text label, arrows, boxes, rectangle border, UI marks, pasted patch, seam, hard rectangular edge, color mismatch, different-resolution area, whole image zoom, whole image crop, whole image pan, whole image redesign, unchanged target furniture, stretched furniture, squashed furniture, duplicate furniture, second copy, extra legs, extra rails, side panels, shelves, cabinets, lattice panels"; "visible annotation, guide overlay, translucent overlay, text label, arrows, boxes, rectangle border, UI marks, pasted patch, seam, hard rectangular edge, color mismatch, different-resolution area, whole image zoom, whole image crop, whole image pan, whole image redesign, unchanged target furniture, stretched furniture, squashed furniture, duplicate furniture, second copy, extra legs, extra rails, side panels, shelves, cabinets, lattice panels";
const manualRegionReferenceImageUrls = regions.flatMap((region) => const manualRegionReferenceImageUrls = regions.flatMap((region) =>
region.referenceImages.map((reference) => reference.url) region.referenceImages.map((reference) => reference.url)
@ -1290,7 +1423,7 @@ export async function POST(request: NextRequest) {
: []; : [];
const scaleAdjustVerificationConfig = isScaleAdjustOperation const scaleAdjustVerificationConfig = isScaleAdjustOperation
? { ? {
allowOutsideChangedWhenChangedInside: useFalScaleAdjustCoordinateGuide allowOutsideChangedWhenChangedInside: useScaleAdjustCoordinateGuide
? true ? true
: undefined, : undefined,
protectedRegions: scaleAdjustCompositeRegions, protectedRegions: scaleAdjustCompositeRegions,
@ -1335,7 +1468,18 @@ export async function POST(request: NextRequest) {
resolvedModel: resolvedModel || null, resolvedModel: resolvedModel || null,
resolvedScaleAdjustProviderType: resolvedScaleAdjustProviderType:
resolvedScaleAdjustModelEntry?.provider?.type || null, resolvedScaleAdjustModelEntry?.provider?.type || null,
falScaleAdjustCoordinateGuideApplied: useFalScaleAdjustCoordinateGuide, scaleAdjustModelFamily: scaleAdjustModelFamily || null,
scaleAdjustCoordinateGuideApplied: useScaleAdjustCoordinateGuide,
falScaleAdjustCoordinateGuideApplied:
useScaleAdjustCoordinateGuide &&
resolvedScaleAdjustModelEntry?.provider?.type === "fal-image",
scaleAdjustProviderFallbackPolicy: isScaleAdjustOperation
? scaleAdjustModelFamily
? "same-name-low-cost-first-fal-last"
: "default-model-failover"
: undefined,
scaleAdjustPreferSameNameProviderFailover:
isScaleAdjustOperation && Boolean(scaleAdjustModelFamily),
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length, manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
regionReferenceImageCount: regionReferenceImageCount:
referenceImages.length, referenceImages.length,
@ -1348,8 +1492,8 @@ export async function POST(request: NextRequest) {
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect) scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
.length, .length,
scaleAdjustPromptStrategy: isScaleAdjustOperation scaleAdjustPromptStrategy: isScaleAdjustOperation
? useFalScaleAdjustCoordinateGuide ? useScaleAdjustCoordinateGuide
? "fal-locked-source-neutral-coordinate-map-v29-bilingual-high-thinking" ? "same-name-locked-source-neutral-coordinate-map-v30-bilingual-fal-last"
: "locked-plate-neutral-coordinate-guide-map-pixel-anchors-v8" : "locked-plate-neutral-coordinate-guide-map-pixel-anchors-v8"
: undefined, : undefined,
scaleAdjustPostCompositeApplied: false, scaleAdjustPostCompositeApplied: false,
@ -1359,11 +1503,12 @@ export async function POST(request: NextRequest) {
sourceWidth, sourceWidth,
}, },
displayPrompt, displayPrompt,
falSafetyTolerance: useFalScaleAdjustCoordinateGuide ? "6" : undefined, falSafetyTolerance: useScaleAdjustCoordinateGuide ? "6" : undefined,
falSystemPrompt: falSystemPrompt:
useFalScaleAdjustCoordinateGuide ? buildFalScaleAdjustSystemPrompt() : undefined, useScaleAdjustCoordinateGuide
falThinkingLevel: useFalScaleAdjustCoordinateGuide ? "high" : undefined, ? buildScaleAdjustCoordinateSystemPrompt()
allowFailover: useFalScaleAdjustCoordinateGuide ? false : undefined, : undefined,
falThinkingLevel: useScaleAdjustCoordinateGuide ? "high" : undefined,
imageCount: 1, imageCount: 1,
imageSize: imageSize:
typeof body.imageSize === "string" && body.imageSize.trim() typeof body.imageSize === "string" && body.imageSize.trim()
@ -1372,8 +1517,8 @@ export async function POST(request: NextRequest) {
model: resolvedModel || undefined, model: resolvedModel || undefined,
negativePrompt: negativePrompt:
isScaleAdjustOperation isScaleAdjustOperation
? useFalScaleAdjustCoordinateGuide ? useScaleAdjustCoordinateGuide
? falScaleAdjustNegativePrompt ? scaleAdjustCoordinateNegativePrompt
: "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、箭头、连线、角点、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、蓝色色块、色条、灰底坐标图、选区边框、白边、画幅压扁、画面拉伸、整张图缩放、全图放大、全图裁切、全图平移、整体重绘、删除文字、清理文字、改写文字、文字模糊、Logo消失、主体拉伸、主体压扁、主体变窄、主体变高但变窄、横向挤压、纵向拉伸、透视变形、矩形贴片、硬边、拼接线、补丁感、局部裁切、色差边缘、背景断裂、背景错位、窗框错位、文字错位、地毯纹理错位、非主体背景重绘、主体未缩放、整块框选区域缩放" : "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、箭头、连线、角点、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、蓝色色块、色条、灰底坐标图、选区边框、白边、画幅压扁、画面拉伸、整张图缩放、全图放大、全图裁切、全图平移、整体重绘、删除文字、清理文字、改写文字、文字模糊、Logo消失、主体拉伸、主体压扁、主体变窄、主体变高但变窄、横向挤压、纵向拉伸、透视变形、矩形贴片、硬边、拼接线、补丁感、局部裁切、色差边缘、背景断裂、背景错位、窗框错位、文字错位、地毯纹理错位、非主体背景重绘、主体未缩放、整块框选区域缩放"
: "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、动物截断、缺头、缺尾巴、缺四肢、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位", : "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、动物截断、缺头、缺尾巴、缺四肢、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
prompt, prompt,

View File

@ -1589,6 +1589,30 @@ type CanvasModelWithProvider = Awaited<
type CanvasImageSizeValue = string; type CanvasImageSizeValue = string;
const CANVAS_FAILOVER_SAME_NAME_THRESHOLD = 0.5; const CANVAS_FAILOVER_SAME_NAME_THRESHOLD = 0.5;
function getCanvasScaleAdjustProviderFailoverPriority(providerType: string) {
switch (providerType) {
case "ruxa-image":
return 10;
case "snapgen-image":
return 20;
case "krea":
return 30;
case "gemini":
case "grsai-gpt-image":
return 40;
case "alibaba-image":
case "bfl-image":
case "openai-image":
case "stepfun-image":
case "volcano-vision":
return 60;
case "fal-image":
return 1000;
default:
return 80;
}
}
interface CanvasImageGenConfig { interface CanvasImageGenConfig {
maxImages?: number; maxImages?: number;
supports1K?: boolean; supports1K?: boolean;
@ -1750,6 +1774,7 @@ function findCanvasFailoverModel(params: {
selectedModel: CanvasModelWithProvider; selectedModel: CanvasModelWithProvider;
imageModels: CanvasModelWithProvider[]; imageModels: CanvasModelWithProvider[];
failedModelRefs: string[]; failedModelRefs: string[];
preferLowCostSameNameProviders?: boolean;
requiresImageInput?: boolean; requiresImageInput?: boolean;
}) { }) {
const failedRefs = new Set( const failedRefs = new Set(
@ -1777,13 +1802,15 @@ function findCanvasFailoverModel(params: {
return getCanvasModelRefKeys(item).every((key) => !failedRefs.has(key)); return getCanvasModelRefKeys(item).every((key) => !failedRefs.has(key));
}; };
for (const fallbackRef of selectedFallbackRefs) { if (!params.preferLowCostSameNameProviders) {
const configured = params.imageModels.find( for (const fallbackRef of selectedFallbackRefs) {
(item) => const configured = params.imageModels.find(
isUsableFallback(item) && (item) =>
(item.model.id === fallbackRef || item.model.modelId === fallbackRef) isUsableFallback(item) &&
); (item.model.id === fallbackRef || item.model.modelId === fallbackRef)
if (configured) return configured; );
if (configured) return configured;
}
} }
const usableModels = params.imageModels.filter(isUsableFallback); const usableModels = params.imageModels.filter(isUsableFallback);
@ -1800,6 +1827,26 @@ function findCanvasFailoverModel(params: {
const rightSameName = const rightSameName =
right.nameSimilarity >= CANVAS_FAILOVER_SAME_NAME_THRESHOLD; right.nameSimilarity >= CANVAS_FAILOVER_SAME_NAME_THRESHOLD;
if (leftSameName !== rightSameName) return leftSameName ? -1 : 1; if (leftSameName !== rightSameName) return leftSameName ? -1 : 1;
if (
params.preferLowCostSameNameProviders &&
leftSameName &&
rightSameName
) {
const leftPriority = getCanvasScaleAdjustProviderFailoverPriority(
left.item.provider.type
);
const rightPriority = getCanvasScaleAdjustProviderFailoverPriority(
right.item.provider.type
);
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
if (right.nameSimilarity !== left.nameSimilarity) {
return right.nameSimilarity - left.nameSimilarity;
}
if (left.sameProviderType !== right.sameProviderType) {
return left.sameProviderType ? -1 : 1;
}
return left.index - right.index;
}
if (left.sameProviderType !== right.sameProviderType) { if (left.sameProviderType !== right.sameProviderType) {
return left.sameProviderType ? -1 : 1; return left.sameProviderType ? -1 : 1;
} }
@ -1900,6 +1947,9 @@ export async function generateCanvasImage(
selectedModel, selectedModel,
imageModels, imageModels,
failedModelRefs: getCanvasModelRefKeys(selectedModel), failedModelRefs: getCanvasModelRefKeys(selectedModel),
preferLowCostSameNameProviders: Boolean(
debugMeta?.scaleAdjustPreferSameNameProviderFailover
),
requiresImageInput, requiresImageInput,
}); });
@ -2499,6 +2549,9 @@ export async function generateCanvasImage(
selectedModel, selectedModel,
imageModels, imageModels,
failedModelRefs, failedModelRefs,
preferLowCostSameNameProviders: Boolean(
debugMeta?.scaleAdjustPreferSameNameProviderFailover
),
requiresImageInput: normalizedReferenceImages.length > 0, requiresImageInput: normalizedReferenceImages.length > 0,
}) })
: null; : null;