fix: keep world 3d assets hydrated
This commit is contained in:
parent
c7de64b3f6
commit
63433120e6
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -870,6 +870,7 @@ export type WorldLabsTaskAssetPayload = {
|
||||
sourceMeshUrl?: string | null;
|
||||
sourceThumbnailUrl?: string | null;
|
||||
sourcePanoUrl?: string | null;
|
||||
assetsPending?: boolean | null;
|
||||
};
|
||||
|
||||
export const worldLabsGenerationService = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user