"use client"; import { formatDistanceToNow } from "date-fns"; import { zhCN } from "date-fns/locale"; import dynamic from "next/dynamic"; import { ArrowLeft, Bone, Box, Boxes, Check, ChevronDown, ChevronUp, CircleDashed, Crosshair, 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, type Model3DViewerSettings, } from "@/components/canvas/model3d/Model3DViewer"; 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, pickBestArchivedWorldLabsSplatUrl, pickPreferredInlineWorldLabsSplatUrl, } from "@/lib/worldlabs/splat-utils"; import { DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, getDefaultThreeDModelAliasId, resolveThreeDModelBillingId, resolveThreeDModelDisplayName, type PublicThreeDModelAliasConfig, } from "@/lib/model3d/model-alias-config"; import { extractModelAssetFormat, resolvePreferredModelAssetUrl, } from "@/lib/model3d/model-url"; import { defaultPointsSystemConfig, get3DTaskCreditCost, get3DWorldCreditCost, normalizePointsSystemConfig, type PointsSystemConfig, } from "@/lib/points/config"; import { canvasService, type Model3DTaskStatus, model3DGenerationService, type WorldLabsTaskAssetPayload, worldLabsGenerationService, } from "@/services/canvas"; import { canvasSelectors, useCanvasStore } from "@/store/canvas"; const Model3DViewer = dynamic( () => import("@/components/canvas/model3d/Model3DViewer").then( (module) => module.Model3DViewer ), { loading: () => (
准备加载 3D 视图...
), ssr: false, } ); const World3DViewer = dynamic( () => import("@/components/canvas/world3d/World3DViewer").then( (module) => module.World3DViewer ), { loading: () => (
准备加载 World 视图...
), ssr: false, } ); type GenerationMode = "text" | "image" | "multiview" | "batch"; type WorkspaceMode = "model" | "world"; type WorldGenerationMode = "text" | "image" | "multi-image" | "video"; type WorldViewportMode = "world" | "imagery" | "mesh" | "splat"; type Model3DTaskAssetPayload = WorldLabsTaskAssetPayload & { providerId?: string | null; providerName?: string | null; sourceModelUrl?: string | null; storageArchivedAt?: string | null; }; 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?: Model3DTaskAssetPayload | 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 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([ "success", "failed", "banned", "expired", "cancelled", "unknown", ]); const TASK_MODE_SET = new Set([ "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 { return extractModelAssetFormat(modelUrl); } function resolveTaskModelUrl(task?: TaskRecord | null): string | null { if (!task) return null; const meshUrl = task.assetPayload?.meshUrl; return resolvePreferredModelAssetUrl({ primaryUrl: task.modelUrl, sourceUrl: task.assetPayload?.sourceModelUrl, fallbackUrl: typeof meshUrl === "string" ? meshUrl.trim() : null, }); } function resolveWorldTaskSplatUrl(task?: TaskRecord | null) { if (!task || task.provider !== "worldlabs-3d") return null; return pickBestArchivedWorldLabsSplatUrl(task.assetPayload?.spzUrls); } function resolveWorldTaskImageUrl(task?: TaskRecord | null) { if (!task || task.provider !== "worldlabs-3d") return null; return ( task.assetPayload?.panoUrl || task.assetPayload?.thumbnailUrl || task.previewImageUrl || null ); } function resolveWorldTaskPreviewUrl(task?: TaskRecord | null) { if (!task) return null; return ( task.previewImageUrl || task.assetPayload?.thumbnailUrl || task.assetPayload?.panoUrl || null ); } function getTaskPreviewCandidates(task?: TaskRecord | null) { if (!task) return []; const candidates = [ task.previewImageUrl, task.assetPayload?.thumbnailUrl, task.assetPayload?.panoUrl, task.assetPayload?.sourcePreviewImageUrl, task.assetPayload?.sourceThumbnailUrl, task.assetPayload?.sourcePanoUrl, ]; const seen = new Set(); return candidates .map((value) => String(value || "").trim()) .filter((value) => { if (!value || seen.has(value)) return false; seen.add(value); return true; }); } function isPersistableTaskPreviewUrl(value?: string | null) { return /^https?:\/\//i.test(String(value || "").trim()); } function TaskAssetPreview({ task }: { task: TaskRecord }) { const candidates = getTaskPreviewCandidates(task); const signature = candidates.join("|"); const [candidateIndex, setCandidateIndex] = useState(0); useEffect(() => { setCandidateIndex(0); }, [signature]); const previewUrl = candidates[candidateIndex] || null; if (!previewUrl) { return (
{task.status === "queued" || task.status === "running" ? ( ) : ( )}
); } return ( 3D 资产预览 setCandidateIndex((index) => index + 1)} src={previewUrl} /> ); } function canImportTaskToCanvas(task?: TaskRecord | null) { if (!task || task.status !== "success") return false; if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task)); return Boolean( resolveTaskModelUrl(task) || task.assetPayload?.worldUrl || resolveWorldTaskImageUrl(task) || resolveWorldTaskSplatUrl(task) ); } function resolveCanvasImportPosition(params: { index: number; totalCount: number; viewportX: number; viewportY: number; viewportZoom: number; stageWidth: number; stageHeight: number; targetWidth: number; targetHeight: number; }) { const column = params.index % 3; const row = Math.floor(params.index / 3); const gap = 36; const centerX = (params.stageWidth * 0.48 - params.viewportX) / params.viewportZoom; const centerY = (params.stageHeight * 0.45 - params.viewportY) / params.viewportZoom; const totalColumns = Math.min(3, Math.max(1, params.totalCount)); const xOffset = (column - (totalColumns - 1) / 2) * (params.targetWidth + gap); const yOffset = row * (params.targetHeight + gap); return { x: centerX - params.targetWidth / 2 + xOffset, y: centerY - params.targetHeight / 2 + yOffset, }; } 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 ): Model3DTaskAssetPayload | null { return payload && typeof payload === "object" && !Array.isArray(payload) ? (payload as Model3DTaskAssetPayload) : 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"; } } function modelModeToTaskType(mode: GenerationMode) { if (mode === "text") return "text_to_model"; if (mode === "multiview") return "multiview_to_model"; return "image_to_model"; } function formatPointAmount(value: number) { const normalized = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; return normalized.toLocaleString("en-US"); } function formatFeatureCostLabel({ cost, isHighVip, pointsEnabled, }: { cost: number; isHighVip?: boolean; pointsEnabled: boolean; }) { if (!pointsEnabled) return "不扣积分"; if (isHighVip) return "VIP免费"; if (cost <= 0) return "免费"; return `${formatPointAmount(cost)} 积分`; } function formatFeatureExtraCostLabel({ cost, isHighVip, pointsEnabled, }: { cost: number; isHighVip?: boolean; pointsEnabled: boolean; }) { if (!pointsEnabled) return "不扣积分"; if (isHighVip) return "VIP免费"; if (cost <= 0) return "免费"; return `+${formatPointAmount(cost)} 积分`; } function CostPill({ active, className, label, }: { active?: boolean; className?: string; label: string; }) { return ( {label} ); } 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 nodes = useCanvasStore(canvasSelectors.nodes); const getCanvasState = useCanvasStore((state) => state.getCanvasState); const [workspaceMode, setWorkspaceMode] = useState("model"); const [mode, setMode] = useState("text"); const [prompt, setPrompt] = useState(""); const [negativePrompt, setNegativePrompt] = useState(""); const [modelVersion, setModelVersion] = useState( getDefaultThreeDModelAliasId( DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels, ), ); 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(null); const [referencePreviewUrl, setReferencePreviewUrl] = useState( null ); const [multiViewSlots, setMultiViewSlots] = useState(() => createMultiViewSlots() ); const [batchImageSlots, setBatchImageSlots] = useState(() => createBatchImageSlots() ); const [worldMode, setWorldMode] = useState("text"); const [worldPrompt, setWorldPrompt] = useState(""); const [worldDisplayName, setWorldDisplayName] = useState(""); const [worldModel, setWorldModel] = useState( getDefaultThreeDModelAliasId( DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.worldModels, ), ); const [worldDisableRecaption, setWorldDisableRecaption] = useState(false); const [worldIsPano, setWorldIsPano] = useState(false); const [worldMakePublic, setWorldMakePublic] = useState(false); const [worldReconstructImages, setWorldReconstructImages] = useState(false); const [worldImageFile, setWorldImageFile] = useState(null); const [worldImagePreviewUrl, setWorldImagePreviewUrl] = useState( null ); const [worldImageSlots, setWorldImageSlots] = useState(() => createWorldImageSlots() ); const [worldVideoFile, setWorldVideoFile] = useState(null); const [worldVideoPreviewUrl, setWorldVideoPreviewUrl] = useState( null ); const [worldViewportMode, setWorldViewportMode] = useState("world"); const [worldFirstPerson, setWorldFirstPerson] = useState(true); const [viewerResetToken, setViewerResetToken] = useState(0); const [taskHistory, setTaskHistory] = useState([]); const [currentTask, setCurrentTask] = useState(null); const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState([]); const [selectedWorldCanvasTaskIds, setSelectedWorldCanvasTaskIds] = useState([]); const [autoDownloadTaskId, setAutoDownloadTaskId] = useState( null ); const [advancedOpen, setAdvancedOpen] = useState(false); // Viewer Settings const [viewerSettings, setViewerSettings] = useState({ cameraPreset: "iso", envPreset: "city", lightIntensity: 1, autoRotate: false, showGrid: true, wireframe: false, }); const [viewerStats, setViewerStats] = useState({ triangles: 0, vertices: 0, meshCount: 0, }); const [viewerLayers, setViewerLayers] = useState([]); const [layerVisibility, setLayerVisibility] = useState< Record >({}); const [isolatedLayerId, setIsolatedLayerId] = useState(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 pollErrorCountRef = useRef(0); const multiViewSlotsRef = useRef([]); const batchImageSlotsRef = useRef([]); const MAX_RETRYABLE_TASK_POLL_FAILURES = 12; const isRetryableTaskPollFailure = (message: string) => { const normalized = String(message || "").toLowerCase(); return ( /http[^0-9]*5\d\d/.test(normalized) || normalized.includes("超时") || normalized.includes("timeout") ); }; // Fetch site settings for Logo const { data: siteSettingsData } = useSWR( "/api/site-settings?scope=branding", fetcher ); const { data: modelAliasConfigResponse } = useSWR<{ success?: boolean; data?: PublicThreeDModelAliasConfig; }>("/api/canvas/model-aliases", fetcher); const { data: membershipSummary } = useSWR<{ pointsSystemConfig?: PointsSystemConfig; isHighVip?: boolean; 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 pointsSystemConfig = useMemo( () => normalizePointsSystemConfig( membershipSummary?.pointsSystemConfig ?? defaultPointsSystemConfig ), [membershipSummary?.pointsSystemConfig] ); const pointsEnabled = pointsSystemConfig.enabled === true; const isHighVip = membershipSummary?.isHighVip === true; const publicModelAliasConfig = modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; const modelFeatureVisibility = publicModelAliasConfig.featureVisibility || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.featureVisibility; const selectedBatchImageCount = useMemo( () => batchImageSlots.filter((slot) => Boolean(slot.file)).length, [batchImageSlots] ); const currentModelTaskType = modelModeToTaskType(mode); const currentModelUnitCost = useMemo( () => get3DTaskCreditCost(pointsSystemConfig, { taskType: currentModelTaskType, pbr: modelFeatureVisibility.pbr !== false && pbr, quad: modelFeatureVisibility.quad !== false && quad, smartLowPoly: modelFeatureVisibility.smartLowPoly !== false && smartLowPoly, }), [ currentModelTaskType, modelFeatureVisibility.pbr, modelFeatureVisibility.quad, modelFeatureVisibility.smartLowPoly, pbr, pointsSystemConfig, quad, smartLowPoly, ] ); const currentModelEstimatedCost = mode === "batch" && selectedBatchImageCount > 0 ? currentModelUnitCost * selectedBatchImageCount : currentModelUnitCost; const modelFeatureCostLabel = formatFeatureCostLabel({ cost: currentModelEstimatedCost, isHighVip, pointsEnabled, }); const modelFeatureCostHint = (() => { if (!pointsEnabled) return "当前未开启积分扣除"; if (isHighVip) return "VIP 用户本次不扣积分"; if ( mode === "batch" && selectedBatchImageCount > 0 && currentModelUnitCost > 0 ) { return `${selectedBatchImageCount} 张 × ${formatFeatureCostLabel({ cost: currentModelUnitCost, isHighVip, pointsEnabled, })}`; } if (mode === "batch") { return `每张 ${formatFeatureCostLabel({ cost: currentModelUnitCost, isHighVip, pointsEnabled, })}`; } return "提交成功后扣除"; })(); const worldBillingModelId = useMemo( () => resolveThreeDModelBillingId( worldModel, publicModelAliasConfig.worldModels ), [publicModelAliasConfig.worldModels, worldModel] ); const worldFeatureCost = useMemo( () => get3DWorldCreditCost(pointsSystemConfig, worldBillingModelId), [pointsSystemConfig, worldBillingModelId] ); const worldFeatureCostLabel = formatFeatureCostLabel({ cost: worldFeatureCost, isHighVip, pointsEnabled, }); const worldFeatureCostHint = !pointsEnabled ? "当前未开启积分扣除" : isHighVip ? "VIP 用户本次不扣积分" : "按当前生成质量扣除"; const refineCostLabel = formatFeatureCostLabel({ cost: get3DTaskCreditCost(pointsSystemConfig, { taskType: "refine_model" }), isHighVip, pointsEnabled, }); const textureCostLabel = formatFeatureCostLabel({ cost: get3DTaskCreditCost(pointsSystemConfig, { taskType: "texture_model" }), isHighVip, pointsEnabled, }); const rigCostLabel = formatFeatureCostLabel({ cost: get3DTaskCreditCost(pointsSystemConfig, { taskType: "animate_rig" }), isHighVip, pointsEnabled, }); const convertCostLabel = formatFeatureCostLabel({ cost: get3DTaskCreditCost(pointsSystemConfig, { taskType: "convert_model" }), isHighVip, pointsEnabled, }); const pbrExtraCostLabel = formatFeatureExtraCostLabel({ cost: pointsSystemConfig.creditCostPer3DModelPbr, isHighVip, pointsEnabled, }); const quadExtraCostLabel = formatFeatureExtraCostLabel({ cost: pointsSystemConfig.creditCostPer3DModelQuad, isHighVip, pointsEnabled, }); const smartLowPolyExtraCostLabel = formatFeatureExtraCostLabel({ cost: pointsSystemConfig.creditCostPer3DModelSmartLowPoly, isHighVip, pointsEnabled, }); 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; const tripoModelOptions = useMemo( () => { return publicModelAliasConfig.tripoModels.filter( (item) => item.enabled !== false, ); }, [publicModelAliasConfig.tripoModels], ); const worldModelOptions = useMemo( () => { return publicModelAliasConfig.worldModels.filter( (item) => item.enabled !== false, ); }, [publicModelAliasConfig.worldModels], ); const getWorldModelDisplayLabel = useCallback( (value?: string | null) => resolveThreeDModelDisplayName( value, publicModelAliasConfig.worldModels, "标准质量", ), [publicModelAliasConfig.worldModels], ); // Effect hooks for data loading and cleanup useEffect(() => { console.log("CanvasModel3DPage mounted - v3.0 Dark Mode"); }, []); useEffect(() => { const nextDefaultTripoModelId = getDefaultThreeDModelAliasId( publicModelAliasConfig.tripoModels, ); if (!nextDefaultTripoModelId) return; if (!tripoModelOptions.some((item) => item.id === modelVersion)) { setModelVersion(nextDefaultTripoModelId); } }, [modelVersion, publicModelAliasConfig.tripoModels, tripoModelOptions]); useEffect(() => { const nextDefaultWorldModelId = getDefaultThreeDModelAliasId( publicModelAliasConfig.worldModels, ); if (!nextDefaultWorldModelId) return; if (!worldModelOptions.some((item) => item.id === worldModel)) { setWorldModel(nextDefaultWorldModelId); } }, [publicModelAliasConfig.worldModels, worldModel, worldModelOptions]); 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: isPersistableTaskPreviewUrl(task.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 = {}; 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 shareableWorldTasks = useMemo( () => visibleTaskHistory.filter( (task) => task.provider === "worldlabs-3d" && task.status === "success" ), [visibleTaskHistory] ); const importableWorldTasks = useMemo( () => visibleTaskHistory.filter( (task) => task.provider === "worldlabs-3d" && canImportTaskToCanvas(task) ), [visibleTaskHistory] ); const selectedWorldShareTasks = useMemo(() => { const selectedIds = new Set(selectedWorldShareTaskIds); return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId)); }, [selectedWorldShareTaskIds, shareableWorldTasks]); const selectedWorldCanvasTasks = useMemo(() => { const selectedIds = new Set(selectedWorldCanvasTaskIds); return importableWorldTasks.filter((task) => selectedIds.has(task.taskId)); }, [selectedWorldCanvasTaskIds, importableWorldTasks]); useEffect(() => { const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId)); setSelectedWorldShareTaskIds((prev) => { const next = prev.filter((taskId) => shareableIds.has(taskId)); return next.length === prev.length ? prev : next; }); }, [shareableWorldTasks]); useEffect(() => { const importableIds = new Set(importableWorldTasks.map((task) => task.taskId)); setSelectedWorldCanvasTaskIds((prev) => { const next = prev.filter((taskId) => importableIds.has(taskId)); return next.length === prev.length ? prev : next; }); }, [importableWorldTasks]); const currentModelUrl = resolveTaskModelUrl(currentTask); 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, { providerId: currentTask.assetPayload?.providerId, }); if (!result.success || !result.data) { const failMessage = result.error || "任务查询失败"; if (isRetryableTaskPollFailure(failMessage)) { pollErrorCountRef.current += 1; if (pollErrorCountRef.current === 1) { console.warn("[Model3DPage] 任务查询临时异常,继续重试", { taskId: currentTask.taskId, provider: currentTask.provider, error: failMessage, attempt: pollAttemptsRef.current, }); toast.info(`任务查询异常,正在重试...`); } if (pollErrorCountRef.current >= MAX_RETRYABLE_TASK_POLL_FAILURES) { const failedRecord: TaskRecord = { ...currentTask, status: "failed", error: `${failMessage}(重试仍异常)`, }; setCurrentTask(failedRecord); upsertTaskHistory(failedRecord); } return; } pollErrorCountRef.current = 0; 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>["data"] >) : null; const tripoResultData = !isWorldTask ? (result.data as NonNullable< Awaited>["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 : tripoResultData?.assetPayload ? { ...(currentTask.assetPayload || {}), ...tripoResultData.assetPayload, } : currentTask.assetPayload ?? null, error: status === "failed" ? result.data.errorMessage || "任务失败" : null, }; pollErrorCountRef.current = 0; 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 = { taskType, prompt: prompt.trim(), negativePrompt: negativePrompt.trim(), modelVersion, faceLimit, texture, pbr: modelFeatureVisibility.pbr !== false && pbr, autoSize, quad: modelFeatureVisibility.quad !== false && quad, generateParts, smartLowPoly: modelFeatureVisibility.smartLowPoly !== false && 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; mode: TaskMode; promptText: string; modelLabel: string; sourceTaskId?: string | null; exportFormat?: string | null; assetPayload?: Model3DTaskAssetPayload | null; previewImageUrl?: string | null; }) => { const normalizedPayload = params.provider === "tripo-3d" && !String(params.payload.providerId || "").trim() && params.assetPayload?.providerId ? { ...params.payload, providerId: params.assetPayload.providerId, } : params.payload; const createResult = params.provider === "worldlabs-3d" ? await worldLabsGenerationService.createTask(normalizedPayload) : await model3DGenerationService.createTask(normalizedPayload); 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 tripoCreateResultData = params.provider === "tripo-3d" ? (createResult.data as { providerId?: string | null; providerName?: string | null; }) : null; const tripoAssetPayload = params.provider === "tripo-3d" ? { ...(params.assetPayload || {}), providerId: tripoCreateResultData?.providerId || params.assetPayload?.providerId || null, providerName: tripoCreateResultData?.providerName || params.assetPayload?.providerName || null, } : 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, previewImageUrl: params.previewImageUrl || null, assetPayload: params.provider === "worldlabs-3d" ? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null : tripoAssetPayload, }; 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; mode: TaskMode; promptText: string; sourceTaskId: string; successMessage: string; assetPayload?: Model3DTaskAssetPayload | null; modelLabel?: string; }) => { setIsSubmitting(true); try { await submitTask({ provider: "tripo-3d", payload: params.payload, mode: params.mode, promptText: params.promptText, modelLabel: params.modelLabel || modelVersion, sourceTaskId: params.sourceTaskId, assetPayload: params.assetPayload ?? null, }); 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"); } if (uploadResult.data.providerId) { payload.providerId = uploadResult.data.providerId; } await submitTask({ provider: "tripo-3d", payload, mode: "image", promptText: normalizedPrompt || "图生3D", modelLabel: modelVersion, assetPayload: { providerId: uploadResult.data.providerId || null, providerName: uploadResult.data.providerName || null, }, previewImageUrl: referencePreviewUrl, }); 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}`); } if (uploadResult.data.providerId) { payload.providerId = uploadResult.data.providerId; } await submitTask({ provider: "tripo-3d", payload, mode: "batch", promptText: `${normalizedPrompt || "批量图生3D"} · ${file.name}`, modelLabel: modelVersion, assetPayload: { providerId: uploadResult.data.providerId || null, providerName: uploadResult.data.providerName || null, }, previewImageUrl: batchImageSlots.find((slot) => slot.file === file)?.previewUrl || null, }); successCount += 1; } toast.success(`已提交 ${successCount} 个批量图生3D任务`); return; } setUploadingImage(true); const uploadResults: Array< Awaited> > = []; let boundTripoProviderId: string | null = null; let boundTripoProviderName: string | null = null; for (const file of selectedMultiFiles) { const result = await model3DGenerationService.uploadImage(file, { providerId: boundTripoProviderId, }); if (result.success && result.data?.providerId && !boundTripoProviderId) { boundTripoProviderId = result.data.providerId; boundTripoProviderName = result.data.providerName || null; } uploadResults.push(result); } const filesPayload: Array> = []; 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; if (boundTripoProviderId) { payload.providerId = boundTripoProviderId; } await submitTask({ provider: "tripo-3d", payload, mode: "multiview", promptText: normalizedPrompt || "多视图3D", modelLabel: modelVersion, assetPayload: { providerId: boundTripoProviderId, providerName: boundTripoProviderName, }, previewImageUrl: multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl || null, }); 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" ); const uploadData = uploadResult.data || null; const mediaAssetId = uploadData?.mediaAssetId || null; const sourceUrl = uploadData?.sourceUrl || null; if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) { throw new Error(uploadResult.error || "上传世界参考图失败"); } await submitTask({ provider: "worldlabs-3d", payload: { mode: "image", ...(mediaAssetId ? { mediaAssetId } : { sourceUrl }), providerId: uploadData.providerId, prompt: normalizedPrompt || undefined, displayName: worldDisplayName.trim() || undefined, model: worldModel, disableRecaption: worldDisableRecaption, isPano: worldIsPano, makePublic: worldMakePublic, }, mode: "world-image", promptText: normalizedPrompt || "图生世界", modelLabel: worldModel, previewImageUrl: sourceUrl || worldImagePreviewUrl, }); toast.success("已提交图生世界任务"); return; } if (worldMode === "video") { setUploadingImage(true); const uploadResult = await worldLabsGenerationService.uploadMedia( worldVideoFile as File, "video" ); const uploadData = uploadResult.data || null; const mediaAssetId = uploadData?.mediaAssetId || null; const sourceUrl = uploadData?.sourceUrl || null; if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) { throw new Error(uploadResult.error || "上传世界视频失败"); } await submitTask({ provider: "worldlabs-3d", payload: { mode: "video", ...(mediaAssetId ? { mediaAssetId } : { sourceUrl }), providerId: uploadData.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>; }> = []; 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 }) => { const mediaAssetId = result.data?.mediaAssetId || null; const sourceUrl = result.data?.sourceUrl || null; if (!result.success || (!mediaAssetId && !sourceUrl)) { throw new Error(result.error || "上传多图世界图片失败"); } return { ...(mediaAssetId ? { mediaAssetId } : { sourceUrl }), 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, previewImageUrl: mediaAssets.find((item) => "sourceUrl" in item)?.sourceUrl || selectedWorldImages.find((slot) => slot.previewUrl)?.previewUrl || null, }); toast.success("已提交多图世界任务"); } catch (error) { toast.error(error instanceof Error ? error.message : "创建世界任务失败"); } finally { setUploadingImage(false); setIsSubmitting(false); } }; const handleImportTasksToCanvas = async (tasks: TaskRecord[]) => { const importableTasks = tasks.filter(canImportTaskToCanvas); if (importableTasks.length === 0) { toast.error("暂无可添加的 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 existing3dNodeCount = nodes.filter( (node) => node.type === "world3d" || node.type === "model3d" ).length; importableTasks.forEach((task, index) => { const isWorldTask = task.provider === "worldlabs-3d"; const modelUrl = resolveTaskModelUrl(task); const worldAssetPayload = task.assetPayload ?? null; const primarySplatUrl = resolveWorldTaskSplatUrl(task); const worldImageUrl = resolveWorldTaskImageUrl(task); const position = resolveCanvasImportPosition({ index: existing3dNodeCount + index, totalCount: existing3dNodeCount + importableTasks.length, viewportX, viewportY, viewportZoom, stageWidth, stageHeight, targetWidth, targetHeight, }); addNode( isWorldTask ? { type: "world3d", position, size: { width: targetWidth, height: targetHeight }, data: { worldUrl: worldAssetPayload?.worldUrl || undefined, imageUrl: worldImageUrl || undefined, meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined, splatUrl: primarySplatUrl || undefined, previewImage: resolveWorldTaskPreviewUrl(task) || undefined, prompt: task.prompt, modelVersion: task.modelVersion, status: "completed", activeView: primarySplatUrl ? "splat" : worldImageUrl ? "image" : "mesh", firstPerson: false, hotspots: [], semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined, metadata: { taskId: task.taskId, provider: task.provider, mode: task.mode, sourceTaskId: task.sourceTaskId, progress: task.progress, triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined, vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined, meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined, assetPayload: task.assetPayload ?? null, }, }, connections: [], } : { type: "model3d", position, size: { width: targetWidth, height: targetHeight }, data: { src: modelUrl!, previewImage: task.previewImageUrl || undefined, prompt: task.prompt, modelVersion: task.modelVersion, format: extractModelFormat(modelUrl!), status: "completed", metadata: { taskId: task.taskId, provider: task.provider, mode: task.mode, sourceTaskId: task.sourceTaskId, progress: task.progress, triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined, vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined, meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined, assetPayload: task.assetPayload ?? null, }, }, connections: [], } ); }); try { await canvasService.saveCanvasState(id, getCanvasState()); } catch (error) { console.error("[Model3DPage] 保存画布失败:", error); } toast.success( importableTasks.length > 1 ? `已添加 ${importableTasks.length} 个 3D 世界到画布` : importableTasks[0]?.provider === "worldlabs-3d" ? "3D 世界已添加到画布" : "3D 模型已添加到画布" ); }; const handleImportToCanvas = async () => { if (!currentTask || currentTask.status !== "success") { toast.error("暂无可添加的 3D 资产"); return; } await handleImportTasksToCanvas([currentTask]); }; 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 ) => { 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) => { 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 handleToggleWorldShareSelection = useCallback( (task: TaskRecord, event?: MouseEvent) => { event?.stopPropagation(); if (task.provider !== "worldlabs-3d" || task.status !== "success") { return; } setSelectedWorldShareTaskIds((prev) => prev.includes(task.taskId) ? prev.filter((taskId) => taskId !== task.taskId) : [...prev, task.taskId] ); }, [] ); const handleSelectAllShareableWorlds = useCallback(() => { setSelectedWorldShareTaskIds((prev) => prev.length === shareableWorldTasks.length ? [] : shareableWorldTasks.map((task) => task.taskId) ); }, [shareableWorldTasks]); const handleToggleWorldCanvasSelection = useCallback( (task: TaskRecord, event?: MouseEvent) => { event?.stopPropagation(); if (task.provider !== "worldlabs-3d" || !canImportTaskToCanvas(task)) { return; } setSelectedWorldCanvasTaskIds((prev) => prev.includes(task.taskId) ? prev.filter((taskId) => taskId !== task.taskId) : [...prev, task.taskId] ); }, [] ); const handleSelectAllImportableWorlds = useCallback(() => { setSelectedWorldCanvasTaskIds((prev) => prev.length === importableWorldTasks.length ? [] : importableWorldTasks.map((task) => task.taskId) ); }, [importableWorldTasks]); const handleImportSelectedWorldsToCanvas = useCallback(async () => { if (selectedWorldCanvasTasks.length === 0) { toast.error("请选择要添加到画布的世界"); return; } await handleImportTasksToCanvas(selectedWorldCanvasTasks); }, [handleImportTasksToCanvas, selectedWorldCanvasTasks]); const handleShareSelectedWorlds = useCallback(async () => { if (selectedWorldShareTasks.length < 2) { toast.error("请至少选择 2 个成功生成的 3D 世界"); return; } try { const response = await fetch(`/api/canvas/${id}/model3d-assets`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ taskIds: selectedWorldShareTasks.map((task) => task.taskId), shareEnabled: true, }), }); const payload = (await response.json().catch(() => ({}))) as { success?: boolean; error?: string; items?: TaskRecordApiEntity[]; shareToken?: string | null; }; if (!response.ok || payload?.success === false) { throw new Error(payload?.error || "批量分享世界失败"); } const updatedTasks = Array.isArray(payload.items) ? payload.items .map((item) => mapApiEntityToTaskRecord(item)) .filter((item): item is TaskRecord => Boolean(item)) : []; for (const updatedTask of updatedTasks) { upsertTaskHistory(updatedTask, { persist: false }); } const shareToken = payload.shareToken || updatedTasks[0]?.shareToken; const shareUrl = buildWorldShareUrl(shareToken); if (!shareUrl) { throw new Error("分享链接生成失败"); } await navigator.clipboard.writeText(shareUrl); toast.success(`已复制包含 ${selectedWorldShareTasks.length} 个世界的分享链接`); } catch (error) { toast.error(error instanceof Error ? error.message : "批量分享世界失败"); } }, [id, selectedWorldShareTasks, upsertTaskHistory]); 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 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 = { 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, assetPayload: readyTask.assetPayload ?? null, }); 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 = { 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, assetPayload: readyTask.assetPayload ?? null, }); 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: "已提交纹理重绘任务", assetPayload: readyTask.assetPayload ?? null, modelLabel: readyTask.modelVersion, }); }; 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: "已提交风格化任务", assetPayload: readyTask.assetPayload ?? null, modelLabel: readyTask.modelVersion, }); }; 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: "已提交可绑定检测任务", assetPayload: readyTask.assetPayload ?? null, modelLabel: readyTask.modelVersion, }); }; 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: "已提交自动绑定任务", assetPayload: readyTask.assetPayload ?? null, modelLabel: readyTask.modelVersion, }); }; 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: "已提交动作重定向任务", assetPayload: readyTask.assetPayload ?? null, modelLabel: readyTask.modelVersion, }); }; 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: "已提交网格分割任务", assetPayload: readyTask.assetPayload ?? null, modelLabel: readyTask.modelVersion, }); }; 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: "已提交网格补全任务", assetPayload: readyTask.assetPayload ?? null, modelLabel: readyTask.modelVersion, }); }; 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: "已提交低模优化任务", assetPayload: readyTask.assetPayload ?? null, modelLabel: readyTask.modelVersion, }); }; 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 = {}; 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 currentWorldShareUrl = currentTask?.provider === "worldlabs-3d" && currentTask?.shareEnabled ? buildWorldShareUrl(currentTask.shareToken) : null; const currentSpzEntries = getSortedWorldLabsSplatEntries(currentWorldAsset?.spzUrls); const currentPrimarySpzUrl = pickPreferredInlineWorldLabsSplatUrl(currentWorldAsset?.spzUrls) || 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 (currentWorldImageUrl) modes.push("imagery"); if (currentModelUrl) modes.push("mesh"); return modes; }, [currentModelUrl, currentPrimarySpzUrl, currentWorldImageUrl]); const preferredWorldViewportMode: WorldViewportMode = currentPrimarySpzUrl ? "splat" : currentWorldImageUrl ? "imagery" : currentModelUrl ? "mesh" : "splat"; const activeWorkspaceTitle = workspaceMode === "world" ? "3D 世界工作台" : "3D 工作台"; const viewerEmptyStateTitle = workspaceMode === "world" ? "准备生成 3D 世界" : "准备生成 3D 资产"; useEffect(() => { setViewerResetToken((prev) => prev + 1); }, [currentTask?.taskId, workspaceMode, worldViewportMode]); 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, ]); useEffect(() => { setWorldFirstPerson( workspaceMode === "world" && worldViewportMode === "splat" ); }, [workspaceMode, worldViewportMode]); return (
{/* Top Navigation Bar */}
{/* Logo & Brand */}
router.push("/")} > {displayLogo ? ( {siteName} ) : (
)} {siteName}
{/* Divider */}
{/* Nav Links */}
积分 {pointsLabel}
{/* User Profile */}
{user?.image ? ( {user.name ) : (
{userAvatarFallback}
)}
{/* 1. Left Nav Sidebar (Icon only) */} {/* 2. Left Tool Panel (Expandable Content) */}

{activeTab === "create" && (workspaceMode === "world" ? "生成世界" : "生成模型")} {activeTab === "edit" && (workspaceMode === "world" ? "世界资产" : "模型编辑")} {activeTab === "scene" && (workspaceMode === "world" ? "世界详情" : "场景管理")}

{activeTab === "create" && ( workspaceMode === "model" ? ( <>
{[ { id: "text", label: "文生", icon: PenLine }, { id: "image", label: "图生", icon: ImagePlus }, { id: "multiview", label: "多视", icon: Box }, { id: "batch", label: "批量", icon: Images }, ].map((m) => ( ))}
预计消耗 {modelFeatureCostLabel}
{modelFeatureCostHint}