From 314884dc77d6ff60a89c2d4fde358081180f34f9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Apr 2026 18:12:00 +0800 Subject: [PATCH] fix: stabilize 3d world canvas assets --- app/api/canvas/3d/tasks/[taskId]/route.ts | 203 +++++++++++++++---- app/api/canvas/world/tasks/[taskId]/route.ts | 158 ++++++++++++++- app/canvas/[id]/model3d/page.tsx | 12 +- components/canvas/CanvasWorkspace.tsx | 12 +- components/canvas/nodes/World3DNode.tsx | 16 +- components/canvas/world3d/World3DViewer.tsx | 42 +--- lib/storage/service.ts | 12 ++ services/canvas/index.ts | 31 ++- 8 files changed, 386 insertions(+), 100 deletions(-) diff --git a/app/api/canvas/3d/tasks/[taskId]/route.ts b/app/api/canvas/3d/tasks/[taskId]/route.ts index 0aa9e9e1e..1f454b48c 100644 --- a/app/api/canvas/3d/tasks/[taskId]/route.ts +++ b/app/api/canvas/3d/tasks/[taskId]/route.ts @@ -11,15 +11,20 @@ import type { TripoTaskData } from "@/lib/tripo/types"; import { uploadFile } from "@/lib/storage/service"; export const runtime = "nodejs"; -export const maxDuration = 30; +export const maxDuration = 120; -type TripoModelArchiveCacheItem = { +type RemoteAssetArchiveCacheItem = { sourceUrl: string; archivedUrl: string; }; -function shouldArchiveTripoModelUrl(modelUrl?: string | null) { - const raw = String(modelUrl || "").trim(); +function isLikelyArchivedStorageUrl(parsed: URL) { + const hostname = parsed.hostname.toLowerCase(); + return hostname.includes("blob.vercel-storage.com"); +} + +function shouldArchiveRemoteAssetUrl(assetUrl?: string | null) { + const raw = String(assetUrl || "").trim(); if (!raw) return false; let parsed: URL; try { @@ -27,32 +32,68 @@ function shouldArchiveTripoModelUrl(modelUrl?: string | null) { } catch { return false; } - const hostname = parsed.hostname.toLowerCase(); - return ( - hostname === "tripo3d.com" || - hostname.endsWith(".tripo3d.com") - ); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; + return !isLikelyArchivedStorageUrl(parsed); } -function resolveModelExtension(modelUrl: string) { +function resolveExtensionFromContentType(contentType: string | null) { + const normalized = String(contentType || "").toLowerCase().split(";")[0]?.trim() || ""; + const map: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "model/gltf-binary": "glb", + "model/gltf+json": "gltf", + "model/stl": "stl", + "model/3mf": "3mf", + "model/vnd.usdz+zip": "usdz", + "application/zip": "zip", + "application/json": "json", + }; + return map[normalized] || ""; +} + +function resolveRemoteAssetExtension(assetUrl: string, fallback: string, contentType?: string | null) { + const contentTypeExtension = resolveExtensionFromContentType(contentType || null); + if (contentTypeExtension) return contentTypeExtension; + try { - const parsed = new URL(modelUrl); + const parsed = new URL(assetUrl); const pathname = parsed.pathname.toLowerCase(); const ext = pathname.split(".").pop() || ""; - const allowed = new Set(["glb", "gltf", "usdz", "fbx", "obj", "stl", "3mf"]); + const allowed = new Set([ + "glb", + "gltf", + "usdz", + "fbx", + "obj", + "stl", + "3mf", + "ply", + "spz", + "splat", + "ksplat", + "zip", + "jpg", + "jpeg", + "png", + "webp", + "gif", + ]); if (allowed.has(ext)) return ext; } catch { // ignore } - return "glb"; + return fallback; } function getTripoArchiveCache() { const globalState = globalThis as any; if (!globalState.__tripoModelArchiveCache) { - globalState.__tripoModelArchiveCache = new Map(); + globalState.__tripoModelArchiveCache = new Map(); } - return globalState.__tripoModelArchiveCache as Map; + return globalState.__tripoModelArchiveCache as Map; } function getTripoArchiveInflight() { @@ -65,29 +106,32 @@ function getTripoArchiveInflight() { async function archiveTripoModelIfNeeded(params: { taskId: string; - modelUrl?: string | null; + cacheKey: string; + sourceUrl?: string | null; + filenamePrefix: string; + fallbackExtension: string; }) { - const modelUrl = String(params.modelUrl || "").trim(); - if (!shouldArchiveTripoModelUrl(modelUrl)) { - return modelUrl || null; + const sourceUrl = String(params.sourceUrl || "").trim(); + if (!shouldArchiveRemoteAssetUrl(sourceUrl)) { + return sourceUrl || null; } const cache = getTripoArchiveCache(); - const cached = cache.get(params.taskId); - if (cached && cached.sourceUrl === modelUrl) { + const cached = cache.get(params.cacheKey); + if (cached && cached.sourceUrl === sourceUrl) { return cached.archivedUrl; } const inflight = getTripoArchiveInflight(); - const existingTask = inflight.get(params.taskId); + const existingTask = inflight.get(params.cacheKey); if (existingTask) { const result = await existingTask; - return result || modelUrl; + return result || sourceUrl; } const archiveTask = (async () => { try { - const upstream = await fetch(modelUrl, { + const upstream = await fetch(sourceUrl, { method: "GET", headers: { "User-Agent": "AI-Chatbot-3D-Archiver/1.0", @@ -95,45 +139,74 @@ async function archiveTripoModelIfNeeded(params: { cache: "no-store", }); if (!upstream.ok) { - throw new Error(`下载 Tripo 模型失败(HTTP ${upstream.status})`); + throw new Error(`下载 Tripo 资源失败(HTTP ${upstream.status})`); } - const modelBuffer = await upstream.arrayBuffer(); - if (!modelBuffer || modelBuffer.byteLength <= 0) { - throw new Error("Tripo 模型文件为空"); + const contentType = upstream.headers.get("content-type"); + if (/\btext\/html\b/i.test(String(contentType || ""))) { + throw new Error("Tripo 资源返回 HTML,已跳过归档以避免保存错误文件"); } - const ext = resolveModelExtension(modelUrl); - const filename = `tripo-model-${params.taskId}.${ext}`; - const uploaded = await uploadFile(filename, modelBuffer); - cache.set(params.taskId, { - sourceUrl: modelUrl, + const assetBuffer = await upstream.arrayBuffer(); + if (!assetBuffer || assetBuffer.byteLength <= 0) { + throw new Error("Tripo 资源文件为空"); + } + + const ext = resolveRemoteAssetExtension( + sourceUrl, + params.fallbackExtension, + contentType + ); + const filename = `${params.filenamePrefix}.${ext}`; + const uploaded = await uploadFile(filename, assetBuffer); + cache.set(params.cacheKey, { + sourceUrl, archivedUrl: uploaded.url, }); - console.log("[TripoModelArchive] success", { + console.log("[TripoAssetArchive] success", { taskId: params.taskId, - sourceHost: new URL(modelUrl).hostname, - bytes: modelBuffer.byteLength, + cacheKey: params.cacheKey, + sourceHost: new URL(sourceUrl).hostname, + bytes: assetBuffer.byteLength, archivedUrl: uploaded.url, }); return uploaded.url; } catch (error) { - console.error("[TripoModelArchive] failed", { + console.error("[TripoAssetArchive] failed", { taskId: params.taskId, - sourceUrl: modelUrl, + cacheKey: params.cacheKey, + sourceUrl, error: error instanceof Error ? error.message : String(error), }); return null; } finally { - inflight.delete(params.taskId); + inflight.delete(params.cacheKey); } })(); - inflight.set(params.taskId, archiveTask); + inflight.set(params.cacheKey, archiveTask); const archivedUrl = await archiveTask; - return archivedUrl || modelUrl; + return archivedUrl || sourceUrl; +} + +function replaceArchivedUrls(value: unknown, replacements: Map): unknown { + if (typeof value === "string") { + return replacements.get(value) || value; + } + if (Array.isArray(value)) { + return value.map((item) => replaceArchivedUrls(item, replacements)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + key, + replaceArchivedUrls(item, replacements), + ]) + ); + } + return value; } interface RouteParams { @@ -166,8 +239,35 @@ export async function GET(request: Request, { params }: RouteParams) { const previewImageUrl = pickTaskPreviewImageUrl(output as Record); const stableModelUrl = await archiveTripoModelIfNeeded({ taskId: normalizedTaskId, - modelUrl, + cacheKey: `tripo:${normalizedTaskId}:model`, + sourceUrl: modelUrl, + filenamePrefix: `tripo-model-${normalizedTaskId}`, + fallbackExtension: "glb", }); + const stablePreviewImageUrl = await archiveTripoModelIfNeeded({ + taskId: normalizedTaskId, + cacheKey: `tripo:${normalizedTaskId}:preview`, + sourceUrl: previewImageUrl, + filenamePrefix: `tripo-preview-${normalizedTaskId}`, + fallbackExtension: "jpg", + }); + const replacements = new Map(); + if (modelUrl && stableModelUrl && modelUrl !== stableModelUrl) { + replacements.set(modelUrl, stableModelUrl); + } + if ( + previewImageUrl && + stablePreviewImageUrl && + previewImageUrl !== stablePreviewImageUrl + ) { + replacements.set(previewImageUrl, stablePreviewImageUrl); + } + const archivedOutput = + replacements.size > 0 + ? replaceArchivedUrls(output, replacements) + : output; + const storageArchivedAt = + replacements.size > 0 ? new Date().toISOString() : null; return NextResponse.json({ success: true, @@ -176,9 +276,24 @@ export async function GET(request: Request, { params }: RouteParams) { type: result.data.type, status: result.data.status, progress: result.data.progress ?? null, - output, + output: archivedOutput, modelUrl: stableModelUrl, - previewImageUrl, + previewImageUrl: stablePreviewImageUrl, + assetPayload: { + providerId: config.providerId || null, + providerName: config.providerName || null, + storageArchivedAt, + sourceModelUrl: + modelUrl && stableModelUrl && modelUrl !== stableModelUrl + ? modelUrl + : null, + sourcePreviewImageUrl: + previewImageUrl && + stablePreviewImageUrl && + previewImageUrl !== stablePreviewImageUrl + ? previewImageUrl + : null, + }, errorCode: result.data.error_code ?? null, errorMessage: result.data.error_msg ?? null, }, diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index 818fe50f0..889fc71a9 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -12,7 +12,7 @@ import type { } from "@/lib/worldlabs/types"; export const runtime = "nodejs"; -export const maxDuration = 60; +export const maxDuration = 120; type ArchiveCacheItem = { sourceUrl: string; @@ -35,12 +35,71 @@ function getArchiveInflight() { return globalState.__worldLabsAssetArchiveInflight as Map>; } -function resolveRemoteFileExtension(url: string, fallback: string) { +function isLikelyArchivedStorageUrl(parsed: URL) { + const hostname = parsed.hostname.toLowerCase(); + return hostname.includes("blob.vercel-storage.com"); +} + +function shouldArchiveRemoteAssetUrl(sourceUrl?: string | null) { + const raw = String(sourceUrl || "").trim(); + if (!raw) return false; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; + return !isLikelyArchivedStorageUrl(parsed); + } catch { + return false; + } +} + +function resolveExtensionFromContentType(contentType: string | null) { + const normalized = String(contentType || "").toLowerCase().split(";")[0]?.trim() || ""; + const map: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "model/gltf-binary": "glb", + "model/gltf+json": "gltf", + "model/stl": "stl", + "model/3mf": "3mf", + "model/vnd.usdz+zip": "usdz", + "application/zip": "zip", + "application/json": "json", + }; + return map[normalized] || ""; +} + +function resolveRemoteFileExtension(url: string, fallback: string, contentType?: string | null) { + const contentTypeExtension = resolveExtensionFromContentType(contentType || null); + if (contentTypeExtension) return contentTypeExtension; + try { const parsed = new URL(url); const pathname = parsed.pathname.toLowerCase(); const ext = pathname.split(".").pop() || ""; - return ext || fallback; + const allowed = new Set([ + "glb", + "gltf", + "usdz", + "fbx", + "obj", + "stl", + "3mf", + "ply", + "spz", + "splat", + "ksplat", + "zip", + "world", + "json", + "jpg", + "jpeg", + "png", + "webp", + "gif", + ]); + return allowed.has(ext) ? ext : fallback; } catch { return fallback; } @@ -51,9 +110,10 @@ async function archiveRemoteAssetIfNeeded(params: { sourceUrl?: string | null; filenamePrefix: string; fallbackExtension: string; + skipHtml?: boolean; }) { const sourceUrl = String(params.sourceUrl || "").trim(); - if (!sourceUrl) return null; + if (!shouldArchiveRemoteAssetUrl(sourceUrl)) return sourceUrl || null; const cache = getArchiveCache(); const cached = cache.get(params.cacheKey); @@ -81,12 +141,29 @@ async function archiveRemoteAssetIfNeeded(params: { throw new Error(`下载 World Labs 资源失败(HTTP ${upstream.status})`); } + const contentType = upstream.headers.get("content-type"); + if (params.skipHtml && /\btext\/html\b/i.test(String(contentType || ""))) { + console.warn("[WorldLabsArchive] skip html viewer url", { + cacheKey: params.cacheKey, + sourceUrl, + }); + cache.set(params.cacheKey, { + sourceUrl, + archivedUrl: sourceUrl, + }); + return sourceUrl; + } + const assetBuffer = await upstream.arrayBuffer(); if (!assetBuffer || assetBuffer.byteLength <= 0) { throw new Error("World Labs 资源文件为空"); } - const ext = resolveRemoteFileExtension(sourceUrl, params.fallbackExtension); + const ext = resolveRemoteFileExtension( + sourceUrl, + params.fallbackExtension, + contentType + ); const filename = `${params.filenamePrefix}.${ext}`; const uploaded = await uploadFile(filename, assetBuffer); cache.set(params.cacheKey, { @@ -171,6 +248,20 @@ function extractWorldFromOperation(operation: WorldLabsOperation) { return null; } +function hasUsableWorldAssets(world?: WorldLabsWorld | null) { + const assets = world?.assets || null; + if (!assets) return false; + const spzUrls = assets.splats?.spz_urls; + return Boolean( + getStringValue(assets.thumbnail_url) || + getStringValue(assets.imagery?.pano_url) || + getStringValue(assets.mesh?.collider_mesh_url) || + (spzUrls && + typeof spzUrls === "object" && + Object.values(spzUrls).some((value) => typeof value === "string" && value.trim())) + ); +} + function buildAssetPayload( world: WorldLabsWorld, worldModelAliases: Awaited< @@ -258,11 +349,18 @@ export async function GET(request: Request, { params }: RouteParams) { let world = extractWorldFromOperation(operation); const worldId = extractWorldId(operation); - if (!world && worldId) { - world = await worldLabsRequest(config, { + if ((!world || !hasUsableWorldAssets(world)) && worldId) { + const detailedWorld = await worldLabsRequest(config, { method: "GET", path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`, }); + world = world + ? { + ...world, + ...detailedWorld, + assets: detailedWorld.assets || world.assets || null, + } + : detailedWorld; } if (!world) { @@ -270,10 +368,18 @@ export async function GET(request: Request, { params }: RouteParams) { } const assets = world.assets || null; + const rawWorldUrl = getStringValue(world.world_marble_url); const rawMeshUrl = getStringValue(assets?.mesh?.collider_mesh_url); 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, + }); const stableModelUrl = await archiveRemoteAssetIfNeeded({ cacheKey: `worldlabs:${normalizedTaskId}:mesh`, sourceUrl: rawMeshUrl, @@ -308,6 +414,24 @@ export async function GET(request: Request, { params }: RouteParams) { providerName: config.providerName || null, ...buildAssetPayload(world, modelAliasConfig.worldModels, assets), }; + const storageArchivedAt = + [ + [rawWorldUrl, stableWorldUrl], + [rawMeshUrl, stableModelUrl], + [rawThumbnailUrl, stableThumbnailUrl], + [rawPanoUrl, stablePanoUrl], + ].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) || + Boolean( + stableSpzUrls && + assetPayload.spzUrls && + Object.entries(stableSpzUrls).some( + ([key, value]) => + typeof assetPayload.spzUrls?.[key] === "string" && + assetPayload.spzUrls[key] !== value + ) + ) + ? new Date().toISOString() + : null; return NextResponse.json({ success: true, @@ -320,15 +444,33 @@ export async function GET(request: Request, { params }: RouteParams) { modelUrl: stableModelUrl, previewImageUrl: stableThumbnailUrl || stablePanoUrl || null, worldId: assetPayload.worldId, - worldUrl: assetPayload.worldUrl, + worldUrl: stableWorldUrl || assetPayload.worldUrl, panoUrl: stablePanoUrl, caption: assetPayload.caption, assetPayload: { ...assetPayload, + worldUrl: stableWorldUrl || assetPayload.worldUrl, thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl, panoUrl: stablePanoUrl || assetPayload.panoUrl, meshUrl: stableModelUrl || assetPayload.meshUrl, spzUrls: stableSpzUrls || assetPayload.spzUrls, + storageArchivedAt, + sourceWorldUrl: + rawWorldUrl && stableWorldUrl && rawWorldUrl !== stableWorldUrl + ? rawWorldUrl + : null, + sourceMeshUrl: + rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl + ? rawMeshUrl + : null, + sourceThumbnailUrl: + rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl + ? rawThumbnailUrl + : null, + sourcePanoUrl: + rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl + ? rawPanoUrl + : null, }, }, }); diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index eac3b8812..18a19b652 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -1353,6 +1353,11 @@ export default function CanvasModel3DPage() { Awaited>["data"] >) : null; + const tripoResultData = !isWorldTask + ? (result.data as NonNullable< + Awaited>["data"] + >) + : null; const nextRecord: TaskRecord = { ...currentTask, status, @@ -1367,7 +1372,12 @@ export default function CanvasModel3DPage() { assetPayload: isWorldTask ? worldResultData?.assetPayload ?? currentTask.assetPayload ?? null - : currentTask.assetPayload ?? null, + : tripoResultData?.assetPayload + ? { + ...(currentTask.assetPayload || {}), + ...tripoResultData.assetPayload, + } + : currentTask.assetPayload ?? null, error: status === "failed" ? result.data.errorMessage || "任务失败" : null, }; diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index fbb084ff4..8c024c04e 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -288,7 +288,8 @@ export function CanvasWorkspace({ return () => window.removeEventListener('resize', updateSize); }, []); - // macOS 触控板横向双指滑动会触发浏览器前进/后退,必须使用非 passive 监听阻止默认行为 + // Browser zoom/history gestures can fire before React handlers if nested viewers stop propagation. + // Capture-phase non-passive handling keeps wheel gestures scoped to the canvas. React.useEffect(() => { const container = containerRef.current; if (!container) return; @@ -299,9 +300,14 @@ export function CanvasWorkspace({ } }; - container.addEventListener("wheel", preventTrackpadHistorySwipe, { passive: false }); + const wheelListenerOptions: AddEventListenerOptions = { + capture: true, + passive: false, + }; + + container.addEventListener("wheel", preventTrackpadHistorySwipe, wheelListenerOptions); return () => { - container.removeEventListener("wheel", preventTrackpadHistorySwipe); + container.removeEventListener("wheel", preventTrackpadHistorySwipe, wheelListenerOptions); }; }, []); diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index c6bb68f41..c03883650 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -201,15 +201,21 @@ export function World3DNode({ [node.size.height, node.size.width] ); const overlayChromeScale = useMemo( - () => clampNumber(getNodeContentScale(node.size, { width: 420, height: 320 }, 0.48), 0.48, 1), - [node.size.height, node.size.width] + () => + clampNumber( + getNodeContentScale(node.size, { width: 420, height: 320 }, 0.48) * + Math.max(viewportZoom, 0.1), + 0.1, + 2.4 + ), + [node.size.height, node.size.width, viewportZoom] ); const placeholderViewportScale = useMemo( () => clampNumber( - placeholderSizeScale / Math.max(viewportZoom, 0.42), - 0.72, - 1.55 + placeholderSizeScale * Math.max(viewportZoom, 0.1), + 0.1, + 2.4 ), [placeholderSizeScale, viewportZoom] ); diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 4a68990aa..ad4348048 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -1064,24 +1064,13 @@ function WorldSplatViewer({ }, [loadState]); if (!splatUrl) { - if (worldUrl) { - return ( -
-