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(` ${label} `), }, ]) .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; });