From 50a59b82afd17773cfa5515d3f1c36a7e28dc4a0 Mon Sep 17 00:00:00 2001 From: 134 Date: Sat, 4 Jul 2026 18:08:52 +0800 Subject: [PATCH] fix: clip scale targets by subject alpha --- app/api/canvas/local-edit/route.ts | 138 +++++++++++++---------------- 1 file changed, 63 insertions(+), 75 deletions(-) diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index 4338b9a4c..ff0c2c446 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -140,6 +140,7 @@ type ScaleAdjustStructureLockedComposite = { compositeClip?: { input: Buffer; left: number; top: number }; method?: "rmbg-cutout" | "pixel-mask"; overlay: { input: Buffer; left: number; top: number }; + targetClip?: { input: Buffer; left: number; top: number }; wash: { input: Buffer; left: number; top: number }; }; type AiModelWithProviderForSelection = Awaited< @@ -1893,84 +1894,42 @@ async function buildScaleAdjustCompositeClipMaskPatch(params: { .toBuffer(); } -function buildScaleAdjustCompositeClipTargetSvg(params: { - height: number; - placements: Array>; - width: number; -}) { - const { height, placements, width } = params; - const minSide = Math.max(1, Math.min(width, height)); - const blur = Math.max(4, Math.min(24, Math.round(minSide * 0.014))); - const pad = Math.max(3, Math.round(blur * 0.7)); - const rects = placements - .map((placement) => { - const left = clamp( - Math.min(placement.placedLeft, placement.targetBox.left) - pad, - 0, - Math.max(0, width - 1) - ); - const top = clamp( - Math.min(placement.placedTop, placement.targetBox.top) - pad, - 0, - Math.max(0, height - 1) - ); - const right = clamp( - Math.max( - placement.placedLeft + placement.placedWidth, - placement.targetBox.left + placement.targetBox.width - ) + pad, - left + 1, - width - ); - const bottom = clamp( - Math.max( - placement.placedTop + placement.placedHeight, - placement.targetBox.top + placement.targetBox.height - ) + pad, - top + 1, - height - ); - const rectWidth = Math.max(1, right - left); - const rectHeight = Math.max(1, bottom - top); - const radius = Math.max(4, Math.round(blur * 1.2)); - return ``; - }) - .join(""); - - return Buffer.from( - ` - - - - - - ${rects} - ` - ); +async function buildScaleAdjustOverlayClipMaskPatch(imageBuffer: Buffer) { + const metadata = await sharp(imageBuffer, { failOn: "none" }).metadata(); + const width = Math.max(1, Math.round(Number(metadata.width || 0))); + const height = Math.max(1, Math.round(Number(metadata.height || 0))); + const alpha = await sharp(imageBuffer, { failOn: "none" }) + .ensureAlpha() + .extractChannel(3) + .blur(Math.max(0.8, Math.min(width, height) * 0.006)) + .raw() + .toBuffer(); + const rgba = Buffer.alloc(width * height * 4); + for (let index = 0; index < width * height; index += 1) { + const offset = index * 4; + const value = alpha[index] || 0; + rgba[offset] = 255; + rgba[offset + 1] = 255; + rgba[offset + 2] = 255; + rgba[offset + 3] = value < 6 ? 0 : Math.min(255, Math.round(value * 1.35)); + } + return sharp(rgba, { + raw: { channels: 4, height, width }, + }) + .png({ compressionLevel: 6 }) + .toBuffer(); } async function buildScaleAdjustCompositeClipMaskBuffer(params: { height: number; - placements: Array>; sourceMaskComposites: Array<{ input: Buffer; left: number; top: number }>; + targetMaskComposites: Array<{ input: Buffer; left: number; top: number }>; width: number; }) { - const { height, placements, sourceMaskComposites, width } = params; - if (placements.length === 0 && sourceMaskComposites.length === 0) return null; - const targetMask = - placements.length > 0 - ? [ - { - input: buildScaleAdjustCompositeClipTargetSvg({ - height, - placements, - width, - }), - left: 0, - top: 0, - }, - ] - : []; + const { height, sourceMaskComposites, targetMaskComposites, width } = params; + if (sourceMaskComposites.length === 0 && targetMaskComposites.length === 0) { + return null; + } return sharp({ create: { @@ -1980,7 +1939,7 @@ async function buildScaleAdjustCompositeClipMaskBuffer(params: { width, }, }) - .composite([...sourceMaskComposites, ...targetMask]) + .composite([...sourceMaskComposites, ...targetMaskComposites]) .png({ compressionLevel: 6 }) .toBuffer(); } @@ -2430,6 +2389,8 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: { }) .png({ compressionLevel: 6 }) .toBuffer(); + const targetClipMaskPatch = + await buildScaleAdjustOverlayClipMaskPatch(structureOverlay); console.info("[Canvas Local Edit] Scale adjust RMBG structure draft applied:", { alphaRatio: Number(alphaRatio.toFixed(3)), @@ -2455,6 +2416,11 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: { left: Math.round(targetCenterX - placedWidth / 2), top: Math.round(targetCenterY - placedHeight / 2), }, + targetClip: { + input: targetClipMaskPatch, + left: Math.round(targetCenterX - placedWidth / 2), + top: Math.round(targetCenterY - placedHeight / 2), + }, wash: { input: sourceWashPatch, left: placement.sourceBox.left, @@ -2614,6 +2580,8 @@ async function buildScaleAdjustStructureLockedComposite(params: { }) .png({ compressionLevel: 6 }) .toBuffer(); + const targetClipMaskPatch = + await buildScaleAdjustOverlayClipMaskPatch(structureOverlay); return { compositeClip: { @@ -2627,6 +2595,11 @@ async function buildScaleAdjustStructureLockedComposite(params: { left: Math.round(targetCenterX - placedWidth / 2), top: Math.round(targetCenterY - placedHeight / 2), }, + targetClip: { + input: targetClipMaskPatch, + left: Math.round(targetCenterX - placedWidth / 2), + top: Math.round(targetCenterY - placedHeight / 2), + }, wash: { input: sourceWashPatch, left: placement.sourceBox.left, @@ -2770,6 +2743,11 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: { left: number; top: number; }> = []; + const targetClipComposites: Array<{ + input: Buffer; + left: number; + top: number; + }> = []; for (const region of regions) { const placement = getScaleAdjustVisualGuidePlacement({ @@ -2816,6 +2794,9 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: { if (structureLockedComposite.compositeClip) { compositeClipComposites.push(structureLockedComposite.compositeClip); } + if (structureLockedComposite.targetClip) { + targetClipComposites.push(structureLockedComposite.targetClip); + } composites.push(structureLockedComposite.overlay); continue; } @@ -2839,6 +2820,12 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: { if (shrinkCutoutComposite) { subjectWashComposites.push(shrinkCutoutComposite.wash); + if (shrinkCutoutComposite.compositeClip) { + compositeClipComposites.push(shrinkCutoutComposite.compositeClip); + } + if (shrinkCutoutComposite.targetClip) { + targetClipComposites.push(shrinkCutoutComposite.targetClip); + } composites.push(shrinkCutoutComposite.overlay); continue; } @@ -2919,11 +2906,12 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: { ]) .png({ compressionLevel: 6 }) .toBuffer(); - const compositeClipMaskBuffer = compositeClipComposites.length + const compositeClipMaskBuffer = + compositeClipComposites.length || targetClipComposites.length ? await buildScaleAdjustCompositeClipMaskBuffer({ height: sourceHeight, - placements, sourceMaskComposites: compositeClipComposites, + targetMaskComposites: targetClipComposites, width: sourceWidth, }) : null; @@ -4515,7 +4503,7 @@ export async function POST(request: NextRequest) { scaleAdjustPromptStrategy: isScaleAdjustOperation ? scaleAdjustPatchReference ? scaleAdjustPatchReference.structureLockedDraft - ? "ai-patch-workspace-structure-locked-draft-v86-mask-clipped-one-shot" + ? "ai-patch-workspace-structure-locked-draft-v87-subject-alpha-clipped-one-shot" : "ai-patch-workspace-draft-v75-soft-wipe-erase-ghost-one-shot" : useScaleAdjustEditMask ? "mask-edit-gpt-image-2-scale-shrink-v74-one-shot"