fix: clip scale targets by subject alpha

This commit is contained in:
134 2026-07-04 18:08:52 +08:00
parent 9ee2d2b50b
commit 50a59b82af

View File

@ -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<ReturnType<typeof getScaleAdjustVisualGuidePlacement>>;
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 `<rect x="${left}" y="${top}" width="${rectWidth}" height="${rectHeight}" rx="${radius}" ry="${radius}" fill="#ffffff" filter="url(#target-soft)" /><rect x="${left}" y="${top}" width="${rectWidth}" height="${rectHeight}" rx="${radius}" ry="${radius}" fill="#ffffff" fill-opacity="0.96" />`;
})
.join("");
return Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
<filter id="target-soft" x="-45%" y="-45%" width="190%" height="190%">
<feGaussianBlur stdDeviation="${blur}" />
</filter>
</defs>
${rects}
</svg>`
);
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<ReturnType<typeof getScaleAdjustVisualGuidePlacement>>;
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"