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(); const normalized = hostname.toLowerCase();
return ( return (
normalized === "tripo3d.com" || 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) { function isLikelyArchivedStorageUrl(parsed: URL) {
const hostname = parsed.hostname.toLowerCase(); 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) { function shouldArchiveRemoteAssetUrl(sourceUrl?: string | null) {
@ -124,8 +128,7 @@ async function archiveRemoteAssetIfNeeded(params: {
const inflight = getArchiveInflight(); const inflight = getArchiveInflight();
const existingTask = inflight.get(params.cacheKey); const existingTask = inflight.get(params.cacheKey);
if (existingTask) { if (existingTask) {
const result = await existingTask; return existingTask;
return result || sourceUrl;
} }
const archiveTask = (async () => { const archiveTask = (async () => {
@ -147,11 +150,7 @@ async function archiveRemoteAssetIfNeeded(params: {
cacheKey: params.cacheKey, cacheKey: params.cacheKey,
sourceUrl, sourceUrl,
}); });
cache.set(params.cacheKey, { return null;
sourceUrl,
archivedUrl: sourceUrl,
});
return sourceUrl;
} }
const assetBuffer = await upstream.arrayBuffer(); const assetBuffer = await upstream.arrayBuffer();
@ -184,8 +183,7 @@ async function archiveRemoteAssetIfNeeded(params: {
})(); })();
inflight.set(params.cacheKey, archiveTask); inflight.set(params.cacheKey, archiveTask);
const archivedUrl = await archiveTask; return archiveTask;
return archivedUrl || sourceUrl;
} }
async function archiveRemoteAssetMapIfNeeded(params: { async function archiveRemoteAssetMapIfNeeded(params: {
@ -201,19 +199,21 @@ async function archiveRemoteAssetMapIfNeeded(params: {
if (entries.length === 0) return null; if (entries.length === 0) return null;
const archivedEntries = await Promise.all( const archivedEntries = (
entries.map(async ([key, value]) => { await Promise.all(
const archivedUrl = await archiveRemoteAssetIfNeeded({ entries.map(async ([key, value]) => {
cacheKey: `${params.cacheKeyPrefix}:${key}`, const archivedUrl = await archiveRemoteAssetIfNeeded({
sourceUrl: value, cacheKey: `${params.cacheKeyPrefix}:${key}`,
filenamePrefix: `${params.filenamePrefix}-${key}`, sourceUrl: value,
fallbackExtension: params.fallbackExtension, filenamePrefix: `${params.filenamePrefix}-${key}`,
}); fallbackExtension: params.fallbackExtension,
return [key, archivedUrl || value] as const; });
}) 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[]) { function getStringValue(...values: unknown[]) {
@ -402,13 +402,9 @@ export async function GET(request: Request, { params }: RouteParams) {
const rawThumbnailUrl = getStringValue(assets?.thumbnail_url); const rawThumbnailUrl = getStringValue(assets?.thumbnail_url);
const rawPanoUrl = getStringValue(assets?.imagery?.pano_url); const rawPanoUrl = getStringValue(assets?.imagery?.pano_url);
const stableWorldUrl = await archiveRemoteAssetIfNeeded({ // world_marble_url is an interactive HTML viewer. It is intentionally not embedded in
cacheKey: `worldlabs:${normalizedTaskId}:world`, // canvas because the third-party iframe is unstable and can expire independently.
sourceUrl: rawWorldUrl, const stableWorldUrl: string | null = null;
filenamePrefix: `worldlabs-world-${normalizedTaskId}`,
fallbackExtension: "world",
skipHtml: true,
});
const stableModelUrl = await archiveRemoteAssetIfNeeded({ const stableModelUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:mesh`, cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
sourceUrl: rawMeshUrl, sourceUrl: rawMeshUrl,
@ -473,21 +469,22 @@ export async function GET(request: Request, { params }: RouteParams) {
modelUrl: stableModelUrl, modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null, previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
worldId: assetPayload.worldId, worldId: assetPayload.worldId,
worldUrl: stableWorldUrl || assetPayload.worldUrl, worldUrl: stableWorldUrl,
panoUrl: stablePanoUrl, panoUrl: stablePanoUrl,
caption: assetPayload.caption, caption: assetPayload.caption,
assetPayload: { assetPayload: {
...assetPayload, ...assetPayload,
worldUrl: stableWorldUrl || assetPayload.worldUrl, worldUrl: stableWorldUrl,
thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl, thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl,
panoUrl: stablePanoUrl || assetPayload.panoUrl, panoUrl: stablePanoUrl || assetPayload.panoUrl,
meshUrl: stableModelUrl || assetPayload.meshUrl, meshUrl: stableModelUrl,
spzUrls: stableSpzUrls || assetPayload.spzUrls, spzUrls: stableSpzUrls,
storageArchivedAt, storageArchivedAt,
sourceWorldUrl: assetsArchivingPending: Boolean(
rawWorldUrl && stableWorldUrl && rawWorldUrl !== stableWorldUrl (rawMeshUrl && !stableModelUrl) ||
? rawWorldUrl (assetPayload.spzUrls && !stableSpzUrls)
: null, ),
sourceWorldUrl: rawWorldUrl || null,
sourceMeshUrl: sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
? rawMeshUrl ? rawMeshUrl
@ -500,6 +497,10 @@ export async function GET(request: Request, { params }: RouteParams) {
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
? rawPanoUrl ? rawPanoUrl
: null, : null,
sourceSpzUrls:
assetPayload.spzUrls && stableSpzUrls !== assetPayload.spzUrls
? assetPayload.spzUrls
: null,
}, },
}, },
}); });

View File

@ -90,7 +90,7 @@ import {
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
import { import {
getSortedWorldLabsSplatEntries, getSortedWorldLabsSplatEntries,
pickBestWorldLabsSplatUrl, pickBestArchivedWorldLabsSplatUrl,
} from "@/lib/worldlabs/splat-utils"; } from "@/lib/worldlabs/splat-utils";
import { import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
@ -1991,7 +1991,7 @@ export default function CanvasModel3DPage() {
const isWorldTask = currentTask.provider === "worldlabs-3d"; const isWorldTask = currentTask.provider === "worldlabs-3d";
const worldAssetPayload = currentTask.assetPayload ?? null; const worldAssetPayload = currentTask.assetPayload ?? null;
const primarySplatUrl = isWorldTask const primarySplatUrl = isWorldTask
? pickBestWorldLabsSplatUrl(worldAssetPayload?.spzUrls) ? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
: null; : null;
const worldImageUrl = const worldImageUrl =
worldAssetPayload?.panoUrl || worldAssetPayload?.panoUrl ||

View File

@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
import { WorldSharePage } from "@/components/canvas/world3d/WorldSharePage"; import { WorldSharePage } from "@/components/canvas/world3d/WorldSharePage";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { model3dAsset } from "@/lib/db/schema"; 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"; export const dynamic = "force-dynamic";
@ -86,7 +86,7 @@ export default async function SharedWorldPage({
previewImageUrl: asset.previewImageUrl || assetPayload?.thumbnailUrl || null, previewImageUrl: asset.previewImageUrl || assetPayload?.thumbnailUrl || null,
prompt: asset.prompt || null, prompt: asset.prompt || null,
semanticsMetadata: assetPayload?.semanticsMetadata || null, semanticsMetadata: assetPayload?.semanticsMetadata || null,
splatUrl: pickBestWorldLabsSplatUrl(assetPayload?.spzUrls), splatUrl: pickBestArchivedWorldLabsSplatUrl(assetPayload?.spzUrls),
title: assetPayload?.displayName || asset.prompt || `World ${asset.taskId.slice(0, 8)}`, title: assetPayload?.displayName || asset.prompt || `World ${asset.taskId.slice(0, 8)}`,
worldUrl: assetPayload?.worldUrl || null, worldUrl: assetPayload?.worldUrl || null,
}; };

View File

@ -14,6 +14,7 @@ import React, { Suspense, useEffect, useMemo, useRef } from "react";
import * as THREE from "three"; import * as THREE from "three";
import type { Mesh, Object3D } from "three"; import type { Mesh, Object3D } from "three";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
export type Model3DLayer = { export type Model3DLayer = {
id: string; id: string;
@ -324,18 +325,7 @@ export function Model3DViewer({
}: Model3DViewerProps) { }: Model3DViewerProps) {
const controlsRef = useRef<any>(null); const controlsRef = useRef<any>(null);
const resolvedModelUrl = useMemo(() => { const resolvedModelUrl = useMemo(() => {
const normalized = String(modelUrl || "").trim(); return buildThreeAssetUrl(modelUrl);
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;
}, [modelUrl]); }, [modelUrl]);
if (!modelUrl) { if (!modelUrl) {

View File

@ -84,7 +84,10 @@ import {
getDefaultThreeDModelAliasId, getDefaultThreeDModelAliasId,
type PublicThreeDModelAliasConfig, type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config"; } from "@/lib/model3d/model-alias-config";
import { pickBestWorldLabsSplatUrl } from "@/lib/worldlabs/splat-utils"; import {
isArchivedWorldLabsAssetUrl,
pickBestArchivedWorldLabsSplatUrl,
} from "@/lib/worldlabs/splat-utils";
import { import {
getNodeContentScale, getNodeContentScale,
getScaledNodeContentStyle, getScaledNodeContentStyle,
@ -3486,7 +3489,7 @@ export function ImageNode({
if (status === "success") { if (status === "success") {
const assetPayload = (result.data.assetPayload || {}) as Record<string, any>; const assetPayload = (result.data.assetPayload || {}) as Record<string, any>;
const splatUrl = pickBestWorldLabsSplatUrl( const splatUrl = pickBestArchivedWorldLabsSplatUrl(
assetPayload.spzUrls as Record<string, string> | null | undefined assetPayload.spzUrls as Record<string, string> | null | undefined
); );
const returnedPanoUrl = const returnedPanoUrl =
@ -3502,11 +3505,14 @@ export function ImageNode({
result.data.previewImageUrl.trim() result.data.previewImageUrl.trim()
? result.data.previewImageUrl.trim() ? result.data.previewImageUrl.trim()
: null; : null;
const returnedMeshUrl = const rawReturnedMeshUrl =
result.data.modelUrl || result.data.modelUrl ||
(typeof assetPayload.meshUrl === "string" && assetPayload.meshUrl.trim() (typeof assetPayload.meshUrl === "string" && assetPayload.meshUrl.trim()
? assetPayload.meshUrl.trim() ? assetPayload.meshUrl.trim()
: null); : null);
const returnedMeshUrl = isArchivedWorldLabsAssetUrl(rawReturnedMeshUrl)
? rawReturnedMeshUrl
: null;
const hasRenderableWorldAsset = Boolean( const hasRenderableWorldAsset = Boolean(
splatUrl || splatUrl ||
returnedPanoUrl || returnedPanoUrl ||

View File

@ -32,7 +32,10 @@ import {
resolveThreeDModelDisplayName, resolveThreeDModelDisplayName,
type PublicThreeDModelAliasConfig, type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config"; } 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 { getNodeContentScale } from "@/lib/canvas/node-content-scale";
import { import {
getCanvasToolbarItem, getCanvasToolbarItem,
@ -67,9 +70,9 @@ const WORLD_ASSET_HYDRATION_MAX_ATTEMPTS = 60;
function resolveAvailableViews(data: World3DNodeData) { function resolveAvailableViews(data: World3DNodeData) {
const views: World3DViewMode[] = []; 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.imageUrl || data.previewImage) views.push("image");
if (data.meshUrl) views.push("mesh"); if (isArchivedWorldLabsAssetUrl(data.meshUrl)) views.push("mesh");
return views; return views;
} }
@ -82,17 +85,20 @@ function resolveActiveView(data: World3DNodeData) {
} }
function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) { function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode) {
if (activeView === "splat" && data.splatUrl) { const splatUrl = isArchivedWorldLabsAssetUrl(data.splatUrl) ? data.splatUrl : null;
return { url: data.splatUrl, filename: "world.spz" }; const meshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : null;
if (activeView === "splat" && splatUrl) {
return { url: splatUrl, filename: "world.spz" };
} }
if (activeView === "mesh" && data.meshUrl) { if (activeView === "mesh" && meshUrl) {
return { url: data.meshUrl, filename: "world.glb" }; return { url: meshUrl, filename: "world.glb" };
} }
if (data.splatUrl) { if (splatUrl) {
return { url: data.splatUrl, filename: "world.spz" }; return { url: splatUrl, filename: "world.spz" };
} }
if (data.meshUrl) { if (meshUrl) {
return { url: data.meshUrl, filename: "world.glb" }; return { url: meshUrl, filename: "world.glb" };
} }
if (data.imageUrl) { if (data.imageUrl) {
return { url: data.imageUrl, filename: "world.jpg" }; return { url: data.imageUrl, filename: "world.jpg" };
@ -104,16 +110,19 @@ function inferDownloadTarget(data: World3DNodeData, activeView: World3DViewMode)
} }
function resolvePrimaryUrl(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) { switch (activeView) {
case "world": case "world":
return data.splatUrl || data.meshUrl || data.imageUrl || null; return splatUrl || meshUrl || data.imageUrl || null;
case "image": case "image":
return data.imageUrl || data.previewImage || data.meshUrl || data.splatUrl || null; return data.imageUrl || data.previewImage || meshUrl || splatUrl || null;
case "mesh": case "mesh":
return data.meshUrl || data.splatUrl || data.imageUrl || null; return meshUrl || splatUrl || data.imageUrl || null;
case "splat": case "splat":
default: 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 activeView = useMemo(() => resolveActiveView(data), [data]);
const availableViews = useMemo(() => resolveAvailableViews(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 = const publicModelAliasConfig =
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
const worldModelLabel = useMemo( const worldModelLabel = useMemo(
@ -262,7 +277,7 @@ export function World3DNode({
useEffect(() => { useEffect(() => {
if (data.status !== "completed") return; if (data.status !== "completed") return;
if (data.splatUrl || data.meshUrl) return; if (renderableSplatUrl || renderableMeshUrl) return;
if (!hydrationTaskId) return; if (!hydrationTaskId) return;
if ( if (
readStringValue(metadata.provider) !== "worldlabs-3d" && readStringValue(metadata.provider) !== "worldlabs-3d" &&
@ -303,14 +318,15 @@ export function World3DNode({
} }
const assetPayload = readRecordValue(result.data.assetPayload); const assetPayload = readRecordValue(result.data.assetPayload);
const splatUrl = pickBestWorldLabsSplatUrl( const splatUrl = pickBestArchivedWorldLabsSplatUrl(
assetPayload.spzUrls as Record<string, string> | null | undefined assetPayload.spzUrls as Record<string, string> | null | undefined
); );
const panoUrl = readStringValue(assetPayload.panoUrl); const panoUrl = readStringValue(assetPayload.panoUrl);
const thumbnailUrl = readStringValue(assetPayload.thumbnailUrl); const thumbnailUrl = readStringValue(assetPayload.thumbnailUrl);
const previewImageUrl = readStringValue(result.data.previewImageUrl); const previewImageUrl = readStringValue(result.data.previewImageUrl);
const meshUrl = const rawMeshUrl =
readStringValue(result.data.modelUrl) || readStringValue(assetPayload.meshUrl); readStringValue(result.data.modelUrl) || readStringValue(assetPayload.meshUrl);
const meshUrl = isArchivedWorldLabsAssetUrl(rawMeshUrl) ? rawMeshUrl : null;
const imageUrl = panoUrl || thumbnailUrl || previewImageUrl; const imageUrl = panoUrl || thumbnailUrl || previewImageUrl;
if (!splatUrl && !meshUrl && !imageUrl) { if (!splatUrl && !meshUrl && !imageUrl) {
@ -318,13 +334,22 @@ export function World3DNode({
return; return;
} }
if (
!splatUrl &&
!meshUrl &&
(!imageUrl || imageUrl === data.imageUrl || imageUrl === data.previewImage)
) {
scheduleNextPoll();
return;
}
updateNode(node.id, { updateNode(node.id, {
data: { data: {
...data, ...data,
worldUrl: readStringValue(assetPayload.worldUrl) || data.worldUrl, worldUrl: readStringValue(assetPayload.worldUrl) || data.worldUrl,
imageUrl: imageUrl || data.imageUrl, imageUrl: imageUrl || data.imageUrl,
meshUrl: meshUrl || data.meshUrl, meshUrl: meshUrl || renderableMeshUrl,
splatUrl: splatUrl || data.splatUrl, splatUrl: splatUrl || renderableSplatUrl,
previewImage: previewImageUrl || imageUrl || data.previewImage, previewImage: previewImageUrl || imageUrl || data.previewImage,
status: "completed", status: "completed",
activeView: splatUrl ? "splat" : imageUrl ? "image" : meshUrl ? "mesh" : data.activeView, activeView: splatUrl ? "splat" : imageUrl ? "image" : meshUrl ? "mesh" : data.activeView,
@ -343,6 +368,10 @@ export function World3DNode({
}, },
} as World3DNodeData, } as World3DNodeData,
}); });
if (!splatUrl && !meshUrl) {
scheduleNextPoll();
}
} }
void pollWorldAssets(); void pollWorldAssets();
@ -360,6 +389,8 @@ export function World3DNode({
metadata, metadata,
metadataAssetPayload, metadataAssetPayload,
node.id, node.id,
renderableMeshUrl,
renderableSplatUrl,
updateNode, updateNode,
]); ]);
@ -584,13 +615,13 @@ export function World3DNode({
hotspots={data.hotspots} hotspots={data.hotspots}
imageUrl={data.imageUrl} imageUrl={data.imageUrl}
interactionMode={interactionMode} interactionMode={interactionMode}
meshUrl={data.meshUrl} meshUrl={renderableMeshUrl}
minimalFrame minimalFrame
mode={activeView} mode={activeView}
onPick={handlePick} onPick={handlePick}
previewImageUrl={data.previewImage} previewImageUrl={data.previewImage}
semanticsMetadata={data.semanticsMetadata} semanticsMetadata={data.semanticsMetadata}
splatUrl={data.splatUrl} splatUrl={renderableSplatUrl}
worldUrl={data.worldUrl} worldUrl={data.worldUrl}
onCaptureReady={handleViewerCaptureReady} onCaptureReady={handleViewerCaptureReady}
/> />

View File

@ -3,7 +3,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { Html, OrbitControls } from "@react-three/drei"; import { Html, OrbitControls } from "@react-three/drei";
import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber"; 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 { import {
AlertCircle, AlertCircle,
Crosshair, Crosshair,
@ -21,6 +21,7 @@ import {
type Model3DViewerSettings, type Model3DViewerSettings,
} from "@/components/canvas/model3d/Model3DViewer"; } from "@/components/canvas/model3d/Model3DViewer";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { World3DHotspot, World3DViewMode } from "@/types/canvas"; import type { World3DHotspot, World3DViewMode } from "@/types/canvas";
@ -302,39 +303,89 @@ function positionCameraInsideWorld({
const horizontalAxis = const horizontalAxis =
size.z >= size.x ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0); 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 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 clampInsideAxis = (
const rawEyeY = value: number,
groundPlaneOffset !== null min: number,
? groundPlaneOffset + 1.58 max: number,
: center.y - size.y * 0.08 + Math.max(size.y * 0.22, 0.9); margin: number,
const minEyeY = worldBounds.min.y + Math.max(size.y * 0.1, 0.55); fallback: number
const maxEyeY = worldBounds.max.y - Math.max(size.y * 0.14, 0.38); ) => {
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 = const eyeY =
minEyeY <= maxEyeY minEyeY <= maxEyeY
? THREE.MathUtils.clamp(rawEyeY, 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 anchor = new THREE.Vector3(center.x, eyeY, center.z);
const cameraDistance = THREE.MathUtils.clamp(majorSpan * 0.16, 0.7, 1.9);
const cameraPosition = anchor const cameraPosition = anchor
.clone() .clone()
.addScaledVector(horizontalAxis, Math.max(majorSpan * 0.12, 0.65)); .addScaledVector(horizontalAxis, cameraDistance);
cameraPosition.x = THREE.MathUtils.clamp( cameraPosition.x = clampInsideAxis(
cameraPosition.x, cameraPosition.x,
worldBounds.min.x + marginX, worldBounds.min.x,
worldBounds.max.x - marginX worldBounds.max.x,
Math.max(size.x * 0.1, 0.24),
center.x
); );
cameraPosition.z = THREE.MathUtils.clamp( cameraPosition.z = clampInsideAxis(
cameraPosition.z, cameraPosition.z,
worldBounds.min.z + marginZ, worldBounds.min.z,
worldBounds.max.z - marginZ worldBounds.max.z,
Math.max(size.z * 0.1, 0.24),
center.z
); );
const lookDirection = anchor.clone().sub(cameraPosition).normalize(); const lookDirection = anchor.clone().sub(cameraPosition);
const anchorDistance = cameraPosition.distanceTo(anchor); if (lookDirection.lengthSq() < 0.0001) {
lookDirection.copy(horizontalAxis).multiplyScalar(-1);
}
lookDirection.normalize();
const targetPosition = cameraPosition const targetPosition = cameraPosition
.clone() .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.position.copy(cameraPosition);
camera.lookAt(targetPosition); camera.lookAt(targetPosition);
@ -775,7 +826,10 @@ function WorldSplatScene({
}), }),
[renderer] [renderer]
); );
const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]); const splatMesh = useMemo(
() => new SparkSplatMesh({ fileType: SplatFileType.SPZ, url: splatUrl }),
[splatUrl]
);
const groundPlaneOffset = const groundPlaneOffset =
typeof semanticsMetadata?.ground_plane_offset === "number" typeof semanticsMetadata?.ground_plane_offset === "number"
? -semanticsMetadata.ground_plane_offset ? -semanticsMetadata.ground_plane_offset
@ -1142,11 +1196,12 @@ function WorldSplatViewer({
}) { }) {
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading"); const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading");
const resolvedSplatUrl = useMemo(() => buildThreeAssetUrl(splatUrl), [splatUrl]);
useEffect(() => { useEffect(() => {
setLoadState("loading"); setLoadState("loading");
setOverlayPhase("loading"); setOverlayPhase("loading");
}, [splatUrl]); }, [resolvedSplatUrl]);
useEffect(() => { useEffect(() => {
if (loadState === "ready") { if (loadState === "ready") {
@ -1162,7 +1217,7 @@ function WorldSplatViewer({
} }
}, [loadState]); }, [loadState]);
if (!splatUrl) { if (!resolvedSplatUrl) {
return ( return (
<WorldEmptyState <WorldEmptyState
description={ description={
@ -1215,7 +1270,7 @@ function WorldSplatViewer({
resetViewToken={resetViewToken} resetViewToken={resetViewToken}
semanticsMetadata={semanticsMetadata} semanticsMetadata={semanticsMetadata}
showInfoOverlay={showInfoOverlay} showInfoOverlay={showInfoOverlay}
splatUrl={splatUrl} splatUrl={resolvedSplatUrl}
/> />
</Canvas> </Canvas>
{overlayPhase !== "hidden" ? ( {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; 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;
}