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:
parent
a8abed6b5b
commit
88df4d8793
@ -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
281
app/api/rmbg/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
撤销
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user