fix: keep world 3d assets hydrated
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-24 19:28:31 +08:00
parent c7de64b3f6
commit 63433120e6
5 changed files with 256 additions and 73 deletions

View File

@ -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);

View File

@ -3363,14 +3363,59 @@ export function ImageNode({
const splatUrl = pickBestWorldLabsSplatUrl(
assetPayload.spzUrls as Record<string, string> | 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;

View File

@ -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<string, any>)
: {};
}
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<string | null>(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<React.CSSProperties>(
() => ({
paddingLeft: `${12 * overlayChromeScale}px`,
paddingRight: `${12 * overlayChromeScale}px`,
paddingBottom: `${12 * overlayChromeScale}px`,
paddingTop: `${48 * overlayChromeScale}px`,
}),
[overlayChromeScale]
);
const bottomOverlayContentStyle = useMemo<React.CSSProperties>(
() => ({
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<typeof setTimeout> | 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<string, string> | 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<string, any>)
: 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({
/>
</div>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent"
style={bottomOverlayStyle}
>
<div
className="flex items-end justify-between gap-3"
style={bottomOverlayContentStyle}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">
{data.prompt || "3D 世界"}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge className="border-white/10 bg-black/55 text-zinc-200" variant="outline">
3D 世界
</Badge>
{hotspotCount ? (
<Badge className="border-amber-500/30 bg-amber-500/10 text-amber-200" variant="outline">
{hotspotCount} 个热点
</Badge>
) : null}
<Badge className="border-white/10 bg-black/55 text-zinc-300" variant="outline">
{getWorldNodeViewLabel(activeView)}
</Badge>
</div>
</div>
<p className="text-[11px] text-zinc-400">
{worldModelLabel}
</p>
</div>
</div>
</>
)}

View File

@ -62,6 +62,7 @@ type World3DViewerProps = {
splatUrl?: string | null;
previewImageUrl?: string | null;
emptyStateTitle?: string;
minimalFrame?: boolean;
meshSettings?: Model3DViewerSettings;
meshLayerVisibility?: Record<string, boolean>;
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 (
<div className="relative h-full w-full overflow-hidden bg-zinc-950">
<img
alt={title}
className="h-full w-full object-cover"
src={previewImageUrl}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/10">
<Loader2 className="h-7 w-7 animate-spin text-white/55 drop-shadow" />
</div>
</div>
);
}
if (fillFrame) {
return (
<div className="flex h-full w-full items-center justify-center bg-zinc-950/70">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-zinc-900/60 shadow-inner">
<Icon className="h-10 w-10 text-zinc-700" />
</div>
</div>
);
}
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-zinc-950/70 p-6 text-center">
{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({
) : (
<WorldEmptyState
description="当前结果还没有可嵌入的在线预览入口,可以先切换到 World、图像或 Mesh 视图。"
fillFrame={minimalFrame}
icon={Globe}
previewImageUrl={previewImageUrl}
title={emptyStateTitle}
@ -1198,19 +1232,26 @@ export function World3DViewer({
return (
<div
className={cn(
"flex h-full w-full items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(39,39,42,0.95),_rgba(2,6,23,1)_68%)] p-6",
minimalFrame
? "flex h-full w-full items-center justify-center overflow-hidden bg-zinc-950"
: "flex h-full w-full items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(39,39,42,0.95),_rgba(2,6,23,1)_68%)] p-6",
className
)}
>
{resolvedImageUrl ? (
<img
alt="世界图像预览"
className="max-h-full w-full rounded-2xl object-contain shadow-[0_30px_80px_rgba(0,0,0,0.45)]"
className={
minimalFrame
? "h-full w-full object-cover"
: "max-h-full w-full rounded-2xl object-contain shadow-[0_30px_80px_rgba(0,0,0,0.45)]"
}
src={resolvedImageUrl}
/>
) : (
<WorldEmptyState
description="当前结果还没有可用的图像资产,可以先切换到 World 或 Mesh 视图。"
fillFrame={minimalFrame}
icon={ImageIcon}
previewImageUrl={previewImageUrl}
title={emptyStateTitle}
@ -1230,6 +1271,7 @@ export function World3DViewer({
onHotspotSelect={onHotspotSelect}
onPick={onPick}
previewImageUrl={previewImageUrl}
minimalFrame={minimalFrame}
resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata}
splatUrl={splatUrl}

View File

@ -870,6 +870,7 @@ export type WorldLabsTaskAssetPayload = {
sourceMeshUrl?: string | null;
sourceThumbnailUrl?: string | null;
sourcePanoUrl?: string | null;
assetsPending?: boolean | null;
};
export const worldLabsGenerationService = {