221 lines
6.6 KiB
TypeScript
221 lines
6.6 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import sharp from "sharp";
|
|
|
|
import { auth } from "@/app/(auth)/auth";
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
async function fetchImageBuffer(imageUrl: string) {
|
|
const response = await fetch(imageUrl, { cache: "no-store" });
|
|
if (!response.ok) {
|
|
throw new Error(`无法获取图片: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
return Buffer.from(arrayBuffer);
|
|
}
|
|
|
|
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 fetchImageBuffer(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 croppedBase64 = croppedBuffer.toString("base64");
|
|
const rmbgUrl = new URL("/api/rmbg", request.url);
|
|
const rmbgResponse = await fetch(rmbgUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
cookie: request.headers.get("cookie") || "",
|
|
...(request.headers.get("authorization")
|
|
? { authorization: request.headers.get("authorization") as string }
|
|
: {}),
|
|
},
|
|
cache: "no-store",
|
|
body: JSON.stringify({
|
|
image_base64: croppedBase64,
|
|
sourceImageUrl: resolvedImageUrl,
|
|
prompt: body.prompt || "框选抠图",
|
|
chatId: body.chatId || null,
|
|
messageId: body.messageId || null,
|
|
skipGallerySave: body.skipGallerySave === true,
|
|
}),
|
|
});
|
|
|
|
const rmbgResult = await rmbgResponse.json().catch(() => null);
|
|
if (!rmbgResponse.ok || !rmbgResult?.success) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: rmbgResult?.error || rmbgResult?.detail || "框选抠图失败",
|
|
},
|
|
{ status: rmbgResponse.status || 500 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({
|
|
...rmbgResult,
|
|
cropRectApplied: cropRect,
|
|
paddedCropRectApplied: paddedCropRect,
|
|
cropRectPixels,
|
|
paddedCropRectPixels,
|
|
sourceNodeId: body.sourceNodeId || null,
|
|
});
|
|
} catch (error) {
|
|
console.error("[BoxCutout] 处理失败:", error);
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "框选抠图失败",
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|