605 lines
19 KiB
TypeScript
605 lines
19 KiB
TypeScript
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(`
|
|
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="${Math.min(width, 18 * (label.length * 0.62 + 1))}" height="34" fill="#111111" fill-opacity="0.9" />
|
|
<text x="8" y="24" fill="#ffffff" font-family="Arial, Helvetica, sans-serif" font-size="20" font-weight="700">${label}</text>
|
|
</svg>
|
|
`),
|
|
},
|
|
])
|
|
.flatten({ background: "#ffffff" })
|
|
.jpeg({ quality: 88 })
|
|
.toBuffer();
|
|
}
|
|
|
|
async function runCase(params: {
|
|
item: VerificationCase;
|
|
outputDir: string;
|
|
upload: Awaited<ReturnType<typeof createStorageUploadSession>>;
|
|
}) {
|
|
const { item, outputDir, upload } = params;
|
|
const startedAt = Date.now();
|
|
const sourceBuffer = await fetchImageBufferFromUrl(item.source);
|
|
const sourceMetadata = await sharp(sourceBuffer, { failOn: "none" }).metadata();
|
|
const sourceWidth = Number(sourceMetadata.width || 0);
|
|
const sourceHeight = Number(sourceMetadata.height || 0);
|
|
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
|
throw new Error("无法读取测试原图尺寸");
|
|
}
|
|
|
|
const regions: CanvasScaleAdjustIntentRegion[] = [
|
|
{ index: 1, rect: item.rect, targetRect: targetAroundCenter(item) },
|
|
];
|
|
const 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<Record<string, unknown>> = [];
|
|
let cursor = 0;
|
|
|
|
await Promise.all(
|
|
Array.from({ length: concurrency }, async () => {
|
|
while (cursor < selectedCases.length) {
|
|
const item = selectedCases[cursor];
|
|
cursor += 1;
|
|
try {
|
|
results.push(await runCase({ item, outputDir, upload }));
|
|
} catch (error) {
|
|
const 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;
|
|
});
|