import { NextRequest, NextResponse } from "next/server"; import sharp from "sharp"; import { auth } from "@/app/(auth)/auth"; import { executeRmbgJob, fetchImageBufferFromUrl, RmbgServiceError, } from "@/lib/ai/rmbg/service"; export const runtime = "nodejs"; export const maxDuration = 60; type NormalizedCropRect = { x: number; y: number; width: number; height: number; }; type BoxCutoutRequest = { imageUrl?: string; cropRect?: Partial; paddingRatio?: number; prompt?: string; chatId?: string | null; messageId?: string | null; skipGallerySave?: boolean; sourceNodeId?: string; }; 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)); } 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; } function normalizePaddingRatio(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 POST(request: NextRequest) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 }); } const body = (await request.json()) as BoxCutoutRequest; const rawImageUrl = String(body.imageUrl || "").trim(); const cropRect = sanitizeNormalizedCropRect(body.cropRect); if (!rawImageUrl) { return NextResponse.json({ success: false, error: "缺少 imageUrl 参数" }, { status: 400 }); } if (!cropRect) { return NextResponse.json({ success: false, error: "缺少有效的 cropRect 参数" }, { status: 400 }); } const resolvedImageUrl = new URL(rawImageUrl, request.url).toString(); const paddingRatio = normalizePaddingRatio(body.paddingRatio); const sourceBuffer = await sharp(await fetchImageBufferFromUrl(resolvedImageUrl), { 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) { return NextResponse.json({ success: false, error: "无法识别原图尺寸" }, { status: 400 }); } const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight); if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) { return NextResponse.json( { success: false, error: "框选区域过小,请重新框选" }, { status: 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 rmbgResult = await executeRmbgJob({ userId: session.user.id, imageBuffer: croppedBuffer, imageBase64: croppedBuffer.toString("base64"), sourceImageUrl: resolvedImageUrl, prompt: body.prompt || "框选抠图", chatId: body.chatId || null, messageId: body.messageId || null, skipGallerySave: body.skipGallerySave === true, }); return NextResponse.json({ success: true, ...rmbgResult, cropRectApplied: cropRect, paddedCropRectApplied: paddedCropRect, cropRectPixels, paddedCropRectPixels, sourceNodeId: body.sourceNodeId || null, }); } catch (error) { if (error instanceof RmbgServiceError) { return NextResponse.json( { success: false, error: error.detail || error.message, }, { status: error.status } ); } console.error("[BoxCutout] 处理失败:", error); return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "框选抠图失败", }, { status: 500 } ); } }