diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index c20f2a6..d47252c 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -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() { 存储配置 + + + 抠图设置 + {/* 用户管理 */} @@ -2851,6 +2901,57 @@ export function AdminSettingsClient() { + {/* 抠图设置 */} + + + + 抠图API配置 + + 配置RMBG-2.0背景移除API,用于画图标注功能中的抠图处理 + + + + + API地址 * + setRmbgConfig({ ...rmbgConfig, apiUrl: e.target.value })} + placeholder="http://156.225.27.52:8000" + /> + + 请输入完整的API地址,例如: http://156.225.27.52:8000 + + + + API密钥(可选) + setRmbgConfig({ ...rmbgConfig, apiKey: e.target.value })} + placeholder="如果API需要密钥,请在此输入" + /> + + 如果API需要认证,请填写API密钥(可选) + + + + API文档说明 + + • API地址应指向RMBG-2.0服务的根地址 + • 系统会自动调用 /rmbg/base64 接口进行抠图处理 + • 支持的图片格式:JPG, PNG, JPEG等常见格式 + • 输出格式:PNG格式的透明背景图片 + + + + 保存配置 + + + + + {/* 批量设置出图限额对话框 */} diff --git a/app/api/rmbg/route.ts b/app/api/rmbg/route.ts new file mode 100644 index 0000000..588fb81 --- /dev/null +++ b/app/api/rmbg/route.ts @@ -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 { + 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 } + ); + } +} + diff --git a/components/image-annotator.tsx b/components/image-annotator.tsx index 702aa84..0ae452e 100644 --- a/components/image-annotator.tsx +++ b/components/image-annotator.tsx @@ -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 图片标注 + + + {isRemovingBg ? "处理中..." : "抠图"} + 撤销
• API地址应指向RMBG-2.0服务的根地址
• 系统会自动调用 /rmbg/base64 接口进行抠图处理
• 支持的图片格式:JPG, PNG, JPEG等常见格式
• 输出格式:PNG格式的透明背景图片