fix: stabilize world 3d asset preview
This commit is contained in:
parent
fec1151f70
commit
48c6cd0f87
@ -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) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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" ? (
|
||||
|
||||
38
lib/canvas/three-asset-url.ts
Normal file
38
lib/canvas/three-asset-url.ts
Normal 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)}`;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user