205 lines
5.9 KiB
TypeScript
205 lines
5.9 KiB
TypeScript
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<NormalizedCropRect>;
|
|
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<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;
|
|
}
|
|
|
|
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 }
|
|
);
|
|
}
|
|
}
|