lovart3/components/scene-fusion-dialog.tsx
2026-02-08 11:38:03 +08:00

661 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useRef } from "react";
import useSWR from "swr";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogDescription
} 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; // 错误信息
}
interface SceneFusionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
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<string | null>(null);
const [scenes, setScenes] = useState<SceneItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
// 文件上传 Input 引用
const productInputRef = useRef<HTMLInputElement>(null);
const sceneInputRef = useRef<HTMLInputElement>(null);
// 处理产品图上传
const handleProductUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="flex items-center gap-2">
<Layers className="w-5 h-5 text-primary" />
详情替换
</DialogTitle>
<DialogDescription>
上传1张产品图和多张场景图,AI 自动将产品融合到所有场景中。
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col md:flex-row">
{/* 左侧:输入区域 */}
<div className="w-full md:w-[400px] border-r flex flex-col bg-muted/10 shrink-0">
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
{/* 1. 产品图上传 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">产品图 A (必须)</Label>
{productImage && (
<Button variant="ghost" size="sm" onClick={() => setProductImage(null)} className="h-6 text-xs text-muted-foreground hover:text-destructive">
清除
</Button>
)}
</div>
<div
className={cn(
"border-2 border-dashed rounded-lg p-4 transition-colors text-center cursor-pointer relative group",
productImage ? "border-primary/50 bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
)}
onClick={() => !productImage && productInputRef.current?.click()}
>
<input
type="file"
ref={productInputRef}
className="hidden"
accept="image/png,image/jpeg,image/webp"
onChange={handleProductUpload}
/>
{productImage ? (
<div className="relative aspect-square w-full max-w-[200px] mx-auto">
<img src={productImage} alt="产品图" className="w-full h-full object-contain rounded-md" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-md">
<Button variant="secondary" size="sm" onClick={(e) => { e.stopPropagation(); productInputRef.current?.click(); }}>
更换图片
</Button>
</div>
</div>
) : (
<div className="py-8 flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="w-8 h-8 opacity-50" />
<span className="text-sm">点击上传产品图</span>
</div>
)}
</div>
</div>
{/* 2. 场景图列表 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
场景参考图 ({scenes.length}/{maxScenes})
</Label>
<Button
variant="outline"
size="sm"
onClick={() => sceneInputRef.current?.click()}
disabled={scenes.length >= maxScenes || isProcessing}
className="h-7 text-xs gap-1"
>
<Plus className="w-3 h-3" /> 批量添加
</Button>
<input
type="file"
ref={sceneInputRef}
className="hidden"
multiple
accept="image/png,image/jpeg,image/webp"
onChange={handleSceneUpload}
/>
</div>
<div className="space-y-3">
{scenes.length === 0 && (
<div
className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => sceneInputRef.current?.click()}
>
<ImageIcon className="w-8 h-8 mx-auto opacity-20 mb-2" />
<p className="text-sm text-muted-foreground">点击添加场景参考图</p>
<p className="text-xs text-muted-foreground/60 mt-1">支持批量上传,最多{maxScenes}张</p>
</div>
)}
{scenes.map((scene, index) => (
<div key={scene.id} className="border rounded-lg p-3 bg-background relative group">
<div className="flex gap-3">
{/* 缩略图 */}
<div className="w-20 h-20 shrink-0 bg-muted rounded-md overflow-hidden border">
<img src={scene.image} alt={`场景 ${index + 1}`} className="w-full h-full object-cover" />
</div>
{/* 关键词输入 */}
<div className="flex-1 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">场景 {index + 1}</span>
{!isProcessing && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => removeScene(scene.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
<Input
placeholder="修改要求:如'放在木桌上'"
value={scene.customPrompt}
onChange={(e) => updateScenePrompt(scene.id, e.target.value)}
className="h-8 text-xs"
disabled={isProcessing}
/>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</ScrollArea>
{/* 默认提示词提示 */}
<div className="px-4 py-2 border-t bg-muted/30">
<p className="text-xs text-muted-foreground">
<span className="font-medium">默认提示词:</span>
{defaultPrompt.length > 60 ? `${defaultPrompt.substring(0, 60)}...` : defaultPrompt}
</p>
</div>
<div className="p-4 border-t bg-background">
<Button
className="w-full"
size="lg"
onClick={startFusion}
disabled={isProcessing || !productImage || scenes.length === 0}
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
处理中...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
开始裂变 ({scenes.length} 张)
</>
)}
</Button>
</div>
</div>
{/* 右侧:结果展示区域 */}
<div className="flex-1 flex flex-col bg-background/50 h-full">
<div className="p-4 border-b flex items-center justify-between bg-background">
<h3 className="font-medium">生成结果</h3>
{scenes.some(s => s.status === 'completed') && (
<Button variant="outline" size="sm" onClick={downloadAll}>
<Download className="w-4 h-4 mr-2" />
全部下载
</Button>
)}
</div>
<ScrollArea className="flex-1 p-4">
{scenes.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-50 min-h-[300px]">
<Layers className="w-16 h-16 mb-4" />
<p>添加场景图并开始裂变,结果将显示在这里</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{scenes.map((scene, index) => (
<div key={scene.id} className="border rounded-lg overflow-hidden bg-background shadow-sm flex flex-col">
<div className="p-2 border-b text-xs text-muted-foreground bg-muted/30 flex justify-between items-center">
<span>场景 {index + 1}</span>
<span className={cn(
"capitalize px-1.5 py-0.5 rounded text-[10px]",
scene.status === 'completed' && "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
scene.status === 'failed' && "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
scene.status === 'processing' && "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
scene.status === 'pending' && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
)}>
{scene.status === 'completed' ? '完成' :
scene.status === 'failed' ? '失败' :
scene.status === 'processing' ? '生成中' : '等待'}
</span>
</div>
<div className="aspect-[4/3] bg-muted/20 relative group">
{scene.result ? (
<img src={scene.result} alt="生成结果" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center p-4">
{scene.status === 'processing' ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="text-xs text-muted-foreground">正在生成...</span>
</div>
) : scene.status === 'failed' ? (
<div className="text-center">
<p className="text-sm text-destructive mb-2">{scene.error || "生成失败"}</p>
<Button size="sm" variant="outline" onClick={() => regenerateScene(scene.id)}>
<RefreshCw className="w-3 h-3 mr-1" /> 重试
</Button>
</div>
) : (
<div className="flex items-center justify-center w-full h-full opacity-30">
<img src={scene.image} className="w-full h-full object-cover blur-sm" />
<span className="absolute text-xs font-medium bg-black/50 text-white px-2 py-1 rounded">等待处理</span>
</div>
)}
</div>
)}
{/* 悬停操作层 */}
{scene.status === 'completed' && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="icon" variant="secondary" onClick={() => downloadImage(scene.originalUrl || scene.result!, `scene-${index+1}.png`)}>
<Download className="w-4 h-4" />
</Button>
<Button size="icon" variant="secondary" onClick={() => regenerateScene(scene.id)}>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
)}
</div>
</div>
))}
</div>
)}
</ScrollArea>
</div>
</div>
</DialogContent>
</Dialog>
);
}