fix: handle complex canvas scale adjustments

This commit is contained in:
134 2026-07-14 09:02:51 +08:00
parent ede6f080a2
commit 5a7121dceb
10 changed files with 1389 additions and 79 deletions

View File

@ -9,10 +9,16 @@ import {
getCanvasImageTaskForUser,
} from "@/lib/canvas/image-task-queue";
import type { CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
import { hasScaleAdjustPositionMoved } from "@/lib/canvas/scale-adjust-geometry";
import {
buildScaleAdjustProductLockAssets,
type CanvasScaleAdjustIntentRegion,
} from "@/lib/canvas/scale-adjust-product-lock";
import {
buildSemanticScaleAdjustCleanupPrompt,
buildSemanticScaleAdjustCompositeRegions,
buildSemanticScaleAdjustExtractionPrompt,
} from "@/lib/canvas/scale-adjust-semantic";
import {
getPointsSystemConfig,
} from "@/lib/db/queries";
@ -99,6 +105,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";
const SCALE_ADJUST_SEMANTIC_CLEANUP_MODEL_REF =
"5198c20f-b4cc-402c-bc73-eeabacd086b4";
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
const MIN_SCALE_ADJUST_PERCENT = -80;
const MAX_SCALE_ADJUST_PERCENT = 200;
@ -649,10 +657,8 @@ export async function POST(request: NextRequest) {
targetRect: getScaleAdjustTargetRect(region, scalePercent),
})
);
const hasPositionMove = scaleAdjustIntentRegions.some(
(region) =>
Math.abs(region.targetRect.x - region.rect.x) >= 0.005 ||
Math.abs(region.targetRect.y - region.rect.y) >= 0.005
const hasPositionMove = scaleAdjustIntentRegions.some((region) =>
hasScaleAdjustPositionMoved(region.rect, region.targetRect)
);
const productLockAssets = await buildScaleAdjustProductLockAssets({
protectPeople: hasPositionMove,
@ -663,11 +669,21 @@ export async function POST(request: NextRequest) {
sourceWidth,
userId: session.user.id,
});
const useSemanticScaleAdjust =
!hasPositionMove && productLockAssets.semanticFallbackSuggested;
const semanticCompositeRegions = useSemanticScaleAdjust
? buildSemanticScaleAdjustCompositeRegions(scaleAdjustIntentRegions)
: [];
const assetSuffix = `${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`;
const [modelMaskUpload, overlayUpload, repairMaskUpload, sourceSubjectMaskUpload] =
await Promise.all([
const [
modelMaskUpload,
overlayUpload,
repairMaskUpload,
semanticSourceCropUploads,
sourceSubjectMaskUpload,
] = await Promise.all([
uploadFile(
`canvas-scale-adjust-mask-${assetSuffix}.png`,
toArrayBuffer(productLockAssets.modelMaskBuffer)
@ -680,7 +696,18 @@ export async function POST(request: NextRequest) {
`canvas-scale-adjust-repair-mask-${assetSuffix}.png`,
toArrayBuffer(productLockAssets.repairMaskBuffer)
),
hasPositionMove
useSemanticScaleAdjust
? Promise.all(
productLockAssets.semanticSourceCropBuffers.map(
(buffer, index) =>
uploadFile(
`canvas-scale-adjust-semantic-source-${assetSuffix}-${index + 1}.png`,
toArrayBuffer(buffer)
)
)
)
: Promise.resolve([]),
hasPositionMove || useSemanticScaleAdjust
? uploadFile(
`canvas-scale-adjust-subject-mask-${assetSuffix}.png`,
toArrayBuffer(productLockAssets.sourceSubjectMaskBuffer)
@ -693,10 +720,16 @@ export async function POST(request: NextRequest) {
toArrayBuffer(productLockAssets.backgroundPlateBuffer)
)
: null;
const prompt = hasPositionMove
? "只编辑白色掩膜区域,完整移除旧位置的产品及其原有阴影,并根据周围墙面、地面、家具和光照连续自然地重建被遮挡背景。黑色区域保持不变,不要保留产品的任何部件或残影。"
: "移除掩膜中的完整产品并自然还原背景。";
const providerImageParams = hasPositionMove
const prompt = useSemanticScaleAdjust
? buildSemanticScaleAdjustExtractionPrompt()
: hasPositionMove
? "只编辑白色掩膜区域,完整移除旧位置的产品及其原有阴影,并根据周围墙面、地面、家具和光照连续自然地重建被遮挡背景。黑色区域保持不变,不要保留产品的任何部件或残影。"
: "移除掩膜中的完整产品并自然还原背景。";
const providerImageParams = useSemanticScaleAdjust
? {
quality: "high",
}
: hasPositionMove
? {
maskImageUrl: modelMaskUpload.url,
quality: "medium",
@ -732,9 +765,11 @@ export async function POST(request: NextRequest) {
const scaleAdjustVerification = {
allowOutsideChangedWhenChangedInside: false,
deterministicProductOverlayApplied: true,
oldFootprintMaskImageUrl: hasPositionMove
oldFootprintMaskImageUrl: useSemanticScaleAdjust
? sourceSubjectMaskUpload?.url
: repairMaskUpload.url,
: hasPositionMove
? sourceSubjectMaskUpload?.url
: repairMaskUpload.url,
regions: scaleAdjustIntentRegions.map((region) => region.rect),
rejectFailedCandidates: true,
rejectNoEffectiveChangeOnly: false,
@ -756,11 +791,19 @@ export async function POST(request: NextRequest) {
scaleAdjustProductLockMaskEditApplied: true,
scaleAdjustPersonExclusionApplied:
productLockAssets.personExclusionApplied,
scaleAdjustPromptStrategy: hasPositionMove
? "gpt-mask-background-rebuild-product-lock-v1"
: "finegrain-erase-product-lock-v1",
scaleAdjustProviderMaskImageUrl: modelMaskUpload.url,
scaleAdjustSequentialCleanupRetry: true,
scaleAdjustPromptStrategy: useSemanticScaleAdjust
? "gpt-extract-product-lock-v2"
: hasPositionMove
? "gpt-mask-background-rebuild-product-lock-v1"
: "finegrain-erase-product-lock-v1",
scaleAdjustProviderMaskImageUrl: useSemanticScaleAdjust
? null
: modelMaskUpload.url,
scaleAdjustSemanticFallbackRegionIndexes:
productLockAssets.semanticFallbackRegionIndexes,
scaleAdjustSemanticFallbackSuggested:
productLockAssets.semanticFallbackSuggested,
scaleAdjustSequentialCleanupRetry: !useSemanticScaleAdjust,
scaleAdjustTargetRegionCount: regions.filter((region) => region.targetRect)
.length,
scalePercent,
@ -778,7 +821,38 @@ export async function POST(request: NextRequest) {
promptExtend: false,
providerImageParams,
referenceImages: [sourceImageUrl],
retryVariants: [
...(useSemanticScaleAdjust
? {
localEditComposite: {
expandRegions: false,
featherMaxPx: 28,
featherMinPx: 12,
featherRatio: 0.012,
maskMode: "region" as const,
regions: semanticCompositeRegions,
sourceImageUrl,
},
}
: {}),
...(useSemanticScaleAdjust &&
semanticSourceCropUploads.length === scaleAdjustIntentRegions.length
? {
semanticScaleAdjustPreparation: {
cleanupImageSize: "2K",
cleanupModel: SCALE_ADJUST_SEMANTIC_CLEANUP_MODEL_REF,
cleanupPrompt: buildSemanticScaleAdjustCleanupPrompt(),
extractionPrompt: buildSemanticScaleAdjustExtractionPrompt(),
regions: scaleAdjustIntentRegions.map((region, index) => ({
source: region.rect,
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
target: region.targetRect,
})),
relightModel: SCALE_ADJUST_REPAIR_MODEL_REF,
sourceImageUrl,
},
}
: {}),
retryVariants: useSemanticScaleAdjust ? [] : [
{
debugMeta: {
scaleAdjustRetryReason: "old-footprint-remnant-detected",
@ -796,7 +870,9 @@ export async function POST(request: NextRequest) {
},
},
],
scaleAdjustProductLockComposite: productLockComposite,
...(useSemanticScaleAdjust
? {}
: { scaleAdjustProductLockComposite: productLockComposite }),
...(hasPositionMove
? {
scaleAdjustModelRelight: {

View File

@ -114,3 +114,48 @@
- GPT Image 2 Edit 的真实旧位置清理和真实光影输出都分别成功过;最终视觉回放由这两份真实模型输出通过当前正式合成代码得到。
- 因上述外部接口波动,最后一轮未得到“同一次在线请求从清理到重打光全部成功”的证据;这属于供应商请求故障,不记为算法视觉失败。生产流程已在任一模型阶段失败时回退到本地验证结果,避免用户任务直接报错。
- 后续复测优先重复完整在线链路,不再重复表中已经证伪的补洞、整图替换和宽区域重绘方法。
## 复杂衣帽架语义回退(2026-07-14)
### 真实问题样本
- 原图尺寸为 `1664x2496`,目标是带两顶帽子、两件衣物、两个包、多个挂钩、立柱和圆底座的完整衣帽架。
- 完整源框为 `{ x: 0.295, y: 0.225, width: 0.215, height: 0.515 }`;本轮回归缩小 `25%`,中心保持不变,目标框为 `{ x: 0.321875, y: 0.289375, width: 0.16125, height: 0.38625 }`。
- 早期使用的窄框漏掉蓝色衣物右侧、包和圆底座,属于输入几何错误,不再使用。
### 已测试方法与结论
| 方法 | 结果 | 结论 |
| --- | --- | --- |
| AOS 在窄框或重叠裁图中直接提取完整产品 | 只提取帽子和部分白衣,遗漏包、蓝衣或底座 | 失败;空间分割低置信度时必须切换语义回退 |
| PhotoRoom/RMBG 直接抠取复杂产品组 | 分别遗漏挂件或直接失败 | 不用于该类多部件产品的最终提取 |
| 方向扩散、调和等本地像素补背景 | 墙面和木饰面出现涂抹纹理 | 失败,不再尝试 |
| Finegrain Eraser 使用完整矩形或产品蒙版 | 仍保留旧立柱、衣物或底座残影 | 失败,不作为复杂样本清理通道 |
| GPT Image 2 对整图直接清理 | 产品能移除,但构图被重排、尺寸变化且水印丢失 | 失败,不能直接采用整图结果 |
| GPT Image 2 只清理局部补丁 | 木饰面边界偏移;差分贴回又产生透明旧影和接缝 | 失败,不再使用补丁差分合成 |
| Nano Banana 2 使用原图和完整局部裁图重建背景 | 旧衣帽架完整移除,构图、窗户、床、床头柜和水印保持 | 通过,作为复杂产品背景清理模型 |
| GPT Image 2 在纯品红背景上提取完整产品组 | 两份候选均包含帽子、衣物、包、挂钩、立柱和圆底座 | 通过;本地按源图颜色覆盖率和几何完整度选优 |
| 纯品红色键初版阈值 `44-112` | 产品边缘出现紫色溢色 | 失败;改为 `28-190`、反混色恢复和轻度 alpha 内收后通过 |
| 用宽矩形清理蒙版检查旧影 | 清洁背景被当作旧产品残留 | 误判;改为只按真实产品蒙版检查 |
| 接缝检测扫描新目标框内部 | 新立柱及其长阴影被当作竖向拼接缝 | 误判;目标框内部停止累计,框外背景继续严格检查 |
| 真实产品蒙版内使用宽松颜色相似度 `56` 和暗像素规则 | 深棕衣帽架与深色木饰面相近,清洁结果仍误报 | 误判;显式产品蒙版改用近原样阈值 `20`,暗像素规则只用于无显式蒙版回退 |
| Nano Banana 2 单次清理请求 | 一轮在 180 秒超时,下一轮约 52 秒成功 | 增加一次同模型清理重试;产品提取 Promise 复用,不重复提取 |
| GPT Image 2 环境光影模型 + 本地暗化场 | 窗边主光对应的墙面阴影可见,产品本体和框外构图保持 | 通过,作为最终光影步骤 |
### 最终流程
1. AOS 仍先生成产品蒙版;蒙版触边、覆盖不足或高瘦复杂裁图自动标记为语义低置信度。
2. Nano Banana 2 根据原图和完整产品裁图只重建产品背后的背景;清理与产品提取并行执行,清理超时只重试清理。
3. GPT Image 2 把整套产品提取到纯品红背景,生成两份完整候选;代码完成色键、反混色去紫边、完整度与颜色保真评分。
4. 选中产品透明像素严格按 `targetRect` 缩放并合成,模型不负责猜测产品尺寸和坐标。
5. 质量守卫使用真实产品蒙版检查旧影,接缝检测排除新产品占据区,但继续检查目标框外背景。
6. 守卫通过后,GPT Image 2 只生成产品周围的环境光与阴影;代码提取暗化场并叠回锁定的产品和原背景。
### 最终验收
- 无缓存端到端测试批次:`coat-rack-production-flow-complete-v11`。
- 最终原尺寸结果文件:`15-user-coat-rack-result.png`。
- 自动守卫:`oldFootprintGhostDetected: false`、`rectangularSeamDetected: false`、`outsideChanged: false`,候选 `passed: true`。
- 光影统计:`appliedShadowStrength: 1146766`、`medianLumaDelta: 11`、`sampleCount: 374490`。
- 完整链路耗时约 `241799ms`,结果 `SCALE_VERIFY_SUMMARY { failed: 0, passed: 1, total: 1 }`。
- 人工原尺寸检查通过:两个帽子、两件衣物、两个包、全部挂钩、立柱和圆底座完整;旧尺寸产品、紫边和矩形接缝均不可见,窗边来光对应的墙面投影已生成。

View File

@ -113,6 +113,23 @@ export interface CanvasScaleAdjustModelRelightConfig {
}>;
sourceHeight: number;
sourceWidth: number;
surfaceMode?: "floor" | "surround";
}
export interface CanvasSemanticScaleAdjustPreparationRegion {
source: CanvasLocalEditCompositeRegion;
sourceCropImageUrl: string;
target: CanvasLocalEditCompositeRegion;
}
export interface CanvasSemanticScaleAdjustPreparationConfig {
cleanupImageSize?: string;
cleanupModel: string;
cleanupPrompt: string;
extractionPrompt: string;
regions: CanvasSemanticScaleAdjustPreparationRegion[];
relightModel?: string;
sourceImageUrl: string;
}
export interface CanvasGenerateImageRetryVariant {
@ -123,6 +140,7 @@ export interface CanvasGenerateImageRetryVariant {
falThinkingLevel?: "minimal" | "high";
imageCount?: number;
label?: string;
localEditComposite?: CanvasLocalEditCompositeConfig | null;
model?: string;
negativePrompt?: string;
prompt?: string;
@ -156,11 +174,12 @@ export interface CanvasGenerateImageRequest {
debugLabel?: string;
debugMeta?: Record<string, unknown>;
ignoreModelMaxImages?: boolean;
localEditComposite?: CanvasLocalEditCompositeConfig;
localEditComposite?: CanvasLocalEditCompositeConfig | null;
scaleAdjustPatchComposite?: CanvasScaleAdjustPatchCompositeConfig;
scaleAdjustProductLockComposite?: CanvasScaleAdjustProductLockCompositeConfig;
scaleAdjustModelRelight?: CanvasScaleAdjustModelRelightConfig;
scaleAdjustVerification?: CanvasScaleAdjustVerificationConfig;
semanticScaleAdjustPreparation?: CanvasSemanticScaleAdjustPreparationConfig;
retryVariants?: CanvasGenerateImageRetryVariant[];
internalRetryVariantIndex?: number;
allowFailover?: boolean;
@ -243,6 +262,8 @@ const SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_RELAXED_MAX_RATIO = 0.84;
const SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_MIN_REMOVED_RATIO = 0.14;
const SCALE_ADJUST_SHRINK_MIN_SOURCE_OUTSIDE_PIXELS = 60;
const SCALE_ADJUST_SHRINK_TARGET_FOOTPRINT_MARGIN = 0.018;
const SCALE_ADJUST_OLD_FOOTPRINT_TARGET_MARGIN = 0.006;
const SCALE_ADJUST_OLD_FOOTPRINT_EXPLICIT_SIMILAR_DIFF_THRESHOLD = 20;
const SCALE_ADJUST_OLD_FOOTPRINT_SIMILAR_DIFF_THRESHOLD = 56;
const SCALE_ADJUST_OLD_FOOTPRINT_MIN_PIXELS = 80;
const SCALE_ADJUST_OLD_FOOTPRINT_SIMILAR_RATIO_THRESHOLD = 0.18;
@ -1171,6 +1192,12 @@ function summarizeScaleAdjustSeamLine(params: {
axis: "horizontal" | "vertical";
boxIndex: number;
changeMask: Uint8Array;
excludedBoxes: Array<{
height: number;
left: number;
top: number;
width: number;
}>;
generatedRaw: Buffer;
height: number;
position: number;
@ -1183,6 +1210,7 @@ function summarizeScaleAdjustSeamLine(params: {
axis,
boxIndex,
changeMask,
excludedBoxes,
generatedRaw,
height,
position,
@ -1216,6 +1244,18 @@ function summarizeScaleAdjustSeamLine(params: {
continue;
}
const intersectsExcludedBox = excludedBoxes.some((box) => {
const right = box.left + box.width;
const bottom = box.top + box.height;
const contains = (x: number, y: number) =>
x >= box.left && x < right && y >= box.top && y < bottom;
return contains(leftX, leftY) || contains(rightX, rightY);
});
if (intersectsExcludedBox) {
currentRun = 0;
continue;
}
const leftPixel = leftY * width + leftX;
const rightPixel = rightY * width + rightX;
const changedBoundary =
@ -1304,6 +1344,13 @@ async function getScaleAdjustRectangularSeamStats(params: {
width: sourceWidth,
})
);
const targetExclusionBoxes = (verification.targetRegions || []).map((region) =>
rectToPixelBox({
height: sourceHeight,
region,
width: sourceWidth,
})
);
const lineStep = Math.max(
1,
Math.floor(Math.min(sourceWidth, sourceHeight) / 1400)
@ -1323,6 +1370,7 @@ async function getScaleAdjustRectangularSeamStats(params: {
axis: "vertical",
boxIndex,
changeMask,
excludedBoxes: targetExclusionBoxes,
generatedRaw,
height: sourceHeight,
position: x,
@ -1341,6 +1389,7 @@ async function getScaleAdjustRectangularSeamStats(params: {
axis: "horizontal",
boxIndex,
changeMask,
excludedBoxes: targetExclusionBoxes,
generatedRaw,
height: sourceHeight,
position: y,
@ -1643,7 +1692,7 @@ async function getScaleAdjustOldFootprintGhostStats(params: {
height: sourceHeight,
region: expandCanvasRegion(
targetRegion,
SCALE_ADJUST_SHRINK_TARGET_FOOTPRINT_MARGIN * 1.35
SCALE_ADJUST_OLD_FOOTPRINT_TARGET_MARGIN * 1.35
),
width: sourceWidth,
});
@ -1703,10 +1752,10 @@ async function getScaleAdjustOldFootprintGhostStats(params: {
(sourceG - generatedG) ** 2 +
(sourceB - generatedB) ** 2
);
if (
sourceGeneratedDiff <=
SCALE_ADJUST_OLD_FOOTPRINT_SIMILAR_DIFF_THRESHOLD
) {
const similarDiffThreshold = explicitMaskRaw
? SCALE_ADJUST_OLD_FOOTPRINT_EXPLICIT_SIMILAR_DIFF_THRESHOLD
: SCALE_ADJUST_OLD_FOOTPRINT_SIMILAR_DIFF_THRESHOLD;
if (sourceGeneratedDiff <= similarDiffThreshold) {
similarPixels += 1;
}
if (
@ -1724,10 +1773,12 @@ async function getScaleAdjustOldFootprintGhostStats(params: {
sourceOutsidePixels > 0 ? darkResidualPixels / sourceOutsidePixels : 0;
const enoughPixels =
sourceOutsidePixels >= SCALE_ADJUST_OLD_FOOTPRINT_MIN_PIXELS;
const usesExplicitMask = explicitPixels > 0;
const detected =
enoughPixels &&
(similarRatio >= SCALE_ADJUST_OLD_FOOTPRINT_SIMILAR_RATIO_THRESHOLD ||
(!isMove &&
(!usesExplicitMask &&
!isMove &&
(darkResidualRatio >=
SCALE_ADJUST_OLD_FOOTPRINT_DARK_RATIO_THRESHOLD ||
(similarRatio >= 0.12 && darkResidualRatio >= 0.14))));
@ -4570,6 +4621,213 @@ export async function generateCanvasImage(
throw new Error("请输入提示词");
}
const semanticPreparation = request.semanticScaleAdjustPreparation;
if (
semanticPreparation &&
/^https?:\/\//i.test(semanticPreparation.sourceImageUrl) &&
semanticPreparation.cleanupModel.trim() &&
semanticPreparation.cleanupPrompt.trim() &&
semanticPreparation.extractionPrompt.trim() &&
semanticPreparation.regions.length > 0 &&
semanticPreparation.regions.every((region) =>
/^https?:\/\//i.test(region.sourceCropImageUrl)
)
) {
const cleanupComposite: CanvasLocalEditCompositeConfig = {
...(request.localEditComposite || {}),
expandRegions: false,
featherMaxPx: 32,
featherMinPx: 24,
featherRatio: 0.012,
maskMode: "region",
regions:
request.localEditComposite?.regions ||
semanticPreparation.regions.map((region) => region.source),
sourceImageUrl: semanticPreparation.sourceImageUrl,
};
const runSemanticCleanup = (attempt: number) =>
generateCanvasImage({
...request,
debugLabel: `${request.debugLabel || "canvas-image"}-semantic-cleanup${
attempt > 1 ? `-retry-${attempt}` : ""
}`,
debugMeta: {
...(request.debugMeta || {}),
scaleAdjustSemanticCleanupAttempt: attempt,
scaleAdjustSemanticStage: "cleanup",
},
ignoreModelMaxImages: true,
imageSize: semanticPreparation.cleanupImageSize || "2K",
localEditComposite: cleanupComposite,
model: semanticPreparation.cleanupModel,
prompt: semanticPreparation.cleanupPrompt,
providerImageParams: { quality: "high" },
referenceImages: [
semanticPreparation.sourceImageUrl,
...semanticPreparation.regions.map(
(region) => region.sourceCropImageUrl
),
],
retryVariants: [],
scaleAdjustModelRelight: undefined,
scaleAdjustVerification: undefined,
semanticScaleAdjustPreparation: undefined,
});
const cleanupPromise = runSemanticCleanup(1).catch((error) => {
console.warn(
"[Canvas Generate Image] Semantic cleanup failed; retrying without re-extracting product:",
{
debugLabel: request.debugLabel || "canvas-image",
error: error instanceof Error ? error.message : String(error),
}
);
return runSemanticCleanup(2);
});
const extractionPromises = semanticPreparation.regions.map(
(region, regionIndex) =>
generateCanvasImage({
...request,
aspectRatio: "auto",
debugLabel: `${request.debugLabel || "canvas-image"}-semantic-extract-${regionIndex + 1}`,
debugMeta: {
...(request.debugMeta || {}),
scaleAdjustSemanticRegionIndex: regionIndex + 1,
scaleAdjustSemanticStage: "extract",
},
ignoreModelMaxImages: true,
imageCount: 2,
imageSize: "1K",
localEditComposite: null,
prompt: semanticPreparation.extractionPrompt,
providerImageParams: { quality: "high" },
referenceImages: [
region.sourceCropImageUrl,
semanticPreparation.sourceImageUrl,
],
retryVariants: [],
scaleAdjustModelRelight: undefined,
scaleAdjustVerification: undefined,
semanticScaleAdjustPreparation: undefined,
})
);
const [cleanupResult, ...extractionResults] = await Promise.all([
cleanupPromise,
...extractionPromises,
]);
const cleanupImageUrl =
cleanupResult.images[0]?.originalUrl ||
cleanupResult.images[0]?.thumbnailUrl ||
"";
if (!cleanupImageUrl) {
throw new Error("比例调整完整产品清理失败");
}
const semanticRegions = semanticPreparation.regions.map(
(region, regionIndex) => ({
candidateImageUrls: (extractionResults[regionIndex]?.images || [])
.map((image) => image.originalUrl || image.thumbnailUrl)
.filter(Boolean),
sourceCropImageUrl: region.sourceCropImageUrl,
target: region.target,
})
);
if (semanticRegions.some((region) => region.candidateImageUrls.length === 0)) {
throw new Error("比例调整完整产品提取失败");
}
const { buildSemanticScaleAdjustProductComposite } = await import(
"@/lib/canvas/scale-adjust-semantic-product"
);
const semanticComposite = await buildSemanticScaleAdjustProductComposite({
backgroundImageUrl: cleanupImageUrl,
regions: semanticRegions,
sourceHeight:
Number(request.debugMeta?.sourceHeight) > 0
? Number(request.debugMeta?.sourceHeight)
: 1,
sourceWidth:
Number(request.debugMeta?.sourceWidth) > 0
? Number(request.debugMeta?.sourceWidth)
: 1,
});
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const thumbnailBuffer = await sharp(semanticComposite.mergedBuffer, {
failOn: "none",
})
.resize(512, 512, { fit: "inside", withoutEnlargement: true })
.png({ compressionLevel: 6 })
.toBuffer();
const [originalUpload, thumbnailUpload, overlayUpload] = await Promise.all([
uploadFile(
`canvas-scale-adjust-semantic-product-${suffix}.png`,
bufferToArrayBuffer(semanticComposite.mergedBuffer)
),
uploadFile(
`canvas-scale-adjust-semantic-product-thumb-${suffix}.png`,
bufferToArrayBuffer(thumbnailBuffer)
),
uploadFile(
`canvas-scale-adjust-semantic-overlay-${suffix}.png`,
bufferToArrayBuffer(semanticComposite.overlayBuffer)
),
]);
console.info("[Canvas Generate Image] Semantic product lock prepared:", {
candidateImageUrls: semanticRegions.map(
(region) => region.candidateImageUrls
),
candidateStats: semanticComposite.candidateStats,
cleanupImageUrl,
debugLabel: request.debugLabel || "canvas-scale-adjust-semantic-product",
originalUrl: originalUpload.url,
overlayUrl: overlayUpload.url,
});
const verifiedImages = await applyScaleAdjustVerificationToImages({
debugLabel: request.debugLabel || "canvas-scale-adjust-semantic-product",
images: [
{
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
},
],
skipAnnotationCleanup: true,
verification: request.scaleAdjustVerification || null,
});
const initialResult: CanvasGenerateImageResult = {
images: verifiedImages,
model: extractionResults[0]?.model || cleanupResult.model,
modelRef: extractionResults[0]?.modelRef || cleanupResult.modelRef,
prompt,
};
console.info("[Canvas Generate Image] Semantic product lock applied:", {
candidateStats: semanticComposite.candidateStats,
debugLabel: request.debugLabel || "canvas-scale-adjust-semantic-product",
regionCount: semanticRegions.length,
});
if (!semanticPreparation.relightModel) return initialResult;
const { applyScaleAdjustModelRelight } = await import(
"@/lib/canvas/scale-adjust-model-relight"
);
return applyScaleAdjustModelRelight({
generateImage: generateCanvasImage,
initialResult,
ownerKey: request.ownerKey,
request: {
...request,
scaleAdjustModelRelight: {
model: semanticPreparation.relightModel,
overlayImageUrl: overlayUpload.url,
quality: "medium",
regions: semanticPreparation.regions.map((region) => ({
target: region.target,
})),
sourceHeight: Number(request.debugMeta?.sourceHeight) || 1,
sourceWidth: Number(request.debugMeta?.sourceWidth) || 1,
surfaceMode: "surround",
},
semanticScaleAdjustPreparation: undefined,
},
});
}
const negativePrompt =
typeof request.negativePrompt === "string" ? request.negativePrompt : undefined;
const model = typeof request.model === "string" ? request.model : undefined;

View File

@ -0,0 +1,21 @@
export type ScaleAdjustRectLike = {
height: number;
width: number;
x: number;
y: number;
};
export function hasScaleAdjustPositionMoved(
source: ScaleAdjustRectLike,
target: ScaleAdjustRectLike,
tolerance = 0.005
) {
const sourceCenterX = source.x + source.width / 2;
const sourceCenterY = source.y + source.height / 2;
const targetCenterX = target.x + target.width / 2;
const targetCenterY = target.y + target.height / 2;
return (
Math.abs(targetCenterX - sourceCenterX) >= tolerance ||
Math.abs(targetCenterY - sourceCenterY) >= tolerance
);
}

View File

@ -67,12 +67,20 @@ async function buildModelRelightMask(params: {
for (const item of config.regions) {
const target = item.target;
const expandedFloorRegion = {
x: target.x - target.width * 0.16,
y: target.y + target.height * 0.68,
width: target.width * 1.32,
height: target.height * 0.5,
};
const expandedFloorRegion =
config.surfaceMode === "surround"
? {
x: target.x - target.width * 0.22,
y: target.y - target.height * 0.08,
width: target.width * 1.52,
height: target.height * 1.22,
}
: {
x: target.x - target.width * 0.16,
y: target.y + target.height * 0.68,
width: target.width * 1.32,
height: target.height * 0.5,
};
const box = rectToPixelBox({
height,
region: expandedFloorRegion,
@ -288,7 +296,9 @@ export async function applyScaleAdjustModelRelight(params: {
"复制产品、增加产品、移动产品、改变产品造型、改变构图、修改人物、文字、标注、边框",
ownerKey,
prompt:
"仅编辑白色遮罩中的地面区域。根据画面现有主光方向、环境光颜色、产品落地点和地面材质,为当前位置的产品生成真实自然的接触阴影与投射阴影。阴影必须贴合产品落地边缘,方向、软硬、颜色和强度与室内现有光照一致。黑色区域保持像素级不变;不要重绘、复制、移动或变形产品,不要修改人物、家具、墙面和构图。",
config.surfaceMode === "surround"
? "仅编辑白色遮罩中的产品周围表面。根据画面现有主光方向、环境光颜色、产品与墙面或地面的接触关系,为当前位置的产品生成真实自然的接触阴影与投射阴影。阴影方向、软硬、颜色和强度必须与室内现有光照一致。黑色区域保持像素级不变;不要重绘、复制、移动或变形产品,不要修改人物、家具、墙面纹理和构图。"
: "仅编辑白色遮罩中的地面区域。根据画面现有主光方向、环境光颜色、产品落地点和地面材质,为当前位置的产品生成真实自然的接触阴影与投射阴影。阴影必须贴合产品落地边缘,方向、软硬、颜色和强度与室内现有光照一致。黑色区域保持像素级不变;不要重绘、复制、移动或变形产品,不要修改人物、家具、墙面和构图。",
promptExtend: false,
providerImageParams: {
maskImageUrl: maskUpload.url,

View File

@ -1,6 +1,7 @@
import sharp from "sharp";
import { executeRmbgJob } from "@/lib/ai/rmbg/service";
import { isScaleAdjustSubjectMaskLowConfidence } from "@/lib/canvas/scale-adjust-semantic";
export type CanvasScaleAdjustRect = {
height: number;
width: number;
@ -28,6 +29,9 @@ export type CanvasScaleAdjustProductLockAssets = {
personExclusionApplied: boolean;
personExclusionMaskBuffer?: Buffer;
repairMaskBuffer: Buffer;
semanticFallbackRegionIndexes: number[];
semanticFallbackSuggested: boolean;
semanticSourceCropBuffers: Buffer[];
sourceSubjectMaskBuffer: Buffer;
subjectReferenceUrls: string[];
};
@ -36,6 +40,22 @@ function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
async function executeScaleAdjustRmbgJob(
params: Parameters<typeof executeRmbgJob>[0]
) {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return await executeRmbgJob(params);
} catch (error) {
lastError = error;
if (attempt === 3) break;
await new Promise((resolve) => setTimeout(resolve, attempt * 800));
}
}
throw lastError;
}
function rectToPixelBox(
rect: CanvasScaleAdjustRect,
sourceWidth: number,
@ -200,7 +220,7 @@ async function buildPersonExclusionAlpha(params: {
userId: string;
}) {
try {
const person = await executeRmbgJob({
const person = await executeScaleAdjustRmbgJob({
foregroundType: "person",
imageBuffer: params.sourceBuffer,
prompt: "比例调整人物保护蒙版",
@ -280,8 +300,10 @@ export async function buildScaleAdjustProductLockAssets(params: {
} = params;
const overlayInputs: sharp.OverlayOptions[] = [];
const maskInputs: sharp.OverlayOptions[] = [];
const semanticSourceCropBuffers: Buffer[] = [];
const sourceSubjectMaskInputs: sharp.OverlayOptions[] = [];
const subjectReferenceUrls: string[] = [];
const semanticFallbackRegionIndexes: number[] = [];
const personExclusionAlpha = params.protectPeople
? await buildPersonExclusionAlpha({
sourceBuffer,
@ -307,7 +329,8 @@ export async function buildScaleAdjustProductLockAssets(params: {
.extract(sourceBox)
.png({ compressionLevel: 6 })
.toBuffer();
const subject = await executeRmbgJob({
semanticSourceCropBuffers.push(cropBuffer);
const subject = await executeScaleAdjustRmbgJob({
imageBuffer: cropBuffer,
prompt: "比例调整完整产品蒙版",
skipGallerySave: true,
@ -325,6 +348,15 @@ export async function buildScaleAdjustProductLockAssets(params: {
exclusionAlpha: regionPersonExclusionAlpha,
imageBase64: subject.image_base64,
});
if (
isScaleAdjustSubjectMaskLowConfidence({
alpha,
height: sourceBox.height,
width: sourceBox.width,
})
) {
semanticFallbackRegionIndexes.push(region.index);
}
const scaledSubjectBuffer = await sharp(subjectBuffer, { failOn: "none" })
.resize(targetBox.width, targetBox.height, {
fit: "fill",
@ -362,8 +394,12 @@ export async function buildScaleAdjustProductLockAssets(params: {
width: sourceWidth,
},
};
const [modelMaskBuffer, overlayBuffer, repairMaskBuffer, sourceSubjectMaskBuffer] =
await Promise.all([
const [
modelMaskBuffer,
overlayBuffer,
repairMaskBuffer,
sourceSubjectMaskBuffer,
] = await Promise.all([
sharp({
create: {
background: { b: 0, g: 0, r: 0 },
@ -422,6 +458,9 @@ export async function buildScaleAdjustProductLockAssets(params: {
personExclusionApplied: Boolean(personExclusionAlpha),
...(personExclusionMaskBuffer ? { personExclusionMaskBuffer } : {}),
repairMaskBuffer,
semanticFallbackRegionIndexes,
semanticFallbackSuggested: semanticFallbackRegionIndexes.length > 0,
semanticSourceCropBuffers,
sourceSubjectMaskBuffer,
subjectReferenceUrls,
};

View File

@ -0,0 +1,500 @@
import "server-only";
import sharp from "sharp";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import type { CanvasLocalEditCompositeRegion } from "@/lib/canvas/generate-image";
type SemanticProductRegion = {
candidateImageUrls: string[];
sourceCropImageUrl: string;
target: CanvasLocalEditCompositeRegion;
};
const COLOR_BINS_PER_CHANNEL = 6;
const COLOR_PROFILE_SIZE = COLOR_BINS_PER_CHANNEL ** 3;
const MAGENTA_DESPILL_MIN_ALPHA = 12;
const MAGENTA_LOW_DISTANCE = 28;
const MAGENTA_HIGH_DISTANCE = 190;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function smoothStep(value: number) {
const progress = clamp(value, 0, 1);
return progress * progress * (3 - 2 * progress);
}
type ColorProfile = {
bins: Float64Array;
total: number;
};
function getColorBin(red: number, green: number, blue: number) {
const toBin = (value: number) =>
Math.min(
COLOR_BINS_PER_CHANNEL - 1,
Math.floor((value * COLOR_BINS_PER_CHANNEL) / 256)
);
return (
toBin(red) * COLOR_BINS_PER_CHANNEL ** 2 +
toBin(green) * COLOR_BINS_PER_CHANNEL +
toBin(blue)
);
}
function isInformativeColor(red: number, green: number, blue: number) {
const range = Math.max(red, green, blue) - Math.min(red, green, blue);
const luminance = red * 0.299 + green * 0.587 + blue * 0.114;
return range >= 14 || luminance < 190;
}
function buildColorProfile(params: {
alpha?: Buffer;
channels: number;
data: Buffer;
}) {
const { alpha, channels, data } = params;
const bins = new Float64Array(COLOR_PROFILE_SIZE);
let total = 0;
const pixelCount = Math.floor(data.length / channels);
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
const alphaWeight = alpha ? (alpha[pixelIndex] || 0) / 255 : 1;
if (alphaWeight < 0.12) continue;
const offset = pixelIndex * channels;
const red = data[offset] || 0;
const green = data[offset + 1] || 0;
const blue = data[offset + 2] || 0;
if (!isInformativeColor(red, green, blue)) continue;
bins[getColorBin(red, green, blue)] += alphaWeight;
total += alphaWeight;
}
return { bins, total };
}
function getNeighborBinWeight(profile: ColorProfile, binIndex: number) {
const redBin = Math.floor(binIndex / COLOR_BINS_PER_CHANNEL ** 2);
const remainder = binIndex % COLOR_BINS_PER_CHANNEL ** 2;
const greenBin = Math.floor(remainder / COLOR_BINS_PER_CHANNEL);
const blueBin = remainder % COLOR_BINS_PER_CHANNEL;
let weight = 0;
for (let redOffset = -1; redOffset <= 1; redOffset += 1) {
for (let greenOffset = -1; greenOffset <= 1; greenOffset += 1) {
for (let blueOffset = -1; blueOffset <= 1; blueOffset += 1) {
if (Math.abs(redOffset) + Math.abs(greenOffset) + Math.abs(blueOffset) > 1) {
continue;
}
const nextRed = redBin + redOffset;
const nextGreen = greenBin + greenOffset;
const nextBlue = blueBin + blueOffset;
if (
nextRed < 0 ||
nextRed >= COLOR_BINS_PER_CHANNEL ||
nextGreen < 0 ||
nextGreen >= COLOR_BINS_PER_CHANNEL ||
nextBlue < 0 ||
nextBlue >= COLOR_BINS_PER_CHANNEL
) {
continue;
}
const nextIndex =
nextRed * COLOR_BINS_PER_CHANNEL ** 2 +
nextGreen * COLOR_BINS_PER_CHANNEL +
nextBlue;
weight += profile.bins[nextIndex] || 0;
}
}
}
return weight;
}
function compareColorProfiles(source: ColorProfile, candidate: ColorProfile) {
const sourceMinWeight = Math.max(2, source.total * 0.0015);
const candidateMinWeight = Math.max(2, candidate.total * 0.001);
let coveredSourceBins = 0;
let coveredSourceWeight = 0;
let matchedCandidateWeight = 0;
let salientSourceBins = 0;
let salientSourceWeight = 0;
for (let index = 0; index < COLOR_PROFILE_SIZE; index += 1) {
const sourceWeight = source.bins[index] || 0;
if (sourceWeight < sourceMinWeight) continue;
salientSourceBins += 1;
salientSourceWeight += sourceWeight;
if (getNeighborBinWeight(candidate, index) >= candidateMinWeight) {
coveredSourceBins += 1;
coveredSourceWeight += sourceWeight;
}
}
for (let index = 0; index < COLOR_PROFILE_SIZE; index += 1) {
const candidateWeight = candidate.bins[index] || 0;
if (candidateWeight < candidateMinWeight) continue;
if (getNeighborBinWeight(source, index) >= sourceMinWeight) {
matchedCandidateWeight += candidateWeight;
}
}
const distinctCoverage =
coveredSourceBins / Math.max(1, salientSourceBins);
const weightedCoverage =
coveredSourceWeight / Math.max(1, salientSourceWeight);
return {
colorCoverage: distinctCoverage * 0.6 + weightedCoverage * 0.4,
colorFidelity: matchedCandidateWeight / Math.max(1, candidate.total),
};
}
async function buildSourceColorProfile(sourceCropImageUrl: string) {
const { data, info } = await sharp(
await fetchImageBufferFromUrl(sourceCropImageUrl),
{ failOn: "none" }
)
.rotate()
.resize({
fit: "inside",
height: 256,
width: 256,
withoutEnlargement: true,
})
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer({ resolveWithObject: true });
return buildColorProfile({ channels: info.channels, data });
}
function rectToPixelBox(params: {
height: number;
region: CanvasLocalEditCompositeRegion;
width: number;
}) {
const { height, region, width } = params;
const left = clamp(Math.floor(region.x * width), 0, width - 1);
const top = clamp(Math.floor(region.y * height), 0, height - 1);
const right = clamp(
Math.ceil((region.x + region.width) * width),
left + 1,
width
);
const bottom = clamp(
Math.ceil((region.y + region.height) * height),
top + 1,
height
);
return { height: bottom - top, left, top, width: right - left };
}
async function extractMagentaProduct(candidateImageUrl: string) {
const candidateBuffer = await sharp(
await fetchImageBufferFromUrl(candidateImageUrl),
{ failOn: "none" }
)
.rotate()
.removeAlpha()
.toColorspace("srgb")
.png({ compressionLevel: 6 })
.toBuffer();
const { data, info } = await sharp(candidateBuffer)
.raw()
.toBuffer({ resolveWithObject: true });
const { height, width } = info;
const alpha = Buffer.alloc(width * height);
let backgroundPixels = 0;
let visiblePixels = 0;
let minX = width;
let minY = height;
let maxX = -1;
let maxY = -1;
for (let pixelIndex = 0; pixelIndex < alpha.length; pixelIndex += 1) {
const offset = pixelIndex * 3;
const redDistance = 255 - (data[offset] || 0);
const greenDistance = data[offset + 1] || 0;
const blueDistance = 255 - (data[offset + 2] || 0);
const distance = Math.sqrt(
redDistance * redDistance +
greenDistance * greenDistance +
blueDistance * blueDistance
);
let nextAlpha = 255;
if (distance <= MAGENTA_LOW_DISTANCE) {
nextAlpha = 0;
} else if (distance < MAGENTA_HIGH_DISTANCE) {
nextAlpha = Math.round(
255 *
smoothStep(
(distance - MAGENTA_LOW_DISTANCE) /
(MAGENTA_HIGH_DISTANCE - MAGENTA_LOW_DISTANCE)
)
);
}
alpha[pixelIndex] = nextAlpha;
const opacity = nextAlpha / 255;
if (nextAlpha < MAGENTA_DESPILL_MIN_ALPHA) {
data[offset] = 0;
data[offset + 1] = 0;
data[offset + 2] = 0;
} else if (nextAlpha < 255) {
data[offset] = clamp(
Math.round(((data[offset] || 0) - (1 - opacity) * 255) / opacity),
0,
255
);
data[offset + 1] = clamp(
Math.round((data[offset + 1] || 0) / opacity),
0,
255
);
data[offset + 2] = clamp(
Math.round(((data[offset + 2] || 0) - (1 - opacity) * 255) / opacity),
0,
255
);
}
if (nextAlpha < 24) backgroundPixels += 1;
if (nextAlpha < 18) continue;
visiblePixels += 1;
const x = pixelIndex % width;
const y = Math.floor(pixelIndex / width);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
const pixelCount = width * height;
const backgroundRatio = backgroundPixels / Math.max(1, pixelCount);
const visibleRatio = visiblePixels / Math.max(1, pixelCount);
if (
backgroundRatio < 0.28 ||
visibleRatio < 0.015 ||
visibleRatio > 0.68 ||
maxX < minX ||
maxY < minY
) {
throw new Error("完整产品提取结果未生成有效纯色背景");
}
const padding = Math.max(2, Math.round(Math.min(width, height) * 0.008));
const left = Math.max(0, minX - padding);
const top = Math.max(0, minY - padding);
const right = Math.min(width, maxX + padding + 1);
const bottom = Math.min(height, maxY + padding + 1);
const softenedAlpha = await sharp(alpha, {
raw: { channels: 1, height, width },
})
.blur(0.35)
.linear(1.18, -24)
.extractChannel(0)
.raw()
.toBuffer();
const rgbaBuffer = await sharp(data, {
raw: { channels: 3, height, width },
})
.joinChannel(softenedAlpha, {
raw: { channels: 1, height, width },
})
.png({ compressionLevel: 6 })
.toBuffer();
const productBuffer = await sharp(rgbaBuffer)
.extract({
height: bottom - top,
left,
top,
width: right - left,
})
.png({ compressionLevel: 6 })
.toBuffer();
const touchesEdge =
minX <= 2 || minY <= 2 || maxX >= width - 3 || maxY >= height - 3;
const colorProfile = buildColorProfile({ alpha, channels: 3, data });
const heightSpan = (maxY - minY + 1) / height;
const widthSpan = (maxX - minX + 1) / width;
const geometryScore =
heightSpan * 180 +
widthSpan * 80 +
Math.min(1, visibleRatio / 0.3) * 50 -
(touchesEdge ? 120 : 0);
return { colorProfile, geometryScore, productBuffer, visiblePixels };
}
async function chooseProductCandidate(params: {
candidateImageUrls: string[];
sourceCropImageUrl: string;
}) {
const { candidateImageUrls, sourceCropImageUrl } = params;
const sourceColorProfile = await buildSourceColorProfile(sourceCropImageUrl);
const settled = await Promise.allSettled(
candidateImageUrls.map((candidateImageUrl) =>
extractMagentaProduct(candidateImageUrl)
)
);
const candidates = settled.flatMap((item, candidateIndex) =>
item.status === "fulfilled"
? [{ ...item.value, candidateIndex }]
: []
);
if (candidates.length === 0) {
throw new Error("比例调整无法提取完整产品组");
}
const scoredCandidates = candidates.map((candidate) => {
const { colorCoverage, colorFidelity } = compareColorProfiles(
sourceColorProfile,
candidate.colorProfile
);
return {
...candidate,
colorCoverage,
colorFidelity,
score:
candidate.geometryScore +
colorCoverage * 520 +
colorFidelity * 160,
};
});
scoredCandidates.sort((left, right) => right.score - left.score);
const selectedCandidate = scoredCandidates[0];
return {
candidate: selectedCandidate,
stats: scoredCandidates.map((candidate) => ({
candidateIndex: candidate.candidateIndex,
colorCoverage: Number(candidate.colorCoverage.toFixed(4)),
colorFidelity: Number(candidate.colorFidelity.toFixed(4)),
score: Math.round(candidate.score),
selected: candidate === selectedCandidate,
visiblePixels: candidate.visiblePixels,
})),
};
}
async function buildFallbackShadow(params: {
height: number;
overlayBuffer: Buffer;
width: number;
}) {
const { height, overlayBuffer, width } = params;
const alpha = await sharp(overlayBuffer, { failOn: "none" })
.resize(width, height, { fit: "fill" })
.ensureAlpha()
.extractChannel(3)
.blur(clamp(Math.min(width, height) * 0.0048, 3, 16))
.extractChannel(0)
.raw()
.toBuffer();
const shadowRgba = Buffer.alloc(width * height * 4);
const offsetX = Math.max(2, Math.round(width * 0.006));
const offsetY = Math.max(2, Math.round(height * 0.004));
for (let y = 0; y < height - offsetY; y += 1) {
for (let x = 0; x < width - offsetX; x += 1) {
const sourceIndex = y * width + x;
const targetIndex = (y + offsetY) * width + x + offsetX;
const rgbaIndex = targetIndex * 4;
shadowRgba[rgbaIndex] = 45;
shadowRgba[rgbaIndex + 1] = 39;
shadowRgba[rgbaIndex + 2] = 35;
shadowRgba[rgbaIndex + 3] = Math.min(
44,
Math.round((alpha[sourceIndex] || 0) * 0.17)
);
}
}
return sharp(shadowRgba, {
raw: { channels: 4, height, width },
})
.png({ compressionLevel: 6 })
.toBuffer();
}
export async function buildSemanticScaleAdjustProductComposite(params: {
backgroundImageUrl: string;
regions: SemanticProductRegion[];
sourceHeight: number;
sourceWidth: number;
}) {
const { backgroundImageUrl, regions, sourceHeight, sourceWidth } = params;
const overlayInputs: sharp.OverlayOptions[] = [];
const candidateStats: Array<{
candidateIndex: number;
colorCoverage: number;
colorFidelity: number;
regionIndex: number;
score: number;
selected: boolean;
visiblePixels: number;
}> = [];
for (const [regionIndex, region] of regions.entries()) {
const choice = await chooseProductCandidate({
candidateImageUrls: region.candidateImageUrls,
sourceCropImageUrl: region.sourceCropImageUrl,
});
const { candidate } = choice;
const targetBox = rectToPixelBox({
height: sourceHeight,
region: region.target,
width: sourceWidth,
});
const resizedProduct = await sharp(candidate.productBuffer, {
failOn: "none",
})
.resize(targetBox.width, targetBox.height, {
fit: "fill",
kernel: sharp.kernel.lanczos3,
})
.png({ compressionLevel: 6 })
.toBuffer();
overlayInputs.push({
input: resizedProduct,
left: targetBox.left,
top: targetBox.top,
});
candidateStats.push(
...choice.stats.map((stat) => ({ ...stat, regionIndex }))
);
}
const overlayBuffer = await sharp({
create: {
background: { alpha: 0, b: 0, g: 0, r: 0 },
channels: 4,
height: sourceHeight,
width: sourceWidth,
},
})
.composite(overlayInputs)
.png({ compressionLevel: 6 })
.toBuffer();
const fallbackShadowBuffer = await buildFallbackShadow({
height: sourceHeight,
overlayBuffer,
width: sourceWidth,
});
const backgroundBuffer = await sharp(
await fetchImageBufferFromUrl(backgroundImageUrl),
{ failOn: "none" }
)
.rotate()
.resize(sourceWidth, sourceHeight, { fit: "fill" })
.ensureAlpha()
.png({ compressionLevel: 6 })
.toBuffer();
const mergedBuffer = await sharp(backgroundBuffer)
.composite([
{ input: fallbackShadowBuffer, left: 0, top: 0 },
{ input: overlayBuffer, left: 0, top: 0 },
])
.png({ compressionLevel: 6 })
.toBuffer();
return { candidateStats, mergedBuffer, overlayBuffer };
}

View File

@ -0,0 +1,114 @@
import type { CanvasScaleAdjustIntentRegion } from "@/lib/canvas/scale-adjust-product-lock";
const SEMANTIC_FALLBACK_MIN_HEIGHT_SPAN = 0.78;
const SEMANTIC_FALLBACK_MIN_WIDTH_SPAN = 0.62;
const SEMANTIC_FALLBACK_TALL_ASPECT_RATIO = 2.7;
const SEMANTIC_COMPOSITE_MIN_PADDING_X = 0.04;
const SEMANTIC_COMPOSITE_MAX_PADDING_X = 0.08;
const SEMANTIC_COMPOSITE_MIN_PADDING_Y = 0.06;
const SEMANTIC_COMPOSITE_MAX_PADDING_Y = 0.12;
function clampRatio(value: number) {
return Math.min(1, Math.max(0, value));
}
export function isScaleAdjustSubjectMaskLowConfidence(params: {
alpha: Uint8Array;
height: number;
width: number;
}) {
const { alpha, height, width } = params;
if (height / Math.max(1, width) >= SEMANTIC_FALLBACK_TALL_ASPECT_RATIO) {
return true;
}
let minX = width;
let minY = height;
let maxX = -1;
let maxY = -1;
for (let index = 0; index < alpha.length; index += 1) {
if (alpha[index] < 96) continue;
const x = index % width;
const y = Math.floor(index / width);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
if (maxX < minX || maxY < minY) return true;
const edgeMargin = Math.max(2, Math.round(Math.min(width, height) * 0.012));
if (
minX <= edgeMargin ||
minY <= edgeMargin ||
maxX >= width - 1 - edgeMargin ||
maxY >= height - 1 - edgeMargin
) {
return true;
}
const widthSpan = (maxX - minX + 1) / width;
const heightSpan = (maxY - minY + 1) / height;
return (
heightSpan < SEMANTIC_FALLBACK_MIN_HEIGHT_SPAN &&
widthSpan < SEMANTIC_FALLBACK_MIN_WIDTH_SPAN
);
}
export function buildSemanticScaleAdjustCompositeRegions(
regions: CanvasScaleAdjustIntentRegion[]
) {
return regions.map((region) => {
const left = Math.min(region.rect.x, region.targetRect.x);
const top = Math.min(region.rect.y, region.targetRect.y);
const right = Math.max(
region.rect.x + region.rect.width,
region.targetRect.x + region.targetRect.width
);
const bottom = Math.max(
region.rect.y + region.rect.height,
region.targetRect.y + region.targetRect.height
);
const width = right - left;
const height = bottom - top;
const paddingX = Math.min(
SEMANTIC_COMPOSITE_MAX_PADDING_X,
Math.max(SEMANTIC_COMPOSITE_MIN_PADDING_X, width * 0.3)
);
const paddingY = Math.min(
SEMANTIC_COMPOSITE_MAX_PADDING_Y,
Math.max(SEMANTIC_COMPOSITE_MIN_PADDING_Y, height * 0.25)
);
const expandedLeft = clampRatio(left - paddingX);
const expandedTop = clampRatio(top - paddingY);
const expandedRight = clampRatio(right + paddingX);
const expandedBottom = clampRatio(bottom + paddingY);
return {
x: expandedLeft,
y: expandedTop,
width: expandedRight - expandedLeft,
height: expandedBottom - expandedTop,
};
});
}
export function buildSemanticScaleAdjustCleanupPrompt() {
return [
"这是两阶段图片编辑的第一阶段。第一张参考图是完整场景,后续参考图是用户框选目标的局部特写。",
"在完整场景中只移除局部特写所展示的整套目标产品组及其阴影,包括产品主体、结构件、支撑件、底座、挂钩,以及附着或悬挂的全部衣物、帽子、包、饰品和配件。",
"不要只移除最显眼的单个部件,不得留下立柱、底座、挂钩、衣物、包、轮廓、透明残影或旧阴影。",
"根据周围墙面、木饰面、地面和光照自然补全被遮挡背景。完整场景的画幅、相机、构图、透视、尺寸、其他家具、文字和水印必须保持原图不变。不要新增产品、文字、框线、箭头或标注。",
].join("\n");
}
export function buildSemanticScaleAdjustExtractionPrompt() {
return [
"Product extraction task. The first reference is the user's target crop and the second reference is the full scene for context. Reconstruct exactly one complete target product group as one intact object.",
"The complete group includes the main product body, every structural part, support, pole, leg, wheel, hook and base, plus every attached or hanging garment, hat, bag, strap, accessory and decoration visible in the crop.",
"Treat every foreground item that overlaps, touches or hangs from the main product inside the crop as part of this one inseparable group. Do not choose only the easiest garment or accessory.",
"Preserve the exact original design, support and base style, proportions, component count, materials, colors, textures, orientation and attachment relationships. Do not redesign the support, replace a round base with legs, simplify the group or omit less salient items.",
"Place the complete group centered and fully visible from its highest point to its lowest support on a perfectly uniform pure magenta RGB(255,0,255) background.",
"The background must be one flat exact color with no gradient, texture, checkerboard, room, floor, wall or shadow.",
"No crop, no missing parts, no duplicate parts, no text, no frame and no annotation.",
].join("\n");
}

View File

@ -8,12 +8,18 @@ import {
type CanvasGenerateImageRequest,
generateCanvasImage,
} from "@/lib/canvas/generate-image";
import { hasScaleAdjustPositionMoved } from "@/lib/canvas/scale-adjust-geometry";
import {
buildScaleAdjustProductLockAssets,
type CanvasScaleAdjustIntentRegion,
type CanvasScaleAdjustRect,
} from "@/lib/canvas/scale-adjust-product-lock";
import { applyScaleAdjustModelRelight } from "@/lib/canvas/scale-adjust-model-relight";
import {
buildSemanticScaleAdjustCleanupPrompt,
buildSemanticScaleAdjustCompositeRegions,
buildSemanticScaleAdjustExtractionPrompt,
} from "@/lib/canvas/scale-adjust-semantic";
import { createStorageUploadSession } from "@/lib/storage/service";
type VerificationCase = {
@ -111,9 +117,30 @@ const cases: VerificationCase[] = [
source: "https://scqny.jiansun.vip/uploads/1783927690517-8uhlqu.jpg",
target: { height: 0.235, width: 0.225, x: 0.55, y: 0.72 },
},
{
id: "15-user-coat-rack",
rect: { height: 0.515, width: 0.215, x: 0.295, y: 0.225 },
scalePercent: -25,
source: "https://scqny.jiansun.vip/uploads/1783943425127-artezr.png",
},
];
const aspectRatios = ["1:1", "4:3", "3:4", "16:9", "9:16"] as const;
const aspectRatios = [
"1:1",
"1:4",
"1:8",
"2:3",
"3:2",
"3:4",
"4:1",
"4:3",
"4:5",
"5:4",
"8:1",
"9:16",
"16:9",
"21:9",
] as const;
function toArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
@ -184,7 +211,11 @@ async function runCase(params: {
const regions: CanvasScaleAdjustIntentRegion[] = [
{ index: 1, rect: item.rect, targetRect: targetAroundCenter(item) },
];
const hasPositionMove = regions.some((region) =>
hasScaleAdjustPositionMoved(region.rect, region.targetRect)
);
const assets = await buildScaleAdjustProductLockAssets({
protectPeople: hasPositionMove,
regions,
sourceBuffer,
sourceHeight,
@ -203,6 +234,12 @@ async function runCase(params: {
resolve(outputDir, `${item.id}-repair-mask.png`),
assets.repairMaskBuffer
),
...assets.semanticSourceCropBuffers.map((buffer, index) =>
writeFile(
resolve(outputDir, `${item.id}-semantic-source-${index + 1}.png`),
buffer
)
),
writeFile(
resolve(outputDir, `${item.id}-source-subject-mask.png`),
assets.sourceSubjectMaskBuffer
@ -233,6 +270,8 @@ async function runCase(params: {
ok: true,
personExclusionApplied: assets.personExclusionApplied,
rect: item.rect,
semanticFallbackRegionIndexes: assets.semanticFallbackRegionIndexes,
semanticFallbackSuggested: assets.semanticFallbackSuggested,
source: item.source,
targetRect: regions[0].targetRect,
};
@ -241,14 +280,32 @@ async function runCase(params: {
}
const suffix = `${item.id}-${Date.now()}`;
const [modelMaskUpload, overlayUpload, repairMaskUpload, sourceSubjectMaskUpload] =
await Promise.all([
const useSemanticScaleAdjust =
!hasPositionMove && assets.semanticFallbackSuggested;
const semanticCompositeRegions = useSemanticScaleAdjust
? buildSemanticScaleAdjustCompositeRegions(regions)
: [];
const [
modelMaskUpload,
overlayUpload,
repairMaskUpload,
semanticSourceCropUploads,
sourceSubjectMaskUpload,
] = 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)
),
Promise.all(
assets.semanticSourceCropBuffers.map((buffer, index) =>
upload(
`verify-scale-semantic-source-${suffix}-${index + 1}.png`,
toArrayBuffer(buffer)
)
)
),
upload(
`verify-scale-subject-mask-${suffix}.png`,
toArrayBuffer(assets.sourceSubjectMaskBuffer)
@ -260,12 +317,9 @@ async function runCase(params: {
toArrayBuffer(assets.backgroundPlateBuffer)
)
: null;
const hasPositionMove = regions.some(
(region) =>
Math.abs(region.targetRect.x - region.rect.x) >= 0.005 ||
Math.abs(region.targetRect.y - region.rect.y) >= 0.005
);
const providerImageParams = hasPositionMove
const providerImageParams = useSemanticScaleAdjust
? { quality: "high" }
: hasPositionMove
? { maskImageUrl: modelMaskUpload.url, quality: "medium" }
: {
objectRemovalMaskImageUrl: modelMaskUpload.url,
@ -282,21 +336,27 @@ async function runCase(params: {
sourceImageUrl: item.source,
}
: {}),
regions: regions.map((region) => ({
source: region.rect,
target: region.targetRect,
})),
shadowSourceImageUrl: item.source,
sourceSubjectMaskImageUrl: sourceSubjectMaskUpload.url,
...(hasPositionMove
? {
regions: regions.map((region) => ({
source: region.rect,
target: region.targetRect,
})),
shadowSourceImageUrl: item.source,
sourceSubjectMaskImageUrl: sourceSubjectMaskUpload.url,
}
: {}),
sourceHeight,
sourceWidth,
};
const verification = {
allowOutsideChangedWhenChangedInside: false,
deterministicProductOverlayApplied: true,
oldFootprintMaskImageUrl: hasPositionMove
oldFootprintMaskImageUrl: useSemanticScaleAdjust
? sourceSubjectMaskUpload.url
: repairMaskUpload.url,
: hasPositionMove
? sourceSubjectMaskUpload.url
: repairMaskUpload.url,
regions: [item.rect],
rejectFailedCandidates: true,
rejectNoEffectiveChangeOnly: false,
@ -304,9 +364,11 @@ async function runCase(params: {
sourceImageUrl: item.source,
targetRegions: [regions[0].targetRect],
};
const prompt = hasPositionMove
? "只编辑白色掩膜区域,完整移除旧位置的产品及其原有阴影,并根据周围墙面、地面、家具和光照连续自然地重建被遮挡背景。黑色区域保持不变,不要保留产品的任何部件或残影。"
: "移除掩膜中的完整产品并自然还原背景。";
const prompt = useSemanticScaleAdjust
? buildSemanticScaleAdjustExtractionPrompt()
: hasPositionMove
? "只编辑白色掩膜区域,完整移除旧位置的产品及其原有阴影,并根据周围墙面、地面、家具和光照连续自然地重建被遮挡背景。黑色区域保持不变,不要保留产品的任何部件或残影。"
: "移除掩膜中的完整产品并自然还原背景。";
const request: CanvasGenerateImageRequest = {
allowFailover: false,
aspectRatio: hasPositionMove
@ -315,10 +377,17 @@ async function runCase(params: {
debugLabel: `verify-scale-final-${item.id}`,
debugMeta: {
operation: "scale-adjust",
scaleAdjustPromptStrategy: hasPositionMove
? "gpt-mask-background-rebuild-product-lock-v1"
: "finegrain-erase-product-lock-v1",
scaleAdjustSequentialCleanupRetry: true,
scaleAdjustPromptStrategy: useSemanticScaleAdjust
? "gpt-extract-product-lock-v2"
: hasPositionMove
? "gpt-mask-background-rebuild-product-lock-v1"
: "finegrain-erase-product-lock-v1",
scaleAdjustSemanticFallbackRegionIndexes:
assets.semanticFallbackRegionIndexes,
scaleAdjustSemanticFallbackSuggested: assets.semanticFallbackSuggested,
scaleAdjustSequentialCleanupRetry: !useSemanticScaleAdjust,
sourceHeight,
sourceWidth,
},
displayPrompt: "比例调整",
imageCount: 1,
@ -330,7 +399,40 @@ async function runCase(params: {
promptExtend: false,
providerImageParams,
referenceImages: [item.source],
retryVariants: [
...(useSemanticScaleAdjust
? {
localEditComposite: {
expandRegions: false,
featherMaxPx: 28,
featherMinPx: 12,
featherRatio: 0.012,
maskMode: "region" as const,
regions: semanticCompositeRegions,
sourceImageUrl: item.source,
},
}
: {}),
...(useSemanticScaleAdjust
? {
semanticScaleAdjustPreparation: {
cleanupImageSize: "2K",
cleanupModel: "5198c20f-b4cc-402c-bc73-eeabacd086b4",
cleanupPrompt: buildSemanticScaleAdjustCleanupPrompt(),
extractionPrompt: buildSemanticScaleAdjustExtractionPrompt(),
regions: regions.map((region, index) => ({
source: region.rect,
sourceCropImageUrl:
semanticSourceCropUploads[index]?.url || "",
target: region.targetRect,
})),
relightModel:
process.env.SCALE_ADJUST_MODEL ||
"e03d3341-cecd-425e-b88b-9350cbbce18f",
sourceImageUrl: item.source,
},
}
: {}),
retryVariants: useSemanticScaleAdjust ? [] : [
{
label: "finegrain-cleanup-pass",
prompt,
@ -344,17 +446,23 @@ async function runCase(params: {
},
},
],
scaleAdjustModelRelight: {
model:
process.env.SCALE_ADJUST_MODEL ||
"e03d3341-cecd-425e-b88b-9350cbbce18f",
overlayImageUrl: overlayUpload.url,
quality: "medium",
regions: regions.map((region) => ({ target: region.targetRect })),
sourceHeight,
sourceWidth,
},
scaleAdjustProductLockComposite: productLockComposite,
...(hasPositionMove
? {
scaleAdjustModelRelight: {
model:
process.env.SCALE_ADJUST_MODEL ||
"e03d3341-cecd-425e-b88b-9350cbbce18f",
overlayImageUrl: overlayUpload.url,
quality: "medium" as const,
regions: regions.map((region) => ({ target: region.targetRect })),
sourceHeight,
sourceWidth,
},
}
: {}),
...(useSemanticScaleAdjust
? {}
: { scaleAdjustProductLockComposite: productLockComposite }),
scaleAdjustVerification: verification,
};
const initialGenerated = await generateCanvasImage(request);

View File

@ -3,6 +3,13 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { hasScaleAdjustTargetChanged } from "../../components/canvas/nodes/image-node/image-node-local-edit";
import { hasScaleAdjustPositionMoved } from "../../lib/canvas/scale-adjust-geometry";
import {
buildSemanticScaleAdjustCleanupPrompt,
buildSemanticScaleAdjustCompositeRegions,
buildSemanticScaleAdjustExtractionPrompt,
isScaleAdjustSubjectMaskLowConfidence,
} from "../../lib/canvas/scale-adjust-semantic";
function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8");
@ -25,6 +32,105 @@ test.describe("canvas scale adjust product lock", () => {
).toBe(true);
});
test("does not treat center-preserving scale as a position move", () => {
const source = { height: 0.4, width: 0.3, x: 0.2, y: 0.25 };
const centeredShrink = {
height: 0.3,
width: 0.225,
x: 0.2375,
y: 0.3,
};
expect(hasScaleAdjustPositionMoved(source, centeredShrink)).toBe(false);
expect(
hasScaleAdjustPositionMoved(source, { ...centeredShrink, x: 0.45 })
).toBe(true);
});
test("routes spatially incomplete product masks to semantic image editing", () => {
const partialMask = new Uint8Array(10_000);
for (let y = 20; y < 60; y += 1) {
for (let x = 30; x < 60; x += 1) partialMask[y * 100 + x] = 255;
}
const completeMask = new Uint8Array(10_000);
for (let y = 10; y < 90; y += 1) {
for (let x = 15; x < 85; x += 1) completeMask[y * 100 + x] = 255;
}
const croppedMask = new Uint8Array(10_000);
for (let y = 20; y < 100; y += 1) {
for (let x = 20; x < 80; x += 1) croppedMask[y * 100 + x] = 255;
}
expect(
isScaleAdjustSubjectMaskLowConfidence({
alpha: partialMask,
height: 100,
width: 100,
})
).toBe(true);
expect(
isScaleAdjustSubjectMaskLowConfidence({
alpha: completeMask,
height: 100,
width: 100,
})
).toBe(false);
expect(
isScaleAdjustSubjectMaskLowConfidence({
alpha: croppedMask,
height: 100,
width: 100,
})
).toBe(true);
});
test("splits complete-product scaling into cleanup and chroma extraction", () => {
const cleanupPrompt = buildSemanticScaleAdjustCleanupPrompt();
const extractionPrompt = buildSemanticScaleAdjustExtractionPrompt();
expect(cleanupPrompt).toContain("只移除局部特写所展示的整套目标产品组");
expect(cleanupPrompt).toContain("立柱、底座、挂钩、衣物、包");
expect(extractionPrompt).toContain("complete target product group");
expect(extractionPrompt).toContain("every attached or hanging garment");
expect(extractionPrompt).toContain("pure magenta RGB(255,0,255)");
expect(extractionPrompt).toContain("no missing parts");
});
test("composites semantic edits with enough context around the complete product", () => {
const [compositeRegion] = buildSemanticScaleAdjustCompositeRegions([
{
index: 1,
rect: { height: 0.515, width: 0.215, x: 0.295, y: 0.225 },
targetRect: {
height: 0.38625,
width: 0.16125,
x: 0.321875,
y: 0.289375,
},
},
]);
expect(compositeRegion.x).toBeLessThan(0.24);
expect(compositeRegion.y).toBeLessThan(0.11);
expect(compositeRegion.x + compositeRegion.width).toBeGreaterThan(0.57);
expect(compositeRegion.y + compositeRegion.height).toBeGreaterThan(0.85);
});
test("locks semantic extraction to exact target geometry after cleanup", () => {
const generator = readSource("lib/canvas/generate-image.ts");
const semanticProduct = readSource(
"lib/canvas/scale-adjust-semantic-product.ts"
);
expect(generator).toContain("buildSemanticScaleAdjustProductComposite");
expect(generator).toContain("candidateImageUrls");
expect(generator).toContain("imageCount: 2");
expect(semanticProduct).toContain('fit: "fill"');
expect(semanticProduct).toContain("MAGENTA_LOW_DISTANCE");
expect(semanticProduct).toContain("MAGENTA_DESPILL_MIN_ALPHA");
expect(semanticProduct).toContain("fallbackShadowBuffer");
});
test("uses the same movement validation in canvas and detail result flows", () => {
const canvasNode = readSource("components/canvas/nodes/ImageNode.tsx");
const detailWorkspace = readSource(
@ -45,19 +151,32 @@ test.describe("canvas scale adjust product lock", () => {
expect(route).toContain("objectRemovalMaskImageUrl: modelMaskUpload.url");
expect(route).toContain("maskImageUrl: modelMaskUpload.url");
expect(route).toContain("hasPositionMove");
expect(route).toContain("hasScaleAdjustPositionMoved(");
expect(route).toContain("useSemanticScaleAdjust");
expect(route).toContain("semanticSourceCropUploads");
expect(route).toContain("buildSemanticScaleAdjustCleanupPrompt");
expect(route).toContain("buildSemanticScaleAdjustExtractionPrompt");
expect(route).toContain("buildSemanticScaleAdjustCompositeRegions");
expect(route).toContain('maskMode: "region" as const');
expect(route).toContain("expandRegions: false");
expect(route).toContain("featherRatio: 0.012");
expect(route).toContain('aspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio');
expect(route).toContain("overlayImageUrl: overlayUpload.url");
expect(route).toContain("repairMaskImageUrl: repairMaskUpload.url");
expect(route).toContain("sourceImageUrl");
expect(route).toContain("protectPeople: hasPositionMove");
expect(route).toContain("oldFootprintMaskImageUrl: hasPositionMove");
expect(route).toContain("oldFootprintMaskImageUrl: useSemanticScaleAdjust");
expect(route).toContain(": hasPositionMove");
expect(route).toContain("? sourceSubjectMaskUpload?.url");
expect(route).toContain("deterministicProductOverlayApplied: true");
expect(route).toContain(
"deterministicProductOverlayApplied: true"
);
expect(route).toContain("scaleAdjustPersonExclusionApplied:");
expect(route).toContain("sourceSubjectMaskImageUrl: sourceSubjectMaskUpload.url");
expect(route).toContain("shadowSourceImageUrl: sourceImageUrl");
expect(route).toContain('"finegrain-erase-product-lock-v1"');
expect(route).toContain('"gpt-mask-background-rebuild-product-lock-v1"');
expect(route).toContain('"gpt-extract-product-lock-v2"');
expect(route).not.toContain("buildScaleAdjustVisualGuide");
expect(route).not.toContain("source-plus-guide-v1");
});
@ -73,16 +192,34 @@ test.describe("canvas scale adjust product lock", () => {
expect(provider).toContain('objectRemovalMode || ""');
});
test("retries old-footprint failures against the first cleaned result", () => {
test("keeps legacy cleanup retry while semantic extraction uses fresh candidates", () => {
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(
"scaleAdjustSequentialCleanupRetry: !useSemanticScaleAdjust"
);
expect(route).toContain("retryVariants: useSemanticScaleAdjust ? []");
expect(route).toContain('label: "finegrain-cleanup-pass"');
expect(route).toContain("semanticScaleAdjustPreparation");
expect(route).toContain("cleanupModel:");
expect(route).not.toContain("cleanupMaskImageUrl:");
expect(route).toContain("extractionPrompt:");
expect(route).toContain("sourceCropImageUrl:");
expect(route).toContain("relightModel:");
expect(route).toContain('quality: "high"');
expect(generator).toContain(
'scaleAdjustRejection?.reason === "old-footprint-remnant-detected"'
);
expect(generator).toContain(
"SCALE_ADJUST_OLD_FOOTPRINT_TARGET_MARGIN = 0.006"
);
expect(generator).toContain(
"SCALE_ADJUST_OLD_FOOTPRINT_EXPLICIT_SIMILAR_DIFF_THRESHOLD = 20"
);
expect(generator).toContain("scaleAdjustSemanticCleanupAttempt: attempt");
expect(generator).toContain("return runSemanticCleanup(2)");
expect(generator).toContain("referenceImages: [sequentialCleanupReferenceUrl]");
});
@ -99,6 +236,8 @@ test.describe("canvas scale adjust product lock", () => {
const rmbg = readSource("lib/ai/rmbg/service.ts");
expect(productLock).toContain('foregroundType: "person"');
expect(productLock).toContain("attempt <= 3");
expect(productLock).toContain("executeScaleAdjustRmbgJob({");
expect(productLock).toContain("regionPersonExclusionAlpha");
expect(productLock).toContain("overlapPixelCount >= overlapLimit");
expect(productLock).toContain("目标产品被人物遮挡");
@ -119,7 +258,7 @@ test.describe("canvas scale adjust product lock", () => {
expect(generator).toContain(
"const maskHit = explicitMaskRaw\n ? explicitMaskValue > 18"
);
expect(generator).toContain("(!isMove &&");
expect(generator).toContain("(!usesExplicitMask &&");
expect(generator).toContain(
"const generatedWidth = compositeBaseBuffer ? overlayWidth : modelWidth"
);