feat: upgrade batch processing modes and enforce 10-item limit
This commit is contained in:
parent
bbad28114b
commit
166fec6b0d
@ -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}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
用户单次裂变可上传的最大场景图数量(1-20张)
|
||||
用户单次裂变可上传的最大场景图数量(1-10张)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<string, number>();
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -2176,14 +2176,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<StickyNote className="mr-2 h-4 w-4" />
|
||||
备注
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={isAddingToChat} onClick={handleAddToChat}>
|
||||
{isAddingToChat ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
对话
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-700"
|
||||
|
||||
@ -1393,15 +1393,17 @@ export function ImageAnnotator({
|
||||
</DialogHeader>
|
||||
|
||||
{/* Top Header - Minimal */}
|
||||
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-start px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none">
|
||||
<div className="pointer-events-auto flex items-center gap-3 rounded-full bg-background/95 backdrop-blur-md px-4 py-1.5 border border-border/50 shadow-sm">
|
||||
<span className="font-semibold text-sm">图片编辑</span>
|
||||
<div className="h-4 w-[1px] bg-border" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isResizeMode ? "调整尺寸" : "图片标注与处理"}
|
||||
</span>
|
||||
{!showSceneFusion && (
|
||||
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-start px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none">
|
||||
<div className="pointer-events-auto flex items-center gap-3 rounded-full bg-background/95 backdrop-blur-md px-4 py-1.5 border border-border/50 shadow-sm">
|
||||
<span className="font-semibold text-sm">图片编辑</span>
|
||||
<div className="h-4 w-[1px] bg-border" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isResizeMode ? "调整尺寸" : "图片标注与处理"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Toolbar - Top (Tools & Actions) */}
|
||||
{!showSceneFusion && (
|
||||
|
||||
@ -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<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>
|
||||
<DialogContent className="max-w-6xl h-[92vh] flex flex-col p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>批量处理</DialogTitle>
|
||||
<DialogDescription>批量处理图片与场景裂变。</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>
|
||||
<SceneFusionView onBack={() => onOpenChange(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user