-
-
图片编辑
-
-
- {isResizeMode ? "调整尺寸" : "图片标注与处理"}
-
+ {!showSceneFusion && (
+
+
+
图片编辑
+
+
+ {isResizeMode ? "调整尺寸" : "图片标注与处理"}
+
+
-
+ )}
{/* Floating Toolbar - Top (Tools & Actions) */}
{!showSceneFusion && (
diff --git a/components/scene-fusion-dialog.tsx b/components/scene-fusion-dialog.tsx
index 5bc7e96b1..3ff76f9a2 100644
--- a/components/scene-fusion-dialog.tsx
+++ b/components/scene-fusion-dialog.tsx
@@ -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
(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 > 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) => {
- 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 (
);
}
+
diff --git a/components/scene-fusion-view.tsx b/components/scene-fusion-view.tsx
index 39f950964..87408c18c 100644
--- a/components/scene-fusion-view.tsx
+++ b/components/scene-fusion-view.tsx
@@ -1,106 +1,157 @@
"use client";
-import React, { useState, useRef, useEffect } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
+import {
+ Download,
+ Image as ImageIcon,
+ Layers,
+ Loader2,
+ Plus,
+ RefreshCw,
+ Sparkles,
+ Trash2,
+ Upload,
+ Wand2,
+} from "lucide-react";
+import { toast } from "sonner";
+import { requestLongTaskResult } from "@/lib/client/long-task";
+import { cn } from "@/lib/utils";
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());
+const MAX_BATCH_ITEMS = 10;
-// 场景项数据结构
-export interface SceneItem {
+type BatchMode = "scene-fission" | "random-bg" | "rmbg" | "enhance" | "watermark";
+type ItemStatus = "pending" | "processing" | "completed" | "failed";
+
+interface ProcessItem {
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; // 错误信息
+ image: string;
+ status: ItemStatus;
+ result?: string;
+ originalUrl?: string;
+ error?: string;
+ targetName?: string;
+ customPrompt?: string;
}
interface SceneFusionViewProps {
onBack: () => void;
- /**
- * 可选:从外部(例如图片编辑器当前图片)传入的初始产品图。
- * 这样在「批量处理」中就可以自动带入当前正在编辑的图片,
- * 避免用户重复上传。
- */
initialProductImage?: string | null;
- /**
- * 可选:初始产品名称,便于在进入批量处理时预填文案。
- */
initialProductName?: string;
}
-export function SceneFusionView({ onBack, initialProductImage, initialProductName }: SceneFusionViewProps) {
- // 最大自动重试次数
- const MAX_AUTO_RETRY = 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" },
+];
- // 获取批量处理配置(使用公开 API 端点)
- const { data: configData } = useSWR(
- "/api/scene-fusion/config",
- fetcher
- );
-
+const imageSizeOptions = [
+ { label: "标准 (1K)", value: "1K" },
+ { label: "高清 (2K)", value: "2K" },
+ { label: "超清 (4K)", value: "4K" },
+];
+
+const randomBackgroundStyles = [
+ "现代简约客厅",
+ "北欧卧室",
+ "轻奢会客厅",
+ "咖啡馆窗边",
+ "自然森林步道",
+ "城市夜景街道",
+ "艺术展厅空间",
+ "暖色书房环境",
+ "阳光厨房空间",
+ "高端商业橱窗",
+];
+
+const modeOptions: Array<{ id: BatchMode; label: string; description: string }> = [
+ { id: "scene-fission", label: "裂变场景", description: "1张产品图 + 多张参考图" },
+ { id: "random-bg", label: "随机换背景", description: "1张图随机生成多种背景" },
+ { id: "rmbg", label: "批量抠图", description: "多图同步去背景" },
+ { id: "enhance", label: "批量变清晰", description: "多图同步增强画质" },
+ { id: "watermark", label: "批量去水印", description: "多图同步去除水印" },
+];
+
+function getModeActionLabel(mode: BatchMode, count: number) {
+ switch (mode) {
+ case "scene-fission":
+ return `开始裂变 (${count} 张)`;
+ case "random-bg":
+ return `开始换背景 (${count} 张)`;
+ case "rmbg":
+ return `批量抠图 (${count} 张)`;
+ case "enhance":
+ return `批量变清晰 (${count} 张)`;
+ case "watermark":
+ return `批量去水印 (${count} 张)`;
+ }
+}
+
+function getModePrompt(mode: BatchMode) {
+ switch (mode) {
+ case "rmbg":
+ return "批量处理-抠图";
+ case "enhance":
+ return "批量处理-变清晰";
+ case "watermark":
+ return "批量处理-去水印";
+ default:
+ return "批量处理";
+ }
+}
+
+export function SceneFusionView({ onBack, initialProductImage, initialProductName }: SceneFusionViewProps) {
+ 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 maxScenes = Math.min(config?.maxScenes ?? MAX_BATCH_ITEMS, MAX_BATCH_ITEMS);
+
+ const [mode, setMode] = useState("scene-fission");
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [aspectRatio, setAspectRatio] = useState("auto");
+ const [imageSize, setImageSize] = useState("2K");
+
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 [productName, setProductName] = useState(initialProductName || "");
+
+ const [sceneItems, setSceneItems] = useState([]);
+ const [randomItems, setRandomItems] = useState([]);
+ const [randomCount, setRandomCount] = useState(4);
+ const [randomPrompt, setRandomPrompt] = useState("");
+
+ const [batchItems, setBatchItems] = useState([]);
+
const productInputRef = useRef(null);
const sceneInputRef = useRef(null);
+ const batchInputRef = useRef(null);
const productPreviewObjectUrlRef = useRef(null);
- // 如果外部传入了初始产品图,则在首次挂载或传入值变化时自动带入
+ const resultItems = useMemo(() => {
+ if (mode === "scene-fission") return sceneItems;
+ if (mode === "random-bg") return randomItems;
+ return batchItems;
+ }, [batchItems, mode, randomItems, sceneItems]);
+
+ const modeCanStart = useMemo(() => {
+ if (mode === "scene-fission") return Boolean(productImage) && sceneItems.length > 0;
+ if (mode === "random-bg") return Boolean(productImage) && randomCount > 0;
+ return batchItems.length > 0;
+ }, [batchItems.length, mode, productImage, randomCount, sceneItems.length]);
+
const updateProductPreview = (previewUrl: string | null, isObjectUrl = false) => {
if (productPreviewObjectUrlRef.current) {
URL.revokeObjectURL(productPreviewObjectUrlRef.current);
@@ -128,16 +179,13 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
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;
@@ -148,683 +196,833 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
updateProductPreview(previewUrl, true);
const reader = new FileReader();
- reader.onload = (e) => {
- const result = e.target?.result;
+ reader.onload = (event) => {
+ const result = event.target?.result;
if (typeof result === "string") {
setProductImage(result);
} else {
toast.error("图片读取失败,请重试");
- clearProductImage();
}
};
- reader.onerror = () => {
- toast.error("图片读取失败,请重试");
- clearProductImage();
- };
+ reader.onerror = () => toast.error("图片读取失败,请重试");
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} 张场景参考图`);
+ if (sceneItems.length + files.length > maxScenes) {
+ toast.error(`单次最多上传 ${maxScenes} 张参考图`);
return;
}
- files.forEach(file => {
+ 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.onload = (event) => {
+ const image = event.target?.result as string;
+ setSceneItems((prev) => [
+ ...prev,
+ {
+ id: Math.random().toString(36).slice(2, 10),
+ image,
+ targetName: "",
+ customPrompt: "",
+ status: "pending",
+ },
+ ]);
};
reader.readAsDataURL(file);
});
-
- // 重置 input
+
e.target.value = "";
};
- // 移除场景
- const removeScene = (id: string) => {
- setScenes(prev => prev.filter(scene => scene.id !== id));
+ const handleBatchUpload = (e: React.ChangeEvent) => {
+ const files = Array.from(e.target.files || []);
+ if (files.length === 0) return;
+ if (batchItems.length + files.length > MAX_BATCH_ITEMS) {
+ toast.error(`单次最多上传 ${MAX_BATCH_ITEMS} 张图片`);
+ return;
+ }
+
+ files.forEach((file) => {
+ if (file.size > 50 * 1024 * 1024) {
+ toast.error(`图片 ${file.name} 大小不能超过 50MB`);
+ return;
+ }
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const image = event.target?.result as string;
+ setBatchItems((prev) => [
+ ...prev,
+ {
+ id: Math.random().toString(36).slice(2, 10),
+ image,
+ status: "pending",
+ },
+ ]);
+ };
+ reader.readAsDataURL(file);
+ });
+
+ e.target.value = "";
};
- // 更新场景关键词
- const updateScenePrompt = (id: string, prompt: string) => {
- setScenes(prev => prev.map(scene =>
- scene.id === id ? { ...scene, customPrompt: prompt } : scene
- ));
+ const updateSceneItem = (id: string, patch: Partial) => {
+ setSceneItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)));
};
- // 更新场景中需要被替换的物体名称
- const updateSceneTargetName = (id: string, targetName: string) => {
- setScenes(prev => prev.map(scene =>
- scene.id === id ? { ...scene, targetName } : scene
- ));
+ const removeSceneItem = (id: string) => {
+ setSceneItems((prev) => prev.filter((item) => item.id !== id));
};
- // 开始裂变
- const startFusion = async () => {
+ const removeBatchItem = (id: string) => {
+ setBatchItems((prev) => prev.filter((item) => item.id !== id));
+ };
+
+ const updateBatchItemsStatus = (ids: string[], status: ItemStatus) => {
+ setBatchItems((prev) =>
+ prev.map((item) =>
+ ids.includes(item.id) ? { ...item, status, error: status === "processing" ? undefined : item.error } : item
+ )
+ );
+ };
+
+ const updateFusionItemsStatus = (
+ setter: React.Dispatch>,
+ ids: string[],
+ status: ItemStatus
+ ) => {
+ setter((prev) =>
+ prev.map((item) =>
+ ids.includes(item.id) ? { ...item, status, error: status === "processing" ? undefined : item.error } : item
+ )
+ );
+ };
+
+ const runSceneFusion = async (
+ scenesPayload: Array<{ id: string; image: string; targetName?: string; customPrompt?: string }>,
+ setter: React.Dispatch>
+ ) => {
+ if (!productImage) return;
+ if (scenesPayload.length === 0) return;
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
- ));
+ updateFusionItemsStatus(
+ setter,
+ scenesPayload.map((item) => item.id),
+ "processing"
+ );
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',
- },
+ 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,
- }],
+ scenes: scenesPayload,
}),
});
if (!response.ok) {
- // 尝试解析错误信息
- try {
- const errorData = await response.json();
- throw new Error(errorData.error || '处理失败');
- } catch {
- throw new Error(`请求失败: ${response.status}`);
- }
+ const errorData = await response.json().catch(() => null);
+ throw new Error(errorData?.error || "处理失败");
}
- // 处理流式响应
const reader = response.body?.getReader();
if (!reader) {
- throw new Error('无法读取响应流');
+ throw new Error("读取流式响应失败");
}
const decoder = new TextDecoder();
- let buffer = '';
- let success = false;
+ let buffer = "";
+ let success = 0;
+ let failed = 0;
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() || '';
+ 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);
+ const msg = JSON.parse(line) as {
+ type: string;
+ data?: {
+ id: string;
+ status: "completed" | "failed";
+ result?: string;
+ originalUrl?: string;
+ error?: string;
+ };
+ };
- 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 (msg.type !== "result" || !msg.data) continue;
+ const data = msg.data;
+ if (data.status === "completed") {
+ success += 1;
+ setter((prev) =>
+ prev.map((item) =>
+ item.id === data.id
+ ? {
+ ...item,
+ status: "completed",
+ result: data.result,
+ originalUrl: data.originalUrl,
+ error: undefined,
+ }
+ : item
+ )
+ );
+ } else {
+ failed += 1;
+ setter((prev) =>
+ prev.map((item) =>
+ item.id === data.id
+ ? {
+ ...item,
+ status: "failed",
+ error: data.error || "生成失败",
+ }
+ : item
+ )
+ );
}
}
}
- if (!success) {
- throw new Error('未收到有效的生成结果');
+ if (success > 0 && failed === 0) {
+ toast.success(`批量处理完成:${success} 张`);
+ } else if (success > 0 || failed > 0) {
+ toast.warning(`批量处理完成:${success} 成功,${failed} 失败`);
}
} 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}次后仍然失败`);
+ const message = error instanceof Error ? error.message : "处理失败";
+ toast.error(message);
+ updateFusionItemsStatus(
+ setter,
+ scenesPayload.map((item) => item.id),
+ "failed"
+ );
+ setter((prev) =>
+ prev.map((item) =>
+ scenesPayload.some((scene) => scene.id === item.id) ? { ...item, error: message } : item
+ )
+ );
+ } finally {
+ setIsProcessing(false);
}
};
- // 下载图片 - 使用 fetch 获取图片数据后下载,解决跨域问题
+ const buildRandomItems = () => {
+ const count = Math.max(1, Math.min(MAX_BATCH_ITEMS, randomCount));
+ const stylePool = [...randomBackgroundStyles].sort(() => Math.random() - 0.5);
+ return Array.from({ length: count }).map((_, index) => {
+ const style = stylePool[index % stylePool.length];
+ const customPrompt = `请保持主体和构图尽量不变,仅替换背景为“${style}”风格,确保光影自然。${randomPrompt ? `补充要求:${randomPrompt}` : ""}`;
+ return {
+ id: `rand-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 6)}`,
+ image: productImage || "",
+ customPrompt,
+ status: "pending" as ItemStatus,
+ };
+ });
+ };
+
+ const processBatchImageItem = async (item: ProcessItem) => {
+ if (mode === "rmbg") {
+ const response = await fetch("/api/rmbg", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ imageUrl: item.image,
+ prompt: getModePrompt(mode),
+ }),
+ });
+ const payload = await response.json();
+ if (!response.ok || !payload?.success) {
+ throw new Error(payload?.error || payload?.detail || "抠图失败");
+ }
+ return {
+ result: payload.thumbnail_url || payload.original_url || item.image,
+ originalUrl: payload.original_url || payload.thumbnail_url || item.image,
+ };
+ }
+
+ if (mode === "enhance") {
+ const payload = await requestLongTaskResult({
+ endpoint: "/api/enhance",
+ payload: {
+ imageUrl: item.image,
+ prompt: getModePrompt(mode),
+ },
+ pollingParams: {
+ prompt: getModePrompt(mode),
+ },
+ });
+ if (!payload?.success) {
+ throw new Error(payload?.error || payload?.detail || "变清晰失败");
+ }
+ return {
+ result: payload.thumbnail_url || payload.original_url || item.image,
+ originalUrl: payload.original_url || payload.thumbnail_url || item.image,
+ };
+ }
+
+ if (mode === "watermark") {
+ const payload = await requestLongTaskResult({
+ endpoint: "/api/watermark-remove",
+ payload: {
+ image_url: item.image,
+ },
+ });
+ if (!payload?.success) {
+ throw new Error(payload?.error || payload?.detail || "去水印失败");
+ }
+ return {
+ result: payload.thumbnail_url || payload.original_url || item.image,
+ originalUrl: payload.original_url || payload.thumbnail_url || item.image,
+ };
+ }
+
+ throw new Error("当前模式不支持此操作");
+ };
+
+ const runBatchImageMode = async () => {
+ if (batchItems.length === 0) {
+ toast.error("请先上传图片");
+ return;
+ }
+
+ let itemsToProcess = batchItems.filter((item) => item.status === "pending" || item.status === "failed");
+ if (itemsToProcess.length === 0) {
+ itemsToProcess = batchItems;
+ }
+
+ if (itemsToProcess.length > MAX_BATCH_ITEMS) {
+ toast.error(`单次最多处理 ${MAX_BATCH_ITEMS} 张图片`);
+ return;
+ }
+
+ setIsProcessing(true);
+ updateBatchItemsStatus(
+ itemsToProcess.map((item) => item.id),
+ "processing"
+ );
+
+ const queue = [...itemsToProcess];
+ const workerCount = Math.max(1, Math.min(3, queue.length));
+ let successCount = 0;
+ let failedCount = 0;
+
+ const workers = Array.from({ length: workerCount }, async () => {
+ while (queue.length > 0) {
+ const current = queue.shift();
+ if (!current) break;
+ try {
+ const result = await processBatchImageItem(current);
+ successCount += 1;
+ setBatchItems((prev) =>
+ prev.map((item) =>
+ item.id === current.id
+ ? {
+ ...item,
+ status: "completed",
+ result: result.result,
+ originalUrl: result.originalUrl,
+ error: undefined,
+ }
+ : item
+ )
+ );
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "处理失败";
+ failedCount += 1;
+ setBatchItems((prev) =>
+ prev.map((item) =>
+ item.id === current.id
+ ? {
+ ...item,
+ status: "failed",
+ error: message,
+ }
+ : item
+ )
+ );
+ }
+ }
+ });
+
+ await Promise.all(workers);
+ setIsProcessing(false);
+
+ if (successCount > 0 && failedCount === 0) {
+ toast.success(`批量处理完成:${successCount} 张`);
+ } else {
+ toast.warning(`批量处理完成:${successCount} 成功,${failedCount} 失败`);
+ }
+ };
+
+ const startProcessing = async () => {
+ if (mode === "scene-fission") {
+ if (!productImage) {
+ toast.error("请先上传产品图");
+ return;
+ }
+ if (sceneItems.length === 0) {
+ toast.error("请至少上传一张场景参考图");
+ return;
+ }
+
+ let scenesToProcess = sceneItems.filter((item) => item.status === "pending" || item.status === "failed");
+ if (scenesToProcess.length === 0) {
+ scenesToProcess = sceneItems;
+ }
+
+ await runSceneFusion(
+ scenesToProcess.map((item) => ({
+ id: item.id,
+ image: item.image,
+ targetName: item.targetName || undefined,
+ customPrompt: item.customPrompt || undefined,
+ })),
+ setSceneItems
+ );
+ return;
+ }
+
+ if (mode === "random-bg") {
+ if (!productImage) {
+ toast.error("请先上传主图");
+ return;
+ }
+ const items = buildRandomItems();
+ setRandomItems(items);
+ await runSceneFusion(
+ items.map((item) => ({
+ id: item.id,
+ image: item.image,
+ customPrompt: item.customPrompt || undefined,
+ })),
+ setRandomItems
+ );
+ return;
+ }
+
+ await runBatchImageMode();
+ };
+
+ const retrySingleItem = async (item: ProcessItem) => {
+ if (isProcessing) return;
+ if (mode === "scene-fission") {
+ await runSceneFusion(
+ [
+ {
+ id: item.id,
+ image: item.image,
+ targetName: item.targetName || undefined,
+ customPrompt: item.customPrompt || undefined,
+ },
+ ],
+ setSceneItems
+ );
+ return;
+ }
+ if (mode === "random-bg") {
+ await runSceneFusion(
+ [
+ {
+ id: item.id,
+ image: item.image,
+ customPrompt: item.customPrompt || undefined,
+ },
+ ],
+ setRandomItems
+ );
+ return;
+ }
+
+ setBatchItems((prev) =>
+ prev.map((batchItem) =>
+ batchItem.id === item.id ? { ...batchItem, status: "pending", error: undefined } : batchItem
+ )
+ );
+ await runBatchImageMode();
+ };
+
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');
+ 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');
+ } catch {
+ window.open(url, "_blank");
}
};
- // 全部下载 - 顺序下载所有图片
const downloadAll = async () => {
- const completedScenes = scenes.filter(s => s.status === 'completed' && (s.originalUrl || s.result));
- if (completedScenes.length === 0) {
+ const completed = resultItems.filter((item) => item.status === "completed" && (item.originalUrl || item.result));
+ if (completed.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);
- }
+ for (let i = 0; i < completed.length; i += 1) {
+ const item = completed[i]!;
+ await downloadImage(item.originalUrl || item.result!, `batch-result-${i + 1}.png`);
+ await new Promise((resolve) => setTimeout(resolve, 400));
}
-
- toast.success(`已下载 ${completedScenes.length} 张图片`);
+ toast.success(`已下载 ${completed.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()}
+
+
+ {modeOptions.map((option) => (
+
- )}
-
- {scenes.map((scene, index) => (
-
-
- {/* 缩略图 */}
-
-

-
-
- {/* 输入区域 */}
-
-
-
+
{option.label}
+
{option.description}
+
))}
+
单次处理硬限制:最多 {MAX_BATCH_ITEMS} 张图片。
+
+ {(mode === "scene-fission" || mode === "random-bg") && (
+ <>
+
+
+
+ {(productImagePreview || productImage) && (
+
+ )}
+
+
+
!(productImagePreview || productImage) && productInputRef.current?.click()}
+ >
+
+ {productImagePreview || productImage ? (
+
+

+
+ ) : (
+
+
+ 点击上传主图
+
+ )}
+
+
+ {(productImagePreview || productImage) && (
+
setProductName(e.target.value)}
+ className="h-9 text-sm"
+ disabled={isProcessing}
+ />
+ )}
+
+
+
+
+
+
+
+ {aspectRatioOptions.map((option) => (
+
+ ))}
+
+
+
+
+
+ {imageSizeOptions.map((option) => (
+
+ ))}
+
+
+
+ >
+ )}
+
+ {mode === "scene-fission" && (
+
+
+
+
+
+
+
+
+ {sceneItems.length === 0 && (
+
sceneInputRef.current?.click()}
+ >
+
+
点击添加场景参考图
+
最多 {maxScenes} 张
+
+ )}
+ {sceneItems.map((item, index) => (
+
+
+
+

+
+
+
+
+ ))}
+
+
+ )}
+
+ {mode === "random-bg" && (
+
+
+
+
+ setRandomCount(Math.max(1, Math.min(MAX_BATCH_ITEMS, Number(e.target.value) || 1)))}
+ className="h-9 text-sm"
+ />
+
+
+
+ setRandomPrompt(e.target.value)}
+ disabled={isProcessing}
+ placeholder="例如:暖色调、真实摄影感、商业海报风"
+ className="h-9 text-sm"
+ />
+
+
+ 使用与“裂变场景”同一套生图模型池,自动随机背景风格生成。
+
+
+ )}
+
+ {(mode === "rmbg" || mode === "enhance" || mode === "watermark") && (
+
+
+
+
+
+
+
+
+ {batchItems.length === 0 && (
+
batchInputRef.current?.click()}
+ >
+
+
点击上传待处理图片
+
最多 {MAX_BATCH_ITEMS} 张
+
+ )}
+
+ {batchItems.map((item, index) => (
+
+
+
+

+
+
+
图片 {index + 1}
+
+ 状态:{item.status === "pending" ? "等待" : item.status === "processing" ? "处理中" : item.status === "completed" ? "完成" : "失败"}
+
+ {item.error &&
{item.error}
}
+
+ {!isProcessing && (
+
+ )}
+
+
+ ))}
+
+
+ )}
-
-
-
- {/* 右侧:结果展示区域 */}
-
-
-
生成结果
- {scenes.some(s => s.status === 'completed') && (
+
+
+
处理结果
+ {resultItems.some((item) => item.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 || "生成失败"}
-
regenerateScene(scene.id)} className="bg-background/80 hover:bg-background">
- 重试
-
-
- ) : (
-
-

-
等待处理
-
- )}
-
- )}
- {/* 悬停操作层 */}
- {scene.status === 'completed' && (
-
- downloadImage(scene.originalUrl || scene.result!, `scene-${index+1}.png`)}>
-
-
- regenerateScene(scene.id)}>
-
-
-
- )}
+
+
+ {resultItems.length === 0 ? (
+
+
+
选择模式并上传图片后,结果会显示在这里
+
+ ) : (
+
+ {resultItems.map((item, index) => (
+
+
+ 任务 {index + 1}
+
+ {item.status === "completed"
+ ? "完成"
+ : item.status === "failed"
+ ? "失败"
+ : item.status === "processing"
+ ? "处理中"
+ : "等待"}
+
+
+
+
+ {item.result ? (
+

+ ) : (
+
+

+ {item.status === "processing" ? (
+
+
+ 处理中...
+
+ ) : item.status === "failed" ? (
+
+
+ {item.error || "处理失败"}
+
+
+ ) : (
+
+ 等待处理
+
+ )}
+
+ )}
+
+ {item.status === "completed" && (
+
+ downloadImage(item.originalUrl || item.result!, `batch-${index + 1}.png`)}
+ >
+
+
+ retrySingleItem(item)}>
+
+
+
+ )}
+
-
- ))}
-
- )}
+ ))}
+
+ )}
+