"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, Box, Copy, Download, ExternalLink, Globe, ImageIcon, Loader2, MoreVertical, Trash2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import useSWR from "swr"; import { toast } from "sonner"; import { getNodeContentScale } from "@/lib/canvas/node-content-scale"; import { DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, resolveThreeDModelDisplayName, type PublicThreeDModelAliasConfig, } from "@/lib/model3d/model-alias-config"; import { extractModelAssetFormat, resolvePreferredModelAssetUrl, } from "@/lib/model3d/model-url"; import { Model3DViewer, type Model3DViewerSettings, } from "@/components/canvas/model3d/Model3DViewer"; import { getCanvasToolbarItem, type CanvasToolbarConfig, } from "@/lib/canvas-toolbar-config"; import { useCanvasStore } from "@/store/canvas"; import { canvasService, model3DGenerationService } from "@/services/canvas"; import type { CanvasNode, Model3DNodeData } from "@/types/canvas"; import { cn, fetcher } from "@/lib/utils"; interface Model3DNodeProps { node: CanvasNode; isSelected: boolean; canvasToolbarConfig?: CanvasToolbarConfig; } const CANVAS_MODEL_VIEWER_SETTINGS: Model3DViewerSettings = { cameraPreset: "iso", envPreset: "studio", lightIntensity: 1, autoRotate: false, showGrid: true, wireframe: false, }; const MODEL_3D_RECOVERY_INTERVAL_MS = 5000; const MODEL_3D_RECOVERY_MAX_ATTEMPTS = 360; function isTerminalModelFailureStatus(status: string) { return ["failed", "error", "banned", "expired", "cancelled", "canceled"].includes( status.trim().toLowerCase() ); } function inferModelFileName(modelUrl: string | null, nodeId: string) { const raw = String(modelUrl || "").trim(); if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`; try { const url = new URL(raw); const filename = url.pathname.split("/").filter(Boolean).pop(); if (filename) return decodeURIComponent(filename); } catch {} return raw.split("/").pop() || `model3d-${nodeId.slice(0, 6)}.glb`; } function inferModelFormat(modelUrl: string | null, explicitFormat?: string) { if (explicitFormat) return explicitFormat.toUpperCase(); return extractModelAssetFormat(modelUrl).toUpperCase(); } function readWorldAssetPayload(data: Model3DNodeData) { const raw = data.metadata?.assetPayload; return raw && typeof raw === "object" ? (raw as Record) : null; } function readOptionalUrl(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : null; } export function Model3DNode({ node, isSelected, canvasToolbarConfig, }: Model3DNodeProps) { const data = node.data as Model3DNodeData; const [isHovered, setIsHovered] = useState(false); const recoveryAttemptRef = useRef(0); const recoveryTaskRef = useRef(null); const deleteNode = useCanvasStore((state) => state.deleteNode); const updateNode = useCanvasStore((state) => state.updateNode); const projectId = useCanvasStore((state) => state.projectId); const getCanvasState = useCanvasStore((state) => state.getCanvasState); const model3dOpen3dItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dOpen3d" ); const model3dOpenWorldItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dOpenWorld" ); const model3dMoreItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dMore" ); const model3dOpenMeshItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dOpenMesh" ); const model3dOpenImageItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dOpenImage" ); const model3dDownloadItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dDownload" ); const model3dCopyItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dCopy" ); const model3dDeleteItem = getCanvasToolbarItem( canvasToolbarConfig, "model3dDelete" ); const viewportZoom = useCanvasStore((state) => state.viewport.zoom); const taskId = String(data.metadata?.taskId || "").trim(); const assetPayload = readWorldAssetPayload(data); const providerId = String( data.metadata?.providerId || assetPayload?.providerId || "" ).trim(); const providerName = String( data.metadata?.providerName || assetPayload?.providerName || "" ).trim(); const sourceImageUrl = String( data.metadata?.sourceImageUrl || data.previewImage || "" ).trim(); const { data: modelAliasConfigResponse } = useSWR<{ success?: boolean; data?: PublicThreeDModelAliasConfig; }>("/api/canvas/model-aliases", fetcher); const providerLabel = data.metadata?.provider === "worldlabs-3d" ? "World Labs 结果" : "Tripo3D 结果"; const publicModelAliasConfig = modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; const modelVersionLabel = useMemo( () => resolveThreeDModelDisplayName( data.modelVersion, publicModelAliasConfig.tripoModels, providerLabel, ), [data.modelVersion, providerLabel, publicModelAliasConfig.tripoModels], ); const worldAssetPayload = readWorldAssetPayload(data); const sourceModelUrl = readOptionalUrl(worldAssetPayload?.sourceModelUrl); const worldUrl = readOptionalUrl(worldAssetPayload?.worldUrl); const worldImageUrl = readOptionalUrl(worldAssetPayload?.panoUrl) || readOptionalUrl(worldAssetPayload?.thumbnailUrl) || readOptionalUrl(data.previewImage); const resolvedModelUrl = useMemo( () => resolvePreferredModelAssetUrl({ primaryUrl: data.src, sourceUrl: sourceModelUrl, }), [data.src, sourceModelUrl] ); const modelFileName = useMemo( () => inferModelFileName(resolvedModelUrl || data.src || null, node.id), [data.src, node.id, resolvedModelUrl] ); const modelFormatLabel = useMemo( () => inferModelFormat(resolvedModelUrl || data.src || null, data.format), [data.format, data.src, resolvedModelUrl] ); const primaryOpenUrl = worldUrl || resolvedModelUrl || null; const primaryOpenItem = worldUrl ? model3dOpenWorldItem : model3dOpen3dItem; const primaryOpenLabel = primaryOpenItem.label; const shouldRenderLiveViewer = isSelected && data.status === "completed" && Boolean(resolvedModelUrl); const placeholderSizeScale = useMemo( () => getNodeContentScale(node.size, { width: 360, height: 300 }, 0.76), [node.size.height, node.size.width] ); const placeholderViewportScale = useMemo( () => Math.min( 1.55, Math.max(0.72, placeholderSizeScale / Math.max(viewportZoom, 0.42)) ), [placeholderSizeScale, viewportZoom] ); const placeholderCenterStyle = useMemo( () => ({ transform: `scale(${placeholderViewportScale})`, transformOrigin: "center center", }), [placeholderViewportScale] ); const placeholderBadgeStyle = useMemo( () => ({ transform: `scale(${placeholderViewportScale})`, transformOrigin: "top left", }), [placeholderViewportScale] ); const persistCanvasStateSnapshot = useCallback(() => { if (!projectId || typeof window === "undefined") return; window.setTimeout(() => { void canvasService.saveCanvasState(projectId, getCanvasState()); }, 0); }, [getCanvasState, projectId]); const persistCanvas3DAsset = useCallback( async (asset: Record) => { if (!projectId) return; try { await fetch(`/api/canvas/${projectId}/model3d-assets`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ asset }), }); } catch (error) { console.error("[Model3DNode] 保存 3D 资产失败:", error); } }, [projectId] ); useEffect(() => { if (data.status !== "generating" || !taskId) return; const provider = String(data.metadata?.provider || "tripo-3d").trim(); if (provider !== "tripo-3d") return; if (recoveryTaskRef.current !== taskId) { recoveryTaskRef.current = taskId; recoveryAttemptRef.current = 0; } let cancelled = false; let timer: ReturnType | null = null; const prompt = String(data.prompt || "图生 3D 模型").trim(); const modelVersion = String(data.modelVersion || "tripo-standard").trim(); const previewImage = String(data.previewImage || sourceImageUrl).trim(); const currentData = () => { const currentNode = getCanvasState().nodes.find((item) => item.id === node.id); return (currentNode?.data || data) as Model3DNodeData; }; const persistTerminalAsset = async (params: { error: string | null; modelUrl: string | null; previewImageUrl: string; progress: number | null; status: "failed" | "success"; taskAssetPayload?: Record | null; }) => { await persistCanvas3DAsset({ taskId, provider: "tripo-3d", status: params.status, mode: "image", prompt, modelVersion, progress: params.progress, modelUrl: params.modelUrl, previewImageUrl: params.previewImageUrl, error: params.error, assetPayload: { ...(params.taskAssetPayload || {}), providerId: providerId || null, providerName: providerName || null, sourceImageUrl, }, createdAt: Date.now(), }); persistCanvasStateSnapshot(); }; const markFailed = async (message: string, progress: number | null) => { const latest = currentData(); updateNode(node.id, { data: { ...latest, src: "", previewImage: previewImage || sourceImageUrl, status: "failed", metadata: { ...(latest.metadata || {}), taskId, provider: "tripo-3d", mode: "image", sourceImageUrl, progress, error: message, }, } as Model3DNodeData, }); await persistTerminalAsset({ error: message, modelUrl: null, previewImageUrl: previewImage || sourceImageUrl, progress, status: "failed", }); toast.error(message); }; const scheduleNextPoll = () => { if (cancelled) return; if (recoveryAttemptRef.current >= MODEL_3D_RECOVERY_MAX_ATTEMPTS) { void markFailed("3D 模型任务同步超时,请稍后重试", null); return; } timer = setTimeout(() => { void pollTask(); }, MODEL_3D_RECOVERY_INTERVAL_MS); }; async function pollTask() { if (cancelled) return; recoveryAttemptRef.current += 1; const result = await model3DGenerationService.getTask(taskId, { providerId: providerId || null, }); if (cancelled) return; if (!result.success || !result.data) { scheduleNextPoll(); return; } const status = String(result.data.status || "unknown").toLowerCase(); const progress = result.data.progress ?? null; if (status === "success") { const modelUrl = String(result.data.modelUrl || "").trim(); const nextPreviewImage = String( result.data.previewImageUrl || previewImage || sourceImageUrl ).trim(); if (!modelUrl) { scheduleNextPoll(); return; } const taskAssetPayload = result.data.assetPayload && typeof result.data.assetPayload === "object" ? (result.data.assetPayload as Record) : {}; const latest = currentData(); updateNode(node.id, { data: { ...latest, src: modelUrl, previewImage: nextPreviewImage, format: inferModelFormat(modelUrl), status: "completed", metadata: { ...(latest.metadata || {}), taskId, provider: "tripo-3d", mode: "image", sourceImageUrl, progress: 100, output: result.data.output || null, assetPayload: { ...taskAssetPayload, providerId: providerId || taskAssetPayload.providerId || null, providerName: providerName || taskAssetPayload.providerName || null, sourceImageUrl, }, recoveredAt: new Date().toISOString(), }, } as Model3DNodeData, }); await persistTerminalAsset({ error: null, modelUrl, previewImageUrl: nextPreviewImage, progress: 100, status: "success", taskAssetPayload, }); toast.success("3D 模型已生成到画布"); return; } if (isTerminalModelFailureStatus(status)) { await markFailed(result.data.errorMessage || "3D 模型生成失败", progress); return; } const latest = currentData(); updateNode(node.id, { data: { ...latest, metadata: { ...(latest.metadata || {}), progress, }, } as Model3DNodeData, }); scheduleNextPoll(); } void pollTask(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [ data.modelVersion, data.previewImage, data.prompt, data.status, getCanvasState, node.id, persistCanvas3DAsset, persistCanvasStateSnapshot, providerId, providerName, sourceImageUrl, taskId, updateNode, ]); const handleOpen = () => { if (!primaryOpenUrl) { toast.error("暂无可打开的地址"); return; } window.open(primaryOpenUrl, "_blank", "noopener,noreferrer"); }; const handleOpenMesh = () => { if (!resolvedModelUrl) { toast.error("暂无可打开的 Mesh 地址"); return; } window.open(resolvedModelUrl, "_blank", "noopener,noreferrer"); }; const handleOpenImage = () => { if (!worldImageUrl) { toast.error("暂无可打开的图像预览"); return; } window.open(worldImageUrl, "_blank", "noopener,noreferrer"); }; const handleDownload = () => { if (!resolvedModelUrl) { toast.error("暂无可下载的 3D 文件"); return; } const link = document.createElement("a"); link.href = resolvedModelUrl; link.download = modelFileName; link.click(); }; const handleCopy = async () => { if (!primaryOpenUrl) { toast.error("暂无可复制的地址"); return; } try { await navigator.clipboard.writeText(primaryOpenUrl); toast.success(worldUrl ? "已复制 World 链接" : "已复制 3D 链接"); } catch { toast.error("复制失败"); } }; return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {data.status === "generating" ? (
{data.metadata?.provider === "worldlabs-3d" ? "3D 世界生成中..." : "3D 模型生成中..."} {data.modelVersion ? `模型:${modelVersionLabel}` : providerLabel}
) : data.status === "failed" ? (
{data.metadata?.provider === "worldlabs-3d" ? "3D 世界生成失败" : "3D 模型生成失败"} 可以重新选择模型后再试一次
) : (
{shouldRenderLiveViewer ? (
event.stopPropagation()} >
) : data.previewImage ? ( {data.prompt ) : (
)}
{modelFormatLabel}

{data.prompt || modelFileName}

{data.modelVersion ? `模型: ${modelVersionLabel}` : providerLabel}

)} {isHovered && data.status === "completed" && (
{primaryOpenLabel} {worldUrl && resolvedModelUrl && resolvedModelUrl !== worldUrl && ( {model3dOpenMeshItem.label} )} {worldImageUrl && ( {model3dOpenImageItem.label} )} {model3dDownloadItem.label} {model3dCopyItem.label} deleteNode(node.id)}> {model3dDeleteItem.label}
)}
); }