fix: clip scale adjust patch composites

This commit is contained in:
134 2026-07-04 17:13:39 +08:00
parent 40fa7b1e26
commit 9ee2d2b50b
2 changed files with 211 additions and 31 deletions

View File

@ -124,6 +124,7 @@ type ScaleAdjustCropReference = {
url: string;
};
type ScaleAdjustPatchReference = {
compositeClipMaskUrl?: string | null;
sourceCropUrl: string;
structureLockedDraft?: boolean;
targetDraftUrl: string;
@ -136,6 +137,7 @@ type ScaleAdjustPatchReference = {
};
};
type ScaleAdjustStructureLockedComposite = {
compositeClip?: { input: Buffer; left: number; top: number };
method?: "rmbg-cutout" | "pixel-mask";
overlay: { input: Buffer; left: number; top: number };
wash: { input: Buffer; left: number; top: number };
@ -1865,6 +1867,124 @@ async function buildScaleAdjustMaskWashPatch(params: {
.toBuffer();
}
async function buildScaleAdjustCompositeClipMaskPatch(params: {
box: { width: number; height: number };
maskBuffer: Buffer | Uint8Array;
}) {
const { box, maskBuffer } = params;
const alpha = await sharp(Buffer.from(maskBuffer), {
raw: { channels: 1, height: box.height, width: box.width },
})
.blur(Math.max(1.4, Math.min(box.width, box.height) * 0.012))
.raw()
.toBuffer();
const rgba = Buffer.alloc(box.width * box.height * 4);
for (let index = 0; index < box.width * box.height; index += 1) {
const offset = index * 4;
rgba[offset] = 255;
rgba[offset + 1] = 255;
rgba[offset + 2] = 255;
rgba[offset + 3] = alpha[index] || 0;
}
return sharp(rgba, {
raw: { channels: 4, height: box.height, width: box.width },
})
.png({ compressionLevel: 6 })
.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 buildScaleAdjustCompositeClipMaskBuffer(params: {
height: number;
placements: Array<ReturnType<typeof getScaleAdjustVisualGuidePlacement>>;
sourceMaskComposites: 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,
},
]
: [];
return sharp({
create: {
background: { alpha: 0, b: 255, g: 255, r: 255 },
channels: 4,
height,
width,
},
})
.composite([...sourceMaskComposites, ...targetMask])
.png({ compressionLevel: 6 })
.toBuffer();
}
function getScaleAdjustAlphaBoundingBox(params: {
height: number;
rawRgba: Buffer;
@ -2241,6 +2361,10 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: {
box: placement.sourceBox,
maskBuffer: Buffer.from(sourceWashMask),
});
const compositeClipMaskPatch = await buildScaleAdjustCompositeClipMaskPatch({
box: placement.sourceBox,
maskBuffer: sourceWashMask,
});
const subjectCutout = await sharp(cleanedCutout, { failOn: "none" })
.extract(alphaBbox)
@ -2320,6 +2444,11 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: {
});
return {
compositeClip: {
input: compositeClipMaskPatch,
left: placement.sourceBox.left,
top: placement.sourceBox.top,
},
method: "rmbg-cutout",
overlay: {
input: structureOverlay,
@ -2435,6 +2564,10 @@ async function buildScaleAdjustStructureLockedComposite(params: {
box: placement.sourceBox,
maskBuffer: Buffer.from(sourceWashMask),
});
const compositeClipMaskPatch = await buildScaleAdjustCompositeClipMaskPatch({
box: placement.sourceBox,
maskBuffer: sourceWashMask,
});
const subjectCrop = await sharp(sourceBuffer, { failOn: "none" })
.extract(sourceSubjectBox)
.removeAlpha()
@ -2483,6 +2616,11 @@ async function buildScaleAdjustStructureLockedComposite(params: {
.toBuffer();
return {
compositeClip: {
input: compositeClipMaskPatch,
left: placement.sourceBox.left,
top: placement.sourceBox.top,
},
method: "pixel-mask",
overlay: {
input: structureOverlay,
@ -2627,6 +2765,11 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: {
left: number;
top: number;
}> = [];
const compositeClipComposites: Array<{
input: Buffer;
left: number;
top: number;
}> = [];
for (const region of regions) {
const placement = getScaleAdjustVisualGuidePlacement({
@ -2669,19 +2812,10 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: {
if (structureLockAppliedRef) {
structureLockAppliedRef.value = true;
}
sourceFootprintBoxes.push({
...placement.sourceFootprintBox,
background: sourceRaw
? estimateScaleAdjustBoxBackground({
box: placement.sourceFootprintBox,
raw: sourceRaw,
width: sourceWidth,
})
: null,
innerOpacity: 0.36,
opacity: 0.72,
});
subjectWashComposites.push(structureLockedComposite.wash);
if (structureLockedComposite.compositeClip) {
compositeClipComposites.push(structureLockedComposite.compositeClip);
}
composites.push(structureLockedComposite.overlay);
continue;
}
@ -2776,7 +2910,7 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: {
]
: [];
return sharp(sourceBuffer, { failOn: "none" })
const buffer = await sharp(sourceBuffer, { failOn: "none" })
.composite([
...washComposite,
...subjectWashComposites,
@ -2785,6 +2919,16 @@ async function buildScaleAdjustVisualLayoutGuideBuffer(params: {
])
.png({ compressionLevel: 6 })
.toBuffer();
const compositeClipMaskBuffer = compositeClipComposites.length
? await buildScaleAdjustCompositeClipMaskBuffer({
height: sourceHeight,
placements,
sourceMaskComposites: compositeClipComposites,
width: sourceWidth,
})
: null;
return { buffer, compositeClipMaskBuffer };
}
async function createScaleAdjustPatchReference(params: {
@ -2812,7 +2956,7 @@ async function createScaleAdjustPatchReference(params: {
scalePercent
);
const structureLockAppliedRef = { value: false };
const targetDraftBuffer = await buildScaleAdjustVisualLayoutGuideBuffer({
const targetDraftReference = await buildScaleAdjustVisualLayoutGuideBuffer({
regions,
scalePercent,
sourceBuffer,
@ -2823,6 +2967,7 @@ async function createScaleAdjustPatchReference(params: {
structureLockedDraft: shouldUseStructureLockedDraft,
userId,
});
const targetDraftBuffer = targetDraftReference.buffer;
const [sourceCropBuffer, targetDraftCropBuffer] = await Promise.all([
sharp(sourceBuffer, { failOn: "none" })
.extract(workspacePixelRect)
@ -2834,7 +2979,7 @@ async function createScaleAdjustPatchReference(params: {
.toBuffer(),
]);
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const [sourceCropUpload, targetDraftUpload] = await Promise.all([
const [sourceCropUpload, targetDraftUpload, compositeClipMaskUpload] = await Promise.all([
uploadFile(
`canvas-scale-adjust-workspace-source-${suffix}.png`,
toArrayBuffer(sourceCropBuffer)
@ -2843,9 +2988,16 @@ async function createScaleAdjustPatchReference(params: {
`canvas-scale-adjust-workspace-target-draft-${suffix}.png`,
toArrayBuffer(targetDraftCropBuffer)
),
targetDraftReference.compositeClipMaskBuffer
? uploadFile(
`canvas-scale-adjust-composite-clip-mask-${suffix}.png`,
toArrayBuffer(targetDraftReference.compositeClipMaskBuffer)
)
: Promise.resolve(null),
]);
return {
compositeClipMaskUrl: compositeClipMaskUpload?.url || null,
sourceCropUrl: sourceCropUpload.url,
structureLockedDraft: structureLockAppliedRef.value,
targetDraftUrl: targetDraftUpload.url,
@ -4003,13 +4155,15 @@ export async function POST(request: NextRequest) {
const annotatedBuffer = isScaleAdjustOperation
? useScaleAdjustCoordinatePrompt
? useScaleAdjustVisualLayoutGuide
? await buildScaleAdjustVisualLayoutGuideBuffer({
regions,
scalePercent,
sourceBuffer,
sourceHeight,
sourceWidth,
})
? (
await buildScaleAdjustVisualLayoutGuideBuffer({
regions,
scalePercent,
sourceBuffer,
sourceHeight,
sourceWidth,
})
).buffer
: useScaleAdjustMarkedPhotoGuide
? await buildScaleAdjustMarkedPhotoGuideBuffer({
height: sourceHeight,
@ -4029,13 +4183,15 @@ export async function POST(request: NextRequest) {
)
.png({ compressionLevel: 6 })
.toBuffer()
: await buildScaleAdjustVisualLayoutGuideBuffer({
regions,
scalePercent,
sourceBuffer,
sourceHeight,
sourceWidth,
})
: (
await buildScaleAdjustVisualLayoutGuideBuffer({
regions,
scalePercent,
sourceBuffer,
sourceHeight,
sourceWidth,
})
).buffer
: await sharp(sourceBuffer, { failOn: "none" })
.composite([
{
@ -4304,6 +4460,8 @@ export async function POST(request: NextRequest) {
scaleAdjustPatchReference?.sourceCropUrl || null,
scaleAdjustPatchTargetDraftUrl:
scaleAdjustPatchReference?.targetDraftUrl || null,
scaleAdjustPatchCompositeClipMaskUrl:
scaleAdjustPatchReference?.compositeClipMaskUrl || null,
scaleAdjustPatchWorkspacePixelRect:
scaleAdjustPatchReference?.workspacePixelRect || null,
scaleAdjustPatchWorkspaceRect:
@ -4357,7 +4515,7 @@ export async function POST(request: NextRequest) {
scaleAdjustPromptStrategy: isScaleAdjustOperation
? scaleAdjustPatchReference
? scaleAdjustPatchReference.structureLockedDraft
? "ai-patch-workspace-structure-locked-draft-v85-one-shot"
? "ai-patch-workspace-structure-locked-draft-v86-mask-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"
@ -4425,6 +4583,8 @@ export async function POST(request: NextRequest) {
maskMode: "diff",
regions: scaleAdjustPatchCompositeRegions,
sourceImageUrl,
clipMaskImageUrl:
scaleAdjustPatchReference.compositeClipMaskUrl || undefined,
workspaceRegion: scaleAdjustPatchReference.workspaceRect,
}
: undefined,

View File

@ -54,6 +54,7 @@ export interface CanvasLocalEditCompositeConfig {
}
export interface CanvasScaleAdjustPatchCompositeConfig {
clipMaskImageUrl?: string;
sourceImageUrl: string;
workspaceRegion: CanvasLocalEditCompositeRegion;
regions?: CanvasLocalEditCompositeRegion[];
@ -403,6 +404,9 @@ function normalizeScaleAdjustPatchCompositeConfig(
: [];
return {
...(/^https?:\/\//i.test(String(value.clipMaskImageUrl || "").trim())
? { clipMaskImageUrl: String(value.clipMaskImageUrl || "").trim() }
: {}),
sourceImageUrl,
workspaceRegion,
...(regions.length > 0 ? { regions } : {}),
@ -2913,6 +2917,21 @@ async function applyScaleAdjustPatchCompositeToImages(params: {
.extract(workspaceBox)
.png({ compressionLevel: 6 })
.toBuffer();
const customClipPatchMaskRaw = composite.clipMaskImageUrl
? await normalizeOpacityMaskToRaw({
height: workspaceBox.height,
maskBuffer: await sharp(await fetchImageBufferFromUrl(composite.clipMaskImageUrl), {
failOn: "none",
})
.rotate()
.resize(sourceWidth, sourceHeight, { fit: "fill" })
.extract(workspaceBox)
.png({ compressionLevel: 6 })
.toBuffer(),
width: workspaceBox.width,
})
: null;
const activeClipPatchMaskRaw = customClipPatchMaskRaw || regionClipPatchMaskRaw;
const diffRegions = (maskRegionBoxes?.length
? maskRegionBoxes
: [{ left: 0, top: 0, width: workspaceBox.width, height: workspaceBox.height }]
@ -2968,7 +2987,7 @@ async function applyScaleAdjustPatchCompositeToImages(params: {
maskBuffer: diffPatchMask,
width: workspaceBox.width,
}),
regionClipPatchMaskRaw ||
activeClipPatchMaskRaw ||
Buffer.alloc(workspaceBox.width * workspaceBox.height, 255)
),
typeof composite.diffAlphaCutoff === "number"
@ -3025,6 +3044,7 @@ async function applyScaleAdjustPatchCompositeToImages(params: {
debugLabel,
featherPx,
imageCount: mergedImages.length,
customClipMaskApplied: Boolean(customClipPatchMaskRaw),
diffRegionClipApplied: Boolean(regionClipPatchMask),
maskMode: composite.maskMode || "workspace",
maskRegionCount: maskRegionBoxes?.length || 0,