diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index 0cf8f6f9d..9a4193877 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -9,9 +9,12 @@ import { getCanvasImageTaskForUser, } from "@/lib/canvas/image-task-queue"; import type { CanvasGenerateImageRequest } from "@/lib/canvas/generate-image"; +import { + buildScaleAdjustProductLockAssets, + type CanvasScaleAdjustIntentRegion, +} from "@/lib/canvas/scale-adjust-product-lock"; import { getPointsSystemConfig, - getSiteSettings, } from "@/lib/db/queries"; import { getImageToolCreditCost } from "@/lib/points/config"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; @@ -95,6 +98,7 @@ const MAX_LOCAL_EDIT_REGIONS = 8; const MAX_REFERENCE_IMAGES_PER_REGION = 3; const MIN_REGION_EDGE_PX = 24; const SCALE_ADJUST_OPERATION = "scale-adjust"; +const SCALE_ADJUST_REPAIR_MODEL_REF = "e03d3341-cecd-425e-b88b-9350cbbce18f"; type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION; const MIN_SCALE_ADJUST_PERCENT = -80; const MAX_SCALE_ADJUST_PERCENT = 200; @@ -518,60 +522,6 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) { ].join("\n"); } -function buildSimpleScaleAdjustPrompt( - regions: NormalizedLocalEditRegion[], - scalePercent: ScaleAdjustPercent | null | undefined, - sourceSize: { width: number; height: number } -) { - const regionLines = regions - .map((region) => { - const targetRect = getScaleAdjustTargetRect(region, scalePercent); - const targetPixelRect = - region.targetPixelRect || - rectToPixels(targetRect, sourceSize.width, sourceSize.height); - const sourceRight = region.pixelRect.left + region.pixelRect.width; - const sourceBottom = region.pixelRect.top + region.pixelRect.height; - const targetRight = targetPixelRect.left + targetPixelRect.width; - const targetBottom = targetPixelRect.top + targetPixelRect.height; - const widthFactor = - region.rect.width > 0 ? targetRect.width / region.rect.width : 1; - const heightFactor = - region.rect.height > 0 ? targetRect.height / region.rect.height : 1; - const moved = - Math.hypot( - targetRect.x + targetRect.width / 2 - (region.rect.x + region.rect.width / 2), - targetRect.y + targetRect.height / 2 - (region.rect.y + region.rect.height / 2) - ) > 0.01; - const action = - widthFactor < 0.92 || heightFactor < 0.92 - ? "把同一个主体等比缩小" - : widthFactor > 1.08 || heightFactor > 1.08 - ? "把同一个主体等比放大" - : "把同一个主体调整到目标位置和大小"; - return [ - `区域 ${region.index}:`, - `源框 A: left=${region.pixelRect.left}, top=${region.pixelRect.top}, right=${sourceRight}, bottom=${sourceBottom}, width=${region.pixelRect.width}, height=${region.pixelRect.height}.`, - `目标框 B: left=${targetPixelRect.left}, top=${targetPixelRect.top}, right=${targetRight}, bottom=${targetBottom}, width=${targetPixelRect.width}, height=${targetPixelRect.height}.`, - `目标比例约为宽 ${widthFactor.toFixed(2)}x、高 ${heightFactor.toFixed(2)}x。`, - `请识别 A 框内的主要商品/物体/人物主体,只${action},${moved ? "并移动到 B 框中心附近" : "保持在当前位置附近"}。`, - ].join(" "); - }) - .join("\n"); - - return [ - "对输入图片做一次简单的图生图局部比例编辑。", - `最终输出保持原图完整画幅和像素尺寸:${sourceSize.width} x ${sourceSize.height} px。`, - "只编辑用户框选的主体本身,保持主体外观、材质、文字、图案、透视和宽高比,不要拉伸或压扁。", - "不要缩放整张图,不要裁剪整张图,不要重绘未选区域。", - "如果主体变小,原位置露出的区域按原图背景自然补齐;白底或纯色背景必须保持干净一致。", - "如果主体变大,让同一个主体自然占据更大的目标范围,并保持边缘、阴影和背景衔接自然。", - "A/B 坐标只是隐藏编辑指令,最终图片里绝不能出现框线、箭头、文字、标注、UI 或半透明色块。", - "", - "编辑要求:", - regionLines, - ].join("\n"); -} - export async function POST(request: NextRequest) { try { const session = await auth(); @@ -660,10 +610,7 @@ export async function POST(request: NextRequest) { ); } - const [pointsConfig, siteSettingsForModel] = await Promise.all([ - getPointsSystemConfig(), - getSiteSettings().catch(() => null), - ]); + const pointsConfig = await getPointsSystemConfig(); const pointsCharge = await prepareFeaturePointsCharge({ userId: session.user.id, unitCost: getImageToolCreditCost(pointsConfig, "repaint"), @@ -691,45 +638,86 @@ export async function POST(request: NextRequest) { typeof body.model === "string" && body.model.trim() ? body.model.trim() : undefined; - const configuredDetailModel = - typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string" - ? siteSettingsForModel.canvasDetailDefaultModelId.trim() - : ""; - const configuredCanvasModel = - typeof siteSettingsForModel?.canvasDefaultModelId === "string" - ? siteSettingsForModel.canvasDefaultModelId.trim() - : ""; - const configuredAutoCanvasModel = - typeof siteSettingsForModel?.canvasAutoModeDefaultModelId === "string" - ? siteSettingsForModel.canvasAutoModeDefaultModelId.trim() - : ""; - const resolvedModel = - requestedModel || - configuredDetailModel || - configuredCanvasModel || - configuredAutoCanvasModel; const requestedImageSize = typeof body.imageSize === "string" && body.imageSize.trim() ? body.imageSize.trim() : "2K"; - const manualRegionReferenceImageUrls = regions.flatMap((region) => - region.referenceImages.map((reference) => reference.url) + const scaleAdjustIntentRegions: CanvasScaleAdjustIntentRegion[] = regions.map( + (region) => ({ + index: region.index, + rect: region.rect, + targetRect: getScaleAdjustTargetRect(region, scalePercent), + }) ); - const prompt = buildSimpleScaleAdjustPrompt(regions, scalePercent, { - width: sourceWidth, - height: sourceHeight, + const productLockAssets = await buildScaleAdjustProductLockAssets({ + regions: scaleAdjustIntentRegions, + sourceBuffer, + sourceHeight, + sourceImageUrl, + sourceWidth, + userId: session.user.id, }); + const assetSuffix = `${Date.now()}-${Math.random() + .toString(36) + .slice(2, 8)}`; + const [modelMaskUpload, overlayUpload, repairMaskUpload] = await Promise.all([ + uploadFile( + `canvas-scale-adjust-mask-${assetSuffix}.png`, + toArrayBuffer(productLockAssets.modelMaskBuffer) + ), + uploadFile( + `canvas-scale-adjust-overlay-${assetSuffix}.png`, + toArrayBuffer(productLockAssets.overlayBuffer) + ), + uploadFile( + `canvas-scale-adjust-repair-mask-${assetSuffix}.png`, + toArrayBuffer(productLockAssets.repairMaskBuffer) + ), + ]); + const backgroundPlateUpload = productLockAssets.backgroundPlateBuffer + ? await uploadFile( + `canvas-scale-adjust-background-${assetSuffix}.png`, + toArrayBuffer(productLockAssets.backgroundPlateBuffer) + ) + : null; + const prompt = "移除掩膜中的完整产品并自然还原背景。"; + const providerImageParams = { + objectRemovalMaskImageUrl: modelMaskUpload.url, + objectRemovalMode: "premium", + }; + const productLockComposite = { + ...(backgroundPlateUpload + ? { backgroundPlateImageUrl: backgroundPlateUpload.url } + : {}), + overlayImageUrl: overlayUpload.url, + sourceHeight, + sourceWidth, + }; + const scaleAdjustVerification = { + allowOutsideChangedWhenChangedInside: false, + oldFootprintMaskImageUrl: repairMaskUpload.url, + regions: scaleAdjustIntentRegions.map((region) => region.rect), + rejectFailedCandidates: true, + rejectNoEffectiveChangeOnly: false, + rejectOldFootprintOnly: true, + sourceImageUrl, + targetRegions: scaleAdjustIntentRegions.map((region) => region.targetRect), + }; const imageRequest: CanvasGenerateImageRequest = { + allowFailover: false, aspectRatio: resolvedAspectRatio, billingFeature: "repaint", - debugLabel: "canvas-scale-adjust-simple", + debugLabel: "canvas-scale-adjust-product-lock", debugMeta: { displayPrompt, operation, requestedModel: requestedModel || null, resolvedAspectRatio, - resolvedModel: resolvedModel || null, - scaleAdjustPromptStrategy: "simple-image-edit-v1", + resolvedModel: SCALE_ADJUST_REPAIR_MODEL_REF, + scaleAdjustProductLockMaskEditApplied: true, + scaleAdjustPromptStrategy: "finegrain-erase-product-lock-v1", + scaleAdjustProviderMaskImageUrl: modelMaskUpload.url, + scaleAdjustSequentialCleanupRetry: true, scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect) .length, scalePercent, @@ -740,12 +728,33 @@ export async function POST(request: NextRequest) { displayPrompt, imageCount: 1, imageSize: requestedImageSize, - model: resolvedModel || undefined, + model: SCALE_ADJUST_REPAIR_MODEL_REF, negativePrompt: - "不要画框线、箭头、坐标、标注、文字或 UI。不要缩放整张图、裁剪整张图、重绘未选区域。不要拉伸、压扁、复制或替换主体。", + "框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影", prompt, promptExtend: false, - referenceImages: [sourceImageUrl, ...manualRegionReferenceImageUrls], + providerImageParams, + referenceImages: [sourceImageUrl], + retryVariants: [ + { + debugMeta: { + scaleAdjustRetryReason: "old-footprint-remnant-detected", + scaleAdjustRetryStrategy: "sequential-finegrain-cleanup", + }, + label: "finegrain-cleanup-pass", + prompt, + providerImageParams, + referenceImages: [sourceImageUrl], + scaleAdjustProductLockComposite: productLockComposite, + scaleAdjustVerification: { + ...scaleAdjustVerification, + rejectNoEffectiveChangeOnly: true, + rejectOldFootprintOnly: false, + }, + }, + ], + scaleAdjustProductLockComposite: productLockComposite, + scaleAdjustVerification, ownerKey: session.user.id, }; @@ -771,7 +780,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, - annotatedReferenceUrl: sourceImageUrl, + annotatedReferenceUrl: overlayUpload.url, aspectRatio: resolvedAspectRatio, displayPrompt, prompt: displayPrompt, diff --git a/docs/evidence/canvas-scale-adjust-final-10-2026-07-11.jpg b/docs/evidence/canvas-scale-adjust-final-10-2026-07-11.jpg new file mode 100644 index 000000000..7e14af522 Binary files /dev/null and b/docs/evidence/canvas-scale-adjust-final-10-2026-07-11.jpg differ diff --git a/docs/画布比例调整验证记录-2026-07-11.md b/docs/画布比例调整验证记录-2026-07-11.md new file mode 100644 index 000000000..6fe3d5d08 --- /dev/null +++ b/docs/画布比例调整验证记录-2026-07-11.md @@ -0,0 +1,61 @@ +# 画布比例调整验证记录(2026-07-11) + +## 验收口径 + +- 测试图来自近期真实生图日志,共 10 张,覆盖白底椅、室内柜体、带人物书架、衣架、桌子和推车。 +- 框选必须完整包住产品。接口返回成功不等于通过,必须人工检查最终整图。 +- 通过条件:产品尺寸明显按目标变化、产品身份不变、原位置无残影、背景无矩形接缝、未选人物和物体无明显改变。 +- 目标是同一最终流程人工视觉通过至少 9/10;未达到前不得作为完成证据。 + +## 已证伪方法 + +| 方法 | 模型/通道 | 视觉结果 | 失败原因 | +| --- | --- | --- | --- | +| 原图 + 红/青目标框操作图 | Nano Banana 2(GRSAI) | 1/3 | 柜体、桌子近似原图,模型经常忽略比例指令 | +| 原图 + 红/青目标框操作图 | Nano Banana Pro(RUXA) | 0/2 | 柜体实际略放大,桌子无变化;像素检测曾误判柜体成功 | +| 原图 + 红/青目标框操作图 | Qwen Image Edit Max | 0/2 | 把操作图当输出模板,生成白底拼贴并保留框线结构 | +| 原图 + 完整产品裁图 | Nano Banana 2(GRSAI) | 1/5 | 第二张图被当成重绘参考,白底椅被放大,衣架/桌子无变化 | +| 只编辑完整框选裁图,再贴回原图 | Nano Banana 2(GRSAI) | 1/5 | 局部重绘产生矩形接缝、重复桌椅或近似原图 | +| 只编辑完整框选裁图,再贴回原图 | Qwen Image Edit Max | 0/2 | 椅子款式被替换,柜体生成白色矩形背景块 | +| 只编辑完整框选裁图,再贴回原图 | Nano Banana 2 Edit(FAL) | 0/2 | 两次都未产生有效缩放,重试仍近似原图 | +| 只编辑完整框选裁图,再贴回原图 | Nano Banana Pro(RUXA) | 0/2 | 柜体无变化,竖图请求多次超过 180 秒 | +| 完整原图 + 83 字极简提示 | Qwen Image Edit Max | 0/4 | 改写整张构图、抽成白底商品图、桌椅重影,不能保真 | +| 完整原图 + 83 字极简提示 | Agnes Image 2.0 Flash | 0/4 | 白底被改成室内,柜体/书架/桌子尺寸基本未变 | +| GPT Image 2 + 产品蒙版 | FAL | 0/3 | 通用编辑仍会改写整幅背景或留下灰色旧产品轮廓;早期测试还发现旧适配器错误发送 `mask_url`,修正为 `mask_image_url` 后仍未达到要求 | +| 产品蒙版 + 通用 Object Removal | FAL | 1/3 | 掩膜外保持良好,但办公椅旧脚轮/阴影残留,桌椅重叠被识别为同一目标 | +| 产品蒙版补洞后只贴回局部 | FAL GPT Image 2 | 0/2 | 原背景虽保住,但旧产品区域出现明显灰色剪影 | +| 对生成背景做矩形羽化贴回 | FAL GPT Image 2 | 0/2 | 出现水平矩形接缝、色差和旧物体边缘 | + +以上方法均保留在本记录中,不再作为后续默认方案重复尝试。 + +## 最终方案 + +最终只保留一条简单流程: + +1. 对用户完整框选区域执行一次产品抠图,得到产品蒙版和原产品透明像素。 +2. Finegrain Eraser 只负责按蒙版移除旧产品并修复背景,不理解比例、不重画产品。 +3. 本地按 `targetRect` 精确缩放原产品透明像素并放回,产品身份和比例由代码锁定。 +4. 透明白底图直接使用透明空底板,不调用模型猜背景。 +5. 首遍检测到旧轮廓时,以上一遍完整结果做一次同掩膜顺序清理,再精确放回产品;第二遍接受有实际比例变化的最佳结果,避免误报门禁循环。 +6. 产品锁流程不执行旧的彩色标注清理,避免把衣架等棕色产品像素误当标记擦掉。 + +## 最终验收 + +证据图:[10 张完整产品四联验收图](./evidence/canvas-scale-adjust-final-10-2026-07-11.jpg) + +| 序号 | 样本 | 人工结果 | 说明 | +| --- | --- | --- | --- | +| 01 | 透明白底办公椅 | 通过 | 完整产品缩小,白底无旧轮廓 | +| 02 | 真实办公室办公椅 | 通过 | 旧椅和脚轮阴影清理干净,办公室保持 | +| 03 | 室内柜体 | 通过 | 柜体完整缩小,房间主体保持 | +| 04 | 人物旁旋转书架 | 通过 | 书架完整缩小,人物、沙发和灯保持 | +| 05 | 第二张室内柜体 | 通过 | 柜体完整缩小,无旧柜残影 | +| 06 | 沙发旁升降桌 | 通过 | 产品完整缩小,沙发与柜体保持 | +| 07 | 室内衣架 | 通过 | 衣架和衣物完整缩小,无标注清理破点 | +| 08 | 桌子与椅子严重重叠 | 失败 | 完整矩形框内两件产品像素相交,抠图把桌椅识别为同一目标并一起缩放 | +| 09 | 客厅推车 | 通过 | 推车完整缩小,地毯与沙发保持 | +| 10 | 窗边升降桌 | 通过 | 顺序清理移除旧底脚,产品完整缩小 | + +人工视觉通过 **9/10(90%)**。接口返回状态不计入通过率;以上结论来自最终整图人工检查。 + +普通画布和“风格化(非 Pro)- 详情模式 - 生图结果区”均提交 `operation: "scale-adjust"` 到共享 `/api/canvas/local-edit` 路由,因此同步使用同一流程。 diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index db81e37cf..c2449235a 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -25,6 +25,8 @@ interface FalImageParams { falMaskImageUrl?: string; quality?: string; maskImageUrl?: string; + objectRemovalMaskImageUrl?: string; + objectRemovalMode?: string; openaiApiKey?: string; systemPrompt?: string; thinkingLevel?: string; @@ -64,6 +66,7 @@ const FAL_NANO_BANANA_MODEL_IDS = new Set([ "fal-ai/nano-banana-pro", ]); const FAL_GPT_IMAGE_2_EDIT_MODEL_ID = "openai/gpt-image-2/edit"; +const FAL_OBJECT_REMOVAL_MODEL_ID = "fal-ai/finegrain-eraser/mask"; const GPT_IMAGE_2_EDIT_IMAGE_SIZE_PRESETS = new Set([ "auto", "square_hd", @@ -746,10 +749,16 @@ function buildFalRequestPayload(params: { const systemPrompt = String(params.imageParams?.systemPrompt || "").trim(); const thinkingLevel = String(params.imageParams?.thinkingLevel || "").trim().toLowerCase(); const activeModelId = params.overrideModelId || params.modelId; + const objectRemovalMaskImageUrl = normalizeOptionalHttpUrl( + params.imageParams?.objectRemovalMaskImageUrl + ); + const isObjectRemoval = Boolean(objectRemovalMaskImageUrl && imageUrls[0]); const isGptImage2Edit = isFalGptImage2EditModelId(activeModelId); - const falEndpointId = isGptImage2Edit - ? activeModelId - : normalizeFalEndpointId(activeModelId, imageUrls.length > 0); + const falEndpointId = isObjectRemoval + ? FAL_OBJECT_REMOVAL_MODEL_ID + : isGptImage2Edit + ? activeModelId + : normalizeFalEndpointId(activeModelId, imageUrls.length > 0); if (!isGptImage2Edit && falEndpointId.toLowerCase().endsWith("/edit") && imageUrls.length === 0) { throw new Error("FAL 图片编辑模型需要至少一张可访问的参考图片 URL"); @@ -759,10 +768,24 @@ function buildFalRequestPayload(params: { params.imageParams?.imageSize, params.imageParams?.aspectRatio, ); - const outputSizeLabel = isGptImage2Edit ? getFalImageSizeLabel(gptImage2EditSize) : resolution; + const outputSizeLabel = isObjectRemoval + ? "match_input" + : isGptImage2Edit + ? getFalImageSizeLabel(gptImage2EditSize) + : resolution; let input: Record; - if (isGptImage2Edit) { + if (isObjectRemoval) { + input = { + image_url: imageUrls[0], + mask_url: objectRemovalMaskImageUrl, + mode: ["express", "standard", "premium"].includes( + String(params.imageParams?.objectRemovalMode || "") + ) + ? String(params.imageParams?.objectRemovalMode) + : "premium", + }; + } else if (isGptImage2Edit) { if (imageUrls.length === 0) { throw new Error("FAL GPT Image 2 Edit 需要至少一张可访问的参考图片 URL"); } diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 688c629cf..c5439134d 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -87,6 +87,8 @@ export interface CanvasScaleAdjustVerificationConfig { oldFootprintMaskImageUrl?: string; protectedRegions?: CanvasLocalEditCompositeRegion[]; rejectFailedCandidates?: boolean; + rejectNoEffectiveChangeOnly?: boolean; + rejectOldFootprintOnly?: boolean; regionPrompts?: string[]; sourceImageUrl: string; regions: CanvasLocalEditCompositeRegion[]; @@ -668,6 +670,9 @@ function normalizeScaleAdjustVerificationConfig( allowOutsideChangedWhenChangedInside: value.allowOutsideChangedWhenChangedInside === true, rejectFailedCandidates: value.rejectFailedCandidates === true, + rejectNoEffectiveChangeOnly: + value.rejectNoEffectiveChangeOnly === true, + rejectOldFootprintOnly: value.rejectOldFootprintOnly === true, ...(/^https?:\/\//i.test(oldFootprintMaskImageUrl) ? { oldFootprintMaskImageUrl } : {}), @@ -1878,8 +1883,9 @@ async function applyScaleAdjustVerificationToImages(params: { images: ParsedImage[]; verification: CanvasScaleAdjustVerificationConfig | null; debugLabel: string; + skipAnnotationCleanup?: boolean; }) { - const { images, verification, debugLabel } = params; + const { images, verification, debugLabel, skipAnnotationCleanup = false } = params; if (!verification || images.length === 0) return images; const sourceImage = await loadScaleAdjustSourceImage(verification); @@ -2170,41 +2176,43 @@ async function applyScaleAdjustVerificationToImages(params: { } let cleanedImage: ParsedImage | undefined; - try { - cleanedImage = (await removeScaleAdjustAnnotationArtifacts({ - debugLabel, - generatedBuffer, - imageIndex: index, - sourceBuffer: sourceImage.sourceBuffer, - sourceHeight: sourceImage.sourceHeight, - sourceWidth: sourceImage.sourceWidth, - verification, - })) ?? undefined; - if (cleanedImage) { - evaluatedImages.push({ - changeDiffScores, - changedInside, - cleanedImage, - diffScores, - footprintStats, - oldFootprintGhostDetected, - oldFootprintGhostStats, - image, - index, - outsideChanged, - outsideDiffScores, - outsidePixelStats, - rectangularSeamDetected, - rectangularSeamStats, + if (!skipAnnotationCleanup) { + try { + cleanedImage = (await removeScaleAdjustAnnotationArtifacts({ + debugLabel, + generatedBuffer, + imageIndex: index, + sourceBuffer: sourceImage.sourceBuffer, + sourceHeight: sourceImage.sourceHeight, + sourceWidth: sourceImage.sourceWidth, + verification, + })) ?? undefined; + if (cleanedImage) { + evaluatedImages.push({ + changeDiffScores, + changedInside, + cleanedImage, + diffScores, + footprintStats, + oldFootprintGhostDetected, + oldFootprintGhostStats, + image, + index, + outsideChanged, + outsideDiffScores, + outsidePixelStats, + rectangularSeamDetected, + rectangularSeamStats, + }); + continue; + } + } catch (cleanupError) { + console.warn("[Canvas Generate Image] Failed to clean scale adjust annotation artifacts:", { + debugLabel, + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + imageIndex: index, }); - continue; } - } catch (cleanupError) { - console.warn("[Canvas Generate Image] Failed to clean scale adjust annotation artifacts:", { - debugLabel, - error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), - imageIndex: index, - }); } evaluatedImages.push({ @@ -2302,21 +2310,24 @@ async function applyScaleAdjustVerificationToImages(params: { }); } - if ( - verification.rejectFailedCandidates && - (!selected.changedInside || - selected.rectangularSeamDetected || - selected.oldFootprintGhostDetected || - (selected.outsideChanged && !outsideChangeAccepted)) - ) { - throw new ScaleAdjustOutputRejectedError({ - reason: selected.rectangularSeamDetected + const rejectionReason = !selected.changedInside + ? "no-effective-scale-change" + : verification.rejectNoEffectiveChangeOnly + ? null + : verification.rejectOldFootprintOnly + ? selected.oldFootprintGhostDetected + ? "old-footprint-remnant-detected" + : null + : selected.rectangularSeamDetected ? "rectangular-seam-detected" : selected.oldFootprintGhostDetected ? "old-footprint-remnant-detected" - : selected.outsideChanged - ? "outside-frame-changed" - : "no-effective-scale-change", + : selected.outsideChanged && !outsideChangeAccepted + ? "outside-frame-changed" + : null; + if (verification.rejectFailedCandidates && rejectionReason) { + throw new ScaleAdjustOutputRejectedError({ + reason: rejectionReason, diffScores: selected.changeDiffScores, images: [selected.cleanedImage || selected.image], outsideDiffScores: selected.outsideDiffScores, @@ -4915,6 +4926,7 @@ export async function generateCanvasImage( const finalImages = await applyScaleAdjustVerificationToImages({ debugLabel, images: scaleAdjustProductLockedImages, + skipAnnotationCleanup: Boolean(scaleAdjustProductLockComposite), verification: scaleAdjustVerification, }); console.info("[Canvas Generate Image] Completed:", { @@ -4970,6 +4982,8 @@ export async function generateCanvasImage( const retryVariants = Array.isArray(request.retryVariants) ? request.retryVariants : []; + const scaleAdjustRejection = + caughtError instanceof ScaleAdjustOutputRejectedError ? caughtError : null; const nextRetryVariantIndex = typeof request.internalRetryVariantIndex === "number" ? request.internalRetryVariantIndex + 1 @@ -4999,17 +5013,26 @@ export async function generateCanvasImage( error: caughtError instanceof Error ? caughtError.message : String(caughtError), }); + const sequentialCleanupReferenceUrl = + Boolean(debugMeta?.scaleAdjustSequentialCleanupRetry) && + scaleAdjustRejection?.reason === "old-footprint-remnant-detected" + ? scaleAdjustRejection.images?.[0]?.originalUrl || + scaleAdjustRejection.images?.[0]?.thumbnailUrl || + "" + : ""; + return await generateCanvasImage({ ...request, ...retryOverrides, + ...(sequentialCleanupReferenceUrl + ? { referenceImages: [sequentialCleanupReferenceUrl] } + : {}), debugMeta: retryDebugMeta, internalRetryVariantIndex: nextRetryVariantIndex, retryVariants, }); } - const scaleAdjustRejection = - caughtError instanceof ScaleAdjustOutputRejectedError ? caughtError : null; const scaleAdjustRejected = Boolean(scaleAdjustRejection); if (scaleAdjustRejected) { diff --git a/lib/canvas/scale-adjust-product-lock.ts b/lib/canvas/scale-adjust-product-lock.ts new file mode 100644 index 000000000..ea47276a6 --- /dev/null +++ b/lib/canvas/scale-adjust-product-lock.ts @@ -0,0 +1,239 @@ +import sharp from "sharp"; + +import { executeRmbgJob } from "@/lib/ai/rmbg/service"; +export type CanvasScaleAdjustRect = { + height: number; + width: number; + x: number; + y: number; +}; + +export type CanvasScaleAdjustIntentRegion = { + index: number; + rect: CanvasScaleAdjustRect; + targetRect: CanvasScaleAdjustRect; +}; + +type PixelBox = { + height: number; + left: number; + top: number; + width: number; +}; + +export type CanvasScaleAdjustProductLockAssets = { + backgroundPlateBuffer?: Buffer; + modelMaskBuffer: Buffer; + overlayBuffer: Buffer; + repairMaskBuffer: Buffer; + subjectReferenceUrls: string[]; +}; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function rectToPixelBox( + rect: CanvasScaleAdjustRect, + sourceWidth: number, + sourceHeight: number +): PixelBox { + const left = clamp(Math.floor(rect.x * sourceWidth), 0, sourceWidth - 1); + const top = clamp(Math.floor(rect.y * sourceHeight), 0, sourceHeight - 1); + const right = clamp( + Math.ceil((rect.x + rect.width) * sourceWidth), + left + 1, + sourceWidth + ); + const bottom = clamp( + Math.ceil((rect.y + rect.height) * sourceHeight), + top + 1, + sourceHeight + ); + return { + height: bottom - top, + left, + top, + width: right - left, + }; +} + +async function normalizeSubjectBuffer(params: { + box: PixelBox; + imageBase64: string; +}) { + const { box, imageBase64 } = params; + const subjectBuffer = await sharp(Buffer.from(imageBase64, "base64"), { + failOn: "none", + }) + .rotate() + .resize(box.width, box.height, { + fit: "fill", + kernel: sharp.kernel.lanczos3, + }) + .ensureAlpha() + .png({ compressionLevel: 6 }) + .toBuffer(); + const alpha = await sharp(subjectBuffer, { failOn: "none" }) + .extractChannel(3) + .raw() + .toBuffer(); + const visiblePixels = alpha.reduce( + (count, value) => count + (value >= 18 ? 1 : 0), + 0 + ); + if (visiblePixels < Math.max(32, box.width * box.height * 0.008)) { + throw new Error("比例调整无法识别完整产品,请重新完整框选产品"); + } + return { alpha, subjectBuffer }; +} + +async function buildExpandedMaskPiece(params: { + alpha: Buffer; + box: PixelBox; +}) { + const { alpha, box } = params; + const radius = Math.max(2, Math.round(Math.min(box.width, box.height) * 0.06)); + const expandedAlpha = await sharp(alpha, { + raw: { channels: 1, height: box.height, width: box.width }, + }) + .blur(radius) + .threshold(8) + .png({ compressionLevel: 6 }) + .toBuffer(); + const whiteRgb = await sharp({ + create: { + background: { b: 255, g: 255, r: 255 }, + channels: 3, + height: box.height, + width: box.width, + }, + }) + .png({ compressionLevel: 6 }) + .toBuffer(); + return sharp(whiteRgb).joinChannel(expandedAlpha).png({ compressionLevel: 6 }).toBuffer(); +} + +export async function buildScaleAdjustProductLockAssets(params: { + regions: CanvasScaleAdjustIntentRegion[]; + sourceBuffer: Buffer; + sourceHeight: number; + sourceImageUrl: string; + sourceWidth: number; + userId: string; +}): Promise { + const { + regions, + sourceBuffer, + sourceHeight, + sourceImageUrl, + sourceWidth, + userId, + } = params; + const overlayInputs: sharp.OverlayOptions[] = []; + const maskInputs: sharp.OverlayOptions[] = []; + const subjectReferenceUrls: string[] = []; + + for (const region of regions) { + const sourceBox = rectToPixelBox( + region.rect, + sourceWidth, + sourceHeight + ); + const targetBox = rectToPixelBox( + region.targetRect, + sourceWidth, + sourceHeight + ); + const cropBuffer = await sharp(sourceBuffer, { failOn: "none" }) + .extract(sourceBox) + .png({ compressionLevel: 6 }) + .toBuffer(); + const subject = await executeRmbgJob({ + imageBuffer: cropBuffer, + prompt: "比例调整完整产品蒙版", + skipGallerySave: true, + sourceImageUrl, + userId, + }); + const { alpha, subjectBuffer } = await normalizeSubjectBuffer({ + box: sourceBox, + imageBase64: subject.image_base64, + }); + const scaledSubjectBuffer = await sharp(subjectBuffer, { failOn: "none" }) + .resize(targetBox.width, targetBox.height, { + fit: "fill", + kernel: sharp.kernel.lanczos3, + }) + .png({ compressionLevel: 6 }) + .toBuffer(); + overlayInputs.push({ + input: scaledSubjectBuffer, + left: targetBox.left, + top: targetBox.top, + }); + maskInputs.push({ + input: await buildExpandedMaskPiece({ alpha, box: sourceBox }), + left: sourceBox.left, + top: sourceBox.top, + }); + subjectReferenceUrls.push(subject.original_url); + } + + const transparentCanvas = { + create: { + background: { alpha: 0, b: 0, g: 0, r: 0 }, + channels: 4 as const, + height: sourceHeight, + width: sourceWidth, + }, + }; + const [modelMaskBuffer, overlayBuffer, repairMaskBuffer] = await Promise.all([ + sharp({ + create: { + background: { b: 0, g: 0, r: 0 }, + channels: 3, + height: sourceHeight, + width: sourceWidth, + }, + }) + .composite(maskInputs) + .flatten({ background: "#000000" }) + .png({ compressionLevel: 6 }) + .toBuffer(), + sharp(transparentCanvas) + .composite(overlayInputs) + .png({ compressionLevel: 6 }) + .toBuffer(), + sharp(transparentCanvas) + .composite(maskInputs) + .png({ compressionLevel: 6 }) + .toBuffer(), + ]); + const sourceAlpha = await sharp(sourceBuffer, { failOn: "none" }) + .rotate() + .resize(sourceWidth, sourceHeight, { fit: "fill" }) + .ensureAlpha() + .extractChannel(3) + .raw() + .toBuffer(); + const transparentPixelCount = sourceAlpha.reduce( + (count, value) => count + (value < 250 ? 1 : 0), + 0 + ); + const hasTransparentBackground = + transparentPixelCount >= sourceWidth * sourceHeight * 0.05; + const backgroundPlateBuffer = hasTransparentBackground + ? await sharp(transparentCanvas) + .png({ compressionLevel: 6 }) + .toBuffer() + : undefined; + + return { + ...(backgroundPlateBuffer ? { backgroundPlateBuffer } : {}), + modelMaskBuffer, + overlayBuffer, + repairMaskBuffer, + subjectReferenceUrls, + }; +} diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 1ac030919..fac8bfdbb 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ /routes\/production-local-fallbacks\.test\.ts/, /routes\/canvas-entry-performance\.test\.ts/, /routes\/settings-visual-consistency\.test\.ts/, + /routes\/canvas-scale-adjust-intent\.test\.ts/, ], fullyParallel: true, forbidOnly: !!process.env.CI, diff --git a/scripts/verify-canvas-scale-adjust.ts b/scripts/verify-canvas-scale-adjust.ts new file mode 100644 index 000000000..3baf26ebc --- /dev/null +++ b/scripts/verify-canvas-scale-adjust.ts @@ -0,0 +1,377 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import sharp from "sharp"; + +import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; +import { + type CanvasGenerateImageRequest, + generateCanvasImage, +} from "@/lib/canvas/generate-image"; +import { + buildScaleAdjustProductLockAssets, + type CanvasScaleAdjustIntentRegion, + type CanvasScaleAdjustRect, +} from "@/lib/canvas/scale-adjust-product-lock"; +import { createStorageUploadSession } from "@/lib/storage/service"; + +type VerificationCase = { + id: string; + rect: CanvasScaleAdjustRect; + scalePercent?: number; + source: string; + target?: CanvasScaleAdjustRect; +}; + +const cases: VerificationCase[] = [ + { + id: "01-chair-cutout", + rect: { height: 0.76, width: 0.6, x: 0.2, y: 0.2 }, + source: "https://scqny.jiansun.vip/uploads/1783578430745-dqst30.png", + target: { height: 0.418, width: 0.33, x: 0.34, y: 0.38 }, + }, + { + id: "02-office-chair", + rect: { height: 0.72, width: 0.64, x: 0.18, y: 0.25 }, + source: "https://scqny.jiansun.vip/uploads/1783562931574-tj4zst.png", + target: { height: 0.45, width: 0.4, x: 0.31, y: 0.38 }, + }, + { + id: "03-cabinet", + rect: { height: 0.91, width: 0.72, x: 0.13, y: 0.03 }, + scalePercent: -25, + source: "https://scqny.jiansun.vip/uploads/1783562287046-gpie6r.png", + }, + { + id: "04-bookshelf-with-person", + rect: { height: 0.88, width: 0.29, x: 0.37, y: 0.07 }, + scalePercent: -25, + source: "https://scqny.jiansun.vip/uploads/1783687809170-vmnsea.png", + }, + { + id: "05-cabinet-room", + rect: { height: 0.91, width: 0.72, x: 0.13, y: 0.03 }, + scalePercent: -25, + source: "https://scqny.jiansun.vip/uploads/1783561235528-gv6es4.jpg", + }, + { + id: "06-standing-desk-a", + rect: { height: 0.61, width: 0.43, x: 0.27, y: 0.32 }, + scalePercent: -25, + source: "https://scqny.jiansun.vip/uploads/1783558912572-dkkmmf.png", + }, + { + id: "07-clothes-rack", + rect: { height: 0.7, width: 0.53, x: 0.32, y: 0.23 }, + scalePercent: -25, + source: "https://scqny.jiansun.vip/uploads/1783689789549-wu4br1.png", + }, + { + id: "08-desk", + rect: { height: 0.7, width: 0.9, x: 0.03, y: 0.26 }, + scalePercent: -30, + source: "https://scqny.jiansun.vip/uploads/1783556368653-aizsul.png", + }, + { + id: "09-coffee-cart", + rect: { height: 0.54, width: 0.5, x: 0.22, y: 0.36 }, + scalePercent: -30, + source: "https://scqny.jiansun.vip/uploads/1783555598917-8w4a6t.png", + }, + { + id: "10-standing-desk-c", + rect: { height: 0.69, width: 0.73, x: 0.23, y: 0.23 }, + scalePercent: -30, + source: "https://scqny.jiansun.vip/uploads/1783555294804-qdykhy.png", + }, +]; + +const aspectRatios = ["1:1", "4:3", "3:4", "16:9", "9:16"] as const; + +function toArrayBuffer(buffer: Buffer) { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer; +} + +function targetAroundCenter(item: VerificationCase): CanvasScaleAdjustRect { + if (item.target) return item.target; + const factor = 1 + (item.scalePercent || 0) / 100; + return { + height: item.rect.height * factor, + width: item.rect.width * factor, + x: item.rect.x + (item.rect.width * (1 - factor)) / 2, + y: item.rect.y + (item.rect.height * (1 - factor)) / 2, + }; +} + +function resolveAspectRatio(width: number, height: number) { + const ratio = width / height; + return aspectRatios.reduce((best, candidate) => { + const [candidateWidth, candidateHeight] = candidate + .split(":") + .map(Number); + const bestParts = best.split(":").map(Number); + const candidateDiff = Math.abs(candidateWidth / candidateHeight - ratio); + const bestDiff = Math.abs(bestParts[0] / bestParts[1] - ratio); + return candidateDiff < bestDiff ? candidate : best; + }, aspectRatios[0]); +} + +async function addLabel(input: Buffer, label: string) { + const metadata = await sharp(input).metadata(); + const width = metadata.width || 420; + const height = metadata.height || 320; + return sharp(input) + .composite([ + { + input: Buffer.from(` + + + ${label} + + `), + }, + ]) + .flatten({ background: "#ffffff" }) + .jpeg({ quality: 88 }) + .toBuffer(); +} + +async function runCase(params: { + item: VerificationCase; + outputDir: string; + upload: Awaited>; +}) { + const { item, outputDir, upload } = params; + const startedAt = Date.now(); + const sourceBuffer = await fetchImageBufferFromUrl(item.source); + const sourceMetadata = await sharp(sourceBuffer, { failOn: "none" }).metadata(); + const sourceWidth = Number(sourceMetadata.width || 0); + const sourceHeight = Number(sourceMetadata.height || 0); + if (sourceWidth <= 0 || sourceHeight <= 0) { + throw new Error("无法读取测试原图尺寸"); + } + + const regions: CanvasScaleAdjustIntentRegion[] = [ + { index: 1, rect: item.rect, targetRect: targetAroundCenter(item) }, + ]; + const assets = await buildScaleAdjustProductLockAssets({ + regions, + sourceBuffer, + sourceHeight, + sourceImageUrl: item.source, + sourceWidth, + userId: + process.env.SCALE_ADJUST_USER_ID || + "00000000-0000-0000-0000-000000000000", + }); + + await Promise.all([ + writeFile(resolve(outputDir, `${item.id}-source.png`), sourceBuffer), + writeFile(resolve(outputDir, `${item.id}-model-mask.png`), assets.modelMaskBuffer), + writeFile(resolve(outputDir, `${item.id}-overlay.png`), assets.overlayBuffer), + writeFile( + resolve(outputDir, `${item.id}-repair-mask.png`), + assets.repairMaskBuffer + ), + ...(assets.backgroundPlateBuffer + ? [ + writeFile( + resolve(outputDir, `${item.id}-background-plate.png`), + assets.backgroundPlateBuffer + ), + ] + : []), + ]); + + const suffix = `${item.id}-${Date.now()}`; + const [modelMaskUpload, overlayUpload, repairMaskUpload] = await Promise.all([ + upload(`verify-scale-mask-${suffix}.png`, toArrayBuffer(assets.modelMaskBuffer)), + upload(`verify-scale-overlay-${suffix}.png`, toArrayBuffer(assets.overlayBuffer)), + upload( + `verify-scale-repair-mask-${suffix}.png`, + toArrayBuffer(assets.repairMaskBuffer) + ), + ]); + const backgroundPlateUpload = assets.backgroundPlateBuffer + ? await upload( + `verify-scale-background-${suffix}.png`, + toArrayBuffer(assets.backgroundPlateBuffer) + ) + : null; + const providerImageParams = { + objectRemovalMaskImageUrl: modelMaskUpload.url, + objectRemovalMode: "premium", + }; + const productLockComposite = { + ...(backgroundPlateUpload + ? { backgroundPlateImageUrl: backgroundPlateUpload.url } + : {}), + overlayImageUrl: overlayUpload.url, + sourceHeight, + sourceWidth, + }; + const verification = { + allowOutsideChangedWhenChangedInside: false, + oldFootprintMaskImageUrl: repairMaskUpload.url, + regions: [item.rect], + rejectFailedCandidates: true, + rejectNoEffectiveChangeOnly: false, + rejectOldFootprintOnly: true, + sourceImageUrl: item.source, + targetRegions: [regions[0].targetRect], + }; + const prompt = "移除掩膜中的完整产品并自然还原背景。"; + const request: CanvasGenerateImageRequest = { + allowFailover: false, + aspectRatio: resolveAspectRatio(sourceWidth, sourceHeight), + debugLabel: `verify-scale-final-${item.id}`, + debugMeta: { + operation: "scale-adjust", + scaleAdjustPromptStrategy: "finegrain-erase-product-lock-v1", + scaleAdjustSequentialCleanupRetry: true, + }, + displayPrompt: "比例调整", + imageCount: 1, + imageSize: "1K", + model: + process.env.SCALE_ADJUST_MODEL || + "e03d3341-cecd-425e-b88b-9350cbbce18f", + prompt, + promptExtend: false, + providerImageParams, + referenceImages: [item.source], + retryVariants: [ + { + label: "finegrain-cleanup-pass", + prompt, + providerImageParams, + referenceImages: [item.source], + scaleAdjustProductLockComposite: productLockComposite, + scaleAdjustVerification: { + ...verification, + rejectNoEffectiveChangeOnly: true, + rejectOldFootprintOnly: false, + }, + }, + ], + scaleAdjustProductLockComposite: productLockComposite, + scaleAdjustVerification: verification, + }; + const generated = await generateCanvasImage(request); + const resultUrl = generated.images[0]?.originalUrl || null; + if (!resultUrl) throw new Error("比例调整未返回结果图片"); + await writeFile( + resolve(outputDir, `${item.id}-result.png`), + await fetchImageBufferFromUrl(resultUrl) + ); + + const result = { + elapsedMs: Date.now() - startedAt, + id: item.id, + ok: true, + rect: item.rect, + resultUrl, + source: item.source, + targetRect: regions[0].targetRect, + }; + console.log(`SCALE_VERIFY_CASE ${JSON.stringify(result)}`); + return result; +} + +async function main() { + const outputDir = resolve( + process.env.SCALE_ADJUST_OUTPUT_DIR || + "/private/tmp/scale-adjust-final-verification" + ); + const requestedIds = new Set( + String(process.env.SCALE_ADJUST_CASES || "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + ); + const selectedCases = requestedIds.size + ? cases.filter((item) => requestedIds.has(item.id)) + : cases; + const concurrency = Math.max( + 1, + Math.min(3, Number(process.env.SCALE_ADJUST_CONCURRENCY || 3)) + ); + await mkdir(outputDir, { recursive: true }); + const upload = await createStorageUploadSession(); + const results: Array> = []; + let cursor = 0; + + await Promise.all( + Array.from({ length: concurrency }, async () => { + while (cursor < selectedCases.length) { + const item = selectedCases[cursor]; + cursor += 1; + try { + results.push(await runCase({ item, outputDir, upload })); + } catch (error) { + const failure = { + error: error instanceof Error ? error.message : String(error), + id: item.id, + ok: false, + source: item.source, + }; + results.push(failure); + console.error(`SCALE_VERIFY_CASE ${JSON.stringify(failure)}`); + } + } + }) + ); + results.sort((left, right) => String(left.id).localeCompare(String(right.id))); + await writeFile( + resolve(outputDir, "results.json"), + JSON.stringify(results, null, 2) + ); + + const panels: Buffer[] = []; + for (const item of selectedCases) { + for (const suffix of ["source", "model-mask", "overlay", "result"]) { + try { + const panel = await sharp(resolve(outputDir, `${item.id}-${suffix}.png`)) + .resize(420, 320, { background: "#ffffff", fit: "contain" }) + .png() + .toBuffer(); + panels.push(await addLabel(panel, `${item.id} ${suffix}`)); + } catch { + // Failed cases remain visible in results.json. + } + } + } + if (panels.length) { + const rows = Math.ceil(panels.length / 4); + await sharp({ + create: { + background: "#e8e8e8", + channels: 3, + height: rows * 320, + width: 4 * 420, + }, + }) + .composite( + panels.map((input, index) => ({ + input, + left: (index % 4) * 420, + top: Math.floor(index / 4) * 320, + })) + ) + .jpeg({ quality: 88 }) + .toFile(resolve(outputDir, "full-results.jpg")); + } + + const passed = results.filter((result) => result.ok === true).length; + console.log( + `SCALE_VERIFY_SUMMARY ${JSON.stringify({ failed: results.length - passed, outputDir, passed, total: results.length })}` + ); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/routes/canvas-scale-adjust-intent.test.ts b/tests/routes/canvas-scale-adjust-intent.test.ts new file mode 100644 index 000000000..54be5193e --- /dev/null +++ b/tests/routes/canvas-scale-adjust-intent.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function readSource(path: string) { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +test.describe("canvas scale adjust product lock", () => { + test("uses one dedicated erase step and exact product overlay", () => { + const route = readSource("app/api/canvas/local-edit/route.ts"); + + expect(route).toContain("buildScaleAdjustProductLockAssets"); + expect(route).toContain('objectRemovalMode: "premium"'); + expect(route).toContain("objectRemovalMaskImageUrl: modelMaskUpload.url"); + expect(route).toContain("overlayImageUrl: overlayUpload.url"); + expect(route).toContain("oldFootprintMaskImageUrl: repairMaskUpload.url"); + expect(route).toContain('scaleAdjustPromptStrategy: "finegrain-erase-product-lock-v1"'); + expect(route).not.toContain("buildScaleAdjustVisualGuide"); + expect(route).not.toContain("source-plus-guide-v1"); + }); + + test("routes object removal through the configured fal credential", () => { + const provider = readSource("lib/ai/providers/fal-image.ts"); + + expect(provider).toContain( + 'const FAL_OBJECT_REMOVAL_MODEL_ID = "fal-ai/finegrain-eraser/mask"' + ); + expect(provider).toContain("image_url: imageUrls[0]"); + expect(provider).toContain("mask_url: objectRemovalMaskImageUrl"); + expect(provider).toContain('objectRemovalMode || ""'); + }); + + test("retries old-footprint failures against the first cleaned result", () => { + const route = readSource("app/api/canvas/local-edit/route.ts"); + const generator = readSource("lib/canvas/generate-image.ts"); + + expect(route).toContain("rejectOldFootprintOnly: true"); + expect(route).toContain("scaleAdjustSequentialCleanupRetry: true"); + expect(route).toContain('label: "finegrain-cleanup-pass"'); + expect(generator).toContain( + 'scaleAdjustRejection?.reason === "old-footprint-remnant-detected"' + ); + expect(generator).toContain("referenceImages: [sequentialCleanupReferenceUrl]"); + }); + + test("keeps transparent product images on a deterministic empty plate", () => { + const productLock = readSource("lib/canvas/scale-adjust-product-lock.ts"); + + expect(productLock).toContain("hasTransparentBackground"); + expect(productLock).toContain("sharp(transparentCanvas)"); + expect(productLock).toContain("backgroundPlateBuffer"); + }); + + test("keeps non-pro detail result scale adjust on the shared backend flow", () => { + const source = readSource( + "components/agents/AgentGenerationWorkspaceDarkV2.tsx" + ); + + expect(source).toContain( + "const resolvedLocalEditModelId = isScaleAdjust\n ? undefined" + ); + expect(source).toContain( + 'operation: isScaleAdjust ? "scale-adjust" : undefined' + ); + expect(source).toContain("targetRect: region.targetRect"); + expect(source).toContain('resultModeType: "detail_generation"'); + expect(source).toContain('mode: "scale_adjust"'); + }); +});