diff --git a/app/api/canvas/3d/model-proxy/route.ts b/app/api/canvas/3d/model-proxy/route.ts index 97519e31c..832c82949 100644 --- a/app/api/canvas/3d/model-proxy/route.ts +++ b/app/api/canvas/3d/model-proxy/route.ts @@ -7,25 +7,74 @@ export const maxDuration = 60; const THREE_ASSET_BROWSER_CACHE_CONTROL = "private, max-age=86400, stale-while-revalidate=604800"; +const ALLOWED_MODEL_HOST_DOMAINS = [ + "bbyy.ai", + "tripo3d.ai", + "tripo3d.com", + "worldlabs.ai", + "s3.1080p.hk", + "img.yydd.ai", + "blob.vercel-storage.com", + "aliyuncs.com", + "amazonaws.com", + "googleapis.com", + "googleusercontent.com", + "cloudfront.net", +]; + +const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [ + "bbyy.ai", + "s3.1080p.hk", + "img.yydd.ai", +]; + +function isHostnameOrSubdomain(hostname: string, domain: string) { + return hostname === domain || hostname.endsWith(`.${domain}`); +} + +function isPrivateNetworkHostname(hostname: string) { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost") return true; + + const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4Match) { + const octets = ipv4Match.slice(1).map((value) => Number(value)); + if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) { + return true; + } + const [a, b] = octets; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ); + } + + if (!normalized.includes(":")) return false; + + return ( + normalized === "::1" || + normalized.startsWith("fc") || + normalized.startsWith("fd") || + normalized.startsWith("fe80:") + ); +} + function isAllowedModelHost(hostname: string) { const normalized = hostname.toLowerCase(); - return ( - normalized === "bbyy.ai" || - normalized.endsWith(".bbyy.ai") || - normalized === "tripo3d.ai" || - normalized.endsWith(".tripo3d.ai") || - normalized === "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") + if (isPrivateNetworkHostname(normalized)) return false; + return ALLOWED_MODEL_HOST_DOMAINS.some((domain) => + isHostnameOrSubdomain(normalized, domain) + ); +} + +function isAllowedHttpModelHost(hostname: string) { + const normalized = hostname.toLowerCase(); + return ALLOWED_HTTP_MODEL_HOST_DOMAINS.some((domain) => + isHostnameOrSubdomain(normalized, domain) ); } @@ -85,7 +134,28 @@ function rewriteGltfPayload(raw: string, sourceUrl: URL) { } } -export async function GET(request: Request) { +function normalizeRangeHeader(range: string | null) { + const normalized = String(range || "").trim(); + if (!normalized) return null; + const match = normalized.match(/^bytes=(\d*)-(\d*)$/); + if (!match) return ""; + + const [, start, end] = match; + if (!start && !end) return ""; + if (start && end && Number(start) > Number(end)) return ""; + + return normalized; +} + +function inferProxyContentType(targetUrl: URL, upstreamContentType: string | null) { + const pathname = targetUrl.pathname.toLowerCase(); + if (pathname.endsWith(".spz")) return "application/octet-stream"; + if (pathname.endsWith(".glb")) return "model/gltf-binary"; + if (pathname.endsWith(".gltf")) return "model/gltf+json"; + return upstreamContentType || "application/octet-stream"; +} + +async function handleRequest(request: Request, method: "GET" | "HEAD") { try { const session = await auth(); if (!session?.user?.id) { @@ -105,28 +175,42 @@ export async function GET(request: Request) { return NextResponse.json({ success: false, error: "url 参数无效" }, { status: 400 }); } - if (targetUrl.protocol !== "https:") { - return NextResponse.json({ success: false, error: "仅支持 https 模型地址" }, { status: 400 }); + if (targetUrl.protocol !== "https:" && targetUrl.protocol !== "http:") { + return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 }); } if (!isAllowedModelHost(targetUrl.hostname)) { return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 }); } + if (targetUrl.protocol === "http:" && !isAllowedHttpModelHost(targetUrl.hostname)) { + return NextResponse.json( + { success: false, error: "HTTP 模型地址仅允许已配置的公开存储域名" }, + { status: 400 } + ); + } + const forwardedHeaders = new Headers(); - const range = request.headers.get("range"); + const range = normalizeRangeHeader(request.headers.get("range")); + if (range === "") { + return NextResponse.json( + { success: false, error: "Range 请求格式无效" }, + { status: 400 } + ); + } if (range) { forwardedHeaders.set("range", range); } const upstream = await fetch(targetUrl.toString(), { - method: "GET", + method, headers: forwardedHeaders, cache: "no-store", }); if (!upstream.ok && upstream.status !== 206) { - const upstreamText = await upstream.text().catch(() => ""); + const upstreamText = + method === "HEAD" ? "" : await upstream.text().catch(() => ""); return NextResponse.json( { success: false, @@ -137,8 +221,12 @@ export async function GET(request: Request) { } const responseHeaders = new Headers(); - const contentType = upstream.headers.get("content-type") || "application/octet-stream"; - const shouldRewriteGltf = !range && isGltfAssetRequest(targetUrl, contentType); + const contentType = inferProxyContentType( + targetUrl, + upstream.headers.get("content-type") + ); + const shouldRewriteGltf = + method === "GET" && !range && isGltfAssetRequest(targetUrl, contentType); responseHeaders.set("content-type", contentType); const contentLength = upstream.headers.get("content-length"); @@ -157,6 +245,14 @@ export async function GET(request: Request) { if (lastModified) responseHeaders.set("last-modified", lastModified); responseHeaders.set("cache-control", THREE_ASSET_BROWSER_CACHE_CONTROL); + responseHeaders.set("x-content-type-options", "nosniff"); + + if (method === "HEAD") { + return new NextResponse(null, { + status: upstream.status, + headers: responseHeaders, + }); + } if (shouldRewriteGltf) { const rewrittenPayload = rewriteGltfPayload(await upstream.text(), targetUrl); @@ -183,3 +279,11 @@ export async function GET(request: Request) { ); } } + +export async function GET(request: Request) { + return handleRequest(request, "GET"); +} + +export async function HEAD(request: Request) { + return handleRequest(request, "HEAD"); +} diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index 84224739d..dabdd98f5 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -1,5 +1,8 @@ +import { and, eq } from "drizzle-orm"; import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; +import { db } from "@/lib/db"; +import { model3dAsset } from "@/lib/db/schema"; import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config"; import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { uploadFile } from "@/lib/storage/service"; @@ -19,6 +22,8 @@ type ArchiveCacheItem = { archivedUrl: string; }; +type ArchiveValidationKind = "spz" | "glb"; + function getArchiveCache() { const globalState = globalThis as any; if (!globalState.__worldLabsAssetArchiveCache) { @@ -109,12 +114,70 @@ function resolveRemoteFileExtension(url: string, fallback: string, contentType?: } } +function hasSpzMagic(bytes: Uint8Array) { + if (bytes.length < 4) return false; + const hasNgspHeader = + bytes[0] === 0x4e && + bytes[1] === 0x47 && + bytes[2] === 0x53 && + bytes[3] === 0x50; + const hasLegacyGzipHeader = bytes[0] === 0x1f && bytes[1] === 0x8b; + return hasNgspHeader || hasLegacyGzipHeader; +} + +function hasGlbMagic(bytes: Uint8Array) { + return ( + bytes.length >= 12 && + bytes[0] === 0x67 && + bytes[1] === 0x6c && + bytes[2] === 0x54 && + bytes[3] === 0x46 + ); +} + +function looksLikeTextErrorPayload(bytes: Uint8Array) { + const head = bytes.slice(0, Math.min(bytes.length, 64)); + const text = new TextDecoder("utf-8", { fatal: false }).decode(head).trimStart(); + return text.startsWith("<") || text.startsWith("{") || text.startsWith("["); +} + +function assertArchivedAssetBuffer(params: { + buffer: ArrayBuffer; + contentType?: string | null; + validateAs?: ArchiveValidationKind; +}) { + const bytes = new Uint8Array(params.buffer); + if (bytes.byteLength <= 0) { + throw new Error("World Labs 资源文件为空"); + } + + if (!params.validateAs) return; + + const contentType = String(params.contentType || "").toLowerCase(); + if ( + /\btext\/html\b/.test(contentType) || + /\bapplication\/json\b/.test(contentType) || + looksLikeTextErrorPayload(bytes) + ) { + throw new Error(`World Labs ${params.validateAs.toUpperCase()} 资源返回了非二进制内容`); + } + + if (params.validateAs === "spz" && !hasSpzMagic(bytes)) { + throw new Error("World Labs SPZ 资源头无效,已拒绝写入成功资产"); + } + + if (params.validateAs === "glb" && !hasGlbMagic(bytes)) { + throw new Error("World Labs GLB 资源头无效,已拒绝写入模型资产"); + } +} + async function archiveRemoteAssetIfNeeded(params: { cacheKey: string; sourceUrl?: string | null; filenamePrefix: string; fallbackExtension: string; skipHtml?: boolean; + validateAs?: ArchiveValidationKind; }) { const sourceUrl = String(params.sourceUrl || "").trim(); if (!shouldArchiveRemoteAssetUrl(sourceUrl)) return sourceUrl || null; @@ -154,11 +217,13 @@ async function archiveRemoteAssetIfNeeded(params: { } const assetBuffer = await upstream.arrayBuffer(); - if (!assetBuffer || assetBuffer.byteLength <= 0) { - throw new Error("World Labs 资源文件为空"); - } + assertArchivedAssetBuffer({ + buffer: assetBuffer, + contentType, + validateAs: params.validateAs, + }); - const ext = resolveRemoteFileExtension( + const ext = params.validateAs || resolveRemoteFileExtension( sourceUrl, params.fallbackExtension, contentType @@ -191,6 +256,7 @@ async function archiveRemoteAssetMapIfNeeded(params: { sourceMap?: Record | null; filenamePrefix: string; fallbackExtension: string; + validateAs?: ArchiveValidationKind; }) { const entries = Object.entries(params.sourceMap || {}).filter( (entry): entry is [string, string] => @@ -207,6 +273,7 @@ async function archiveRemoteAssetMapIfNeeded(params: { sourceUrl: value, filenamePrefix: `${params.filenamePrefix}-${key}`, fallbackExtension: params.fallbackExtension, + validateAs: params.validateAs, }); return archivedUrl ? ([key, archivedUrl] as const) : null; }) @@ -229,6 +296,56 @@ function getObjectValue(value: unknown) { return value && typeof value === "object" ? (value as Record) : null; } +function readProviderIdFromAssetPayload(value: unknown) { + const payload = getObjectValue(value); + const providerId = payload?.providerId; + return typeof providerId === "string" && providerId.trim() + ? providerId.trim() + : null; +} + +async function getPersistedWorldAsset(taskId: string, userId: string) { + const rows = await db + .select({ + id: model3dAsset.id, + assetPayload: model3dAsset.assetPayload, + }) + .from(model3dAsset) + .where( + and( + eq(model3dAsset.taskId, taskId), + eq(model3dAsset.userId, userId), + eq(model3dAsset.provider, "worldlabs-3d") + ) + ) + .limit(1); + return rows[0] || null; +} + +async function updatePersistedWorldAsset(params: { + id?: string | null; + status: "running" | "success" | "failed"; + progress?: number | null; + modelUrl?: string | null; + previewImageUrl?: string | null; + error?: string | null; + assetPayload?: Record | null; +}) { + if (!params.id) return; + await db + .update(model3dAsset) + .set({ + status: params.status, + progress: params.progress ?? null, + modelUrl: params.modelUrl ?? null, + previewImageUrl: params.previewImageUrl ?? null, + error: params.error ?? null, + assetPayload: params.assetPayload ?? null, + updatedAt: new Date(), + }) + .where(eq(model3dAsset.id, params.id)); +} + function extractWorldId(operation: WorldLabsOperation) { const response = getObjectValue(operation.response); const metadata = getObjectValue(operation.metadata); @@ -311,8 +428,16 @@ export async function GET(request: Request, { params }: RouteParams) { return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 }); } - const providerId = new URL(request.url).searchParams.get("providerId"); - const config = await getWorldLabsProviderConfig(providerId); + const persistedWorldAsset = await getPersistedWorldAsset( + normalizedTaskId, + session.user.id + ); + const explicitProviderId = new URL(request.url).searchParams.get("providerId"); + const providerId = + explicitProviderId || readProviderIdFromAssetPayload(persistedWorldAsset?.assetPayload); + const config = await getWorldLabsProviderConfig(providerId, { + includeDisabledPreferred: true, + }); providerForLog = config.providerId || null; const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const operation = await worldLabsRequest(config, { @@ -339,6 +464,19 @@ export async function GET(request: Request, { params }: RouteParams) { } if (operation.error?.message) { + const failedAssetPayload = { + ...(getObjectValue(persistedWorldAsset?.assetPayload) || {}), + providerId: config.providerId || null, + providerName: config.providerName || null, + operationMetadata: operation.metadata ?? null, + }; + await updatePersistedWorldAsset({ + id: persistedWorldAsset?.id, + status: "failed", + progress: null, + error: operation.error.message, + assetPayload: failedAssetPayload, + }); console.warn("[WorldLabsTask] failed status", { elapsedMs: Date.now() - startedAt, taskId: normalizedTaskId, @@ -379,6 +517,25 @@ export async function GET(request: Request, { params }: RouteParams) { } if (!hasUsableWorldAssets(world)) { + await updatePersistedWorldAsset({ + id: persistedWorldAsset?.id, + status: "running", + progress: 95, + assetPayload: { + ...(getObjectValue(persistedWorldAsset?.assetPayload) || {}), + providerId: config.providerId || null, + providerName: config.providerName || null, + worldId: getStringValue(world.world_id) || worldId || null, + worldUrl: getStringValue(world.world_marble_url), + displayName: getStringValue(world.display_name), + model: resolveThreeDModelAliasId( + getStringValue(world.model), + modelAliasConfig.worldModels + ), + assetsPending: true, + operationMetadata: operation.metadata ?? null, + }, + }); console.info("[WorldLabsTask] assets pending", { elapsedMs: Date.now() - startedAt, taskId: normalizedTaskId, @@ -427,6 +584,7 @@ export async function GET(request: Request, { params }: RouteParams) { sourceUrl: rawMeshUrl, filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`, fallbackExtension: "glb", + validateAs: "glb", }); const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({ cacheKey: `worldlabs:${normalizedTaskId}:thumb`, @@ -449,6 +607,7 @@ export async function GET(request: Request, { params }: RouteParams) { : null, filenamePrefix: `worldlabs-splat-${normalizedTaskId}`, fallbackExtension: "spz", + validateAs: "spz", }); const assetPayload = { @@ -456,6 +615,20 @@ export async function GET(request: Request, { params }: RouteParams) { providerName: config.providerName || null, ...buildAssetPayload(world, modelAliasConfig.worldModels, assets), }; + const rawSpzUrls = + assetPayload.spzUrls && typeof assetPayload.spzUrls === "object" + ? Object.entries(assetPayload.spzUrls).filter( + (entry): entry is [string, string] => + Boolean(entry[0]) && + typeof entry[1] === "string" && + entry[1].trim().length > 0 + ) + : []; + const stableSpzEntryCount = stableSpzUrls + ? Object.keys(stableSpzUrls).length + : 0; + const hasMissingSpzArchive = + rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length; const storageArchivedAt = [ [rawWorldUrl, stableWorldUrl], @@ -487,10 +660,51 @@ export async function GET(request: Request, { params }: RouteParams) { storageArchivedAt, assetsArchivingPending: Boolean( (rawMeshUrl && !stableModelUrl) || - (assetPayload.spzUrls && !stableSpzUrls) + hasMissingSpzArchive ), }); + const responseAssetPayload = { + ...assetPayload, + worldUrl: stableWorldUrl, + thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl, + panoUrl: stablePanoUrl || assetPayload.panoUrl, + meshUrl: stableModelUrl, + spzUrls: stableSpzUrls, + spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null, + storageArchivedAt, + assetsArchivingPending: Boolean( + (rawMeshUrl && !stableModelUrl) || + hasMissingSpzArchive + ), + sourceWorldUrl: rawWorldUrl || null, + sourceMeshUrl: + rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl + ? rawMeshUrl + : null, + sourceThumbnailUrl: + rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl + ? rawThumbnailUrl + : null, + sourcePanoUrl: + rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl + ? rawPanoUrl + : null, + sourceSpzUrls: + assetPayload.spzUrls && stableSpzUrls !== assetPayload.spzUrls + ? assetPayload.spzUrls + : null, + }; + await updatePersistedWorldAsset({ + id: persistedWorldAsset?.id, + status: "success", + progress: 100, + modelUrl: stableModelUrl, + previewImageUrl: stableThumbnailUrl || stablePanoUrl || null, + error: null, + assetPayload: responseAssetPayload, + }); + return NextResponse.json({ success: true, data: { @@ -505,36 +719,7 @@ export async function GET(request: Request, { params }: RouteParams) { worldUrl: stableWorldUrl, panoUrl: stablePanoUrl, caption: assetPayload.caption, - assetPayload: { - ...assetPayload, - worldUrl: stableWorldUrl, - thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl, - panoUrl: stablePanoUrl || assetPayload.panoUrl, - meshUrl: stableModelUrl, - spzUrls: stableSpzUrls, - storageArchivedAt, - assetsArchivingPending: Boolean( - (rawMeshUrl && !stableModelUrl) || - (assetPayload.spzUrls && !stableSpzUrls) - ), - sourceWorldUrl: rawWorldUrl || null, - sourceMeshUrl: - rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl - ? rawMeshUrl - : null, - sourceThumbnailUrl: - rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl - ? rawThumbnailUrl - : null, - sourcePanoUrl: - rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl - ? rawPanoUrl - : null, - sourceSpzUrls: - assetPayload.spzUrls && stableSpzUrls !== assetPayload.spzUrls - ? assetPayload.spzUrls - : null, - }, + assetPayload: responseAssetPayload, }, }); } catch (error) { diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index faa7239f6..b481d06cb 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -57,6 +57,19 @@ import { } from "@/lib/canvas/local-upload-state"; import { inferOriginalImageUrl } from "@/lib/image-url"; +const CANVAS_NO_DRAG_SELECTOR = + '[data-canvas-no-drag="true"], [data-canvas-no-drag="true"] *'; +const CANVAS_DRAG_CANCEL_SELECTOR = [ + "input", + "textarea", + "select", + "button", + "a", + '[contenteditable="true"]', + '[role="textbox"]', + CANVAS_NO_DRAG_SELECTOR, +].join(", "); + const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record = { idea: Lightbulb, question: HelpCircle, @@ -96,11 +109,7 @@ function extractImageFilesFromDataTransfer(dataTransfer?: DataTransfer | null) { function isEditablePasteTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) return false; if (target.isContentEditable) return true; - return Boolean( - target.closest( - 'input, textarea, select, [contenteditable="true"], [role="textbox"], [data-canvas-no-drag="true"]' - ) - ); + return Boolean(target.closest(CANVAS_DRAG_CANCEL_SELECTOR)); } function getNodeVideoCanvasMarkers(node: CanvasNode) { @@ -1554,7 +1563,7 @@ export function CanvasWorkspace({ enableResizing={enableImageResizeHandles} resizeHandleStyles={imageResizeHandleStyles} disableDragging={tool !== 'select'} - cancel={'input, textarea, button, a, [contenteditable="true"], [data-canvas-no-drag="true"]'} + cancel={CANVAS_DRAG_CANCEL_SELECTOR} className={cn( "absolute", tool === "select" && "cursor-pointer" diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index 40b4b97ba..272ee656c 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -7,6 +7,7 @@ import { Camera, Copy, Crosshair, + Cuboid, Download, ImageIcon, Loader2, @@ -33,8 +34,8 @@ import { type PublicThreeDModelAliasConfig, } from "@/lib/model3d/model-alias-config"; import { + getPreferredInlineWorldLabsSplatUrls, isArchivedWorldLabsAssetUrl, - pickBestArchivedWorldLabsSplatUrl, pickPreferredInlineWorldLabsSplatUrl, } from "@/lib/worldlabs/splat-utils"; import { getNodeContentScale } from "@/lib/canvas/node-content-scale"; @@ -67,7 +68,7 @@ const VIEW_META: Array<{ }> = [ { id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" }, { id: "splat", label: "3D", icon: Box, toolbarKey: "world3dViewSplat" }, - { id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" }, + { id: "mesh", label: "M", icon: Cuboid, toolbarKey: "world3dViewMesh" }, ]; const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000; @@ -81,11 +82,8 @@ function resolveAvailableViews(data: World3DNodeData) { const hasMesh = isArchivedWorldLabsAssetUrl(data.meshUrl); if (data.imageUrl || data.previewImage) views.push("image"); - if (hasSplat) { - views.push("splat"); - } else if (hasMesh) { - views.push("mesh"); - } + if (hasSplat) views.push("splat"); + if (hasMesh) views.push("mesh"); return views; } @@ -94,8 +92,7 @@ function resolveActiveView(data: World3DNodeData) { const hasSplat = availableViews.includes("splat"); if ( data.activeView && - availableViews.includes(data.activeView) && - !(data.activeView === "mesh" && hasSplat) + availableViews.includes(data.activeView) ) { return data.activeView; } @@ -224,17 +221,27 @@ export function World3DNode({ const activeView = useMemo(() => resolveActiveView(data), [data]); const availableViews = useMemo(() => resolveAvailableViews(data), [data]); - const renderableSplatUrl = useMemo(() => { - const assetPayloadSplatUrl = pickPreferredInlineWorldLabsSplatUrl( + const renderableSplatUrls = useMemo(() => { + const assetPayloadSplatUrls = getPreferredInlineWorldLabsSplatUrls( metadataAssetPayload.spzUrls as Record | null | undefined ); + const urls = [...assetPayloadSplatUrls]; - if (isArchivedWorldLabsAssetUrl(assetPayloadSplatUrl)) { - return assetPayloadSplatUrl; + const fallbackSplatUrl = readStringValue(data.splatUrl); + if (fallbackSplatUrl && isArchivedWorldLabsAssetUrl(fallbackSplatUrl)) { + urls.push(fallbackSplatUrl); } - return isArchivedWorldLabsAssetUrl(data.splatUrl) ? data.splatUrl : undefined; + const seen = new Set(); + return urls.filter((url) => { + const normalized = String(url || "").trim(); + if (!normalized || !isArchivedWorldLabsAssetUrl(normalized)) return false; + if (seen.has(normalized)) return false; + seen.add(normalized); + return true; + }); }, [data.splatUrl, metadataAssetPayload.spzUrls]); + const renderableSplatUrl = renderableSplatUrls[0]; const renderableMeshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl) ? data.meshUrl : undefined; @@ -476,7 +483,7 @@ export function World3DNode({ if (status === "success") { const assetPayload = readRecordValue(result.data.assetPayload); - const splatUrl = pickBestArchivedWorldLabsSplatUrl( + const splatUrl = pickPreferredInlineWorldLabsSplatUrl( assetPayload.spzUrls as Record | null | undefined ); const panoUrl = readStringValue(assetPayload.panoUrl); @@ -625,7 +632,7 @@ export function World3DNode({ } const assetPayload = readRecordValue(result.data.assetPayload); - const splatUrl = pickBestArchivedWorldLabsSplatUrl( + const splatUrl = pickPreferredInlineWorldLabsSplatUrl( assetPayload.spzUrls as Record | null | undefined ); const panoUrl = readStringValue(assetPayload.panoUrl); @@ -798,6 +805,18 @@ export function World3DNode({ [] ); + const stopViewerCanvasEvent = useCallback((event: React.SyntheticEvent) => { + event.stopPropagation(); + }, []); + + const handleViewerPointerDown = useCallback( + (event: React.PointerEvent) => { + selectNode(node.id, event.shiftKey); + event.stopPropagation(); + }, + [node.id, selectNode] + ); + const handleCaptureSnapshot = useCallback(async () => { if (!viewerCapture) { toast.error("当前视图暂不支持拍照"); @@ -929,8 +948,21 @@ export function World3DNode({ ) : ( <>
event.stopPropagation()} > ; + progress?: number | null; }) { - const isRevealing = phase === "revealing"; const isLoading = phase === "loading"; return ( @@ -749,6 +751,12 @@ function WorldSplatLoadingOverlay({
) : null} + {isLoading ? ( +
+ + {typeof progress === "number" ? {progress}% : null} +
+ ) : null} ); } @@ -1071,6 +1079,7 @@ function WorldSplatScene({ onHotspotSelect, semanticsMetadata, onLoadStateChange, + onLoadProgress, onRevealComplete, resetViewToken, showInfoOverlay = true, @@ -1086,7 +1095,8 @@ function WorldSplatScene({ onPick?: (pick: World3DPickResult) => void; onHotspotSelect?: (hotspotId: string) => void; semanticsMetadata?: Record | null; - onLoadStateChange?: (state: "ready" | "error") => void; + onLoadStateChange?: (state: "ready" | "error", detail?: string) => void; + onLoadProgress?: (event: ProgressEvent) => void; onRevealComplete?: () => void; resetViewToken?: number; showInfoOverlay?: boolean; @@ -1143,8 +1153,9 @@ function WorldSplatScene({ lodScale: minimalFrame ? SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE : SPARK_INLINE_LOD_SPLAT_SCALE, + onProgress: onLoadProgress, }), - [minimalFrame, splatUrl] + [minimalFrame, onLoadProgress, splatUrl] ); const groundPlaneOffset = typeof semanticsMetadata?.ground_plane_offset === "number" @@ -1190,7 +1201,10 @@ function WorldSplatScene({ let timeoutId: ReturnType | null = null; const loadTimeoutId = globalThis.setTimeout(() => { if (cancelled) return; - onLoadStateChange?.("error"); + onLoadStateChange?.( + "error", + `SPZ 加载超时(${SPARK_SPLAT_LOAD_TIMEOUT_MS}ms)` + ); }, SPARK_SPLAT_LOAD_TIMEOUT_MS); const markReady = () => { @@ -1238,8 +1252,12 @@ function WorldSplatScene({ .catch((error) => { globalThis.clearTimeout(loadTimeoutId); if (!cancelled) { - console.warn("[WorldSplatScene] Failed to load SPZ", error); - onLoadStateChange?.("error"); + const message = error instanceof Error ? error.message : String(error); + console.warn("[WorldSplatScene] Failed to load SPZ", { + error: message, + splatUrl, + }); + onLoadStateChange?.("error", message); } }); return () => { @@ -1499,6 +1517,7 @@ function WorldSplatCaptureBridge({ function WorldSplatViewer({ splatUrl, + splatUrls, worldUrl, previewImageUrl, firstPerson, @@ -1514,6 +1533,7 @@ function WorldSplatViewer({ showMovementControls = false, }: { splatUrl?: string | null; + splatUrls?: string[] | null; worldUrl?: string | null; previewImageUrl?: string | null; minimalFrame?: boolean; @@ -1530,12 +1550,40 @@ function WorldSplatViewer({ }) { const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading"); const [overlayPhase, setOverlayPhase] = useState("loading"); - const resolvedSplatUrl = useMemo(() => buildThreeAssetUrl(splatUrl), [splatUrl]); + const [activeSplatIndex, setActiveSplatIndex] = useState(0); + const [failedSplatLoads, setFailedSplatLoads] = useState< + Array<{ url: string; error: string }> + >([]); + const [loadProgress, setLoadProgress] = useState(null); + const resolvedSplatUrls = useMemo(() => { + const rawUrls = [ + splatUrl, + ...(Array.isArray(splatUrls) ? splatUrls : []), + ]; + const seen = new Set(); + + return rawUrls + .map((url) => String(buildThreeAssetUrl(url) || "").trim()) + .filter((url) => { + if (!url || seen.has(url)) return false; + seen.add(url); + return true; + }); + }, [splatUrl, splatUrls]); + const resolvedSplatSignature = resolvedSplatUrls.join("|"); + const resolvedSplatUrl = resolvedSplatUrls[activeSplatIndex] || ""; const loadStartedAtRef = useRef(0); const movementStateRef = useRef(createMovementState()); useEffect(() => { + setActiveSplatIndex(0); + setFailedSplatLoads([]); + }, [resolvedSplatSignature]); + + useEffect(() => { + if (!resolvedSplatUrl) return; loadStartedAtRef.current = performance.now(); + setLoadProgress(null); setLoadState("loading"); setOverlayPhase("loading"); }, [resolvedSplatUrl]); @@ -1549,6 +1597,7 @@ function WorldSplatViewer({ useEffect(() => { if (loadState === "ready") { const elapsed = performance.now() - loadStartedAtRef.current; + setLoadProgress(100); setOverlayPhase( elapsed <= SPLAT_FAST_LOAD_SKIP_REVEAL_MS ? "hidden" : "revealing" ); @@ -1559,10 +1608,59 @@ function WorldSplatViewer({ } if (loadState === "error") { + setLoadProgress(null); setOverlayPhase("hidden"); } }, [loadState]); + const handleSplatProgress = React.useCallback((event: ProgressEvent) => { + if (!event.lengthComputable || event.total <= 0) return; + const progress = Math.round((event.loaded / event.total) * 100); + setLoadProgress(THREE.MathUtils.clamp(progress, 1, 99)); + }, []); + + const handleSplatLoadStateChange = React.useCallback( + (state: "ready" | "error", detail?: string) => { + if (state === "ready") { + setLoadState("ready"); + return; + } + + const message = detail || "SPZ 加载失败"; + const failedUrl = resolvedSplatUrl; + const nextIndex = activeSplatIndex + 1; + const nextUrl = resolvedSplatUrls[nextIndex] || ""; + + if (failedUrl) { + setFailedSplatLoads((items) => [ + ...items, + { url: failedUrl, error: message }, + ]); + } + + if (nextUrl) { + console.warn("[WorldSplatViewer] Falling back to next SPZ asset", { + error: message, + failedUrl, + nextUrl, + }); + setActiveSplatIndex(nextIndex); + setLoadProgress(null); + setLoadState("loading"); + setOverlayPhase("loading"); + return; + } + + console.error("[WorldSplatViewer] All SPZ assets failed", { + error: message, + failedUrl, + attemptedUrls: resolvedSplatUrls, + }); + setLoadState("error"); + }, + [activeSplatIndex, resolvedSplatUrl, resolvedSplatUrls] + ); + if (!resolvedSplatUrl) { return ( setOverlayPhase("hidden")} revealPhase={overlayPhase} @@ -1632,7 +1733,11 @@ function WorldSplatViewer({ ) : null} {overlayPhase !== "hidden" ? ( - + ) : null} ); @@ -1644,6 +1749,7 @@ export function World3DViewer({ imageUrl, meshUrl, splatUrl, + splatUrls, previewImageUrl, emptyStateTitle = "准备生成 3D 世界", minimalFrame = false, @@ -1763,6 +1869,7 @@ export function World3DViewer({ showInfoOverlay={!minimalFrame} showMovementControls={showMovementControls} splatUrl={splatUrl} + splatUrls={splatUrls} worldUrl={worldUrl} /> diff --git a/docs/worldlabs-proxy-provider-requirements.md b/docs/worldlabs-proxy-provider-requirements.md new file mode 100644 index 000000000..e7ac68e6e --- /dev/null +++ b/docs/worldlabs-proxy-provider-requirements.md @@ -0,0 +1,316 @@ +# World Labs 3D 世界生成中转接口兼容要求 + +本文档用于说明我方系统接入 World Labs 3D 世界生成功能时,对第三方中转服务的接口兼容要求。 + +## 核心原则 + +我方系统会按照 World Labs 官方 API 协议发起请求: + +```text +{baseUrl} + /marble/v1/... +``` + +中转服务需要兼容官方路径、请求体、响应体和错误格式。请不要要求我方把 `baseUrl` 配置成某个完整接口地址。 + +正确示例: + +```text +http://your-proxy-domain.com +``` + +错误示例: + +```text +http://your-proxy-domain.com/marble/v1/worlds:generate +``` + +## 我方请求方式 + +我方后台会在请求中携带: + +```http +WLT-Api-Key: {配置的 API Key} +Accept: application/json +Content-Type: application/json +``` + +中转服务应支持 `WLT-Api-Key` 请求头。如果中转服务内部自行管理官方 Key,也可以将该字段作为租户标识或鉴权凭据,但不应要求我方改成其他 header。 + +## 必须兼容的接口 + +### 1. 准备上传素材 + +```http +POST {baseUrl}/marble/v1/media-assets:prepare_upload +``` + +请求体示例: + +```json +{ + "file_name": "input.jpg", + "extension": "jpg", + "kind": "image", + "metadata": { + "mime_type": "image/jpeg", + "uploaded_from": "canvas_world_mode" + } +} +``` + +响应必须包含可用的上传信息: + +```json +{ + "media_asset": {}, + "upload_info": { + "upload_url": "https://...", + "upload_method": "PUT", + "required_headers": {} + } +} +``` + +要求: + +- `upload_url` 必须可由我方服务器直接访问。 +- `upload_method` 如为 `PUT`,必须支持 `PUT` 上传文件二进制。 +- `required_headers` 中返回的 header,我方会原样带上。 +- 上传成功时,上传地址必须返回 `2xx`。 + +### 2. 创建世界生成任务 + +```http +POST {baseUrl}/marble/v1/worlds:generate +``` + +请求体示例: + +```json +{ + "display_name": "", + "model": "marble-1.1", + "permission": { + "public": false + }, + "world_prompt": { + "type": "image", + "image_prompt": { + "source": "media_asset", + "media_asset_id": "media_asset_id_here" + } + } +} +``` + +响应需兼容官方 operation 结构,至少包含: + +```json +{ + "operation_id": "task-id", + "done": false, + "metadata": { + "progress": { + "status": "RUNNING" + } + } +} +``` + +要求: + +- `operation_id` 必须稳定,后续查询状态必须使用同一个 id。 +- 不要在创建成功但任务未完成时返回业务失败。 +- 如果余额不足、Key 无效,应返回明确 HTTP 状态码和错误信息。 + +### 3. 查询任务状态 + +```http +GET {baseUrl}/marble/v1/operations/{operation_id} +``` + +运行中响应示例: + +```json +{ + "operation_id": "task-id", + "done": false, + "metadata": { + "progress": { + "status": "RUNNING", + "description": "World generation in progress" + } + } +} +``` + +成功响应示例: + +```json +{ + "operation_id": "task-id", + "done": true, + "error": null, + "metadata": { + "progress": { + "status": "SUCCEEDED" + }, + "world_id": "world-id" + }, + "response": { + "world_id": "world-id", + "world_marble_url": "https://marble.worldlabs.ai/world/...", + "assets": { + "mesh": { + "collider_mesh_url": "https://cdn.marble.worldlabs.ai/.../model.glb" + }, + "imagery": { + "pano_url": "https://cdn.marble.worldlabs.ai/.../rgb_0.png" + }, + "thumbnail_url": "https://cdn.marble.worldlabs.ai/.../thumbnail.webp", + "splats": { + "spz_urls": { + "100k": "https://cdn.marble.worldlabs.ai/...100k.spz", + "150k": "https://cdn.marble.worldlabs.ai/...150k.spz", + "500k": "https://cdn.marble.worldlabs.ai/...500k.spz", + "full_res": "https://cdn.marble.worldlabs.ai/...full.spz" + } + } + } + } +} +``` + +失败响应示例: + +```json +{ + "operation_id": "task-id", + "done": true, + "error": { + "message": "Insufficient account balance" + } +} +``` + +要求: + +- `done=false` 表示任务仍在运行。 +- `done=true` 且 `error=null` 表示任务成功。 +- `done=true` 且 `error.message` 存在表示任务失败。 +- 不要把运行中的任务返回为 HTTP 404。 +- 不要因为任务未完成就返回 `Operation not found`。 + +### 4. 查询世界详情 + +```http +GET {baseUrl}/marble/v1/worlds/{world_id} +``` + +当 `operations` 成功响应中资产不完整时,我方会调用该接口补充详情。响应应包含与 `response` 内 world 对象兼容的数据结构。 + +## 错误码要求 + +建议按以下语义返回: + +| 场景 | HTTP 状态码 | message 示例 | +| --- | --- | --- | +| API Key 缺失或无效 | `401` | `Unauthorized` | +| 权限不足 | `403` | `Forbidden` | +| 余额不足 | `402` 或 `403` | `Insufficient account balance` | +| 请求体错误 | `400` | `Invalid request body` | +| 任务不存在 | `404` | `Operation not found` | +| 上游超时 | `504` | `Upstream timeout` | +| 上游异常 | `502` | `Upstream error` | + +响应体建议: + +```json +{ + "message": "Insufficient account balance" +} +``` + +或: + +```json +{ + "error": { + "message": "Insufficient account balance" + } +} +``` + +## 超时与稳定性要求 + +- `prepare_upload` 建议 30 秒内返回。 +- `worlds:generate` 建议 60 秒内返回 operation,而不是等待生成完成。 +- `operations/{id}` 建议 10 秒内返回当前状态。 +- 生成任务本身可异步运行,前端会轮询状态。 +- 同一个 `operation_id` 多次查询必须返回同一个任务状态,不应随机切换账号或路由。 + +## 上传地址要求 + +`prepare_upload` 返回的 `upload_url` 可以是官方签名地址,也可以是中转服务自己的上传地址,但必须满足: + +- 我方服务器可直接访问。 +- 支持返回的 `upload_method`。 +- 支持上传原始文件二进制。 +- 上传成功返回 `2xx`。 +- 上传完成后,后续 `worlds:generate` 使用的 `media_asset_id` 必须可用。 + +## 安全要求 + +- 不要在错误信息中返回完整官方 API Key。 +- 日志中建议只记录 Key 前后少量字符,例如 `sk-****abcd`。 +- 中转服务如保存官方 Key,应加密存储。 +- 上传 URL 如有签名,应设置合理过期时间。 +- 不要允许用户通过 `baseUrl` 或 path 注入访问内网地址。 + +## 我方验收用例 + +中转服务上线前,请至少通过以下链路: + +1. `POST /marble/v1/media-assets:prepare_upload` 返回 `media_asset_id` 和可上传 URL。 +2. 使用返回的 `upload_url` 上传图片,返回 `2xx`。 +3. `POST /marble/v1/worlds:generate` 返回稳定 `operation_id`。 +4. `GET /marble/v1/operations/{operation_id}` 在运行中返回 `done=false`。 +5. 任务完成后返回 `done=true`、`error=null`、`world_id` 和资产 URL。 +6. 资产 URL 中至少应包含 thumbnail、pano、mesh 或 spz 中的一种,最好完整返回。 +7. `GET /marble/v1/worlds/{world_id}` 可返回完整 world 详情。 + +## 配置示例 + +我方系统中的 API 地址应填写中转根地址: + +```text +http://23.169.168.133:8080 +``` + +最终我方会请求: + +```text +http://23.169.168.133:8080/marble/v1/media-assets:prepare_upload +http://23.169.168.133:8080/marble/v1/worlds:generate +http://23.169.168.133:8080/marble/v1/operations/{operation_id} +http://23.169.168.133:8080/marble/v1/worlds/{world_id} +``` + +如果切回官方接口,配置为: + +```text +https://api.worldlabs.ai +``` + +最终请求: + +```text +https://api.worldlabs.ai/marble/v1/media-assets:prepare_upload +https://api.worldlabs.ai/marble/v1/worlds:generate +https://api.worldlabs.ai/marble/v1/operations/{operation_id} +https://api.worldlabs.ai/marble/v1/worlds/{world_id} +``` + +## 结论 + +中转服务需要做的是兼容 World Labs 官方 API 协议,而不是要求我方修改接口路径。只要中转根地址完整支持 `/marble/v1/...`,我方系统就可以在官方接口和中转接口之间无缝切换。 diff --git a/lib/worldlabs/server.ts b/lib/worldlabs/server.ts index 753f94615..a41005235 100644 --- a/lib/worldlabs/server.ts +++ b/lib/worldlabs/server.ts @@ -1,6 +1,7 @@ import "server-only"; import { + getAiProviderById, getAiProvidersByType, updateAiProvider, } from "@/lib/db/queries"; @@ -24,6 +25,22 @@ export function sanitizeWorldLabsBaseUrl(value: string): string { .trim(); } +function providerToWorldLabsConfig( + provider: Awaited>[number] | undefined +): WorldLabsProviderConfig | null { + if (!provider) return null; + const providerApiKey = sanitizeWorldLabsApiKey(String(provider.apiKey || "")); + if (!providerApiKey) return null; + return { + apiKey: providerApiKey, + baseUrl: + sanitizeWorldLabsBaseUrl(String(provider.baseUrl || "")) || + DEFAULT_WORLDLABS_BASE_URL, + providerId: provider.id, + providerName: provider.name, + } satisfies WorldLabsProviderConfig; +} + export async function getWorldLabsProviderConfigs(): Promise< WorldLabsProviderConfig[] > { @@ -33,20 +50,7 @@ export async function getWorldLabsProviderConfigs(): Promise< }); const normalizedProviders = providers - .map((provider): WorldLabsProviderConfig | null => { - const providerApiKey = sanitizeWorldLabsApiKey( - String(provider.apiKey || "") - ); - if (!providerApiKey) return null; - return { - apiKey: providerApiKey, - baseUrl: - sanitizeWorldLabsBaseUrl(String(provider.baseUrl || "")) || - DEFAULT_WORLDLABS_BASE_URL, - providerId: provider.id, - providerName: provider.name, - }; - }) + .map((provider) => providerToWorldLabsConfig(provider)) .filter((item): item is WorldLabsProviderConfig => Boolean(item)); if (normalizedProviders.length > 0) { @@ -70,7 +74,8 @@ export async function getWorldLabsProviderConfigs(): Promise< } export async function getWorldLabsProviderConfig( - preferredProviderId?: string | null + preferredProviderId?: string | null, + options?: { includeDisabledPreferred?: boolean } ): Promise { const providers = await getWorldLabsProviderConfigs(); const preferredId = String(preferredProviderId || "").trim(); @@ -80,6 +85,14 @@ export async function getWorldLabsProviderConfig( if (matchedProvider) { return matchedProvider; } + if (options?.includeDisabledPreferred) { + const disabledOrEnabledProvider = providerToWorldLabsConfig( + await getAiProviderById({ id: preferredId }) + ); + if (disabledOrEnabledProvider) { + return disabledOrEnabledProvider; + } + } } const worldLabsProvider = diff --git a/lib/worldlabs/splat-utils.ts b/lib/worldlabs/splat-utils.ts index de522b6ae..65fcd5a70 100644 --- a/lib/worldlabs/splat-utils.ts +++ b/lib/worldlabs/splat-utils.ts @@ -96,6 +96,39 @@ function pickClosestResolutionEntry( })[0]?.entry || null; } +function getResolutionDistance(entry: [string, string], targetScore: number) { + const score = parseResolutionScore(entry[0]); + if ( + !Number.isFinite(score) || + score <= 0 || + score === Number.MAX_SAFE_INTEGER + ) { + return Number.MAX_SAFE_INTEGER; + } + return Math.abs(score - targetScore); +} + +function compareInlineSplatEntries( + left: [string, string], + right: [string, string] +) { + const leftDistance = getResolutionDistance( + left, + INLINE_WORLDLABS_SPLAT_TARGET_SCORE + ); + const rightDistance = getResolutionDistance( + right, + INLINE_WORLDLABS_SPLAT_TARGET_SCORE + ); + if (leftDistance !== rightDistance) return leftDistance - rightDistance; + + const leftScore = parseResolutionScore(left[0]); + const rightScore = parseResolutionScore(right[0]); + if (leftScore !== rightScore) return leftScore - rightScore; + + return left[0].localeCompare(right[0], "en"); +} + const KNOWN_ARCHIVED_ASSET_HOSTS = [ "blob.vercel-storage.com", "s3.1080p.hk", @@ -189,3 +222,24 @@ export function pickPreferredInlineWorldLabsSplatUrl( ) { return pickPreferredInlineWorldLabsSplatEntry(spzUrls)?.[1] || null; } + +export function getPreferredInlineWorldLabsSplatUrls( + spzUrls?: Record | null +) { + const archivedEntries = getArchivedWorldLabsSplatEntries(spzUrls); + const sourceEntries = + archivedEntries.length > 0 + ? archivedEntries + : getSortedWorldLabsSplatEntries(spzUrls); + const orderedEntries = sourceEntries.slice().sort(compareInlineSplatEntries); + const seen = new Set(); + + return orderedEntries + .map(([, url]) => url) + .filter((url) => { + const normalized = String(url || "").trim(); + if (!normalized || seen.has(normalized)) return false; + seen.add(normalized); + return true; + }); +}