3647 lines
137 KiB
TypeScript
3647 lines
137 KiB
TypeScript
"use client";
|
||
|
||
import { formatDistanceToNow } from "date-fns";
|
||
import { zhCN } from "date-fns/locale";
|
||
import {
|
||
ArrowLeft,
|
||
Bone,
|
||
Box,
|
||
Boxes,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Download,
|
||
Eye,
|
||
EyeOff,
|
||
Grid3X3,
|
||
History,
|
||
Home,
|
||
ImagePlus,
|
||
Images,
|
||
Layers3,
|
||
LayoutGrid,
|
||
Loader2,
|
||
Maximize,
|
||
Package,
|
||
Palette,
|
||
PenLine,
|
||
Play,
|
||
PlusCircle,
|
||
RefreshCw,
|
||
RotateCw,
|
||
Scissors,
|
||
Settings2,
|
||
Sparkles,
|
||
Sun,
|
||
Globe,
|
||
ImageIcon,
|
||
Link2,
|
||
PanelTop,
|
||
Video,
|
||
WandSparkles,
|
||
X,
|
||
} from "lucide-react";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { useSession } from "next-auth/react";
|
||
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { toast } from "sonner";
|
||
import useSWR from "swr";
|
||
import {
|
||
type Model3DLayer,
|
||
type Model3DStats,
|
||
Model3DViewer,
|
||
type Model3DViewerSettings,
|
||
} from "@/components/canvas/model3d/Model3DViewer";
|
||
import { World3DViewer } from "@/components/canvas/world3d/World3DViewer";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from "@/components/ui/collapsible";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from "@/components/ui/tooltip";
|
||
import { cn, fetcher } from "@/lib/utils";
|
||
import {
|
||
canvasService,
|
||
type Model3DTaskStatus,
|
||
model3DGenerationService,
|
||
type WorldLabsTaskAssetPayload,
|
||
worldLabsGenerationService,
|
||
} from "@/services/canvas";
|
||
import { canvasSelectors, useCanvasStore } from "@/store/canvas";
|
||
|
||
type GenerationMode = "text" | "image" | "multiview" | "batch";
|
||
type WorkspaceMode = "model" | "world";
|
||
type WorldGenerationMode = "text" | "image" | "multi-image" | "video";
|
||
type WorldViewportMode = "world" | "imagery" | "mesh" | "splat";
|
||
type TaskProvider = "tripo-3d" | "worldlabs-3d";
|
||
type TaskMode =
|
||
| GenerationMode
|
||
| "world-text"
|
||
| "world-image"
|
||
| "world-multi-image"
|
||
| "world-video"
|
||
| "convert"
|
||
| "refine"
|
||
| "texture"
|
||
| "stylize"
|
||
| "rigcheck"
|
||
| "rig"
|
||
| "retarget"
|
||
| "segment"
|
||
| "complete"
|
||
| "lowpoly";
|
||
|
||
type TaskRecord = {
|
||
taskId: string;
|
||
provider: TaskProvider;
|
||
status: Model3DTaskStatus;
|
||
mode: TaskMode;
|
||
prompt: string;
|
||
modelVersion: string;
|
||
createdAt: number;
|
||
progress?: number | null;
|
||
modelUrl?: string | null;
|
||
previewImageUrl?: string | null;
|
||
error?: string | null;
|
||
sourceTaskId?: string | null;
|
||
exportFormat?: string | null;
|
||
assetPayload?: WorldLabsTaskAssetPayload | null;
|
||
shareEnabled?: boolean;
|
||
shareToken?: string | null;
|
||
sharedAt?: number | null;
|
||
};
|
||
|
||
type TaskRecordApiEntity = {
|
||
taskId?: unknown;
|
||
provider?: unknown;
|
||
status?: unknown;
|
||
mode?: unknown;
|
||
prompt?: unknown;
|
||
modelVersion?: unknown;
|
||
progress?: unknown;
|
||
modelUrl?: unknown;
|
||
previewImageUrl?: unknown;
|
||
error?: unknown;
|
||
sourceTaskId?: unknown;
|
||
exportFormat?: unknown;
|
||
assetPayload?: unknown;
|
||
shareEnabled?: unknown;
|
||
shareToken?: unknown;
|
||
sharedAt?: unknown;
|
||
createdAt?: unknown;
|
||
};
|
||
|
||
type MultiViewSlot = {
|
||
id: string;
|
||
file: File | null;
|
||
previewUrl: string | null;
|
||
};
|
||
|
||
type WorldImageSlot = {
|
||
id: string;
|
||
file: File | null;
|
||
previewUrl: string | null;
|
||
azimuth: number;
|
||
};
|
||
|
||
const MODEL_VERSION_OPTIONS = [
|
||
"Turbo-v1.0-20250506",
|
||
"v3.1-20260211",
|
||
"v3.0-20250812",
|
||
"v2.5-20250123",
|
||
"v2.0-20240919",
|
||
"v1.4-20240625",
|
||
] as const;
|
||
|
||
const WORLDLABS_MODEL_OPTIONS = [
|
||
"marble-1.1",
|
||
"marble-1.1-plus",
|
||
"marble-1.0",
|
||
"marble-1.0-draft",
|
||
] as const;
|
||
|
||
const ENV_PRESET_OPTIONS: Model3DViewerSettings["envPreset"][] = [
|
||
"city",
|
||
"sunset",
|
||
"studio",
|
||
"dawn",
|
||
"night",
|
||
"warehouse",
|
||
"forest",
|
||
"apartment",
|
||
"park",
|
||
"lobby",
|
||
];
|
||
|
||
const EXPORT_FORMAT_OPTIONS = [
|
||
"GLTF",
|
||
"USDZ",
|
||
"FBX",
|
||
"OBJ",
|
||
"STL",
|
||
"3MF",
|
||
] as const;
|
||
const STYLE_OPTIONS = ["lego", "voxel", "voronoi", "minecraft"] as const;
|
||
const RIG_TYPE_OPTIONS = [
|
||
"biped",
|
||
"quadruped",
|
||
"hexapod",
|
||
"octopod",
|
||
"avian",
|
||
"serpentine",
|
||
"aquatic",
|
||
"others",
|
||
] as const;
|
||
const RIG_SPEC_OPTIONS = ["tripo", "mixamo"] as const;
|
||
const RIG_FORMAT_OPTIONS = ["glb", "fbx"] as const;
|
||
const ANIMATION_OPTIONS = [
|
||
"preset:idle",
|
||
"preset:walk",
|
||
"preset:run",
|
||
"preset:jump",
|
||
"preset:turn",
|
||
"preset:slash",
|
||
"preset:shoot",
|
||
"preset:hurt",
|
||
"preset:fall",
|
||
] as const;
|
||
const FINAL_TASK_STATUS = new Set<Model3DTaskStatus>([
|
||
"success",
|
||
"failed",
|
||
"banned",
|
||
"expired",
|
||
"cancelled",
|
||
"unknown",
|
||
]);
|
||
const TASK_MODE_SET = new Set<TaskMode>([
|
||
"text",
|
||
"image",
|
||
"multiview",
|
||
"batch",
|
||
"world-text",
|
||
"world-image",
|
||
"world-multi-image",
|
||
"world-video",
|
||
"convert",
|
||
"refine",
|
||
"texture",
|
||
"stylize",
|
||
"rigcheck",
|
||
"rig",
|
||
"retarget",
|
||
"segment",
|
||
"complete",
|
||
"lowpoly",
|
||
]);
|
||
const TASK_HISTORY_LIMIT = 40;
|
||
const TASK_HISTORY_STORAGE_PREFIX = "canvas-model3d-task-history-v2";
|
||
|
||
const WORLD_MULTI_IMAGE_DEFAULTS = [0, 90, 180, 270];
|
||
|
||
function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus {
|
||
switch (status) {
|
||
case "queued":
|
||
case "running":
|
||
case "success":
|
||
case "failed":
|
||
case "banned":
|
||
case "expired":
|
||
case "cancelled":
|
||
case "unknown":
|
||
return status;
|
||
default:
|
||
return "unknown";
|
||
}
|
||
}
|
||
|
||
function extractModelFormat(modelUrl?: string | null): string {
|
||
const value = String(modelUrl || "").toLowerCase();
|
||
if (!value) return "3d";
|
||
if (value.includes(".gltf")) return "gltf";
|
||
if (value.includes(".glb")) return "glb";
|
||
if (value.includes(".usdz")) return "usdz";
|
||
if (value.includes(".fbx")) return "fbx";
|
||
if (value.includes(".obj")) return "obj";
|
||
if (value.includes(".stl")) return "stl";
|
||
if (value.includes(".3mf")) return "3mf";
|
||
return "3d";
|
||
}
|
||
|
||
function resolveTaskModelUrl(task?: TaskRecord | null): string | null {
|
||
if (!task) return null;
|
||
if (task.modelUrl) return task.modelUrl;
|
||
const meshUrl = task.assetPayload?.meshUrl;
|
||
return typeof meshUrl === "string" && meshUrl.trim() ? meshUrl.trim() : null;
|
||
}
|
||
|
||
function createMultiViewSlots(count = 4): MultiViewSlot[] {
|
||
return Array.from({ length: count }, (_, index) => ({
|
||
id: `slot_${index + 1}`,
|
||
file: null,
|
||
previewUrl: null,
|
||
}));
|
||
}
|
||
|
||
function createBatchImageSlots(count = 10): MultiViewSlot[] {
|
||
return Array.from({ length: count }, (_, index) => ({
|
||
id: `batch_slot_${index + 1}`,
|
||
file: null,
|
||
previewUrl: null,
|
||
}));
|
||
}
|
||
|
||
function createWorldImageSlots(values = WORLD_MULTI_IMAGE_DEFAULTS): WorldImageSlot[] {
|
||
return values.map((azimuth, index) => ({
|
||
id: `world_slot_${index + 1}`,
|
||
file: null,
|
||
previewUrl: null,
|
||
azimuth,
|
||
}));
|
||
}
|
||
|
||
function modeLabel(mode: TaskMode) {
|
||
switch (mode) {
|
||
case "text":
|
||
return "文生3D";
|
||
case "image":
|
||
return "图生3D";
|
||
case "multiview":
|
||
return "多视图3D";
|
||
case "batch":
|
||
return "批量图生3D";
|
||
case "world-text":
|
||
return "文生世界";
|
||
case "world-image":
|
||
return "图生世界";
|
||
case "world-multi-image":
|
||
return "多图世界";
|
||
case "world-video":
|
||
return "视频世界";
|
||
case "convert":
|
||
return "格式转换";
|
||
case "refine":
|
||
return "模型精修";
|
||
case "texture":
|
||
return "纹理重绘";
|
||
case "stylize":
|
||
return "风格化";
|
||
case "rigcheck":
|
||
return "可绑定检测";
|
||
case "rig":
|
||
return "自动绑定";
|
||
case "retarget":
|
||
return "动作重定向";
|
||
case "segment":
|
||
return "网格分割";
|
||
case "complete":
|
||
return "网格补全";
|
||
case "lowpoly":
|
||
return "低模优化";
|
||
default:
|
||
return mode;
|
||
}
|
||
}
|
||
|
||
function statusLabel(status: Model3DTaskStatus) {
|
||
switch (status) {
|
||
case "queued":
|
||
return "排队中";
|
||
case "running":
|
||
return "生成中";
|
||
case "success":
|
||
return "成功";
|
||
case "failed":
|
||
return "失败";
|
||
case "banned":
|
||
return "审核拦截";
|
||
case "expired":
|
||
return "已过期";
|
||
case "cancelled":
|
||
return "已取消";
|
||
default:
|
||
return "未知";
|
||
}
|
||
}
|
||
|
||
function makeStorageKey(projectId: string) {
|
||
return `${TASK_HISTORY_STORAGE_PREFIX}:${projectId}`;
|
||
}
|
||
|
||
function buildWorldShareUrl(shareToken?: string | null) {
|
||
const normalized = String(shareToken || "").trim();
|
||
if (!normalized) return null;
|
||
if (typeof window === "undefined") {
|
||
return `/worlds/share/${normalized}`;
|
||
}
|
||
return new URL(`/worlds/share/${normalized}`, window.location.origin).toString();
|
||
}
|
||
|
||
function normalizeTaskProvider(provider: unknown): TaskProvider {
|
||
return String(provider || "").trim() === "worldlabs-3d" ? "worldlabs-3d" : "tripo-3d";
|
||
}
|
||
|
||
function normalizeAssetPayload(
|
||
payload: unknown
|
||
): WorldLabsTaskAssetPayload | null {
|
||
return payload && typeof payload === "object" && !Array.isArray(payload)
|
||
? (payload as WorldLabsTaskAssetPayload)
|
||
: null;
|
||
}
|
||
|
||
function normalizeTaskMode(mode: unknown): TaskMode {
|
||
const value = String(mode || "").trim() as TaskMode;
|
||
return TASK_MODE_SET.has(value) ? value : "text";
|
||
}
|
||
|
||
function toTaskTimestamp(value: unknown): number {
|
||
if (value instanceof Date) return value.getTime();
|
||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||
if (typeof value === "string" && value.trim()) {
|
||
const parsed = Date.parse(value);
|
||
if (Number.isFinite(parsed)) return parsed;
|
||
}
|
||
return Date.now();
|
||
}
|
||
|
||
function toNullableTaskTimestamp(value: unknown): number | null {
|
||
if (value === undefined || value === null || value === "") return null;
|
||
return toTaskTimestamp(value);
|
||
}
|
||
|
||
function normalizeTaskRecord(raw: TaskRecord): TaskRecord {
|
||
return {
|
||
...raw,
|
||
taskId: String(raw.taskId || "").trim(),
|
||
provider: normalizeTaskProvider(raw.provider),
|
||
status: normalizeTaskStatus(raw.status),
|
||
mode: normalizeTaskMode(raw.mode),
|
||
prompt: String(raw.prompt || "").trim(),
|
||
modelVersion: String(raw.modelVersion || "").trim(),
|
||
createdAt: toTaskTimestamp(raw.createdAt),
|
||
progress:
|
||
raw.progress === undefined || raw.progress === null
|
||
? null
|
||
: Number.isFinite(Number(raw.progress))
|
||
? Number(raw.progress)
|
||
: null,
|
||
modelUrl: raw.modelUrl ? String(raw.modelUrl).trim() : null,
|
||
previewImageUrl: raw.previewImageUrl
|
||
? String(raw.previewImageUrl).trim()
|
||
: null,
|
||
error: raw.error ? String(raw.error).trim() : null,
|
||
sourceTaskId: raw.sourceTaskId ? String(raw.sourceTaskId).trim() : null,
|
||
exportFormat: raw.exportFormat ? String(raw.exportFormat).trim() : null,
|
||
assetPayload: normalizeAssetPayload(raw.assetPayload),
|
||
shareEnabled: raw.shareEnabled === true,
|
||
shareToken: raw.shareToken ? String(raw.shareToken).trim() : null,
|
||
sharedAt: toNullableTaskTimestamp(raw.sharedAt),
|
||
};
|
||
}
|
||
|
||
function mapApiEntityToTaskRecord(entity: TaskRecordApiEntity): TaskRecord | null {
|
||
const taskId = String(entity?.taskId || "").trim();
|
||
if (!taskId) return null;
|
||
|
||
return normalizeTaskRecord({
|
||
taskId,
|
||
provider: normalizeTaskProvider(entity?.provider),
|
||
status: normalizeTaskStatus(String(entity?.status || "")),
|
||
mode: normalizeTaskMode(entity?.mode),
|
||
prompt: String(entity?.prompt || "").trim(),
|
||
modelVersion: String(entity?.modelVersion || "").trim(),
|
||
createdAt: toTaskTimestamp(entity?.createdAt),
|
||
progress:
|
||
entity?.progress === undefined || entity?.progress === null
|
||
? null
|
||
: Number(entity.progress),
|
||
modelUrl: entity?.modelUrl ? String(entity.modelUrl).trim() : null,
|
||
previewImageUrl: entity?.previewImageUrl
|
||
? String(entity.previewImageUrl).trim()
|
||
: null,
|
||
error: entity?.error ? String(entity.error).trim() : null,
|
||
sourceTaskId: entity?.sourceTaskId ? String(entity.sourceTaskId).trim() : null,
|
||
exportFormat: entity?.exportFormat ? String(entity.exportFormat).trim() : null,
|
||
assetPayload: normalizeAssetPayload(entity?.assetPayload),
|
||
shareEnabled: entity?.shareEnabled === true,
|
||
shareToken: entity?.shareToken ? String(entity.shareToken).trim() : null,
|
||
sharedAt: toNullableTaskTimestamp(entity?.sharedAt),
|
||
});
|
||
}
|
||
|
||
function taskRecordChanged(prev: TaskRecord, next: TaskRecord) {
|
||
return (
|
||
prev.status !== next.status ||
|
||
prev.progress !== next.progress ||
|
||
prev.modelUrl !== next.modelUrl ||
|
||
prev.previewImageUrl !== next.previewImageUrl ||
|
||
prev.error !== next.error ||
|
||
prev.prompt !== next.prompt ||
|
||
prev.modelVersion !== next.modelVersion ||
|
||
prev.sourceTaskId !== next.sourceTaskId ||
|
||
prev.exportFormat !== next.exportFormat ||
|
||
JSON.stringify(prev.assetPayload || null) !== JSON.stringify(next.assetPayload || null) ||
|
||
prev.provider !== next.provider
|
||
);
|
||
}
|
||
|
||
function inferWorkspaceModeFromTask(task: TaskRecord | null): WorkspaceMode {
|
||
return task?.provider === "worldlabs-3d" ? "world" : "model";
|
||
}
|
||
|
||
function taskMatchesWorkspace(task: TaskRecord, workspaceMode: WorkspaceMode) {
|
||
return workspaceMode === "world"
|
||
? task.provider === "worldlabs-3d"
|
||
: task.provider !== "worldlabs-3d";
|
||
}
|
||
|
||
function worldModeToTaskMode(mode: WorldGenerationMode): TaskMode {
|
||
switch (mode) {
|
||
case "text":
|
||
return "world-text";
|
||
case "image":
|
||
return "world-image";
|
||
case "multi-image":
|
||
return "world-multi-image";
|
||
case "video":
|
||
return "world-video";
|
||
default:
|
||
return "world-image";
|
||
}
|
||
}
|
||
|
||
export default function CanvasModel3DPage() {
|
||
const router = useRouter();
|
||
const routeParams = useParams();
|
||
const id = String(routeParams?.id || "");
|
||
const legacyHistoryStorageKey = useMemo(() => makeStorageKey(id), [id]);
|
||
const { data: session } = useSession();
|
||
const user = session?.user;
|
||
|
||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||
const addNode = useCanvasStore((state) => state.addNode);
|
||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||
|
||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("model");
|
||
const [mode, setMode] = useState<GenerationMode>("text");
|
||
const [prompt, setPrompt] = useState("");
|
||
const [negativePrompt, setNegativePrompt] = useState("");
|
||
const [modelVersion, setModelVersion] = useState<
|
||
(typeof MODEL_VERSION_OPTIONS)[number]
|
||
>("Turbo-v1.0-20250506");
|
||
const [faceLimit, setFaceLimit] = useState(120_000);
|
||
const [texture, setTexture] = useState(true);
|
||
const [pbr, setPbr] = useState(true);
|
||
const [autoSize, setAutoSize] = useState(false);
|
||
const [quad, setQuad] = useState(false);
|
||
const [generateParts, setGenerateParts] = useState(false);
|
||
const [smartLowPoly, setSmartLowPoly] = useState(false);
|
||
const [geometryQuality, setGeometryQuality] = useState<
|
||
"standard" | "detailed"
|
||
>("standard");
|
||
const [textureQuality, setTextureQuality] = useState<"standard" | "detailed">(
|
||
"standard"
|
||
);
|
||
const [textureAlignment, setTextureAlignment] = useState<
|
||
"original_image" | "geometry"
|
||
>("original_image");
|
||
const [orientation, setOrientation] = useState<"default" | "align_image">(
|
||
"default"
|
||
);
|
||
const [modelSeed, setModelSeed] = useState("");
|
||
const [textureSeed, setTextureSeed] = useState("");
|
||
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [uploadingImage, setUploadingImage] = useState(false);
|
||
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 [worldMode, setWorldMode] = useState<WorldGenerationMode>("text");
|
||
const [worldPrompt, setWorldPrompt] = useState("");
|
||
const [worldDisplayName, setWorldDisplayName] = useState("");
|
||
const [worldModel, setWorldModel] = useState<
|
||
(typeof WORLDLABS_MODEL_OPTIONS)[number]
|
||
>("marble-1.1");
|
||
const [worldDisableRecaption, setWorldDisableRecaption] = useState(false);
|
||
const [worldIsPano, setWorldIsPano] = useState(false);
|
||
const [worldMakePublic, setWorldMakePublic] = useState(false);
|
||
const [worldReconstructImages, setWorldReconstructImages] = useState(false);
|
||
const [worldImageFile, setWorldImageFile] = useState<File | null>(null);
|
||
const [worldImagePreviewUrl, setWorldImagePreviewUrl] = useState<string | null>(
|
||
null
|
||
);
|
||
const [worldImageSlots, setWorldImageSlots] = useState<WorldImageSlot[]>(() =>
|
||
createWorldImageSlots()
|
||
);
|
||
const [worldVideoFile, setWorldVideoFile] = useState<File | null>(null);
|
||
const [worldVideoPreviewUrl, setWorldVideoPreviewUrl] = useState<string | null>(
|
||
null
|
||
);
|
||
const [worldViewportMode, setWorldViewportMode] =
|
||
useState<WorldViewportMode>("world");
|
||
|
||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
|
||
const [autoDownloadTaskId, setAutoDownloadTaskId] = useState<string | null>(
|
||
null
|
||
);
|
||
|
||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||
|
||
// Viewer Settings
|
||
const [viewerSettings, setViewerSettings] = useState<Model3DViewerSettings>({
|
||
envPreset: "city",
|
||
lightIntensity: 1,
|
||
autoRotate: false,
|
||
showGrid: true,
|
||
wireframe: false,
|
||
});
|
||
const [viewerStats, setViewerStats] = useState<Model3DStats>({
|
||
triangles: 0,
|
||
vertices: 0,
|
||
meshCount: 0,
|
||
});
|
||
const [viewerLayers, setViewerLayers] = useState<Model3DLayer[]>([]);
|
||
const [layerVisibility, setLayerVisibility] = useState<
|
||
Record<string, boolean>
|
||
>({});
|
||
const [isolatedLayerId, setIsolatedLayerId] = useState<string | null>(null);
|
||
|
||
// Post Processing States
|
||
const [exportFormat, setExportFormat] =
|
||
useState<(typeof EXPORT_FORMAT_OPTIONS)[number]>("GLTF");
|
||
const [stylizeStyle, setStylizeStyle] =
|
||
useState<(typeof STYLE_OPTIONS)[number]>("lego");
|
||
const [stylizeBlockSize, setStylizeBlockSize] = useState(80);
|
||
const [texturePromptText, setTexturePromptText] = useState("");
|
||
const [rigType, setRigType] =
|
||
useState<(typeof RIG_TYPE_OPTIONS)[number]>("biped");
|
||
const [rigSpec, setRigSpec] =
|
||
useState<(typeof RIG_SPEC_OPTIONS)[number]>("tripo");
|
||
const [rigFormat, setRigFormat] =
|
||
useState<(typeof RIG_FORMAT_OPTIONS)[number]>("glb");
|
||
const [retargetAnimation, setRetargetAnimation] =
|
||
useState<(typeof ANIMATION_OPTIONS)[number]>("preset:walk");
|
||
const [retargetBake, setRetargetBake] = useState(true);
|
||
const [retargetExportWithGeometry, setRetargetExportWithGeometry] =
|
||
useState(false);
|
||
const [retargetInPlace, setRetargetInPlace] = useState(false);
|
||
const [completionPartNames, setCompletionPartNames] = useState("");
|
||
const [lowpolyFaceLimit, setLowpolyFaceLimit] = useState(30_000);
|
||
const [lowpolyQuad, setLowpolyQuad] = useState(false);
|
||
const [lowpolyBake, setLowpolyBake] = useState(true);
|
||
|
||
// UI States
|
||
const [activeTab, setActiveTab] = useState<"create" | "edit" | "scene">(
|
||
"create"
|
||
);
|
||
|
||
const pollAttemptsRef = useRef(0);
|
||
const multiViewSlotsRef = useRef<MultiViewSlot[]>([]);
|
||
const batchImageSlotsRef = useRef<MultiViewSlot[]>([]);
|
||
|
||
// Fetch site settings for Logo
|
||
const { data: siteSettingsData } = useSWR("/api/site-settings", fetcher);
|
||
const { data: membershipSummary } = useSWR<{
|
||
summary?: {
|
||
currentCredits?: number;
|
||
};
|
||
} | null>("/api/user/membership-summary", async (url: string) => {
|
||
const response = await fetch(url, { cache: "no-store" });
|
||
if (!response.ok) return null;
|
||
return response.json();
|
||
});
|
||
|
||
const siteLogo = siteSettingsData?.siteLogo;
|
||
const siteLogoDark = siteSettingsData?.siteLogoDark;
|
||
const siteName = siteSettingsData?.siteName || "SparkAI";
|
||
const pointsLabel = useMemo(() => {
|
||
const rawValue = Number(membershipSummary?.summary?.currentCredits ?? 0);
|
||
const normalized = Number.isFinite(rawValue) ? Math.max(0, rawValue) : 0;
|
||
return normalized.toLocaleString("en-US");
|
||
}, [membershipSummary?.summary?.currentCredits]);
|
||
const userAvatarFallback = useMemo(() => {
|
||
const fromName = String(user?.name || "").trim();
|
||
if (fromName) return fromName.charAt(0).toUpperCase();
|
||
const fromEmail = String(user?.email || "").trim();
|
||
if (fromEmail) return fromEmail.charAt(0).toUpperCase();
|
||
return "U";
|
||
}, [user?.email, user?.name]);
|
||
|
||
// Display logo logic (prefer dark mode logo since we are in dark theme)
|
||
const displayLogo =
|
||
siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo;
|
||
|
||
// Effect hooks for data loading and cleanup
|
||
useEffect(() => {
|
||
console.log("CanvasModel3DPage mounted - v3.0 Dark Mode");
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const html = document.documentElement;
|
||
const body = document.body;
|
||
const prevHtmlOverscrollX = html.style.overscrollBehaviorX;
|
||
const prevBodyOverscrollX = body.style.overscrollBehaviorX;
|
||
|
||
html.style.overscrollBehaviorX = "none";
|
||
body.style.overscrollBehaviorX = "none";
|
||
|
||
const preventHistorySwipe = (event: WheelEvent) => {
|
||
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
|
||
if (event.cancelable) {
|
||
event.preventDefault();
|
||
}
|
||
};
|
||
|
||
window.addEventListener("wheel", preventHistorySwipe, { passive: false });
|
||
|
||
return () => {
|
||
window.removeEventListener("wheel", preventHistorySwipe);
|
||
html.style.overscrollBehaviorX = prevHtmlOverscrollX;
|
||
body.style.overscrollBehaviorX = prevBodyOverscrollX;
|
||
};
|
||
}, []);
|
||
|
||
const persistTaskRecord = useCallback(
|
||
async (task: TaskRecord) => {
|
||
if (!id) return;
|
||
try {
|
||
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
asset: {
|
||
taskId: task.taskId,
|
||
provider: task.provider,
|
||
status: task.status,
|
||
mode: task.mode,
|
||
prompt: task.prompt,
|
||
modelVersion: task.modelVersion,
|
||
progress: task.progress ?? null,
|
||
modelUrl: task.modelUrl ?? null,
|
||
previewImageUrl: task.previewImageUrl ?? null,
|
||
error: task.error ?? null,
|
||
sourceTaskId: task.sourceTaskId ?? null,
|
||
exportFormat: task.exportFormat ?? null,
|
||
assetPayload: task.assetPayload ?? null,
|
||
createdAt: task.createdAt,
|
||
},
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
const payload = await response.json().catch(() => ({}));
|
||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||
}
|
||
} catch (error) {
|
||
console.error("[Model3DPage] 持久化 3D 资产失败:", error);
|
||
}
|
||
},
|
||
[id]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!id) return;
|
||
let cancelled = false;
|
||
|
||
const migrateLegacyHistory = async () => {
|
||
if (typeof window === "undefined") return [] as TaskRecord[];
|
||
const raw = window.localStorage.getItem(legacyHistoryStorageKey);
|
||
if (!raw) return [] as TaskRecord[];
|
||
|
||
try {
|
||
const parsed = JSON.parse(raw) as TaskRecord[];
|
||
if (!Array.isArray(parsed)) {
|
||
window.localStorage.removeItem(legacyHistoryStorageKey);
|
||
return [] as TaskRecord[];
|
||
}
|
||
|
||
const normalized = parsed
|
||
.filter((item) => item && typeof item.taskId === "string")
|
||
.map((item) => normalizeTaskRecord(item))
|
||
.slice(0, TASK_HISTORY_LIMIT);
|
||
|
||
for (const item of normalized) {
|
||
await persistTaskRecord(item);
|
||
}
|
||
window.localStorage.removeItem(legacyHistoryStorageKey);
|
||
return normalized;
|
||
} catch {
|
||
window.localStorage.removeItem(legacyHistoryStorageKey);
|
||
return [] as TaskRecord[];
|
||
}
|
||
};
|
||
|
||
const loadTaskHistory = async () => {
|
||
try {
|
||
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
|
||
cache: "no-store",
|
||
});
|
||
const payload = (await response.json().catch(() => ({}))) as {
|
||
success?: boolean;
|
||
error?: string;
|
||
data?: TaskRecordApiEntity[];
|
||
};
|
||
if (!response.ok || payload?.success === false) {
|
||
throw new Error(payload?.error || "加载 3D 资产失败");
|
||
}
|
||
const list: TaskRecordApiEntity[] = Array.isArray(payload?.data)
|
||
? payload.data
|
||
: [];
|
||
|
||
let normalized = list
|
||
.map((item) => mapApiEntityToTaskRecord(item))
|
||
.filter((item): item is TaskRecord => Boolean(item))
|
||
.slice(0, TASK_HISTORY_LIMIT);
|
||
|
||
if (normalized.length === 0) {
|
||
normalized = await migrateLegacyHistory();
|
||
} else if (typeof window !== "undefined") {
|
||
window.localStorage.removeItem(legacyHistoryStorageKey);
|
||
}
|
||
|
||
if (cancelled) return;
|
||
setTaskHistory(normalized);
|
||
const pending = normalized.find(
|
||
(item) => !FINAL_TASK_STATUS.has(item.status)
|
||
);
|
||
setCurrentTask(pending || normalized[0] || null);
|
||
} catch (error) {
|
||
console.error("[Model3DPage] 加载 3D 资产库失败:", error);
|
||
const migrated = await migrateLegacyHistory();
|
||
if (cancelled) return;
|
||
setTaskHistory(migrated);
|
||
const pending = migrated.find(
|
||
(item) => !FINAL_TASK_STATUS.has(item.status)
|
||
);
|
||
const initialTask = pending || migrated[0] || null;
|
||
setCurrentTask(initialTask);
|
||
setWorkspaceMode(inferWorkspaceModeFromTask(initialTask));
|
||
}
|
||
};
|
||
|
||
void loadTaskHistory();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [id, legacyHistoryStorageKey, persistTaskRecord]);
|
||
|
||
useEffect(() => {
|
||
if (!referenceImage) {
|
||
if (referencePreviewUrl) {
|
||
URL.revokeObjectURL(referencePreviewUrl);
|
||
}
|
||
setReferencePreviewUrl(null);
|
||
return;
|
||
}
|
||
const objectUrl = URL.createObjectURL(referenceImage);
|
||
setReferencePreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return objectUrl;
|
||
});
|
||
return () => {
|
||
URL.revokeObjectURL(objectUrl);
|
||
};
|
||
}, [referenceImage]);
|
||
|
||
useEffect(() => {
|
||
if (!worldImageFile) {
|
||
if (worldImagePreviewUrl) {
|
||
URL.revokeObjectURL(worldImagePreviewUrl);
|
||
}
|
||
setWorldImagePreviewUrl(null);
|
||
return;
|
||
}
|
||
const objectUrl = URL.createObjectURL(worldImageFile);
|
||
setWorldImagePreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return objectUrl;
|
||
});
|
||
return () => {
|
||
URL.revokeObjectURL(objectUrl);
|
||
};
|
||
}, [worldImageFile]);
|
||
|
||
useEffect(() => {
|
||
if (!worldVideoFile) {
|
||
if (worldVideoPreviewUrl) {
|
||
URL.revokeObjectURL(worldVideoPreviewUrl);
|
||
}
|
||
setWorldVideoPreviewUrl(null);
|
||
return;
|
||
}
|
||
const objectUrl = URL.createObjectURL(worldVideoFile);
|
||
setWorldVideoPreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return objectUrl;
|
||
});
|
||
return () => {
|
||
URL.revokeObjectURL(objectUrl);
|
||
};
|
||
}, [worldVideoFile]);
|
||
|
||
useEffect(() => {
|
||
multiViewSlotsRef.current = multiViewSlots;
|
||
}, [multiViewSlots]);
|
||
|
||
useEffect(() => {
|
||
batchImageSlotsRef.current = batchImageSlots;
|
||
}, [batchImageSlots]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
for (const slot of multiViewSlotsRef.current) {
|
||
if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl);
|
||
}
|
||
for (const slot of batchImageSlotsRef.current) {
|
||
if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl);
|
||
}
|
||
for (const slot of worldImageSlots) {
|
||
if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl);
|
||
}
|
||
};
|
||
}, [worldImageSlots]);
|
||
|
||
const upsertTaskHistory = useCallback(
|
||
(next: TaskRecord, options?: { persist?: boolean }) => {
|
||
const normalized = normalizeTaskRecord(next);
|
||
setTaskHistory((prev) => {
|
||
const index = prev.findIndex((item) => item.taskId === normalized.taskId);
|
||
if (index >= 0) {
|
||
const updated = [...prev];
|
||
updated[index] = { ...updated[index], ...normalized };
|
||
return updated.slice(0, TASK_HISTORY_LIMIT);
|
||
}
|
||
return [normalized, ...prev].slice(0, TASK_HISTORY_LIMIT);
|
||
});
|
||
setCurrentTask((prev) =>
|
||
prev?.taskId === normalized.taskId ? { ...prev, ...normalized } : prev
|
||
);
|
||
|
||
if (options?.persist !== false) {
|
||
void persistTaskRecord(normalized);
|
||
}
|
||
},
|
||
[persistTaskRecord]
|
||
);
|
||
|
||
const effectiveLayerVisibility = useMemo(() => {
|
||
if (!isolatedLayerId) return layerVisibility;
|
||
const next: Record<string, boolean> = {};
|
||
for (const layer of viewerLayers) {
|
||
next[layer.id] =
|
||
layer.id === isolatedLayerId && layerVisibility[layer.id] !== false;
|
||
}
|
||
return next;
|
||
}, [isolatedLayerId, layerVisibility, viewerLayers]);
|
||
|
||
const visibleTaskHistory = useMemo(
|
||
() => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)),
|
||
[taskHistory, workspaceMode]
|
||
);
|
||
|
||
const currentModelUrl = currentTask?.modelUrl || null;
|
||
const currentPreviewImage =
|
||
currentTask?.previewImageUrl ||
|
||
(workspaceMode === "model"
|
||
? mode === "image"
|
||
? referencePreviewUrl
|
||
: mode === "multiview"
|
||
? multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl
|
||
: mode === "batch"
|
||
? batchImageSlots.find((slot) => slot.previewUrl)?.previewUrl
|
||
: null
|
||
: worldMode === "image"
|
||
? worldImagePreviewUrl
|
||
: worldMode === "multi-image"
|
||
? worldImageSlots.find((slot) => slot.previewUrl)?.previewUrl
|
||
: worldMode === "video"
|
||
? worldVideoPreviewUrl
|
||
: null) ||
|
||
null;
|
||
|
||
useEffect(() => {
|
||
if (currentTask && taskMatchesWorkspace(currentTask, workspaceMode)) {
|
||
return;
|
||
}
|
||
|
||
const fallbackTask = taskHistory.find((task) =>
|
||
taskMatchesWorkspace(task, workspaceMode)
|
||
);
|
||
if (fallbackTask) {
|
||
setCurrentTask(fallbackTask);
|
||
return;
|
||
}
|
||
|
||
if (currentTask) {
|
||
setCurrentTask(null);
|
||
}
|
||
}, [currentTask, taskHistory, workspaceMode]);
|
||
|
||
// Polling logic
|
||
useEffect(() => {
|
||
if (!currentTask || FINAL_TASK_STATUS.has(currentTask.status)) {
|
||
return;
|
||
}
|
||
|
||
pollAttemptsRef.current = 0;
|
||
let cancelled = false;
|
||
|
||
const runPoll = async () => {
|
||
if (cancelled) return;
|
||
pollAttemptsRef.current += 1;
|
||
|
||
const isWorldTask = currentTask.provider === "worldlabs-3d";
|
||
const result =
|
||
isWorldTask
|
||
? await worldLabsGenerationService.getTask(currentTask.taskId, {
|
||
providerId: currentTask.assetPayload?.providerId,
|
||
})
|
||
: await model3DGenerationService.getTask(currentTask.taskId);
|
||
if (!result.success || !result.data) {
|
||
const failMessage = result.error || "任务查询失败";
|
||
const failedRecord: TaskRecord = {
|
||
...currentTask,
|
||
status: "failed",
|
||
error: failMessage,
|
||
};
|
||
setCurrentTask(failedRecord);
|
||
upsertTaskHistory(failedRecord);
|
||
toast.error(failMessage);
|
||
return;
|
||
}
|
||
|
||
const status = normalizeTaskStatus(result.data.status);
|
||
const worldResultData = isWorldTask
|
||
? (result.data as NonNullable<
|
||
Awaited<ReturnType<typeof worldLabsGenerationService.getTask>>["data"]
|
||
>)
|
||
: null;
|
||
const nextRecord: TaskRecord = {
|
||
...currentTask,
|
||
status,
|
||
progress: result.data.progress ?? null,
|
||
modelUrl: result.data.modelUrl ?? currentTask.modelUrl ?? null,
|
||
previewImageUrl:
|
||
result.data.previewImageUrl ?? currentTask.previewImageUrl ?? null,
|
||
sourceTaskId:
|
||
isWorldTask
|
||
? worldResultData?.worldId ?? currentTask.sourceTaskId ?? null
|
||
: currentTask.sourceTaskId ?? null,
|
||
assetPayload:
|
||
isWorldTask
|
||
? worldResultData?.assetPayload ?? currentTask.assetPayload ?? null
|
||
: currentTask.assetPayload ?? null,
|
||
error:
|
||
status === "failed" ? result.data.errorMessage || "任务失败" : null,
|
||
};
|
||
if (taskRecordChanged(currentTask, nextRecord)) {
|
||
setCurrentTask(nextRecord);
|
||
upsertTaskHistory(nextRecord);
|
||
}
|
||
|
||
if (status === "success") {
|
||
toast.success(`${modeLabel(nextRecord.mode)}任务已完成`);
|
||
if (
|
||
autoDownloadTaskId &&
|
||
autoDownloadTaskId === nextRecord.taskId &&
|
||
nextRecord.modelUrl
|
||
) {
|
||
const link = document.createElement("a");
|
||
link.href = nextRecord.modelUrl;
|
||
link.download = `${
|
||
nextRecord.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model"
|
||
}-${nextRecord.taskId}.${extractModelFormat(nextRecord.modelUrl)}`;
|
||
link.click();
|
||
setAutoDownloadTaskId(null);
|
||
}
|
||
}
|
||
|
||
if (pollAttemptsRef.current >= 360 && !FINAL_TASK_STATUS.has(status)) {
|
||
const timeoutRecord: TaskRecord = {
|
||
...nextRecord,
|
||
status: "failed",
|
||
error: "任务轮询超时,请稍后重试",
|
||
};
|
||
setCurrentTask(timeoutRecord);
|
||
upsertTaskHistory(timeoutRecord);
|
||
toast.error("任务轮询超时,请稍后重试");
|
||
}
|
||
};
|
||
|
||
void runPoll();
|
||
const timer = window.setInterval(() => {
|
||
void runPoll();
|
||
}, 2000);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(timer);
|
||
};
|
||
}, [autoDownloadTaskId, currentTask, upsertTaskHistory]);
|
||
|
||
// File handling functions
|
||
const handleMultiViewFileChange = (index: number, file: File | null) => {
|
||
setMultiViewSlots((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 clearMultiViewSlot = (index: number) => {
|
||
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 handleWorldImageSlotChange = (index: number, file: File | null) => {
|
||
setWorldImageSlots((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 handleWorldImageAzimuthChange = (index: number, azimuth: number) => {
|
||
setWorldImageSlots((prev) => {
|
||
const next = [...prev];
|
||
const target = next[index];
|
||
if (!target) return prev;
|
||
next[index] = {
|
||
...target,
|
||
azimuth,
|
||
};
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// Task submission functions
|
||
const buildGenerationPayload = (
|
||
taskType: "text_to_model" | "image_to_model" | "multiview_to_model"
|
||
) => {
|
||
const payload: Record<string, any> = {
|
||
taskType,
|
||
prompt: prompt.trim(),
|
||
negativePrompt: negativePrompt.trim(),
|
||
modelVersion,
|
||
faceLimit,
|
||
texture,
|
||
pbr,
|
||
autoSize,
|
||
quad,
|
||
generateParts,
|
||
smartLowPoly,
|
||
geometryQuality,
|
||
textureQuality,
|
||
modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined,
|
||
textureSeed: textureSeed.trim() ? Number(textureSeed) : undefined,
|
||
};
|
||
|
||
if (taskType === "image_to_model" || taskType === "multiview_to_model") {
|
||
payload.textureAlignment = textureAlignment;
|
||
payload.orientation = orientation;
|
||
}
|
||
return payload;
|
||
};
|
||
|
||
const submitTask = async (params: {
|
||
provider: TaskProvider;
|
||
payload: Record<string, any>;
|
||
mode: TaskMode;
|
||
promptText: string;
|
||
modelLabel: string;
|
||
sourceTaskId?: string | null;
|
||
exportFormat?: string | null;
|
||
assetPayload?: WorldLabsTaskAssetPayload | null;
|
||
}) => {
|
||
const createResult =
|
||
params.provider === "worldlabs-3d"
|
||
? await worldLabsGenerationService.createTask(params.payload)
|
||
: await model3DGenerationService.createTask(params.payload);
|
||
if (!createResult.success || !createResult.data?.taskId) {
|
||
throw new Error(createResult.error || "创建任务失败");
|
||
}
|
||
const worldCreateResultData =
|
||
params.provider === "worldlabs-3d" &&
|
||
"assetPayload" in createResult.data
|
||
? createResult.data
|
||
: null;
|
||
|
||
const newTask: TaskRecord = {
|
||
taskId: createResult.data.taskId,
|
||
provider: params.provider,
|
||
status: normalizeTaskStatus(createResult.data.status),
|
||
prompt: params.promptText,
|
||
mode: params.mode,
|
||
modelVersion: params.modelLabel,
|
||
createdAt: Date.now(),
|
||
sourceTaskId: params.sourceTaskId || null,
|
||
exportFormat: params.exportFormat || null,
|
||
assetPayload:
|
||
params.provider === "worldlabs-3d"
|
||
? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null
|
||
: params.assetPayload ?? null,
|
||
};
|
||
setCurrentTask(newTask);
|
||
upsertTaskHistory(newTask);
|
||
return newTask;
|
||
};
|
||
|
||
const getReadyTask = () => {
|
||
if (!currentTask || currentTask.status !== "success") {
|
||
toast.error("请先完成一个成功任务");
|
||
return null;
|
||
}
|
||
return currentTask;
|
||
};
|
||
|
||
const submitDerivedTask = async (params: {
|
||
payload: Record<string, any>;
|
||
mode: TaskMode;
|
||
promptText: string;
|
||
sourceTaskId: string;
|
||
successMessage: string;
|
||
}) => {
|
||
setIsSubmitting(true);
|
||
try {
|
||
await submitTask({
|
||
provider: "tripo-3d",
|
||
payload: params.payload,
|
||
mode: params.mode,
|
||
promptText: params.promptText,
|
||
modelLabel: modelVersion,
|
||
sourceTaskId: params.sourceTaskId,
|
||
});
|
||
toast.success(params.successMessage);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "提交任务失败");
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleGenerate = async () => {
|
||
const normalizedPrompt = prompt.trim();
|
||
if (mode === "text" && !normalizedPrompt) {
|
||
toast.error("请输入提示词");
|
||
return;
|
||
}
|
||
if (mode === "image" && !referenceImage) {
|
||
toast.error("图生3D请先上传参考图");
|
||
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);
|
||
try {
|
||
if (mode === "text") {
|
||
await submitTask({
|
||
provider: "tripo-3d",
|
||
payload: buildGenerationPayload("text_to_model"),
|
||
mode: "text",
|
||
promptText: normalizedPrompt,
|
||
modelLabel: modelVersion,
|
||
});
|
||
toast.success("已提交文生3D任务");
|
||
return;
|
||
}
|
||
|
||
if (mode === "image") {
|
||
setUploadingImage(true);
|
||
const uploadResult = await model3DGenerationService.uploadImage(
|
||
referenceImage as File
|
||
);
|
||
if (!uploadResult.success || !uploadResult.data) {
|
||
throw new Error(uploadResult.error || "上传参考图失败");
|
||
}
|
||
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");
|
||
}
|
||
await submitTask({
|
||
provider: "tripo-3d",
|
||
payload,
|
||
mode: "image",
|
||
promptText: normalizedPrompt || "图生3D",
|
||
modelLabel: modelVersion,
|
||
});
|
||
toast.success("已提交图生3D任务");
|
||
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({
|
||
provider: "tripo-3d",
|
||
payload,
|
||
mode: "batch",
|
||
promptText: `${normalizedPrompt || "批量图生3D"} · ${file.name}`,
|
||
modelLabel: modelVersion,
|
||
});
|
||
successCount += 1;
|
||
}
|
||
toast.success(`已提交 ${successCount} 个批量图生3D任务`);
|
||
return;
|
||
}
|
||
|
||
setUploadingImage(true);
|
||
const uploadResults = await Promise.all(
|
||
selectedMultiFiles.map(async (file) =>
|
||
model3DGenerationService.uploadImage(file)
|
||
)
|
||
);
|
||
const filesPayload: Array<Record<string, any>> = [];
|
||
for (const result of uploadResults) {
|
||
if (!result.success || !result.data) {
|
||
throw new Error(result.error || "上传多视图图片失败");
|
||
}
|
||
if (result.data.imageToken) {
|
||
filesPayload.push({
|
||
type: "jpg",
|
||
file_token: result.data.imageToken,
|
||
});
|
||
} else if (result.data.file) {
|
||
filesPayload.push(result.data.file);
|
||
}
|
||
}
|
||
if (filesPayload.length < 2) {
|
||
throw new Error("多视图图片上传失败,请重试");
|
||
}
|
||
const payload = buildGenerationPayload("multiview_to_model");
|
||
payload.files = filesPayload;
|
||
await submitTask({
|
||
provider: "tripo-3d",
|
||
payload,
|
||
mode: "multiview",
|
||
promptText: normalizedPrompt || "多视图3D",
|
||
modelLabel: modelVersion,
|
||
});
|
||
toast.success("已提交多视图3D任务");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "创建任务失败");
|
||
} finally {
|
||
setUploadingImage(false);
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleWorldGenerate = async () => {
|
||
const normalizedPrompt = worldPrompt.trim();
|
||
const selectedWorldImages = worldImageSlots.filter((slot) => slot.file);
|
||
|
||
if (worldMode === "text" && !normalizedPrompt) {
|
||
toast.error("请输入世界描述");
|
||
return;
|
||
}
|
||
if (worldMode === "image" && !worldImageFile) {
|
||
toast.error("图生世界请先上传参考图");
|
||
return;
|
||
}
|
||
if (worldMode === "multi-image" && selectedWorldImages.length < 2) {
|
||
toast.error("多图世界至少上传 2 张图片");
|
||
return;
|
||
}
|
||
if (worldMode === "video" && !worldVideoFile) {
|
||
toast.error("视频世界请先上传视频");
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
setUploadingImage(false);
|
||
|
||
try {
|
||
if (worldMode === "text") {
|
||
await submitTask({
|
||
provider: "worldlabs-3d",
|
||
payload: {
|
||
mode: "text",
|
||
prompt: normalizedPrompt,
|
||
displayName: worldDisplayName.trim() || undefined,
|
||
model: worldModel,
|
||
disableRecaption: worldDisableRecaption,
|
||
makePublic: worldMakePublic,
|
||
},
|
||
mode: "world-text",
|
||
promptText: normalizedPrompt || "文生世界",
|
||
modelLabel: worldModel,
|
||
});
|
||
toast.success("已提交文生世界任务");
|
||
return;
|
||
}
|
||
|
||
if (worldMode === "image") {
|
||
setUploadingImage(true);
|
||
const uploadResult = await worldLabsGenerationService.uploadMedia(
|
||
worldImageFile as File,
|
||
"image"
|
||
);
|
||
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
||
throw new Error(uploadResult.error || "上传世界参考图失败");
|
||
}
|
||
await submitTask({
|
||
provider: "worldlabs-3d",
|
||
payload: {
|
||
mode: "image",
|
||
mediaAssetId: uploadResult.data.mediaAssetId,
|
||
providerId: uploadResult.data.providerId,
|
||
prompt: normalizedPrompt || undefined,
|
||
displayName: worldDisplayName.trim() || undefined,
|
||
model: worldModel,
|
||
disableRecaption: worldDisableRecaption,
|
||
isPano: worldIsPano,
|
||
makePublic: worldMakePublic,
|
||
},
|
||
mode: "world-image",
|
||
promptText: normalizedPrompt || "图生世界",
|
||
modelLabel: worldModel,
|
||
});
|
||
toast.success("已提交图生世界任务");
|
||
return;
|
||
}
|
||
|
||
if (worldMode === "video") {
|
||
setUploadingImage(true);
|
||
const uploadResult = await worldLabsGenerationService.uploadMedia(
|
||
worldVideoFile as File,
|
||
"video"
|
||
);
|
||
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
||
throw new Error(uploadResult.error || "上传世界视频失败");
|
||
}
|
||
await submitTask({
|
||
provider: "worldlabs-3d",
|
||
payload: {
|
||
mode: "video",
|
||
mediaAssetId: uploadResult.data.mediaAssetId,
|
||
providerId: uploadResult.data.providerId,
|
||
prompt: normalizedPrompt || undefined,
|
||
displayName: worldDisplayName.trim() || undefined,
|
||
model: worldModel,
|
||
makePublic: worldMakePublic,
|
||
},
|
||
mode: "world-video",
|
||
promptText: normalizedPrompt || worldVideoFile?.name || "视频世界",
|
||
modelLabel: worldModel,
|
||
});
|
||
toast.success("已提交视频世界任务");
|
||
return;
|
||
}
|
||
|
||
setUploadingImage(true);
|
||
let boundWorldProviderId: string | null = null;
|
||
const uploads: Array<{
|
||
slot: WorldImageSlot;
|
||
result: Awaited<ReturnType<typeof worldLabsGenerationService.uploadMedia>>;
|
||
}> = [];
|
||
for (const slot of selectedWorldImages) {
|
||
const result = await worldLabsGenerationService.uploadMedia(
|
||
slot.file as File,
|
||
"image",
|
||
{
|
||
providerId: boundWorldProviderId,
|
||
}
|
||
);
|
||
if (result.success && result.data?.providerId && !boundWorldProviderId) {
|
||
boundWorldProviderId = result.data.providerId;
|
||
}
|
||
uploads.push({ slot, result });
|
||
}
|
||
|
||
const mediaAssets = uploads.map(({ slot, result }) => {
|
||
if (!result.success || !result.data?.mediaAssetId) {
|
||
throw new Error(result.error || "上传多图世界图片失败");
|
||
}
|
||
return {
|
||
mediaAssetId: result.data.mediaAssetId,
|
||
azimuth: slot.azimuth,
|
||
};
|
||
});
|
||
|
||
await submitTask({
|
||
provider: "worldlabs-3d",
|
||
payload: {
|
||
mode: "multi-image",
|
||
providerId: boundWorldProviderId,
|
||
mediaAssets,
|
||
prompt: normalizedPrompt || undefined,
|
||
displayName: worldDisplayName.trim() || undefined,
|
||
model: worldModel,
|
||
disableRecaption: worldDisableRecaption,
|
||
reconstructImages: worldReconstructImages,
|
||
makePublic: worldMakePublic,
|
||
},
|
||
mode: "world-multi-image",
|
||
promptText: normalizedPrompt || "多图世界",
|
||
modelLabel: worldModel,
|
||
});
|
||
toast.success("已提交多图世界任务");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "创建世界任务失败");
|
||
} finally {
|
||
setUploadingImage(false);
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleImportToCanvas = async () => {
|
||
if (!currentTask || currentTask.status !== "success") {
|
||
toast.error("暂无可导入的世界资产");
|
||
return;
|
||
}
|
||
|
||
const isWorldTask = currentTask.provider === "worldlabs-3d";
|
||
const worldAssetPayload = currentTask.assetPayload ?? null;
|
||
const primarySplatUrl = isWorldTask
|
||
? Object.values(worldAssetPayload?.spzUrls || {}).find(
|
||
(value): value is string => typeof value === "string" && value.trim().length > 0
|
||
) || null
|
||
: null;
|
||
const worldImageUrl =
|
||
worldAssetPayload?.panoUrl ||
|
||
worldAssetPayload?.thumbnailUrl ||
|
||
currentTask.previewImageUrl ||
|
||
null;
|
||
|
||
const canImportWorld = Boolean(
|
||
currentModelUrl ||
|
||
worldAssetPayload?.worldUrl ||
|
||
worldImageUrl ||
|
||
primarySplatUrl
|
||
);
|
||
|
||
if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) {
|
||
toast.error(isWorldTask ? "暂无可导入的 3D 世界" : "暂无可导入的 3D 模型");
|
||
return;
|
||
}
|
||
|
||
const viewportX = viewport.x || 0;
|
||
const viewportY = viewport.y || 0;
|
||
const viewportZoom = viewport.zoom || 1;
|
||
const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
|
||
const stageHeight =
|
||
typeof window !== "undefined" ? window.innerHeight : 720;
|
||
const targetWidth = 380;
|
||
const targetHeight = 300;
|
||
const x = (stageWidth * 0.48 - viewportX) / viewportZoom - targetWidth / 2;
|
||
const y =
|
||
(stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2;
|
||
|
||
addNode(
|
||
isWorldTask
|
||
? {
|
||
type: "world3d",
|
||
position: { x, y },
|
||
size: { width: targetWidth, height: targetHeight },
|
||
data: {
|
||
worldUrl: worldAssetPayload?.worldUrl || undefined,
|
||
imageUrl: worldImageUrl || undefined,
|
||
meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined,
|
||
splatUrl: primarySplatUrl || undefined,
|
||
previewImage:
|
||
currentTask.previewImageUrl ||
|
||
worldAssetPayload?.thumbnailUrl ||
|
||
undefined,
|
||
prompt: currentTask.prompt,
|
||
modelVersion: currentTask.modelVersion,
|
||
status: "completed",
|
||
activeView: primarySplatUrl
|
||
? "splat"
|
||
: worldAssetPayload?.worldUrl
|
||
? "world"
|
||
: worldImageUrl
|
||
? "image"
|
||
: "mesh",
|
||
firstPerson: false,
|
||
hotspots: [],
|
||
semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined,
|
||
metadata: {
|
||
taskId: currentTask.taskId,
|
||
provider: currentTask.provider,
|
||
mode: currentTask.mode,
|
||
sourceTaskId: currentTask.sourceTaskId,
|
||
progress: currentTask.progress,
|
||
triangles: viewerStats.triangles,
|
||
vertices: viewerStats.vertices,
|
||
meshCount: viewerStats.meshCount,
|
||
assetPayload: currentTask.assetPayload ?? null,
|
||
},
|
||
},
|
||
connections: [],
|
||
}
|
||
: {
|
||
type: "model3d",
|
||
position: { x, y },
|
||
size: { width: targetWidth, height: targetHeight },
|
||
data: {
|
||
src: currentModelUrl!,
|
||
previewImage: currentTask.previewImageUrl || undefined,
|
||
prompt: currentTask.prompt,
|
||
modelVersion: currentTask.modelVersion,
|
||
format: extractModelFormat(currentModelUrl!),
|
||
status: "completed",
|
||
metadata: {
|
||
taskId: currentTask.taskId,
|
||
provider: currentTask.provider,
|
||
mode: currentTask.mode,
|
||
sourceTaskId: currentTask.sourceTaskId,
|
||
progress: currentTask.progress,
|
||
triangles: viewerStats.triangles,
|
||
vertices: viewerStats.vertices,
|
||
meshCount: viewerStats.meshCount,
|
||
assetPayload: currentTask.assetPayload ?? null,
|
||
},
|
||
},
|
||
connections: [],
|
||
}
|
||
);
|
||
|
||
try {
|
||
await canvasService.saveCanvasState(id, getCanvasState());
|
||
} catch (error) {
|
||
console.error("[Model3DPage] 保存画布失败:", error);
|
||
}
|
||
toast.success(isWorldTask ? "3D 世界已导入画布" : "3D 模型已导入画布");
|
||
};
|
||
|
||
const handleDownloadCurrent = () => {
|
||
const modelUrl = resolveTaskModelUrl(currentTask);
|
||
if (!modelUrl) {
|
||
toast.error("暂无可下载的 3D 模型");
|
||
return;
|
||
}
|
||
const link = document.createElement("a");
|
||
link.href = modelUrl;
|
||
link.download = `${
|
||
currentTask?.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model"
|
||
}-${currentTask?.taskId || "result"}.${extractModelFormat(modelUrl)}`;
|
||
link.click();
|
||
};
|
||
|
||
const handleDownloadTask = (
|
||
task: TaskRecord,
|
||
event?: MouseEvent<HTMLButtonElement>
|
||
) => {
|
||
event?.stopPropagation();
|
||
const modelUrl = resolveTaskModelUrl(task);
|
||
if (!modelUrl) {
|
||
toast.error(task.provider === "worldlabs-3d" ? "该世界暂无可下载的 Mesh" : "该模型暂无可下载文件");
|
||
return;
|
||
}
|
||
const link = document.createElement("a");
|
||
link.href = modelUrl;
|
||
link.download = `${
|
||
task.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model"
|
||
}-${task.taskId}.${extractModelFormat(modelUrl)}`;
|
||
link.click();
|
||
};
|
||
|
||
const updateWorldShareState = useCallback(
|
||
async (task: TaskRecord, shareEnabled: boolean) => {
|
||
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
taskId: task.taskId,
|
||
shareEnabled,
|
||
}),
|
||
});
|
||
const payload = (await response.json().catch(() => ({}))) as {
|
||
success?: boolean;
|
||
error?: string;
|
||
data?: TaskRecordApiEntity;
|
||
};
|
||
if (!response.ok || payload?.success === false) {
|
||
throw new Error(payload?.error || "更新世界分享状态失败");
|
||
}
|
||
const updatedTask = payload?.data ? mapApiEntityToTaskRecord(payload.data) : null;
|
||
if (!updatedTask) {
|
||
throw new Error("分享结果返回无效");
|
||
}
|
||
upsertTaskHistory(updatedTask, { persist: false });
|
||
return updatedTask;
|
||
},
|
||
[id, upsertTaskHistory]
|
||
);
|
||
|
||
const handleShareTask = useCallback(
|
||
async (task?: TaskRecord | null, event?: MouseEvent<HTMLButtonElement>) => {
|
||
event?.stopPropagation();
|
||
if (!task || task.provider !== "worldlabs-3d" || task.status !== "success") {
|
||
toast.error("仅成功生成的 3D 世界支持分享");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const sharedTask =
|
||
task.shareEnabled && task.shareToken
|
||
? task
|
||
: await updateWorldShareState(task, true);
|
||
const shareUrl = buildWorldShareUrl(sharedTask.shareToken);
|
||
if (!shareUrl) {
|
||
throw new Error("分享链接生成失败");
|
||
}
|
||
await navigator.clipboard.writeText(shareUrl);
|
||
toast.success(
|
||
task.shareEnabled ? "已复制世界分享链接" : "已开启分享并复制链接"
|
||
);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "分享世界失败");
|
||
}
|
||
},
|
||
[updateWorldShareState]
|
||
);
|
||
|
||
const handleDisableCurrentShare = useCallback(async () => {
|
||
if (!currentTask || currentTask.provider !== "worldlabs-3d") {
|
||
toast.error("当前没有可关闭分享的世界资产");
|
||
return;
|
||
}
|
||
if (!currentTask.shareEnabled) {
|
||
toast.error("当前世界尚未开启分享");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await updateWorldShareState(currentTask, false);
|
||
toast.success("已关闭世界分享");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "关闭分享失败");
|
||
}
|
||
}, [currentTask, updateWorldShareState]);
|
||
|
||
const handleOpenWorldUrl = () => {
|
||
const worldUrl = currentTask?.assetPayload?.worldUrl;
|
||
if (!worldUrl) {
|
||
toast.error("当前任务暂无可打开的 World 链接");
|
||
return;
|
||
}
|
||
window.open(worldUrl, "_blank", "noopener,noreferrer");
|
||
};
|
||
|
||
const handleOpenPanoUrl = () => {
|
||
const imageUrl =
|
||
currentTask?.assetPayload?.panoUrl ||
|
||
currentTask?.assetPayload?.thumbnailUrl ||
|
||
currentPreviewImage;
|
||
if (!imageUrl) {
|
||
toast.error("当前任务暂无可打开的图像预览");
|
||
return;
|
||
}
|
||
window.open(imageUrl, "_blank", "noopener,noreferrer");
|
||
};
|
||
|
||
const handleConvertAndDownload = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
setIsSubmitting(true);
|
||
try {
|
||
const baseTaskId = readyTask.taskId;
|
||
const payload: Record<string, any> = {
|
||
taskType: "convert_model",
|
||
originalModelTaskId: baseTaskId,
|
||
format: exportFormat,
|
||
textureSize: 4096,
|
||
textureFormat: "JPEG",
|
||
};
|
||
const task = await submitTask({
|
||
provider: "tripo-3d",
|
||
payload,
|
||
mode: "convert",
|
||
promptText: `${modeLabel(readyTask.mode)} -> ${exportFormat}`,
|
||
modelLabel: readyTask.modelVersion,
|
||
sourceTaskId: baseTaskId,
|
||
exportFormat,
|
||
});
|
||
setAutoDownloadTaskId(task.taskId);
|
||
toast.success(`已提交${exportFormat}转换任务,完成后自动下载`);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "格式转换失败");
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleRefineModel = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
setIsSubmitting(true);
|
||
try {
|
||
const payload: Record<string, any> = {
|
||
taskType: "refine_model",
|
||
draftModelTaskId: readyTask.taskId,
|
||
};
|
||
await submitTask({
|
||
provider: "tripo-3d",
|
||
payload,
|
||
mode: "refine",
|
||
promptText: `精修任务(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
modelLabel: readyTask.modelVersion,
|
||
sourceTaskId: readyTask.taskId,
|
||
});
|
||
toast.success("已提交模型精修任务");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "提交精修任务失败");
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleTextureModel = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "texture_model",
|
||
originalModelTaskId: readyTask.taskId,
|
||
texture,
|
||
pbr,
|
||
textureQuality,
|
||
textureAlignment,
|
||
modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined,
|
||
textureSeed: textureSeed.trim() ? Number(textureSeed) : undefined,
|
||
texturePromptText: texturePromptText.trim() || undefined,
|
||
},
|
||
mode: "texture",
|
||
promptText: `纹理重绘(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交纹理重绘任务",
|
||
});
|
||
};
|
||
|
||
const handleStylizeModel = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "stylize_model",
|
||
originalModelTaskId: readyTask.taskId,
|
||
style: stylizeStyle,
|
||
blockSize: Number.isFinite(stylizeBlockSize)
|
||
? stylizeBlockSize
|
||
: undefined,
|
||
},
|
||
mode: "stylize",
|
||
promptText: `风格化 ${stylizeStyle}(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交风格化任务",
|
||
});
|
||
};
|
||
|
||
const handleRigCheck = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "animate_prerigcheck",
|
||
originalModelTaskId: readyTask.taskId,
|
||
},
|
||
mode: "rigcheck",
|
||
promptText: `可绑定检测(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交可绑定检测任务",
|
||
});
|
||
};
|
||
|
||
const handleRigModel = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "animate_rig",
|
||
originalModelTaskId: readyTask.taskId,
|
||
outFormat: rigFormat,
|
||
rigType,
|
||
spec: rigSpec,
|
||
},
|
||
mode: "rig",
|
||
promptText: `自动绑定 ${rigType}(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交自动绑定任务",
|
||
});
|
||
};
|
||
|
||
const handleRetargetAnimation = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "animate_retarget",
|
||
originalModelTaskId: readyTask.taskId,
|
||
animation: retargetAnimation,
|
||
outFormat: rigFormat,
|
||
bakeAnimation: retargetBake,
|
||
exportWithGeometry: retargetExportWithGeometry,
|
||
animateInPlace: retargetInPlace,
|
||
},
|
||
mode: "retarget",
|
||
promptText: `动作 ${retargetAnimation}(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交动作重定向任务",
|
||
});
|
||
};
|
||
|
||
const handleMeshSegmentation = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "mesh_segmentation",
|
||
originalModelTaskId: readyTask.taskId,
|
||
},
|
||
mode: "segment",
|
||
promptText: `网格分割(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交网格分割任务",
|
||
});
|
||
};
|
||
|
||
const handleMeshCompletion = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
const parts = completionPartNames
|
||
.split(",")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "mesh_completion",
|
||
originalModelTaskId: readyTask.taskId,
|
||
partNames: parts.length ? parts : undefined,
|
||
},
|
||
mode: "complete",
|
||
promptText: `网格补全(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交网格补全任务",
|
||
});
|
||
};
|
||
|
||
const handleLowpolyOptimize = async () => {
|
||
const readyTask = getReadyTask();
|
||
if (!readyTask) return;
|
||
|
||
await submitDerivedTask({
|
||
payload: {
|
||
taskType: "highpoly_to_lowpoly",
|
||
originalModelTaskId: readyTask.taskId,
|
||
faceLimit: Number.isFinite(lowpolyFaceLimit)
|
||
? lowpolyFaceLimit
|
||
: undefined,
|
||
quad: lowpolyQuad,
|
||
bake: lowpolyBake,
|
||
},
|
||
mode: "lowpoly",
|
||
promptText: `低模优化(来源 ${readyTask.taskId.slice(0, 8)})`,
|
||
sourceTaskId: readyTask.taskId,
|
||
successMessage: "已提交低模优化任务",
|
||
});
|
||
};
|
||
|
||
const handleSelectTask = (task: TaskRecord) => {
|
||
setWorkspaceMode(inferWorkspaceModeFromTask(task));
|
||
setCurrentTask(task);
|
||
if (task.status === "success" && task.modelUrl) {
|
||
setViewerLayers([]);
|
||
setLayerVisibility({});
|
||
setIsolatedLayerId(null);
|
||
}
|
||
};
|
||
|
||
const handleClearHistory = async () => {
|
||
const prevTaskHistory = taskHistory;
|
||
const prevCurrentTask = currentTask;
|
||
const tasksToDelete = visibleTaskHistory;
|
||
setTaskHistory((prev) =>
|
||
prev.filter((task) => !tasksToDelete.some((candidate) => candidate.taskId === task.taskId))
|
||
);
|
||
setCurrentTask((prev) =>
|
||
prev && tasksToDelete.some((task) => task.taskId === prev.taskId) ? null : prev
|
||
);
|
||
|
||
try {
|
||
if (tasksToDelete.length === taskHistory.length) {
|
||
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
|
||
method: "DELETE",
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok || payload?.success === false) {
|
||
throw new Error(payload?.error || "清空资产库失败");
|
||
}
|
||
} else {
|
||
for (const task of tasksToDelete) {
|
||
const response = await fetch(
|
||
`/api/canvas/${id}/model3d-assets?taskId=${encodeURIComponent(task.taskId)}`,
|
||
{
|
||
method: "DELETE",
|
||
}
|
||
);
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok || payload?.success === false) {
|
||
throw new Error(payload?.error || "清空资产库失败");
|
||
}
|
||
}
|
||
}
|
||
if (typeof window !== "undefined") {
|
||
if (tasksToDelete.length === taskHistory.length) {
|
||
window.localStorage.removeItem(legacyHistoryStorageKey);
|
||
}
|
||
}
|
||
toast.success("资产库已清空");
|
||
} catch (error) {
|
||
setTaskHistory(prevTaskHistory);
|
||
setCurrentTask(prevCurrentTask);
|
||
toast.error(error instanceof Error ? error.message : "清空资产库失败");
|
||
}
|
||
};
|
||
|
||
const handleLayersChange = useCallback((layers: Model3DLayer[]) => {
|
||
setViewerLayers(layers);
|
||
setLayerVisibility((prev) => {
|
||
const next: Record<string, boolean> = {};
|
||
for (const layer of layers) {
|
||
next[layer.id] = prev[layer.id] !== false;
|
||
}
|
||
return next;
|
||
});
|
||
setIsolatedLayerId((prev) =>
|
||
prev && layers.some((layer) => layer.id === prev) ? prev : null
|
||
);
|
||
}, []);
|
||
|
||
const currentWorldAsset =
|
||
currentTask?.provider === "worldlabs-3d" ? currentTask.assetPayload ?? null : null;
|
||
const currentWorldId =
|
||
currentWorldAsset?.worldId || currentTask?.sourceTaskId || null;
|
||
const currentWorldShareUrl =
|
||
currentTask?.provider === "worldlabs-3d" && currentTask?.shareEnabled
|
||
? buildWorldShareUrl(currentTask.shareToken)
|
||
: null;
|
||
const currentSpzEntries = Object.entries(currentWorldAsset?.spzUrls || {});
|
||
const currentPrimarySpzUrl =
|
||
currentSpzEntries.find(([, url]) => typeof url === "string" && url.trim())?.[1] || null;
|
||
const currentWorldImageUrl =
|
||
currentWorldAsset?.panoUrl ||
|
||
currentWorldAsset?.thumbnailUrl ||
|
||
(workspaceMode === "world" ? currentPreviewImage : null) ||
|
||
null;
|
||
const importDisabled =
|
||
!currentTask ||
|
||
currentTask.status !== "success" ||
|
||
(currentTask.provider === "worldlabs-3d"
|
||
? !Boolean(
|
||
currentModelUrl ||
|
||
currentWorldAsset?.worldUrl ||
|
||
currentWorldImageUrl ||
|
||
currentPrimarySpzUrl
|
||
)
|
||
: !currentModelUrl);
|
||
const activeStatusLabel = currentTask
|
||
? statusLabel(currentTask.status)
|
||
: "未开始";
|
||
const activePromptSummary =
|
||
currentTask?.prompt?.trim() ||
|
||
(workspaceMode === "world" ? "尚未创建世界任务" : "尚未创建模型任务");
|
||
const activeProviderLabel =
|
||
currentTask?.provider === "worldlabs-3d" || workspaceMode === "world"
|
||
? "World Labs"
|
||
: "Tripo3D";
|
||
const activeRelativeTime = currentTask
|
||
? formatDistanceToNow(new Date(currentTask.createdAt), {
|
||
addSuffix: true,
|
||
locale: zhCN,
|
||
})
|
||
: null;
|
||
const availableWorldViewportModes = useMemo(() => {
|
||
const modes: WorldViewportMode[] = [];
|
||
if (currentPrimarySpzUrl) modes.push("splat");
|
||
if (currentWorldAsset?.worldUrl) modes.push("world");
|
||
if (currentWorldImageUrl) modes.push("imagery");
|
||
if (currentModelUrl) modes.push("mesh");
|
||
return modes;
|
||
}, [currentModelUrl, currentPrimarySpzUrl, currentWorldAsset?.worldUrl, currentWorldImageUrl]);
|
||
const preferredWorldViewportMode: WorldViewportMode =
|
||
currentPrimarySpzUrl
|
||
? "splat"
|
||
: currentWorldAsset?.worldUrl
|
||
? "world"
|
||
: currentWorldImageUrl
|
||
? "imagery"
|
||
: currentModelUrl
|
||
? "mesh"
|
||
: "world";
|
||
const activeWorkspaceTitle =
|
||
workspaceMode === "world" ? "3D 世界工作台" : "3D 工作台";
|
||
const viewerEmptyStateTitle =
|
||
workspaceMode === "world" ? "准备生成 3D 世界" : "准备生成 3D 资产";
|
||
const waitingTaskLabel =
|
||
workspaceMode === "world" ? "等待创建世界任务" : "等待创建模型任务";
|
||
useEffect(() => {
|
||
if (workspaceMode !== "world") return;
|
||
setWorldViewportMode(preferredWorldViewportMode);
|
||
}, [workspaceMode, currentTask?.taskId, preferredWorldViewportMode]);
|
||
|
||
useEffect(() => {
|
||
if (workspaceMode !== "world") return;
|
||
if (availableWorldViewportModes.length === 0) return;
|
||
if (!availableWorldViewportModes.includes(worldViewportMode)) {
|
||
setWorldViewportMode(preferredWorldViewportMode);
|
||
}
|
||
}, [
|
||
availableWorldViewportModes,
|
||
preferredWorldViewportMode,
|
||
workspaceMode,
|
||
worldViewportMode,
|
||
]);
|
||
|
||
return (
|
||
<div className="flex h-dvh w-full flex-col overflow-hidden bg-zinc-950 text-zinc-100">
|
||
{/* Top Navigation Bar */}
|
||
<header className="flex h-14 shrink-0 items-center justify-between border-white/10 border-b bg-zinc-950 px-4">
|
||
<div className="flex items-center gap-6">
|
||
{/* Logo & Brand */}
|
||
<div
|
||
className="flex cursor-pointer items-center gap-2 transition-opacity hover:opacity-80"
|
||
onClick={() => router.push("/")}
|
||
>
|
||
{displayLogo ? (
|
||
<img
|
||
alt={siteName}
|
||
className="h-6 w-6 object-contain"
|
||
src={displayLogo}
|
||
/>
|
||
) : (
|
||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-yellow-500/10">
|
||
<Sparkles className="h-4 w-4 text-yellow-500" />
|
||
</div>
|
||
)}
|
||
<span className="font-bold text-lg text-white tracking-tight">
|
||
{siteName}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div className="h-4 w-[1px] bg-white/10" />
|
||
|
||
{/* Nav Links */}
|
||
<nav className="flex items-center gap-1">
|
||
<Button
|
||
className="h-8 gap-1 px-2 text-zinc-400 hover:bg-white/5 hover:text-white"
|
||
variant="ghost"
|
||
>
|
||
{activeWorkspaceTitle}
|
||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||
</Button>
|
||
</nav>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{/* DCC Bridge (Mock) */}
|
||
<Button
|
||
className="hidden h-8 gap-2 text-zinc-400 hover:bg-white/5 hover:text-white sm:flex"
|
||
variant="ghost"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
<span className="text-xs">DCC Bridge</span>
|
||
</Button>
|
||
|
||
<div className="flex h-8 items-center rounded-full bg-blue-500/10 px-3 text-blue-400">
|
||
<span className="font-medium text-xs">积分</span>
|
||
<span className="ml-1 font-semibold text-sm">{pointsLabel}</span>
|
||
</div>
|
||
|
||
{/* User Profile */}
|
||
<div className="ml-1 h-8 w-8 overflow-hidden rounded-full border border-white/10 bg-zinc-800">
|
||
{user?.image ? (
|
||
<img
|
||
alt={user.name || "User"}
|
||
className="h-full w-full object-cover"
|
||
src={user.image}
|
||
/>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center font-semibold text-xs text-zinc-200">
|
||
{userAvatarFallback}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* 1. Left Nav Sidebar (Icon only) */}
|
||
<aside className="z-30 flex w-16 flex-col items-center border-white/10 border-r bg-zinc-950 py-4">
|
||
<TooltipProvider delayDuration={0}>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="mb-6 h-10 w-10 rounded-xl text-zinc-400 hover:bg-white/10 hover:text-white"
|
||
onClick={() => router.push(`/canvas/${id}`)}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<ArrowLeft className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">返回画布</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
"h-10 w-10 rounded-xl",
|
||
activeTab === "create"
|
||
? "bg-yellow-500 text-black hover:bg-yellow-400"
|
||
: "text-zinc-400 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
onClick={() => setActiveTab("create")}
|
||
size="icon"
|
||
variant={activeTab === "create" ? "default" : "ghost"}
|
||
>
|
||
<Sparkles className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{workspaceMode === "world" ? "生成世界" : "生成模型"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
"h-10 w-10 rounded-xl",
|
||
activeTab === "edit"
|
||
? "bg-yellow-500 text-black hover:bg-yellow-400"
|
||
: "text-zinc-400 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
onClick={() => setActiveTab("edit")}
|
||
size="icon"
|
||
variant={activeTab === "edit" ? "default" : "ghost"}
|
||
>
|
||
<WandSparkles className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{workspaceMode === "world" ? "世界资产" : "编辑与优化"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className={cn(
|
||
"h-10 w-10 rounded-xl",
|
||
activeTab === "scene"
|
||
? "bg-yellow-500 text-black hover:bg-yellow-400"
|
||
: "text-zinc-400 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
onClick={() => setActiveTab("scene")}
|
||
size="icon"
|
||
variant={activeTab === "scene" ? "default" : "ghost"}
|
||
>
|
||
<Layers3 className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">
|
||
{workspaceMode === "world" ? "世界详情" : "场景层级"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
|
||
<div className="mt-auto flex flex-col gap-4">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/5 font-bold text-xs text-yellow-500">
|
||
{pointsLabel}
|
||
</div>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="right">可用积分</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</TooltipProvider>
|
||
</aside>
|
||
|
||
{/* 2. Left Tool Panel (Expandable Content) */}
|
||
<div className="z-20 flex w-80 flex-col border-white/10 border-r bg-zinc-900/50 backdrop-blur-sm">
|
||
<div className="space-y-3 border-white/10 border-b px-4 py-4">
|
||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-zinc-950 p-1">
|
||
<button
|
||
className={cn(
|
||
"rounded-md px-3 py-2 text-xs font-medium transition-all",
|
||
workspaceMode === "model"
|
||
? "bg-zinc-800 text-white shadow-sm"
|
||
: "text-zinc-500 hover:text-zinc-300"
|
||
)}
|
||
onClick={() => setWorkspaceMode("model")}
|
||
type="button"
|
||
>
|
||
3D模型模式
|
||
</button>
|
||
<button
|
||
className={cn(
|
||
"rounded-md px-3 py-2 text-xs font-medium transition-all",
|
||
workspaceMode === "world"
|
||
? "bg-zinc-800 text-white shadow-sm"
|
||
: "text-zinc-500 hover:text-zinc-300"
|
||
)}
|
||
onClick={() => setWorkspaceMode("world")}
|
||
type="button"
|
||
>
|
||
3D世界模式
|
||
</button>
|
||
</div>
|
||
<h2 className="font-semibold text-sm text-white">
|
||
{activeTab === "create" &&
|
||
(workspaceMode === "world" ? "生成世界" : "生成模型")}
|
||
{activeTab === "edit" &&
|
||
(workspaceMode === "world" ? "世界资产" : "模型编辑")}
|
||
{activeTab === "scene" &&
|
||
(workspaceMode === "world" ? "世界详情" : "场景管理")}
|
||
</h2>
|
||
</div>
|
||
<ScrollArea className="flex-1">
|
||
<div className="space-y-6 p-4">
|
||
{activeTab === "create" && (
|
||
workspaceMode === "model" ? (
|
||
<>
|
||
<div className="grid grid-cols-4 gap-1 rounded-lg bg-zinc-950 p-1">
|
||
{[
|
||
{ id: "text", label: "文生", icon: PenLine },
|
||
{ id: "image", label: "图生", icon: ImagePlus },
|
||
{ id: "multiview", label: "多视", icon: Box },
|
||
{ id: "batch", label: "批量", icon: Images },
|
||
].map((m) => (
|
||
<button
|
||
className={cn(
|
||
"flex flex-col items-center justify-center gap-1 rounded-md py-2 text-[10px] transition-all",
|
||
mode === m.id
|
||
? "bg-zinc-800 text-white shadow-sm"
|
||
: "text-zinc-500 hover:text-zinc-300"
|
||
)}
|
||
key={m.id}
|
||
onClick={() => setMode(m.id as any)}
|
||
type="button"
|
||
>
|
||
<m.icon className="h-4 w-4" />
|
||
{m.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">提示词</Label>
|
||
<Textarea
|
||
className="min-h-24 resize-none border-white/10 bg-zinc-950 text-sm text-zinc-300 placeholder:text-zinc-700 focus-visible:ring-yellow-500/50"
|
||
onChange={(e) => setPrompt(e.target.value)}
|
||
placeholder="描述你想生成的3D模型..."
|
||
value={prompt}
|
||
/>
|
||
</div>
|
||
|
||
{mode === "image" && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">参考图</Label>
|
||
<label className="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-white/10 border-dashed bg-zinc-950 px-3 py-6 text-xs text-zinc-500 transition-colors hover:bg-zinc-900/50">
|
||
{referencePreviewUrl ? (
|
||
<div className="relative w-full">
|
||
<img
|
||
alt="ref"
|
||
className="h-32 w-full rounded-md object-contain"
|
||
src={referencePreviewUrl}
|
||
/>
|
||
<Button
|
||
className="-right-2 -top-2 absolute h-6 w-6 rounded-full"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
setReferenceImage(null);
|
||
}}
|
||
size="icon"
|
||
variant="destructive"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<PlusCircle className="h-6 w-6" />
|
||
<span>点击上传图片</span>
|
||
</>
|
||
)}
|
||
<Input
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) =>
|
||
setReferenceImage(e.target.files?.[0] || null)
|
||
}
|
||
type="file"
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{mode === "multiview" && (
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{multiViewSlots.map((slot, i) => (
|
||
<label
|
||
className="relative flex aspect-square cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-white/10 border-dashed bg-zinc-950 hover:bg-zinc-900"
|
||
key={slot.id}
|
||
>
|
||
{slot.previewUrl ? (
|
||
<img
|
||
className="h-full w-full object-cover"
|
||
src={slot.previewUrl}
|
||
/>
|
||
) : (
|
||
<span className="text-xs text-zinc-600">
|
||
视图 {i + 1}
|
||
</span>
|
||
)}
|
||
<Input
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) =>
|
||
handleMultiViewFileChange(
|
||
i,
|
||
e.target.files?.[0] || null
|
||
)
|
||
}
|
||
type="file"
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Collapsible
|
||
onOpenChange={setAdvancedOpen}
|
||
open={advancedOpen}
|
||
>
|
||
<CollapsibleTrigger asChild>
|
||
<Button
|
||
className="w-full justify-between border-white/10 bg-transparent text-zinc-400 hover:bg-white/5 hover:text-white"
|
||
variant="outline"
|
||
>
|
||
高级参数
|
||
{advancedOpen ? (
|
||
<ChevronUp className="h-4 w-4" />
|
||
) : (
|
||
<ChevronDown className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="space-y-4 pt-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">
|
||
模型版本
|
||
</Label>
|
||
<Select
|
||
onValueChange={(v) => setModelVersion(v as any)}
|
||
value={modelVersion}
|
||
>
|
||
<SelectTrigger className="border-white/10 bg-zinc-950 text-zinc-300">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
|
||
{MODEL_VERSION_OPTIONS.map((v) => (
|
||
<SelectItem
|
||
className="focus:bg-zinc-800 focus:text-white"
|
||
key={v}
|
||
value={v}
|
||
>
|
||
{v}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-3 rounded-lg border border-white/10 bg-zinc-950 p-3">
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span>PBR 材质</span>
|
||
<Switch
|
||
checked={pbr}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setPbr}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span>四边面优化</span>
|
||
<Switch
|
||
checked={quad}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setQuad}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span>智能低模</span>
|
||
<Switch
|
||
checked={smartLowPoly}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setSmartLowPoly}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
<Button
|
||
className="w-full bg-yellow-500 text-black hover:bg-yellow-400"
|
||
disabled={isSubmitting}
|
||
onClick={handleGenerate}
|
||
size="lg"
|
||
>
|
||
{isSubmitting ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Sparkles className="mr-2 h-4 w-4" />
|
||
)}
|
||
{isSubmitting ? "生成中..." : "开始生成"}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-4 gap-1 rounded-lg bg-zinc-950 p-1">
|
||
{[
|
||
{ id: "text", label: "文生", icon: PenLine },
|
||
{ id: "image", label: "图生", icon: ImagePlus },
|
||
{ id: "multi-image", label: "多图", icon: Images },
|
||
{ id: "video", label: "视频", icon: Video },
|
||
].map((item) => (
|
||
<button
|
||
className={cn(
|
||
"flex flex-col items-center justify-center gap-1 rounded-md py-2 text-[10px] transition-all",
|
||
worldMode === item.id
|
||
? "bg-zinc-800 text-white shadow-sm"
|
||
: "text-zinc-500 hover:text-zinc-300"
|
||
)}
|
||
key={item.id}
|
||
onClick={() => setWorldMode(item.id as WorldGenerationMode)}
|
||
type="button"
|
||
>
|
||
<item.icon className="h-4 w-4" />
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">世界名称</Label>
|
||
<Input
|
||
className="border-white/10 bg-zinc-950 text-zinc-300"
|
||
onChange={(e) => setWorldDisplayName(e.target.value)}
|
||
placeholder="可选,用于区分历史记录"
|
||
value={worldDisplayName}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">
|
||
{worldMode === "text" ? "世界描述" : "附加提示词"}
|
||
</Label>
|
||
<Textarea
|
||
className="min-h-24 resize-none border-white/10 bg-zinc-950 text-sm text-zinc-300 placeholder:text-zinc-700 focus-visible:ring-yellow-500/50"
|
||
onChange={(e) => setWorldPrompt(e.target.value)}
|
||
placeholder={
|
||
worldMode === "text"
|
||
? "描述你想生成的 3D 世界,例如:sunlit modern loft with warm wood and panoramic city skyline"
|
||
: "可选。补充风格、氛围或场景要求。"
|
||
}
|
||
value={worldPrompt}
|
||
/>
|
||
</div>
|
||
|
||
{worldMode === "image" && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">参考图</Label>
|
||
<label className="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-white/10 border-dashed bg-zinc-950 px-3 py-6 text-xs text-zinc-500 transition-colors hover:bg-zinc-900/50">
|
||
{worldImagePreviewUrl ? (
|
||
<div className="relative w-full">
|
||
<img
|
||
alt="world-ref"
|
||
className="h-32 w-full rounded-md object-contain"
|
||
src={worldImagePreviewUrl}
|
||
/>
|
||
<Button
|
||
className="-right-2 -top-2 absolute h-6 w-6 rounded-full"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
setWorldImageFile(null);
|
||
}}
|
||
size="icon"
|
||
variant="destructive"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<ImageIcon className="h-6 w-6" />
|
||
<span>点击上传单张参考图</span>
|
||
</>
|
||
)}
|
||
<Input
|
||
accept="image/png,image/jpeg,image/webp"
|
||
className="hidden"
|
||
onChange={(e) =>
|
||
setWorldImageFile(e.target.files?.[0] || null)
|
||
}
|
||
type="file"
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{worldMode === "multi-image" && (
|
||
<div className="space-y-3">
|
||
<div className="rounded-lg border border-white/10 bg-zinc-950/70 p-3 text-[11px] text-zinc-500">
|
||
建议上传围绕同一空间的多角度图片,并尽量填写合理的方位角。常用值:0 / 90 / 180 / 270。
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{worldImageSlots.map((slot, index) => (
|
||
<div
|
||
className="space-y-2 rounded-lg border border-white/10 bg-zinc-950 p-2"
|
||
key={slot.id}
|
||
>
|
||
<label className="relative flex aspect-square cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-white/10 border-dashed bg-zinc-950 hover:bg-zinc-900">
|
||
{slot.previewUrl ? (
|
||
<img
|
||
className="h-full w-full object-cover"
|
||
src={slot.previewUrl}
|
||
/>
|
||
) : (
|
||
<span className="text-xs text-zinc-600">
|
||
角度图 {index + 1}
|
||
</span>
|
||
)}
|
||
<Input
|
||
accept="image/png,image/jpeg,image/webp"
|
||
className="hidden"
|
||
onChange={(e) =>
|
||
handleWorldImageSlotChange(
|
||
index,
|
||
e.target.files?.[0] || null
|
||
)
|
||
}
|
||
type="file"
|
||
/>
|
||
</label>
|
||
<div className="space-y-1">
|
||
<Label className="text-[11px] text-zinc-500">
|
||
方位角(度)
|
||
</Label>
|
||
<Input
|
||
className="h-8 border-white/10 bg-zinc-900 text-xs text-zinc-300"
|
||
onChange={(e) =>
|
||
handleWorldImageAzimuthChange(
|
||
index,
|
||
Number(e.target.value || 0)
|
||
)
|
||
}
|
||
type="number"
|
||
value={slot.azimuth}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{worldMode === "video" && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">视频输入</Label>
|
||
<label className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border border-white/10 border-dashed bg-zinc-950 px-3 py-6 text-xs text-zinc-500 transition-colors hover:bg-zinc-900/50">
|
||
{worldVideoFile ? (
|
||
<>
|
||
<div className="flex items-center gap-2 text-zinc-300">
|
||
<Video className="h-4 w-4" />
|
||
<span className="max-w-[220px] truncate">
|
||
{worldVideoFile.name}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
className="h-7"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
setWorldVideoFile(null);
|
||
}}
|
||
size="sm"
|
||
variant="destructive"
|
||
>
|
||
移除视频
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Video className="h-6 w-6" />
|
||
<span>点击上传视频</span>
|
||
</>
|
||
)}
|
||
<Input
|
||
accept="video/mp4,video/quicktime,video/webm,video/x-m4v"
|
||
className="hidden"
|
||
onChange={(e) =>
|
||
setWorldVideoFile(e.target.files?.[0] || null)
|
||
}
|
||
type="file"
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
<Collapsible
|
||
onOpenChange={setAdvancedOpen}
|
||
open={advancedOpen}
|
||
>
|
||
<CollapsibleTrigger asChild>
|
||
<Button
|
||
className="w-full justify-between border-white/10 bg-transparent text-zinc-400 hover:bg-white/5 hover:text-white"
|
||
variant="outline"
|
||
>
|
||
世界参数
|
||
{advancedOpen ? (
|
||
<ChevronUp className="h-4 w-4" />
|
||
) : (
|
||
<ChevronDown className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="space-y-4 pt-4">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-zinc-400">World 模型</Label>
|
||
<Select
|
||
onValueChange={(value) => setWorldModel(value as any)}
|
||
value={worldModel}
|
||
>
|
||
<SelectTrigger className="border-white/10 bg-zinc-950 text-zinc-300">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
|
||
{WORLDLABS_MODEL_OPTIONS.map((item) => (
|
||
<SelectItem
|
||
className="focus:bg-zinc-800 focus:text-white"
|
||
key={item}
|
||
value={item}
|
||
>
|
||
{item}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-3 rounded-lg border border-white/10 bg-zinc-950 p-3">
|
||
{(worldMode === "image" || worldMode === "multi-image") && (
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span>关闭自动重描述</span>
|
||
<Switch
|
||
checked={worldDisableRecaption}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setWorldDisableRecaption}
|
||
/>
|
||
</div>
|
||
)}
|
||
{worldMode === "image" && (
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span>输入图已是全景图</span>
|
||
<Switch
|
||
checked={worldIsPano}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setWorldIsPano}
|
||
/>
|
||
</div>
|
||
)}
|
||
{worldMode === "multi-image" && (
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span>重建输入图</span>
|
||
<Switch
|
||
checked={worldReconstructImages}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setWorldReconstructImages}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||
<span>生成公开 World</span>
|
||
<Switch
|
||
checked={worldMakePublic}
|
||
className="data-[state=checked]:bg-yellow-500"
|
||
onCheckedChange={setWorldMakePublic}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
<Button
|
||
className="w-full bg-yellow-500 text-black hover:bg-yellow-400"
|
||
disabled={isSubmitting}
|
||
onClick={handleWorldGenerate}
|
||
size="lg"
|
||
>
|
||
{isSubmitting || uploadingImage ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Globe className="mr-2 h-4 w-4" />
|
||
)}
|
||
{isSubmitting || uploadingImage ? "处理中..." : "开始生成世界"}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)
|
||
)}
|
||
|
||
{activeTab === "edit" &&
|
||
(workspaceMode === "model" ? (
|
||
<div className="space-y-6">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button
|
||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||
disabled={importDisabled}
|
||
onClick={handleImportToCanvas}
|
||
variant="secondary"
|
||
>
|
||
<PlusCircle className="h-4 w-4" /> 导入画布
|
||
</Button>
|
||
<Button
|
||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||
disabled={!currentModelUrl}
|
||
onClick={handleDownloadCurrent}
|
||
variant="secondary"
|
||
>
|
||
<Download className="h-4 w-4" /> 下载
|
||
</Button>
|
||
</div>
|
||
|
||
<Separator className="bg-white/10" />
|
||
|
||
<div className="space-y-3">
|
||
<h4 className="flex items-center gap-2 font-medium text-xs text-zinc-400">
|
||
<Package className="h-3.5 w-3.5" /> 格式转换
|
||
</h4>
|
||
<div className="flex gap-2">
|
||
<Select
|
||
onValueChange={(v) => setExportFormat(v as any)}
|
||
value={exportFormat}
|
||
>
|
||
<SelectTrigger className="flex-1 border-white/10 bg-zinc-950 text-zinc-300">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
|
||
{EXPORT_FORMAT_OPTIONS.map((f) => (
|
||
<SelectItem
|
||
className="focus:bg-zinc-800 focus:text-white"
|
||
key={f}
|
||
value={f}
|
||
>
|
||
{f}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
className="border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={
|
||
isSubmitting ||
|
||
!currentTask ||
|
||
currentTask.status !== "success"
|
||
}
|
||
onClick={handleConvertAndDownload}
|
||
variant="outline"
|
||
>
|
||
转换
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<h4 className="mb-2 font-medium text-xs text-zinc-400">
|
||
常用操作
|
||
</h4>
|
||
<Button
|
||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={
|
||
isSubmitting ||
|
||
!currentTask ||
|
||
currentTask.status !== "success"
|
||
}
|
||
onClick={handleRefineModel}
|
||
variant="outline"
|
||
>
|
||
<Sparkles className="mr-2 h-4 w-4" /> 模型精修
|
||
</Button>
|
||
<Button
|
||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={
|
||
isSubmitting ||
|
||
!currentTask ||
|
||
currentTask.status !== "success"
|
||
}
|
||
onClick={handleTextureModel}
|
||
variant="outline"
|
||
>
|
||
<Palette className="mr-2 h-4 w-4" /> 纹理重绘
|
||
</Button>
|
||
<Button
|
||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={
|
||
isSubmitting ||
|
||
!currentTask ||
|
||
currentTask.status !== "success"
|
||
}
|
||
onClick={handleRigModel}
|
||
variant="outline"
|
||
>
|
||
<Bone className="mr-2 h-4 w-4" /> 自动绑定
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button
|
||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||
disabled={importDisabled}
|
||
onClick={handleImportToCanvas}
|
||
variant="secondary"
|
||
>
|
||
<PlusCircle className="h-4 w-4" /> 导入画布
|
||
</Button>
|
||
<Button
|
||
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
|
||
disabled={!currentModelUrl}
|
||
onClick={handleDownloadCurrent}
|
||
variant="secondary"
|
||
>
|
||
<Download className="h-4 w-4" /> 下载 Mesh
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button
|
||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={!currentWorldAsset?.worldUrl}
|
||
onClick={handleOpenWorldUrl}
|
||
variant="outline"
|
||
>
|
||
<Globe className="mr-2 h-4 w-4" /> 打开 World
|
||
</Button>
|
||
<Button
|
||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={!currentWorldImageUrl}
|
||
onClick={handleOpenPanoUrl}
|
||
variant="outline"
|
||
>
|
||
<ImageIcon className="mr-2 h-4 w-4" /> 打开图像
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button
|
||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={!currentTask || currentTask.status !== "success"}
|
||
onClick={() => void handleShareTask(currentTask)}
|
||
variant="outline"
|
||
>
|
||
<Link2 className="mr-2 h-4 w-4" />
|
||
{currentWorldShareUrl ? "复制分享链接" : "开启分享"}
|
||
</Button>
|
||
<Button
|
||
className="w-full justify-start border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
disabled={!currentWorldShareUrl}
|
||
onClick={() => void handleDisableCurrentShare()}
|
||
variant="outline"
|
||
>
|
||
<X className="mr-2 h-4 w-4" /> 关闭分享
|
||
</Button>
|
||
</div>
|
||
|
||
<Card className="border-white/10 bg-zinc-950/60 text-zinc-100">
|
||
<CardHeader className="space-y-1 pb-3">
|
||
<CardTitle className="text-sm">当前世界资产</CardTitle>
|
||
<CardDescription className="text-xs text-zinc-500">
|
||
当前支持以原生 world3d 节点导入画布,并保留 World、Splat、图像与 Mesh 入口。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3 text-xs text-zinc-400">
|
||
<div className="space-y-1">
|
||
<div className="text-[11px] text-zinc-500">描述</div>
|
||
<div className="line-clamp-3 rounded-md bg-white/5 px-3 py-2 text-zinc-200">
|
||
{currentWorldAsset?.caption ||
|
||
currentTask?.prompt ||
|
||
"暂无描述"}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||
<div className="text-[11px] text-zinc-500">World ID</div>
|
||
<div className="truncate text-zinc-200">
|
||
{currentWorldId || "暂无"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||
<div className="text-[11px] text-zinc-500">模型</div>
|
||
<div className="truncate text-zinc-200">
|
||
{currentWorldAsset?.model ||
|
||
currentTask?.modelVersion ||
|
||
"默认"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between rounded-md bg-white/5 px-3 py-2">
|
||
<span className="flex items-center gap-2">
|
||
<Link2 className="h-3.5 w-3.5" />
|
||
World 页面
|
||
</span>
|
||
<Badge
|
||
className="text-[10px]"
|
||
variant={
|
||
currentWorldAsset?.worldUrl ? "default" : "secondary"
|
||
}
|
||
>
|
||
{currentWorldAsset?.worldUrl ? "可用" : "暂无"}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex items-center justify-between rounded-md bg-white/5 px-3 py-2">
|
||
<span className="flex items-center gap-2">
|
||
<ImageIcon className="h-3.5 w-3.5" />
|
||
图像预览
|
||
</span>
|
||
<Badge
|
||
className="text-[10px]"
|
||
variant={currentWorldImageUrl ? "default" : "secondary"}
|
||
>
|
||
{currentWorldImageUrl ? "可用" : "暂无"}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex items-center justify-between rounded-md bg-white/5 px-3 py-2">
|
||
<span className="flex items-center gap-2">
|
||
<Box className="h-3.5 w-3.5" />
|
||
Mesh
|
||
</span>
|
||
<Badge
|
||
className="text-[10px]"
|
||
variant={currentModelUrl ? "default" : "secondary"}
|
||
>
|
||
{currentModelUrl ? "可导入" : "缺失"}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex items-center justify-between rounded-md bg-white/5 px-3 py-2">
|
||
<span className="flex items-center gap-2">
|
||
<Link2 className="h-3.5 w-3.5" />
|
||
分享页
|
||
</span>
|
||
<Badge
|
||
className="text-[10px]"
|
||
variant={currentWorldShareUrl ? "default" : "secondary"}
|
||
>
|
||
{currentWorldShareUrl ? "已开启" : "未开启"}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
{currentWorldShareUrl ? (
|
||
<div className="rounded-md bg-white/5 px-3 py-2 text-zinc-200">
|
||
<div className="text-[11px] text-zinc-500">分享链接</div>
|
||
<div className="mt-1 truncate">{currentWorldShareUrl}</div>
|
||
</div>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
))}
|
||
|
||
{activeTab === "scene" &&
|
||
(workspaceMode === "model" ? (
|
||
<div className="space-y-4">
|
||
<div className="rounded-lg border border-white/10 bg-zinc-950 p-4">
|
||
<div className="grid grid-cols-2 gap-4 text-center">
|
||
<div>
|
||
<div className="font-bold text-lg text-white">
|
||
{viewerStats.triangles.toLocaleString()}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">面数</div>
|
||
</div>
|
||
<div>
|
||
<div className="font-bold text-lg text-white">
|
||
{viewerStats.vertices.toLocaleString()}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">顶点</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<h4 className="font-medium text-xs text-zinc-400">
|
||
网格列表 ({viewerLayers.length})
|
||
</h4>
|
||
{viewerLayers.map((layer) => (
|
||
<div
|
||
className="flex items-center justify-between rounded-md bg-zinc-900 p-2 text-xs text-zinc-300"
|
||
key={layer.id}
|
||
>
|
||
<span className="truncate">{layer.name}</span>
|
||
<div className="flex gap-1">
|
||
<Button
|
||
className="h-6 w-6 hover:text-white"
|
||
onClick={() =>
|
||
setLayerVisibility((prev) => ({
|
||
...prev,
|
||
[layer.id]: prev[layer.id] === false,
|
||
}))
|
||
}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
{layerVisibility[layer.id] === false ? (
|
||
<EyeOff className="h-3 w-3" />
|
||
) : (
|
||
<Eye className="h-3 w-3" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
className="h-6 w-6 hover:text-white"
|
||
onClick={() =>
|
||
setIsolatedLayerId((prev) =>
|
||
prev === layer.id ? null : layer.id
|
||
)
|
||
}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<Maximize className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Card className="border-white/10 bg-zinc-950/60 text-zinc-100">
|
||
<CardContent className="space-y-1 p-4 text-center">
|
||
<div className="font-bold text-lg text-white">
|
||
{currentModelUrl ? viewerStats.triangles.toLocaleString() : "0"}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">Mesh 面数</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="border-white/10 bg-zinc-950/60 text-zinc-100">
|
||
<CardContent className="space-y-1 p-4 text-center">
|
||
<div className="font-bold text-lg text-white">
|
||
{currentSpzEntries.length}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">SPZ 资产</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="border-white/10 bg-zinc-950/60 text-zinc-100">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm">世界摘要</CardTitle>
|
||
<CardDescription className="text-xs text-zinc-500">
|
||
这里展示 World Labs 返回的主要资产信息,便于在当前工作台里做导入和跳转。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3 text-xs text-zinc-400">
|
||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||
<div className="text-[11px] text-zinc-500">Caption</div>
|
||
<div className="mt-1 whitespace-pre-wrap text-zinc-200">
|
||
{currentWorldAsset?.caption ||
|
||
currentTask?.prompt ||
|
||
"暂无描述"}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||
<div className="text-[11px] text-zinc-500">World ID</div>
|
||
<div className="truncate text-zinc-200">
|
||
{currentWorldId || "暂无"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||
<div className="text-[11px] text-zinc-500">公开链接</div>
|
||
<div className="truncate text-zinc-200">
|
||
{currentWorldAsset?.worldUrl ? "已生成" : "暂无"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||
<div className="text-[11px] text-zinc-500">图像预览</div>
|
||
<div className="truncate text-zinc-200">
|
||
{currentWorldImageUrl ? "可访问" : "暂无"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||
<div className="text-[11px] text-zinc-500">Mesh</div>
|
||
<div className="truncate text-zinc-200">
|
||
{currentModelUrl ? "已归档" : "暂无"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-md bg-white/5 px-3 py-2">
|
||
<div className="text-[11px] text-zinc-500">分享页</div>
|
||
<div className="truncate text-zinc-200">
|
||
{currentWorldShareUrl ? "已开启" : "未开启"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{currentWorldShareUrl ? (
|
||
<div className="rounded-md bg-white/5 px-3 py-2 text-zinc-200">
|
||
<div className="text-[11px] text-zinc-500">分享链接</div>
|
||
<div className="mt-1 truncate">{currentWorldShareUrl}</div>
|
||
</div>
|
||
) : null}
|
||
<div className="space-y-2">
|
||
<h4 className="font-medium text-xs text-zinc-500">
|
||
SPZ 列表
|
||
</h4>
|
||
{currentSpzEntries.length ? (
|
||
currentSpzEntries.map(([label, url]) => (
|
||
<div
|
||
className="flex items-center justify-between rounded-md bg-white/5 px-3 py-2"
|
||
key={label}
|
||
>
|
||
<span className="truncate text-zinc-200">
|
||
{label}
|
||
</span>
|
||
<Button
|
||
className="h-7 border-white/10 bg-transparent text-zinc-300 hover:bg-white/10"
|
||
onClick={() =>
|
||
window.open(url, "_blank", "noopener,noreferrer")
|
||
}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
打开
|
||
</Button>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="rounded-md bg-white/5 px-3 py-3 text-zinc-500">
|
||
当前结果没有返回 SPZ 资产。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
|
||
{/* 3. Main Viewer Area */}
|
||
<div className="relative flex-1 bg-black">
|
||
<div className="absolute left-6 top-6 z-10 max-w-md rounded-2xl border border-white/10 bg-zinc-900/75 p-4 shadow-2xl backdrop-blur-md">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant={currentTask?.status === "success" ? "default" : "secondary"}>
|
||
{activeStatusLabel}
|
||
</Badge>
|
||
<Badge
|
||
className="border-white/10 bg-white/5 text-zinc-300"
|
||
variant="outline"
|
||
>
|
||
{activeProviderLabel}
|
||
</Badge>
|
||
{workspaceMode === "world" && (
|
||
<Badge
|
||
className="border-sky-500/30 bg-sky-500/10 text-sky-300"
|
||
variant="outline"
|
||
>
|
||
3D 世界
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<p className="mt-3 line-clamp-2 text-sm text-white">
|
||
{activePromptSummary}
|
||
</p>
|
||
<div className="mt-2 flex items-center gap-2 text-[11px] text-zinc-500">
|
||
<History className="h-3.5 w-3.5" />
|
||
<span>{activeRelativeTime || waitingTaskLabel}</span>
|
||
{currentWorldId && (
|
||
<>
|
||
<span className="text-zinc-700">•</span>
|
||
<span className="truncate">World {currentWorldId}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{workspaceMode === "world" && (
|
||
<div className="absolute right-6 top-6 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/75 p-2 shadow-2xl backdrop-blur-md">
|
||
{[
|
||
{
|
||
id: "splat" as WorldViewportMode,
|
||
label: "Splat",
|
||
icon: Sparkles,
|
||
disabled: !currentPrimarySpzUrl,
|
||
},
|
||
{
|
||
id: "world" as WorldViewportMode,
|
||
label: "World",
|
||
icon: Globe,
|
||
disabled: !currentWorldAsset?.worldUrl,
|
||
},
|
||
{
|
||
id: "imagery" as WorldViewportMode,
|
||
label: "图像",
|
||
icon: ImageIcon,
|
||
disabled: !currentWorldImageUrl,
|
||
},
|
||
{
|
||
id: "mesh" as WorldViewportMode,
|
||
label: "Mesh",
|
||
icon: Box,
|
||
disabled: !currentModelUrl,
|
||
},
|
||
].map((view) => (
|
||
<Button
|
||
className="gap-2 rounded-full"
|
||
disabled={view.disabled}
|
||
key={view.id}
|
||
onClick={() => setWorldViewportMode(view.id)}
|
||
size="sm"
|
||
variant={worldViewportMode === view.id ? "secondary" : "ghost"}
|
||
>
|
||
<view.icon className="h-4 w-4" />
|
||
{view.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{workspaceMode === "world" ? (
|
||
<World3DViewer
|
||
emptyStateTitle={viewerEmptyStateTitle}
|
||
imageUrl={currentWorldImageUrl}
|
||
meshLayerVisibility={effectiveLayerVisibility}
|
||
meshSettings={viewerSettings}
|
||
meshUrl={currentModelUrl}
|
||
mode={worldViewportMode === "imagery" ? "image" : worldViewportMode}
|
||
onMeshLayersChange={handleLayersChange}
|
||
onMeshStatsChange={setViewerStats}
|
||
previewImageUrl={currentPreviewImage}
|
||
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
|
||
splatUrl={currentPrimarySpzUrl}
|
||
worldUrl={currentWorldAsset?.worldUrl}
|
||
/>
|
||
) : (
|
||
<Model3DViewer
|
||
emptyStateTitle={viewerEmptyStateTitle}
|
||
layerVisibility={effectiveLayerVisibility}
|
||
modelUrl={currentModelUrl}
|
||
onLayersChange={handleLayersChange}
|
||
onStatsChange={setViewerStats}
|
||
previewImageUrl={currentPreviewImage}
|
||
settings={viewerSettings}
|
||
/>
|
||
)}
|
||
|
||
{/* Floating Toolbar */}
|
||
{(workspaceMode !== "world" || worldViewportMode === "mesh") && (
|
||
<div className="-translate-x-1/2 absolute bottom-8 left-1/2 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/80 p-2 shadow-2xl backdrop-blur-md">
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
|
||
onClick={() =>
|
||
setViewerSettings((prev) => ({
|
||
...prev,
|
||
showGrid: !prev.showGrid,
|
||
}))
|
||
}
|
||
size="icon"
|
||
variant={viewerSettings.showGrid ? "secondary" : "ghost"}
|
||
>
|
||
<Grid3X3 className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>网格</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
className="h-8 w-8 rounded-full text-zinc-300 hover:text-white"
|
||
onClick={() =>
|
||
setViewerSettings((prev) => ({
|
||
...prev,
|
||
autoRotate: !prev.autoRotate,
|
||
}))
|
||
}
|
||
size="icon"
|
||
variant={viewerSettings.autoRotate ? "secondary" : "ghost"}
|
||
>
|
||
<RotateCw className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>自动旋转</TooltipContent>
|
||
</Tooltip>
|
||
<Separator className="h-4 bg-white/20" orientation="vertical" />
|
||
<div className="flex items-center gap-2 px-2">
|
||
<Sun className="h-3 w-3 text-zinc-400" />
|
||
<Slider
|
||
className="w-20"
|
||
max={2}
|
||
min={0.2}
|
||
onValueChange={(v) =>
|
||
setViewerSettings((prev) => ({
|
||
...prev,
|
||
lightIntensity: v[0] || 1,
|
||
}))
|
||
}
|
||
step={0.1}
|
||
value={[viewerSettings.lightIntensity]}
|
||
/>
|
||
</div>
|
||
</TooltipProvider>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 4. Right Assets Panel */}
|
||
<div className="z-20 flex w-72 flex-col border-white/10 border-l bg-zinc-900/50 backdrop-blur-sm">
|
||
<div className="flex h-14 items-center justify-between border-white/10 border-b px-4">
|
||
<h2 className="font-semibold text-sm text-white">
|
||
{workspaceMode === "world" ? "世界资产库" : "模型资产库"}
|
||
</h2>
|
||
<Button
|
||
className="h-8 w-8 text-zinc-400 hover:text-white"
|
||
onClick={handleClearHistory}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<ScrollArea className="flex-1">
|
||
<div className="grid grid-cols-2 gap-2 p-3">
|
||
{visibleTaskHistory.map((task) => (
|
||
<button
|
||
className={cn(
|
||
"group relative aspect-square overflow-hidden rounded-lg border border-white/5 bg-zinc-950 transition-all hover:border-yellow-500/50",
|
||
currentTask?.taskId === task.taskId &&
|
||
"border-yellow-500 ring-1 ring-yellow-500/20"
|
||
)}
|
||
key={task.taskId}
|
||
onClick={() => handleSelectTask(task)}
|
||
type="button"
|
||
>
|
||
{task.provider === "worldlabs-3d" && task.status === "success" ? (
|
||
<Button
|
||
className={cn(
|
||
"absolute left-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
|
||
task.shareEnabled &&
|
||
"border-amber-400/40 bg-amber-400/20 text-amber-100 opacity-100"
|
||
)}
|
||
onClick={(event) => void handleShareTask(task, event)}
|
||
size="icon"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<Link2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
) : null}
|
||
{task.status === "success" && resolveTaskModelUrl(task) ? (
|
||
<Button
|
||
className="absolute right-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100"
|
||
onClick={(event) => handleDownloadTask(task, event)}
|
||
size="icon"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<Download className="h-3.5 w-3.5" />
|
||
</Button>
|
||
) : null}
|
||
{task.previewImageUrl ? (
|
||
<img
|
||
alt="preview"
|
||
className="h-full w-full object-cover transition-transform group-hover:scale-110"
|
||
src={task.previewImageUrl}
|
||
/>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center text-zinc-700">
|
||
<Box className="h-8 w-8" />
|
||
</div>
|
||
)}
|
||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 pt-6">
|
||
<div className="flex items-center justify-between">
|
||
<Badge
|
||
className="h-4 px-1 text-[9px]"
|
||
variant={
|
||
task.status === "success" ? "default" : "secondary"
|
||
}
|
||
>
|
||
{statusLabel(task.status)}
|
||
</Badge>
|
||
<Badge
|
||
className="border-white/10 bg-black/50 text-[9px] text-zinc-200"
|
||
variant="outline"
|
||
>
|
||
{task.provider === "worldlabs-3d" ? "WORLD" : "MODEL"}
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-1 truncate text-left text-[10px] text-zinc-300">
|
||
{modeLabel(task.mode)}
|
||
</div>
|
||
<div className="truncate text-left text-[10px] text-zinc-500">
|
||
{formatDistanceToNow(new Date(task.createdAt), {
|
||
addSuffix: true,
|
||
locale: zhCN,
|
||
})}
|
||
</div>
|
||
{task.status === "success" && resolveTaskModelUrl(task) ? (
|
||
<div className="mt-1 text-left text-[10px] text-zinc-200">
|
||
点击右上角可下载模型
|
||
</div>
|
||
) : null}
|
||
{task.provider === "worldlabs-3d" && task.shareEnabled ? (
|
||
<div className="mt-1 text-left text-[10px] text-amber-200">
|
||
已开启分享
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</button>
|
||
))}
|
||
{visibleTaskHistory.length === 0 && (
|
||
<div className="col-span-2 py-10 text-center text-xs text-zinc-500">
|
||
{workspaceMode === "world" ? "暂无世界记录" : "暂无模型记录"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|