diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index 889fc71a9..e3cae0321 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -367,6 +367,35 @@ export async function GET(request: Request, { params }: RouteParams) { throw new Error("World Labs 任务已完成,但未返回 world 数据"); } + if (!hasUsableWorldAssets(world)) { + return NextResponse.json({ + success: true, + data: { + taskId: normalizedTaskId, + type: "world", + status: "running", + progress: 95, + output: operation.response ?? null, + worldId: getStringValue(world.world_id) || worldId || null, + worldUrl: getStringValue(world.world_marble_url), + previewImageUrl: null, + assetPayload: { + providerId: config.providerId || null, + providerName: config.providerName || null, + worldId: getStringValue(world.world_id) || worldId || null, + worldUrl: getStringValue(world.world_marble_url), + displayName: getStringValue(world.display_name), + model: resolveThreeDModelAliasId( + getStringValue(world.model), + modelAliasConfig.worldModels + ), + assetsPending: true, + operationMetadata: operation.metadata ?? null, + }, + }, + }); + } + const assets = world.assets || null; const rawWorldUrl = getStringValue(world.world_marble_url); const rawMeshUrl = getStringValue(assets?.mesh?.collider_mesh_url); diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index d8d4a1516..fccefc651 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -3363,14 +3363,59 @@ export function ImageNode({ const splatUrl = pickBestWorldLabsSplatUrl( assetPayload.spzUrls as Record | null | undefined ); - const imageUrl = - (typeof assetPayload.panoUrl === "string" && assetPayload.panoUrl) || - (typeof assetPayload.thumbnailUrl === "string" && assetPayload.thumbnailUrl) || - result.data.previewImageUrl || - params.sourceImageUrl; - const meshUrl = + const returnedPanoUrl = + typeof assetPayload.panoUrl === "string" && assetPayload.panoUrl.trim() + ? assetPayload.panoUrl.trim() + : null; + const returnedThumbnailUrl = + typeof assetPayload.thumbnailUrl === "string" && assetPayload.thumbnailUrl.trim() + ? assetPayload.thumbnailUrl.trim() + : null; + const returnedPreviewImageUrl = + typeof result.data.previewImageUrl === "string" && + result.data.previewImageUrl.trim() + ? result.data.previewImageUrl.trim() + : null; + const returnedMeshUrl = result.data.modelUrl || - (typeof assetPayload.meshUrl === "string" ? assetPayload.meshUrl : null); + (typeof assetPayload.meshUrl === "string" && assetPayload.meshUrl.trim() + ? assetPayload.meshUrl.trim() + : null); + const hasRenderableWorldAsset = Boolean( + splatUrl || + returnedPanoUrl || + returnedThumbnailUrl || + returnedPreviewImageUrl || + returnedMeshUrl + ); + if (!hasRenderableWorldAsset) { + updateNode(params.nodeId, { + data: { + imageUrl: params.sourceImageUrl, + previewImage: params.sourceImageUrl, + prompt: params.promptText, + modelVersion: params.modelVersion, + status: "generating", + activeView: "image", + metadata: { + taskId: params.taskId, + provider: "worldlabs-3d", + mode: "world-image", + sourceImageUrl: params.sourceImageUrl, + progress: 95, + assetPayload, + }, + }, + }); + persistCanvasStateSnapshot(); + continue; + } + const imageUrl = + returnedPanoUrl || + returnedThumbnailUrl || + returnedPreviewImageUrl || + params.sourceImageUrl; + const meshUrl = returnedMeshUrl; const worldUrl = typeof assetPayload.worldUrl === "string" ? assetPayload.worldUrl : null; const previewImageUrl = result.data.previewImageUrl || imageUrl; diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index c03883650..8479e40b9 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, Box, @@ -18,7 +18,6 @@ import { } from "lucide-react"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { World3DViewer } from "@/components/canvas/world3d/World3DViewer"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -33,6 +32,7 @@ import { resolveThreeDModelDisplayName, type PublicThreeDModelAliasConfig, } from "@/lib/model3d/model-alias-config"; +import { pickBestWorldLabsSplatUrl } from "@/lib/worldlabs/splat-utils"; import { getNodeContentScale } from "@/lib/canvas/node-content-scale"; import { getCanvasToolbarItem, @@ -40,7 +40,7 @@ import { type CanvasToolbarItemKey, } from "@/lib/canvas-toolbar-config"; import { toast } from "sonner"; -import { imageGenerationService } from "@/services/canvas"; +import { imageGenerationService, worldLabsGenerationService } from "@/services/canvas"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas"; import { cn, fetcher } from "@/lib/utils"; @@ -62,19 +62,8 @@ const VIEW_META: Array<{ { id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" }, ]; -function getWorldNodeViewLabel(view: World3DViewMode) { - switch (view) { - case "splat": - return "WORLD"; - case "image": - return "IMAGE"; - case "mesh": - return "MESH"; - case "world": - default: - return "ONLINE"; - } -} +const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000; +const WORLD_ASSET_HYDRATION_MAX_ATTEMPTS = 60; function resolveAvailableViews(data: World3DNodeData) { const views: World3DViewMode[] = []; @@ -128,6 +117,16 @@ function resolvePrimaryUrl(data: World3DNodeData, activeView: World3DViewMode) { } } +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 clampNumber(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } @@ -144,6 +143,8 @@ export function World3DNode({ null ); const [isCapturingSnapshot, setIsCapturingSnapshot] = useState(false); + const hydrationAttemptRef = useRef(0); + const hydrationTaskRef = useRef(null); const deleteNode = useCanvasStore((state) => state.deleteNode); const updateNode = useCanvasStore((state) => state.updateNode); const addNode = useCanvasStore((state) => state.addNode); @@ -175,6 +176,15 @@ export function World3DNode({ 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 { data: modelAliasConfigResponse } = useSWR<{ success?: boolean; data?: PublicThreeDModelAliasConfig; @@ -247,25 +257,111 @@ export function World3DNode({ }), [overlayChromeScale] ); - const bottomOverlayStyle = useMemo( - () => ({ - paddingLeft: `${12 * overlayChromeScale}px`, - paddingRight: `${12 * overlayChromeScale}px`, - paddingBottom: `${12 * overlayChromeScale}px`, - paddingTop: `${48 * overlayChromeScale}px`, - }), - [overlayChromeScale] - ); - const bottomOverlayContentStyle = useMemo( - () => ({ - transform: `scale(${overlayChromeScale})`, - transformOrigin: "bottom left", - width: `${100 / overlayChromeScale}%`, - }), - [overlayChromeScale] - ); const captureEnabled = data.status === "completed" && (activeView === "splat" || activeView === "mesh"); + useEffect(() => { + if (data.status !== "completed") return; + if (data.splatUrl || data.meshUrl) 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 = pickBestWorldLabsSplatUrl( + assetPayload.spzUrls as Record | null | undefined + ); + const panoUrl = readStringValue(assetPayload.panoUrl); + const thumbnailUrl = readStringValue(assetPayload.thumbnailUrl); + const previewImageUrl = readStringValue(result.data.previewImageUrl); + const meshUrl = + readStringValue(result.data.modelUrl) || readStringValue(assetPayload.meshUrl); + const imageUrl = panoUrl || thumbnailUrl || previewImageUrl; + + if (!splatUrl && !meshUrl && !imageUrl) { + scheduleNextPoll(); + return; + } + + updateNode(node.id, { + data: { + ...data, + worldUrl: readStringValue(assetPayload.worldUrl) || data.worldUrl, + imageUrl: imageUrl || data.imageUrl, + meshUrl: meshUrl || data.meshUrl, + splatUrl: splatUrl || data.splatUrl, + previewImage: previewImageUrl || imageUrl || data.previewImage, + status: "completed", + activeView: splatUrl ? "splat" : imageUrl ? "image" : meshUrl ? "mesh" : data.activeView, + firstPerson: data.firstPerson ?? false, + semanticsMetadata: + assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object" + ? (assetPayload.semanticsMetadata as Record) + : data.semanticsMetadata, + metadata: { + ...metadata, + assetPayload: { + ...metadataAssetPayload, + ...assetPayload, + }, + hydratedAt: new Date().toISOString(), + }, + } as World3DNodeData, + }); + } + + void pollWorldAssets(); + + return () => { + cancelled = true; + if (timer) { + clearTimeout(timer); + } + }; + }, [ + data, + hydrationProviderId, + hydrationTaskId, + metadata, + metadataAssetPayload, + node.id, + updateNode, + ]); + const handleSetView = (view: World3DViewMode) => { updateNode(node.id, { data: { @@ -488,6 +584,7 @@ export function World3DNode({ imageUrl={data.imageUrl} interactionMode={interactionMode} meshUrl={data.meshUrl} + minimalFrame mode={activeView} onPick={handlePick} previewImageUrl={data.previewImage} @@ -498,37 +595,6 @@ export function World3DNode({ /> -
-
-
-

- {data.prompt || "3D 世界"} -

-
- - 3D 世界 - - {hotspotCount ? ( - - {hotspotCount} 个热点 - - ) : null} - - {getWorldNodeViewLabel(activeView)} - -
-
-

- {worldModelLabel} -

-
-
)} diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index ad4348048..a69981287 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -62,6 +62,7 @@ type World3DViewerProps = { splatUrl?: string | null; previewImageUrl?: string | null; emptyStateTitle?: string; + minimalFrame?: boolean; meshSettings?: Model3DViewerSettings; meshLayerVisibility?: Record; onMeshLayersChange?: (layers: Model3DLayer[]) => void; @@ -338,13 +339,40 @@ function WorldEmptyState({ icon: Icon, title, description, + fillFrame = false, previewImageUrl, }: { icon: React.ComponentType<{ className?: string }>; title: string; description: string; + fillFrame?: boolean; previewImageUrl?: string | null; }) { + if (previewImageUrl && fillFrame) { + return ( +
+ {title} +
+ +
+
+ ); + } + + if (fillFrame) { + return ( +
+
+ +
+
+ ); + } + return (
{previewImageUrl ? ( @@ -1027,11 +1055,13 @@ function WorldSplatViewer({ onHotspotSelect, semanticsMetadata, resetViewToken, + minimalFrame = false, onCaptureReady, }: { splatUrl?: string | null; worldUrl?: string | null; previewImageUrl?: string | null; + minimalFrame?: boolean; firstPerson?: boolean; hotspots?: World3DHotspot[]; interactionMode?: "navigate" | "hotspot"; @@ -1071,6 +1101,7 @@ function WorldSplatViewer({ ? "官方在线预览页不能稳定嵌入画布,正在等待可本地渲染的 World 资产。" : "当前结果没有返回可用的世界数据。" } + fillFrame={minimalFrame} icon={Sparkles} previewImageUrl={previewImageUrl} title="准备加载 World 视图" @@ -1086,6 +1117,7 @@ function WorldSplatViewer({ ? "World 资产加载失败,已避免退回不稳定的官方 iframe。可以切换到图像或 Mesh 视图继续查看。" : "当前世界数据暂时无法加载,可以切换到图像或 Mesh 视图继续查看。" } + fillFrame={minimalFrame} icon={AlertCircle} previewImageUrl={previewImageUrl} title="World 视图加载失败" @@ -1131,6 +1163,7 @@ export function World3DViewer({ splatUrl, previewImageUrl, emptyStateTitle = "准备生成 3D 世界", + minimalFrame = false, meshSettings, meshLayerVisibility, onMeshLayersChange, @@ -1185,6 +1218,7 @@ export function World3DViewer({ ) : ( {resolvedImageUrl ? ( 世界图像预览 ) : (