diff --git a/app/api/canvas/3d/model-proxy/route.ts b/app/api/canvas/3d/model-proxy/route.ts index 2daf0d2da..5da07108c 100644 --- a/app/api/canvas/3d/model-proxy/route.ts +++ b/app/api/canvas/3d/model-proxy/route.ts @@ -8,7 +8,17 @@ function isAllowedModelHost(hostname: string) { const normalized = hostname.toLowerCase(); return ( normalized === "tripo3d.com" || - normalized.endsWith(".tripo3d.com") + normalized.endsWith(".tripo3d.com") || + normalized.includes("worldlabs") || + normalized.includes("marble") || + normalized.includes("s3.1080p.hk") || + normalized.includes("img.yydd.ai") || + normalized.includes("blob.vercel-storage.com") || + normalized.includes("aliyuncs.com") || + normalized.includes("amazonaws.com") || + normalized.includes("googleapis.com") || + normalized.includes("googleusercontent.com") || + normalized.includes("cloudfront.net") ); } @@ -92,4 +102,3 @@ export async function GET(request: Request) { ); } } - diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index e3cae0321..0a9b92da7 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -37,7 +37,11 @@ function getArchiveInflight() { function isLikelyArchivedStorageUrl(parsed: URL) { const hostname = parsed.hostname.toLowerCase(); - return hostname.includes("blob.vercel-storage.com"); + return ( + hostname.includes("blob.vercel-storage.com") || + hostname.includes("s3.1080p.hk") || + hostname.includes("img.yydd.ai") + ); } function shouldArchiveRemoteAssetUrl(sourceUrl?: string | null) { @@ -124,8 +128,7 @@ async function archiveRemoteAssetIfNeeded(params: { const inflight = getArchiveInflight(); const existingTask = inflight.get(params.cacheKey); if (existingTask) { - const result = await existingTask; - return result || sourceUrl; + return existingTask; } const archiveTask = (async () => { @@ -147,11 +150,7 @@ async function archiveRemoteAssetIfNeeded(params: { cacheKey: params.cacheKey, sourceUrl, }); - cache.set(params.cacheKey, { - sourceUrl, - archivedUrl: sourceUrl, - }); - return sourceUrl; + return null; } const assetBuffer = await upstream.arrayBuffer(); @@ -184,8 +183,7 @@ async function archiveRemoteAssetIfNeeded(params: { })(); inflight.set(params.cacheKey, archiveTask); - const archivedUrl = await archiveTask; - return archivedUrl || sourceUrl; + return archiveTask; } async function archiveRemoteAssetMapIfNeeded(params: { @@ -201,19 +199,21 @@ async function archiveRemoteAssetMapIfNeeded(params: { if (entries.length === 0) return null; - const archivedEntries = await Promise.all( - entries.map(async ([key, value]) => { - const archivedUrl = await archiveRemoteAssetIfNeeded({ - cacheKey: `${params.cacheKeyPrefix}:${key}`, - sourceUrl: value, - filenamePrefix: `${params.filenamePrefix}-${key}`, - fallbackExtension: params.fallbackExtension, - }); - return [key, archivedUrl || value] as const; - }) - ); + const archivedEntries = ( + await Promise.all( + entries.map(async ([key, value]) => { + const archivedUrl = await archiveRemoteAssetIfNeeded({ + cacheKey: `${params.cacheKeyPrefix}:${key}`, + sourceUrl: value, + filenamePrefix: `${params.filenamePrefix}-${key}`, + fallbackExtension: params.fallbackExtension, + }); + return archivedUrl ? ([key, archivedUrl] as const) : null; + }) + ) + ).filter((entry): entry is readonly [string, string] => Boolean(entry)); - return Object.fromEntries(archivedEntries); + return archivedEntries.length > 0 ? Object.fromEntries(archivedEntries) : null; } function getStringValue(...values: unknown[]) { @@ -402,13 +402,9 @@ export async function GET(request: Request, { params }: RouteParams) { const rawThumbnailUrl = getStringValue(assets?.thumbnail_url); const rawPanoUrl = getStringValue(assets?.imagery?.pano_url); - const stableWorldUrl = await archiveRemoteAssetIfNeeded({ - cacheKey: `worldlabs:${normalizedTaskId}:world`, - sourceUrl: rawWorldUrl, - filenamePrefix: `worldlabs-world-${normalizedTaskId}`, - fallbackExtension: "world", - skipHtml: true, - }); + // world_marble_url is an interactive HTML viewer. It is intentionally not embedded in + // canvas because the third-party iframe is unstable and can expire independently. + const stableWorldUrl: string | null = null; const stableModelUrl = await archiveRemoteAssetIfNeeded({ cacheKey: `worldlabs:${normalizedTaskId}:mesh`, sourceUrl: rawMeshUrl, @@ -473,21 +469,22 @@ export async function GET(request: Request, { params }: RouteParams) { modelUrl: stableModelUrl, previewImageUrl: stableThumbnailUrl || stablePanoUrl || null, worldId: assetPayload.worldId, - worldUrl: stableWorldUrl || assetPayload.worldUrl, + worldUrl: stableWorldUrl, panoUrl: stablePanoUrl, caption: assetPayload.caption, assetPayload: { ...assetPayload, - worldUrl: stableWorldUrl || assetPayload.worldUrl, + worldUrl: stableWorldUrl, thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl, panoUrl: stablePanoUrl || assetPayload.panoUrl, - meshUrl: stableModelUrl || assetPayload.meshUrl, - spzUrls: stableSpzUrls || assetPayload.spzUrls, + meshUrl: stableModelUrl, + spzUrls: stableSpzUrls, storageArchivedAt, - sourceWorldUrl: - rawWorldUrl && stableWorldUrl && rawWorldUrl !== stableWorldUrl - ? rawWorldUrl - : null, + assetsArchivingPending: Boolean( + (rawMeshUrl && !stableModelUrl) || + (assetPayload.spzUrls && !stableSpzUrls) + ), + sourceWorldUrl: rawWorldUrl || null, sourceMeshUrl: rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl ? rawMeshUrl @@ -500,6 +497,10 @@ export async function GET(request: Request, { params }: RouteParams) { rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl ? rawPanoUrl : null, + sourceSpzUrls: + assetPayload.spzUrls && stableSpzUrls !== assetPayload.spzUrls + ? assetPayload.spzUrls + : null, }, }, }); diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 0224ccbf7..0c3758f81 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -90,7 +90,7 @@ import { import { cn, fetcher } from "@/lib/utils"; import { getSortedWorldLabsSplatEntries, - pickBestWorldLabsSplatUrl, + pickBestArchivedWorldLabsSplatUrl, } from "@/lib/worldlabs/splat-utils"; import { DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, @@ -1991,7 +1991,7 @@ export default function CanvasModel3DPage() { const isWorldTask = currentTask.provider === "worldlabs-3d"; const worldAssetPayload = currentTask.assetPayload ?? null; const primarySplatUrl = isWorldTask - ? pickBestWorldLabsSplatUrl(worldAssetPayload?.spzUrls) + ? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls) : null; const worldImageUrl = worldAssetPayload?.panoUrl || diff --git a/app/worlds/share/[token]/page.tsx b/app/worlds/share/[token]/page.tsx index 42be6879f..d788c629e 100644 --- a/app/worlds/share/[token]/page.tsx +++ b/app/worlds/share/[token]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation"; import { WorldSharePage } from "@/components/canvas/world3d/WorldSharePage"; import { db } from "@/lib/db"; import { model3dAsset } from "@/lib/db/schema"; -import { pickBestWorldLabsSplatUrl } from "@/lib/worldlabs/splat-utils"; +import { pickBestArchivedWorldLabsSplatUrl } from "@/lib/worldlabs/splat-utils"; export const dynamic = "force-dynamic"; @@ -86,7 +86,7 @@ export default async function SharedWorldPage({ previewImageUrl: asset.previewImageUrl || assetPayload?.thumbnailUrl || null, prompt: asset.prompt || null, semanticsMetadata: assetPayload?.semanticsMetadata || null, - splatUrl: pickBestWorldLabsSplatUrl(assetPayload?.spzUrls), + splatUrl: pickBestArchivedWorldLabsSplatUrl(assetPayload?.spzUrls), title: assetPayload?.displayName || asset.prompt || `World ${asset.taskId.slice(0, 8)}`, worldUrl: assetPayload?.worldUrl || null, }; diff --git a/components/canvas/model3d/Model3DViewer.tsx b/components/canvas/model3d/Model3DViewer.tsx index 4bdfbe856..d5e15b0e3 100644 --- a/components/canvas/model3d/Model3DViewer.tsx +++ b/components/canvas/model3d/Model3DViewer.tsx @@ -14,6 +14,7 @@ import React, { Suspense, useEffect, useMemo, useRef } from "react"; import * as THREE from "three"; import type { Mesh, Object3D } from "three"; import { Button } from "@/components/ui/button"; +import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; export type Model3DLayer = { id: string; @@ -324,18 +325,7 @@ export function Model3DViewer({ }: Model3DViewerProps) { const controlsRef = useRef(null); const resolvedModelUrl = useMemo(() => { - const normalized = String(modelUrl || "").trim(); - if (!normalized) return ""; - try { - const parsed = new URL(normalized); - const hostname = parsed.hostname.toLowerCase(); - if (hostname === "tripo3d.com" || hostname.endsWith(".tripo3d.com")) { - return `/api/canvas/3d/model-proxy?url=${encodeURIComponent(normalized)}`; - } - } catch { - // keep original url when parse fails (e.g. relative path) - } - return normalized; + return buildThreeAssetUrl(modelUrl); }, [modelUrl]); if (!modelUrl) { diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 2b0124041..e6201750e 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -84,7 +84,10 @@ import { getDefaultThreeDModelAliasId, type PublicThreeDModelAliasConfig, } from "@/lib/model3d/model-alias-config"; -import { pickBestWorldLabsSplatUrl } from "@/lib/worldlabs/splat-utils"; +import { + isArchivedWorldLabsAssetUrl, + pickBestArchivedWorldLabsSplatUrl, +} from "@/lib/worldlabs/splat-utils"; import { getNodeContentScale, getScaledNodeContentStyle, @@ -3486,7 +3489,7 @@ export function ImageNode({ if (status === "success") { const assetPayload = (result.data.assetPayload || {}) as Record; - const splatUrl = pickBestWorldLabsSplatUrl( + const splatUrl = pickBestArchivedWorldLabsSplatUrl( assetPayload.spzUrls as Record | null | undefined ); const returnedPanoUrl = @@ -3502,11 +3505,14 @@ export function ImageNode({ result.data.previewImageUrl.trim() ? result.data.previewImageUrl.trim() : null; - const returnedMeshUrl = + const rawReturnedMeshUrl = result.data.modelUrl || (typeof assetPayload.meshUrl === "string" && assetPayload.meshUrl.trim() ? assetPayload.meshUrl.trim() : null); + const returnedMeshUrl = isArchivedWorldLabsAssetUrl(rawReturnedMeshUrl) + ? rawReturnedMeshUrl + : null; const hasRenderableWorldAsset = Boolean( splatUrl || returnedPanoUrl || diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index 53797675e..b3de276b1 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -32,7 +32,10 @@ import { resolveThreeDModelDisplayName, type PublicThreeDModelAliasConfig, } from "@/lib/model3d/model-alias-config"; -import { pickBestWorldLabsSplatUrl } from "@/lib/worldlabs/splat-utils"; +import { + isArchivedWorldLabsAssetUrl, + pickBestArchivedWorldLabsSplatUrl, +} from "@/lib/worldlabs/splat-utils"; import { getNodeContentScale } from "@/lib/canvas/node-content-scale"; import { getCanvasToolbarItem, @@ -67,9 +70,9 @@ const WORLD_ASSET_HYDRATION_MAX_ATTEMPTS = 60; function resolveAvailableViews(data: World3DNodeData) { const views: World3DViewMode[] = []; - if (data.splatUrl) views.push("splat"); + if (isArchivedWorldLabsAssetUrl(data.splatUrl)) views.push("splat"); if (data.imageUrl || data.previewImage) views.push("image"); - if (data.meshUrl) views.push("mesh"); + if (isArchivedWorldLabsAssetUrl(data.meshUrl)) views.push("mesh"); return views; } @@ -82,17 +85,20 @@ function resolveActiveView(data: World3DNodeData) { } function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) { - if (activeView === "splat" && data.splatUrl) { - return { url: data.splatUrl, filename: "world.spz" }; + const splatUrl = isArchivedWorldLabsAssetUrl(data.splatUrl) ? data.splatUrl : null; + const meshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : null; + + if (activeView === "splat" && splatUrl) { + return { url: splatUrl, filename: "world.spz" }; } - if (activeView === "mesh" && data.meshUrl) { - return { url: data.meshUrl, filename: "world.glb" }; + if (activeView === "mesh" && meshUrl) { + return { url: meshUrl, filename: "world.glb" }; } - if (data.splatUrl) { - return { url: data.splatUrl, filename: "world.spz" }; + if (splatUrl) { + return { url: splatUrl, filename: "world.spz" }; } - if (data.meshUrl) { - return { url: data.meshUrl, filename: "world.glb" }; + if (meshUrl) { + return { url: meshUrl, filename: "world.glb" }; } if (data.imageUrl) { return { url: data.imageUrl, filename: "world.jpg" }; @@ -104,16 +110,19 @@ function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) } 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 data.splatUrl || data.meshUrl || data.imageUrl || null; + return splatUrl || meshUrl || data.imageUrl || null; case "image": - return data.imageUrl || data.previewImage || data.meshUrl || data.splatUrl || null; + return data.imageUrl || data.previewImage || meshUrl || splatUrl || null; case "mesh": - return data.meshUrl || data.splatUrl || data.imageUrl || null; + return meshUrl || splatUrl || data.imageUrl || null; case "splat": default: - return data.splatUrl || data.imageUrl || data.meshUrl || null; + return splatUrl || data.imageUrl || meshUrl || null; } } @@ -192,6 +201,12 @@ export function World3DNode({ const activeView = useMemo(() => resolveActiveView(data), [data]); const availableViews = useMemo(() => resolveAvailableViews(data), [data]); + const renderableSplatUrl = isArchivedWorldLabsAssetUrl(data.splatUrl) + ? data.splatUrl + : undefined; + const renderableMeshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) + ? data.meshUrl + : undefined; const publicModelAliasConfig = modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; const worldModelLabel = useMemo( @@ -262,7 +277,7 @@ export function World3DNode({ useEffect(() => { if (data.status !== "completed") return; - if (data.splatUrl || data.meshUrl) return; + if (renderableSplatUrl || renderableMeshUrl) return; if (!hydrationTaskId) return; if ( readStringValue(metadata.provider) !== "worldlabs-3d" && @@ -303,14 +318,15 @@ export function World3DNode({ } const assetPayload = readRecordValue(result.data.assetPayload); - const splatUrl = pickBestWorldLabsSplatUrl( + const splatUrl = pickBestArchivedWorldLabsSplatUrl( assetPayload.spzUrls as Record | null | undefined ); const panoUrl = readStringValue(assetPayload.panoUrl); const thumbnailUrl = readStringValue(assetPayload.thumbnailUrl); const previewImageUrl = readStringValue(result.data.previewImageUrl); - const meshUrl = + const rawMeshUrl = readStringValue(result.data.modelUrl) || readStringValue(assetPayload.meshUrl); + const meshUrl = isArchivedWorldLabsAssetUrl(rawMeshUrl) ? rawMeshUrl : null; const imageUrl = panoUrl || thumbnailUrl || previewImageUrl; if (!splatUrl && !meshUrl && !imageUrl) { @@ -318,13 +334,22 @@ export function World3DNode({ 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 || data.meshUrl, - splatUrl: splatUrl || data.splatUrl, + meshUrl: meshUrl || renderableMeshUrl, + splatUrl: splatUrl || renderableSplatUrl, previewImage: previewImageUrl || imageUrl || data.previewImage, status: "completed", activeView: splatUrl ? "splat" : imageUrl ? "image" : meshUrl ? "mesh" : data.activeView, @@ -343,6 +368,10 @@ export function World3DNode({ }, } as World3DNodeData, }); + + if (!splatUrl && !meshUrl) { + scheduleNextPoll(); + } } void pollWorldAssets(); @@ -360,6 +389,8 @@ export function World3DNode({ metadata, metadataAssetPayload, node.id, + renderableMeshUrl, + renderableSplatUrl, updateNode, ]); @@ -584,13 +615,13 @@ export function World3DNode({ hotspots={data.hotspots} imageUrl={data.imageUrl} interactionMode={interactionMode} - meshUrl={data.meshUrl} + meshUrl={renderableMeshUrl} minimalFrame mode={activeView} onPick={handlePick} previewImageUrl={data.previewImage} semanticsMetadata={data.semanticsMetadata} - splatUrl={data.splatUrl} + splatUrl={renderableSplatUrl} worldUrl={data.worldUrl} onCaptureReady={handleViewerCaptureReady} /> diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 9bb8819d8..b78ae4408 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -3,7 +3,7 @@ /* eslint-disable @next/next/no-img-element */ import { Html, OrbitControls } from "@react-three/drei"; import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber"; -import { SparkRenderer, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark"; +import { SparkRenderer, SplatFileType, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark"; import { AlertCircle, Crosshair, @@ -21,6 +21,7 @@ import { type Model3DViewerSettings, } from "@/components/canvas/model3d/Model3DViewer"; import { Badge } from "@/components/ui/badge"; +import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; import { cn } from "@/lib/utils"; import type { World3DHotspot, World3DViewMode } from "@/types/canvas"; @@ -302,39 +303,89 @@ function positionCameraInsideWorld({ const horizontalAxis = size.z >= size.x ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0); const majorSpan = Math.max(horizontalAxis.z === 1 ? size.z : size.x, 1.4); - const marginX = Math.max(size.x * 0.12, 0.35); - const marginZ = Math.max(size.z * 0.12, 0.35); - const rawEyeY = - groundPlaneOffset !== null - ? groundPlaneOffset + 1.58 - : center.y - size.y * 0.08 + Math.max(size.y * 0.22, 0.9); - const minEyeY = worldBounds.min.y + Math.max(size.y * 0.1, 0.55); - const maxEyeY = worldBounds.max.y - Math.max(size.y * 0.14, 0.38); + + const clampInsideAxis = ( + value: number, + min: number, + max: number, + margin: number, + fallback: number + ) => { + const span = Math.max(max - min, 0); + const safeMargin = Math.min(margin, span * 0.42); + const safeMin = min + safeMargin; + const safeMax = max - safeMargin; + if (safeMin > safeMax) return fallback; + return THREE.MathUtils.clamp(value, safeMin, safeMax); + }; + + const semanticGroundY = + groundPlaneOffset !== null && + Number.isFinite(groundPlaneOffset) && + groundPlaneOffset >= worldBounds.min.y - size.y * 0.25 && + groundPlaneOffset <= worldBounds.max.y + size.y * 0.25 + ? THREE.MathUtils.clamp(groundPlaneOffset, worldBounds.min.y, worldBounds.max.y) + : null; + const estimatedFloorY = semanticGroundY ?? worldBounds.min.y + size.y * 0.06; + const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.18, 0.9, 1.48); + const rawEyeY = estimatedFloorY + humanEyeOffset; + const minEyeY = worldBounds.min.y + Math.max(size.y * 0.12, 0.42); + const maxEyeY = Math.min( + worldBounds.max.y - Math.max(size.y * 0.22, 0.42), + center.y + size.y * 0.16 + ); const eyeY = minEyeY <= maxEyeY ? THREE.MathUtils.clamp(rawEyeY, minEyeY, maxEyeY) - : center.y; + : center.y - size.y * 0.08; const anchor = new THREE.Vector3(center.x, eyeY, center.z); + const cameraDistance = THREE.MathUtils.clamp(majorSpan * 0.16, 0.7, 1.9); const cameraPosition = anchor .clone() - .addScaledVector(horizontalAxis, Math.max(majorSpan * 0.12, 0.65)); + .addScaledVector(horizontalAxis, cameraDistance); - cameraPosition.x = THREE.MathUtils.clamp( + cameraPosition.x = clampInsideAxis( cameraPosition.x, - worldBounds.min.x + marginX, - worldBounds.max.x - marginX + worldBounds.min.x, + worldBounds.max.x, + Math.max(size.x * 0.1, 0.24), + center.x ); - cameraPosition.z = THREE.MathUtils.clamp( + cameraPosition.z = clampInsideAxis( cameraPosition.z, - worldBounds.min.z + marginZ, - worldBounds.max.z - marginZ + worldBounds.min.z, + worldBounds.max.z, + Math.max(size.z * 0.1, 0.24), + center.z ); - const lookDirection = anchor.clone().sub(cameraPosition).normalize(); - const anchorDistance = cameraPosition.distanceTo(anchor); + const lookDirection = anchor.clone().sub(cameraPosition); + if (lookDirection.lengthSq() < 0.0001) { + lookDirection.copy(horizontalAxis).multiplyScalar(-1); + } + lookDirection.normalize(); const targetPosition = cameraPosition .clone() - .addScaledVector(lookDirection, THREE.MathUtils.clamp(anchorDistance * 0.8, 0.55, 1.2)); + .addScaledVector(lookDirection, THREE.MathUtils.clamp(majorSpan * 0.24, 0.75, 2.4)); + targetPosition.y = THREE.MathUtils.clamp( + eyeY - THREE.MathUtils.clamp(size.y * 0.025, 0.04, 0.18), + minEyeY <= maxEyeY ? minEyeY : worldBounds.min.y, + minEyeY <= maxEyeY ? maxEyeY : worldBounds.max.y + ); + targetPosition.x = clampInsideAxis( + targetPosition.x, + worldBounds.min.x, + worldBounds.max.x, + Math.max(size.x * 0.08, 0.18), + center.x + ); + targetPosition.z = clampInsideAxis( + targetPosition.z, + worldBounds.min.z, + worldBounds.max.z, + Math.max(size.z * 0.08, 0.18), + center.z + ); camera.position.copy(cameraPosition); camera.lookAt(targetPosition); @@ -775,7 +826,10 @@ function WorldSplatScene({ }), [renderer] ); - const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]); + const splatMesh = useMemo( + () => new SparkSplatMesh({ fileType: SplatFileType.SPZ, url: splatUrl }), + [splatUrl] + ); const groundPlaneOffset = typeof semanticsMetadata?.ground_plane_offset === "number" ? -semanticsMetadata.ground_plane_offset @@ -1142,11 +1196,12 @@ function WorldSplatViewer({ }) { const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); const [overlayPhase, setOverlayPhase] = useState("loading"); + const resolvedSplatUrl = useMemo(() => buildThreeAssetUrl(splatUrl), [splatUrl]); useEffect(() => { setLoadState("loading"); setOverlayPhase("loading"); - }, [splatUrl]); + }, [resolvedSplatUrl]); useEffect(() => { if (loadState === "ready") { @@ -1162,7 +1217,7 @@ function WorldSplatViewer({ } }, [loadState]); - if (!splatUrl) { + if (!resolvedSplatUrl) { return ( {overlayPhase !== "hidden" ? ( diff --git a/lib/canvas/three-asset-url.ts b/lib/canvas/three-asset-url.ts new file mode 100644 index 000000000..bef5009b3 --- /dev/null +++ b/lib/canvas/three-asset-url.ts @@ -0,0 +1,38 @@ +const THREE_ASSET_PROXY_PATH = "/api/canvas/3d/model-proxy"; + +const PROXY_HOST_KEYWORDS = [ + "tripo3d.com", + "worldlabs", + "marble", + "s3.1080p.hk", + "img.yydd.ai", + "blob.vercel-storage.com", + "aliyuncs.com", + "amazonaws.com", + "googleapis.com", + "googleusercontent.com", + "cloudfront.net", +]; + +export function shouldProxyThreeAssetUrl(value?: string | null) { + const raw = String(value || "").trim(); + if (!raw || raw.startsWith("/") || raw.startsWith("blob:") || raw.startsWith("data:")) { + return false; + } + + try { + const parsed = new URL(raw); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; + const hostname = parsed.hostname.toLowerCase(); + return PROXY_HOST_KEYWORDS.some((keyword) => hostname.includes(keyword)); + } catch { + return false; + } +} + +export function buildThreeAssetUrl(value?: string | null) { + const raw = String(value || "").trim(); + if (!raw) return ""; + if (!shouldProxyThreeAssetUrl(raw)) return raw; + return `${THREE_ASSET_PROXY_PATH}?url=${encodeURIComponent(raw)}`; +} diff --git a/lib/worldlabs/splat-utils.ts b/lib/worldlabs/splat-utils.ts index 9dc647163..f15d86771 100644 --- a/lib/worldlabs/splat-utils.ts +++ b/lib/worldlabs/splat-utils.ts @@ -58,3 +58,74 @@ export function pickBestWorldLabsSplatUrl( ) { return pickBestWorldLabsSplatEntry(spzUrls)?.[1] || null; } + +const KNOWN_ARCHIVED_ASSET_HOSTS = [ + "blob.vercel-storage.com", + "s3.1080p.hk", + "img.yydd.ai", +]; + +function isKnownArchivedAssetHost(hostname: string) { + return KNOWN_ARCHIVED_ASSET_HOSTS.some( + (host) => hostname === host || hostname.endsWith(`.${host}`) + ); +} + +export function isLikelyWorldLabsTemporaryAssetUrl(value?: string | null) { + const raw = String(value || "").trim(); + if (!raw) return false; + if (raw.startsWith("/") || raw.startsWith("blob:") || raw.startsWith("data:")) { + return false; + } + + try { + const parsed = new URL(raw); + const hostname = parsed.hostname.toLowerCase(); + const pathname = parsed.pathname.toLowerCase(); + + if (isKnownArchivedAssetHost(hostname)) { + return false; + } + + if (hostname.includes("worldlabs") || hostname.includes("marble")) { + return true; + } + + const likelyWorldAsset = + pathname.includes("worldlabs") || + pathname.includes("marble") || + pathname.includes("spz") || + pathname.includes("splat") || + pathname.includes("mesh") || + pathname.endsWith(".glb") || + pathname.endsWith(".gltf"); + + return ( + likelyWorldAsset && + (hostname.includes("googleapis.com") || + hostname.includes("googleusercontent.com") || + hostname.includes("cloudfront.net")) + ); + } catch { + return false; + } +} + +export function isArchivedWorldLabsAssetUrl(value?: string | null) { + const raw = String(value || "").trim(); + return Boolean(raw) && !isLikelyWorldLabsTemporaryAssetUrl(raw); +} + +export function getArchivedWorldLabsSplatEntries( + spzUrls?: Record | null +) { + return getSortedWorldLabsSplatEntries(spzUrls).filter(([, url]) => + isArchivedWorldLabsAssetUrl(url) + ); +} + +export function pickBestArchivedWorldLabsSplatUrl( + spzUrls?: Record | null +) { + return getArchivedWorldLabsSplatEntries(spzUrls)[0]?.[1] || null; +}