ai-chatbot/components/canvas/nodes/World3DNode.tsx
Your Name 939138a02a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: preserve world snapshot composition during refinement
2026-05-26 14:15:43 +08:00

1351 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
Box,
Camera,
Copy,
Crosshair,
Cuboid,
Download,
ImageIcon,
Loader2,
MoreVertical,
Sparkles,
Trash2,
Waypoints,
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, World3DHotspot, 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" },
{ id: "mesh", label: "M", icon: Cuboid, toolbarKey: "world3dViewMesh" },
];
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 &&
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<string, any>)
: {};
}
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 [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate");
const [viewerCapture, setViewerCapture] = useState<(() => Promise<Blob | null>) | null>(
null
);
const [isCapturingSnapshot, setIsCapturingSnapshot] = useState(false);
const [generationTick, setGenerationTick] = useState(0);
const hydrationAttemptRef = useRef(0);
const hydrationTaskRef = useRef<string | null>(null);
const generationRecoveryAttemptRef = useRef(0);
const generationRecoveryTaskRef = useRef<string | null>(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 world3dFirstPersonItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dFirstPerson"
);
const world3dHotspotItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dHotspot"
);
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<string, string> | null | undefined
);
const urls = [...assetPayloadSplatUrls];
const fallbackSplatUrl = readStringValue(data.splatUrl);
if (fallbackSplatUrl && isArchivedWorldLabsAssetUrl(fallbackSplatUrl)) {
urls.push(fallbackSplatUrl);
}
const seen = new Set<string>();
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 hotspotCount = data.hotspots?.length || 0;
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<React.CSSProperties>(
() => ({
transform: `scale(${placeholderViewportScale})`,
transformOrigin: "center center",
}),
[placeholderViewportScale]
);
const placeholderBadgeStyle = useMemo<React.CSSProperties>(
() => ({
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<React.CSSProperties>(
() => ({
transform: `scale(${overlayChromeScale})`,
transformOrigin: "top left",
}),
[overlayChromeScale]
);
const topRightControlsStyle = useMemo<React.CSSProperties>(
() => ({
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<string, any>) => {
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<typeof setTimeout> | 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<string, string> | 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 ?? false,
hotspots: data.hotspots || [],
semanticsMetadata:
assetPayload.semanticsMetadata &&
typeof assetPayload.semanticsMetadata === "object"
? (assetPayload.semanticsMetadata as Record<string, any>)
: 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<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 = pickPreferredInlineWorldLabsSplatUrl(
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 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 ?? 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,
});
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 handleToggleFirstPerson = () => {
const nextFirstPerson = !data.firstPerson;
updateNode(node.id, {
data: {
...data,
firstPerson: nextFirstPerson,
} as World3DNodeData,
});
if (nextFirstPerson) {
setInteractionMode("navigate");
}
};
const handleToggleHotspotMode = () => {
const nextMode = interactionMode === "hotspot" ? "navigate" : "hotspot";
setInteractionMode(nextMode);
if (nextMode === "hotspot" && data.firstPerson) {
updateNode(node.id, {
data: {
...data,
firstPerson: false,
} as World3DNodeData,
});
}
};
const handlePick = (pick: { point: [number, number, number] }) => {
if (interactionMode !== "hotspot") return;
const nextHotspot: World3DHotspot = {
id: crypto.randomUUID(),
label: `热点 ${hotspotCount + 1}`,
position: {
x: pick.point[0],
y: pick.point[1],
z: pick.point[2],
},
createdAt: new Date().toISOString(),
};
updateNode(node.id, {
data: {
...data,
hotspots: [...(data.hotspots || []), nextHotspot],
} as World3DNodeData,
});
setInteractionMode("navigate");
toast.success("已添加世界热点");
};
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<Blob | null>) | null) => {
setViewerCapture(() => capture);
},
[]
);
const stopViewerCanvasEvent = useCallback((event: React.SyntheticEvent) => {
event.stopPropagation();
}, []);
const handleViewerPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
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 (
<div
className={cn(
"relative h-full w-full overflow-hidden rounded-none border border-[#dbe2ee] bg-white shadow-[0_0_0_1px_rgba(219,226,238,0.34),0_16px_34px_rgba(22,24,29,0.08)] transition-all dark:border-zinc-800 dark:bg-zinc-950",
isSelected
? "border-[#5b76fe] ring-2 ring-[#5b76fe]/14 shadow-[0_0_0_1px_rgba(91,118,254,0.14),0_20px_38px_rgba(91,118,254,0.12)]"
: "hover:border-[#cfd9ea]"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{data.status === "generating" ? (
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#f7f9fc] dark:bg-zinc-900">
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#dde5f4] bg-white text-[#566374] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<Sparkles className="h-3.5 w-3.5" />
</span>
</div>
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-cyan-200">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="text-sm font-medium text-[#3f4b63] dark:text-zinc-100">
3D 世界生成中... {displayedGenerationProgress}%
</span>
<span className="relative h-1.5 w-36 overflow-hidden rounded-full bg-[#dfe6f3] dark:bg-zinc-800">
<span
className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-[#5b76fe] via-[#35d4ff] to-[#7cf4d2] transition-[width] duration-700 ease-out"
style={{ width: `${displayedGenerationProgress}%` }}
/>
</span>
<span className="text-xs text-[#7a8395] dark:text-zinc-400">
{displayedGenerationProgress >= 95
? "已接近完成,正在同步空间数据"
: worldModelLabel || "世界资产正在构建中"}
</span>
</div>
</div>
) : data.status === "failed" ? (
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#fff7f8] dark:bg-red-950/30">
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
<AlertCircle className="h-3.5 w-3.5" />
</span>
</div>
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
<AlertCircle className="h-5 w-5" />
</span>
<span className="text-sm font-medium text-[#c84b5e] dark:text-red-200">
3D 世界生成失败
</span>
<span className="text-xs text-[#b77480] dark:text-red-300/80">
可以重新选择模型后再试一次
</span>
</div>
</div>
) : (
<>
<div
className="absolute inset-0 touch-none"
data-canvas-no-drag="true"
onClick={stopViewerCanvasEvent}
onContextMenu={stopViewerCanvasEvent}
onDoubleClick={stopViewerCanvasEvent}
onMouseDown={stopViewerCanvasEvent}
onMouseMove={stopViewerCanvasEvent}
onMouseUp={stopViewerCanvasEvent}
onPointerCancel={stopViewerCanvasEvent}
onPointerDown={handleViewerPointerDown}
onPointerMove={stopViewerCanvasEvent}
onPointerUp={stopViewerCanvasEvent}
onTouchEnd={stopViewerCanvasEvent}
onTouchMove={stopViewerCanvasEvent}
onTouchStart={stopViewerCanvasEvent}
onWheel={(event) => event.stopPropagation()}
>
<World3DViewer
className="h-full w-full"
firstPerson={Boolean(data.firstPerson) && activeView === "splat"}
hotspots={data.hotspots}
imageUrl={data.imageUrl}
interactionMode={interactionMode}
meshUrl={renderableMeshUrl}
minimalFrame
mode={activeView}
onPick={handlePick}
previewImageUrl={data.previewImage}
semanticsMetadata={data.semanticsMetadata}
splatUrl={renderableSplatUrl}
splatUrls={renderableSplatUrls}
worldUrl={data.worldUrl}
onCaptureReady={handleViewerCaptureReady}
showMovementControls={
(activeView === "splat" || activeView === "mesh") &&
(isSelected || isHovered)
}
/>
</div>
</>
)}
{showTopLeftControls ? (
<div
className="absolute left-3 top-3 z-20 flex items-center gap-1.5"
style={topLeftControlsStyle}
>
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
{VIEW_META.filter((view) => availableViews.includes(view.id)).map((view) => {
const viewItem = getCanvasToolbarItem(canvasToolbarConfig, view.toolbarKey);
return (
<Button
className="h-5 min-w-5 rounded-full px-1.5 text-[9px]"
key={view.id}
onClick={() => handleSetView(view.id)}
size="sm"
title={viewItem.label}
type="button"
variant={activeView === view.id ? "secondary" : "ghost"}
>
<CanvasToolbarIcon
active={activeView !== view.id}
className="h-2.5 w-2.5"
fallback={view.icon}
item={viewItem}
/>
</Button>
);
})}
</div>
{activeView === "splat" ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
<Button
className="h-5 rounded-full px-1.5"
onClick={handleToggleFirstPerson}
size="sm"
title={world3dFirstPersonItem.label}
type="button"
variant={data.firstPerson ? "secondary" : "ghost"}
>
<CanvasToolbarIcon
active={!data.firstPerson}
className="h-2.5 w-2.5"
fallback={Waypoints}
item={world3dFirstPersonItem}
/>
</Button>
<Button
className="h-5 rounded-full px-1.5"
onClick={handleToggleHotspotMode}
size="sm"
title={world3dHotspotItem.label}
type="button"
variant={interactionMode === "hotspot" ? "secondary" : "ghost"}
>
<CanvasToolbarIcon
active={interactionMode !== "hotspot"}
className="h-2.5 w-2.5"
fallback={Crosshair}
item={world3dHotspotItem}
/>
</Button>
</div>
) : null}
{captureEnabled ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
<Button
className="h-5 rounded-full px-1.5"
disabled={!viewerCapture || isCapturingSnapshot}
onClick={() => void handleCaptureSnapshot()}
size="sm"
title="AI 实景拍照"
type="button"
variant="ghost"
>
{isCapturingSnapshot ? (
<Loader2 className="h-2.5 w-2.5 animate-spin text-white" />
) : (
<Camera className="h-2.5 w-2.5 text-white" />
)}
</Button>
</div>
) : null}
{modelDownloadTarget ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
<Button
className="h-5 rounded-full px-1.5"
onClick={handleDownloadModel}
size="sm"
title="下载模型"
type="button"
variant="ghost"
>
<CanvasToolbarIcon
className="h-2.5 w-2.5"
fallback={Download}
item={world3dDownloadItem}
/>
</Button>
</div>
) : null}
</div>
) : null}
{isHovered && data.status === "completed" && (
<div
className="absolute right-3 top-3 z-20 flex items-center gap-1"
style={topRightControlsStyle}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="h-7 w-7 rounded-full"
size="icon"
title={world3dMoreItem.label}
variant="secondary"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={MoreVertical}
item={world3dMoreItem}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{data.meshUrl ? (
<DropdownMenuItem onClick={() => window.open(data.meshUrl, "_blank", "noopener,noreferrer")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Box}
item={world3dOpenMeshItem}
/>
{world3dOpenMeshItem.label}
</DropdownMenuItem>
) : null}
{data.imageUrl ? (
<DropdownMenuItem onClick={() => window.open(data.imageUrl, "_blank", "noopener,noreferrer")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={ImageIcon}
item={world3dOpenImageItem}
/>
{world3dOpenImageItem.label}
</DropdownMenuItem>
) : null}
{modelDownloadTarget ? (
<DropdownMenuItem onClick={handleDownloadModel}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={world3dDownloadItem}
/>
{world3dDownloadItem.label}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={handleCopy}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Copy}
item={world3dCopyItem}
/>
{world3dCopyItem.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Trash2}
item={world3dDeleteItem}
/>
{world3dDeleteItem.label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
);
}