1885 lines
71 KiB
TypeScript
1885 lines
71 KiB
TypeScript
"use client";
|
||
|
||
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,
|
||
Eraser,
|
||
X,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||
import { cn } from "@/lib/utils";
|
||
import {
|
||
SCENE_FUSION_ASPECT_RATIO_OPTIONS,
|
||
SCENE_FUSION_IMAGE_SIZE_OPTIONS,
|
||
type SceneFusionCapabilitySummary,
|
||
} from "@/lib/scene-fusion/output-capabilities";
|
||
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";
|
||
|
||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||
const MAX_BATCH_ITEMS = 10;
|
||
const BATCH_REQUEST_STAGGER_MS = 700;
|
||
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<void>;
|
||
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<BatchMode>("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<string | null>(null);
|
||
const [productImagePreview, setProductImagePreview] = useState<string | null>(null);
|
||
const [productName, setProductName] = useState(initialProductName || "");
|
||
|
||
const [sceneItems, setSceneItems] = useState<ProcessItem[]>([]);
|
||
const [multiProductItems, setMultiProductItems] = useState<ProcessItem[]>([]);
|
||
const [referenceSceneImage, setReferenceSceneImage] = useState<string | null>(null);
|
||
const [referenceTargetName, setReferenceTargetName] = useState("");
|
||
const [referenceCustomPrompt, setReferenceCustomPrompt] = useState("");
|
||
const [randomItems, setRandomItems] = useState<ProcessItem[]>([]);
|
||
const [randomCount, setRandomCount] = useState<number>(4);
|
||
const [randomPrompt, setRandomPrompt] = useState("");
|
||
|
||
const [batchItems, setBatchItems] = useState<ProcessItem[]>([]);
|
||
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
|
||
const [previewImageTitle, setPreviewImageTitle] = useState<string>("处理结果预览");
|
||
|
||
const productInputRef = useRef<HTMLInputElement>(null);
|
||
const sceneInputRef = useRef<HTMLInputElement>(null);
|
||
const multiProductInputRef = useRef<HTMLInputElement>(null);
|
||
const referenceSceneInputRef = useRef<HTMLInputElement>(null);
|
||
const batchInputRef = useRef<HTMLInputElement>(null);
|
||
const productPreviewObjectUrlRef = useRef<string | null>(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<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
if (file.size > 50 * 1024 * 1024) {
|
||
toast.error("图片大小不能超过 50MB");
|
||
return;
|
||
}
|
||
|
||
setProductImage(null);
|
||
const previewUrl = URL.createObjectURL(file);
|
||
updateProductPreview(previewUrl, true);
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = (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<HTMLInputElement>) => {
|
||
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<HTMLInputElement>) => {
|
||
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<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 = (event) => {
|
||
const image = event.target?.result as string;
|
||
setReferenceSceneImage(image);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
e.target.value = "";
|
||
};
|
||
|
||
const handleBatchUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
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<ProcessItem>) => {
|
||
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<React.SetStateAction<ProcessItem[]>>,
|
||
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<React.SetStateAction<ProcessItem[]>>
|
||
) => {
|
||
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(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
|
||
)
|
||
);
|
||
}
|
||
|
||
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(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
|
||
)
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="flex h-full min-h-0 w-full flex-col bg-white dark:bg-zinc-950">
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
|
||
<div className="w-full md:w-[420px] border-r flex min-h-0 flex-col bg-white dark:bg-zinc-950 shrink-0">
|
||
<div className="border-b h-16 px-4 shrink-0 bg-white dark:bg-zinc-950 flex items-center">
|
||
<div className="flex items-center gap-2">
|
||
<Layers className="w-4 h-4 text-primary" />
|
||
<span className="font-medium">批量处理</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain px-4 py-4">
|
||
<div className="space-y-6">
|
||
<div className="space-y-2">
|
||
<Label className="text-base font-medium">处理模式</Label>
|
||
<Select value={mode} onValueChange={(value) => setMode(value as BatchMode)} disabled={isProcessing}>
|
||
<SelectTrigger className="h-10 bg-white dark:bg-zinc-950">
|
||
<span className="truncate">
|
||
{selectedModeOption?.label || "请选择处理模式"}
|
||
</span>
|
||
</SelectTrigger>
|
||
<SelectContent className="z-[30000] !bg-white !dark:bg-zinc-950">
|
||
{modeOptions.map((option) => (
|
||
<SelectItem key={option.id} value={option.id} className="items-start py-2">
|
||
<div className="flex flex-col">
|
||
<span className="text-sm">{option.label}</span>
|
||
<span className="text-xs text-muted-foreground">{option.description}</span>
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-xs text-muted-foreground">单次处理硬限制:最多 {MAX_BATCH_ITEMS} 张图片。</p>
|
||
</div>
|
||
|
||
{(mode === "scene-fission" || mode === "random-bg") && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-base font-medium">主图 A (必须)</Label>
|
||
{(productImagePreview || productImage) && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 text-xs text-muted-foreground hover:text-destructive"
|
||
onClick={clearProductImage}
|
||
>
|
||
清除
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
"border-2 border-dashed rounded-lg p-4 transition-colors text-center cursor-pointer relative group",
|
||
productImagePreview || productImage
|
||
? "border-primary/50 bg-primary/5"
|
||
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
|
||
)}
|
||
onClick={() => !(productImagePreview || productImage) && productInputRef.current?.click()}
|
||
>
|
||
<input
|
||
type="file"
|
||
ref={productInputRef}
|
||
className="hidden"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={handleProductUpload}
|
||
/>
|
||
{productImagePreview || productImage ? (
|
||
<div className="relative aspect-square w-full max-w-[200px] mx-auto">
|
||
<img
|
||
src={productImagePreview || productImage || ""}
|
||
alt="主图"
|
||
className="w-full h-full object-contain rounded-md"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="py-8 flex flex-col items-center gap-2 text-muted-foreground">
|
||
<Upload className="w-8 h-8 opacity-50" />
|
||
<span className="text-sm">点击上传主图</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{(productImagePreview || productImage) && (
|
||
<Input
|
||
placeholder="主图名称(如:沙发、椅子)"
|
||
value={productName}
|
||
onChange={(e) => setProductName(e.target.value)}
|
||
className="h-9 text-sm"
|
||
disabled={isProcessing}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{showOutputSettings && (
|
||
<div className="space-y-3">
|
||
<div className="space-y-1">
|
||
<Label className="text-base font-medium">输出设置</Label>
|
||
{outputCapabilities?.strategyLabel ? (
|
||
<p className="text-xs text-muted-foreground">{outputCapabilities.strategyLabel}</p>
|
||
) : null}
|
||
{outputCapabilities?.note ? (
|
||
<p className="text-xs text-muted-foreground">{outputCapabilities.note}</p>
|
||
) : null}
|
||
</div>
|
||
|
||
{showAspectRatioSetting ? (
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs text-muted-foreground">图片比例</label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{availableAspectRatioOptions.map((option) => (
|
||
<button
|
||
key={option.value}
|
||
onClick={() => setAspectRatio(option.value)}
|
||
disabled={isProcessing}
|
||
className={cn(
|
||
"px-2.5 py-1 text-xs rounded-md border transition-colors",
|
||
aspectRatio === option.value
|
||
? "bg-primary text-primary-foreground border-primary"
|
||
: "bg-background hover:bg-muted border-input"
|
||
)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1">
|
||
<label className="text-xs text-muted-foreground">图片比例</label>
|
||
<p className="text-xs text-muted-foreground">
|
||
当前模型链路不支持自定义图片比例,将按模型默认比例生成。
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{showImageSizeSetting ? (
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs text-muted-foreground">输出分辨率</label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{availableImageSizeOptions.map((option) => (
|
||
<button
|
||
key={option.value}
|
||
onClick={() => setImageSize(option.value)}
|
||
disabled={isProcessing}
|
||
className={cn(
|
||
"px-2.5 py-1 text-xs rounded-md border transition-colors",
|
||
imageSize === option.value
|
||
? "bg-primary text-primary-foreground border-primary"
|
||
: "bg-background hover:bg-muted border-input"
|
||
)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1">
|
||
<label className="text-xs text-muted-foreground">输出分辨率</label>
|
||
<p className="text-xs text-muted-foreground">
|
||
当前模型链路不支持自定义分辨率,将按模型默认清晰度生成。
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{mode === "scene-fission" && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-base font-medium">
|
||
场景参考图 ({sceneItems.length}/{maxScenes})
|
||
</Label>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-7 text-xs gap-1"
|
||
onClick={() => sceneInputRef.current?.click()}
|
||
disabled={sceneItems.length >= maxScenes || isProcessing}
|
||
>
|
||
<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">
|
||
{sceneItems.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>
|
||
)}
|
||
{sceneItems.map((item, index) => (
|
||
<div key={item.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={item.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={() => removeSceneItem(item.id)}
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<Input
|
||
placeholder="被替换物体(如:椅子、桌子)"
|
||
value={item.targetName || ""}
|
||
onChange={(e) => updateSceneItem(item.id, { targetName: e.target.value })}
|
||
className="h-7 text-xs"
|
||
disabled={isProcessing}
|
||
/>
|
||
<Input
|
||
placeholder="修改要求(可选)"
|
||
value={item.customPrompt || ""}
|
||
onChange={(e) => updateSceneItem(item.id, { customPrompt: e.target.value })}
|
||
className="h-7 text-xs"
|
||
disabled={isProcessing}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{mode === "multi-product-scene" && (
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-base font-medium">产品图列表 ({multiProductItems.length}/{MAX_BATCH_ITEMS})</Label>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-7 text-xs gap-1"
|
||
onClick={() => multiProductInputRef.current?.click()}
|
||
disabled={multiProductItems.length >= MAX_BATCH_ITEMS || isProcessing}
|
||
>
|
||
<Plus className="w-3 h-3" /> 批量添加
|
||
</Button>
|
||
<input
|
||
type="file"
|
||
ref={multiProductInputRef}
|
||
className="hidden"
|
||
multiple
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={handleMultiProductUpload}
|
||
/>
|
||
</div>
|
||
{multiProductItems.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={() => multiProductInputRef.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">最多 {MAX_BATCH_ITEMS} 张</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{multiProductItems.map((item, index) => (
|
||
<div key={item.id} className="border rounded-lg p-3 bg-background relative group">
|
||
<div className="flex gap-3 items-center">
|
||
<div className="w-20 h-20 shrink-0 bg-muted rounded-md overflow-hidden border">
|
||
<img src={item.image} alt={`产品 ${index + 1}`} className="w-full h-full object-cover" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="text-xs font-medium text-muted-foreground">产品 {index + 1}</div>
|
||
<div className="text-xs text-muted-foreground mt-1">
|
||
状态:{item.status === "pending" ? "等待" : item.status === "processing" ? "处理中" : item.status === "completed" ? "完成" : "失败"}
|
||
</div>
|
||
{item.error && <div className="text-xs text-destructive mt-1 line-clamp-2">{item.error}</div>}
|
||
</div>
|
||
{!isProcessing && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||
onClick={() => removeMultiProductItem(item.id)}
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-base font-medium">参考图 B(统一替换目标)</Label>
|
||
{referenceSceneImage && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-6 text-xs text-muted-foreground hover:text-destructive"
|
||
onClick={() => setReferenceSceneImage(null)}
|
||
>
|
||
清除
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"border-2 border-dashed rounded-lg p-4 transition-colors text-center cursor-pointer relative group",
|
||
referenceSceneImage
|
||
? "border-primary/50 bg-primary/5"
|
||
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
|
||
)}
|
||
onClick={() => !referenceSceneImage && referenceSceneInputRef.current?.click()}
|
||
>
|
||
<input
|
||
type="file"
|
||
ref={referenceSceneInputRef}
|
||
className="hidden"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={handleReferenceSceneUpload}
|
||
/>
|
||
{referenceSceneImage ? (
|
||
<div className="relative aspect-square w-full max-w-[220px] mx-auto">
|
||
<img
|
||
src={referenceSceneImage}
|
||
alt="参考图"
|
||
className="w-full h-full object-cover rounded-md"
|
||
/>
|
||
</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>
|
||
|
||
<Input
|
||
placeholder="被替换物体(可选,如:椅子、花瓶)"
|
||
value={referenceTargetName}
|
||
onChange={(e) => setReferenceTargetName(e.target.value)}
|
||
className="h-8 text-xs"
|
||
disabled={isProcessing}
|
||
/>
|
||
<Input
|
||
placeholder="补充要求(可选)"
|
||
value={referenceCustomPrompt}
|
||
onChange={(e) => setReferenceCustomPrompt(e.target.value)}
|
||
className="h-8 text-xs"
|
||
disabled={isProcessing}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{mode === "random-bg" && (
|
||
<div className="space-y-3">
|
||
<Label className="text-base font-medium">随机背景参数</Label>
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs text-muted-foreground">生成张数(最多10)</label>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={MAX_BATCH_ITEMS}
|
||
value={randomCount}
|
||
disabled={isProcessing}
|
||
onChange={(e) => setRandomCount(Math.max(1, Math.min(MAX_BATCH_ITEMS, Number(e.target.value) || 1)))}
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs text-muted-foreground">补充要求(可选)</label>
|
||
<Input
|
||
value={randomPrompt}
|
||
onChange={(e) => setRandomPrompt(e.target.value)}
|
||
disabled={isProcessing}
|
||
placeholder="例如:暖色调、真实摄影感、商业海报风"
|
||
className="h-9 text-sm"
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
使用与“裂变场景”同一套生图模型池,自动随机背景风格生成。
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{(mode === "rmbg" || mode === "enhance" || mode === "watermark") && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-base font-medium">
|
||
待处理图片 ({batchItems.length}/{MAX_BATCH_ITEMS})
|
||
</Label>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-7 text-xs gap-1"
|
||
onClick={() => batchInputRef.current?.click()}
|
||
disabled={batchItems.length >= MAX_BATCH_ITEMS || isProcessing}
|
||
>
|
||
<Plus className="w-3 h-3" /> 批量添加
|
||
</Button>
|
||
<input
|
||
type="file"
|
||
ref={batchInputRef}
|
||
className="hidden"
|
||
multiple
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={handleBatchUpload}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{batchItems.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={() => batchInputRef.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">最多 {MAX_BATCH_ITEMS} 张</p>
|
||
</div>
|
||
)}
|
||
|
||
{batchItems.map((item, index) => (
|
||
<div key={item.id} className="border rounded-lg p-3 bg-background relative group">
|
||
<div className="flex gap-3 items-center">
|
||
<div className="w-20 h-20 shrink-0 bg-muted rounded-md overflow-hidden border">
|
||
<img src={item.image} alt={`图片 ${index + 1}`} className="w-full h-full object-cover" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="text-xs font-medium text-muted-foreground">图片 {index + 1}</div>
|
||
<div className="text-xs text-muted-foreground mt-1">
|
||
状态:{item.status === "pending" ? "等待" : item.status === "processing" ? "处理中" : item.status === "completed" ? "完成" : "失败"}
|
||
</div>
|
||
{item.error && <div className="text-xs text-destructive mt-1 line-clamp-2">{item.error}</div>}
|
||
</div>
|
||
{!isProcessing && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||
onClick={() => removeBatchItem(item.id)}
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4 border-t bg-background shrink-0">
|
||
<Button
|
||
className="w-full h-12 text-base font-semibold shadow-sm bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100"
|
||
size="lg"
|
||
onClick={startProcessing}
|
||
disabled={isProcessing || !modeCanStart}
|
||
>
|
||
{isProcessing ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
处理中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Sparkles className="w-4 h-4 mr-2" />
|
||
{getModeActionLabel(mode, mode === "random-bg" ? randomCount : resultItems.length)}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex h-full min-h-0 flex-1 flex-col bg-white dark:bg-zinc-950">
|
||
<div className="h-16 px-4 pr-14 border-b flex items-center justify-between bg-white dark:bg-zinc-950 shrink-0">
|
||
<h3 className="font-medium">处理结果</h3>
|
||
{(completedResultItems.length > 0 || onBatchAddToCanvas) && (
|
||
<div className="flex items-center gap-2">
|
||
{onBatchAddToCanvas && completedResultItems.length > 0 && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleBatchAddToCanvas}
|
||
disabled={!resultAllFinished}
|
||
title={resultAllFinished ? "将处理结果批量添加到画布" : "请等待所有任务处理完成"}
|
||
>
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
批量添加到画布
|
||
</Button>
|
||
)}
|
||
{completedResultItems.length > 0 && (
|
||
<Button variant="outline" size="sm" onClick={downloadAll}>
|
||
<Download className="w-4 h-4 mr-2" />
|
||
全部下载
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<ScrollArea className="flex-1 min-h-0">
|
||
<div className="p-4">
|
||
{resultItems.length === 0 ? (
|
||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-50 min-h-[300px]">
|
||
<Wand2 className="w-16 h-16 mb-4" />
|
||
<p>选择模式并上传图片后,结果会显示在这里</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
|
||
{resultItems.map((item, index) => (
|
||
<div key={item.id} className="border rounded-lg overflow-hidden bg-white dark:bg-zinc-950 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]",
|
||
item.status === "completed" && "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||
item.status === "failed" && "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||
item.status === "processing" &&
|
||
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||
item.status === "pending" && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||
)}
|
||
>
|
||
{item.status === "completed"
|
||
? "完成"
|
||
: item.status === "failed"
|
||
? "失败"
|
||
: item.status === "processing"
|
||
? "处理中"
|
||
: "等待"}
|
||
</span>
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
"aspect-[3/4] bg-muted/20 relative group",
|
||
item.status === "completed" && (item.result || item.originalUrl) && "cursor-zoom-in"
|
||
)}
|
||
onClick={() => {
|
||
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 ? (
|
||
<img src={item.result} alt="处理结果" className="w-full h-full object-cover" />
|
||
) : (
|
||
<div className="w-full h-full flex flex-col items-center justify-center p-0 relative overflow-hidden">
|
||
<img src={item.image} alt="原图" className="absolute inset-0 w-full h-full object-cover opacity-35" />
|
||
{item.status === "processing" ? (
|
||
<div className="relative z-10 flex flex-col items-center gap-2">
|
||
<Loader2 className="w-7 h-7 animate-spin text-primary" />
|
||
<span className="text-xs text-white/95 bg-black/50 px-2 py-1 rounded">处理中...</span>
|
||
</div>
|
||
) : item.status === "failed" ? (
|
||
<div className="relative z-10 text-center px-3">
|
||
<p className="text-xs text-white bg-black/55 px-2 py-1 rounded line-clamp-2">
|
||
{item.error || "处理失败"}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<span className="relative z-10 text-xs font-medium bg-black/50 text-white px-2 py-1 rounded">
|
||
等待处理
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{item.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={(event) => {
|
||
event.stopPropagation();
|
||
downloadImage(item.originalUrl || item.result!, `batch-${index + 1}.png`);
|
||
}}
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="secondary"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
retrySingleItem(item);
|
||
}}
|
||
>
|
||
<RefreshCw className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
</div>
|
||
{previewImageUrl && (
|
||
<div
|
||
className="fixed inset-0 z-[31000] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||
onClick={closeResultPreview}
|
||
>
|
||
<div
|
||
className="relative w-full max-w-6xl max-h-[90vh] bg-background rounded-lg border shadow-xl overflow-hidden"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||
<span className="text-sm font-medium">{previewImageTitle}</span>
|
||
<Button size="icon" variant="ghost" onClick={closeResultPreview}>
|
||
<X className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
<div className="flex items-center justify-center bg-black/5 p-3">
|
||
<img
|
||
src={previewImageUrl}
|
||
alt={previewImageTitle}
|
||
className="max-h-[calc(90vh-72px)] w-auto max-w-full object-contain rounded"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|