Your Name 0bdc16492d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
refactor: reuse shared rmbg service for box cutout
2026-03-07 10:12:57 +08:00

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 }
);
}
}