import { mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import sharp from "sharp"; import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; import { type CanvasGenerateImageRequest, generateCanvasImage, } from "@/lib/canvas/generate-image"; import { buildScaleAdjustProductLockAssets, type CanvasScaleAdjustIntentRegion, type CanvasScaleAdjustRect, } from "@/lib/canvas/scale-adjust-product-lock"; import { createStorageUploadSession } from "@/lib/storage/service"; type VerificationCase = { id: string; rect: CanvasScaleAdjustRect; scalePercent?: number; source: string; target?: CanvasScaleAdjustRect; }; const cases: VerificationCase[] = [ { id: "01-chair-cutout", rect: { height: 0.76, width: 0.6, x: 0.2, y: 0.2 }, source: "https://scqny.jiansun.vip/uploads/1783578430745-dqst30.png", target: { height: 0.418, width: 0.33, x: 0.34, y: 0.38 }, }, { id: "02-office-chair", rect: { height: 0.72, width: 0.64, x: 0.18, y: 0.25 }, source: "https://scqny.jiansun.vip/uploads/1783562931574-tj4zst.png", target: { height: 0.45, width: 0.4, x: 0.31, y: 0.38 }, }, { id: "03-cabinet", rect: { height: 0.91, width: 0.72, x: 0.13, y: 0.03 }, scalePercent: -25, source: "https://scqny.jiansun.vip/uploads/1783562287046-gpie6r.png", }, { id: "04-bookshelf-with-person", rect: { height: 0.88, width: 0.29, x: 0.37, y: 0.07 }, scalePercent: -25, source: "https://scqny.jiansun.vip/uploads/1783687809170-vmnsea.png", }, { id: "05-cabinet-room", rect: { height: 0.91, width: 0.72, x: 0.13, y: 0.03 }, scalePercent: -25, source: "https://scqny.jiansun.vip/uploads/1783561235528-gv6es4.jpg", }, { id: "06-standing-desk-a", rect: { height: 0.61, width: 0.43, x: 0.27, y: 0.32 }, scalePercent: -25, source: "https://scqny.jiansun.vip/uploads/1783558912572-dkkmmf.png", }, { id: "07-clothes-rack", rect: { height: 0.7, width: 0.53, x: 0.32, y: 0.23 }, scalePercent: -25, source: "https://scqny.jiansun.vip/uploads/1783689789549-wu4br1.png", }, { id: "08-desk", rect: { height: 0.7, width: 0.9, x: 0.03, y: 0.26 }, scalePercent: -30, source: "https://scqny.jiansun.vip/uploads/1783556368653-aizsul.png", }, { id: "09-coffee-cart", rect: { height: 0.54, width: 0.5, x: 0.22, y: 0.36 }, scalePercent: -30, source: "https://scqny.jiansun.vip/uploads/1783555598917-8w4a6t.png", }, { id: "10-standing-desk-c", rect: { height: 0.69, width: 0.73, x: 0.23, y: 0.23 }, scalePercent: -30, source: "https://scqny.jiansun.vip/uploads/1783555294804-qdykhy.png", }, ]; const aspectRatios = ["1:1", "4:3", "3:4", "16:9", "9:16"] as const; function toArrayBuffer(buffer: Buffer) { return buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength ) as ArrayBuffer; } function targetAroundCenter(item: VerificationCase): CanvasScaleAdjustRect { if (item.target) return item.target; const factor = 1 + (item.scalePercent || 0) / 100; return { height: item.rect.height * factor, width: item.rect.width * factor, x: item.rect.x + (item.rect.width * (1 - factor)) / 2, y: item.rect.y + (item.rect.height * (1 - factor)) / 2, }; } function resolveAspectRatio(width: number, height: number) { const ratio = width / height; return aspectRatios.reduce((best, candidate) => { const [candidateWidth, candidateHeight] = candidate .split(":") .map(Number); const bestParts = best.split(":").map(Number); const candidateDiff = Math.abs(candidateWidth / candidateHeight - ratio); const bestDiff = Math.abs(bestParts[0] / bestParts[1] - ratio); return candidateDiff < bestDiff ? candidate : best; }, aspectRatios[0]); } async function addLabel(input: Buffer, label: string) { const metadata = await sharp(input).metadata(); const width = metadata.width || 420; const height = metadata.height || 320; return sharp(input) .composite([ { input: Buffer.from(` ${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 assets = await buildScaleAdjustProductLockAssets({ regions, sourceBuffer, sourceHeight, sourceImageUrl: item.source, sourceWidth, userId: process.env.SCALE_ADJUST_USER_ID || "00000000-0000-0000-0000-000000000000", }); await Promise.all([ writeFile(resolve(outputDir, `${item.id}-source.png`), sourceBuffer), writeFile(resolve(outputDir, `${item.id}-model-mask.png`), assets.modelMaskBuffer), writeFile(resolve(outputDir, `${item.id}-overlay.png`), assets.overlayBuffer), writeFile( resolve(outputDir, `${item.id}-repair-mask.png`), assets.repairMaskBuffer ), ...(assets.backgroundPlateBuffer ? [ writeFile( resolve(outputDir, `${item.id}-background-plate.png`), assets.backgroundPlateBuffer ), ] : []), ]); const suffix = `${item.id}-${Date.now()}`; const [modelMaskUpload, overlayUpload, repairMaskUpload] = await Promise.all([ upload(`verify-scale-mask-${suffix}.png`, toArrayBuffer(assets.modelMaskBuffer)), upload(`verify-scale-overlay-${suffix}.png`, toArrayBuffer(assets.overlayBuffer)), upload( `verify-scale-repair-mask-${suffix}.png`, toArrayBuffer(assets.repairMaskBuffer) ), ]); const backgroundPlateUpload = assets.backgroundPlateBuffer ? await upload( `verify-scale-background-${suffix}.png`, toArrayBuffer(assets.backgroundPlateBuffer) ) : null; const providerImageParams = { objectRemovalMaskImageUrl: modelMaskUpload.url, objectRemovalMode: "premium", }; const productLockComposite = { ...(backgroundPlateUpload ? { backgroundPlateImageUrl: backgroundPlateUpload.url } : {}), overlayImageUrl: overlayUpload.url, sourceHeight, sourceWidth, }; const verification = { allowOutsideChangedWhenChangedInside: false, oldFootprintMaskImageUrl: repairMaskUpload.url, regions: [item.rect], rejectFailedCandidates: true, rejectNoEffectiveChangeOnly: false, rejectOldFootprintOnly: true, sourceImageUrl: item.source, targetRegions: [regions[0].targetRect], }; const prompt = "移除掩膜中的完整产品并自然还原背景。"; const request: CanvasGenerateImageRequest = { allowFailover: false, aspectRatio: resolveAspectRatio(sourceWidth, sourceHeight), debugLabel: `verify-scale-final-${item.id}`, debugMeta: { operation: "scale-adjust", scaleAdjustPromptStrategy: "finegrain-erase-product-lock-v1", scaleAdjustSequentialCleanupRetry: true, }, displayPrompt: "比例调整", imageCount: 1, imageSize: "1K", model: process.env.SCALE_ADJUST_MODEL || "e03d3341-cecd-425e-b88b-9350cbbce18f", prompt, promptExtend: false, providerImageParams, referenceImages: [item.source], retryVariants: [ { label: "finegrain-cleanup-pass", prompt, providerImageParams, referenceImages: [item.source], scaleAdjustProductLockComposite: productLockComposite, scaleAdjustVerification: { ...verification, rejectNoEffectiveChangeOnly: true, rejectOldFootprintOnly: false, }, }, ], scaleAdjustProductLockComposite: productLockComposite, scaleAdjustVerification: verification, }; const generated = await generateCanvasImage(request); const resultUrl = generated.images[0]?.originalUrl || null; if (!resultUrl) throw new Error("比例调整未返回结果图片"); await writeFile( resolve(outputDir, `${item.id}-result.png`), await fetchImageBufferFromUrl(resultUrl) ); const result = { elapsedMs: Date.now() - startedAt, id: item.id, ok: true, rect: item.rect, resultUrl, source: item.source, targetRect: regions[0].targetRect, }; console.log(`SCALE_VERIFY_CASE ${JSON.stringify(result)}`); return result; } async function main() { const outputDir = resolve( process.env.SCALE_ADJUST_OUTPUT_DIR || "/private/tmp/scale-adjust-final-verification" ); const requestedIds = new Set( String(process.env.SCALE_ADJUST_CASES || "") .split(",") .map((value) => value.trim()) .filter(Boolean) ); const selectedCases = requestedIds.size ? cases.filter((item) => requestedIds.has(item.id)) : cases; const concurrency = Math.max( 1, Math.min(3, Number(process.env.SCALE_ADJUST_CONCURRENCY || 3)) ); await mkdir(outputDir, { recursive: true }); const upload = await createStorageUploadSession(); const results: Array> = []; let cursor = 0; await Promise.all( Array.from({ length: concurrency }, async () => { while (cursor < selectedCases.length) { const item = selectedCases[cursor]; cursor += 1; try { results.push(await runCase({ item, outputDir, upload })); } catch (error) { const failure = { error: error instanceof Error ? error.message : String(error), id: item.id, ok: false, source: item.source, }; results.push(failure); console.error(`SCALE_VERIFY_CASE ${JSON.stringify(failure)}`); } } }) ); results.sort((left, right) => String(left.id).localeCompare(String(right.id))); await writeFile( resolve(outputDir, "results.json"), JSON.stringify(results, null, 2) ); const panels: Buffer[] = []; for (const item of selectedCases) { for (const suffix of ["source", "model-mask", "overlay", "result"]) { try { const panel = await sharp(resolve(outputDir, `${item.id}-${suffix}.png`)) .resize(420, 320, { background: "#ffffff", fit: "contain" }) .png() .toBuffer(); panels.push(await addLabel(panel, `${item.id} ${suffix}`)); } catch { // Failed cases remain visible in results.json. } } } if (panels.length) { const rows = Math.ceil(panels.length / 4); await sharp({ create: { background: "#e8e8e8", channels: 3, height: rows * 320, width: 4 * 420, }, }) .composite( panels.map((input, index) => ({ input, left: (index % 4) * 420, top: Math.floor(index / 4) * 320, })) ) .jpeg({ quality: 88 }) .toFile(resolve(outputDir, "full-results.jpg")); } const passed = results.filter((result) => result.ok === true).length; console.log( `SCALE_VERIFY_SUMMARY ${JSON.stringify({ failed: results.length - passed, outputDir, passed, total: results.length })}` ); } main().catch((error) => { console.error(error); process.exitCode = 1; });