feat: add multi-product-to-single-scene batch replacement mode
This commit is contained in:
parent
8fc52dfc76
commit
879aa932fc
@ -27,7 +27,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||||
const MAX_BATCH_ITEMS = 10;
|
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";
|
type ItemStatus = "pending" | "processing" | "completed" | "failed";
|
||||||
|
|
||||||
interface ProcessItem {
|
interface ProcessItem {
|
||||||
@ -77,6 +77,7 @@ const randomBackgroundStyles = [
|
|||||||
|
|
||||||
const modeOptions: Array<{ id: BatchMode; label: string; description: string; icon: React.ComponentType<{ className?: string }> }> = [
|
const modeOptions: Array<{ id: BatchMode; label: string; description: string; icon: React.ComponentType<{ className?: string }> }> = [
|
||||||
{ id: "scene-fission", label: "裂变场景", description: "1张产品图 + 多张参考图", icon: Layers },
|
{ 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: "random-bg", label: "随机换背景", description: "1张图随机生成多种背景", icon: Wand2 },
|
||||||
{ id: "rmbg", label: "批量抠图", description: "多图同步去背景", icon: Eraser },
|
{ id: "rmbg", label: "批量抠图", description: "多图同步去背景", icon: Eraser },
|
||||||
{ id: "enhance", label: "批量变清晰", description: "多图同步增强画质", icon: Sparkles },
|
{ id: "enhance", label: "批量变清晰", description: "多图同步增强画质", icon: Sparkles },
|
||||||
@ -87,6 +88,8 @@ function getModeActionLabel(mode: BatchMode, count: number) {
|
|||||||
switch (mode) {
|
switch (mode) {
|
||||||
case "scene-fission":
|
case "scene-fission":
|
||||||
return `开始裂变 (${count} 张)`;
|
return `开始裂变 (${count} 张)`;
|
||||||
|
case "multi-product-scene":
|
||||||
|
return `开始多产品替换 (${count} 张)`;
|
||||||
case "random-bg":
|
case "random-bg":
|
||||||
return `开始换背景 (${count} 张)`;
|
return `开始换背景 (${count} 张)`;
|
||||||
case "rmbg":
|
case "rmbg":
|
||||||
@ -131,6 +134,10 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
|
|||||||
const [productName, setProductName] = useState(initialProductName || "");
|
const [productName, setProductName] = useState(initialProductName || "");
|
||||||
|
|
||||||
const [sceneItems, setSceneItems] = useState<ProcessItem[]>([]);
|
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 [randomItems, setRandomItems] = useState<ProcessItem[]>([]);
|
||||||
const [randomCount, setRandomCount] = useState<number>(4);
|
const [randomCount, setRandomCount] = useState<number>(4);
|
||||||
const [randomPrompt, setRandomPrompt] = useState("");
|
const [randomPrompt, setRandomPrompt] = useState("");
|
||||||
@ -139,20 +146,24 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
|
|||||||
|
|
||||||
const productInputRef = useRef<HTMLInputElement>(null);
|
const productInputRef = useRef<HTMLInputElement>(null);
|
||||||
const sceneInputRef = useRef<HTMLInputElement>(null);
|
const sceneInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const multiProductInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const referenceSceneInputRef = useRef<HTMLInputElement>(null);
|
||||||
const batchInputRef = useRef<HTMLInputElement>(null);
|
const batchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const productPreviewObjectUrlRef = useRef<string | null>(null);
|
const productPreviewObjectUrlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const resultItems = useMemo(() => {
|
const resultItems = useMemo(() => {
|
||||||
if (mode === "scene-fission") return sceneItems;
|
if (mode === "scene-fission") return sceneItems;
|
||||||
|
if (mode === "multi-product-scene") return multiProductItems;
|
||||||
if (mode === "random-bg") return randomItems;
|
if (mode === "random-bg") return randomItems;
|
||||||
return batchItems;
|
return batchItems;
|
||||||
}, [batchItems, mode, randomItems, sceneItems]);
|
}, [batchItems, mode, multiProductItems, randomItems, sceneItems]);
|
||||||
|
|
||||||
const modeCanStart = useMemo(() => {
|
const modeCanStart = useMemo(() => {
|
||||||
if (mode === "scene-fission") return Boolean(productImage) && sceneItems.length > 0;
|
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;
|
if (mode === "random-bg") return Boolean(productImage) && randomCount > 0;
|
||||||
return batchItems.length > 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) => {
|
const updateProductPreview = (previewUrl: string | null, isObjectUrl = false) => {
|
||||||
if (productPreviewObjectUrlRef.current) {
|
if (productPreviewObjectUrlRef.current) {
|
||||||
@ -244,6 +255,54 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
|
|||||||
e.target.value = "";
|
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 handleBatchUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
@ -283,6 +342,10 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
|
|||||||
setSceneItems((prev) => prev.filter((item) => item.id !== id));
|
setSceneItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeMultiProductItem = (id: string) => {
|
||||||
|
setMultiProductItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
const removeBatchItem = (id: string) => {
|
const removeBatchItem = (id: string) => {
|
||||||
setBatchItems((prev) => prev.filter((item) => item.id !== id));
|
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 buildRandomItems = () => {
|
||||||
const count = Math.max(1, Math.min(MAX_BATCH_ITEMS, randomCount));
|
const count = Math.max(1, Math.min(MAX_BATCH_ITEMS, randomCount));
|
||||||
const stylePool = [...randomBackgroundStyles].sort(() => Math.random() - 0.5);
|
const stylePool = [...randomBackgroundStyles].sort(() => Math.random() - 0.5);
|
||||||
@ -626,6 +863,11 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === "multi-product-scene") {
|
||||||
|
await runMultiProductSceneMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await runBatchImageMode();
|
await runBatchImageMode();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -659,6 +901,20 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
|
|||||||
return;
|
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) =>
|
setBatchItems((prev) =>
|
||||||
prev.map((batchItem) =>
|
prev.map((batchItem) =>
|
||||||
batchItem.id === item.id ? { ...batchItem, status: "pending", error: undefined } : batchItem
|
batchItem.id === item.id ? { ...batchItem, status: "pending", error: undefined } : batchItem
|
||||||
@ -754,109 +1010,109 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(mode === "scene-fission" || mode === "random-bg") && (
|
{(mode === "scene-fission" || mode === "random-bg") && (
|
||||||
<>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<Label className="text-base font-medium">主图 A (必须)</Label>
|
||||||
<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) && (
|
{(productImagePreview || productImage) && (
|
||||||
<Input
|
<Button
|
||||||
placeholder="主图名称(如:沙发、椅子)"
|
variant="ghost"
|
||||||
value={productName}
|
size="sm"
|
||||||
onChange={(e) => setProductName(e.target.value)}
|
className="h-6 text-xs text-muted-foreground hover:text-destructive"
|
||||||
className="h-9 text-sm"
|
onClick={clearProductImage}
|
||||||
disabled={isProcessing}
|
>
|
||||||
/>
|
清除
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div
|
||||||
<Label className="text-base font-medium">输出设置</Label>
|
className={cn(
|
||||||
<div className="space-y-1.5">
|
"border-2 border-dashed rounded-lg p-4 transition-colors text-center cursor-pointer relative group",
|
||||||
<label className="text-xs text-muted-foreground">图片比例</label>
|
productImagePreview || productImage
|
||||||
<div className="flex flex-wrap gap-1.5">
|
? "border-primary/50 bg-primary/5"
|
||||||
{aspectRatioOptions.map((option) => (
|
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
|
||||||
<button
|
)}
|
||||||
key={option.value}
|
onClick={() => !(productImagePreview || productImage) && productInputRef.current?.click()}
|
||||||
onClick={() => setAspectRatio(option.value)}
|
>
|
||||||
disabled={isProcessing}
|
<input
|
||||||
className={cn(
|
type="file"
|
||||||
"px-2.5 py-1 text-xs rounded-md border transition-colors",
|
ref={productInputRef}
|
||||||
aspectRatio === option.value
|
className="hidden"
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
accept="image/png,image/jpeg,image/webp"
|
||||||
: "bg-background hover:bg-muted border-input"
|
onChange={handleProductUpload}
|
||||||
)}
|
/>
|
||||||
>
|
{productImagePreview || productImage ? (
|
||||||
{option.label}
|
<div className="relative aspect-square w-full max-w-[200px] mx-auto">
|
||||||
</button>
|
<img
|
||||||
))}
|
src={productImagePreview || productImage || ""}
|
||||||
|
alt="主图"
|
||||||
|
className="w-full h-full object-contain rounded-md"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="py-8 flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
<label className="text-xs text-muted-foreground">输出分辨率</label>
|
<Upload className="w-8 h-8 opacity-50" />
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<span className="text-sm">点击上传主图</span>
|
||||||
{imageSizeOptions.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>
|
||||||
|
|
||||||
|
{(productImagePreview || productImage) && (
|
||||||
|
<Input
|
||||||
|
placeholder="主图名称(如:沙发、椅子)"
|
||||||
|
value={productName}
|
||||||
|
onChange={(e) => setProductName(e.target.value)}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(mode === "scene-fission" || mode === "random-bg" || mode === "multi-product-scene") && (
|
||||||
|
<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">图片比例</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{aspectRatioOptions.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>
|
</div>
|
||||||
</>
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">输出分辨率</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{imageSizeOptions.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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === "scene-fission" && (
|
{mode === "scene-fission" && (
|
||||||
@ -937,6 +1193,134 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
|
|||||||
</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" && (
|
{mode === "random-bg" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-base font-medium">随机背景参数</Label>
|
<Label className="text-base font-medium">随机背景参数</Label>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user