211 lines
5.7 KiB
TypeScript
211 lines
5.7 KiB
TypeScript
import sharp from "sharp";
|
|
|
|
import {
|
|
executeRmbgJob,
|
|
fetchImageBufferFromUrl,
|
|
} from "@/lib/ai/rmbg/service";
|
|
|
|
export type NormalizedCropRect = {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
export interface ExecuteBoxCutoutJobParams {
|
|
userId: string;
|
|
imageUrl: string;
|
|
cropRect: Partial<NormalizedCropRect>;
|
|
paddingRatio?: number;
|
|
prompt?: string;
|
|
chatId?: string | null;
|
|
messageId?: string | null;
|
|
skipGallerySave?: boolean;
|
|
sourceNodeId?: string;
|
|
}
|
|
|
|
export interface ExecuteBoxCutoutJobResult {
|
|
success: true;
|
|
original_url: string;
|
|
thumbnail_url?: string | null;
|
|
generatedImageId?: string | null;
|
|
mediaAssetId?: string | null;
|
|
cropRectApplied: NormalizedCropRect;
|
|
paddedCropRectApplied: NormalizedCropRect;
|
|
cropRectPixels: {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
paddedCropRectPixels: {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
sourceNodeId?: string | null;
|
|
}
|
|
|
|
export class BoxCutoutServiceError extends Error {
|
|
status: number;
|
|
detail?: string;
|
|
|
|
constructor(message: string, status = 400, detail?: string) {
|
|
super(message);
|
|
this.name = "BoxCutoutServiceError";
|
|
this.status = status;
|
|
this.detail = detail;
|
|
}
|
|
}
|
|
|
|
const MIN_CROP_EDGE_PX = 24;
|
|
const DEFAULT_PADDING_RATIO = 0.08;
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
export function sanitizeNormalizedCropRect(
|
|
input: Partial<NormalizedCropRect> | undefined,
|
|
): NormalizedCropRect | null {
|
|
if (!input) return null;
|
|
|
|
const x = Number(input.x);
|
|
const y = Number(input.y);
|
|
const width = Number(input.width);
|
|
const height = Number(input.height);
|
|
|
|
if (![x, y, width, height].every((value) => Number.isFinite(value))) {
|
|
return null;
|
|
}
|
|
|
|
const normalized: NormalizedCropRect = {
|
|
x: clamp(x, 0, 1),
|
|
y: clamp(y, 0, 1),
|
|
width: clamp(width, 0, 1),
|
|
height: clamp(height, 0, 1),
|
|
};
|
|
|
|
if (normalized.width <= 0 || normalized.height <= 0) {
|
|
return null;
|
|
}
|
|
|
|
normalized.width = Math.min(normalized.width, 1 - normalized.x);
|
|
normalized.height = Math.min(normalized.height, 1 - normalized.y);
|
|
|
|
if (normalized.width <= 0 || normalized.height <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
export function normalizeBoxCutoutPaddingRatio(value: unknown) {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) return DEFAULT_PADDING_RATIO;
|
|
return clamp(numeric, 0, 0.25);
|
|
}
|
|
|
|
function toPixelRect(
|
|
rect: NormalizedCropRect,
|
|
sourceWidth: number,
|
|
sourceHeight: number,
|
|
) {
|
|
const left = clamp(Math.round(rect.x * sourceWidth), 0, Math.max(sourceWidth - 1, 0));
|
|
const top = clamp(Math.round(rect.y * sourceHeight), 0, Math.max(sourceHeight - 1, 0));
|
|
const width = clamp(Math.round(rect.width * sourceWidth), 1, sourceWidth - left);
|
|
const height = clamp(Math.round(rect.height * sourceHeight), 1, sourceHeight - top);
|
|
|
|
return { left, top, width, height };
|
|
}
|
|
|
|
function applyPadding(
|
|
rect: NormalizedCropRect,
|
|
paddingRatio: number,
|
|
): NormalizedCropRect {
|
|
const padX = rect.width * paddingRatio;
|
|
const padY = rect.height * paddingRatio;
|
|
|
|
const x = clamp(rect.x - padX, 0, 1);
|
|
const y = clamp(rect.y - padY, 0, 1);
|
|
const right = clamp(rect.x + rect.width + padX, 0, 1);
|
|
const bottom = clamp(rect.y + rect.height + padY, 0, 1);
|
|
|
|
return {
|
|
x,
|
|
y,
|
|
width: clamp(right - x, 0, 1),
|
|
height: clamp(bottom - y, 0, 1),
|
|
};
|
|
}
|
|
|
|
export async function executeBoxCutoutJob(
|
|
params: ExecuteBoxCutoutJobParams,
|
|
): Promise<ExecuteBoxCutoutJobResult> {
|
|
const imageUrl = String(params.imageUrl || "").trim();
|
|
if (!imageUrl) {
|
|
throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400);
|
|
}
|
|
|
|
const cropRect = sanitizeNormalizedCropRect(params.cropRect);
|
|
if (!cropRect) {
|
|
throw new BoxCutoutServiceError("缺少有效的 cropRect 参数", 400);
|
|
}
|
|
|
|
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
|
|
const sourceBuffer = await sharp(await fetchImageBufferFromUrl(imageUrl), {
|
|
failOn: "none",
|
|
})
|
|
.rotate()
|
|
.toBuffer();
|
|
const sourceImage = sharp(sourceBuffer, { failOn: "none" });
|
|
const sourceMetadata = await sourceImage.metadata();
|
|
const sourceWidth = Number(sourceMetadata.width || 0);
|
|
const sourceHeight = Number(sourceMetadata.height || 0);
|
|
|
|
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
|
throw new BoxCutoutServiceError("无法识别原图尺寸", 400);
|
|
}
|
|
|
|
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
|
|
if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) {
|
|
throw new BoxCutoutServiceError("框选区域过小,请重新框选", 400);
|
|
}
|
|
|
|
const paddedCropRect = applyPadding(cropRect, paddingRatio);
|
|
const paddedCropRectPixels = toPixelRect(paddedCropRect, sourceWidth, sourceHeight);
|
|
const croppedBuffer = await sourceImage
|
|
.extract({
|
|
left: paddedCropRectPixels.left,
|
|
top: paddedCropRectPixels.top,
|
|
width: paddedCropRectPixels.width,
|
|
height: paddedCropRectPixels.height,
|
|
})
|
|
.toBuffer();
|
|
|
|
const result = await executeRmbgJob({
|
|
userId: params.userId,
|
|
imageBuffer: croppedBuffer,
|
|
imageBase64: croppedBuffer.toString("base64"),
|
|
sourceImageUrl: imageUrl,
|
|
prompt: params.prompt || "框选抠图",
|
|
chatId: params.chatId || null,
|
|
messageId: params.messageId || null,
|
|
skipGallerySave: params.skipGallerySave === true,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
original_url: result.original_url,
|
|
thumbnail_url: result.thumbnail_url,
|
|
generatedImageId: result.generatedImageId,
|
|
mediaAssetId: result.mediaAssetId,
|
|
cropRectApplied: cropRect,
|
|
paddedCropRectApplied: paddedCropRect,
|
|
cropRectPixels,
|
|
paddedCropRectPixels,
|
|
sourceNodeId: params.sourceNodeId || null,
|
|
};
|
|
}
|