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 { 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 = {
expectedError?: string;
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",
},
{
id: "11-office-chair-move-right",
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.72, width: 0.64, x: 0.31, y: 0.25 },
},
{
id: "12-bookshelf-person-move-shrink",
expectedError: "目标产品被人物遮挡",
rect: { height: 0.88, width: 0.29, x: 0.37, y: 0.07 },
source: "https://scqny.jiansun.vip/uploads/1783687809170-vmnsea.png",
target: { height: 0.704, width: 0.232, x: 0.54, y: 0.246 },
},
{
id: "13-coffee-cart-move-left",
rect: { height: 0.54, width: 0.5, x: 0.22, y: 0.36 },
source: "https://scqny.jiansun.vip/uploads/1783555598917-8w4a6t.png",
target: { height: 0.54, width: 0.5, x: 0.05, y: 0.36 },
},
{
id: "14-side-table-person-scene-move-left",
rect: { height: 0.235, width: 0.225, x: 0.77, y: 0.72 },
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",
"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(
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(`
`),
},
])
.flatten({ background: "#ffffff" })
.jpeg({ quality: 88 })
.toBuffer();
}
async function runCase(params: {
item: VerificationCase;
outputDir: string;
upload: Awaited>;
}) {
const { item, outputDir, upload } = params;
const startedAt = Date.now();
const sourceBuffer = await fetchImageBufferFromUrl(item.source);
const sourceMetadata = await sharp(sourceBuffer, { failOn: "none" }).metadata();
const sourceWidth = Number(sourceMetadata.width || 0);
const sourceHeight = Number(sourceMetadata.height || 0);
if (sourceWidth <= 0 || sourceHeight <= 0) {
throw new Error("无法读取测试原图尺寸");
}
const regions: CanvasScaleAdjustIntentRegion[] = [
{ index: 1, rect: item.rect, targetRect: targetAroundCenter(item) },
];
const hasPositionMove = regions.some((region) =>
hasScaleAdjustPositionMoved(region.rect, region.targetRect)
);
const assets = await buildScaleAdjustProductLockAssets({
protectPeople: hasPositionMove,
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.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
),
...(assets.backgroundPlateBuffer
? [
writeFile(
resolve(outputDir, `${item.id}-background-plate.png`),
assets.backgroundPlateBuffer
),
]
: []),
...(assets.personExclusionMaskBuffer
? [
writeFile(
resolve(outputDir, `${item.id}-person-exclusion-mask.png`),
assets.personExclusionMaskBuffer
),
]
: []),
]);
if (process.env.SCALE_ADJUST_ASSETS_ONLY === "1") {
const result = {
elapsedMs: Date.now() - startedAt,
id: item.id,
mode: "assets-only",
ok: true,
personExclusionApplied: assets.personExclusionApplied,
rect: item.rect,
semanticFallbackRegionIndexes: assets.semanticFallbackRegionIndexes,
semanticFallbackSuggested: assets.semanticFallbackSuggested,
source: item.source,
targetRect: regions[0].targetRect,
};
console.log(`SCALE_VERIFY_CASE ${JSON.stringify(result)}`);
return result;
}
const suffix = `${item.id}-${Date.now()}`;
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)
),
]);
const backgroundPlateUpload = assets.backgroundPlateBuffer
? await upload(
`verify-scale-background-${suffix}.png`,
toArrayBuffer(assets.backgroundPlateBuffer)
)
: null;
const providerImageParams = useSemanticScaleAdjust
? { quality: "high" }
: hasPositionMove
? { maskImageUrl: modelMaskUpload.url, quality: "medium" }
: {
objectRemovalMaskImageUrl: modelMaskUpload.url,
objectRemovalMode: "premium",
};
const productLockComposite = {
...(backgroundPlateUpload
? { backgroundPlateImageUrl: backgroundPlateUpload.url }
: {}),
overlayImageUrl: overlayUpload.url,
...(hasPositionMove
? {
repairMaskImageUrl: repairMaskUpload.url,
sourceImageUrl: item.source,
}
: {}),
...(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: useSemanticScaleAdjust
? sourceSubjectMaskUpload.url
: hasPositionMove
? sourceSubjectMaskUpload.url
: repairMaskUpload.url,
regions: [item.rect],
rejectFailedCandidates: true,
rejectNoEffectiveChangeOnly: false,
rejectOldFootprintOnly: true,
sourceImageUrl: item.source,
targetRegions: [regions[0].targetRect],
};
const prompt = useSemanticScaleAdjust
? buildSemanticScaleAdjustExtractionPrompt()
: hasPositionMove
? "只编辑白色掩膜区域,完整移除旧位置的产品及其原有阴影,并根据周围墙面、地面、家具和光照连续自然地重建被遮挡背景。黑色区域保持不变,不要保留产品的任何部件或残影。"
: "移除掩膜中的完整产品并自然还原背景。";
const request: CanvasGenerateImageRequest = {
allowFailover: false,
aspectRatio: hasPositionMove
? "auto"
: resolveAspectRatio(sourceWidth, sourceHeight),
debugLabel: `verify-scale-final-${item.id}`,
debugMeta: {
operation: "scale-adjust",
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,
imageSize: "1K",
model:
process.env.SCALE_ADJUST_MODEL ||
"e03d3341-cecd-425e-b88b-9350cbbce18f",
prompt,
promptExtend: false,
providerImageParams,
referenceImages: [item.source],
...(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,
providerImageParams,
referenceImages: [item.source],
scaleAdjustProductLockComposite: productLockComposite,
scaleAdjustVerification: {
...verification,
rejectNoEffectiveChangeOnly: true,
rejectOldFootprintOnly: false,
},
},
],
...(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);
const generated = await applyScaleAdjustModelRelight({
generateImage: generateCanvasImage,
initialResult: initialGenerated,
ownerKey:
process.env.SCALE_ADJUST_USER_ID ||
"00000000-0000-0000-0000-000000000000",
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,
personExclusionApplied: assets.personExclusionApplied,
rect: item.rect,
resultUrl,
source: item.source,
targetRect: regions[0].targetRect,
};
console.log(`SCALE_VERIFY_CASE ${JSON.stringify(result)}`);
return result;
}
async function main() {
const outputDir = resolve(
process.env.SCALE_ADJUST_OUTPUT_DIR ||
"/private/tmp/scale-adjust-final-verification"
);
const requestedIds = new Set(
String(process.env.SCALE_ADJUST_CASES || "")
.split(",")
.map((value) => value.trim())
.filter(Boolean)
);
const selectedCases = requestedIds.size
? cases.filter((item) => requestedIds.has(item.id))
: cases;
const concurrency = Math.max(
1,
Math.min(3, Number(process.env.SCALE_ADJUST_CONCURRENCY || 3))
);
await mkdir(outputDir, { recursive: true });
const upload = await createStorageUploadSession();
const results: Array> = [];
let cursor = 0;
await Promise.all(
Array.from({ length: concurrency }, async () => {
while (cursor < selectedCases.length) {
const item = selectedCases[cursor];
cursor += 1;
try {
results.push(await runCase({ item, outputDir, upload }));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (item.expectedError && errorMessage.includes(item.expectedError)) {
const expectedFailure = {
error: errorMessage,
expectedFailure: true,
id: item.id,
ok: true,
source: item.source,
};
results.push(expectedFailure);
console.log(`SCALE_VERIFY_CASE ${JSON.stringify(expectedFailure)}`);
continue;
}
const failure = {
error: errorMessage,
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;
});