feat: add PhotoRoom background removal support in RMBG settings
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-01-27 22:15:29 +08:00
parent 48a8d2e1ff
commit ab583c38ef
2 changed files with 317 additions and 191 deletions

View File

@ -372,8 +372,11 @@ export function AdminSettingsClient() {
// 抠图API配置状态
const [rmbgConfig, setRmbgConfig] = useState({
provider: "rmbg" as "rmbg" | "photoroom", // 抠图服务提供商
apiUrl: "",
apiKey: "",
// PhotoRoom 配置
photoroomApiKey: "",
});
// 变清晰API配置状态
@ -420,10 +423,17 @@ export function AdminSettingsClient() {
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 };
const config = rmbgConfigData.setting.value as {
provider?: "rmbg" | "photoroom";
apiUrl?: string;
apiKey?: string;
photoroomApiKey?: string;
};
setRmbgConfig({
provider: config.provider || "rmbg",
apiUrl: config.apiUrl || "",
apiKey: config.apiKey || "",
photoroomApiKey: config.photoroomApiKey || "",
});
}
}, [rmbgConfigData]);
@ -3423,44 +3433,107 @@ export function AdminSettingsClient() {
<CardHeader>
<CardTitle>抠图API配置</CardTitle>
<CardDescription>
配置RMBG-2.0背景移除API,用于画图标注功能中的抠图处理
配置背景移除API,用于画图标注功能中的抠图处理。支持自部署RMBG和PhotoRoom云服务。
</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>
<Label>抠图服务提供商</Label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="rmbgProvider"
value="rmbg"
checked={rmbgConfig.provider === "rmbg"}
onChange={() => setRmbgConfig({ ...rmbgConfig, provider: "rmbg" })}
className="w-4 h-4"
/>
<span className="text-sm">RMBG-2.0 (自部署)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="rmbgProvider"
value="photoroom"
checked={rmbgConfig.provider === "photoroom"}
onChange={() => setRmbgConfig({ ...rmbgConfig, provider: "photoroom" })}
className="w-4 h-4"
/>
<span className="text-sm">PhotoRoom (云服务)</span>
</label>
</div>
</div>
<Separator />
{/* RMBG 配置 */}
{rmbgConfig.provider === "rmbg" && (
<>
<div className="text-sm font-medium text-muted-foreground">RMBG-2.0 配置</div>
<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>
<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">RMBG-2.0 说明</div>
<div className="text-xs text-blue-800 space-y-1">
<p>• 需要自行部署RMBG-2.0服务</p>
<p>• 系统会自动调用 /rmbg/base64 接口进行抠图处理</p>
<p>• 支持的图片格式:JPG, PNG, JPEG等常见格式</p>
</div>
</div>
</>
)}
{/* PhotoRoom 配置 */}
{rmbgConfig.provider === "photoroom" && (
<>
<div className="text-sm font-medium text-muted-foreground">PhotoRoom 配置</div>
<div className="space-y-2">
<Label htmlFor="photoroomApiKey">API密钥 *</Label>
<Input
id="photoroomApiKey"
type="password"
value={rmbgConfig.photoroomApiKey}
onChange={(e) => setRmbgConfig({ ...rmbgConfig, photoroomApiKey: e.target.value })}
placeholder="输入PhotoRoom API密钥"
/>
<div className="text-xs text-muted-foreground">
在 PhotoRoom 开发者平台获取API密钥
</div>
</div>
<div className="rounded-lg border border-green-200 bg-green-50 p-4 space-y-2">
<div className="text-sm font-medium text-green-900">PhotoRoom 说明</div>
<div className="text-xs text-green-800 space-y-1">
<p>• 使用 PhotoRoom 云服务进行高清抠图</p>
<p>• 支持 HD 高清背景移除,效果更好</p>
<p>• API文档:https://www.photoroom.com/api</p>
<p>• 按调用次数计费,请注意用量</p>
</div>
</div>
</>
)}
<Button onClick={handleSaveRmbgConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>

View File

@ -31,13 +31,10 @@ async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer
}
/**
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL
* 将图片 buffer 上传到 S3,返回缩略图和原图的 URL
*/
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/png"): Promise<{ thumbnail: string; original: string }> {
async function uploadBufferToS3(buffer: Buffer, 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();
@ -81,10 +78,146 @@ async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/pn
}
}
/**
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL
*/
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/png"): Promise<{ thumbnail: string; original: string }> {
const buffer = Buffer.from(base64Data, "base64");
return uploadBufferToS3(buffer, mimeType);
}
/**
* 使用 RMBG-2.0 API 进行抠图
*/
async function processWithRMBG(
imageBase64: string,
config: { apiUrl: string; apiKey?: string }
): Promise<{ success: boolean; image_base64?: string; processing_time?: number; error?: string }> {
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",
};
if (config.apiKey) {
headers["X-API-Key"] = config.apiKey;
}
const startTime = Date.now();
// 添加超时控制(30秒)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(apiUrl, {
method: "POST",
headers,
body: JSON.stringify(requestBody),
signal: controller.signal,
});
clearTimeout(timeoutId);
const processingTime = (Date.now() - startTime) / 1000;
if (!response.ok) {
const errorText = await response.text();
console.error("[RMBG API] 请求失败:", { status: response.status, error: errorText });
return { success: false, error: errorText || response.statusText };
}
const result = await response.json();
if (!result.success || !result.image_base64) {
return { success: false, error: result.detail || "未知错误" };
}
return {
success: true,
image_base64: result.image_base64,
processing_time: result.processing_time || processingTime,
};
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
return { success: false, error: "请求超时(30秒)" };
}
return { success: false, error: error instanceof Error ? error.message : "未知错误" };
}
}
/**
* 使用 PhotoRoom API 进行抠图
*/
async function processWithPhotoRoom(
imageBuffer: Buffer,
apiKey: string
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
const startTime = Date.now();
try {
// 构建 FormData
const formData = new FormData();
// 将 Buffer 转换为 Uint8Array 以避免类型问题
const uint8Array = new Uint8Array(imageBuffer);
const blob = new Blob([uint8Array], { type: "image/png" });
formData.append("imageFile", blob, "image.png");
formData.append("referenceBox", "originalImage");
console.log("[PhotoRoom] Sending request to API...");
// 添加超时控制(60秒,PhotoRoom可能需要更长时间)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000);
const response = await fetch("https://image-api.photoroom.com/v2/edit", {
method: "POST",
headers: {
"x-api-key": apiKey,
"pr-hd-background-removal": "auto",
},
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
const processingTime = (Date.now() - startTime) / 1000;
console.log("[PhotoRoom] Response status:", response.status);
if (!response.ok) {
const errorText = await response.text();
console.error("[PhotoRoom API] 请求失败:", { status: response.status, error: errorText });
return { success: false, error: `PhotoRoom API错误: ${response.status} - ${errorText}` };
}
// PhotoRoom 直接返回图片二进制数据
const resultBuffer = Buffer.from(await response.arrayBuffer());
console.log("[PhotoRoom] Received image buffer, size:", resultBuffer.length, "bytes");
return {
success: true,
imageBuffer: resultBuffer,
processing_time: processingTime,
};
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return { success: false, error: "PhotoRoom请求超时(60秒)" };
}
console.error("[PhotoRoom] Error:", error);
return { success: false, error: error instanceof Error ? error.message : "未知错误" };
}
}
/**
* 抠图API路由
* 从数据库读取RMBG API配置,调用外部API进行抠图处理
* 将处理后的图片上传到S3并保存到数据库
* 从数据库读取API配置,支持 RMBG-2.0 和 PhotoRoom 两种服务
*/
export async function POST(request: NextRequest) {
try {
@ -98,7 +231,8 @@ export async function POST(request: NextRequest) {
}
const userId = session.user.id;
// 1. 从数据库获取RMBG API配置
// 1. 从数据库获取抠图API配置
const rmbgConfig = await getSystemSetting({ key: "rmbg_api_config" });
if (!rmbgConfig || !rmbgConfig.value) {
@ -109,24 +243,37 @@ export async function POST(request: NextRequest) {
}
const config = rmbgConfig.value as {
provider?: "rmbg" | "photoroom";
apiUrl?: string;
apiKey?: string;
photoroomApiKey?: string;
};
if (!config.apiUrl) {
const provider = config.provider || "rmbg";
// 验证配置
if (provider === "rmbg" && !config.apiUrl) {
return NextResponse.json(
{ error: "抠图API地址未配置" },
{ error: "RMBG API地址未配置" },
{ status: 400 }
);
}
if (provider === "photoroom" && !config.photoroomApiKey) {
return NextResponse.json(
{ error: "PhotoRoom API密钥未配置" },
{ status: 400 }
);
}
// 2. 获取请求体(支持 imageUrl、image_base64 或 FormData)
// 2. 获取请求体
const contentType = request.headers.get("content-type") || "";
let imageBuffer: Buffer;
let imageBase64: string;
let prompt = ""; // 用户提示词(可选)
let chatId: string | null = null; // 聊天ID(可选)
let messageId: string | null = null; // 消息ID(可选)
let prompt = "";
let chatId: string | null = null;
let messageId: string | null = null;
if (contentType.includes("application/json")) {
const body = await request.json();
@ -134,9 +281,7 @@ export async function POST(request: NextRequest) {
chatId = body.chatId || null;
messageId = body.messageId || null;
// 优先使用 imageUrl,其次使用 image_base64
if (body.imageUrl) {
// 从 URL 获取图片并转换为 Base64
console.log(`[RMBG] 从 URL 获取图片: ${body.imageUrl}`);
try {
const imageResponse = await fetch(body.imageUrl);
@ -147,9 +292,9 @@ export async function POST(request: NextRequest) {
);
}
const arrayBuffer = await imageResponse.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
imageBase64 = buffer.toString("base64");
console.log(`[RMBG] 图片已转换为 Base64,大小: ${buffer.length} bytes`);
imageBuffer = Buffer.from(arrayBuffer);
imageBase64 = imageBuffer.toString("base64");
console.log(`[RMBG] 图片已获取,大小: ${imageBuffer.length} bytes`);
} catch (fetchError) {
console.error("[RMBG] 获取图片失败:", fetchError);
return NextResponse.json(
@ -159,6 +304,7 @@ export async function POST(request: NextRequest) {
}
} else if (body.image_base64) {
imageBase64 = body.image_base64;
imageBuffer = Buffer.from(imageBase64, "base64");
} else {
return NextResponse.json(
{ error: "缺少 imageUrl 或 image_base64 参数" },
@ -166,7 +312,6 @@ export async function POST(request: NextRequest) {
);
}
} else if (contentType.includes("multipart/form-data")) {
// FormData格式:文件上传
const formData = await request.formData();
const file = formData.get("file") as File;
@ -177,10 +322,9 @@ export async function POST(request: NextRequest) {
);
}
// 将文件转换为Base64
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
imageBase64 = buffer.toString("base64");
imageBuffer = Buffer.from(arrayBuffer);
imageBase64 = imageBuffer.toString("base64");
prompt = (formData.get("prompt") as string) || "抠图处理";
chatId = (formData.get("chatId") as string) || null;
messageId = (formData.get("messageId") as string) || null;
@ -191,134 +335,52 @@ export async function POST(request: NextRequest) {
);
}
// 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,添加到请求头(根据RMBG API文档要求使用X-API-Key)
if (config.apiKey) {
headers["X-API-Key"] = config.apiKey;
}
const startTime = Date.now();
let response: Response;
// 3. 根据提供商调用不同的API
console.log(`[RMBG] Using provider: ${provider}`);
try {
// 添加超时控制(30秒)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
let resultBuffer: Buffer;
let resultBase64: string;
let processingTime: number;
if (provider === "photoroom") {
// 使用 PhotoRoom API
const result = await processWithPhotoRoom(imageBuffer, config.photoroomApiKey!);
response = await fetch(apiUrl, {
method: "POST",
headers,
body: JSON.stringify(requestBody),
signal: controller.signal,
});
clearTimeout(timeoutId);
} catch (fetchError) {
const processingTime = (Date.now() - startTime) / 1000;
console.error("[RMBG API] 网络请求失败:", {
error: fetchError,
apiUrl,
processingTime,
});
// 检查是否是网络连接错误
if (fetchError instanceof Error) {
// 检查超时错误
if (fetchError.name === 'AbortError') {
return NextResponse.json(
{
error: "抠图API请求超时",
detail: "请求超过30秒未响应,请检查API服务器状态或网络连接",
},
{ status: 504 }
);
}
// 检查错误消息和cause中的网络错误
const errorMessage = fetchError.message || '';
const errorCause = (fetchError as any).cause;
const causeMessage = errorCause?.message || '';
const causeCode = errorCause?.code || '';
// 检查各种网络连接错误
if (errorMessage.includes('EHOSTUNREACH') ||
errorMessage.includes('ENOTFOUND') ||
errorMessage.includes('ECONNREFUSED') ||
errorMessage.includes('No route to host') ||
causeMessage.includes('EHOSTUNREACH') ||
causeMessage.includes('ENOTFOUND') ||
causeMessage.includes('ECONNREFUSED') ||
causeMessage.includes('No route to host') ||
causeCode === 'EHOSTUNREACH' ||
causeCode === 'ENOTFOUND' ||
causeCode === 'ECONNREFUSED') {
return NextResponse.json(
{
error: "无法连接到抠图API服务器",
detail: `服务器无法访问 ${apiUrl},请检查:\n1. API服务器是否在线\n2. 网络连接是否正常\n3. 防火墙设置是否正确\n4. 端口8000是否已开放`,
},
{ status: 503 }
);
}
if (!result.success || !result.imageBuffer) {
return NextResponse.json(
{ error: "抠图处理失败", detail: result.error },
{ status: 500 }
);
}
return NextResponse.json(
{
error: "抠图API请求失败",
detail: fetchError instanceof Error ? fetchError.message : "未知网络错误",
},
{ status: 500 }
);
}
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,
apiUrl,
resultBuffer = result.imageBuffer;
resultBase64 = resultBuffer.toString("base64");
processingTime = result.processing_time || 0;
} else {
// 使用 RMBG-2.0 API
const result = await processWithRMBG(imageBase64, {
apiUrl: config.apiUrl!,
apiKey: config.apiKey,
});
return NextResponse.json(
{
error: "抠图API请求失败",
detail: errorText || response.statusText,
},
{ status: response.status }
);
if (!result.success || !result.image_base64) {
return NextResponse.json(
{ error: "抠图处理失败", detail: result.error },
{ status: 500 }
);
}
resultBase64 = result.image_base64;
resultBuffer = Buffer.from(resultBase64, "base64");
processingTime = result.processing_time || 0;
}
const result = await response.json();
// 4. 处理返回结果
if (!result.success || !result.image_base64) {
return NextResponse.json(
{ error: "抠图处理失败", detail: result.detail || "未知错误" },
{ status: 500 }
);
}
// 5. 上传处理后的图片到S3存储桶
// 4. 上传处理后的图片到S3
let thumbnailUrl: string;
let originalUrl: string;
try {
const uploadResult = await uploadBase64ToS3(result.image_base64, "image/png");
const uploadResult = await uploadBufferToS3(resultBuffer, "image/png");
thumbnailUrl = uploadResult.thumbnail;
originalUrl = uploadResult.original;
console.log(`[RMBG] ✅ Images uploaded to S3 successfully`);
@ -333,7 +395,7 @@ export async function POST(request: NextRequest) {
);
}
// 6. 保存图片记录到数据库(相册和生图记录)
// 5. 保存图片记录到数据库
try {
const [savedImage] = await db.insert(generatedImage).values({
userId,
@ -341,31 +403,23 @@ export async function POST(request: NextRequest) {
messageId: messageId || null,
thumbnailUrl,
originalUrl,
imageSize: "2K", // 默认尺寸
imageSize: "2K",
prompt: prompt || "抠图处理",
modelId: "rmbg-2.0", // 标记为抠图处理
modelId: provider === "photoroom" ? "photoroom-hd" : "rmbg-2.0",
}).returning();
console.log(`[RMBG] ✅ 图片已保存到相册和生图记录,ID: ${savedImage.id}`);
console.log(`[RMBG] ✅ 图片已保存到相册,ID: ${savedImage.id}`);
} catch (dbError) {
console.error("[RMBG] ❌ 保存到数据库失败:", dbError);
// 数据库保存失败时仍然返回成功,但记录错误(图片已上传到S3,用户可以访问)
// 如果希望严格保证数据一致性,可以取消注释下面的代码来返回错误
// return NextResponse.json(
// {
// error: "图片已处理但保存到相册失败",
// detail: dbError instanceof Error ? dbError.message : "未知错误"
// },
// { status: 500 }
// );
}
// 7. 返回结果(包含S3 URL和Base64,前端可以选择使用)
// 6. 返回结果
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,
image_base64: resultBase64,
thumbnail_url: thumbnailUrl,
original_url: originalUrl,
processing_time: processingTime,
provider: provider,
});
} catch (error) {
console.error("[RMBG API] 处理错误:", error);
@ -375,4 +429,3 @@ export async function POST(request: NextRequest) {
);
}
}