429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
import sharp from "sharp";
|
|
|
|
import { executeRmbgJob } from "@/lib/ai/rmbg/service";
|
|
export type CanvasScaleAdjustRect = {
|
|
height: number;
|
|
width: number;
|
|
x: number;
|
|
y: number;
|
|
};
|
|
|
|
export type CanvasScaleAdjustIntentRegion = {
|
|
index: number;
|
|
rect: CanvasScaleAdjustRect;
|
|
targetRect: CanvasScaleAdjustRect;
|
|
};
|
|
|
|
type PixelBox = {
|
|
height: number;
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
};
|
|
|
|
export type CanvasScaleAdjustProductLockAssets = {
|
|
backgroundPlateBuffer?: Buffer;
|
|
modelMaskBuffer: Buffer;
|
|
overlayBuffer: Buffer;
|
|
personExclusionApplied: boolean;
|
|
personExclusionMaskBuffer?: Buffer;
|
|
repairMaskBuffer: Buffer;
|
|
sourceSubjectMaskBuffer: Buffer;
|
|
subjectReferenceUrls: string[];
|
|
};
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function rectToPixelBox(
|
|
rect: CanvasScaleAdjustRect,
|
|
sourceWidth: number,
|
|
sourceHeight: number
|
|
): PixelBox {
|
|
const left = clamp(Math.floor(rect.x * sourceWidth), 0, sourceWidth - 1);
|
|
const top = clamp(Math.floor(rect.y * sourceHeight), 0, sourceHeight - 1);
|
|
const right = clamp(
|
|
Math.ceil((rect.x + rect.width) * sourceWidth),
|
|
left + 1,
|
|
sourceWidth
|
|
);
|
|
const bottom = clamp(
|
|
Math.ceil((rect.y + rect.height) * sourceHeight),
|
|
top + 1,
|
|
sourceHeight
|
|
);
|
|
return {
|
|
height: bottom - top,
|
|
left,
|
|
top,
|
|
width: right - left,
|
|
};
|
|
}
|
|
|
|
async function normalizeSubjectBuffer(params: {
|
|
box: PixelBox;
|
|
exclusionAlpha?: Buffer | null;
|
|
imageBase64: string;
|
|
}) {
|
|
const { box, exclusionAlpha, imageBase64 } = params;
|
|
const rawSubjectBuffer = await sharp(Buffer.from(imageBase64, "base64"), {
|
|
failOn: "none",
|
|
})
|
|
.rotate()
|
|
.resize(box.width, box.height, {
|
|
fit: "fill",
|
|
kernel: sharp.kernel.lanczos3,
|
|
})
|
|
.ensureAlpha()
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer();
|
|
const rawAlpha = await sharp(rawSubjectBuffer, { failOn: "none" })
|
|
.extractChannel(3)
|
|
.raw()
|
|
.toBuffer();
|
|
const alpha = Buffer.from(rawAlpha);
|
|
if (exclusionAlpha?.length === rawAlpha.length) {
|
|
let productPixelCount = 0;
|
|
let overlapPixelCount = 0;
|
|
for (let index = 0; index < alpha.length; index += 1) {
|
|
if (alpha[index] >= 18) productPixelCount += 1;
|
|
if (alpha[index] >= 18 && exclusionAlpha[index] >= 8) {
|
|
overlapPixelCount += 1;
|
|
}
|
|
}
|
|
const overlapLimit = Math.max(96, Math.round(productPixelCount * 0.004));
|
|
if (overlapPixelCount >= overlapLimit) {
|
|
throw new Error(
|
|
"目标产品被人物遮挡,暂无法在保持产品完整的前提下移动,请选择未被人物遮挡的完整产品"
|
|
);
|
|
}
|
|
for (let index = 0; index < alpha.length; index += 1) {
|
|
if (exclusionAlpha[index] >= 8) alpha[index] = 0;
|
|
}
|
|
}
|
|
const visiblePixels = alpha.reduce(
|
|
(count, value) => count + (value >= 18 ? 1 : 0),
|
|
0
|
|
);
|
|
if (visiblePixels < Math.max(32, box.width * box.height * 0.008)) {
|
|
throw new Error("比例调整无法识别完整产品,请重新完整框选产品");
|
|
}
|
|
const subjectBuffer = exclusionAlpha
|
|
? await sharp(
|
|
await sharp(rawSubjectBuffer, { failOn: "none" })
|
|
.removeAlpha()
|
|
.toColorspace("srgb")
|
|
.raw()
|
|
.toBuffer(),
|
|
{
|
|
raw: { channels: 3, height: box.height, width: box.width },
|
|
}
|
|
)
|
|
.joinChannel(alpha, {
|
|
raw: { channels: 1, height: box.height, width: box.width },
|
|
})
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer()
|
|
: rawSubjectBuffer;
|
|
return { alpha, subjectBuffer };
|
|
}
|
|
|
|
async function buildExpandedMaskPiece(params: {
|
|
alpha: Buffer;
|
|
box: PixelBox;
|
|
exclusionAlpha?: Buffer | null;
|
|
}) {
|
|
const { alpha, box, exclusionAlpha } = params;
|
|
const radius = Math.max(2, Math.round(Math.min(box.width, box.height) * 0.06));
|
|
const blurredAlphaRaw = await sharp(alpha, {
|
|
raw: { channels: 1, height: box.height, width: box.width },
|
|
})
|
|
.blur(radius)
|
|
.extractChannel(0)
|
|
.raw()
|
|
.toBuffer();
|
|
const expandedAlphaRaw = Buffer.from(blurredAlphaRaw);
|
|
for (let index = 0; index < expandedAlphaRaw.length; index += 1) {
|
|
expandedAlphaRaw[index] = expandedAlphaRaw[index] >= 8 ? 255 : 0;
|
|
}
|
|
if (exclusionAlpha?.length === expandedAlphaRaw.length) {
|
|
for (let index = 0; index < expandedAlphaRaw.length; index += 1) {
|
|
if (exclusionAlpha[index] >= 8) expandedAlphaRaw[index] = 0;
|
|
}
|
|
}
|
|
const expandedAlpha = await sharp(expandedAlphaRaw, {
|
|
raw: { channels: 1, height: box.height, width: box.width },
|
|
})
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer();
|
|
const whiteRgb = await sharp({
|
|
create: {
|
|
background: { b: 255, g: 255, r: 255 },
|
|
channels: 3,
|
|
height: box.height,
|
|
width: box.width,
|
|
},
|
|
})
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer();
|
|
return sharp(whiteRgb).joinChannel(expandedAlpha).png({ compressionLevel: 6 }).toBuffer();
|
|
}
|
|
|
|
async function buildSubjectMaskPiece(params: { alpha: Buffer; box: PixelBox }) {
|
|
const alphaPng = await sharp(params.alpha, {
|
|
raw: { channels: 1, height: params.box.height, width: params.box.width },
|
|
})
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer();
|
|
const whiteRgb = await sharp({
|
|
create: {
|
|
background: { b: 255, g: 255, r: 255 },
|
|
channels: 3,
|
|
height: params.box.height,
|
|
width: params.box.width,
|
|
},
|
|
})
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer();
|
|
return sharp(whiteRgb)
|
|
.joinChannel(alphaPng)
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer();
|
|
}
|
|
|
|
async function buildPersonExclusionAlpha(params: {
|
|
sourceBuffer: Buffer;
|
|
sourceHeight: number;
|
|
sourceImageUrl: string;
|
|
sourceWidth: number;
|
|
userId: string;
|
|
}) {
|
|
try {
|
|
const person = await executeRmbgJob({
|
|
foregroundType: "person",
|
|
imageBuffer: params.sourceBuffer,
|
|
prompt: "比例调整人物保护蒙版",
|
|
skipGallerySave: true,
|
|
sourceImageUrl: params.sourceImageUrl,
|
|
userId: params.userId,
|
|
});
|
|
const alpha = await sharp(Buffer.from(person.image_base64, "base64"), {
|
|
failOn: "none",
|
|
})
|
|
.rotate()
|
|
.resize(params.sourceWidth, params.sourceHeight, { fit: "fill" })
|
|
.ensureAlpha()
|
|
.extractChannel(3)
|
|
.raw()
|
|
.toBuffer();
|
|
const visiblePixels = alpha.reduce(
|
|
(count, value) => count + (value >= 18 ? 1 : 0),
|
|
0
|
|
);
|
|
if (visiblePixels < Math.max(32, params.sourceWidth * params.sourceHeight * 0.001)) {
|
|
return null;
|
|
}
|
|
return sharp(alpha, {
|
|
raw: {
|
|
channels: 1,
|
|
height: params.sourceHeight,
|
|
width: params.sourceWidth,
|
|
},
|
|
})
|
|
.blur(Math.max(1, Math.min(params.sourceWidth, params.sourceHeight) * 0.002))
|
|
.threshold(8)
|
|
.extractChannel(0)
|
|
.raw()
|
|
.toBuffer();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function extractAlphaRegion(params: {
|
|
alpha: Buffer | null;
|
|
box: PixelBox;
|
|
sourceHeight: number;
|
|
sourceWidth: number;
|
|
}) {
|
|
if (!params.alpha) return null;
|
|
return sharp(params.alpha, {
|
|
raw: {
|
|
channels: 1,
|
|
height: params.sourceHeight,
|
|
width: params.sourceWidth,
|
|
},
|
|
})
|
|
.extract(params.box)
|
|
.extractChannel(0)
|
|
.raw()
|
|
.toBuffer();
|
|
}
|
|
|
|
export async function buildScaleAdjustProductLockAssets(params: {
|
|
protectPeople?: boolean;
|
|
regions: CanvasScaleAdjustIntentRegion[];
|
|
sourceBuffer: Buffer;
|
|
sourceHeight: number;
|
|
sourceImageUrl: string;
|
|
sourceWidth: number;
|
|
userId: string;
|
|
}): Promise<CanvasScaleAdjustProductLockAssets> {
|
|
const {
|
|
regions,
|
|
sourceBuffer,
|
|
sourceHeight,
|
|
sourceImageUrl,
|
|
sourceWidth,
|
|
userId,
|
|
} = params;
|
|
const overlayInputs: sharp.OverlayOptions[] = [];
|
|
const maskInputs: sharp.OverlayOptions[] = [];
|
|
const sourceSubjectMaskInputs: sharp.OverlayOptions[] = [];
|
|
const subjectReferenceUrls: string[] = [];
|
|
const personExclusionAlpha = params.protectPeople
|
|
? await buildPersonExclusionAlpha({
|
|
sourceBuffer,
|
|
sourceHeight,
|
|
sourceImageUrl,
|
|
sourceWidth,
|
|
userId,
|
|
})
|
|
: null;
|
|
|
|
for (const region of regions) {
|
|
const sourceBox = rectToPixelBox(
|
|
region.rect,
|
|
sourceWidth,
|
|
sourceHeight
|
|
);
|
|
const targetBox = rectToPixelBox(
|
|
region.targetRect,
|
|
sourceWidth,
|
|
sourceHeight
|
|
);
|
|
const cropBuffer = await sharp(sourceBuffer, { failOn: "none" })
|
|
.extract(sourceBox)
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer();
|
|
const subject = await executeRmbgJob({
|
|
imageBuffer: cropBuffer,
|
|
prompt: "比例调整完整产品蒙版",
|
|
skipGallerySave: true,
|
|
sourceImageUrl,
|
|
userId,
|
|
});
|
|
const regionPersonExclusionAlpha = await extractAlphaRegion({
|
|
alpha: personExclusionAlpha,
|
|
box: sourceBox,
|
|
sourceHeight,
|
|
sourceWidth,
|
|
});
|
|
const { alpha, subjectBuffer } = await normalizeSubjectBuffer({
|
|
box: sourceBox,
|
|
exclusionAlpha: regionPersonExclusionAlpha,
|
|
imageBase64: subject.image_base64,
|
|
});
|
|
const scaledSubjectBuffer = await sharp(subjectBuffer, { failOn: "none" })
|
|
.resize(targetBox.width, targetBox.height, {
|
|
fit: "fill",
|
|
kernel: sharp.kernel.lanczos3,
|
|
})
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer();
|
|
overlayInputs.push({
|
|
input: scaledSubjectBuffer,
|
|
left: targetBox.left,
|
|
top: targetBox.top,
|
|
});
|
|
maskInputs.push({
|
|
input: await buildExpandedMaskPiece({
|
|
alpha,
|
|
box: sourceBox,
|
|
exclusionAlpha: regionPersonExclusionAlpha,
|
|
}),
|
|
left: sourceBox.left,
|
|
top: sourceBox.top,
|
|
});
|
|
sourceSubjectMaskInputs.push({
|
|
input: await buildSubjectMaskPiece({ alpha, box: sourceBox }),
|
|
left: sourceBox.left,
|
|
top: sourceBox.top,
|
|
});
|
|
subjectReferenceUrls.push(subject.original_url);
|
|
}
|
|
|
|
const transparentCanvas = {
|
|
create: {
|
|
background: { alpha: 0, b: 0, g: 0, r: 0 },
|
|
channels: 4 as const,
|
|
height: sourceHeight,
|
|
width: sourceWidth,
|
|
},
|
|
};
|
|
const [modelMaskBuffer, overlayBuffer, repairMaskBuffer, sourceSubjectMaskBuffer] =
|
|
await Promise.all([
|
|
sharp({
|
|
create: {
|
|
background: { b: 0, g: 0, r: 0 },
|
|
channels: 3,
|
|
height: sourceHeight,
|
|
width: sourceWidth,
|
|
},
|
|
})
|
|
.composite(maskInputs)
|
|
.flatten({ background: "#000000" })
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer(),
|
|
sharp(transparentCanvas)
|
|
.composite(overlayInputs)
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer(),
|
|
sharp(transparentCanvas)
|
|
.composite(maskInputs)
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer(),
|
|
sharp(transparentCanvas)
|
|
.composite(sourceSubjectMaskInputs)
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer(),
|
|
]);
|
|
const sourceAlpha = await sharp(sourceBuffer, { failOn: "none" })
|
|
.rotate()
|
|
.resize(sourceWidth, sourceHeight, { fit: "fill" })
|
|
.ensureAlpha()
|
|
.extractChannel(3)
|
|
.raw()
|
|
.toBuffer();
|
|
const transparentPixelCount = sourceAlpha.reduce(
|
|
(count, value) => count + (value < 250 ? 1 : 0),
|
|
0
|
|
);
|
|
const hasTransparentBackground =
|
|
transparentPixelCount >= sourceWidth * sourceHeight * 0.05;
|
|
const personExclusionMaskBuffer = personExclusionAlpha
|
|
? await sharp(personExclusionAlpha, {
|
|
raw: { channels: 1, height: sourceHeight, width: sourceWidth },
|
|
})
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer()
|
|
: undefined;
|
|
const backgroundPlateBuffer = hasTransparentBackground
|
|
? await sharp(transparentCanvas)
|
|
.png({ compressionLevel: 6 })
|
|
.toBuffer()
|
|
: undefined;
|
|
|
|
return {
|
|
...(backgroundPlateBuffer ? { backgroundPlateBuffer } : {}),
|
|
modelMaskBuffer,
|
|
overlayBuffer,
|
|
personExclusionApplied: Boolean(personExclusionAlpha),
|
|
...(personExclusionMaskBuffer ? { personExclusionMaskBuffer } : {}),
|
|
repairMaskBuffer,
|
|
sourceSubjectMaskBuffer,
|
|
subjectReferenceUrls,
|
|
};
|
|
}
|