backup: preserve verified canvas scale adjustment

This commit is contained in:
134 2026-07-13 13:41:09 +08:00
parent 752d1f99d2
commit 8119219c15
9 changed files with 944 additions and 141 deletions

View File

@ -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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 KiB

View File

@ -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` 路由,因此同步使用同一流程。

View File

@ -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<string, any>;
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");
}

View File

@ -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) {

View File

@ -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<CanvasScaleAdjustProductLockAssets> {
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,
};
}

View File

@ -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,

View File

@ -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(`
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<rect width="${Math.min(width, 18 * (label.length * 0.62 + 1))}" height="34" fill="#111111" fill-opacity="0.9" />
<text x="8" y="24" fill="#ffffff" font-family="Arial, Helvetica, sans-serif" font-size="20" font-weight="700">${label}</text>
</svg>
`),
},
])
.flatten({ background: "#ffffff" })
.jpeg({ quality: 88 })
.toBuffer();
}
async function runCase(params: {
item: VerificationCase;
outputDir: string;
upload: Awaited<ReturnType<typeof createStorageUploadSession>>;
}) {
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<Record<string, unknown>> = [];
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;
});

View File

@ -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"');
});
});