diff --git a/components/image-annotator.tsx b/components/image-annotator.tsx index 586698487..4ad2de11d 100644 --- a/components/image-annotator.tsx +++ b/components/image-annotator.tsx @@ -9,7 +9,7 @@ import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { toast } from "sonner"; import { AngleEditDialog } from "./angle-edit-dialog"; -import { SceneFusionDialog } from "./scene-fusion-dialog"; +import { SceneFusionView } from "./scene-fusion-view"; type Tool = "pen" | "arrow" | "circle" | "text"; @@ -1216,7 +1216,15 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod className="fixed inset-0 z-[9999] flex items-center justify-center bg-background" onClick={onCancel} > - {/* 弹窗容器 - 全屏显示 */} + {showSceneFusion ? ( +
e.stopPropagation()} + > + setShowSceneFusion(false)} /> +
+ ) : ( + /* 弹窗容器 - 全屏显示 */
e.stopPropagation()} @@ -1701,6 +1709,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod )}
+ )} {/* 文字输入弹窗 */} {textPosition && ( @@ -1739,12 +1748,6 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod onComplete={handleAngleEditComplete} /> )} - - {/* 场景裂变弹窗 */} - ); diff --git a/components/scene-fusion-view.tsx b/components/scene-fusion-view.tsx new file mode 100644 index 000000000..29a4e4575 --- /dev/null +++ b/components/scene-fusion-view.tsx @@ -0,0 +1,563 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import useSWR from "swr"; +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()); + +// 场景项数据结构 +export interface SceneItem { + id: string; + image: string; // 场景图 URL 或 Base64 + customPrompt: string; // 该场景的补充关键词 + status: 'pending' | 'processing' | 'completed' | 'failed'; + result?: string; // 生成结果缩略图 URL + originalUrl?: string; // 生成结果原图 URL(用于下载) + error?: string; // 错误信息 +} + +interface SceneFusionViewProps { + onBack: () => void; +} + +export function SceneFusionView({ onBack }: SceneFusionViewProps) { + // 获取场景裂变配置(使用公开 API 端点) + 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 [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 > 10 * 1024 * 1024) { + toast.error("图片大小不能超过 10MB"); + 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 > 10 * 1024 * 1024) { + toast.error(`图片 ${file.name} 大小不能超过 10MB`); + 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) => { + if (!productImage) return; + + const scene = scenes.find(s => s.id === id); + if (!scene) return; + + setScenes(prev => prev.map(s => + s.id === id ? { ...s, status: 'processing', error: undefined } : 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, + }], + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || '处理失败'); + } + + const result = data.results?.[0]; + if (result?.status === 'completed') { + setScenes(prev => prev.map(s => + s.id === id ? { + ...s, + status: 'completed', + result: result.result, + originalUrl: result.originalUrl, + } : s + )); + toast.success('重新生成成功'); + } else { + throw new Error(result?.error || '生成失败'); + } + } catch (error) { + console.error('[SceneFusion] Regenerate error:', error); + setScenes(prev => prev.map(s => + s.id === id ? { ...s, status: 'failed', error: error instanceof Error ? error.message : '处理失败' } : s + )); + toast.error(error instanceof Error ? error.message : '重新生成失败'); + } + }; + + // 下载图片 + const downloadImage = (url: string, filename: string) => { + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + // 全部下载 + 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!; + // 延迟下载避免浏览器阻止 + setTimeout(() => { + downloadImage(url, `scene-fusion-${i + 1}.png`); + }, i * 300); + } + }; + + 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' && ( +
+ + +
+ )} +
+
+ ))} +
+ )} +
+
+
+
+ ); +} \ No newline at end of file