From 2a40607fc151e340a96c0e6ce0a4f89d2718f400 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 6 Mar 2026 00:56:42 +0800 Subject: [PATCH] fix(scene-fusion): strengthen random-bg replacement, UI actions, and gallery persistence --- app/api/enhance/route.ts | 81 ++++++++++++------- app/api/rmbg/route.ts | 41 ++++++---- app/api/scene-fusion/route.ts | 109 +++++++++++++++++++------- app/api/watermark-remove/route.ts | 33 +++++--- components/canvas/nodes/ImageNode.tsx | 34 ++++++++ components/scene-fusion-view.tsx | 17 ++-- 6 files changed, 231 insertions(+), 84 deletions(-) diff --git a/app/api/enhance/route.ts b/app/api/enhance/route.ts index d3c4ce1e3..061bb943a 100644 --- a/app/api/enhance/route.ts +++ b/app/api/enhance/route.ts @@ -436,19 +436,32 @@ async function finalizeEnhanceTaskResult(params: { const uploadResult = await uploadBase64ToS3(enhancedImageBase64, "image/jpeg"); const finalPrompt = params.prompt?.trim() || "图片变清晰处理"; - try { - await db.insert(generatedImage).values({ - userId: params.userId, - chatId: params.chatId || null, - messageId: params.messageId || null, - thumbnailUrl: uploadResult.thumbnail, - originalUrl: uploadResult.original, - imageSize: "2K", - prompt: finalPrompt, - modelId: "enhance-1.0", - }); - } catch (dbError) { - console.error("[Enhance] 保存到相册失败(已忽略):", dbError); + let saved = false; + let saveError: unknown = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + await db.insert(generatedImage).values({ + userId: params.userId, + chatId: params.chatId || null, + messageId: params.messageId || null, + thumbnailUrl: uploadResult.thumbnail, + originalUrl: uploadResult.original, + imageSize: "2K", + prompt: finalPrompt, + modelId: "enhance-1.0", + }); + saved = true; + break; + } catch (dbError) { + saveError = dbError; + if (attempt < 3) { + await new Promise((resolve) => setTimeout(resolve, 120 * attempt)); + } + } + } + if (!saved) { + console.error("[Enhance] 保存到相册失败:", saveError); + throw new Error("图片变清晰成功但保存到相册失败,请稍后重试"); } const processingTime = @@ -714,21 +727,33 @@ export async function POST(request: NextRequest) { } // 步骤4: 保存到数据库 - try { - const [savedImage] = await db.insert(generatedImage).values({ - userId, - chatId: chatId || null, - messageId: messageId || null, - thumbnailUrl, - originalUrl, - imageSize: `${config.backupScale}x放大`, - prompt: prompt || "Real-ESRGAN图片变清晰处理", - modelId: `realesrgan-${config.backupModel}`, - }).returning(); - console.log(`[Enhance-RealESRGAN] ✅ 保存到数据库成功,ID: ${savedImage.id}`); - } catch (dbError) { - console.error("[Enhance-RealESRGAN] ⚠️ 保存到数据库失败:", dbError); - // 继续返回成功,图片已处理并上传 + let saved = false; + let saveError: unknown = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const [savedImage] = await db.insert(generatedImage).values({ + userId, + chatId: chatId || null, + messageId: messageId || null, + thumbnailUrl, + originalUrl, + imageSize: `${config.backupScale}x放大`, + prompt: prompt || "Real-ESRGAN图片变清晰处理", + modelId: `realesrgan-${config.backupModel}`, + }).returning(); + console.log(`[Enhance-RealESRGAN] ✅ 保存到数据库成功,ID: ${savedImage.id}`); + saved = true; + break; + } catch (dbError) { + saveError = dbError; + if (attempt < 3) { + await new Promise((resolve) => setTimeout(resolve, 120 * attempt)); + } + } + } + if (!saved) { + console.error("[Enhance-RealESRGAN] ❌ 保存到数据库失败:", saveError); + throw new Error("图片变清晰成功但保存到相册失败,请稍后重试"); } // 返回结果 diff --git a/app/api/rmbg/route.ts b/app/api/rmbg/route.ts index c7eb256fc..f05c40df0 100644 --- a/app/api/rmbg/route.ts +++ b/app/api/rmbg/route.ts @@ -648,20 +648,33 @@ export async function POST(request: NextRequest) { // 5. 保存图片记录到数据库(可选) if (!skipGallerySave) { - try { - const [savedImage] = await db.insert(generatedImage).values({ - userId, - chatId: chatId || null, - messageId: messageId || null, - thumbnailUrl, - originalUrl, - imageSize: "2K", - prompt: prompt || "抠图处理", - modelId: provider === "photoroom" ? "photoroom-hd" : provider === "aoscdn" ? "aos-segmentation" : "rmbg-2.0", - }).returning(); - console.log(`[RMBG] ✅ 图片已保存到相册,ID: ${savedImage.id}`); - } catch (dbError) { - console.error("[RMBG] ❌ 保存到数据库失败:", dbError); + let saved = false; + let saveError: unknown = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const [savedImage] = await db.insert(generatedImage).values({ + userId, + chatId: chatId || null, + messageId: messageId || null, + thumbnailUrl, + originalUrl, + imageSize: "2K", + prompt: prompt || "抠图处理", + modelId: provider === "photoroom" ? "photoroom-hd" : provider === "aoscdn" ? "aos-segmentation" : "rmbg-2.0", + }).returning(); + console.log(`[RMBG] ✅ 图片已保存到相册,ID: ${savedImage.id}`); + saved = true; + break; + } catch (dbError) { + saveError = dbError; + if (attempt < 3) { + await sleep(120 * attempt); + } + } + } + if (!saved) { + console.error("[RMBG] ❌ 保存到数据库失败:", saveError); + throw new Error("图片处理成功但保存到相册失败,请稍后重试"); } } else { console.log("[RMBG] Skip gallery save by request flag"); diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index 1b0448b6b..55d0a8255 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -34,6 +34,8 @@ const sceneFusionRequestSchema = z.object({ })).min(1, "至少需要一个场景图").max(10, "最多支持10个场景图"), }); +type SceneFusionMode = "scene-fission" | "multi-product-scene" | "random-bg"; + // 默认提示词(当数据库未配置时使用)- 使用"参考"的表述方式 const FALLBACK_PROMPT = "请参考第一张图片中的产品,将其自然地融合到第二张图片的场景中。要求:1)保持产品的整体外观、颜色、形状和设计风格与第一张图一致;2)调整产品的大小和角度以适应场景;3)注意光影和透视关系使融合自然。"; @@ -47,6 +49,8 @@ const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000; const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8; const SCENE_FUSION_STRICT_PROMPT_TAIL = "硬性要求:严禁直接返回第一张参考图(产品图)原图,必须保留第二张图的场景与构图,仅替换目标主体;若识别不确定,请优先保持第二张图背景不变并完成主体替换。"; +const SCENE_FUSION_RANDOM_BG_PROMPT_TAIL = + "硬性要求:背景必须与原图明显不同(颜色、结构、元素至少两项显著变化),禁止保留原背景纹理或直接回传原图;仅保留产品主体并保证主体边缘完整自然。"; const sceneFusionModelCooldown = new Map(); let sceneFusionRoundRobinCursor = 0; @@ -155,7 +159,11 @@ function getNextSceneFusionRoundRobinOffset(poolSize: number): number { function buildPreRmbgCandidateUrls(request: Request): string[] { const urls: string[] = []; const requestUrl = new URL(request.url); - urls.push(new URL("/api/rmbg", request.url).toString()); + const requestHost = requestUrl.hostname.toLowerCase(); + const requestProtocol = requestUrl.protocol.replace(":", ""); + if (!(requestProtocol === "https" && (requestHost === "localhost" || requestHost === "127.0.0.1"))) { + urls.push(new URL("/api/rmbg", request.url).toString()); + } const host = request.headers.get("host")?.trim(); const protoHint = request.headers @@ -166,7 +174,14 @@ function buildPreRmbgCandidateUrls(request: Request): string[] { const protocol = protoHint === "https" || protoHint === "http" ? protoHint : requestUrl.protocol.replace(":", ""); if (host) { - urls.push(`${protocol}://${host}/api/rmbg`); + const normalizedHost = host.split(":")[0]?.toLowerCase() || ""; + const isLocalHost = normalizedHost === "localhost" || normalizedHost === "127.0.0.1"; + if (!(protocol === "https" && isLocalHost)) { + urls.push(`${protocol}://${host}/api/rmbg`); + } + if (isLocalHost) { + urls.push(`http://${host}/api/rmbg`); + } } const localPort = requestUrl.port || process.env.PORT || "3125"; @@ -899,6 +914,39 @@ async function downloadAndUploadToS3( } } +async function saveSceneFusionImageRecord(params: { + userId: string; + thumbnailUrl: string; + originalUrl: string; + imageSize: string; + prompt: string; + modelId: string; +}): Promise { + let lastError: unknown = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + await db.insert(generatedImage).values({ + userId: params.userId, + chatId: null, + messageId: null, + thumbnailUrl: params.thumbnailUrl, + originalUrl: params.originalUrl, + imageSize: params.imageSize, + prompt: params.prompt, + modelId: params.modelId, + }); + return; + } catch (error) { + lastError = error; + if (attempt < 3) { + await waitSceneFusion(120 * attempt); + } + } + } + + throw lastError instanceof Error ? lastError : new Error("保存到相册失败"); +} + /** * 根据 aspectRatio 和 imageSize 计算实际尺寸 * @param aspectRatio - 宽高比,如 "1:1", "4:3", "16:9", "auto" @@ -1063,13 +1111,17 @@ function buildPrompt( defaultPrompt: string, productName?: string, targetName?: string, - customPrompt?: string + customPrompt?: string, + sceneMode: SceneFusionMode = "scene-fission" ): string { let prompt = defaultPrompt; - - // 如果用户提供了具体的产品名称和被替换物体名称,构建更精确的提示词 - // 使用"参考图1"的表述方式,并强调产品的视觉特征 - if (productName && targetName) { + + if (sceneMode === "random-bg") { + const subject = productName || "产品"; + prompt = `请以第一张图片中的${subject}为唯一主体,对第二张图片进行背景重绘。要求:1)${subject}外观、材质、颜色与细节保持一致;2)允许轻微调整主体位置与角度以匹配新背景;3)背景需要完整重建并与原图明显不同;4)光影透视自然,输出商业摄影质感。`; + } else if (productName && targetName) { + // 如果用户提供了具体的产品名称和被替换物体名称,构建更精确的提示词 + // 使用"参考图1"的表述方式,并强调产品的视觉特征 prompt = `请参考第一张图片中的${productName},将其自然地融合到第二张图片的场景中,替换掉场景中的${targetName}。要求:1)保持${productName}的整体外观、颜色、形状和设计风格与第一张图一致;2)调整${productName}的大小和角度以适应场景;3)注意光影和透视关系使融合自然。`; } else if (productName) { prompt = `请参考第一张图片中的${productName},将其自然地融合到第二张图片的场景中。要求:1)保持${productName}的整体外观、颜色、形状和设计风格与第一张图一致;2)调整${productName}的大小和角度以适应场景;3)注意光影和透视关系使融合自然。`; @@ -1082,7 +1134,11 @@ function buildPrompt( prompt = `${prompt} 补充要求:${customPrompt}`; } - if (!prompt.includes("严禁直接返回第一张参考图")) { + if (sceneMode === "random-bg") { + if (!prompt.includes("背景必须与原图明显不同")) { + prompt = `${prompt} ${SCENE_FUSION_RANDOM_BG_PROMPT_TAIL}`; + } + } else if (!prompt.includes("严禁直接返回第一张参考图")) { prompt = `${prompt} ${SCENE_FUSION_STRICT_PROMPT_TAIL}`; } @@ -1099,13 +1155,14 @@ async function processSceneWithProvider( targetName: string | undefined, customPrompt: string | undefined, defaultPrompt: string, + sceneMode: SceneFusionMode, provider: { type: string; baseUrl: string; apiKey: string }, modelId: string, aspectRatio?: string, imageSize?: string ): Promise<{ thumbnail: string; original: string; prompt: string }> { // 构建智能提示词 - const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt); + const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt, sceneMode); console.log(`[SceneFusion] Built prompt: ${prompt}`); console.log(`[SceneFusion] Provider type: ${provider.type}`); @@ -1382,13 +1439,14 @@ async function processScene( targetName: string | undefined, customPrompt: string | undefined, defaultPrompt: string, + sceneMode: SceneFusionMode, baseURL: string, apiKey: string, modelId: string, size?: string ): Promise<{ thumbnail: string; original: string; prompt: string }> { // 构建智能提示词 - const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt); + const prompt = buildPrompt(defaultPrompt, productName, targetName, customPrompt, sceneMode); console.log(`[SceneFusion] Built prompt: ${prompt}`); @@ -1417,6 +1475,7 @@ async function processSceneWithDiversity( targetName?: string; customPrompt?: string; defaultPrompt: string; + sceneMode: SceneFusionMode; aspectRatio?: string; imageSize?: string; sceneIndex: number; @@ -1467,6 +1526,7 @@ async function processSceneWithDiversity( params.targetName, params.customPrompt, params.defaultPrompt, + params.sceneMode, { type: candidate.provider.type, baseUrl: candidate.provider.baseUrl, @@ -1777,6 +1837,7 @@ export async function POST(request: Request) { targetName: resolvedScene.targetName, customPrompt: resolvedScene.customPrompt, defaultPrompt: config.defaultPrompt, + sceneMode: resolvedSceneMode, aspectRatio, imageSize, sceneIndex: currentIndex, @@ -1788,23 +1849,17 @@ export async function POST(request: Request) { const dbImageSize = imageSize || "2K"; - try { - await db.insert(generatedImage).values({ - userId: session.user.id, - chatId: null, - messageId: null, - thumbnailUrl: result.thumbnail, - originalUrl: result.original, - imageSize: dbImageSize, - prompt: `[场景裂变] ${result.prompt}`, - modelId: candidate.model.modelId, - }); - console.log( - `[SceneFusion] ✅ Saved image to gallery: ${result.original.substring(0, 80)}...` - ); - } catch (dbError) { - console.error("[SceneFusion] ❌ Failed to save to gallery:", dbError); - } + await saveSceneFusionImageRecord({ + userId: session.user.id, + thumbnailUrl: result.thumbnail, + originalUrl: result.original, + imageSize: dbImageSize, + prompt: `[场景裂变] ${result.prompt}`, + modelId: candidate.model.modelId, + }); + console.log( + `[SceneFusion] ✅ Saved image to gallery: ${result.original.substring(0, 80)}...` + ); successCount += 1; if (!enqueue({ diff --git a/app/api/watermark-remove/route.ts b/app/api/watermark-remove/route.ts index d0cdfa682..a6f5367d7 100644 --- a/app/api/watermark-remove/route.ts +++ b/app/api/watermark-remove/route.ts @@ -136,17 +136,30 @@ async function finalizeWatermarkTaskResult(params: { const filename = `watermark-removed-${Date.now()}.png`; const uploadResult = await uploadFile(filename, imageBuffer); - try { - await db.insert(generatedImage).values({ - userId: params.userId, - thumbnailUrl: uploadResult.url, - originalUrl: uploadResult.url, - imageSize: "2K", - prompt: "去水印处理", - modelId: "watermark-remove", - }); - } catch (saveError) { + let saved = false; + let saveError: unknown = null; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + await db.insert(generatedImage).values({ + userId: params.userId, + thumbnailUrl: uploadResult.url, + originalUrl: uploadResult.url, + imageSize: "2K", + prompt: "去水印处理", + modelId: "watermark-remove", + }); + saved = true; + break; + } catch (error) { + saveError = error; + if (attempt < 3) { + await new Promise((resolve) => setTimeout(resolve, 120 * attempt)); + } + } + } + if (!saved) { console.error("[WatermarkRemove] 保存到相册失败:", saveError); + throw new Error("去水印成功但保存到相册失败,请稍后重试"); } return { diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 10faf9146..f11520501 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1374,6 +1374,39 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { }); }; + const handleSceneFusionBatchAddToCanvas = useCallback((imageUrls: string[]) => { + const validUrls = imageUrls.filter((url) => typeof url === "string" && url.trim().length > 0); + if (validUrls.length === 0) { + throw new Error("没有可添加到画布的图片"); + } + + const maxColumns = 3; + const gap = 20; + const baseX = node.position.x + node.size.width + gap; + const baseY = node.position.y; + + validUrls.forEach((url, index) => { + const column = index % maxColumns; + const row = Math.floor(index / maxColumns); + addNode({ + type: "image", + position: { + x: baseX + column * (node.size.width + gap), + y: baseY + row * (node.size.height + gap), + }, + size: { width: node.size.width, height: node.size.height }, + data: { + src: url, + status: "completed", + prompt: "批量处理结果", + title: `${displayTitle}-批量${index + 1}`, + originalSrc: data.src, + }, + connections: [node.id], + }); + }); + }, [addNode, data.src, displayTitle, node.id, node.position.x, node.position.y, node.size.height, node.size.width]); + // 图片标注完成回调 const handleAnnotateComplete = async (annotatedImageUrl: string) => { setAnnotatorOpen(false); @@ -2010,6 +2043,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { {/* 提示词重绘对话框 */} diff --git a/components/scene-fusion-view.tsx b/components/scene-fusion-view.tsx index 867f8b2d5..131cae3c8 100644 --- a/components/scene-fusion-view.tsx +++ b/components/scene-fusion-view.tsx @@ -837,7 +837,7 @@ export function SceneFusionView({ const stylePool = [...randomBackgroundStyles].sort(() => Math.random() - 0.5); return Array.from({ length: count }).map((_, index) => { const style = stylePool[index % stylePool.length]; - const customPrompt = `请保持主体和构图尽量不变,仅替换背景为“${style}”风格,确保光影自然。${randomPrompt ? `补充要求:${randomPrompt}` : ""}`; + const customPrompt = `请保持产品主体清晰完整,背景必须明显变化为“${style}”风格(不得与原背景相似),光影自然。${randomPrompt ? `补充要求:${randomPrompt}` : ""}`; return { id: `rand-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 6)}`, image: productImage || "", @@ -855,6 +855,7 @@ export function SceneFusionView({ body: JSON.stringify({ imageUrl: item.image, prompt: getModePrompt(mode), + skipGallerySave: false, }), }); const payload = await response.json(); @@ -1619,12 +1620,18 @@ export function SceneFusionView({
-
+

处理结果

- {(completedResultItems.length > 0 || (resultAllFinished && onBatchAddToCanvas)) && ( + {(completedResultItems.length > 0 || onBatchAddToCanvas) && (
- {resultAllFinished && onBatchAddToCanvas && completedResultItems.length > 0 && ( -