"use client"; import { Download, Eraser, Image as ImageIcon, Layers, Loader2, Plus, RefreshCw, Sparkles, Trash2, Upload, Wand2, X, } from "lucide-react"; import type React from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; 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 { Select, SelectContent, SelectItem, SelectTrigger, } from "@/components/ui/select"; import { requestLongTaskResult } from "@/lib/client/long-task"; import { SCENE_FUSION_ASPECT_RATIO_OPTIONS, SCENE_FUSION_IMAGE_SIZE_OPTIONS, type SceneFusionCapabilitySummary, } from "@/lib/scene-fusion/output-capabilities"; import { cn } from "@/lib/utils"; const fetcher = (url: string) => fetch(url).then((res) => res.json()); const MAX_BATCH_ITEMS = 10; const BATCH_REQUEST_STAGGER_MS = 700; const BATCH_SERIAL_WORKER_COUNT = 1; const MULTI_PRODUCT_FAILOVER_MAX_ROUNDS = 5; const MULTI_PRODUCT_FAILOVER_RETRY_DELAY_MS = 1500; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); type BatchMode = | "scene-fission" | "multi-product-scene" | "random-bg" | "rmbg" | "enhance" | "watermark"; type ItemStatus = "pending" | "processing" | "completed" | "failed"; interface ProcessItem { id: string; image: string; status: ItemStatus; result?: string; originalUrl?: string; error?: string; targetName?: string; customPrompt?: string; } interface SceneFusionViewProps { initialProductImage?: string | null; initialProductName?: string; onBatchAddToCanvas?: (imageUrls: string[]) => void | Promise; onBack?: () => void; } 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, }, { id: "watermark", label: "批量去水印", description: "多图同步去除水印", icon: RefreshCw, }, ]; 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": 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({ initialProductImage, initialProductName, onBatchAddToCanvas, }: SceneFusionViewProps) { const { data: configData } = useSWR("/api/scene-fusion/config", fetcher); const config = configData as | { enabled?: boolean; defaultPrompt?: string; maxScenes?: number; outputCapabilities?: SceneFusionCapabilitySummary; } | undefined; const maxScenes = Math.min( config?.maxScenes ?? MAX_BATCH_ITEMS, MAX_BATCH_ITEMS ); const outputCapabilities = config?.outputCapabilities; const availableAspectRatioOptions = useMemo( () => SCENE_FUSION_ASPECT_RATIO_OPTIONS.filter((option) => outputCapabilities?.aspectRatio?.enabled === false ? false : !outputCapabilities?.aspectRatio?.options?.length || outputCapabilities.aspectRatio.options.includes(option.value) ), [ outputCapabilities?.aspectRatio?.enabled, outputCapabilities?.aspectRatio?.options, ] ); const availableImageSizeOptions = useMemo( () => SCENE_FUSION_IMAGE_SIZE_OPTIONS.filter((option) => outputCapabilities?.imageSize?.enabled === false ? false : !outputCapabilities?.imageSize?.options?.length || outputCapabilities.imageSize.options.includes(option.value) ), [ outputCapabilities?.imageSize?.enabled, outputCapabilities?.imageSize?.options, ] ); const showAspectRatioSetting = availableAspectRatioOptions.length > 0; const showImageSizeSetting = availableImageSizeOptions.length > 0; const [mode, setMode] = useState("scene-fission"); const [isProcessing, setIsProcessing] = useState(false); const [aspectRatio, setAspectRatio] = useState("auto"); const [imageSize, setImageSize] = useState("2K"); const showOutputSettings = mode === "scene-fission" || mode === "random-bg" || mode === "multi-product-scene"; const [productImage, setProductImage] = useState(null); const [productImagePreview, setProductImagePreview] = useState( null ); 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(""); const [batchItems, setBatchItems] = useState([]); const [previewImageUrl, setPreviewImageUrl] = useState(null); const [previewImageTitle, setPreviewImageTitle] = useState("处理结果预览"); 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, 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, multiProductItems.length, productImage, randomCount, referenceSceneImage, sceneItems.length, ]); const selectedModeOption = useMemo( () => modeOptions.find((option) => option.id === mode), [mode] ); const completedResultItems = useMemo( () => resultItems.filter( (item) => item.status === "completed" && (item.originalUrl || item.result) ), [resultItems] ); const resultAllFinished = useMemo( () => resultItems.length > 0 && resultItems.every( (item) => item.status !== "processing" && item.status !== "pending" ), [resultItems] ); const sceneFusionOutputPayload = useMemo( () => ({ aspectRatio: showAspectRatioSetting ? aspectRatio : undefined, imageSize: showImageSizeSetting ? imageSize : undefined, }), [aspectRatio, imageSize, showAspectRatioSetting, showImageSizeSetting] ); 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); } }; }, []); useEffect(() => { if (showAspectRatioSetting) { const nextAspectRatio = availableAspectRatioOptions.find( (option) => option.value === aspectRatio )?.value || outputCapabilities?.aspectRatio?.defaultValue || availableAspectRatioOptions[0]?.value || "auto"; if (nextAspectRatio !== aspectRatio) { setAspectRatio(nextAspectRatio); } return; } if (aspectRatio !== "auto") { setAspectRatio("auto"); } }, [ aspectRatio, availableAspectRatioOptions, outputCapabilities?.aspectRatio?.defaultValue, showAspectRatioSetting, ]); useEffect(() => { if (showImageSizeSetting) { const nextImageSize = availableImageSizeOptions.find((option) => option.value === imageSize) ?.value || outputCapabilities?.imageSize?.defaultValue || availableImageSizeOptions[0]?.value || "2K"; if (nextImageSize !== imageSize) { setImageSize(nextImageSize); } return; } if (imageSize !== "2K") { setImageSize("2K"); } }, [ availableImageSizeOptions, imageSize, outputCapabilities?.imageSize?.defaultValue, showImageSizeSetting, ]); const handleProductUpload = (e: React.ChangeEvent) => { 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 = (event) => { const result = event.target?.result; if (typeof result === "string") { setProductImage(result); } else { toast.error("图片读取失败,请重试"); } }; reader.onerror = () => toast.error("图片读取失败,请重试"); reader.readAsDataURL(file); e.target.value = ""; }; const handleSceneUpload = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length === 0) return; if (sceneItems.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 = (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); }); 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; 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 updateSceneItem = (id: string, patch: Partial) => { setSceneItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)) ); }; const removeSceneItem = (id: string) => { 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)); }; 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; } setIsProcessing(true); updateFusionItemsStatus( setter, scenesPayload.map((item) => item.id), "processing" ); try { const response = await fetch("/api/scene-fusion", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ productImage, productName: productName || undefined, sceneMode: mode, ...sceneFusionOutputPayload, scenes: scenesPayload, }), }); 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 = ""; let success = 0; let failed = 0; let streamErrorMessage: string | null = null; const handleParsedMessage = (msg: { type: string; data?: { id: string; status: "completed" | "failed"; result?: string; originalUrl?: string; error?: string; productName?: string; productSource?: "user" | "agent" | "none"; sceneTargets?: Array<{ id: string; targetName?: string; source?: "user" | "agent"; confidence?: number; model?: string; }>; }; error?: string; }) => { if (msg.type === "agent_meta" && msg.data) { const detectedProductName = typeof msg.data.productName === "string" ? msg.data.productName.trim() : ""; if (!productName.trim() && detectedProductName) { setProductName(detectedProductName); } if ( Array.isArray(msg.data.sceneTargets) && msg.data.sceneTargets.length > 0 ) { setter((prev) => prev.map((item) => { const detected = msg.data?.sceneTargets?.find( (entry) => entry.id === item.id ); if (!detected?.targetName) return item; const currentTargetName = (item.targetName || "").trim(); if (currentTargetName) return item; return { ...item, targetName: detected.targetName, }; }) ); } return; } if (msg.type === "result" && msg.data) { 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 ) ); return; } failed += 1; setter((prev) => prev.map((item) => item.id === data.id ? { ...item, status: "failed", error: data.error || "生成失败", } : item ) ); return; } if (msg.type === "error") { streamErrorMessage = msg.error || "处理失败"; } }; 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 msg = JSON.parse(line) as { type: string; data?: { id: string; status: "completed" | "failed"; result?: string; originalUrl?: string; error?: string; productName?: string; productSource?: "user" | "agent" | "none"; sceneTargets?: Array<{ id: string; targetName?: string; source?: "user" | "agent"; confidence?: number; model?: string; }>; }; error?: string; }; handleParsedMessage(msg); } catch (parseError) { console.warn( "[SceneFusionView] Ignore invalid stream line:", parseError ); } } } if (buffer.trim()) { try { const msg = JSON.parse(buffer.trim()) as { type: string; data?: { id: string; status: "completed" | "failed"; result?: string; originalUrl?: string; error?: string; productName?: string; productSource?: "user" | "agent" | "none"; sceneTargets?: Array<{ id: string; targetName?: string; source?: "user" | "agent"; confidence?: number; model?: string; }>; }; error?: string; }; handleParsedMessage(msg); } catch (parseError) { console.warn( "[SceneFusionView] Ignore trailing stream buffer:", parseError ); } } if (streamErrorMessage) { setter((prev) => prev.map((item) => scenesPayload.some((scene) => scene.id === item.id) && item.status === "processing" ? { ...item, status: "failed", error: streamErrorMessage || "处理失败", } : item ) ); toast.warning(streamErrorMessage); } if (success > 0 && failed === 0) { toast.success(`批量处理完成:${success} 张`); } else if (success > 0 || failed > 0) { toast.warning(`批量处理完成:${success} 成功,${failed} 失败`); } } catch (error) { const message = error instanceof Error ? error.message : "处理失败"; toast.error(message); setter((prev) => prev.map((item) => scenesPayload.some((scene) => scene.id === item.id) && item.status === "processing" ? { ...item, status: "failed", error: message } : item ) ); } finally { setIsProcessing(false); } }; const runSingleSceneFusionAttempt = 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, sceneMode: mode, ...sceneFusionOutputPayload, 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 runSingleSceneFusionRequest = async (params: { productImageInput: string; sceneImageInput: string; requestId: string; targetName?: string; customPrompt?: string; }) => { let lastError: Error | null = null; for ( let round = 1; round <= MULTI_PRODUCT_FAILOVER_MAX_ROUNDS; round += 1 ) { try { return await runSingleSceneFusionAttempt({ ...params, requestId: `${params.requestId}-r${round}`, }); } catch (error) { lastError = error instanceof Error ? error : new Error("处理失败"); const isLastRound = round === MULTI_PRODUCT_FAILOVER_MAX_ROUNDS; console.warn( `[SceneFusionView] 多产品替换第 ${round}/${MULTI_PRODUCT_FAILOVER_MAX_ROUNDS} 轮失败,准备故障迁移`, lastError.message ); if (!isLastRound) { await sleep(MULTI_PRODUCT_FAILOVER_RETRY_DELAY_MS); } } } throw new Error( `故障迁移已重试 ${MULTI_PRODUCT_FAILOVER_MAX_ROUNDS} 轮仍失败:${lastError?.message || "处理失败"}` ); }; 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(BATCH_SERIAL_WORKER_COUNT, 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 ) ); } if (queue.length > 0) { await sleep(BATCH_REQUEST_STAGGER_MS); } } }); 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 ); 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), skipGallerySave: false, }), }); 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(BATCH_SERIAL_WORKER_COUNT, 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 ) ); } if (queue.length > 0) { await sleep(BATCH_REQUEST_STAGGER_MS); } } }); 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; } if (mode === "multi-product-scene") { await runMultiProductSceneMode(); 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; } 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 ) ); 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"); link.href = blobUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() => URL.revokeObjectURL(blobUrl), 100); } catch { window.open(url, "_blank"); } }; const downloadAll = async () => { const completed = resultItems.filter( (item) => item.status === "completed" && (item.originalUrl || item.result) ); if (completed.length === 0) { toast.error("没有可下载的图片"); return; } 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(`已下载 ${completed.length} 张图片`); }; const handleBatchAddToCanvas = async () => { if (!onBatchAddToCanvas) return; const imageUrls = completedResultItems .map((item) => item.originalUrl || item.result) .filter((url): url is string => Boolean(url)); if (imageUrls.length === 0) { toast.error("没有可添加到画布的图片"); return; } try { await onBatchAddToCanvas(imageUrls); toast.success(`已批量添加 ${imageUrls.length} 张到画布`); } catch (error) { const message = error instanceof Error ? error.message : "添加到画布失败"; toast.error(message); } }; const openResultPreview = (imageUrl: string, title: string) => { setPreviewImageUrl(imageUrl); setPreviewImageTitle(title); }; const closeResultPreview = () => { setPreviewImageUrl(null); }; return (
批量处理

单次处理硬限制:最多 {MAX_BATCH_ITEMS} 张图片。

{(mode === "scene-fission" || mode === "random-bg") && (
{(productImagePreview || productImage) && ( )}
!(productImagePreview || productImage) && productInputRef.current?.click() } > {productImagePreview || productImage ? (
主图
) : (
点击上传主图
)}
{(productImagePreview || productImage) && ( setProductName(e.target.value)} placeholder="主图名称(如:沙发、椅子)" value={productName} /> )}
)} {showOutputSettings && (
{outputCapabilities?.strategyLabel ? (

{outputCapabilities.strategyLabel}

) : null} {outputCapabilities?.note ? (

{outputCapabilities.note}

) : null}
{showAspectRatioSetting ? (
{availableAspectRatioOptions.map((option) => ( ))}
) : (

当前模型链路不支持自定义图片比例,将按模型默认比例生成。

)} {showImageSizeSetting ? (
{availableImageSizeOptions.map((option) => ( ))}
) : (

当前模型链路不支持自定义分辨率,将按模型默认清晰度生成。

)}
)} {mode === "scene-fission" && (
{sceneItems.length === 0 && (
sceneInputRef.current?.click()} >

点击添加场景参考图

最多 {maxScenes} 张

)} {sceneItems.map((item, index) => (
{`场景
场景 {index + 1} {!isProcessing && ( )}
updateSceneItem(item.id, { targetName: e.target.value, }) } placeholder="被替换物体(如:椅子、桌子)" value={item.targetName || ""} /> updateSceneItem(item.id, { customPrompt: e.target.value, }) } placeholder="修改要求(可选)" value={item.customPrompt || ""} />
))}
)} {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)} placeholder="被替换物体(可选,如:椅子、花瓶)" value={referenceTargetName} /> setReferenceCustomPrompt(e.target.value)} placeholder="补充要求(可选)" value={referenceCustomPrompt} />
)} {mode === "random-bg" && (
setRandomCount( Math.max( 1, Math.min( MAX_BATCH_ITEMS, Number(e.target.value) || 1 ) ) ) } type="number" value={randomCount} />
setRandomPrompt(e.target.value)} placeholder="例如:暖色调、真实摄影感、商业海报风" value={randomPrompt} />

使用与“裂变场景”同一套生图模型池,自动随机背景风格生成。

)} {(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 && ( )}
))}
)}

处理结果

{(completedResultItems.length > 0 || onBatchAddToCanvas) && (
{onBatchAddToCanvas && completedResultItems.length > 0 && ( )} {completedResultItems.length > 0 && ( )}
)}
{resultItems.length === 0 ? (

选择模式并上传图片后,结果会显示在这里

) : (
{resultItems.map((item, index) => (
任务 {index + 1} {item.status === "completed" ? "完成" : item.status === "failed" ? "失败" : item.status === "processing" ? "处理中" : "等待"}
{ const previewUrl = item.originalUrl || item.result; if (item.status === "completed" && previewUrl) { openResultPreview( previewUrl, `任务 ${index + 1} 预览` ); } }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; const previewUrl = item.originalUrl || item.result; if (item.status === "completed" && previewUrl) { event.preventDefault(); openResultPreview( previewUrl, `任务 ${index + 1} 预览` ); } }} role="button" tabIndex={ item.status === "completed" && (item.result || item.originalUrl) ? 0 : -1 } > {item.result ? ( 处理结果 ) : (
原图 {item.status === "processing" ? (
处理中...
) : item.status === "failed" ? (

{item.error || "处理失败"}

) : ( 等待处理 )}
)} {item.status === "completed" && (
)}
))}
)}
{previewImageUrl && (
event.stopPropagation()} >
{previewImageTitle}
{previewImageTitle}
)}
); }