951 lines
38 KiB
TypeScript
951 lines
38 KiB
TypeScript
"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<string | null>(null);
|
||
const [productImagePreview, setProductImagePreview] = useState<string | null>(null);
|
||
const [productName, setProductName] = useState<string>(initialProductName || ""); // 产品名称(如"沙发")
|
||
const [scenes, setScenes] = useState<SceneItem[]>([]);
|
||
const [isProcessing, setIsProcessing] = useState(false);
|
||
const [aspectRatio, setAspectRatio] = useState<string>("auto"); // 图片比例
|
||
const [imageSize, setImageSize] = useState<string>("2K"); // 图片分辨率
|
||
|
||
// 文件上传 Input 引用
|
||
const productInputRef = useRef<HTMLInputElement>(null);
|
||
const sceneInputRef = useRef<HTMLInputElement>(null);
|
||
const productPreviewObjectUrlRef = useRef<string | null>(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<HTMLInputElement>) => {
|
||
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<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: "",
|
||
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 (
|
||
<div className="flex flex-col w-full h-full bg-background">
|
||
{/* 顶部标题栏 - 已在外部显示,此处隐藏 */
|
||
/*
|
||
<div className="flex items-center justify-between border-b px-4 py-3 shrink-0">
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="ghost" size="icon" onClick={onBack} title="返回图片编辑">
|
||
<ArrowLeft className="w-5 h-5" />
|
||
</Button>
|
||
<div className="flex items-center gap-2 font-medium">
|
||
<Layers className="w-5 h-5 text-primary" />
|
||
详情替换
|
||
</div>
|
||
</div>
|
||
<div className="text-sm text-muted-foreground hidden md:block">
|
||
上传1张产品图和多张场景图,AI 自动将产品融合到所有场景中
|
||
</div>
|
||
<div className="w-10"></div>
|
||
</div>
|
||
*/}
|
||
|
||
<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>
|
||
{(productImagePreview || productImage) && (
|
||
<Button variant="ghost" size="sm" onClick={clearProductImage} 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",
|
||
(productImagePreview || productImage) ? "border-primary/50 bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
|
||
)}
|
||
onClick={() => !(productImagePreview || productImage) && productInputRef.current?.click()}
|
||
>
|
||
<input
|
||
type="file"
|
||
ref={productInputRef}
|
||
className="hidden"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={handleProductUpload}
|
||
/>
|
||
|
||
{(productImagePreview || productImage) ? (
|
||
<div className="relative aspect-square w-full max-w-[200px] mx-auto">
|
||
<img src={productImagePreview || 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>
|
||
|
||
{/* 产品名称输入 */}
|
||
{(productImagePreview || productImage) && (
|
||
<div className="mt-2">
|
||
<Input
|
||
placeholder="产品名称(如:沙发、椅子、花瓶)"
|
||
value={productName}
|
||
onChange={(e) => setProductName(e.target.value)}
|
||
className="h-9 text-sm"
|
||
disabled={isProcessing}
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-1">输入产品名称可以让 AI 更精准理解</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 1.5 图片尺寸设置 */}
|
||
<div className="space-y-3">
|
||
<Label className="text-base font-medium">输出设置</Label>
|
||
|
||
{/* 比例选择 */}
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs text-muted-foreground">图片比例</label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{aspectRatioOptions.map((option) => (
|
||
<button
|
||
key={option.value}
|
||
onClick={() => setAspectRatio(option.value)}
|
||
disabled={isProcessing}
|
||
className={cn(
|
||
"px-2.5 py-1 text-xs rounded-md border transition-colors",
|
||
aspectRatio === option.value
|
||
? "bg-primary text-primary-foreground border-primary"
|
||
: "bg-background hover:bg-muted border-input"
|
||
)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分辨率选择 */}
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs text-muted-foreground">输出分辨率</label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{imageSizeOptions.map((option) => (
|
||
<button
|
||
key={option.value}
|
||
onClick={() => setImageSize(option.value)}
|
||
disabled={isProcessing}
|
||
className={cn(
|
||
"px-2.5 py-1 text-xs rounded-md border transition-colors",
|
||
imageSize === option.value
|
||
? "bg-primary text-primary-foreground border-primary"
|
||
: "bg-background hover:bg-muted border-input"
|
||
)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</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.targetName}
|
||
onChange={(e) => updateSceneTargetName(scene.id, e.target.value)}
|
||
className="h-7 text-xs"
|
||
disabled={isProcessing}
|
||
/>
|
||
<Input
|
||
placeholder="修改要求(可选)"
|
||
value={scene.customPrompt}
|
||
onChange={(e) => updateScenePrompt(scene.id, e.target.value)}
|
||
className="h-7 text-xs"
|
||
disabled={isProcessing}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
<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-2 md:grid-cols-3 xl:grid-cols-4 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-[3/4] 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-0 relative overflow-hidden">
|
||
{scene.status === 'processing' ? (
|
||
<>
|
||
{/* 背景模糊图 - 使用更大的负边距和缩放确保完全覆盖,消除黑边 */}
|
||
<img
|
||
src={scene.image}
|
||
alt="生成中"
|
||
className="absolute -inset-8 w-[calc(100%+4rem)] h-[calc(100%+4rem)] object-cover blur-xl scale-110"
|
||
/>
|
||
{/* 动态遮罩层 */}
|
||
<div className="absolute inset-0 bg-black/25" />
|
||
|
||
{/* 动态光效 - 模拟扫描效果 */}
|
||
<div className="absolute inset-0 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.3),transparent)] bg-[length:200%_100%] animate-[shimmer_2s_infinite]" />
|
||
|
||
{/* 中心 Loading */}
|
||
<div className="relative z-10 flex flex-col items-center gap-3">
|
||
<div className="relative">
|
||
{/* 外圈脉冲 */}
|
||
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
|
||
<div className="bg-background/80 backdrop-blur-sm rounded-full p-3 shadow-lg border border-primary/20">
|
||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||
</div>
|
||
</div>
|
||
<div className="bg-background/60 backdrop-blur-md px-3 py-1 rounded-full border border-white/10">
|
||
<span className="text-xs font-medium text-white/90">正在融合...</span>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : scene.status === 'failed' ? (
|
||
<div className="text-center p-4 relative z-10">
|
||
<p className="text-sm text-destructive mb-2 font-medium">{scene.error || "生成失败"}</p>
|
||
<Button size="sm" variant="outline" onClick={() => regenerateScene(scene.id)} className="bg-background/80 hover:bg-background">
|
||
<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>
|
||
</div>
|
||
);
|
||
}
|