Compare commits
1 Commits
main
...
backup/can
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5349fece05 |
@ -9,9 +9,12 @@ import {
|
|||||||
getCanvasImageTaskForUser,
|
getCanvasImageTaskForUser,
|
||||||
} from "@/lib/canvas/image-task-queue";
|
} from "@/lib/canvas/image-task-queue";
|
||||||
import type { CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
|
import type { CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
|
||||||
|
import {
|
||||||
|
buildScaleAdjustProductLockAssets,
|
||||||
|
type CanvasScaleAdjustIntentRegion,
|
||||||
|
} from "@/lib/canvas/scale-adjust-product-lock";
|
||||||
import {
|
import {
|
||||||
getPointsSystemConfig,
|
getPointsSystemConfig,
|
||||||
getSiteSettings,
|
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
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 MAX_REFERENCE_IMAGES_PER_REGION = 3;
|
||||||
const MIN_REGION_EDGE_PX = 24;
|
const MIN_REGION_EDGE_PX = 24;
|
||||||
const SCALE_ADJUST_OPERATION = "scale-adjust";
|
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;
|
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
|
||||||
const MIN_SCALE_ADJUST_PERCENT = -80;
|
const MIN_SCALE_ADJUST_PERCENT = -80;
|
||||||
const MAX_SCALE_ADJUST_PERCENT = 200;
|
const MAX_SCALE_ADJUST_PERCENT = 200;
|
||||||
@ -518,60 +522,6 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
|
|||||||
].join("\n");
|
].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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -660,10 +610,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [pointsConfig, siteSettingsForModel] = await Promise.all([
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
getPointsSystemConfig(),
|
|
||||||
getSiteSettings().catch(() => null),
|
|
||||||
]);
|
|
||||||
const pointsCharge = await prepareFeaturePointsCharge({
|
const pointsCharge = await prepareFeaturePointsCharge({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
|
||||||
@ -691,45 +638,86 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof body.model === "string" && body.model.trim()
|
typeof body.model === "string" && body.model.trim()
|
||||||
? body.model.trim()
|
? body.model.trim()
|
||||||
: undefined;
|
: 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 =
|
const requestedImageSize =
|
||||||
typeof body.imageSize === "string" && body.imageSize.trim()
|
typeof body.imageSize === "string" && body.imageSize.trim()
|
||||||
? body.imageSize.trim()
|
? body.imageSize.trim()
|
||||||
: "2K";
|
: "2K";
|
||||||
const manualRegionReferenceImageUrls = regions.flatMap((region) =>
|
const scaleAdjustIntentRegions: CanvasScaleAdjustIntentRegion[] = regions.map(
|
||||||
region.referenceImages.map((reference) => reference.url)
|
(region) => ({
|
||||||
|
index: region.index,
|
||||||
|
rect: region.rect,
|
||||||
|
targetRect: getScaleAdjustTargetRect(region, scalePercent),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const prompt = buildSimpleScaleAdjustPrompt(regions, scalePercent, {
|
const productLockAssets = await buildScaleAdjustProductLockAssets({
|
||||||
width: sourceWidth,
|
regions: scaleAdjustIntentRegions,
|
||||||
height: sourceHeight,
|
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 = {
|
const imageRequest: CanvasGenerateImageRequest = {
|
||||||
|
allowFailover: false,
|
||||||
aspectRatio: resolvedAspectRatio,
|
aspectRatio: resolvedAspectRatio,
|
||||||
billingFeature: "repaint",
|
billingFeature: "repaint",
|
||||||
debugLabel: "canvas-scale-adjust-simple",
|
debugLabel: "canvas-scale-adjust-product-lock",
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
displayPrompt,
|
displayPrompt,
|
||||||
operation,
|
operation,
|
||||||
requestedModel: requestedModel || null,
|
requestedModel: requestedModel || null,
|
||||||
resolvedAspectRatio,
|
resolvedAspectRatio,
|
||||||
resolvedModel: resolvedModel || null,
|
resolvedModel: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||||||
scaleAdjustPromptStrategy: "simple-image-edit-v1",
|
scaleAdjustProductLockMaskEditApplied: true,
|
||||||
|
scaleAdjustPromptStrategy: "finegrain-erase-product-lock-v1",
|
||||||
|
scaleAdjustProviderMaskImageUrl: modelMaskUpload.url,
|
||||||
|
scaleAdjustSequentialCleanupRetry: true,
|
||||||
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
|
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
|
||||||
.length,
|
.length,
|
||||||
scalePercent,
|
scalePercent,
|
||||||
@ -740,12 +728,33 @@ export async function POST(request: NextRequest) {
|
|||||||
displayPrompt,
|
displayPrompt,
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
imageSize: requestedImageSize,
|
imageSize: requestedImageSize,
|
||||||
model: resolvedModel || undefined,
|
model: SCALE_ADJUST_REPAIR_MODEL_REF,
|
||||||
negativePrompt:
|
negativePrompt:
|
||||||
"不要画框线、箭头、坐标、标注、文字或 UI。不要缩放整张图、裁剪整张图、重绘未选区域。不要拉伸、压扁、复制或替换主体。",
|
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
|
||||||
prompt,
|
prompt,
|
||||||
promptExtend: false,
|
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,
|
ownerKey: session.user.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -771,7 +780,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
annotatedReferenceUrl: sourceImageUrl,
|
annotatedReferenceUrl: overlayUpload.url,
|
||||||
aspectRatio: resolvedAspectRatio,
|
aspectRatio: resolvedAspectRatio,
|
||||||
displayPrompt,
|
displayPrompt,
|
||||||
prompt: 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;
|
falMaskImageUrl?: string;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
maskImageUrl?: string;
|
maskImageUrl?: string;
|
||||||
|
objectRemovalMaskImageUrl?: string;
|
||||||
|
objectRemovalMode?: string;
|
||||||
openaiApiKey?: string;
|
openaiApiKey?: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
@ -64,6 +66,7 @@ const FAL_NANO_BANANA_MODEL_IDS = new Set([
|
|||||||
"fal-ai/nano-banana-pro",
|
"fal-ai/nano-banana-pro",
|
||||||
]);
|
]);
|
||||||
const FAL_GPT_IMAGE_2_EDIT_MODEL_ID = "openai/gpt-image-2/edit";
|
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([
|
const GPT_IMAGE_2_EDIT_IMAGE_SIZE_PRESETS = new Set([
|
||||||
"auto",
|
"auto",
|
||||||
"square_hd",
|
"square_hd",
|
||||||
@ -746,10 +749,16 @@ function buildFalRequestPayload(params: {
|
|||||||
const systemPrompt = String(params.imageParams?.systemPrompt || "").trim();
|
const systemPrompt = String(params.imageParams?.systemPrompt || "").trim();
|
||||||
const thinkingLevel = String(params.imageParams?.thinkingLevel || "").trim().toLowerCase();
|
const thinkingLevel = String(params.imageParams?.thinkingLevel || "").trim().toLowerCase();
|
||||||
const activeModelId = params.overrideModelId || params.modelId;
|
const activeModelId = params.overrideModelId || params.modelId;
|
||||||
|
const objectRemovalMaskImageUrl = normalizeOptionalHttpUrl(
|
||||||
|
params.imageParams?.objectRemovalMaskImageUrl
|
||||||
|
);
|
||||||
|
const isObjectRemoval = Boolean(objectRemovalMaskImageUrl && imageUrls[0]);
|
||||||
const isGptImage2Edit = isFalGptImage2EditModelId(activeModelId);
|
const isGptImage2Edit = isFalGptImage2EditModelId(activeModelId);
|
||||||
const falEndpointId = isGptImage2Edit
|
const falEndpointId = isObjectRemoval
|
||||||
? activeModelId
|
? FAL_OBJECT_REMOVAL_MODEL_ID
|
||||||
: normalizeFalEndpointId(activeModelId, imageUrls.length > 0);
|
: isGptImage2Edit
|
||||||
|
? activeModelId
|
||||||
|
: normalizeFalEndpointId(activeModelId, imageUrls.length > 0);
|
||||||
|
|
||||||
if (!isGptImage2Edit && falEndpointId.toLowerCase().endsWith("/edit") && imageUrls.length === 0) {
|
if (!isGptImage2Edit && falEndpointId.toLowerCase().endsWith("/edit") && imageUrls.length === 0) {
|
||||||
throw new Error("FAL 图片编辑模型需要至少一张可访问的参考图片 URL");
|
throw new Error("FAL 图片编辑模型需要至少一张可访问的参考图片 URL");
|
||||||
@ -759,10 +768,24 @@ function buildFalRequestPayload(params: {
|
|||||||
params.imageParams?.imageSize,
|
params.imageParams?.imageSize,
|
||||||
params.imageParams?.aspectRatio,
|
params.imageParams?.aspectRatio,
|
||||||
);
|
);
|
||||||
const outputSizeLabel = isGptImage2Edit ? getFalImageSizeLabel(gptImage2EditSize) : resolution;
|
const outputSizeLabel = isObjectRemoval
|
||||||
|
? "match_input"
|
||||||
|
: isGptImage2Edit
|
||||||
|
? getFalImageSizeLabel(gptImage2EditSize)
|
||||||
|
: resolution;
|
||||||
|
|
||||||
let input: Record<string, any>;
|
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) {
|
if (imageUrls.length === 0) {
|
||||||
throw new Error("FAL GPT Image 2 Edit 需要至少一张可访问的参考图片 URL");
|
throw new Error("FAL GPT Image 2 Edit 需要至少一张可访问的参考图片 URL");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,6 +87,8 @@ export interface CanvasScaleAdjustVerificationConfig {
|
|||||||
oldFootprintMaskImageUrl?: string;
|
oldFootprintMaskImageUrl?: string;
|
||||||
protectedRegions?: CanvasLocalEditCompositeRegion[];
|
protectedRegions?: CanvasLocalEditCompositeRegion[];
|
||||||
rejectFailedCandidates?: boolean;
|
rejectFailedCandidates?: boolean;
|
||||||
|
rejectNoEffectiveChangeOnly?: boolean;
|
||||||
|
rejectOldFootprintOnly?: boolean;
|
||||||
regionPrompts?: string[];
|
regionPrompts?: string[];
|
||||||
sourceImageUrl: string;
|
sourceImageUrl: string;
|
||||||
regions: CanvasLocalEditCompositeRegion[];
|
regions: CanvasLocalEditCompositeRegion[];
|
||||||
@ -668,6 +670,9 @@ function normalizeScaleAdjustVerificationConfig(
|
|||||||
allowOutsideChangedWhenChangedInside:
|
allowOutsideChangedWhenChangedInside:
|
||||||
value.allowOutsideChangedWhenChangedInside === true,
|
value.allowOutsideChangedWhenChangedInside === true,
|
||||||
rejectFailedCandidates: value.rejectFailedCandidates === true,
|
rejectFailedCandidates: value.rejectFailedCandidates === true,
|
||||||
|
rejectNoEffectiveChangeOnly:
|
||||||
|
value.rejectNoEffectiveChangeOnly === true,
|
||||||
|
rejectOldFootprintOnly: value.rejectOldFootprintOnly === true,
|
||||||
...(/^https?:\/\//i.test(oldFootprintMaskImageUrl)
|
...(/^https?:\/\//i.test(oldFootprintMaskImageUrl)
|
||||||
? { oldFootprintMaskImageUrl }
|
? { oldFootprintMaskImageUrl }
|
||||||
: {}),
|
: {}),
|
||||||
@ -1878,8 +1883,9 @@ async function applyScaleAdjustVerificationToImages(params: {
|
|||||||
images: ParsedImage[];
|
images: ParsedImage[];
|
||||||
verification: CanvasScaleAdjustVerificationConfig | null;
|
verification: CanvasScaleAdjustVerificationConfig | null;
|
||||||
debugLabel: string;
|
debugLabel: string;
|
||||||
|
skipAnnotationCleanup?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { images, verification, debugLabel } = params;
|
const { images, verification, debugLabel, skipAnnotationCleanup = false } = params;
|
||||||
if (!verification || images.length === 0) return images;
|
if (!verification || images.length === 0) return images;
|
||||||
|
|
||||||
const sourceImage = await loadScaleAdjustSourceImage(verification);
|
const sourceImage = await loadScaleAdjustSourceImage(verification);
|
||||||
@ -2170,41 +2176,43 @@ async function applyScaleAdjustVerificationToImages(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cleanedImage: ParsedImage | undefined;
|
let cleanedImage: ParsedImage | undefined;
|
||||||
try {
|
if (!skipAnnotationCleanup) {
|
||||||
cleanedImage = (await removeScaleAdjustAnnotationArtifacts({
|
try {
|
||||||
debugLabel,
|
cleanedImage = (await removeScaleAdjustAnnotationArtifacts({
|
||||||
generatedBuffer,
|
debugLabel,
|
||||||
imageIndex: index,
|
generatedBuffer,
|
||||||
sourceBuffer: sourceImage.sourceBuffer,
|
imageIndex: index,
|
||||||
sourceHeight: sourceImage.sourceHeight,
|
sourceBuffer: sourceImage.sourceBuffer,
|
||||||
sourceWidth: sourceImage.sourceWidth,
|
sourceHeight: sourceImage.sourceHeight,
|
||||||
verification,
|
sourceWidth: sourceImage.sourceWidth,
|
||||||
})) ?? undefined;
|
verification,
|
||||||
if (cleanedImage) {
|
})) ?? undefined;
|
||||||
evaluatedImages.push({
|
if (cleanedImage) {
|
||||||
changeDiffScores,
|
evaluatedImages.push({
|
||||||
changedInside,
|
changeDiffScores,
|
||||||
cleanedImage,
|
changedInside,
|
||||||
diffScores,
|
cleanedImage,
|
||||||
footprintStats,
|
diffScores,
|
||||||
oldFootprintGhostDetected,
|
footprintStats,
|
||||||
oldFootprintGhostStats,
|
oldFootprintGhostDetected,
|
||||||
image,
|
oldFootprintGhostStats,
|
||||||
index,
|
image,
|
||||||
outsideChanged,
|
index,
|
||||||
outsideDiffScores,
|
outsideChanged,
|
||||||
outsidePixelStats,
|
outsideDiffScores,
|
||||||
rectangularSeamDetected,
|
outsidePixelStats,
|
||||||
rectangularSeamStats,
|
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({
|
evaluatedImages.push({
|
||||||
@ -2302,21 +2310,24 @@ async function applyScaleAdjustVerificationToImages(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const rejectionReason = !selected.changedInside
|
||||||
verification.rejectFailedCandidates &&
|
? "no-effective-scale-change"
|
||||||
(!selected.changedInside ||
|
: verification.rejectNoEffectiveChangeOnly
|
||||||
selected.rectangularSeamDetected ||
|
? null
|
||||||
selected.oldFootprintGhostDetected ||
|
: verification.rejectOldFootprintOnly
|
||||||
(selected.outsideChanged && !outsideChangeAccepted))
|
? selected.oldFootprintGhostDetected
|
||||||
) {
|
? "old-footprint-remnant-detected"
|
||||||
throw new ScaleAdjustOutputRejectedError({
|
: null
|
||||||
reason: selected.rectangularSeamDetected
|
: selected.rectangularSeamDetected
|
||||||
? "rectangular-seam-detected"
|
? "rectangular-seam-detected"
|
||||||
: selected.oldFootprintGhostDetected
|
: selected.oldFootprintGhostDetected
|
||||||
? "old-footprint-remnant-detected"
|
? "old-footprint-remnant-detected"
|
||||||
: selected.outsideChanged
|
: selected.outsideChanged && !outsideChangeAccepted
|
||||||
? "outside-frame-changed"
|
? "outside-frame-changed"
|
||||||
: "no-effective-scale-change",
|
: null;
|
||||||
|
if (verification.rejectFailedCandidates && rejectionReason) {
|
||||||
|
throw new ScaleAdjustOutputRejectedError({
|
||||||
|
reason: rejectionReason,
|
||||||
diffScores: selected.changeDiffScores,
|
diffScores: selected.changeDiffScores,
|
||||||
images: [selected.cleanedImage || selected.image],
|
images: [selected.cleanedImage || selected.image],
|
||||||
outsideDiffScores: selected.outsideDiffScores,
|
outsideDiffScores: selected.outsideDiffScores,
|
||||||
@ -4915,6 +4926,7 @@ export async function generateCanvasImage(
|
|||||||
const finalImages = await applyScaleAdjustVerificationToImages({
|
const finalImages = await applyScaleAdjustVerificationToImages({
|
||||||
debugLabel,
|
debugLabel,
|
||||||
images: scaleAdjustProductLockedImages,
|
images: scaleAdjustProductLockedImages,
|
||||||
|
skipAnnotationCleanup: Boolean(scaleAdjustProductLockComposite),
|
||||||
verification: scaleAdjustVerification,
|
verification: scaleAdjustVerification,
|
||||||
});
|
});
|
||||||
console.info("[Canvas Generate Image] Completed:", {
|
console.info("[Canvas Generate Image] Completed:", {
|
||||||
@ -4970,6 +4982,8 @@ export async function generateCanvasImage(
|
|||||||
const retryVariants = Array.isArray(request.retryVariants)
|
const retryVariants = Array.isArray(request.retryVariants)
|
||||||
? request.retryVariants
|
? request.retryVariants
|
||||||
: [];
|
: [];
|
||||||
|
const scaleAdjustRejection =
|
||||||
|
caughtError instanceof ScaleAdjustOutputRejectedError ? caughtError : null;
|
||||||
const nextRetryVariantIndex =
|
const nextRetryVariantIndex =
|
||||||
typeof request.internalRetryVariantIndex === "number"
|
typeof request.internalRetryVariantIndex === "number"
|
||||||
? request.internalRetryVariantIndex + 1
|
? request.internalRetryVariantIndex + 1
|
||||||
@ -4999,17 +5013,26 @@ export async function generateCanvasImage(
|
|||||||
error: caughtError instanceof Error ? caughtError.message : String(caughtError),
|
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({
|
return await generateCanvasImage({
|
||||||
...request,
|
...request,
|
||||||
...retryOverrides,
|
...retryOverrides,
|
||||||
|
...(sequentialCleanupReferenceUrl
|
||||||
|
? { referenceImages: [sequentialCleanupReferenceUrl] }
|
||||||
|
: {}),
|
||||||
debugMeta: retryDebugMeta,
|
debugMeta: retryDebugMeta,
|
||||||
internalRetryVariantIndex: nextRetryVariantIndex,
|
internalRetryVariantIndex: nextRetryVariantIndex,
|
||||||
retryVariants,
|
retryVariants,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const scaleAdjustRejection =
|
|
||||||
caughtError instanceof ScaleAdjustOutputRejectedError ? caughtError : null;
|
|
||||||
const scaleAdjustRejected = Boolean(scaleAdjustRejection);
|
const scaleAdjustRejected = Boolean(scaleAdjustRejection);
|
||||||
|
|
||||||
if (scaleAdjustRejected) {
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
/routes\/image-message-contracts\.test\.ts/,
|
/routes\/image-message-contracts\.test\.ts/,
|
||||||
/routes\/production-local-fallbacks\.test\.ts/,
|
/routes\/production-local-fallbacks\.test\.ts/,
|
||||||
/routes\/canvas-entry-performance\.test\.ts/,
|
/routes\/canvas-entry-performance\.test\.ts/,
|
||||||
|
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
||||||
],
|
],
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
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