diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index c263e5039..c74821bb4 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -3,47 +3,47 @@ import { formatDistanceToNow } from "date-fns"; import { zhCN } from "date-fns/locale"; import dynamic from "next/dynamic"; - import { - ArrowLeft, - Bone, - Box, - Boxes, - Check, - ChevronDown, - ChevronUp, - Crosshair, - Download, - Eye, - EyeOff, - Grid3X3, - Home, - ImagePlus, - Images, - Layers3, - LayoutGrid, - Loader2, - Maximize, - Package, - Palette, - PenLine, - Play, +import { + ArrowLeft, + Bone, + Box, + Boxes, + Check, + ChevronDown, + ChevronUp, + Crosshair, + Download, + Eye, + EyeOff, + Grid3X3, + Home, + ImagePlus, + Images, + Layers3, + LayoutGrid, + Loader2, + Maximize, + Package, + Palette, + PenLine, + Play, Plus, PlusCircle, - RefreshCw, - RotateCw, - Scissors, - Settings2, - Sparkles, - Sun, - Globe, - ImageIcon, - Link2, - PanelTop, - Video, - WandSparkles, - X, - } from "lucide-react"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; + 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"; @@ -340,36 +340,6 @@ function resolveTaskModelUrl(task?: TaskRecord | null): string | null { }); } -function getTaskImportContext(task?: TaskRecord | null) { - const isWorldTask = task?.provider === "worldlabs-3d"; - const modelUrl = resolveTaskModelUrl(task); - const worldAssetPayload = isWorldTask ? task?.assetPayload ?? null : null; - const primarySplatUrl = - isWorldTask && worldAssetPayload - ? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload.spzUrls) - : null; - const worldImageUrl = - worldAssetPayload?.panoUrl || - worldAssetPayload?.thumbnailUrl || - task?.previewImageUrl || - null; - const canImportWorld = Boolean( - modelUrl || - worldAssetPayload?.worldUrl || - worldImageUrl || - primarySplatUrl - ); - - return { - canImport: Boolean(task?.status === "success") && (isWorldTask ? canImportWorld : Boolean(modelUrl)), - isWorldTask, - modelUrl, - primarySplatUrl, - worldAssetPayload, - worldImageUrl, - }; -} - function createMultiViewSlots(count = 4): MultiViewSlot[] { return Array.from({ length: count }, (_, index) => ({ id: `slot_${index + 1}`, @@ -673,20 +643,9 @@ function CostPill({ export default function CanvasModel3DPage() { const router = useRouter(); - const searchParams = useSearchParams(); const routeParams = useParams(); const id = String(routeParams?.id || ""); const legacyHistoryStorageKey = useMemo(() => makeStorageKey(id), [id]); - const returnTo = useMemo(() => { - const raw = searchParams.get("returnTo")?.trim(); - return raw || `/canvas/${id}`; - }, [id, searchParams]); - const shouldReturnToVideoCanvas = useMemo( - () => - searchParams.get("source") === "video-canvas" || - /(?:\?|&)mode=video(?:&|$)|(?:\?|&)workspaceType=video(?:&|$)/.test(returnTo), - [returnTo, searchParams] - ); const { data: session } = useSession(); const user = session?.user; @@ -770,10 +729,11 @@ export default function CanvasModel3DPage() { const [taskHistory, setTaskHistory] = useState([]); const [currentTask, setCurrentTask] = useState(null); const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState([]); - //多选"添加到画布":独立于分享多选,让用户从历史列表中挑选多个世界/模型一次性上画布 + // 多选"添加到画布":独立于分享多选,让用户从历史列表中挑选多个世界/模型一次性上画布 const [selectedAddTaskIds, setSelectedAddTaskIds] = useState>(new Set()); - const [autoDownloadTaskId, setAutoDownloadTaskId] = useState( null - ); + const [autoDownloadTaskId, setAutoDownloadTaskId] = useState( + null + ); const [advancedOpen, setAdvancedOpen] = useState(false); @@ -1176,9 +1136,7 @@ export default function CanvasModel3DPage() { const pending = normalized.find( (item) => !FINAL_TASK_STATUS.has(item.status) ); - const initialTask = pending || normalized[0] || null; - setCurrentTask(initialTask); - setWorkspaceMode(inferWorkspaceModeFromTask(initialTask)); + setCurrentTask(pending || normalized[0] || null); } catch (error) { console.error("[Model3DPage] 加载 3D 资产库失败:", error); const migrated = await migrateLegacyHistory(); @@ -1308,18 +1266,18 @@ export default function CanvasModel3DPage() { 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 selectedWorldShareTasks = useMemo(() => { + 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 selectedWorldShareTasks = useMemo(() => { const selectedIds = new Set(selectedWorldShareTaskIds); return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId)); }, [selectedWorldShareTaskIds, shareableWorldTasks]); @@ -2048,40 +2006,31 @@ export default function CanvasModel3DPage() { } }; - const buildReturnUrl = useCallback( - (taskId?: string) => { - if (typeof window === "undefined") return returnTo; - const url = new URL(returnTo, window.location.origin); - if (taskId) { - url.searchParams.set("directorImport", "1"); - url.searchParams.set("directorTaskId", taskId); - } - return `${url.pathname}${url.search}`; - }, - [returnTo] - ); - - const handleImportToCanvas = async (task = currentTask) => { - if (!task || task.status !== "success") { + const handleImportToCanvas = async () => { + if (!currentTask || currentTask.status !== "success") { toast.error("暂无可添加的 3D 资产"); return; } - if (shouldReturnToVideoCanvas) { - router.push(buildReturnUrl(task.taskId)); - return; - } + const isWorldTask = currentTask.provider === "worldlabs-3d"; + const worldAssetPayload = currentTask.assetPayload ?? null; + const primarySplatUrl = isWorldTask + ? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls) + : null; + const worldImageUrl = + worldAssetPayload?.panoUrl || + worldAssetPayload?.thumbnailUrl || + currentTask.previewImageUrl || + null; - const { - canImport, - isWorldTask, - modelUrl, - primarySplatUrl, - worldAssetPayload, - worldImageUrl, - } = getTaskImportContext(task); + const canImportWorld = Boolean( + currentModelUrl || + worldAssetPayload?.worldUrl || + worldImageUrl || + primarySplatUrl + ); - if (!canImport) { + if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) { toast.error(isWorldTask ? "暂无可添加的 3D 世界" : "暂无可添加的 3D 模型"); return; } @@ -2107,14 +2056,14 @@ export default function CanvasModel3DPage() { data: { worldUrl: worldAssetPayload?.worldUrl || undefined, imageUrl: worldImageUrl || undefined, - meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined, + meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined, splatUrl: primarySplatUrl || undefined, previewImage: - task.previewImageUrl || + currentTask.previewImageUrl || worldAssetPayload?.thumbnailUrl || undefined, - prompt: task.prompt, - modelVersion: task.modelVersion, + prompt: currentTask.prompt, + modelVersion: currentTask.modelVersion, status: "completed", activeView: primarySplatUrl ? "splat" @@ -2125,15 +2074,15 @@ export default function CanvasModel3DPage() { hotspots: [], semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined, metadata: { - taskId: task.taskId, - provider: task.provider, - mode: task.mode, - sourceTaskId: task.sourceTaskId, - progress: task.progress, + 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: task.assetPayload ?? null, + assetPayload: currentTask.assetPayload ?? null, }, }, connections: [], @@ -2143,22 +2092,22 @@ export default function CanvasModel3DPage() { position: { x, y }, size: { width: targetWidth, height: targetHeight }, data: { - src: modelUrl!, - previewImage: task.previewImageUrl || undefined, - prompt: task.prompt, - modelVersion: task.modelVersion, - format: extractModelFormat(modelUrl!), + src: currentModelUrl!, + previewImage: currentTask.previewImageUrl || undefined, + prompt: currentTask.prompt, + modelVersion: currentTask.modelVersion, + format: extractModelFormat(currentModelUrl!), status: "completed", metadata: { - taskId: task.taskId, - provider: task.provider, - mode: task.mode, - sourceTaskId: task.sourceTaskId, - progress: task.progress, + 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: task.assetPayload ?? null, + assetPayload: currentTask.assetPayload ?? null, }, }, connections: [], @@ -2284,53 +2233,53 @@ export default function CanvasModel3DPage() { ); }, [shareableWorldTasks]); - const handleShareSelectedWorlds = useCallback(async () => { - if (selectedWorldShareTasks.length <2) { - toast.error("请至少选择2 个成功生成的3D 世界"); - return; - } + 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 || "批量分享世界失败"); - } + 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 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 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]); - //多选添加到画布的 handlers + // 多选添加到画布的 handlers const handleToggleAddSelection = useCallback( (task: TaskRecord, event?: MouseEvent) => { event?.stopPropagation(); @@ -2372,7 +2321,7 @@ export default function CanvasModel3DPage() { }); }, [addableTasks]); - //批量添加到画布:为每个 task 计算节点配置,复用 addNode 的 ring-scan避让逻辑 + //批量添加到画布:复用 addNodesBulk → addNode ring-scan 自动避让 const handleBatchImportToCanvas = useCallback(async () => { const targets = selectedAddTasks; if (targets.length ===0) { @@ -2390,29 +2339,38 @@ export default function CanvasModel3DPage() { const targetWidth =380; const targetHeight =300; - const baseX = + const baseX = (stageWidth *0.48 - viewportX) / viewportZoom - targetWidth /2; const baseY = (stageHeight *0.45 - viewportY) / viewportZoom - targetHeight /2; - //收集每个 task 的节点配置(让 store内部做位置避让) - const nodeConfigs: Array< - Parameters[0] - > = []; + const nodeConfigs: Array[0]> = []; for (const task of targets) { - const { - canImport, - isWorldTask, - modelUrl, - primarySplatUrl, - worldAssetPayload, - worldImageUrl, - } = getTaskImportContext(task); + if (task.status !== "success") continue; - if (!canImport) { - continue; - } + const isWorldTask = task.provider === "worldlabs-3d"; + const worldAssetPayload = task.assetPayload ?? null; + const taskModelUrl = resolveTaskModelUrl(task); + const primarySplatUrl = isWorldTask + ? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls) + : null; + const worldImageUrl = + worldAssetPayload?.panoUrl || + worldAssetPayload?.thumbnailUrl || + task.previewImageUrl || + null; + + const canImport = isWorldTask + ? Boolean( + taskModelUrl || + worldAssetPayload?.worldUrl || + worldImageUrl || + primarySplatUrl + ) + : Boolean(taskModelUrl); + + if (!canImport) continue; nodeConfigs.push( isWorldTask @@ -2423,7 +2381,7 @@ export default function CanvasModel3DPage() { data: { worldUrl: worldAssetPayload?.worldUrl || undefined, imageUrl: worldImageUrl || undefined, - meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined, + meshUrl: taskModelUrl || worldAssetPayload?.meshUrl || undefined, splatUrl: primarySplatUrl || undefined, previewImage: task.previewImageUrl || @@ -2460,11 +2418,11 @@ export default function CanvasModel3DPage() { position: { x: baseX, y: baseY }, size: { width: targetWidth, height: targetHeight }, data: { - src: modelUrl!, + src: taskModelUrl!, previewImage: task.previewImageUrl || undefined, prompt: task.prompt, modelVersion: task.modelVersion, - format: extractModelFormat(modelUrl!), + format: extractModelFormat(taskModelUrl!), status: "completed", metadata: { taskId: task.taskId, @@ -2488,15 +2446,12 @@ export default function CanvasModel3DPage() { return; } - //复用 addNodesBulk(循环 addNode → 自动避让) const createdNodeIds = addNodesBulk(nodeConfigs); - //选中新增的节点,让用户立刻看到位置 if (createdNodeIds.length >0) { selectNodes(createdNodeIds); } - // 清空选中状态,提示结果 setSelectedAddTaskIds(new Set()); const skipped = targets.length - nodeConfigs.length; const worldCount = nodeConfigs.filter((c) => c.type === "world3d").length; @@ -2522,7 +2477,6 @@ export default function CanvasModel3DPage() { addNode, addNodesBulk, getCanvasState, - getTaskImportContext, id, selectNodes, selectedAddTasks, @@ -2534,7 +2488,7 @@ export default function CanvasModel3DPage() { viewport.zoom, ]); - const handleDisableCurrentShare = useCallback(async () => { + const handleDisableCurrentShare = useCallback(async () => { if (!currentTask || currentTask.provider !== "worldlabs-3d") { toast.error("当前没有可关闭分享的世界资产"); return; @@ -2801,7 +2755,6 @@ export default function CanvasModel3DPage() { const handleSelectTask = (task: TaskRecord) => { setWorkspaceMode(inferWorkspaceModeFromTask(task)); setCurrentTask(task); - setSelectedWorldShareTaskIds([]); if (task.status === "success" && task.modelUrl) { setViewerLayers([]); setLayerVisibility({}); @@ -2886,7 +2839,17 @@ export default function CanvasModel3DPage() { currentWorldAsset?.thumbnailUrl || (workspaceMode === "world" ? currentPreviewImage : null) || null; - const importDisabled = !getTaskImportContext(currentTask).canImport; + 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"); @@ -3007,7 +2970,7 @@ export default function CanvasModel3DPage() { - {workspaceMode === "world" ? ( -
-
- 多世界分享 - 已选 {selectedWorldShareTasks.length} -
-
- - +
@@ -4346,89 +4309,36 @@ export default function CanvasModel3DPage() { ) : null} - +
- {visibleTaskHistory.map((task) => { - const canShareWorld = - task.provider === "worldlabs-3d" && task.status === "success"; - const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId); + {visibleTaskHistory.map((task) => { + const canShareWorld = + task.provider === "worldlabs-3d" && task.status === "success"; + const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId); const isSelectedForAdd = task.status === "success" && selectedAddTaskIds.has(task.taskId); const canAddToCanvas = task.status === "success"; - return ( - - ) : null} - {canShareWorld ? ( - - ) : null} - {canAddToCanvas ? ( - - ) : null} + key={task.taskId} + onClick={() => handleSelectTask(task)} + type="button" + > + {currentTask?.taskId === task.taskId ? ( + + 当前 + + ) : null} {canShareWorld ? ( - ) : null} - {task.status === "success" && resolveTaskModelUrl(task) ? ( - - ) : null} + + ) : null} + {canAddToCanvas ? ( + + ) : null} + {task.status === "success" && resolveTaskModelUrl(task) ? ( + + ) : null} {task.previewImageUrl ? (