"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; // 该场景的修改要求 retryCount?: number; // 重试次数 targetName: string; // 背景中需要被替换的物体名称 status: 'pending' | 'processing' | 'completed' | 'failed'; result?: string; // 生成结果缩略图 URL originalUrl?: string; // 生成结果原图 URL(用于下载) error?: string; // 错误信息 } interface SceneFusionViewProps { onBack: () => void; /** * 可选:从外部(例如图片编辑器当前图片)传入的初始产品图。 * 这样在「详情替换」中就可以自动带入当前正在编辑的图片, * 避免用户重复上传。 */ initialProductImage?: string | null; /** * 可选:初始产品名称,便于在进入详情替换时预填文案。 */ initialProductName?: string; } export function SceneFusionView({ onBack, initialProductImage, initialProductName }: SceneFusionViewProps) { // 最大自动重试次数 const MAX_AUTO_RETRY = 10; // 获取详情替换配置(使用公开 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 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 [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 productInputRef = useRef(null); const sceneInputRef = useRef(null); const productPreviewObjectUrlRef = useRef(null); // 如果外部传入了初始产品图,则在首次挂载或传入值变化时自动带入 const updateProductPreview = (previewUrl: string | null, isObjectUrl = false) => { if (productPreviewObjectUrlRef.current) { URL.revokeObjectURL(productPreviewObjectUrlRef.current); productPreviewObjectUrlRef.current = null; } setProductImagePreview(previewUrl); if (isObjectUrl && previewUrl) { productPreviewObjectUrlRef.current = previewUrl; } }; const clearProductImage = () => { setProductImage(null); updateProductPreview(null); }; useEffect(() => { if (initialProductImage) { setProductImage(initialProductImage); updateProductPreview(initialProductImage); } }, [initialProductImage]); useEffect(() => { 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; } setProductImage(null); const previewUrl = URL.createObjectURL(file); updateProductPreview(previewUrl, true); const reader = new FileReader(); reader.onload = (e) => { const result = e.target?.result; if (typeof result === "string") { setProductImage(result); } else { toast.error("图片读取失败,请重试"); clearProductImage(); } }; reader.onerror = () => { toast.error("图片读取失败,请重试"); clearProductImage(); }; 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: "", targetName: "", // 背景中需要被替换的物体名称 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 updateSceneTargetName = (id: string, targetName: string) => { setScenes(prev => prev.map(scene => scene.id === id ? { ...scene, targetName } : 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 { 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', }, body: JSON.stringify({ productImage, productName: productName || undefined, aspectRatio, imageSize, scenes: [{ id: scene.id, image: scene.image, targetName: scene.targetName || undefined, 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. 产品图上传 */}
{(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()} >

点击添加场景参考图

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

)} {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} />
))}
{/* 右侧:结果展示区域 */}

生成结果

{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' ? ( <> {/* 背景模糊图 - 使用更大的负边距和缩放确保完全覆盖,消除黑边 */} 生成中 {/* 动态遮罩层 */}
{/* 动态光效 - 模拟扫描效果 */}
{/* 中心 Loading */}
{/* 外圈脉冲 */}
正在融合...
) : scene.status === 'failed' ? (

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

) : (
等待处理
)}
)} {/* 悬停操作层 */} {scene.status === 'completed' && (
)}
))}
)}
); }