feat: 添加画图标注抠图功能

- 新增抠图API路由 (/api/rmbg)
  - 从数据库读取RMBG API配置
  - 调用外部RMBG服务进行抠图
  - 上传处理后的图片到S3存储桶(缩略图+原图)
  - 保存图片记录到GeneratedImage表
  - 返回S3 URL和Base64给前端

- 在画图标注组件中添加抠图功能
  - 添加抠图按钮(带Scissors图标)
  - 调用抠图API处理画布图片
  - 使用S3 URL替换画布图片
  - 显示处理状态和成功提示

- 在系统设置中添加抠图API配置
  - 新增抠图设置标签页
  - API地址和密钥配置
  - 配置保存到SystemSettings表(key: rmbg_api_config)

- 功能特点:
  - 图片自动上传到S3存储桶
  - 自动生成缩略图(500px,PNG格式保持透明)
  - 保存到数据库,可在相册和出图记录中查看
  - 支持透明背景PNG格式
  - 完整的错误处理和用户提示
This commit is contained in:
Your Name 2025-12-28 23:38:51 +08:00
parent a8abed6b5b
commit 88df4d8793
3 changed files with 461 additions and 1 deletions

View File

@ -47,6 +47,7 @@ import {
Clock,
Sparkles,
RefreshCw,
Scissors,
} from "lucide-react";
import {
Dialog,
@ -340,6 +341,24 @@ export function AdminSettingsClient() {
}
}, [siteSettingsData]);
// 抠图API配置状态
const [rmbgConfig, setRmbgConfig] = useState({
apiUrl: "",
apiKey: "",
});
// 加载抠图API配置
const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher);
useEffect(() => {
if (rmbgConfigData?.setting?.value) {
const config = rmbgConfigData.setting.value as { apiUrl?: string; apiKey?: string };
setRmbgConfig({
apiUrl: config.apiUrl || "",
apiKey: config.apiKey || "",
});
}
}, [rmbgConfigData]);
// 导航链接状态
const [navLinkForm, setNavLinkForm] = useState({
id: "",
@ -620,6 +639,33 @@ export function AdminSettingsClient() {
}
};
// --- 抠图API配置逻辑 ---
const handleSaveRmbgConfig = async () => {
try {
if (!rmbgConfig.apiUrl.trim()) {
toast.error("请填写API地址");
return;
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "rmbg_api_config",
value: {
apiUrl: rmbgConfig.apiUrl.trim(),
apiKey: rmbgConfig.apiKey.trim() || undefined,
},
}),
});
if (!response.ok) throw new Error("保存失败");
toast.success("抠图API配置保存成功");
} catch (error) {
toast.error("保存抠图API配置失败");
}
};
// --- 导航链接逻辑 ---
const resetNavLinkForm = () => {
setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true });
@ -970,6 +1016,10 @@ export function AdminSettingsClient() {
<HardDrive className="mr-2 h-4 w-4" />
存储配置
</TabsTrigger>
<TabsTrigger value="rmbg">
<Scissors className="mr-2 h-4 w-4" />
抠图设置
</TabsTrigger>
</TabsList>
{/* 用户管理 */}
@ -2851,6 +2901,57 @@ export function AdminSettingsClient() {
</Card>
</TabsContent>
{/* 抠图设置 */}
<TabsContent value="rmbg" className="mt-6">
<Card>
<CardHeader>
<CardTitle>抠图API配置</CardTitle>
<CardDescription>
配置RMBG-2.0背景移除API,用于画图标注功能中的抠图处理
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-2">
<Label htmlFor="rmbgApiUrl">API地址 *</Label>
<Input
id="rmbgApiUrl"
value={rmbgConfig.apiUrl}
onChange={(e) => setRmbgConfig({ ...rmbgConfig, apiUrl: e.target.value })}
placeholder="http://156.225.27.52:8000"
/>
<div className="text-xs text-muted-foreground">
请输入完整的API地址,例如: http://156.225.27.52:8000
</div>
</div>
<div className="space-y-2">
<Label htmlFor="rmbgApiKey">API密钥(可选)</Label>
<Input
id="rmbgApiKey"
type="password"
value={rmbgConfig.apiKey}
onChange={(e) => setRmbgConfig({ ...rmbgConfig, apiKey: e.target.value })}
placeholder="如果API需要密钥,请在此输入"
/>
<div className="text-xs text-muted-foreground">
如果API需要认证,请填写API密钥(可选)
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
<div className="text-sm font-medium text-blue-900">API文档说明</div>
<div className="text-xs text-blue-800 space-y-1">
<p>• API地址应指向RMBG-2.0服务的根地址</p>
<p>• 系统会自动调用 /rmbg/base64 接口进行抠图处理</p>
<p>• 支持的图片格式:JPG, PNG, JPEG等常见格式</p>
<p>• 输出格式:PNG格式的透明背景图片</p>
</div>
</div>
<Button onClick={handleSaveRmbgConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 批量设置出图限额对话框 */}

281
app/api/rmbg/route.ts Normal file
View File

@ -0,0 +1,281 @@
import { NextRequest, NextResponse } from "next/server";
import { getSystemSetting } from "@/lib/db/queries";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
/**
* 压缩图片生成缩略图(使用 sharp)
*/
async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer> {
try {
const sharp = await import("sharp");
// 创建缩略图(宽度 500px,高度按比例,质量 80%)
const thumbnailBuffer = await sharp.default(buffer)
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.png({ quality: 80 }) // PNG格式保持透明背景
.toBuffer();
console.log(`[RMBG] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
return thumbnailBuffer;
} catch (error) {
console.error("[RMBG] Failed to create thumbnail:", error);
// 如果失败,返回原图
return buffer;
}
}
/**
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL
*/
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/png"): Promise<{ thumbnail: string; original: string }> {
try {
// 解析 base64 数据
const buffer = Buffer.from(base64Data, "base64");
// 生成文件名
const ext = mimeType.split("/")[1] || "png";
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `rmbg-thumb-${timestamp}-${randomStr}.${ext}`;
const originalFilename = `rmbg-orig-${timestamp}-${randomStr}.${ext}`;
console.log(`[RMBG] Original size: ${buffer.length} bytes`);
// 生成缩略图
const thumbnailBuffer = await createThumbnail(buffer, mimeType);
// 转换为 ArrayBuffer
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
// 并行上传缩略图和原图到 S3
console.log(`[RMBG] Uploading thumbnail and original to S3...`);
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
uploadFile(originalFilename, originalArrayBuffer),
]);
console.log(`[RMBG] Uploaded to S3:`);
console.log(` - Thumbnail: ${thumbnailResult.url}`);
console.log(` - Original: ${originalResult.url}`);
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
} catch (error) {
console.error("[RMBG] Failed to upload to S3:", error);
throw new Error(`图片上传到存储桶失败: ${error instanceof Error ? error.message : "未知错误"}。请检查系统设置中的存储配置。`);
}
}
/**
* 抠图API路由
* 从数据库读取RMBG API配置,调用外部API进行抠图处理
* 将处理后的图片上传到S3并保存到数据库
*/
export async function POST(request: NextRequest) {
try {
// 0. 获取用户会话
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "未授权,请先登录" },
{ status: 401 }
);
}
const userId = session.user.id;
// 1. 从数据库获取RMBG API配置
const rmbgConfig = await getSystemSetting({ key: "rmbg_api_config" });
if (!rmbgConfig || !rmbgConfig.value) {
return NextResponse.json(
{ error: "抠图API配置未设置,请在系统设置中配置" },
{ status: 400 }
);
}
const config = rmbgConfig.value as {
apiUrl?: string;
apiKey?: string;
};
if (!config.apiUrl) {
return NextResponse.json(
{ error: "抠图API地址未配置" },
{ status: 400 }
);
}
// 2. 获取请求体(Base64图片或FormData)
const contentType = request.headers.get("content-type") || "";
let imageBase64: string;
let prompt = ""; // 用户提示词(可选)
let chatId: string | null = null; // 聊天ID(可选)
let messageId: string | null = null; // 消息ID(可选)
if (contentType.includes("application/json")) {
// JSON格式:Base64图片
const body = await request.json();
imageBase64 = body.image_base64;
prompt = body.prompt || "抠图处理";
chatId = body.chatId || null;
messageId = body.messageId || null;
if (!imageBase64) {
return NextResponse.json(
{ error: "缺少image_base64参数" },
{ status: 400 }
);
}
} else if (contentType.includes("multipart/form-data")) {
// FormData格式:文件上传
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "缺少file参数" },
{ status: 400 }
);
}
// 将文件转换为Base64
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
imageBase64 = buffer.toString("base64");
prompt = (formData.get("prompt") as string) || "抠图处理";
chatId = (formData.get("chatId") as string) || null;
messageId = (formData.get("messageId") as string) || null;
} else {
return NextResponse.json(
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
{ status: 400 }
);
}
// 3. 调用RMBG API
const apiUrl = config.apiUrl.endsWith("/")
? `${config.apiUrl}rmbg/base64`
: `${config.apiUrl}/rmbg/base64`;
const requestBody = {
image_base64: imageBase64,
};
const headers: HeadersInit = {
"Content-Type": "application/json",
};
// 如果配置了API Key,添加到请求头
if (config.apiKey) {
headers["Authorization"] = `Bearer ${config.apiKey}`;
// 或者根据API文档要求添加,例如:
// headers["X-API-Key"] = config.apiKey;
}
const startTime = Date.now();
const response = await fetch(apiUrl, {
method: "POST",
headers,
body: JSON.stringify(requestBody),
});
const processingTime = (Date.now() - startTime) / 1000;
if (!response.ok) {
const errorText = await response.text();
console.error("[RMBG API] 请求失败:", {
status: response.status,
statusText: response.statusText,
error: errorText,
});
return NextResponse.json(
{
error: "抠图API请求失败",
detail: errorText || response.statusText,
},
{ status: response.status }
);
}
const result = await response.json();
// 4. 处理返回结果
if (!result.success || !result.image_base64) {
return NextResponse.json(
{ error: "抠图处理失败", detail: result.detail || "未知错误" },
{ status: 500 }
);
}
// 5. 上传处理后的图片到S3存储桶
let thumbnailUrl: string;
let originalUrl: string;
try {
const uploadResult = await uploadBase64ToS3(result.image_base64, "image/png");
thumbnailUrl = uploadResult.thumbnail;
originalUrl = uploadResult.original;
console.log(`[RMBG] ✅ Images uploaded to S3 successfully`);
} catch (uploadError) {
console.error("[RMBG] ❌ Failed to upload to S3:", uploadError);
return NextResponse.json(
{
error: "图片上传到存储桶失败",
detail: uploadError instanceof Error ? uploadError.message : "未知错误"
},
{ status: 500 }
);
}
// 6. 保存图片记录到数据库
try {
await db.insert(generatedImage).values({
userId,
chatId: chatId || null,
messageId: messageId || null,
thumbnailUrl,
originalUrl,
imageSize: "2K", // 默认尺寸
prompt: prompt || "抠图处理",
modelId: "rmbg-2.0", // 标记为抠图处理
});
console.log(`[RMBG] ✅ Image record saved to database`);
} catch (dbError) {
console.error("[RMBG] ❌ Failed to save image record to database:", dbError);
// 即使数据库保存失败,也返回成功(因为图片已上传到S3)
}
// 7. 返回结果(包含S3 URL和Base64,前端可以选择使用)
return NextResponse.json({
success: true,
image_base64: result.image_base64, // 保留Base64用于前端立即显示
thumbnail_url: thumbnailUrl, // S3缩略图URL
original_url: originalUrl, // S3原图URL
processing_time: result.processing_time || processingTime,
});
} catch (error) {
console.error("[RMBG API] 处理错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
{ status: 500 }
);
}
}

View File

@ -1,8 +1,9 @@
"use client";
import React, { useRef, useState, useEffect, useCallback } from "react";
import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2 } from "lucide-react";
import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors } from "lucide-react";
import { Button } from "./ui/button";
import { toast } from "sonner";
type Tool = "pen" | "arrow" | "circle" | "text";
@ -56,6 +57,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isLoading, setIsLoading] = useState(true);
const [isRemovingBg, setIsRemovingBg] = useState(false);
// 加载图片
useEffect(() => {
@ -308,6 +310,72 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
setActions([]);
};
// 抠图功能
const handleRemoveBackground = async () => {
if (!image || !canvasRef.current) return;
setIsRemovingBg(true);
try {
// 获取当前画布的图片(包含标注)
const canvas = canvasRef.current;
const currentImageData = canvas.toDataURL("image/png");
// 提取Base64数据(去掉data:image/png;base64,前缀)
const base64Data = currentImageData.split(",")[1];
// 调用抠图API
const response = await fetch("/api/rmbg", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "画图标注-抠图处理", // 可选:添加提示词
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "抠图失败");
}
const result = await response.json();
if (result.success && result.image_base64) {
// 将返回的Base64图片加载为新图片
const processedImage = new window.Image();
processedImage.crossOrigin = "anonymous";
processedImage.onload = () => {
// 更新图片和画布
setImage(processedImage);
// 清空所有标注(因为背景已移除)
setActions([]);
toast.success(`抠图完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
};
processedImage.onerror = () => {
throw new Error("加载处理后的图片失败");
};
// 优先使用S3 URL(如果可用),否则使用Base64
if (result.original_url) {
processedImage.src = result.original_url;
} else {
processedImage.src = `data:image/png;base64,${result.image_base64}`;
}
} else {
throw new Error(result.error || "抠图处理失败");
}
} catch (error) {
console.error("[抠图] 错误:", error);
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
} finally {
setIsRemovingBg(false);
}
};
// 确认
const handleConfirm = () => {
const canvas = canvasRef.current;
@ -345,6 +413,16 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-base font-medium">图片标注</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleRemoveBackground}
disabled={isRemovingBg || !image}
title="移除背景(抠图)"
>
<Scissors className={`mr-1 h-4 w-4 ${isRemovingBg ? "animate-spin" : ""}`} />
{isRemovingBg ? "处理中..." : "抠图"}
</Button>
<Button variant="ghost" size="sm" onClick={handleUndo} disabled={actions.length === 0}>
<Undo className="mr-1 h-4 w-4" />
撤销