import { expect, test } from "@playwright/test"; 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"); } test.describe("canvas scale adjust product lock", () => { test("accepts target movement even when target size stays unchanged", () => { const source = { height: 0.4, width: 0.3, x: 0.2, y: 0.25 }; expect(hasScaleAdjustTargetChanged(source, source)).toBe(false); expect( hasScaleAdjustTargetChanged(source, { ...source, x: 0.46, y: 0.12 }) ).toBe(true); expect( hasScaleAdjustTargetChanged(source, { ...source, height: 0.32, width: 0.24, }) ).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( "components/agents/AgentGenerationWorkspaceDarkV2.tsx" ); expect(canvasNode).toContain("hasScaleAdjustTargetChanged("); expect(detailWorkspace).toContain("hasScaleAdjustTargetChanged("); expect(canvasNode).toContain("青色目标框大小或位置"); expect(detailWorkspace).toContain("目标框大小和位置均未变化"); }); test("uses one dedicated erase step and exact product overlay", () => { const route = readSource("app/api/canvas/local-edit/route.ts"); expect(route).toContain("buildScaleAdjustProductLockAssets"); expect(route).toContain('objectRemovalMode: "premium"'); 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: useSemanticScaleAdjust"); expect(route).toContain(": hasPositionMove"); expect(route).toContain("? sourceSubjectMaskUpload?.url"); 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"); }); test("routes object removal through the configured fal credential", () => { const provider = readSource("lib/ai/providers/fal-image.ts"); expect(provider).toContain( 'const FAL_OBJECT_REMOVAL_MODEL_ID = "fal-ai/finegrain-eraser/mask"' ); expect(provider).toContain("image_url: imageUrls[0]"); expect(provider).toContain("mask_url: objectRemovalMaskImageUrl"); expect(provider).toContain('objectRemovalMode || ""'); }); 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: !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]"); }); test("keeps transparent product images on a deterministic empty plate", () => { const productLock = readSource("lib/canvas/scale-adjust-product-lock.ts"); expect(productLock).toContain("hasTransparentBackground"); expect(productLock).toContain("sharp(transparentCanvas)"); expect(productLock).toContain("backgroundPlateBuffer"); }); test("protects people and rejects products materially occluded by them", () => { const productLock = readSource("lib/canvas/scale-adjust-product-lock.ts"); 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("目标产品被人物遮挡"); expect(productLock).toContain("personExclusionApplied: Boolean(personExclusionAlpha)"); expect(productLock).toContain("expandedAlphaRaw[index] >= 8 ? 255 : 0"); expect(productLock).toContain(".threshold(8)\n .extractChannel(0)"); expect(rmbg).toContain("foregroundType: foregroundType ??"); }); test("moves the original contact shadow with the exact product overlay", () => { const generator = readSource("lib/canvas/generate-image.ts"); expect(generator).toContain("buildScaleAdjustTransferredShadow"); expect(generator).toContain("cleanedLuma - sourceLuma - 4"); expect(generator).toContain("transferredShadowBuffer"); expect(generator).toContain("transferredShadowCount"); expect(generator).toContain("if (!(isShrink || isMove)) continue"); expect(generator).toContain( "const maskHit = explicitMaskRaw\n ? explicitMaskValue > 18" ); expect(generator).toContain("(!usesExplicitMask &&"); expect(generator).toContain( "const generatedWidth = compositeBaseBuffer ? overlayWidth : modelWidth" ); expect(generator).toContain( ".extractChannel(3)\n .blur(Math.max(4" ); expect(generator).toContain(".extractChannel(0)\n .raw()\n .toBuffer()"); }); test("uses the image edit model to rebuild lighting at the target position", () => { const route = readSource("app/api/canvas/local-edit/route.ts"); const queue = readSource("lib/canvas/image-task-queue.ts"); const relight = readSource("lib/canvas/scale-adjust-model-relight.ts"); expect(route).toContain("scaleAdjustModelRelight:"); expect(route).toContain("...(hasPositionMove"); expect(route).toContain("overlayImageUrl: overlayUpload.url"); expect(queue).toContain("applyScaleAdjustModelRelight"); expect(relight).toContain('debugLabel: "canvas-scale-adjust-model-relight"'); expect(relight).toContain("maskImageUrl: maskUpload.url"); expect(relight).toContain("生成真实自然的接触阴影与投射阴影"); expect(relight).toContain("findMedianLumaDelta"); expect(relight).toContain("productAlpha[pixelIndex] >= 8 ? 0"); }); test("keeps non-pro detail result scale adjust on the shared backend flow", () => { const source = readSource( "components/agents/AgentGenerationWorkspaceDarkV2.tsx" ); expect(source).toContain( "const resolvedLocalEditModelId = isScaleAdjust\n ? undefined" ); expect(source).toContain( 'operation: isScaleAdjust ? "scale-adjust" : undefined' ); expect(source).toContain("targetRect: region.targetRect"); expect(source).toContain('resultModeType: "detail_generation"'); expect(source).toContain('mode: "scale_adjust"'); }); });