From 166fec6b0d6004224574e972a59a422b01a0ec73 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 5 Mar 2026 17:50:22 +0800 Subject: [PATCH] feat: upgrade batch processing modes and enforce 10-item limit --- .../settings/admin-settings-client.tsx | 15 +- app/api/scene-fusion/config/route.ts | 5 +- app/api/scene-fusion/route.ts | 12 +- components/canvas/nodes/ImageNode.tsx | 8 - components/image-annotator.tsx | 18 +- components/scene-fusion-dialog.tsx | 657 +------ components/scene-fusion-view.tsx | 1665 +++++++++-------- 7 files changed, 969 insertions(+), 1411 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index efe0651a6..464e867a1 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -1027,7 +1027,7 @@ export function AdminSettingsClient() { setSceneFusionConfig({ enabled: config.enabled ?? true, defaultPrompt: config.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。", - maxScenes: config.maxScenes ?? 10, + maxScenes: Math.min(config.maxScenes ?? 10, 10), modelId: config.modelId ?? "", }); } @@ -2118,7 +2118,7 @@ export function AdminSettingsClient() { value: { enabled: sceneFusionConfig.enabled, defaultPrompt: sceneFusionConfig.defaultPrompt.trim(), - maxScenes: sceneFusionConfig.maxScenes, + maxScenes: Math.max(1, Math.min(10, sceneFusionConfig.maxScenes || 10)), modelId: sceneFusionConfig.modelId, }, }), @@ -8684,13 +8684,18 @@ export function AdminSettingsClient() { id="maxScenes" type="number" value={sceneFusionConfig.maxScenes} - onChange={(e) => setSceneFusionConfig({ ...sceneFusionConfig, maxScenes: Number.parseInt(e.target.value) || 10 })} + onChange={(e) => + setSceneFusionConfig({ + ...sceneFusionConfig, + maxScenes: Math.max(1, Math.min(10, Number.parseInt(e.target.value) || 10)), + }) + } min={1} - max={20} + max={10} disabled={!sceneFusionConfig.enabled} />
- 用户单次裂变可上传的最大场景图数量(1-20张) + 用户单次裂变可上传的最大场景图数量(1-10张)
diff --git a/app/api/scene-fusion/config/route.ts b/app/api/scene-fusion/config/route.ts index 632001a00..9884e8ee2 100644 --- a/app/api/scene-fusion/config/route.ts +++ b/app/api/scene-fusion/config/route.ts @@ -10,6 +10,7 @@ import { getSystemSetting } from "@/lib/db/queries"; // 默认提示词 const FALLBACK_PROMPT = "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。"; +const SCENE_FUSION_MAX_ITEMS = 10; export async function GET() { try { @@ -35,7 +36,7 @@ export async function GET() { return NextResponse.json({ enabled: config.enabled ?? true, defaultPrompt: config.defaultPrompt || FALLBACK_PROMPT, - maxScenes: config.maxScenes ?? 10, + maxScenes: Math.min(config.maxScenes ?? SCENE_FUSION_MAX_ITEMS, SCENE_FUSION_MAX_ITEMS), mode: "diversity", }); } @@ -44,7 +45,7 @@ export async function GET() { return NextResponse.json({ enabled: true, defaultPrompt: FALLBACK_PROMPT, - maxScenes: 10, + maxScenes: SCENE_FUSION_MAX_ITEMS, mode: "diversity", }); } catch (error) { diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index 11395fc11..a7379464d 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -29,13 +29,14 @@ const sceneFusionRequestSchema = z.object({ image: z.string().min(1, "场景图不能为空"), targetName: z.string().optional(), // 背景中需要被替换的物体名称(如"椅子") customPrompt: z.string().optional(), - })).min(1, "至少需要一个场景图").max(20, "最多支持20个场景图"), + })).min(1, "至少需要一个场景图").max(10, "最多支持10个场景图"), }); // 默认提示词(当数据库未配置时使用)- 使用"参考"的表述方式 const FALLBACK_PROMPT = "请参考第一张图片中的产品,将其自然地融合到第二张图片的场景中。要求:1)保持产品的整体外观、颜色、形状和设计风格与第一张图一致;2)调整产品的大小和角度以适应场景;3)注意光影和透视关系使融合自然。"; const SCENE_FUSION_MODEL_COOLDOWN_MS = 2 * 60 * 1000; +const SCENE_FUSION_MAX_ITEMS = 10; const sceneFusionModelCooldown = new Map(); interface SceneFusionModelCandidate { @@ -183,7 +184,7 @@ async function getSceneFusionConfig() { return { enabled: config.enabled ?? true, defaultPrompt: config.defaultPrompt || FALLBACK_PROMPT, - maxScenes: config.maxScenes ?? 10, + maxScenes: Math.min(config.maxScenes ?? SCENE_FUSION_MAX_ITEMS, SCENE_FUSION_MAX_ITEMS), modelId: config.modelId || "", }; } @@ -194,7 +195,7 @@ async function getSceneFusionConfig() { return { enabled: true, defaultPrompt: FALLBACK_PROMPT, - maxScenes: 10, + maxScenes: SCENE_FUSION_MAX_ITEMS, modelId: "", }; } @@ -911,9 +912,10 @@ export async function POST(request: Request) { console.log(`[SceneFusion] Output settings: aspectRatio=${aspectRatio || 'auto'}, imageSize=${imageSize || '2K'}, calculated size=${size || 'auto'}`); // 检查场景数量限制 - if (scenes.length > config.maxScenes) { + const effectiveMaxScenes = Math.min(config.maxScenes, SCENE_FUSION_MAX_ITEMS); + if (scenes.length > effectiveMaxScenes) { return NextResponse.json( - { error: `最多只能添加 ${config.maxScenes} 个场景` }, + { error: `最多只能添加 ${effectiveMaxScenes} 个场景` }, { status: 400 } ); } diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index d689fecb8..a4366df87 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -2176,14 +2176,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { 备注 - - {isAddingToChat ? ( - - ) : ( - - )} - 对话 - {/* Top Header - Minimal */} -
-
- 图片编辑 -
- - {isResizeMode ? "调整尺寸" : "图片标注与处理"} - + {!showSceneFusion && ( +
+
+ 图片编辑 +
+ + {isResizeMode ? "调整尺寸" : "图片标注与处理"} + +
-
+ )} {/* Floating Toolbar - Top (Tools & Actions) */} {!showSceneFusion && ( diff --git a/components/scene-fusion-dialog.tsx b/components/scene-fusion-dialog.tsx index 5bc7e96b1..3ff76f9a2 100644 --- a/components/scene-fusion-dialog.tsx +++ b/components/scene-fusion-dialog.tsx @@ -1,48 +1,14 @@ "use client"; -import React, { useState, useRef } from "react"; -import useSWR from "swr"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogFooter, - DialogDescription +import React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Upload, - Plus, - Trash2, - Download, - RefreshCw, - Image as ImageIcon, - Loader2, - X, - Layers, - Sparkles -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { toast } from "sonner"; - -const fetcher = (url: string) => fetch(url).then((res) => res.json()); - -// 场景项数据结构 -export interface SceneItem { - id: string; - image: string; // 场景图 URL 或 Base64 - customPrompt: string; // 该场景的修改要求 - retryCount?: number; // 重试次数 - status: 'pending' | 'processing' | 'completed' | 'failed'; - result?: string; // 生成结果缩略图 URL - originalUrl?: string; // 生成结果原图 URL(用于下载) - error?: string; // 错误信息 -} +import { SceneFusionView } from "@/components/scene-fusion-view"; interface SceneFusionDialogProps { open: boolean; @@ -50,611 +16,16 @@ interface SceneFusionDialogProps { } export function SceneFusionDialog({ open, onOpenChange }: SceneFusionDialogProps) { - // 最大自动重试次数 - const MAX_AUTO_RETRY = 10; - - // 获取批量处理配置(使用公开 API 端点) - const { data: configData } = useSWR( - open ? "/api/scene-fusion/config" : null, - fetcher - ); - - const config = configData as { - enabled?: boolean; - defaultPrompt?: string; - maxScenes?: number; - } | undefined; - - const defaultPrompt = config?.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。"; - const maxScenes = config?.maxScenes || 10; - - // 状态管理 - const [productImage, setProductImage] = useState(null); - const [scenes, setScenes] = useState([]); - const [isProcessing, setIsProcessing] = useState(false); - - // 文件上传 Input 引用 - const productInputRef = useRef(null); - const sceneInputRef = useRef(null); - - // 处理产品图上传 - const handleProductUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - if (file.size > 50 * 1024 * 1024) { - toast.error("图片大小不能超过 50MB"); - return; - } - - const reader = new FileReader(); - reader.onload = (e) => { - setProductImage(e.target?.result as string); - }; - reader.readAsDataURL(file); - // 重置 input,允许重复选择同一文件 - e.target.value = ""; - }; - - // 处理场景图上传 - const handleSceneUpload = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - if (files.length === 0) return; - - if (scenes.length + files.length > maxScenes) { - toast.error(`最多只能添加 ${maxScenes} 张场景参考图`); - return; - } - - files.forEach(file => { - if (file.size > 50 * 1024 * 1024) { - toast.error(`图片 ${file.name} 大小不能超过 50MB`); - return; - } - - const reader = new FileReader(); - reader.onload = (e) => { - const newScene: SceneItem = { - id: Math.random().toString(36).substring(7), - image: e.target?.result as string, - customPrompt: "", - status: 'pending' - }; - setScenes(prev => [...prev, newScene]); - }; - reader.readAsDataURL(file); - }); - - // 重置 input - e.target.value = ""; - }; - - // 移除场景 - const removeScene = (id: string) => { - setScenes(prev => prev.filter(scene => scene.id !== id)); - }; - - // 更新场景关键词 - const updateScenePrompt = (id: string, prompt: string) => { - setScenes(prev => prev.map(scene => - scene.id === id ? { ...scene, customPrompt: prompt } : scene - )); - }; - - // 开始裂变 - const startFusion = async () => { - if (config?.enabled === false) { - toast.error("批量处理功能已被管理员禁用"); - return; - } - if (!productImage) { - toast.error("请先上传产品图"); - return; - } - if (scenes.length === 0) { - toast.error("请至少添加一张场景参考图"); - return; - } - - setIsProcessing(true); - - // 将所有 pending 或 failed 的状态改为 processing - const scenesToProcess = scenes.filter(s => s.status === 'pending' || s.status === 'failed'); - setScenes(prev => prev.map(s => - (s.status === 'pending' || s.status === 'failed') - ? { ...s, status: 'processing' } - : s - )); - - try { - // 调用后端 API - const response = await fetch('/api/scene-fusion', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - productImage, - scenes: scenesToProcess.map(s => ({ - id: s.id, - image: s.image, - customPrompt: s.customPrompt || undefined, - })), - }), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || '处理失败'); - } - - // 更新场景状态 - setScenes(prev => prev.map(s => { - const result = data.results?.find((r: any) => r.id === s.id); - if (result) { - if (result.status === 'completed') { - return { - ...s, - status: 'completed', - result: result.result, - originalUrl: result.originalUrl, - }; - } else { - return { - ...s, - status: 'failed', - error: result.error || '生成失败' - }; - } - } - return s; - })); - - const { summary } = data; - if (summary.failed > 0) { - toast.warning(`批量处理完成:${summary.success} 成功,${summary.failed} 失败`); - } else { - toast.success(`批量处理完成:${summary.success} 张图片已生成`); - } - } catch (error) { - console.error('[SceneFusion] Error:', error); - toast.error(error instanceof Error ? error.message : '处理失败'); - - // 将 processing 状态改回 failed - setScenes(prev => prev.map(s => - s.status === 'processing' ? { ...s, status: 'failed', error: '处理失败' } : s - )); - } finally { - setIsProcessing(false); - } - }; - - // 重新生成单个场景(带自动重试) - const regenerateScene = async (id: string, retryCount: number = 0) => { - if (!productImage) return; - - const scene = scenes.find(s => s.id === id); - if (!scene) return; - - // 只在首次调用时显示处理状态 - if (retryCount === 0) { - setScenes(prev => prev.map(s => - s.id === id ? { ...s, status: 'processing', error: undefined, retryCount: 0 } : s - )); - } - - try { - const response = await fetch('/api/scene-fusion', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - productImage, - scenes: [{ - id: scene.id, - image: scene.image, - customPrompt: scene.customPrompt || undefined, - }], - }), - }); - - if (!response.ok) { - // 尝试解析错误信息 - try { - const errorData = await response.json(); - throw new Error(errorData.error || '处理失败'); - } catch { - throw new Error(`请求失败: ${response.status}`); - } - } - - // 处理流式响应 - const reader = response.body?.getReader(); - if (!reader) { - throw new Error('无法读取响应流'); - } - - const decoder = new TextDecoder(); - let buffer = ''; - let success = false; - - while (true) { - const { done, value } = await reader.read(); - - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (!line.trim()) continue; - - try { - const message = JSON.parse(line); - console.log('[SceneFusion] Regenerate message:', message.type); - - if (message.type === 'result') { - const result = message.data; - if (result.id === id) { - if (result.status === 'completed') { - setScenes(prev => prev.map(s => - s.id === id ? { - ...s, - status: 'completed', - result: result.result, - originalUrl: result.originalUrl, - retryCount: 0, - } : s - )); - toast.success('生成成功'); - success = true; - } else { - throw new Error(result.error || '生成失败'); - } - } - } else if (message.type === 'error') { - throw new Error(message.error || '处理失败'); - } - } catch (parseError) { - if (parseError instanceof SyntaxError) { - console.error('[SceneFusion] Failed to parse message:', line); - } else { - throw parseError; - } - } - } - } - - if (!success) { - throw new Error('未收到有效的生成结果'); - } - } catch (error) { - console.error(`[SceneFusion] Regenerate error (retry ${retryCount}/${MAX_AUTO_RETRY}):`, error); - - // 检查是否需要自动重试 - if (retryCount < MAX_AUTO_RETRY) { - console.log(`[SceneFusion] Auto-retrying scene ${id} (${retryCount + 1}/${MAX_AUTO_RETRY})...`); - // 更新重试计数,保持 processing 状态(用户无感) - setScenes(prev => prev.map(s => - s.id === id ? { ...s, status: 'processing', retryCount: retryCount + 1, error: undefined } : s - )); - // 延迟2秒后自动重试 - await new Promise(resolve => setTimeout(resolve, 2000)); - return regenerateScene(id, retryCount + 1); - } - - // 达到最大重试次数,显示失败 - setScenes(prev => prev.map(s => - s.id === id ? { ...s, status: 'failed', error: `重试${MAX_AUTO_RETRY}次后失败: ${error instanceof Error ? error.message : '处理失败'}` } : s - )); - toast.error(`重试${MAX_AUTO_RETRY}次后仍然失败`); - } - }; - - // 下载图片 - 使用 fetch 获取图片数据后下载,解决跨域问题 - const downloadImage = async (url: string, filename: string) => { - try { - const response = await fetch(url); - const blob = await response.blob(); - const blobUrl = URL.createObjectURL(blob); - - const link = document.createElement('a'); - link.href = blobUrl; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // 释放 Blob URL - setTimeout(() => URL.revokeObjectURL(blobUrl), 100); - } catch (error) { - console.error('下载图片失败:', error); - // 降级方案:直接打开链接 - window.open(url, '_blank'); - } - }; - - // 全部下载 - 顺序下载所有图片 - const downloadAll = async () => { - const completedScenes = scenes.filter(s => s.status === 'completed' && (s.originalUrl || s.result)); - if (completedScenes.length === 0) { - toast.error("没有可下载的图片"); - return; - } - - toast.info(`正在下载 ${completedScenes.length} 张图片,请稍候...`); - - // 顺序下载,每张图片下载完成后再下载下一张 - for (let i = 0; i < completedScenes.length; i++) { - const scene = completedScenes[i]; - const url = scene.originalUrl || scene.result!; - try { - await downloadImage(url, `scene-fusion-${i + 1}.png`); - // 添加小延迟避免浏览器阻止 - await new Promise(resolve => setTimeout(resolve, 500)); - } catch (error) { - console.error(`下载第 ${i + 1} 张图片失败:`, error); - } - } - - toast.success(`已下载 ${completedScenes.length} 张图片`); - }; - return ( - - - - - 批量处理 - - - 上传1张产品图和多张场景图,AI 自动将产品融合到所有场景中。 - + + + 批量处理 + 批量处理图片与场景裂变。 - -
- {/* 左侧:输入区域 */} -
- -
- {/* 1. 产品图上传 */} -
-
- - {productImage && ( - - )} -
- -
!productImage && productInputRef.current?.click()} - > - - - {productImage ? ( -
- 产品图 -
- -
-
- ) : ( -
- - 点击上传产品图 -
- )} -
-
- - {/* 2. 场景图列表 */} -
-
- - - -
- -
- {scenes.length === 0 && ( -
sceneInputRef.current?.click()} - > - -

点击添加场景参考图

-

支持批量上传,最多{maxScenes}张

-
- )} - - {scenes.map((scene, index) => ( -
-
- {/* 缩略图 */} -
- {`场景 -
- - {/* 关键词输入 */} -
-
- 场景 {index + 1} - {!isProcessing && ( - - )} -
- updateScenePrompt(scene.id, e.target.value)} - className="h-8 text-xs" - disabled={isProcessing} - /> -
-
-
- ))} -
-
-
-
- - {/* 默认提示词提示 */} -
-

- 默认提示词: - {defaultPrompt.length > 60 ? `${defaultPrompt.substring(0, 60)}...` : defaultPrompt} -

-
- -
- -
-
- - {/* 右侧:结果展示区域 */} -
-
-

生成结果

- {scenes.some(s => s.status === 'completed') && ( - - )} -
- - - {scenes.length === 0 ? ( -
- -

添加场景图并开始裂变,结果将显示在这里

-
- ) : ( -
- {scenes.map((scene, index) => ( -
-
- 场景 {index + 1} - - {scene.status === 'completed' ? '完成' : - scene.status === 'failed' ? '失败' : - scene.status === 'processing' ? '生成中' : '等待'} - -
- -
- {scene.result ? ( - 生成结果 - ) : ( -
- {scene.status === 'processing' ? ( -
- - 正在生成... -
- ) : scene.status === 'failed' ? ( -
-

{scene.error || "生成失败"}

- -
- ) : ( -
- - 等待处理 -
- )} -
- )} - - {/* 悬停操作层 */} - {scene.status === 'completed' && ( -
- - -
- )} -
-
- ))} -
- )} -
-
-
+ onOpenChange(false)} />
); } + diff --git a/components/scene-fusion-view.tsx b/components/scene-fusion-view.tsx index 39f950964..87408c18c 100644 --- a/components/scene-fusion-view.tsx +++ b/components/scene-fusion-view.tsx @@ -1,106 +1,157 @@ "use client"; -import React, { useState, useRef, useEffect } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; +import { + Download, + Image as ImageIcon, + Layers, + Loader2, + Plus, + RefreshCw, + Sparkles, + Trash2, + Upload, + Wand2, +} from "lucide-react"; +import { toast } from "sonner"; +import { requestLongTaskResult } from "@/lib/client/long-task"; +import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Upload, - Plus, - Trash2, - Download, - RefreshCw, - Image as ImageIcon, - Loader2, - Layers, - Sparkles, - ArrowLeft -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { toast } from "sonner"; const fetcher = (url: string) => fetch(url).then((res) => res.json()); +const MAX_BATCH_ITEMS = 10; -// 场景项数据结构 -export interface SceneItem { +type BatchMode = "scene-fission" | "random-bg" | "rmbg" | "enhance" | "watermark"; +type ItemStatus = "pending" | "processing" | "completed" | "failed"; + +interface ProcessItem { id: string; - image: string; // 场景图 URL 或 Base64 - customPrompt: string; // 该场景的修改要求 - retryCount?: number; // 重试次数 - targetName: string; // 背景中需要被替换的物体名称 - status: 'pending' | 'processing' | 'completed' | 'failed'; - result?: string; // 生成结果缩略图 URL - originalUrl?: string; // 生成结果原图 URL(用于下载) - error?: string; // 错误信息 + image: string; + status: ItemStatus; + result?: string; + originalUrl?: string; + error?: string; + targetName?: string; + customPrompt?: string; } interface SceneFusionViewProps { onBack: () => void; - /** - * 可选:从外部(例如图片编辑器当前图片)传入的初始产品图。 - * 这样在「批量处理」中就可以自动带入当前正在编辑的图片, - * 避免用户重复上传。 - */ initialProductImage?: string | null; - /** - * 可选:初始产品名称,便于在进入批量处理时预填文案。 - */ initialProductName?: string; } -export function SceneFusionView({ onBack, initialProductImage, initialProductName }: SceneFusionViewProps) { - // 最大自动重试次数 - const MAX_AUTO_RETRY = 10; +const aspectRatioOptions = [ + { label: "自动", value: "auto" }, + { label: "1:1", value: "1:1" }, + { label: "4:3", value: "4:3" }, + { label: "3:4", value: "3:4" }, + { label: "16:9", value: "16:9" }, + { label: "9:16", value: "9:16" }, +]; - // 获取批量处理配置(使用公开 API 端点) - const { data: configData } = useSWR( - "/api/scene-fusion/config", - fetcher - ); - +const imageSizeOptions = [ + { label: "标准 (1K)", value: "1K" }, + { label: "高清 (2K)", value: "2K" }, + { label: "超清 (4K)", value: "4K" }, +]; + +const randomBackgroundStyles = [ + "现代简约客厅", + "北欧卧室", + "轻奢会客厅", + "咖啡馆窗边", + "自然森林步道", + "城市夜景街道", + "艺术展厅空间", + "暖色书房环境", + "阳光厨房空间", + "高端商业橱窗", +]; + +const modeOptions: Array<{ id: BatchMode; label: string; description: string }> = [ + { id: "scene-fission", label: "裂变场景", description: "1张产品图 + 多张参考图" }, + { id: "random-bg", label: "随机换背景", description: "1张图随机生成多种背景" }, + { id: "rmbg", label: "批量抠图", description: "多图同步去背景" }, + { id: "enhance", label: "批量变清晰", description: "多图同步增强画质" }, + { id: "watermark", label: "批量去水印", description: "多图同步去除水印" }, +]; + +function getModeActionLabel(mode: BatchMode, count: number) { + switch (mode) { + case "scene-fission": + return `开始裂变 (${count} 张)`; + case "random-bg": + return `开始换背景 (${count} 张)`; + case "rmbg": + return `批量抠图 (${count} 张)`; + case "enhance": + return `批量变清晰 (${count} 张)`; + case "watermark": + return `批量去水印 (${count} 张)`; + } +} + +function getModePrompt(mode: BatchMode) { + switch (mode) { + case "rmbg": + return "批量处理-抠图"; + case "enhance": + return "批量处理-变清晰"; + case "watermark": + return "批量处理-去水印"; + default: + return "批量处理"; + } +} + +export function SceneFusionView({ onBack, initialProductImage, initialProductName }: SceneFusionViewProps) { + const { data: configData } = useSWR("/api/scene-fusion/config", fetcher); const config = configData as { enabled?: boolean; defaultPrompt?: string; maxScenes?: number; } | undefined; - - const defaultPrompt = config?.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。"; - const maxScenes = config?.maxScenes || 10; - - // 比例选项 - const aspectRatioOptions = [ - { label: "自动", value: "auto" }, - { label: "1:1", value: "1:1" }, - { label: "4:3", value: "4:3" }, - { label: "3:4", value: "3:4" }, - { label: "16:9", value: "16:9" }, - { label: "9:16", value: "9:16" }, - ]; - - // 分辨率选项 - const imageSizeOptions = [ - { label: "标准 (1K)", value: "1K" }, - { label: "高清 (2K)", value: "2K" }, - { label: "超清 (4K)", value: "4K" }, - ]; - - // 状态管理 + + const maxScenes = Math.min(config?.maxScenes ?? MAX_BATCH_ITEMS, MAX_BATCH_ITEMS); + + const [mode, setMode] = useState("scene-fission"); + const [isProcessing, setIsProcessing] = useState(false); + const [aspectRatio, setAspectRatio] = useState("auto"); + const [imageSize, setImageSize] = useState("2K"); + const [productImage, setProductImage] = useState(null); const [productImagePreview, setProductImagePreview] = useState(null); - const [productName, setProductName] = useState(initialProductName || ""); // 产品名称(如"沙发") - const [scenes, setScenes] = useState([]); - const [isProcessing, setIsProcessing] = useState(false); - const [aspectRatio, setAspectRatio] = useState("auto"); // 图片比例 - const [imageSize, setImageSize] = useState("2K"); // 图片分辨率 - - // 文件上传 Input 引用 + const [productName, setProductName] = useState(initialProductName || ""); + + const [sceneItems, setSceneItems] = useState([]); + const [randomItems, setRandomItems] = useState([]); + const [randomCount, setRandomCount] = useState(4); + const [randomPrompt, setRandomPrompt] = useState(""); + + const [batchItems, setBatchItems] = useState([]); + const productInputRef = useRef(null); const sceneInputRef = useRef(null); + const batchInputRef = useRef(null); const productPreviewObjectUrlRef = useRef(null); - // 如果外部传入了初始产品图,则在首次挂载或传入值变化时自动带入 + const resultItems = useMemo(() => { + if (mode === "scene-fission") return sceneItems; + if (mode === "random-bg") return randomItems; + return batchItems; + }, [batchItems, mode, randomItems, sceneItems]); + + const modeCanStart = useMemo(() => { + if (mode === "scene-fission") return Boolean(productImage) && sceneItems.length > 0; + if (mode === "random-bg") return Boolean(productImage) && randomCount > 0; + return batchItems.length > 0; + }, [batchItems.length, mode, productImage, randomCount, sceneItems.length]); + const updateProductPreview = (previewUrl: string | null, isObjectUrl = false) => { if (productPreviewObjectUrlRef.current) { URL.revokeObjectURL(productPreviewObjectUrlRef.current); @@ -128,16 +179,13 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam return () => { if (productPreviewObjectUrlRef.current) { URL.revokeObjectURL(productPreviewObjectUrlRef.current); - productPreviewObjectUrlRef.current = null; } }; }, []); - // 处理产品图上传 const handleProductUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; - if (file.size > 50 * 1024 * 1024) { toast.error("图片大小不能超过 50MB"); return; @@ -148,683 +196,833 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam updateProductPreview(previewUrl, true); const reader = new FileReader(); - reader.onload = (e) => { - const result = e.target?.result; + reader.onload = (event) => { + const result = event.target?.result; if (typeof result === "string") { setProductImage(result); } else { toast.error("图片读取失败,请重试"); - clearProductImage(); } }; - reader.onerror = () => { - toast.error("图片读取失败,请重试"); - clearProductImage(); - }; + reader.onerror = () => toast.error("图片读取失败,请重试"); reader.readAsDataURL(file); - // 重置 input,允许重复选择同一文件 e.target.value = ""; }; - // 处理场景图上传 const handleSceneUpload = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length === 0) return; - - if (scenes.length + files.length > maxScenes) { - toast.error(`最多只能添加 ${maxScenes} 张场景参考图`); + if (sceneItems.length + files.length > maxScenes) { + toast.error(`单次最多上传 ${maxScenes} 张参考图`); return; } - files.forEach(file => { + files.forEach((file) => { if (file.size > 50 * 1024 * 1024) { toast.error(`图片 ${file.name} 大小不能超过 50MB`); return; } - const reader = new FileReader(); - reader.onload = (e) => { - const newScene: SceneItem = { - id: Math.random().toString(36).substring(7), - image: e.target?.result as string, - customPrompt: "", - targetName: "", // 背景中需要被替换的物体名称 - status: 'pending' - }; - setScenes(prev => [...prev, newScene]); + reader.onload = (event) => { + const image = event.target?.result as string; + setSceneItems((prev) => [ + ...prev, + { + id: Math.random().toString(36).slice(2, 10), + image, + targetName: "", + customPrompt: "", + status: "pending", + }, + ]); }; reader.readAsDataURL(file); }); - - // 重置 input + e.target.value = ""; }; - // 移除场景 - const removeScene = (id: string) => { - setScenes(prev => prev.filter(scene => scene.id !== id)); + const handleBatchUpload = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + if (batchItems.length + files.length > MAX_BATCH_ITEMS) { + toast.error(`单次最多上传 ${MAX_BATCH_ITEMS} 张图片`); + return; + } + + files.forEach((file) => { + if (file.size > 50 * 1024 * 1024) { + toast.error(`图片 ${file.name} 大小不能超过 50MB`); + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + const image = event.target?.result as string; + setBatchItems((prev) => [ + ...prev, + { + id: Math.random().toString(36).slice(2, 10), + image, + status: "pending", + }, + ]); + }; + reader.readAsDataURL(file); + }); + + e.target.value = ""; }; - // 更新场景关键词 - const updateScenePrompt = (id: string, prompt: string) => { - setScenes(prev => prev.map(scene => - scene.id === id ? { ...scene, customPrompt: prompt } : scene - )); + const updateSceneItem = (id: string, patch: Partial) => { + setSceneItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item))); }; - // 更新场景中需要被替换的物体名称 - const updateSceneTargetName = (id: string, targetName: string) => { - setScenes(prev => prev.map(scene => - scene.id === id ? { ...scene, targetName } : scene - )); + const removeSceneItem = (id: string) => { + setSceneItems((prev) => prev.filter((item) => item.id !== id)); }; - // 开始裂变 - const startFusion = async () => { + const removeBatchItem = (id: string) => { + setBatchItems((prev) => prev.filter((item) => item.id !== id)); + }; + + const updateBatchItemsStatus = (ids: string[], status: ItemStatus) => { + setBatchItems((prev) => + prev.map((item) => + ids.includes(item.id) ? { ...item, status, error: status === "processing" ? undefined : item.error } : item + ) + ); + }; + + const updateFusionItemsStatus = ( + setter: React.Dispatch>, + ids: string[], + status: ItemStatus + ) => { + setter((prev) => + prev.map((item) => + ids.includes(item.id) ? { ...item, status, error: status === "processing" ? undefined : item.error } : item + ) + ); + }; + + const runSceneFusion = async ( + scenesPayload: Array<{ id: string; image: string; targetName?: string; customPrompt?: string }>, + setter: React.Dispatch> + ) => { + if (!productImage) return; + if (scenesPayload.length === 0) return; if (config?.enabled === false) { toast.error("批量处理功能已被管理员禁用"); return; } - if (!productImage) { - toast.error("请先上传产品图"); - return; - } - if (scenes.length === 0) { - toast.error("请至少添加一张场景参考图"); - return; - } setIsProcessing(true); - - // 将所有 pending 或 failed 的状态改为 processing - const scenesToProcess = scenes.filter(s => s.status === 'pending' || s.status === 'failed'); - setScenes(prev => prev.map(s => - (s.status === 'pending' || s.status === 'failed') - ? { ...s, status: 'processing' } - : s - )); + updateFusionItemsStatus( + setter, + scenesPayload.map((item) => item.id), + "processing" + ); try { - console.log('[SceneFusion Client] Starting streaming API call with', scenesToProcess.length, 'scenes'); - - // 创建 AbortController 用于超时控制(10分钟) - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10 * 60 * 1000); - - // 调用后端 API(流式响应) - const response = await fetch('/api/scene-fusion', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - signal: controller.signal, - body: JSON.stringify({ - productImage, - productName: productName || undefined, // 产品名称 - aspectRatio, // 图片比例 - imageSize, // 图片分辨率 - scenes: scenesToProcess.map(s => ({ - id: s.id, - image: s.image, - targetName: s.targetName || undefined, // 背景中需要被替换的物体名称 - customPrompt: s.customPrompt || undefined, - })), - }), - }); - - clearTimeout(timeoutId); - console.log('[SceneFusion Client] Response received, status:', response.status); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || '处理失败'); - } - - // 流式读取响应 - const reader = response.body?.getReader(); - const decoder = new TextDecoder(); - - if (!reader) { - throw new Error('无法读取响应流'); - } - - let buffer = ''; - let totalScenes = 0; - let completedCount = 0; - let failedCount = 0; - - while (true) { - const { done, value } = await reader.read(); - - if (done) { - console.log('[SceneFusion Client] Stream completed'); - break; - } - - // 解码数据并添加到缓冲区 - buffer += decoder.decode(value, { stream: true }); - - // 处理完整的行(以\n分隔) - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // 保留最后一个不完整的行 - - for (const line of lines) { - if (!line.trim()) continue; - - try { - const message = JSON.parse(line); - console.log('[SceneFusion Client] Received message:', message.type); - - if (message.type === 'start') { - totalScenes = message.total; - console.log('[SceneFusion Client] Total scenes:', totalScenes); - } else if (message.type === 'result') { - const result = message.data; - console.log('[SceneFusion Client] Scene result:', result.id, result.status); - - // 立即更新对应场景的状态 - setScenes(prev => prev.map(s => { - if (s.id === result.id) { - if (result.status === 'completed') { - completedCount++; - toast.success(`场景 ${completedCount} 生成完成!`); - return { - ...s, - status: 'completed', - result: result.result, - originalUrl: result.originalUrl, - retryCount: 0, // 重置重试计数 - }; - } else { - // 检查是否需要自动重试 - const currentRetryCount = s.retryCount || 0; - if (currentRetryCount < MAX_AUTO_RETRY) { - console.log(`[SceneFusion] Scene ${result.id} failed, will auto-retry (${currentRetryCount + 1}/${MAX_AUTO_RETRY})`); - // 标记为需要重试,稍后会自动重试 - return { - ...s, - status: 'pending', // 改为 pending 以便重新处理 - error: undefined, - retryCount: currentRetryCount + 1, - }; - } else { - failedCount++; - console.log(`[SceneFusion] Scene ${result.id} failed after ${MAX_AUTO_RETRY} retries`); - return { - ...s, - status: 'failed', - error: `重试${MAX_AUTO_RETRY}次后仍然失败: ${result.error || '生成失败'}` - }; - } - } - } - return s; - })); - } else if (message.type === 'done') { - const { summary } = message; - console.log('[SceneFusion Client] All done:', summary); - - // 检查是否有需要自动重试的场景 - // 注意:这里不直接调用,而是在 finally 中检查 - if (summary.failed === 0) { - toast.success(`批量处理完成:${summary.success} 张图片已生成`); - } - } else if (message.type === 'error') { - console.error('[SceneFusion Client] Stream error:', message.error); - toast.error(`处理失败:${message.error || '未知错误'}`); - // 将所有处理中的场景标记为失败 - setScenes(prev => prev.map(s => - s.status === 'processing' ? { ...s, status: 'failed', error: message.error || '处理失败' } : s - )); - } - } catch (parseError) { - console.error('[SceneFusion Client] Failed to parse message:', parseError, 'Line:', line); - } - } - } - } catch (error) { - console.error('[SceneFusion] Error:', error); - toast.error(error instanceof Error ? error.message : '处理失败'); - - // 将 processing 状态改回 pending(以便自动重试)或 failed - setScenes(prev => prev.map(s => { - if (s.status === 'processing') { - const currentRetryCount = s.retryCount || 0; - if (currentRetryCount < MAX_AUTO_RETRY) { - return { ...s, status: 'pending', retryCount: currentRetryCount + 1 }; - } - return { ...s, status: 'failed', error: '处理失败' }; - } - return s; - })); - } finally { - setIsProcessing(false); - - // 检查是否有需要自动重试的场景(状态为 pending 且 retryCount > 0) - // 使用 setTimeout 延迟执行,避免立即重试 - setTimeout(() => { - setScenes(currentScenes => { - const pendingRetries = currentScenes.filter(s => s.status === 'pending' && (s.retryCount || 0) > 0); - if (pendingRetries.length > 0) { - console.log(`[SceneFusion] Auto-retrying ${pendingRetries.length} failed scenes...`); - // 触发重新处理 - 由于 startFusion 会处理 pending 状态的场景 - // 我们需要在下一个事件循环中调用 - setTimeout(() => startFusion(), 100); - } else { - // 所有场景都处理完成,检查最终结果 - const failed = currentScenes.filter(s => s.status === 'failed').length; - const completed = currentScenes.filter(s => s.status === 'completed').length; - if (failed > 0 && completed > 0) { - toast.warning(`批量处理完成:${completed} 成功,${failed} 失败`); - } - } - return currentScenes; - }); - }, 500); - } - }; - - // 重新生成单个场景(带自动重试) - const regenerateScene = async (id: string, retryCount: number = 0) => { - if (!productImage) return; - - const scene = scenes.find(s => s.id === id); - if (!scene) return; - - // 只在首次调用时显示处理状态 - if (retryCount === 0) { - setScenes(prev => prev.map(s => - s.id === id ? { ...s, status: 'processing', error: undefined, retryCount: 0 } : s - )); - } - - try { - const response = await fetch('/api/scene-fusion', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + const response = await fetch("/api/scene-fusion", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ productImage, productName: productName || undefined, aspectRatio, imageSize, - scenes: [{ - id: scene.id, - image: scene.image, - targetName: scene.targetName || undefined, - customPrompt: scene.customPrompt || undefined, - }], + scenes: scenesPayload, }), }); if (!response.ok) { - // 尝试解析错误信息 - try { - const errorData = await response.json(); - throw new Error(errorData.error || '处理失败'); - } catch { - throw new Error(`请求失败: ${response.status}`); - } + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.error || "处理失败"); } - // 处理流式响应 const reader = response.body?.getReader(); if (!reader) { - throw new Error('无法读取响应流'); + throw new Error("读取流式响应失败"); } const decoder = new TextDecoder(); - let buffer = ''; - let success = false; + let buffer = ""; + let success = 0; + let failed = 0; while (true) { const { done, value } = await reader.read(); - if (done) break; buffer += decoder.decode(value, { stream: true }); - - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; for (const line of lines) { if (!line.trim()) continue; - - try { - const message = JSON.parse(line); - console.log('[SceneFusion] Regenerate message:', message.type); + const msg = JSON.parse(line) as { + type: string; + data?: { + id: string; + status: "completed" | "failed"; + result?: string; + originalUrl?: string; + error?: string; + }; + }; - if (message.type === 'result') { - const result = message.data; - if (result.id === id) { - if (result.status === 'completed') { - setScenes(prev => prev.map(s => - s.id === id ? { - ...s, - status: 'completed', - result: result.result, - originalUrl: result.originalUrl, - retryCount: 0, - } : s - )); - toast.success('生成成功'); - success = true; - } else { - throw new Error(result.error || '生成失败'); - } - } - } else if (message.type === 'error') { - throw new Error(message.error || '处理失败'); - } - } catch (parseError) { - if (parseError instanceof SyntaxError) { - console.error('[SceneFusion] Failed to parse message:', line); - } else { - throw parseError; - } + if (msg.type !== "result" || !msg.data) continue; + const data = msg.data; + if (data.status === "completed") { + success += 1; + setter((prev) => + prev.map((item) => + item.id === data.id + ? { + ...item, + status: "completed", + result: data.result, + originalUrl: data.originalUrl, + error: undefined, + } + : item + ) + ); + } else { + failed += 1; + setter((prev) => + prev.map((item) => + item.id === data.id + ? { + ...item, + status: "failed", + error: data.error || "生成失败", + } + : item + ) + ); } } } - if (!success) { - throw new Error('未收到有效的生成结果'); + if (success > 0 && failed === 0) { + toast.success(`批量处理完成:${success} 张`); + } else if (success > 0 || failed > 0) { + toast.warning(`批量处理完成:${success} 成功,${failed} 失败`); } } catch (error) { - console.error(`[SceneFusion] Regenerate error (retry ${retryCount}/${MAX_AUTO_RETRY}):`, error); - - // 检查是否需要自动重试 - if (retryCount < MAX_AUTO_RETRY) { - console.log(`[SceneFusion] Auto-retrying scene ${id} (${retryCount + 1}/${MAX_AUTO_RETRY})...`); - // 更新重试计数,保持 processing 状态(用户无感) - setScenes(prev => prev.map(s => - s.id === id ? { ...s, status: 'processing', retryCount: retryCount + 1, error: undefined } : s - )); - // 延迟2秒后自动重试 - await new Promise(resolve => setTimeout(resolve, 2000)); - return regenerateScene(id, retryCount + 1); - } - - // 达到最大重试次数,显示失败 - setScenes(prev => prev.map(s => - s.id === id ? { ...s, status: 'failed', error: `重试${MAX_AUTO_RETRY}次后失败: ${error instanceof Error ? error.message : '处理失败'}` } : s - )); - toast.error(`重试${MAX_AUTO_RETRY}次后仍然失败`); + const message = error instanceof Error ? error.message : "处理失败"; + toast.error(message); + updateFusionItemsStatus( + setter, + scenesPayload.map((item) => item.id), + "failed" + ); + setter((prev) => + prev.map((item) => + scenesPayload.some((scene) => scene.id === item.id) ? { ...item, error: message } : item + ) + ); + } finally { + setIsProcessing(false); } }; - // 下载图片 - 使用 fetch 获取图片数据后下载,解决跨域问题 + const buildRandomItems = () => { + const count = Math.max(1, Math.min(MAX_BATCH_ITEMS, randomCount)); + 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}` : ""}`; + return { + id: `rand-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 6)}`, + image: productImage || "", + customPrompt, + status: "pending" as ItemStatus, + }; + }); + }; + + const processBatchImageItem = async (item: ProcessItem) => { + if (mode === "rmbg") { + const response = await fetch("/api/rmbg", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + imageUrl: item.image, + prompt: getModePrompt(mode), + }), + }); + const payload = await response.json(); + if (!response.ok || !payload?.success) { + throw new Error(payload?.error || payload?.detail || "抠图失败"); + } + return { + result: payload.thumbnail_url || payload.original_url || item.image, + originalUrl: payload.original_url || payload.thumbnail_url || item.image, + }; + } + + if (mode === "enhance") { + const payload = await requestLongTaskResult({ + endpoint: "/api/enhance", + payload: { + imageUrl: item.image, + prompt: getModePrompt(mode), + }, + pollingParams: { + prompt: getModePrompt(mode), + }, + }); + if (!payload?.success) { + throw new Error(payload?.error || payload?.detail || "变清晰失败"); + } + return { + result: payload.thumbnail_url || payload.original_url || item.image, + originalUrl: payload.original_url || payload.thumbnail_url || item.image, + }; + } + + if (mode === "watermark") { + const payload = await requestLongTaskResult({ + endpoint: "/api/watermark-remove", + payload: { + image_url: item.image, + }, + }); + if (!payload?.success) { + throw new Error(payload?.error || payload?.detail || "去水印失败"); + } + return { + result: payload.thumbnail_url || payload.original_url || item.image, + originalUrl: payload.original_url || payload.thumbnail_url || item.image, + }; + } + + throw new Error("当前模式不支持此操作"); + }; + + const runBatchImageMode = async () => { + if (batchItems.length === 0) { + toast.error("请先上传图片"); + return; + } + + let itemsToProcess = batchItems.filter((item) => item.status === "pending" || item.status === "failed"); + if (itemsToProcess.length === 0) { + itemsToProcess = batchItems; + } + + if (itemsToProcess.length > MAX_BATCH_ITEMS) { + toast.error(`单次最多处理 ${MAX_BATCH_ITEMS} 张图片`); + return; + } + + setIsProcessing(true); + updateBatchItemsStatus( + itemsToProcess.map((item) => item.id), + "processing" + ); + + const queue = [...itemsToProcess]; + const workerCount = Math.max(1, Math.min(3, queue.length)); + let successCount = 0; + let failedCount = 0; + + const workers = Array.from({ length: workerCount }, async () => { + while (queue.length > 0) { + const current = queue.shift(); + if (!current) break; + try { + const result = await processBatchImageItem(current); + successCount += 1; + setBatchItems((prev) => + prev.map((item) => + item.id === current.id + ? { + ...item, + status: "completed", + result: result.result, + originalUrl: result.originalUrl, + error: undefined, + } + : item + ) + ); + } catch (error) { + const message = error instanceof Error ? error.message : "处理失败"; + failedCount += 1; + setBatchItems((prev) => + prev.map((item) => + item.id === current.id + ? { + ...item, + status: "failed", + error: message, + } + : item + ) + ); + } + } + }); + + await Promise.all(workers); + setIsProcessing(false); + + if (successCount > 0 && failedCount === 0) { + toast.success(`批量处理完成:${successCount} 张`); + } else { + toast.warning(`批量处理完成:${successCount} 成功,${failedCount} 失败`); + } + }; + + const startProcessing = async () => { + if (mode === "scene-fission") { + if (!productImage) { + toast.error("请先上传产品图"); + return; + } + if (sceneItems.length === 0) { + toast.error("请至少上传一张场景参考图"); + return; + } + + let scenesToProcess = sceneItems.filter((item) => item.status === "pending" || item.status === "failed"); + if (scenesToProcess.length === 0) { + scenesToProcess = sceneItems; + } + + await runSceneFusion( + scenesToProcess.map((item) => ({ + id: item.id, + image: item.image, + targetName: item.targetName || undefined, + customPrompt: item.customPrompt || undefined, + })), + setSceneItems + ); + return; + } + + if (mode === "random-bg") { + if (!productImage) { + toast.error("请先上传主图"); + return; + } + const items = buildRandomItems(); + setRandomItems(items); + await runSceneFusion( + items.map((item) => ({ + id: item.id, + image: item.image, + customPrompt: item.customPrompt || undefined, + })), + setRandomItems + ); + return; + } + + await runBatchImageMode(); + }; + + const retrySingleItem = async (item: ProcessItem) => { + if (isProcessing) return; + if (mode === "scene-fission") { + await runSceneFusion( + [ + { + id: item.id, + image: item.image, + targetName: item.targetName || undefined, + customPrompt: item.customPrompt || undefined, + }, + ], + setSceneItems + ); + return; + } + if (mode === "random-bg") { + await runSceneFusion( + [ + { + id: item.id, + image: item.image, + customPrompt: item.customPrompt || undefined, + }, + ], + setRandomItems + ); + return; + } + + setBatchItems((prev) => + prev.map((batchItem) => + batchItem.id === item.id ? { ...batchItem, status: "pending", error: undefined } : batchItem + ) + ); + await runBatchImageMode(); + }; + const downloadImage = async (url: string, filename: string) => { try { const response = await fetch(url); const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); - - const link = document.createElement('a'); + const link = document.createElement("a"); link.href = blobUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); - - // 释放 Blob URL setTimeout(() => URL.revokeObjectURL(blobUrl), 100); - } catch (error) { - console.error('下载图片失败:', error); - // 降级方案:直接打开链接 - window.open(url, '_blank'); + } catch { + window.open(url, "_blank"); } }; - // 全部下载 - 顺序下载所有图片 const downloadAll = async () => { - const completedScenes = scenes.filter(s => s.status === 'completed' && (s.originalUrl || s.result)); - if (completedScenes.length === 0) { + const completed = resultItems.filter((item) => item.status === "completed" && (item.originalUrl || item.result)); + if (completed.length === 0) { toast.error("没有可下载的图片"); return; } - - toast.info(`正在下载 ${completedScenes.length} 张图片,请稍候...`); - - // 顺序下载,每张图片下载完成后再下载下一张 - for (let i = 0; i < completedScenes.length; i++) { - const scene = completedScenes[i]; - const url = scene.originalUrl || scene.result!; - try { - await downloadImage(url, `scene-fusion-${i + 1}.png`); - // 添加小延迟避免浏览器阻止 - await new Promise(resolve => setTimeout(resolve, 500)); - } catch (error) { - console.error(`下载第 ${i + 1} 张图片失败:`, error); - } + for (let i = 0; i < completed.length; i += 1) { + const item = completed[i]!; + await downloadImage(item.originalUrl || item.result!, `batch-result-${i + 1}.png`); + await new Promise((resolve) => setTimeout(resolve, 400)); } - - toast.success(`已下载 ${completedScenes.length} 张图片`); + toast.success(`已下载 ${completed.length} 张图片`); }; return ( -
- {/* 顶部标题栏 - 已在外部显示,此处隐藏 */ - /* -
-
- -
- - 批量处理 +
+
+
+
+
+
+ + 批量处理 +
+ +
-
-
- 上传1张产品图和多张场景图,AI 自动将产品融合到所有场景中 -
-
-
- */} -
- {/* 左侧:输入区域 */} -
- -
- {/* 1. 产品图上传 */} + +
-
- - {(productImagePreview || productImage) && ( - - )} -
- -
!(productImagePreview || productImage) && productInputRef.current?.click()} - > - - - {(productImagePreview || productImage) ? ( -
- 产品图 -
- -
-
- ) : ( -
- - 点击上传产品图 -
- )} -
- - {/* 产品名称输入 */} - {(productImagePreview || productImage) && ( -
- setProductName(e.target.value)} - className="h-9 text-sm" - disabled={isProcessing} - /> -

输入产品名称可以让 AI 更精准理解

-
- )} -
- - {/* 1.5 图片尺寸设置 */} -
- - - {/* 比例选择 */} -
- -
- {aspectRatioOptions.map((option) => ( - - ))} -
-
- - {/* 分辨率选择 */} -
- -
- {imageSizeOptions.map((option) => ( - - ))} -
-
-
- - {/* 2. 场景图列表 */} -
-
- - - -
- -
- {scenes.length === 0 && ( -
sceneInputRef.current?.click()} + +
+ {modeOptions.map((option) => ( +
- )} - - {scenes.map((scene, index) => ( -
-
- {/* 缩略图 */} -
- {`场景 -
- - {/* 输入区域 */} -
-
- 场景 {index + 1} - {!isProcessing && ( - - )} -
- updateSceneTargetName(scene.id, e.target.value)} - className="h-7 text-xs" - disabled={isProcessing} - /> - updateScenePrompt(scene.id, e.target.value)} - className="h-7 text-xs" - disabled={isProcessing} - /> -
-
-
+
{option.label}
+
{option.description}
+ ))}
+

单次处理硬限制:最多 {MAX_BATCH_ITEMS} 张图片。

+ + {(mode === "scene-fission" || mode === "random-bg") && ( + <> +
+
+ + {(productImagePreview || productImage) && ( + + )} +
+ +
!(productImagePreview || productImage) && productInputRef.current?.click()} + > + + {productImagePreview || productImage ? ( +
+ 主图 +
+ ) : ( +
+ + 点击上传主图 +
+ )} +
+ + {(productImagePreview || productImage) && ( + setProductName(e.target.value)} + className="h-9 text-sm" + disabled={isProcessing} + /> + )} +
+ +
+ +
+ +
+ {aspectRatioOptions.map((option) => ( + + ))} +
+
+
+ +
+ {imageSizeOptions.map((option) => ( + + ))} +
+
+
+ + )} + + {mode === "scene-fission" && ( +
+
+ + + +
+ +
+ {sceneItems.length === 0 && ( +
sceneInputRef.current?.click()} + > + +

点击添加场景参考图

+

最多 {maxScenes} 张

+
+ )} + {sceneItems.map((item, index) => ( +
+
+
+ {`场景 +
+
+
+ 场景 {index + 1} + {!isProcessing && ( + + )} +
+ updateSceneItem(item.id, { targetName: e.target.value })} + className="h-7 text-xs" + disabled={isProcessing} + /> + updateSceneItem(item.id, { customPrompt: e.target.value })} + className="h-7 text-xs" + disabled={isProcessing} + /> +
+
+
+ ))} +
+
+ )} + + {mode === "random-bg" && ( +
+ +
+ + setRandomCount(Math.max(1, Math.min(MAX_BATCH_ITEMS, Number(e.target.value) || 1)))} + className="h-9 text-sm" + /> +
+
+ + setRandomPrompt(e.target.value)} + disabled={isProcessing} + placeholder="例如:暖色调、真实摄影感、商业海报风" + className="h-9 text-sm" + /> +
+

+ 使用与“裂变场景”同一套生图模型池,自动随机背景风格生成。 +

+
+ )} + + {(mode === "rmbg" || mode === "enhance" || mode === "watermark") && ( +
+
+ + + +
+ +
+ {batchItems.length === 0 && ( +
batchInputRef.current?.click()} + > + +

点击上传待处理图片

+

最多 {MAX_BATCH_ITEMS} 张

+
+ )} + + {batchItems.map((item, index) => ( +
+
+
+ {`图片 +
+
+
图片 {index + 1}
+
+ 状态:{item.status === "pending" ? "等待" : item.status === "processing" ? "处理中" : item.status === "completed" ? "完成" : "失败"} +
+ {item.error &&
{item.error}
} +
+ {!isProcessing && ( + + )} +
+
+ ))} +
+
+ )}
- -
-
- {/* 右侧:结果展示区域 */} -
-
-

生成结果

- {scenes.some(s => s.status === 'completed') && ( +
+
+

处理结果

+ {resultItems.some((item) => item.status === "completed") && ( )}
- - - {scenes.length === 0 ? ( -
- -

添加场景图并开始裂变,结果将显示在这里

-
- ) : ( -
- {scenes.map((scene, index) => ( -
-
- 场景 {index + 1} - - {scene.status === 'completed' ? '完成' : - scene.status === 'failed' ? '失败' : - scene.status === 'processing' ? '生成中' : '等待'} - -
- -
- {scene.result ? ( - 生成结果 - ) : ( -
- {scene.status === 'processing' ? ( - <> - {/* 背景模糊图 - 使用更大的负边距和缩放确保完全覆盖,消除黑边 */} - 生成中 - {/* 动态遮罩层 */} -
- - {/* 动态光效 - 模拟扫描效果 */} -
- - {/* 中心 Loading */} -
-
- {/* 外圈脉冲 */} -
-
- -
-
-
- 正在融合... -
-
- - ) : scene.status === 'failed' ? ( -
-

{scene.error || "生成失败"}

- -
- ) : ( -
- - 等待处理 -
- )} -
- )} - {/* 悬停操作层 */} - {scene.status === 'completed' && ( -
- - -
- )} + +
+ {resultItems.length === 0 ? ( +
+ +

选择模式并上传图片后,结果会显示在这里

+
+ ) : ( +
+ {resultItems.map((item, index) => ( +
+
+ 任务 {index + 1} + + {item.status === "completed" + ? "完成" + : item.status === "failed" + ? "失败" + : item.status === "processing" + ? "处理中" + : "等待"} + +
+ +
+ {item.result ? ( + 处理结果 + ) : ( +
+ 原图 + {item.status === "processing" ? ( +
+ + 处理中... +
+ ) : item.status === "failed" ? ( +
+

+ {item.error || "处理失败"} +

+
+ ) : ( + + 等待处理 + + )} +
+ )} + + {item.status === "completed" && ( +
+ + +
+ )} +
-
- ))} -
- )} + ))} +
+ )} +