Your Name b3b49b4fe2
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: improve worldlabs admin controls and splat selection
2026-04-05 23:42:37 +08:00

3595 lines
135 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import {
ArrowLeft,
Bone,
Box,
Boxes,
ChevronDown,
ChevronUp,
Download,
Eye,
EyeOff,
Grid3X3,
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 {
getSortedWorldLabsSplatEntries,
pickBestWorldLabsSplatUrl,
} from "@/lib/worldlabs/splat-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
? pickBestWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
: 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 = getSortedWorldLabsSplatEntries(currentWorldAsset?.spzUrls);
const currentPrimarySpzUrl = currentSpzEntries[0]?.[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 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 资产";
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">
{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>
);
}