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; 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 | 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 { 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, }; }