"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, Box, Camera, Copy, Download, ImageIcon, Loader2, MoreVertical, Sparkles, Trash2, type LucideIcon, } from "lucide-react"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { World3DViewer } from "@/components/canvas/world3d/World3DViewer"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import useSWR from "swr"; import { DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, resolveThreeDModelDisplayName, type PublicThreeDModelAliasConfig, } from "@/lib/model3d/model-alias-config"; import { getPreferredInlineWorldLabsSplatUrls, isArchivedWorldLabsAssetUrl, pickPreferredInlineWorldLabsSplatUrl, } from "@/lib/worldlabs/splat-utils"; import { getNodeContentScale } from "@/lib/canvas/node-content-scale"; import { getCanvasToolbarItem, type CanvasToolbarConfig, type CanvasToolbarItemKey, } from "@/lib/canvas-toolbar-config"; import { toast } from "sonner"; import { canvasService, imageGenerationService, worldLabsGenerationService, } from "@/services/canvas"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, World3DNodeData, World3DViewMode } from "@/types/canvas"; import { cn, fetcher } from "@/lib/utils"; interface World3DNodeProps { node: CanvasNode; isSelected: boolean; canvasToolbarConfig?: CanvasToolbarConfig; } const VIEW_META: Array<{ id: World3DViewMode; label: string; icon: LucideIcon; toolbarKey: CanvasToolbarItemKey; }> = [ { id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" }, { id: "splat", label: "3D", icon: Box, toolbarKey: "world3dViewSplat" }, ]; const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000; const WORLD_ASSET_HYDRATION_MAX_ATTEMPTS = 60; const WORLD_GENERATION_RECOVERY_INTERVAL_MS = 5000; const WORLD_GENERATION_RECOVERY_MAX_ATTEMPTS = 360; const WORLD_SNAPSHOT_REPAINT_POLL_INTERVAL_MS = 1600; const WORLD_SNAPSHOT_REPAINT_TIMEOUT_MS = 4 * 60 * 1000; const WORLD_SNAPSHOT_REPAINT_PROMPT = [ "严格以参考图中的 3D 世界拍照作为唯一图像来源,执行图生图真实化修复,不要重新设计或替换场景。", "必须保持原始镜头位置、取景范围、透视、地形轮廓、道路走向、草地/树木/灌木/建筑等主要物体的位置、远近层次、光照方向和空间关系。", "只允许修复 3D Gaussian Splat 或 Mesh 截图中的模糊、破面、孔洞、黑边、拉伸网格、重复纹理、噪点、漂浮碎片和不自然阴影。", "在原有内容基础上补充真实世界细节:自然草叶、树干纹理、灌木叶片、道路材质、景深过渡、环境光和合理接触阴影。", "禁止改变场景类型,禁止新增不存在的大型地貌、建筑、河流、山谷、梯田、人物或动物。", "最终输出清晰、连贯、照片级、可信的真实场景图片。", ].join("\n"); const WORLD_SNAPSHOT_REPAINT_NEGATIVE_PROMPT = [ "界面按钮", "UI", "文字", "水印", "logo", "准星", "操作提示", "3D 破面", "网格纹理", "孔洞", "黑边", "重复纹理", "扭曲树木", "漂浮碎片", "更换场景", "重新构图", "改变地形", "大型新增物体", "梯田", "山谷", "人物", "动物", "低清晰度", "卡通", "插画", ].join(", "); function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function guessNearestAspectRatio(width: number, height: number) { const ratio = width > 0 && height > 0 ? width / height : 1; const candidates = [ { label: "1:1", value: 1 }, { label: "4:3", value: 4 / 3 }, { label: "3:4", value: 3 / 4 }, { label: "16:9", value: 16 / 9 }, { label: "9:16", value: 9 / 16 }, { label: "3:2", value: 3 / 2 }, { label: "2:3", value: 2 / 3 }, ]; return candidates.reduce((best, candidate) => Math.abs(candidate.value - ratio) < Math.abs(best.value - ratio) ? candidate : best ).label; } async function waitForWorldSnapshotRepaint(taskId: string) { const deadline = Date.now() + WORLD_SNAPSHOT_REPAINT_TIMEOUT_MS; let lastError = ""; while (Date.now() < deadline) { await sleep(WORLD_SNAPSHOT_REPAINT_POLL_INTERVAL_MS); const taskResult = await imageGenerationService.getImageTask(taskId); const task = taskResult.data?.task; const status = String(task?.status || "").toLowerCase(); if (!taskResult.success || !task) { lastError = taskResult.error || "查询 AI 实景优化任务失败"; continue; } if (status === "succeeded" || status === "completed") { const image = task.result?.images?.[0]; const imageUrl = image?.originalUrl || image?.thumbnailUrl || ""; if (!imageUrl) { throw new Error("AI 实景优化已完成但未返回图片"); } return { imageUrl, model: task.result?.model || task.result?.modelRef || undefined, prompt: task.result?.prompt || undefined, }; } if (status === "failed") { throw new Error(String(task.error || "AI 实景优化失败")); } } throw new Error(lastError || "AI 实景优化超时,请稍后重试"); } function resolveAvailableViews(data: World3DNodeData) { const views: World3DViewMode[] = []; const hasSplat = isArchivedWorldLabsAssetUrl(data.splatUrl); const hasMesh = isArchivedWorldLabsAssetUrl(data.meshUrl); if (data.imageUrl || data.previewImage) views.push("image"); if (hasSplat) views.push("splat"); if (hasMesh) views.push("mesh"); return views; } function resolveActiveView(data: World3DNodeData) { const availableViews = resolveAvailableViews(data); const hasSplat = availableViews.includes("splat"); if ( data.activeView && data.activeView !== "mesh" && availableViews.includes(data.activeView) ) { return data.activeView; } if (hasSplat) { return "splat"; } if (availableViews.includes("mesh")) { return "mesh"; } return availableViews[0] || "image"; } function inferModelDownloadTarget(data: World3DNodeData) { const meshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : null; return meshUrl ? { url: meshUrl, filename: "world.glb" } : null; } function resolvePrimaryUrl(data: World3DNodeData, activeView: World3DViewMode) { const splatUrl = isArchivedWorldLabsAssetUrl(data.splatUrl) ? data.splatUrl : null; const meshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : null; switch (activeView) { case "world": return splatUrl || meshUrl || data.imageUrl || null; case "image": return data.imageUrl || data.previewImage || meshUrl || splatUrl || null; case "mesh": return meshUrl || splatUrl || data.imageUrl || null; case "splat": default: return splatUrl || data.imageUrl || meshUrl || null; } } function readStringValue(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : null; } function readRecordValue(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function isTerminalWorldFailureStatus(status: string) { return ["failed", "error", "cancelled", "canceled"].includes( status.trim().toLowerCase() ); } function clampNumber(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function readFiniteNumber(value: unknown) { const numberValue = Number(value); return Number.isFinite(numberValue) ? numberValue : null; } export function World3DNode({ node, isSelected, canvasToolbarConfig, }: World3DNodeProps) { const data = node.data as World3DNodeData; const [isHovered, setIsHovered] = useState(false); const [viewerCapture, setViewerCapture] = useState<(() => Promise) | null>( null ); const [isCapturingSnapshot, setIsCapturingSnapshot] = useState(false); const [generationTick, setGenerationTick] = useState(0); const hydrationAttemptRef = useRef(0); const hydrationTaskRef = useRef(null); const generationRecoveryAttemptRef = useRef(0); const generationRecoveryTaskRef = useRef(null); const deleteNode = useCanvasStore((state) => state.deleteNode); const updateNode = useCanvasStore((state) => state.updateNode); const addNode = useCanvasStore((state) => state.addNode); const selectNode = useCanvasStore((state) => state.selectNode); const projectId = useCanvasStore((state) => state.projectId); const getCanvasState = useCanvasStore((state) => state.getCanvasState); const viewportZoom = useCanvasStore((state) => state.viewport.zoom); const world3dMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dMore"); const world3dOpenMeshItem = getCanvasToolbarItem( canvasToolbarConfig, "world3dOpenMesh" ); const world3dOpenImageItem = getCanvasToolbarItem( canvasToolbarConfig, "world3dOpenImage" ); const world3dDownloadItem = getCanvasToolbarItem( canvasToolbarConfig, "world3dDownload" ); const world3dCopyItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dCopy"); const world3dDeleteItem = getCanvasToolbarItem( canvasToolbarConfig, "world3dDelete" ); const metadata = useMemo(() => readRecordValue(data.metadata), [data.metadata]); const metadataAssetPayload = useMemo( () => readRecordValue(metadata.assetPayload), [metadata.assetPayload] ); const hydrationTaskId = readStringValue(metadata.taskId); const hydrationProviderId = readStringValue(metadataAssetPayload.providerId) || readStringValue(metadata.providerId); const hydrationProviderName = readStringValue(metadataAssetPayload.providerName) || readStringValue(metadata.providerName); const { data: modelAliasConfigResponse } = useSWR<{ success?: boolean; data?: PublicThreeDModelAliasConfig; }>("/api/canvas/model-aliases", fetcher); const activeView = useMemo(() => resolveActiveView(data), [data]); const availableViews = useMemo(() => resolveAvailableViews(data), [data]); const renderableSplatUrls = useMemo(() => { const assetPayloadSplatUrls = getPreferredInlineWorldLabsSplatUrls( metadataAssetPayload.spzUrls as Record | null | undefined ); const urls = [...assetPayloadSplatUrls]; const fallbackSplatUrl = readStringValue(data.splatUrl); if (fallbackSplatUrl && isArchivedWorldLabsAssetUrl(fallbackSplatUrl)) { urls.push(fallbackSplatUrl); } const seen = new Set(); return urls.filter((url) => { const normalized = String(url || "").trim(); if (!normalized || !isArchivedWorldLabsAssetUrl(normalized)) return false; if (seen.has(normalized)) return false; seen.add(normalized); return true; }); }, [data.splatUrl, metadataAssetPayload.spzUrls]); const renderableSplatUrl = renderableSplatUrls[0]; const renderableMeshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : undefined; const publicModelAliasConfig = modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; const worldModelLabel = useMemo( () => resolveThreeDModelDisplayName( data.modelVersion, publicModelAliasConfig.worldModels, "原生世界节点", ), [data.modelVersion, publicModelAliasConfig.worldModels], ); const primaryUrl = resolvePrimaryUrl(data, activeView); const modelDownloadTarget = inferModelDownloadTarget(data); const interactionMode = "navigate" as const; const placeholderSizeScale = useMemo( () => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76), [node.size.height, node.size.width] ); const overlayChromeScale = useMemo( () => clampNumber( getNodeContentScale(node.size, { width: 420, height: 320 }, 0.48) * Math.max(viewportZoom, 0.1), 0.1, 2.4 ), [node.size.height, node.size.width, viewportZoom] ); const placeholderViewportScale = useMemo( () => clampNumber( placeholderSizeScale * Math.max(viewportZoom, 0.1), 0.1, 2.4 ), [placeholderSizeScale, viewportZoom] ); const placeholderCenterStyle = useMemo( () => ({ transform: `scale(${placeholderViewportScale})`, transformOrigin: "center center", }), [placeholderViewportScale] ); const placeholderBadgeStyle = useMemo( () => ({ transform: `scale(${placeholderViewportScale})`, transformOrigin: "top left", }), [placeholderViewportScale] ); const displayedGenerationProgress = useMemo(() => { if (data.status !== "generating") return 100; const explicitProgress = readFiniteNumber(metadata.progress); if (explicitProgress !== null) { return Math.round(clampNumber(explicitProgress, 1, 99)); } const startedAtRaw = readStringValue(metadata.generationStartedAt) || readStringValue(metadata.createdAt) || readStringValue(metadata.startedAt); const startedAt = startedAtRaw ? Date.parse(startedAtRaw) : NaN; const elapsedMs = Number.isFinite(startedAt) ? Math.max(0, Date.now() - startedAt) : generationTick * 1000; const simulatedProgress = 8 + Math.floor((1 - Math.exp(-elapsedMs / 180000)) * 91); return Math.round(clampNumber(simulatedProgress, 8, 99)); }, [ data.status, generationTick, metadata.createdAt, metadata.generationStartedAt, metadata.progress, metadata.startedAt, ]); const topLeftControlsStyle = useMemo( () => ({ transform: `scale(${overlayChromeScale})`, transformOrigin: "top left", }), [overlayChromeScale] ); const topRightControlsStyle = useMemo( () => ({ transform: `scale(${overlayChromeScale})`, transformOrigin: "top right", }), [overlayChromeScale] ); const captureEnabled = data.status === "completed" && (activeView === "splat" || activeView === "mesh"); const showTopLeftControls = data.status === "completed"; useEffect(() => { if (data.status !== "generating") { setGenerationTick(0); return; } setGenerationTick(0); const timer = window.setInterval(() => { setGenerationTick((tick) => tick + 1); }, 1000); return () => window.clearInterval(timer); }, [data.status, hydrationTaskId]); 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 { const response = await fetch(`/api/canvas/${projectId}/model3d-assets`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ asset }), }); if (!response.ok) { const payload = await response.json().catch(() => ({})); throw new Error(payload?.error || `HTTP ${response.status}`); } } catch (error) { console.error("[World3DNode] 保存画布 3D 世界资产失败:", error); } }, [projectId] ); useEffect(() => { if (data.status !== "generating") return; const provider = readStringValue(metadata.provider) || readStringValue(metadataAssetPayload.provider); if (provider && provider !== "worldlabs-3d") return; if (!hydrationTaskId) return; const taskId = hydrationTaskId; const providerId = hydrationProviderId; const providerName = hydrationProviderName; const sourceImageUrl = readStringValue(metadata.sourceImageUrl) || readStringValue(data.imageUrl) || readStringValue(data.previewImage) || ""; const promptText = readStringValue(data.prompt) || readStringValue(metadata.prompt) || ""; const modelVersion = readStringValue(data.modelVersion) || readStringValue(metadata.modelVersion) || "world-standard"; let cancelled = false; let timer: ReturnType | null = null; if (generationRecoveryTaskRef.current !== taskId) { generationRecoveryTaskRef.current = taskId; generationRecoveryAttemptRef.current = 0; } const failRecoveredTask = async (message: string, progress: number | null) => { updateNode(node.id, { data: { ...data, imageUrl: data.imageUrl || sourceImageUrl || undefined, previewImage: data.previewImage || sourceImageUrl || undefined, prompt: promptText, modelVersion, status: "failed", activeView: "image", metadata: { ...metadata, taskId, provider: "worldlabs-3d", mode: "world-image", sourceImageUrl, progress, error: message, }, } as World3DNodeData, }); await persistCanvas3DAsset({ taskId, provider: "worldlabs-3d", status: "failed", mode: "world-image", prompt: promptText, modelVersion, progress, modelUrl: null, previewImageUrl: sourceImageUrl || data.previewImage || data.imageUrl || null, error: message, assetPayload: { providerId: providerId || null, providerName: providerName || null, sourceImageUrl, }, createdAt: Date.now(), }); persistCanvasStateSnapshot(); toast.error(message); }; const scheduleNextPoll = () => { if (cancelled) return; if (generationRecoveryAttemptRef.current >= WORLD_GENERATION_RECOVERY_MAX_ATTEMPTS) { void failRecoveredTask("3D 世界任务同步超时,请稍后重试", null); return; } timer = setTimeout(() => { void pollWorldGeneration(); }, WORLD_GENERATION_RECOVERY_INTERVAL_MS); }; async function pollWorldGeneration() { if (cancelled) return; generationRecoveryAttemptRef.current += 1; const result = await worldLabsGenerationService.getTask(taskId, { providerId, }); if (cancelled) return; if (!result.success || !result.data) { scheduleNextPoll(); return; } const status = String(result.data.status || "unknown"); const progress = result.data.progress ?? null; if (status === "success") { const assetPayload = readRecordValue(result.data.assetPayload); const splatUrl = pickPreferredInlineWorldLabsSplatUrl( assetPayload.spzUrls as Record | null | undefined ); const panoUrl = readStringValue(assetPayload.panoUrl); const thumbnailUrl = readStringValue(assetPayload.thumbnailUrl); const previewImageFromTask = readStringValue(result.data.previewImageUrl); const rawMeshUrl = readStringValue(result.data.modelUrl) || readStringValue(assetPayload.meshUrl); const meshUrl = isArchivedWorldLabsAssetUrl(rawMeshUrl) ? rawMeshUrl : null; const imageUrl = panoUrl || thumbnailUrl || previewImageFromTask || sourceImageUrl; const hasRenderableWorldAsset = Boolean( splatUrl || panoUrl || thumbnailUrl || previewImageFromTask || meshUrl ); if (!hasRenderableWorldAsset) { scheduleNextPoll(); return; } const previewImageUrl = previewImageFromTask || imageUrl || sourceImageUrl; const nextAssetPayload = { ...assetPayload, providerId: providerId || assetPayload.providerId || null, providerName: providerName || assetPayload.providerName || null, sourceImageUrl, }; updateNode(node.id, { data: { ...data, worldUrl: readStringValue(assetPayload.worldUrl) || data.worldUrl, imageUrl: imageUrl || data.imageUrl, meshUrl: meshUrl || undefined, splatUrl: splatUrl || undefined, previewImage: previewImageUrl || data.previewImage, prompt: promptText, modelVersion, status: "completed", activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image", firstPerson: data.firstPerson ?? true, hotspots: data.hotspots || [], semanticsMetadata: assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object" ? (assetPayload.semanticsMetadata as Record) : data.semanticsMetadata, metadata: { ...metadata, taskId, provider: "worldlabs-3d", mode: "world-image", sourceImageUrl, progress: 100, assetPayload: nextAssetPayload, recoveredAt: new Date().toISOString(), }, } as World3DNodeData, }); await persistCanvas3DAsset({ taskId, provider: "worldlabs-3d", status: "success", mode: "world-image", prompt: promptText, modelVersion, progress: 100, modelUrl: meshUrl, previewImageUrl, error: null, assetPayload: nextAssetPayload, createdAt: Date.now(), }); persistCanvasStateSnapshot(); toast.success("3D 世界已生成到画布"); return; } if (isTerminalWorldFailureStatus(status)) { await failRecoveredTask(result.data.errorMessage || "3D 世界生成失败", progress); return; } scheduleNextPoll(); } void pollWorldGeneration(); return () => { cancelled = true; if (timer) { clearTimeout(timer); } }; }, [ data, hydrationProviderId, hydrationProviderName, hydrationTaskId, metadata, metadataAssetPayload, node.id, persistCanvas3DAsset, persistCanvasStateSnapshot, updateNode, ]); useEffect(() => { if (data.status !== "completed") return; if (renderableSplatUrl || renderableMeshUrl) return; if (!hydrationTaskId) return; if ( readStringValue(metadata.provider) !== "worldlabs-3d" && readStringValue(metadataAssetPayload.provider) !== "worldlabs-3d" ) { return; } const taskId = hydrationTaskId; const providerId = hydrationProviderId; let cancelled = false; let timer: ReturnType | null = null; if (hydrationTaskRef.current !== taskId) { hydrationTaskRef.current = taskId; hydrationAttemptRef.current = 0; } const scheduleNextPoll = () => { if (cancelled) return; if (hydrationAttemptRef.current >= WORLD_ASSET_HYDRATION_MAX_ATTEMPTS) return; timer = setTimeout(() => { void pollWorldAssets(); }, WORLD_ASSET_HYDRATION_INTERVAL_MS); }; async function pollWorldAssets() { if (cancelled) return; hydrationAttemptRef.current += 1; const result = await worldLabsGenerationService.getTask(taskId, { providerId, }); if (cancelled) return; if (!result.success || !result.data) { scheduleNextPoll(); return; } const assetPayload = readRecordValue(result.data.assetPayload); const splatUrl = pickPreferredInlineWorldLabsSplatUrl( assetPayload.spzUrls as Record | null | undefined ); const panoUrl = readStringValue(assetPayload.panoUrl); const thumbnailUrl = readStringValue(assetPayload.thumbnailUrl); const previewImageUrl = readStringValue(result.data.previewImageUrl); const rawMeshUrl = readStringValue(result.data.modelUrl) || readStringValue(assetPayload.meshUrl); const meshUrl = isArchivedWorldLabsAssetUrl(rawMeshUrl) ? rawMeshUrl : null; const imageUrl = panoUrl || thumbnailUrl || previewImageUrl; if (!splatUrl && !meshUrl && !imageUrl) { scheduleNextPoll(); return; } if ( !splatUrl && !meshUrl && (!imageUrl || imageUrl === data.imageUrl || imageUrl === data.previewImage) ) { scheduleNextPoll(); return; } updateNode(node.id, { data: { ...data, worldUrl: readStringValue(assetPayload.worldUrl) || data.worldUrl, imageUrl: imageUrl || data.imageUrl, meshUrl: meshUrl || renderableMeshUrl, splatUrl: splatUrl || renderableSplatUrl, previewImage: previewImageUrl || imageUrl || data.previewImage, status: "completed", activeView: splatUrl || renderableSplatUrl ? "splat" : meshUrl || renderableMeshUrl ? "mesh" : imageUrl ? "image" : data.activeView, firstPerson: data.firstPerson ?? true, semanticsMetadata: assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object" ? (assetPayload.semanticsMetadata as Record) : data.semanticsMetadata, metadata: { ...metadata, assetPayload: { ...metadataAssetPayload, ...assetPayload, }, hydratedAt: new Date().toISOString(), }, } as World3DNodeData, }); if (!splatUrl && !meshUrl) { scheduleNextPoll(); } } void pollWorldAssets(); return () => { cancelled = true; if (timer) { clearTimeout(timer); } }; }, [ data, hydrationProviderId, hydrationTaskId, metadata, metadataAssetPayload, node.id, renderableMeshUrl, renderableSplatUrl, updateNode, ]); const handleSetView = (view: World3DViewMode) => { updateNode(node.id, { data: { ...data, activeView: view, } as World3DNodeData, }); }; const handleCopy = async () => { if (!primaryUrl) { toast.error("暂无可复制的世界地址"); return; } try { await navigator.clipboard.writeText(primaryUrl); toast.success("已复制世界链接"); } catch { toast.error("复制失败"); } }; const handleDownloadModel = () => { if (!modelDownloadTarget) { toast.error("暂无可下载的 GLB 模型"); return; } const link = document.createElement("a"); link.href = modelDownloadTarget.url; link.download = modelDownloadTarget.filename; link.click(); }; const handleViewerCaptureReady = useCallback( (capture: (() => Promise) | null) => { setViewerCapture(() => capture); }, [] ); const stopViewerCanvasEvent = useCallback((event: React.SyntheticEvent) => { event.stopPropagation(); }, []); const handleViewerPointerDown = useCallback( (event: React.PointerEvent) => { selectNode(node.id, event.shiftKey); event.stopPropagation(); }, [node.id, selectNode] ); const handleCaptureSnapshot = useCallback(async () => { if (!viewerCapture) { toast.error("当前视图暂不支持拍照"); return; } setIsCapturingSnapshot(true); let resultNodeId: string | null = null; try { const blob = await viewerCapture(); if (!blob) { throw new Error("当前世界画面抓取失败"); } const file = new File([blob], `world-snapshot-${Date.now()}.png`, { type: blob.type || "image/png", }); const uploadResult = await imageGenerationService.uploadImage(file); if (!uploadResult.success || !uploadResult.data?.url) { throw new Error(uploadResult.error || "上传拍照结果失败"); } const snapshotWidth = Math.max(320, Math.min(640, Math.round(node.size.width * 0.9))); const snapshotHeight = Math.max( 220, Math.round(snapshotWidth * (node.size.height / Math.max(node.size.width, 1))) ); const sourceSnapshotUrl = uploadResult.data.url; const titleBase = (data.prompt || "3D世界").trim() || "3D世界"; const aspectRatio = guessNearestAspectRatio(snapshotWidth, snapshotHeight); const sourceWorldPrompt = String(data.prompt || "").trim(); const refinePrompt = sourceWorldPrompt ? `${WORLD_SNAPSHOT_REPAINT_PROMPT}\n原始世界需求:${sourceWorldPrompt}` : WORLD_SNAPSHOT_REPAINT_PROMPT; resultNodeId = addNode({ type: "image", position: { x: node.position.x + node.size.width + 24, y: node.position.y + 12, }, size: { width: snapshotWidth, height: snapshotHeight }, data: { src: "", title: `${titleBase}-AI实景优化`, prompt: refinePrompt, status: "generating", metadata: { aspectRatio, captureMode: "world3d-ai-refine", sourceSnapshotAssetId: uploadResult.data.assetId, sourceSnapshotUrl, sourceWorldNodeId: node.id, sourceWorldPrompt, sourceWorldView: activeView, }, }, connections: [node.id], }); selectNode(resultNodeId); toast.success("已开始 AI 优化当前世界视角"); const generationResult = await imageGenerationService.generateImage({ prompt: refinePrompt, negativePrompt: WORLD_SNAPSHOT_REPAINT_NEGATIVE_PROMPT, referenceImage: sourceSnapshotUrl, aspectRatio, imageSize: "2K", imageCount: 1, promptExtend: false, billingFeature: "repaint", async: true, }); if (!generationResult.success) { throw new Error(generationResult.error || "AI 实景优化提交失败"); } const immediateImage = generationResult.data?.images?.[0]; const immediateUrl = immediateImage?.originalUrl || immediateImage?.thumbnailUrl || ""; const finalResult = immediateUrl ? { imageUrl: immediateUrl, model: generationResult.data?.model, prompt: generationResult.data?.prompt, } : generationResult.data?.taskId ? await waitForWorldSnapshotRepaint(generationResult.data.taskId) : null; if (!finalResult?.imageUrl) { throw new Error("AI 实景优化未返回图片"); } updateNode(resultNodeId, { data: { src: finalResult.imageUrl, title: `${titleBase}-AI实景`, prompt: finalResult.prompt || refinePrompt, status: "completed", originalSrc: finalResult.imageUrl, model: finalResult.model, metadata: { aspectRatio, captureMode: "world3d-ai-refine", sourceSnapshotAssetId: uploadResult.data.assetId, sourceSnapshotUrl, sourceWorldNodeId: node.id, sourceWorldPrompt, sourceWorldView: activeView, }, }, }); toast.success("AI 实景优化图片已生成"); } catch (error) { const message = error instanceof Error ? error.message : "世界拍照失败"; if (resultNodeId) { updateNode(resultNodeId, { data: { src: "", title: `${(data.prompt || "3D世界").trim() || "3D世界"}-AI实景失败`, prompt: message, status: "failed", metadata: { captureMode: "world3d-ai-refine", error: message, sourceWorldNodeId: node.id, sourceWorldPrompt: data.prompt || "", sourceWorldView: activeView, }, }, }); } toast.error(message); } finally { setIsCapturingSnapshot(false); } }, [ activeView, addNode, data.prompt, node.id, node.position.x, node.position.y, node.size.height, node.size.width, selectNode, updateNode, viewerCapture, ]); return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {data.status === "generating" ? (
3D 世界生成中... {displayedGenerationProgress}% {displayedGenerationProgress >= 95 ? "已接近完成,正在同步空间数据" : worldModelLabel || "世界资产正在构建中"}
) : data.status === "failed" ? (
3D 世界生成失败 可以重新选择模型后再试一次
) : ( <>
event.stopPropagation()} >
)} {showTopLeftControls ? (
{VIEW_META.filter((view) => availableViews.includes(view.id)).map((view) => { const viewItem = getCanvasToolbarItem(canvasToolbarConfig, view.toolbarKey); return ( ); })}
{captureEnabled ? (
) : null} {modelDownloadTarget ? (
) : null}
) : null} {isHovered && data.status === "completed" && (
{data.meshUrl ? ( window.open(data.meshUrl, "_blank", "noopener,noreferrer")}> {world3dOpenMeshItem.label} ) : null} {data.imageUrl ? ( window.open(data.imageUrl, "_blank", "noopener,noreferrer")}> {world3dOpenImageItem.label} ) : null} {modelDownloadTarget ? ( {world3dDownloadItem.label} ) : null} {world3dCopyItem.label} deleteNode(node.id)}> {world3dDeleteItem.label}
)}
); }