fix(scene-fusion): strengthen random-bg replacement, UI actions, and gallery persistence
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-03-06 00:56:42 +08:00
parent 7a32e6ef65
commit 2a40607fc1
6 changed files with 231 additions and 84 deletions

View File

@ -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("图片变清晰成功但保存到相册失败,请稍后重试");
}
// 返回结果

View File

@ -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");

View File

@ -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<string, number>();
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<void> {
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({

View File

@ -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 {

View File

@ -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) {
<SceneFusionDialog
open={sceneFusionDialogOpen}
onOpenChange={setSceneFusionDialogOpen}
onBatchAddToCanvas={handleSceneFusionBatchAddToCanvas}
/>
{/* 提示词重绘对话框 */}

View File

@ -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({
</div>
<div className="flex h-full min-h-0 flex-1 flex-col bg-white dark:bg-zinc-950">
<div className="h-16 px-4 border-b flex items-center justify-between bg-white dark:bg-zinc-950 shrink-0">
<div className="h-16 px-4 pr-14 border-b flex items-center justify-between bg-white dark:bg-zinc-950 shrink-0">
<h3 className="font-medium">处理结果</h3>
{(completedResultItems.length > 0 || (resultAllFinished && onBatchAddToCanvas)) && (
{(completedResultItems.length > 0 || onBatchAddToCanvas) && (
<div className="flex items-center gap-2">
{resultAllFinished && onBatchAddToCanvas && completedResultItems.length > 0 && (
<Button variant="outline" size="sm" onClick={handleBatchAddToCanvas}>
{onBatchAddToCanvas && completedResultItems.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleBatchAddToCanvas}
disabled={!resultAllFinished}
title={resultAllFinished ? "将处理结果批量添加到画布" : "请等待所有任务处理完成"}
>
<Plus className="w-4 h-4 mr-2" />
批量添加到画布
</Button>