From f7a5accb61a610f638a3983ed052d88f0197db57 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 6 Jul 2026 02:29:49 +0800 Subject: [PATCH] fix: protect people in product locked scale adjust --- app/api/canvas/local-edit/route.ts | 420 +++++++++++++++++++++-------- lib/canvas/generate-image.ts | 57 +++- 2 files changed, 362 insertions(+), 115 deletions(-) diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index aa3d169a6..a76b245da 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -128,6 +128,7 @@ type ScaleAdjustPatchReference = { compositeClipMaskUrl?: string | null; productLockMethod?: string | null; productLockOverlayUrl?: string | null; + productLockProtectMaskUrl?: string | null; sourceCropUrl: string; structureLockedDraft?: boolean; targetDraftFullUrl?: string | null; @@ -145,6 +146,7 @@ type ScaleAdjustStructureLockedComposite = { method?: "pixel-mask" | "rect-crop-guide" | "rect-crop-guide-rmbg-lock" | "rmbg-cutout"; overlay: { input: Buffer; left: number; top: number }; productLock?: { input: Buffer; left: number; top: number }; + productLockProtectMask?: { input: Buffer; left: number; top: number }; targetClip?: { input: Buffer; left: number; top: number }; wash: { input: Buffer; left: number; top: number }; }; @@ -1586,14 +1588,17 @@ function buildScaleAdjustStructureInitialMask(params: { } function buildScaleAdjustProtectedPersonSeedMask(params: { + background?: { b: number; g: number; luma: number; r: number } | null; box: { left: number; top: number; width: number; height: number }; raw: Buffer; side: ScaleAdjustProtectedPersonSide; width: number; }) { - const { box, raw, side, width } = params; + const { background = null, box, raw, side, width } = params; + const candidate = new Uint8Array(box.width * box.height); + const anchor = new Uint8Array(box.width * box.height); + const visited = new Uint8Array(box.width * box.height); const seed = new Uint8Array(box.width * box.height); - let seedCount = 0; const sideAllowsX = (localX: number) => { if (side === "right") return localX >= box.width * 0.52; if (side === "left") return localX <= box.width * 0.48; @@ -1611,17 +1616,106 @@ function buildScaleAdjustProtectedPersonSeedMask(params: { const b = raw[rawIndex + 2] || 0; const { luma, saturation } = getScaleAdjustPixelStats(r, g, b); const likelySkin = isScaleAdjustLikelySkinPixel(r, g, b); + const colorDistance = background + ? Math.sqrt( + (r - background.r) ** 2 + + (g - background.g) ** 2 + + (b - background.b) ** 2 + ) + : 999; + const backgroundLikeLightPixel = + Boolean(background) && + luma >= (background?.luma || 0) - 3 && + colorDistance < 18; const likelyWhiteFabric = isScaleAdjustLikelyWhiteFabricPixel(r, g, b, luma, saturation) && luma < 246 && - saturation < 0.22; + saturation < 0.22 && + !backgroundLikeLightPixel && + (colorDistance > 16 || !background || luma < background.luma - 5); if (!likelySkin && !likelyWhiteFabric) continue; - seed[localY * box.width + localX] = 255; - seedCount += 1; + const index = localY * box.width + localX; + candidate[index] = 255; + if (likelySkin) { + anchor[index] = 255; + } + } + } + + const queueX: number[] = []; + const queueY: number[] = []; + let seedCount = 0; + let anchorCount = 0; + for (let y = 0; y < box.height; y += 1) { + for (let x = 0; x < box.width; x += 1) { + const startIndex = y * box.width + x; + if (candidate[startIndex] === 0 || visited[startIndex]) continue; + + queueX.length = 0; + queueY.length = 0; + queueX.push(x); + queueY.push(y); + visited[startIndex] = 1; + let head = 0; + let area = 0; + let anchors = 0; + let minX = x; + let minY = y; + let maxX = x; + let maxY = y; + + while (head < queueX.length) { + const cx = queueX[head] || 0; + const cy = queueY[head] || 0; + head += 1; + area += 1; + if (anchor[cy * box.width + cx]) anchors += 1; + minX = Math.min(minX, cx); + minY = Math.min(minY, cy); + maxX = Math.max(maxX, cx); + maxY = Math.max(maxY, cy); + + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (dx === 0 && dy === 0) continue; + const nx = cx + dx; + const ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= box.width || ny >= box.height) continue; + const nextIndex = ny * box.width + nx; + if (candidate[nextIndex] === 0 || visited[nextIndex]) continue; + visited[nextIndex] = 1; + queueX.push(nx); + queueY.push(ny); + } + } + } + + const componentWidth = maxX - minX + 1; + const componentHeight = maxY - minY + 1; + const broadBackgroundStripe = + anchors === 0 && + componentHeight >= box.height * 0.48 && + componentWidth >= box.width * 0.16; + const tinyUnanchoredPatch = anchors === 0 && area < box.width * box.height * 0.004; + if (anchors === 0 || broadBackgroundStripe || tinyUnanchoredPatch) { + continue; + } + + for (let index = 0; index < queueX.length; index += 1) { + const pixelX = queueX[index] || 0; + const pixelY = queueY[index] || 0; + const seedIndex = pixelY * box.width + pixelX; + if (seed[seedIndex]) continue; + seed[seedIndex] = 255; + seedCount += 1; + } + anchorCount += anchors; } } const seedRatio = seedCount / Math.max(1, box.width * box.height); + const anchorRatio = anchorCount / Math.max(1, box.width * box.height); + if (anchorCount < 12 || anchorRatio < 0.00012) return null; if (seedCount < 24 || seedRatio < 0.0006) return null; return dilateScaleAdjustStructureMask({ @@ -2148,6 +2242,19 @@ function thresholdScaleAdjustAlphaMask(params: { return output; } +function removeScaleAdjustMaskFromAlpha(params: { + alphaMask: Buffer | Uint8Array; + removeMask: Uint8Array | null; +}) { + const { alphaMask, removeMask } = params; + if (!removeMask || removeMask.length !== alphaMask.length) return alphaMask; + const output = Buffer.from(alphaMask); + for (let index = 0; index < output.length; index += 1) { + if (removeMask[index]) output[index] = 0; + } + return output; +} + async function applyScaleAdjustAlphaMaskToRgba(params: { alphaMask: Uint8Array; height: number; @@ -2343,7 +2450,9 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: { userId, imageBase64: sourceCropBuffer.toString("base64"), imageBuffer: sourceCropBuffer, - prompt: "比例调整主体抠图:只保留框选的目标人物/家具/商品主体,输出透明背景 PNG", + prompt: protectedPersonSide + ? "比例调整家具/商品抠图:只保留框选区域内需要缩放的目标家具/商品主体和它本身的配件结构,明确排除非目标人物、手、脚、腿、衣服、书本、身体遮挡物和背景,输出透明背景 PNG" + : "比例调整主体抠图:只保留框选的目标人物/家具/商品主体,输出透明背景 PNG", skipGallerySave: true, sourceImageUrl, }); @@ -2378,7 +2487,25 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: { rawRgba: cutoutRaw, width: cutoutWidth, }); - const protectedCleanedAlpha = + const background = estimateScaleAdjustBoxBackground({ + box: placement.sourceBox, + raw, + width: sourceWidth, + }); + const protectedPersonMask = + protectedPersonSide && sameSizeCutout && sourceCropRaw + ? validateScaleAdjustProtectedPersonMask({ + box: { width: cutoutWidth, height: cutoutHeight }, + mask: buildScaleAdjustProtectedPersonSeedMask({ + background, + box: { left: 0, top: 0, width: cutoutWidth, height: cutoutHeight }, + raw: sourceCropRaw, + side: protectedPersonSide, + width: cutoutWidth, + }), + }) + : null; + const sideProtectedCleanedAlpha = protectedPersonSide && sameSizeCutout && sourceCropRaw ? suppressScaleAdjustProtectedFurnitureDarkAlpha({ alphaMask: Buffer.from(cleanedAlpha), @@ -2389,10 +2516,12 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: { sourceBox: placement.sourceBox, }) : cleanedAlpha; - if (protectedCleanedAlpha !== cleanedAlpha) { - for (let index = 0; index < protectedCleanedAlpha.length; index += 1) { - cleanedRaw[index * 4 + 3] = protectedCleanedAlpha[index] || 0; - } + const protectedCleanedAlpha = removeScaleAdjustMaskFromAlpha({ + alphaMask: sideProtectedCleanedAlpha, + removeMask: protectedPersonMask, + }); + for (let index = 0; index < protectedCleanedAlpha.length; index += 1) { + cleanedRaw[index * 4 + 3] = protectedCleanedAlpha[index] || 0; } const highConfidenceCutoutMask = thresholdScaleAdjustAlphaMask({ maskBuffer: protectedCleanedAlpha, @@ -2427,27 +2556,10 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: { ); return null; } - const background = estimateScaleAdjustBoxBackground({ - box: placement.sourceBox, - raw, - width: sourceWidth, - }); const highConfidenceSourceMask = thresholdScaleAdjustAlphaMask({ maskBuffer: sourceMaskBuffer, threshold: 96, }); - const protectedPersonMask = - protectedPersonSide && sameSizeCutout && sourceCropRaw - ? validateScaleAdjustProtectedPersonMask({ - box: { width: cutoutWidth, height: cutoutHeight }, - mask: buildScaleAdjustProtectedPersonSeedMask({ - box: { left: 0, top: 0, width: cutoutWidth, height: cutoutHeight }, - raw: sourceCropRaw, - side: protectedPersonSide, - width: cutoutWidth, - }), - }) - : null; const filteredSourceMask = filterScaleAdjustStructureComponents({ box: placement.sourceBox, mask: highConfidenceSourceMask, @@ -2517,7 +2629,7 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: { }); const sourceCompositeClipMask = dilateScaleAdjustStructureMask({ height: placement.sourceBox.height, - mask: filteredSourceBbox ? filteredSourceMask : highConfidenceSourceMask, + mask: sourceWashBaseMask, radius: Math.max( 1, Math.round( @@ -2602,6 +2714,23 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: { .toBuffer(); const targetClipMaskPatch = await buildScaleAdjustOverlayClipMaskPatch(structureOverlay); + const productLockProtectMaskPatch = protectedPersonMask + ? await buildScaleAdjustCompositeClipMaskPatch({ + box: placement.sourceBox, + maskBuffer: dilateScaleAdjustStructureMask({ + height: placement.sourceBox.height, + mask: protectedPersonMask, + radius: Math.max( + 2, + Math.round( + Math.min(placement.sourceBox.width, placement.sourceBox.height) * + 0.012 + ) + ), + width: placement.sourceBox.width, + }), + }) + : null; console.info("[Canvas Local Edit] Scale adjust RMBG structure draft applied:", { alphaRatio: Number(alphaRatio.toFixed(3)), @@ -2637,6 +2766,15 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: { left: Math.round(targetCenterX - placedWidth / 2), top: Math.round(targetCenterY - placedHeight / 2), }, + ...(productLockProtectMaskPatch + ? { + productLockProtectMask: { + input: productLockProtectMaskPatch, + left: placement.sourceBox.left, + top: placement.sourceBox.top, + }, + } + : {}), targetClip: { input: targetClipMaskPatch, left: Math.round(targetCenterX - placedWidth / 2), @@ -3220,11 +3358,16 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: { left: number; top: number; }> = []; - const productLockComposites: Array<{ - input: Buffer; - left: number; - top: number; - }> = []; + const productLockComposites: Array<{ + input: Buffer; + left: number; + top: number; + }> = []; + const productLockProtectMaskComposites: Array<{ + input: Buffer; + left: number; + top: number; + }> = []; for (const region of regions) { const placement = getScaleAdjustVisualGuidePlacement({ @@ -3284,15 +3427,20 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: { if (structureLockedComposite.compositeClip) { compositeClipComposites.push(structureLockedComposite.compositeClip); } - if (structureLockedComposite.targetClip) { - targetClipComposites.push(structureLockedComposite.targetClip); + if (structureLockedComposite.targetClip && !structureLockedComposite.productLock) { + targetClipComposites.push(structureLockedComposite.targetClip); + } + if (structureLockedComposite.productLock) { + productLockComposites.push(structureLockedComposite.productLock); + } + if (structureLockedComposite.productLockProtectMask) { + productLockProtectMaskComposites.push( + structureLockedComposite.productLockProtectMask + ); + } + composites.push(structureLockedComposite.overlay); + continue; } - if (structureLockedComposite.productLock) { - productLockComposites.push(structureLockedComposite.productLock); - } - composites.push(structureLockedComposite.overlay); - continue; - } const shouldUseShrinkCutoutDraft = process.env.CANVAS_SCALE_ADJUST_ENABLE_RMBG_SHRINK_CUTOUT === "1" && @@ -3317,15 +3465,20 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: { if (shrinkCutoutComposite.compositeClip) { compositeClipComposites.push(shrinkCutoutComposite.compositeClip); } - if (shrinkCutoutComposite.targetClip) { - targetClipComposites.push(shrinkCutoutComposite.targetClip); + if (shrinkCutoutComposite.targetClip && !shrinkCutoutComposite.productLock) { + targetClipComposites.push(shrinkCutoutComposite.targetClip); + } + if (shrinkCutoutComposite.productLock) { + productLockComposites.push(shrinkCutoutComposite.productLock); + } + if (shrinkCutoutComposite.productLockProtectMask) { + productLockProtectMaskComposites.push( + shrinkCutoutComposite.productLockProtectMask + ); + } + composites.push(shrinkCutoutComposite.overlay); + continue; } - if (shrinkCutoutComposite.productLock) { - productLockComposites.push(shrinkCutoutComposite.productLock); - } - composites.push(shrinkCutoutComposite.overlay); - continue; - } sourceFootprintBoxes.push({ ...placement.sourceFootprintBox, @@ -3412,31 +3565,50 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: { .png({ compressionLevel: 6 }) .toBuffer() : null; - const productLockOverlayBuffer = productLockComposites.length - ? await sharp({ - create: { - background: { alpha: 0, b: 0, g: 0, r: 0 }, + const productLockOverlayBuffer = productLockComposites.length + ? await sharp({ + create: { + background: { alpha: 0, b: 0, g: 0, r: 0 }, channels: 4, height: sourceHeight, width: sourceWidth, }, }) .composite(productLockComposites) - .png({ compressionLevel: 6 }) - .toBuffer() - : null; - const compositeClipMaskBuffer = - compositeClipComposites.length || targetClipComposites.length - ? await buildScaleAdjustCompositeClipMaskBuffer({ - height: sourceHeight, + .png({ compressionLevel: 6 }) + .toBuffer() + : null; + const productLockProtectMaskBuffer = productLockProtectMaskComposites.length + ? await sharp({ + create: { + background: { alpha: 0, b: 255, g: 255, r: 255 }, + channels: 4, + height: sourceHeight, + width: sourceWidth, + }, + }) + .composite(productLockProtectMaskComposites) + .png({ compressionLevel: 6 }) + .toBuffer() + : null; + const compositeClipMaskBuffer = + compositeClipComposites.length || targetClipComposites.length + ? await buildScaleAdjustCompositeClipMaskBuffer({ + height: sourceHeight, sourceMaskComposites: compositeClipComposites, targetMaskComposites: targetClipComposites, width: sourceWidth, }) : null; - return { backgroundPlateBuffer, buffer, compositeClipMaskBuffer, productLockOverlayBuffer }; -} + return { + backgroundPlateBuffer, + buffer, + compositeClipMaskBuffer, + productLockOverlayBuffer, + productLockProtectMaskBuffer, + }; + } async function createScaleAdjustPatchReference(params: { fullFrameReferenceOnly?: boolean; @@ -3478,12 +3650,13 @@ async function createScaleAdjustPatchReference(params: { const targetDraftBuffer = targetDraftReference.buffer; const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; if (params.fullFrameReferenceOnly && structureLockAppliedRef.value) { - const [ - targetDraftFullUpload, - backgroundPlateFullUpload, - productLockOverlayUpload, - compositeClipMaskUpload, - ] = await Promise.all([ + const [ + targetDraftFullUpload, + backgroundPlateFullUpload, + productLockOverlayUpload, + productLockProtectMaskUpload, + compositeClipMaskUpload, + ] = await Promise.all([ uploadFile( `canvas-scale-adjust-full-target-draft-${suffix}.png`, toArrayBuffer(targetDraftBuffer) @@ -3494,15 +3667,21 @@ async function createScaleAdjustPatchReference(params: { toArrayBuffer(targetDraftReference.backgroundPlateBuffer) ) : Promise.resolve(null), - targetDraftReference.productLockOverlayBuffer - ? uploadFile( - `canvas-scale-adjust-product-lock-overlay-${suffix}.png`, - toArrayBuffer(targetDraftReference.productLockOverlayBuffer) - ) - : Promise.resolve(null), - targetDraftReference.compositeClipMaskBuffer - ? uploadFile( - `canvas-scale-adjust-composite-clip-mask-${suffix}.png`, + targetDraftReference.productLockOverlayBuffer + ? uploadFile( + `canvas-scale-adjust-product-lock-overlay-${suffix}.png`, + toArrayBuffer(targetDraftReference.productLockOverlayBuffer) + ) + : Promise.resolve(null), + targetDraftReference.productLockProtectMaskBuffer + ? uploadFile( + `canvas-scale-adjust-product-lock-protect-mask-${suffix}.png`, + toArrayBuffer(targetDraftReference.productLockProtectMaskBuffer) + ) + : Promise.resolve(null), + targetDraftReference.compositeClipMaskBuffer + ? uploadFile( + `canvas-scale-adjust-composite-clip-mask-${suffix}.png`, toArrayBuffer(targetDraftReference.compositeClipMaskBuffer) ) : Promise.resolve(null), @@ -3510,10 +3689,11 @@ async function createScaleAdjustPatchReference(params: { return { backgroundPlateFullUrl: backgroundPlateFullUpload?.url || null, - compositeClipMaskUrl: compositeClipMaskUpload?.url || null, - productLockMethod: productLockOverlayUpload ? "rmbg-cutout" : null, - productLockOverlayUrl: productLockOverlayUpload?.url || null, - sourceCropUrl: sourceImageUrl || targetDraftFullUpload.url, + compositeClipMaskUrl: compositeClipMaskUpload?.url || null, + productLockMethod: productLockOverlayUpload ? "rmbg-cutout" : null, + productLockOverlayUrl: productLockOverlayUpload?.url || null, + productLockProtectMaskUrl: productLockProtectMaskUpload?.url || null, + sourceCropUrl: sourceImageUrl || targetDraftFullUpload.url, structureLockedDraft: structureLockAppliedRef.value, targetDraftFullUrl: targetDraftFullUpload.url, targetDraftUrl: targetDraftFullUpload.url, @@ -3532,14 +3712,15 @@ async function createScaleAdjustPatchReference(params: { .png({ compressionLevel: 6 }) .toBuffer(), ]); - const [ - sourceCropUpload, - targetDraftUpload, - targetDraftFullUpload, - backgroundPlateFullUpload, - productLockOverlayUpload, - compositeClipMaskUpload, - ] = await Promise.all([ + const [ + sourceCropUpload, + targetDraftUpload, + targetDraftFullUpload, + backgroundPlateFullUpload, + productLockOverlayUpload, + productLockProtectMaskUpload, + compositeClipMaskUpload, + ] = await Promise.all([ uploadFile( `canvas-scale-adjust-workspace-source-${suffix}.png`, toArrayBuffer(sourceCropBuffer) @@ -3558,15 +3739,21 @@ async function createScaleAdjustPatchReference(params: { toArrayBuffer(targetDraftReference.backgroundPlateBuffer) ) : Promise.resolve(null), - targetDraftReference.productLockOverlayBuffer - ? uploadFile( - `canvas-scale-adjust-product-lock-overlay-${suffix}.png`, - toArrayBuffer(targetDraftReference.productLockOverlayBuffer) - ) - : Promise.resolve(null), - targetDraftReference.compositeClipMaskBuffer - ? uploadFile( - `canvas-scale-adjust-composite-clip-mask-${suffix}.png`, + targetDraftReference.productLockOverlayBuffer + ? uploadFile( + `canvas-scale-adjust-product-lock-overlay-${suffix}.png`, + toArrayBuffer(targetDraftReference.productLockOverlayBuffer) + ) + : Promise.resolve(null), + targetDraftReference.productLockProtectMaskBuffer + ? uploadFile( + `canvas-scale-adjust-product-lock-protect-mask-${suffix}.png`, + toArrayBuffer(targetDraftReference.productLockProtectMaskBuffer) + ) + : Promise.resolve(null), + targetDraftReference.compositeClipMaskBuffer + ? uploadFile( + `canvas-scale-adjust-composite-clip-mask-${suffix}.png`, toArrayBuffer(targetDraftReference.compositeClipMaskBuffer) ) : Promise.resolve(null), @@ -3574,10 +3761,11 @@ async function createScaleAdjustPatchReference(params: { return { backgroundPlateFullUrl: backgroundPlateFullUpload?.url || null, - compositeClipMaskUrl: compositeClipMaskUpload?.url || null, - productLockMethod: productLockOverlayUpload ? "rmbg-cutout" : null, - productLockOverlayUrl: productLockOverlayUpload?.url || null, - sourceCropUrl: sourceCropUpload.url, + compositeClipMaskUrl: compositeClipMaskUpload?.url || null, + productLockMethod: productLockOverlayUpload ? "rmbg-cutout" : null, + productLockOverlayUrl: productLockOverlayUpload?.url || null, + productLockProtectMaskUrl: productLockProtectMaskUpload?.url || null, + sourceCropUrl: sourceCropUpload.url, structureLockedDraft: structureLockAppliedRef.value, targetDraftFullUrl: targetDraftFullUpload.url, targetDraftUrl: targetDraftUpload.url, @@ -5201,10 +5389,12 @@ export async function POST(request: NextRequest) { useScaleAdjustProductLockComposite, scaleAdjustProductLockMethod: scaleAdjustPatchReference?.productLockMethod || null, - scaleAdjustProductLockOverlayUrl: - scaleAdjustPatchReference?.productLockOverlayUrl || null, - scaleAdjustPatchCompositeClipMaskUrl: - scaleAdjustPatchReference?.compositeClipMaskUrl || null, + scaleAdjustProductLockOverlayUrl: + scaleAdjustPatchReference?.productLockOverlayUrl || null, + scaleAdjustProductLockProtectMaskUrl: + scaleAdjustPatchReference?.productLockProtectMaskUrl || null, + scaleAdjustPatchCompositeClipMaskUrl: + scaleAdjustPatchReference?.compositeClipMaskUrl || null, scaleAdjustPatchWorkspacePixelRect: scaleAdjustPatchReference?.workspacePixelRect || null, scaleAdjustPatchWorkspaceRect: @@ -5365,13 +5555,15 @@ export async function POST(request: NextRequest) { workspaceRegion: scaleAdjustPatchReference.workspaceRect, } : undefined, - scaleAdjustProductLockComposite: useScaleAdjustProductLockComposite - ? { - overlayImageUrl: - scaleAdjustPatchReference?.productLockOverlayUrl as string, - sourceHeight, - sourceWidth, - } + scaleAdjustProductLockComposite: useScaleAdjustProductLockComposite + ? { + overlayImageUrl: + scaleAdjustPatchReference?.productLockOverlayUrl as string, + protectMaskImageUrl: + scaleAdjustPatchReference?.productLockProtectMaskUrl || undefined, + sourceHeight, + sourceWidth, + } : undefined, scaleAdjustVerification: scaleAdjustVerificationConfig, localEditComposite: isScaleAdjustOperation diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index baf12f257..e56eab833 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -73,6 +73,7 @@ export interface CanvasScaleAdjustPatchCompositeConfig { export interface CanvasScaleAdjustProductLockCompositeConfig { overlayImageUrl: string; + protectMaskImageUrl?: string; sourceHeight: number; sourceWidth: number; } @@ -555,12 +556,16 @@ function normalizeScaleAdjustProductLockCompositeConfig( if (!value || typeof value !== "object") return null; const overlayImageUrl = String(value.overlayImageUrl || "").trim(); if (!/^https?:\/\//i.test(overlayImageUrl)) return null; + const protectMaskImageUrl = String(value.protectMaskImageUrl || "").trim(); const sourceWidth = Math.round(Number(value.sourceWidth || 0)); const sourceHeight = Math.round(Number(value.sourceHeight || 0)); if (sourceWidth <= 0 || sourceHeight <= 0) return null; return { overlayImageUrl, + ...(protectMaskImageUrl && /^https?:\/\//i.test(protectMaskImageUrl) + ? { protectMaskImageUrl } + : {}), sourceHeight, sourceWidth, }; @@ -2807,6 +2812,44 @@ async function applyRawAlphaToImage(params: { .toBuffer(); } +async function applyScaleAdjustProductProtectMaskToOverlay(params: { + height: number; + overlayBuffer: Buffer; + protectMaskBuffer: Buffer; + width: number; +}) { + const { height, overlayBuffer, protectMaskBuffer, width } = params; + const overlayRaw = await sharp(overlayBuffer, { failOn: "none" }) + .resize(width, height, { fit: "fill" }) + .ensureAlpha() + .raw() + .toBuffer(); + const protectRaw = await sharp(protectMaskBuffer, { failOn: "none" }) + .rotate() + .resize(width, height, { fit: "fill" }) + .ensureAlpha() + .extractChannel(3) + .raw() + .toBuffer(); + const pixelCount = Math.min( + protectRaw.length, + Math.floor(overlayRaw.length / 4) + ); + for (let index = 0; index < pixelCount; index += 1) { + const protect = protectRaw[index] || 0; + if (protect <= 0) continue; + const alphaIndex = index * 4 + 3; + const alpha = overlayRaw[alphaIndex] || 0; + overlayRaw[alphaIndex] = Math.round((alpha * Math.max(0, 255 - protect)) / 255); + } + + return sharp(overlayRaw, { + raw: { channels: 4, height, width }, + }) + .png({ compressionLevel: 6 }) + .toBuffer(); +} + async function applyLocalEditCompositeToImages(params: { images: ParsedImage[]; composite: CanvasLocalEditCompositeConfig | null; @@ -3204,6 +3247,9 @@ async function applyScaleAdjustProductLockCompositeToImages(params: { .ensureAlpha() .png({ compressionLevel: 6 }) .toBuffer(); + const protectMaskBuffer = composite.protectMaskImageUrl + ? await fetchImageBufferFromUrl(composite.protectMaskImageUrl) + : null; const overlayMetadata = await sharp(overlayBuffer, { failOn: "none" }).metadata(); const overlayWidth = Math.round(Number(overlayMetadata.width || 0)); const overlayHeight = Math.round(Number(overlayMetadata.height || 0)); @@ -3230,13 +3276,21 @@ async function applyScaleAdjustProductLockCompositeToImages(params: { continue; } - const resizedOverlayBuffer = await sharp(overlayBuffer, { failOn: "none" }) + const resizedOverlayBaseBuffer = await sharp(overlayBuffer, { failOn: "none" }) .resize(generatedWidth, generatedHeight, { fit: "fill", kernel: sharp.kernel.lanczos3, }) .png({ compressionLevel: 6 }) .toBuffer(); + const resizedOverlayBuffer = protectMaskBuffer + ? await applyScaleAdjustProductProtectMaskToOverlay({ + height: generatedHeight, + overlayBuffer: resizedOverlayBaseBuffer, + protectMaskBuffer, + width: generatedWidth, + }) + : resizedOverlayBaseBuffer; const mergedBuffer = await sharp(normalizedGeneratedBuffer, { failOn: "none", }) @@ -3271,6 +3325,7 @@ async function applyScaleAdjustProductLockCompositeToImages(params: { imageCount: mergedImages.length, overlayHeight, overlayWidth, + protectMaskApplied: Boolean(protectMaskBuffer), sourceHeight: composite.sourceHeight, sourceWidth: composite.sourceWidth, });