diff --git a/components/scene-fusion-view.tsx b/components/scene-fusion-view.tsx index f3373aeca..9fd0eff61 100644 --- a/components/scene-fusion-view.tsx +++ b/components/scene-fusion-view.tsx @@ -27,7 +27,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; const fetcher = (url: string) => fetch(url).then((res) => res.json()); const MAX_BATCH_ITEMS = 10; -type BatchMode = "scene-fission" | "random-bg" | "rmbg" | "enhance" | "watermark"; +type BatchMode = "scene-fission" | "multi-product-scene" | "random-bg" | "rmbg" | "enhance" | "watermark"; type ItemStatus = "pending" | "processing" | "completed" | "failed"; interface ProcessItem { @@ -77,6 +77,7 @@ const randomBackgroundStyles = [ const modeOptions: Array<{ id: BatchMode; label: string; description: string; icon: React.ComponentType<{ className?: string }> }> = [ { id: "scene-fission", label: "裂变场景", description: "1张产品图 + 多张参考图", icon: Layers }, + { id: "multi-product-scene", label: "多产品替换", description: "10张产品图 + 1张参考图", icon: ImageIcon }, { id: "random-bg", label: "随机换背景", description: "1张图随机生成多种背景", icon: Wand2 }, { id: "rmbg", label: "批量抠图", description: "多图同步去背景", icon: Eraser }, { id: "enhance", label: "批量变清晰", description: "多图同步增强画质", icon: Sparkles }, @@ -87,6 +88,8 @@ function getModeActionLabel(mode: BatchMode, count: number) { switch (mode) { case "scene-fission": return `开始裂变 (${count} 张)`; + case "multi-product-scene": + return `开始多产品替换 (${count} 张)`; case "random-bg": return `开始换背景 (${count} 张)`; case "rmbg": @@ -131,6 +134,10 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam const [productName, setProductName] = useState(initialProductName || ""); const [sceneItems, setSceneItems] = useState([]); + const [multiProductItems, setMultiProductItems] = useState([]); + const [referenceSceneImage, setReferenceSceneImage] = useState(null); + const [referenceTargetName, setReferenceTargetName] = useState(""); + const [referenceCustomPrompt, setReferenceCustomPrompt] = useState(""); const [randomItems, setRandomItems] = useState([]); const [randomCount, setRandomCount] = useState(4); const [randomPrompt, setRandomPrompt] = useState(""); @@ -139,20 +146,24 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam const productInputRef = useRef(null); const sceneInputRef = useRef(null); + const multiProductInputRef = useRef(null); + const referenceSceneInputRef = useRef(null); const batchInputRef = useRef(null); const productPreviewObjectUrlRef = useRef(null); const resultItems = useMemo(() => { if (mode === "scene-fission") return sceneItems; + if (mode === "multi-product-scene") return multiProductItems; if (mode === "random-bg") return randomItems; return batchItems; - }, [batchItems, mode, randomItems, sceneItems]); + }, [batchItems, mode, multiProductItems, randomItems, sceneItems]); const modeCanStart = useMemo(() => { if (mode === "scene-fission") return Boolean(productImage) && sceneItems.length > 0; + if (mode === "multi-product-scene") return multiProductItems.length > 0 && Boolean(referenceSceneImage); if (mode === "random-bg") return Boolean(productImage) && randomCount > 0; return batchItems.length > 0; - }, [batchItems.length, mode, productImage, randomCount, sceneItems.length]); + }, [batchItems.length, mode, multiProductItems.length, productImage, randomCount, referenceSceneImage, sceneItems.length]); const updateProductPreview = (previewUrl: string | null, isObjectUrl = false) => { if (productPreviewObjectUrlRef.current) { @@ -244,6 +255,54 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam e.target.value = ""; }; + const handleMultiProductUpload = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + if (multiProductItems.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; + setMultiProductItems((prev) => [ + ...prev, + { + id: Math.random().toString(36).slice(2, 10), + image, + status: "pending", + }, + ]); + }; + reader.readAsDataURL(file); + }); + + e.target.value = ""; + }; + + const handleReferenceSceneUpload = (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 = (event) => { + const image = event.target?.result as string; + setReferenceSceneImage(image); + }; + reader.readAsDataURL(file); + e.target.value = ""; + }; + const handleBatchUpload = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length === 0) return; @@ -283,6 +342,10 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam setSceneItems((prev) => prev.filter((item) => item.id !== id)); }; + const removeMultiProductItem = (id: string) => { + setMultiProductItems((prev) => prev.filter((item) => item.id !== id)); + }; + const removeBatchItem = (id: string) => { setBatchItems((prev) => prev.filter((item) => item.id !== id)); }; @@ -431,6 +494,180 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam } }; + const runSingleSceneFusionRequest = async (params: { + productImageInput: string; + sceneImageInput: string; + requestId: string; + targetName?: string; + customPrompt?: string; + }) => { + const response = await fetch("/api/scene-fusion", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + productImage: params.productImageInput, + productName: productName || undefined, + aspectRatio, + imageSize, + scenes: [ + { + id: params.requestId, + image: params.sceneImageInput, + targetName: params.targetName || undefined, + customPrompt: params.customPrompt || undefined, + }, + ], + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.error || "处理失败"); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("读取流式响应失败"); + } + + const decoder = new TextDecoder(); + let buffer = ""; + + 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; + const msg = JSON.parse(line) as { + type: string; + data?: { + id: string; + status: "completed" | "failed"; + result?: string; + originalUrl?: string; + error?: string; + }; + error?: string; + }; + + if (msg.type === "result" && msg.data?.id === params.requestId) { + if (msg.data.status === "completed") { + return { + result: msg.data.result || params.sceneImageInput, + originalUrl: msg.data.originalUrl || msg.data.result || params.sceneImageInput, + }; + } + throw new Error(msg.data.error || "生成失败"); + } + + if (msg.type === "error") { + throw new Error(msg.error || "处理失败"); + } + } + } + + throw new Error("未收到生成结果"); + }; + + const runMultiProductSceneMode = async () => { + if (multiProductItems.length === 0) { + toast.error("请先上传产品图"); + return; + } + if (!referenceSceneImage) { + toast.error("请先上传参考图"); + return; + } + if (config?.enabled === false) { + toast.error("批量处理功能已被管理员禁用"); + return; + } + + let itemsToProcess = multiProductItems.filter((item) => item.status === "pending" || item.status === "failed"); + if (itemsToProcess.length === 0) { + itemsToProcess = multiProductItems; + } + + if (itemsToProcess.length > MAX_BATCH_ITEMS) { + toast.error(`单次最多处理 ${MAX_BATCH_ITEMS} 张产品图`); + return; + } + + setIsProcessing(true); + setMultiProductItems((prev) => + prev.map((item) => + itemsToProcess.some((target) => target.id === item.id) + ? { ...item, status: "processing", error: undefined } + : item + ) + ); + + const queue = [...itemsToProcess]; + const workerCount = Math.max(1, Math.min(2, 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; + const requestId = `multi-product-${current.id}-${Date.now()}`; + + try { + const result = await runSingleSceneFusionRequest({ + productImageInput: current.image, + sceneImageInput: referenceSceneImage, + requestId, + targetName: referenceTargetName || undefined, + customPrompt: referenceCustomPrompt || undefined, + }); + successCount += 1; + setMultiProductItems((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; + setMultiProductItems((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 buildRandomItems = () => { const count = Math.max(1, Math.min(MAX_BATCH_ITEMS, randomCount)); const stylePool = [...randomBackgroundStyles].sort(() => Math.random() - 0.5); @@ -626,6 +863,11 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam return; } + if (mode === "multi-product-scene") { + await runMultiProductSceneMode(); + return; + } + await runBatchImageMode(); }; @@ -659,6 +901,20 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam return; } + if (mode === "multi-product-scene") { + if (!referenceSceneImage) { + toast.error("请先上传参考图"); + return; + } + setMultiProductItems((prev) => + prev.map((productItem) => + productItem.id === item.id ? { ...productItem, status: "pending", error: undefined } : productItem + ) + ); + await runMultiProductSceneMode(); + return; + } + setBatchItems((prev) => prev.map((batchItem) => batchItem.id === item.id ? { ...batchItem, status: "pending", error: undefined } : batchItem @@ -754,109 +1010,109 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam {(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) => ( - - ))} +
!(productImagePreview || productImage) && productInputRef.current?.click()} + > + + {productImagePreview || productImage ? ( +
+ 主图
-
-
- -
- {imageSizeOptions.map((option) => ( - - ))} + ) : ( +
+ + 点击上传主图
+ )} +
+ + {(productImagePreview || productImage) && ( + setProductName(e.target.value)} + className="h-9 text-sm" + disabled={isProcessing} + /> + )} +
+ )} + + {(mode === "scene-fission" || mode === "random-bg" || mode === "multi-product-scene") && ( +
+ +
+ +
+ {aspectRatioOptions.map((option) => ( + + ))}
- +
+ +
+ {imageSizeOptions.map((option) => ( + + ))} +
+
+
)} {mode === "scene-fission" && ( @@ -937,6 +1193,134 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
)} + {mode === "multi-product-scene" && ( +
+
+
+ + + +
+ {multiProductItems.length === 0 ? ( +
multiProductInputRef.current?.click()} + > + +

点击上传产品图(可多张)

+

最多 {MAX_BATCH_ITEMS} 张

+
+ ) : ( +
+ {multiProductItems.map((item, index) => ( +
+
+
+ {`产品 +
+
+
产品 {index + 1}
+
+ 状态:{item.status === "pending" ? "等待" : item.status === "processing" ? "处理中" : item.status === "completed" ? "完成" : "失败"} +
+ {item.error &&
{item.error}
} +
+ {!isProcessing && ( + + )} +
+
+ ))} +
+ )} +
+ +
+
+ + {referenceSceneImage && ( + + )} +
+
!referenceSceneImage && referenceSceneInputRef.current?.click()} + > + + {referenceSceneImage ? ( +
+ 参考图 +
+ ) : ( +
+ + 点击上传参考图 +
+ )} +
+ + setReferenceTargetName(e.target.value)} + className="h-8 text-xs" + disabled={isProcessing} + /> + setReferenceCustomPrompt(e.target.value)} + className="h-8 text-xs" + disabled={isProcessing} + /> +
+
+ )} + {mode === "random-bg" && (