feat: 3D生成面板新增四模式与批量图生入口
This commit is contained in:
parent
77324a79dc
commit
0c9f152128
@ -5,13 +5,17 @@ import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Box,
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Layers3,
|
||||
Loader2,
|
||||
PenLine,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
@ -41,7 +45,7 @@ import { canvasSelectors, useCanvasStore } from "@/store/canvas";
|
||||
import { canvasService, model3DGenerationService, type Model3DTaskStatus } from "@/services/canvas";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type GenerationMode = "text" | "image" | "multiview";
|
||||
type GenerationMode = "text" | "image" | "multiview" | "batch";
|
||||
type TaskMode =
|
||||
| GenerationMode
|
||||
| "convert"
|
||||
@ -181,6 +185,14 @@ function createMultiViewSlots(count = 4): MultiViewSlot[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function createBatchImageSlots(count = 10): MultiViewSlot[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `batch_slot_${index + 1}`,
|
||||
file: null,
|
||||
previewUrl: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function modeLabel(mode: TaskMode) {
|
||||
switch (mode) {
|
||||
case "text":
|
||||
@ -189,6 +201,8 @@ function modeLabel(mode: TaskMode) {
|
||||
return "图生3D";
|
||||
case "multiview":
|
||||
return "多视图3D";
|
||||
case "batch":
|
||||
return "批量图生3D";
|
||||
case "convert":
|
||||
return "格式转换";
|
||||
case "refine":
|
||||
@ -276,6 +290,9 @@ export default function CanvasModel3DPage() {
|
||||
const [referenceImage, setReferenceImage] = useState<File | null>(null);
|
||||
const [referencePreviewUrl, setReferencePreviewUrl] = useState<string | null>(null);
|
||||
const [multiViewSlots, setMultiViewSlots] = useState<MultiViewSlot[]>(() => createMultiViewSlots());
|
||||
const [batchImageSlots, setBatchImageSlots] = useState<MultiViewSlot[]>(() =>
|
||||
createBatchImageSlots()
|
||||
);
|
||||
|
||||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||||
@ -320,6 +337,7 @@ export default function CanvasModel3DPage() {
|
||||
const [isolatedLayerId, setIsolatedLayerId] = useState<string | null>(null);
|
||||
const pollAttemptsRef = useRef(0);
|
||||
const multiViewSlotsRef = useRef<MultiViewSlot[]>([]);
|
||||
const batchImageSlotsRef = useRef<MultiViewSlot[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBalance = async () => {
|
||||
@ -376,6 +394,10 @@ export default function CanvasModel3DPage() {
|
||||
multiViewSlotsRef.current = multiViewSlots;
|
||||
}, [multiViewSlots]);
|
||||
|
||||
useEffect(() => {
|
||||
batchImageSlotsRef.current = batchImageSlots;
|
||||
}, [batchImageSlots]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const slot of multiViewSlotsRef.current) {
|
||||
@ -383,6 +405,11 @@ export default function CanvasModel3DPage() {
|
||||
URL.revokeObjectURL(slot.previewUrl);
|
||||
}
|
||||
}
|
||||
for (const slot of batchImageSlotsRef.current) {
|
||||
if (slot.previewUrl) {
|
||||
URL.revokeObjectURL(slot.previewUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -411,7 +438,8 @@ export default function CanvasModel3DPage() {
|
||||
const currentPreviewImage =
|
||||
currentTask?.previewImageUrl ||
|
||||
(mode === "image" ? referencePreviewUrl : null) ||
|
||||
multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl ||
|
||||
(mode === "multiview" ? multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl : null) ||
|
||||
(mode === "batch" ? batchImageSlots.find((slot) => slot.previewUrl)?.previewUrl : null) ||
|
||||
null;
|
||||
|
||||
useEffect(() => {
|
||||
@ -506,6 +534,50 @@ export default function CanvasModel3DPage() {
|
||||
handleMultiViewFileChange(index, null);
|
||||
};
|
||||
|
||||
const setBatchFiles = (files: File[]) => {
|
||||
const nextFiles = files.slice(0, 10);
|
||||
setBatchImageSlots((prev) => {
|
||||
for (const slot of prev) {
|
||||
if (slot.previewUrl) {
|
||||
URL.revokeObjectURL(slot.previewUrl);
|
||||
}
|
||||
}
|
||||
return createBatchImageSlots().map((slot, index) => {
|
||||
const file = nextFiles[index] || null;
|
||||
return {
|
||||
...slot,
|
||||
file,
|
||||
previewUrl: file ? URL.createObjectURL(file) : null,
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchPickerChange = (files: FileList | null) => {
|
||||
const nextFiles = files ? Array.from(files) : [];
|
||||
setBatchFiles(nextFiles);
|
||||
};
|
||||
|
||||
const handleBatchFileChange = (index: number, file: File | null) => {
|
||||
setBatchImageSlots((prev) => {
|
||||
const next = [...prev];
|
||||
const target = next[index];
|
||||
if (!target) return prev;
|
||||
|
||||
if (target.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||||
next[index] = {
|
||||
...target,
|
||||
file,
|
||||
previewUrl: file ? URL.createObjectURL(file) : null,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearBatchSlot = (index: number) => {
|
||||
handleBatchFileChange(index, null);
|
||||
};
|
||||
|
||||
const buildGenerationPayload = (taskType: "text_to_model" | "image_to_model" | "multiview_to_model") => {
|
||||
const payload: Record<string, any> = {
|
||||
taskType,
|
||||
@ -602,10 +674,15 @@ export default function CanvasModel3DPage() {
|
||||
return;
|
||||
}
|
||||
const selectedMultiFiles = multiViewSlots.map((slot) => slot.file).filter(Boolean) as File[];
|
||||
const selectedBatchFiles = batchImageSlots.map((slot) => slot.file).filter(Boolean) as File[];
|
||||
if (mode === "multiview" && selectedMultiFiles.length < 2) {
|
||||
toast.error("多视图生成至少上传 2 张图片");
|
||||
return;
|
||||
}
|
||||
if (mode === "batch" && selectedBatchFiles.length < 1) {
|
||||
toast.error("批量图生请至少上传 1 张图片");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setUploadingImage(false);
|
||||
@ -643,6 +720,33 @@ export default function CanvasModel3DPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "batch") {
|
||||
setUploadingImage(true);
|
||||
let successCount = 0;
|
||||
for (const file of selectedBatchFiles.slice(0, 10)) {
|
||||
const uploadResult = await model3DGenerationService.uploadImage(file);
|
||||
if (!uploadResult.success || !uploadResult.data) {
|
||||
throw new Error(uploadResult.error || `上传失败: ${file.name}`);
|
||||
}
|
||||
const payload = buildGenerationPayload("image_to_model");
|
||||
if (uploadResult.data.imageToken) {
|
||||
payload.imageToken = uploadResult.data.imageToken;
|
||||
} else if (uploadResult.data.file) {
|
||||
payload.file = uploadResult.data.file;
|
||||
} else {
|
||||
throw new Error(`图片未返回可用 token: ${file.name}`);
|
||||
}
|
||||
await submitTask({
|
||||
payload,
|
||||
mode: "batch",
|
||||
promptText: `${normalizedPrompt || "批量图生3D"} · ${file.name}`,
|
||||
});
|
||||
successCount += 1;
|
||||
}
|
||||
toast.success(`已提交 ${successCount} 个批量图生3D任务`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingImage(true);
|
||||
const uploadResults = await Promise.all(
|
||||
selectedMultiFiles.map(async (file) => model3DGenerationService.uploadImage(file))
|
||||
@ -1017,28 +1121,47 @@ export default function CanvasModel3DPage() {
|
||||
<CardDescription>补齐多视图与高级参数</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 overflow-y-auto xl:min-h-0 xl:flex-1">
|
||||
<div className="grid grid-cols-3 gap-1 rounded-lg border bg-muted/30 p-1">
|
||||
<Button
|
||||
variant={mode === "text" ? "default" : "ghost"}
|
||||
className="h-8 px-1 text-xs"
|
||||
onClick={() => setMode("text")}
|
||||
>
|
||||
文生3D
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "image" ? "default" : "ghost"}
|
||||
className="h-8 px-1 text-xs"
|
||||
onClick={() => setMode("image")}
|
||||
>
|
||||
图生3D
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "multiview" ? "default" : "ghost"}
|
||||
className="h-8 px-1 text-xs"
|
||||
onClick={() => setMode("multiview")}
|
||||
>
|
||||
多视图
|
||||
</Button>
|
||||
<div className="space-y-2 rounded-xl border bg-muted/30 p-2">
|
||||
<div className="grid grid-cols-4 gap-1 rounded-full bg-background/80 p-1">
|
||||
<Button
|
||||
variant={mode === "image" ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="h-9 rounded-full"
|
||||
onClick={() => setMode("image")}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "multiview" ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="h-9 rounded-full"
|
||||
onClick={() => setMode("multiview")}
|
||||
>
|
||||
<Box className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "batch" ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="h-9 rounded-full"
|
||||
onClick={() => setMode("batch")}
|
||||
>
|
||||
<Images className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "text" ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="h-9 rounded-full"
|
||||
onClick={() => setMode("text")}
|
||||
>
|
||||
<PenLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 text-center text-[11px] text-muted-foreground">
|
||||
<span>图生</span>
|
||||
<span>多视图</span>
|
||||
<span>批量</span>
|
||||
<span>文生</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -1147,6 +1270,87 @@ export default function CanvasModel3DPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "batch" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="model3d-batch-upload">批量参考图(最多 10 张)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setBatchFiles([])}
|
||||
>
|
||||
清空全部
|
||||
</Button>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="model3d-batch-upload"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed px-3 py-4 text-xs transition-colors",
|
||||
"hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Boxes className="h-4 w-4" />
|
||||
<span>批量上传 PNG/JPG/WEBP(每张 <=10MB)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="model3d-batch-upload"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
handleBatchPickerChange(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{batchImageSlots.map((slot, index) => (
|
||||
<div key={slot.id} className="space-y-1">
|
||||
<label
|
||||
htmlFor={`batch-slot-${slot.id}`}
|
||||
className={cn(
|
||||
"relative flex aspect-square cursor-pointer items-center justify-center rounded-md border border-dashed text-xs transition-colors",
|
||||
slot.previewUrl ? "overflow-hidden border-solid" : "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{slot.previewUrl ? (
|
||||
<img
|
||||
src={slot.previewUrl}
|
||||
alt={slot.file?.name || `批量${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">图 {index + 1}</span>
|
||||
)}
|
||||
<input
|
||||
id={`batch-slot-${slot.id}`}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) =>
|
||||
handleBatchFileChange(index, event.target.files?.[0] || null)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{slot.previewUrl ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-full text-[11px]"
|
||||
onClick={() => clearBatchSlot(index)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>模型版本</Label>
|
||||
<Select value={modelVersion} onValueChange={(value) => setModelVersion(value as any)}>
|
||||
@ -1210,7 +1414,7 @@ export default function CanvasModel3DPage() {
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
{(mode === "image" || mode === "multiview") && (
|
||||
{(mode === "image" || mode === "multiview" || mode === "batch") && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>纹理对齐</Label>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user