1254 lines
42 KiB
TypeScript
1254 lines
42 KiB
TypeScript
"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<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 [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 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 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<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 ?? true,
|
||
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 ?? true,
|
||
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 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={activeView === "splat"}
|
||
hotspots={data.hotspots}
|
||
imageUrl={data.imageUrl}
|
||
interactionMode={interactionMode}
|
||
meshUrl={renderableMeshUrl}
|
||
minimalFrame
|
||
mode={activeView}
|
||
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>
|
||
|
||
{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>
|
||
);
|
||
}
|