fix: stabilize world 3d asset preview
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-30 14:35:17 +08:00
parent fec1151f70
commit 48c6cd0f87
10 changed files with 305 additions and 104 deletions

View File

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

View File

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

View File

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

View File

@ -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,
};

View File

@ -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<any>(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) {

View File

@ -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<string, any>;
const splatUrl = pickBestWorldLabsSplatUrl(
const splatUrl = pickBestArchivedWorldLabsSplatUrl(
assetPayload.spzUrls as Record<string, string> | 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 ||

View File

@ -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<string, string> | 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}
/>

View File

@ -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<SplatLoadingPhase>("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 (
<WorldEmptyState
description={
@ -1215,7 +1270,7 @@ function WorldSplatViewer({
resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata}
showInfoOverlay={showInfoOverlay}
splatUrl={splatUrl}
splatUrl={resolvedSplatUrl}
/>
</Canvas>
{overlayPhase !== "hidden" ? (

View File

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

View File

@ -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<string, string> | null
) {
return getSortedWorldLabsSplatEntries(spzUrls).filter(([, url]) =>
isArchivedWorldLabsAssetUrl(url)
);
}
export function pickBestArchivedWorldLabsSplatUrl(
spzUrls?: Record<string, string> | null
) {
return getArchivedWorldLabsSplatEntries(spzUrls)[0]?.[1] || null;
}