backup: preserve verified canvas scale adjustment
This commit is contained in:
parent
752d1f99d2
commit
8119219c15
@ -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,
|
||||
|
||||
BIN
docs/evidence/canvas-scale-adjust-final-10-2026-07-11.jpg
Normal file
BIN
docs/evidence/canvas-scale-adjust-final-10-2026-07-11.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 718 KiB |
61
docs/画布比例调整验证记录-2026-07-11.md
Normal file
61
docs/画布比例调整验证记录-2026-07-11.md
Normal 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` 路由,因此同步使用同一流程。
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
239
lib/canvas/scale-adjust-product-lock.ts
Normal file
239
lib/canvas/scale-adjust-product-lock.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
377
scripts/verify-canvas-scale-adjust.ts
Normal file
377
scripts/verify-canvas-scale-adjust.ts
Normal 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;
|
||||
});
|
||||
70
tests/routes/canvas-scale-adjust-intent.test.ts
Normal file
70
tests/routes/canvas-scale-adjust-intent.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user