From 0c919e851a76b94ca57c19d2b55c56815313e2c8 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 2 Jul 2026 00:11:25 +0800 Subject: [PATCH] fix: prefer low-cost scale adjust providers --- app/api/canvas/local-edit/route.ts | 193 +++++++++++++++++++++++++---- lib/canvas/generate-image.ts | 67 ++++++++-- 2 files changed, 229 insertions(+), 31 deletions(-) diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index e6d5f054e..ca9935cec 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -232,10 +232,110 @@ function isScaleAdjustDedicatedDetailModel( 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 ) { - 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: { @@ -247,19 +347,48 @@ function resolveScaleAdjustPreferredModelId(params: { params.models, params.requestedModelId ); - if (isScaleAdjustDedicatedDetailModel(requestedModel)) { - return requestedModel?.model.id || params.requestedModelId; - } - const configuredModel = findModelSelectionEntry( params.models, 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)) { return configuredModel?.model.id || params.configuredModelId; } - const autoDetailModel = params.models.find(isScaleAdjustDedicatedDetailModel); if (autoDetailModel) { return autoDetailModel.model.id; } @@ -969,7 +1098,7 @@ function buildScaleAdjustPrompt( ].join("\n"); } -function buildFalScaleAdjustCoordinatePrompt( +function buildScaleAdjustCoordinatePrompt( regions: NormalizedLocalEditRegion[], scalePercent: ScaleAdjustPercent | null | undefined, sourceSize: { width: number; height: number }, @@ -1042,7 +1171,7 @@ function buildFalScaleAdjustCoordinatePrompt( ].join("\n"); } -function buildFalScaleAdjustSystemPrompt() { +function buildScaleAdjustCoordinateSystemPrompt() { return [ "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.", @@ -1213,8 +1342,12 @@ export async function POST(request: NextRequest) { const resolvedScaleAdjustModelEntry = isScaleAdjustOperation ? findModelSelectionEntry(scaleAdjustModelCandidates, resolvedModel || "") : null; - const useFalScaleAdjustCoordinateGuide = - isScaleAdjustOperation && isFalImageModelEntry(resolvedScaleAdjustModelEntry); + const useScaleAdjustCoordinateGuide = + isScaleAdjustOperation && + isScaleAdjustCoordinateGuideModel(resolvedScaleAdjustModelEntry); + const scaleAdjustModelFamily = getScaleAdjustModelFamily( + resolvedScaleAdjustModelEntry + ); const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" }) .composite([ { @@ -1252,8 +1385,8 @@ export async function POST(request: NextRequest) { }) : []; const prompt = isScaleAdjustOperation - ? useFalScaleAdjustCoordinateGuide - ? buildFalScaleAdjustCoordinatePrompt( + ? useScaleAdjustCoordinateGuide + ? buildScaleAdjustCoordinatePrompt( regions, scalePercent, { @@ -1269,7 +1402,7 @@ export async function POST(request: NextRequest) { { width: sourceWidth, height: sourceHeight } ) : 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"; const manualRegionReferenceImageUrls = regions.flatMap((region) => region.referenceImages.map((reference) => reference.url) @@ -1290,7 +1423,7 @@ export async function POST(request: NextRequest) { : []; const scaleAdjustVerificationConfig = isScaleAdjustOperation ? { - allowOutsideChangedWhenChangedInside: useFalScaleAdjustCoordinateGuide + allowOutsideChangedWhenChangedInside: useScaleAdjustCoordinateGuide ? true : undefined, protectedRegions: scaleAdjustCompositeRegions, @@ -1335,7 +1468,18 @@ export async function POST(request: NextRequest) { resolvedModel: resolvedModel || null, resolvedScaleAdjustProviderType: 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, regionReferenceImageCount: referenceImages.length, @@ -1348,8 +1492,8 @@ export async function POST(request: NextRequest) { scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect) .length, scaleAdjustPromptStrategy: isScaleAdjustOperation - ? useFalScaleAdjustCoordinateGuide - ? "fal-locked-source-neutral-coordinate-map-v29-bilingual-high-thinking" + ? useScaleAdjustCoordinateGuide + ? "same-name-locked-source-neutral-coordinate-map-v30-bilingual-fal-last" : "locked-plate-neutral-coordinate-guide-map-pixel-anchors-v8" : undefined, scaleAdjustPostCompositeApplied: false, @@ -1359,11 +1503,12 @@ export async function POST(request: NextRequest) { sourceWidth, }, displayPrompt, - falSafetyTolerance: useFalScaleAdjustCoordinateGuide ? "6" : undefined, + falSafetyTolerance: useScaleAdjustCoordinateGuide ? "6" : undefined, falSystemPrompt: - useFalScaleAdjustCoordinateGuide ? buildFalScaleAdjustSystemPrompt() : undefined, - falThinkingLevel: useFalScaleAdjustCoordinateGuide ? "high" : undefined, - allowFailover: useFalScaleAdjustCoordinateGuide ? false : undefined, + useScaleAdjustCoordinateGuide + ? buildScaleAdjustCoordinateSystemPrompt() + : undefined, + falThinkingLevel: useScaleAdjustCoordinateGuide ? "high" : undefined, imageCount: 1, imageSize: typeof body.imageSize === "string" && body.imageSize.trim() @@ -1372,8 +1517,8 @@ export async function POST(request: NextRequest) { model: resolvedModel || undefined, negativePrompt: isScaleAdjustOperation - ? useFalScaleAdjustCoordinateGuide - ? falScaleAdjustNegativePrompt + ? useScaleAdjustCoordinateGuide + ? scaleAdjustCoordinateNegativePrompt : "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、箭头、连线、角点、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、蓝色色块、色条、灰底坐标图、选区边框、白边、画幅压扁、画面拉伸、整张图缩放、全图放大、全图裁切、全图平移、整体重绘、删除文字、清理文字、改写文字、文字模糊、Logo消失、主体拉伸、主体压扁、主体变窄、主体变高但变窄、横向挤压、纵向拉伸、透视变形、矩形贴片、硬边、拼接线、补丁感、局部裁切、色差边缘、背景断裂、背景错位、窗框错位、文字错位、地毯纹理错位、非主体背景重绘、主体未缩放、整块框选区域缩放" : "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、动物截断、缺头、缺尾巴、缺四肢、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位", prompt, diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 03260bc5f..9d284e099 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -1589,6 +1589,30 @@ type CanvasModelWithProvider = Awaited< type CanvasImageSizeValue = string; 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 { maxImages?: number; supports1K?: boolean; @@ -1750,6 +1774,7 @@ function findCanvasFailoverModel(params: { selectedModel: CanvasModelWithProvider; imageModels: CanvasModelWithProvider[]; failedModelRefs: string[]; + preferLowCostSameNameProviders?: boolean; requiresImageInput?: boolean; }) { const failedRefs = new Set( @@ -1777,13 +1802,15 @@ function findCanvasFailoverModel(params: { return getCanvasModelRefKeys(item).every((key) => !failedRefs.has(key)); }; - for (const fallbackRef of selectedFallbackRefs) { - const configured = params.imageModels.find( - (item) => - isUsableFallback(item) && - (item.model.id === fallbackRef || item.model.modelId === fallbackRef) - ); - if (configured) return configured; + if (!params.preferLowCostSameNameProviders) { + for (const fallbackRef of selectedFallbackRefs) { + const configured = params.imageModels.find( + (item) => + isUsableFallback(item) && + (item.model.id === fallbackRef || item.model.modelId === fallbackRef) + ); + if (configured) return configured; + } } const usableModels = params.imageModels.filter(isUsableFallback); @@ -1800,6 +1827,26 @@ function findCanvasFailoverModel(params: { const rightSameName = right.nameSimilarity >= CANVAS_FAILOVER_SAME_NAME_THRESHOLD; 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) { return left.sameProviderType ? -1 : 1; } @@ -1900,6 +1947,9 @@ export async function generateCanvasImage( selectedModel, imageModels, failedModelRefs: getCanvasModelRefKeys(selectedModel), + preferLowCostSameNameProviders: Boolean( + debugMeta?.scaleAdjustPreferSameNameProviderFailover + ), requiresImageInput, }); @@ -2499,6 +2549,9 @@ export async function generateCanvasImage( selectedModel, imageModels, failedModelRefs, + preferLowCostSameNameProviders: Boolean( + debugMeta?.scaleAdjustPreferSameNameProviderFailover + ), requiresImageInput: normalizedReferenceImages.length > 0, }) : null;