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 { createLogger } from "@/lib/observability/logger"; import { uploadFile } from "@/lib/storage/service"; import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client"; import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server"; import type { WorldLabsOperation, WorldLabsWorld, WorldLabsWorldAssets, } from "@/lib/worldlabs/types"; export const runtime = "nodejs"; export const maxDuration = 120; const logger = createLogger("world-labs-task-status"); type ArchiveCacheItem = { sourceUrl: string; archivedUrl: string; }; type ArchiveValidationKind = "spz" | "glb"; function getArchiveCache() { const globalState = globalThis as any; if (!globalState.__worldLabsAssetArchiveCache) { globalState.__worldLabsAssetArchiveCache = new Map(); } return globalState.__worldLabsAssetArchiveCache as Map; } function getArchiveInflight() { const globalState = globalThis as any; if (!globalState.__worldLabsAssetArchiveInflight) { globalState.__worldLabsAssetArchiveInflight = new Map>(); } return globalState.__worldLabsAssetArchiveInflight as Map>; } function isLikelyArchivedStorageUrl(parsed: URL) { const hostname = parsed.hostname.toLowerCase(); return ( hostname.includes("blob.vercel-storage.com") || hostname.includes("s3.1080p.hk") || hostname.includes("img.yydd.ai") ); } 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() || ""; 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; } } 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; const cache = getArchiveCache(); const cached = cache.get(params.cacheKey); if (cached && cached.sourceUrl === sourceUrl) { return cached.archivedUrl; } const inflight = getArchiveInflight(); const existingTask = inflight.get(params.cacheKey); if (existingTask) { return existingTask; } const archiveTask = (async () => { try { const upstream = await fetch(sourceUrl, { method: "GET", headers: { "User-Agent": "AI-Chatbot-WorldLabs-Archiver/1.0", }, cache: "no-store", }); if (!upstream.ok) { 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 || ""))) { logger.warn("archive skipped html viewer", { cacheKey: params.cacheKey, sourceUrl, }); return null; } const assetBuffer = await upstream.arrayBuffer(); assertArchivedAssetBuffer({ buffer: assetBuffer, contentType, validateAs: params.validateAs, }); const ext = params.validateAs || resolveRemoteFileExtension( sourceUrl, params.fallbackExtension, contentType ); const filename = `${params.filenamePrefix}.${ext}`; const uploaded = await uploadFile(filename, assetBuffer); cache.set(params.cacheKey, { sourceUrl, archivedUrl: uploaded.url, }); return uploaded.url; } catch (error) { logger.warn("archive failed", { cacheKey: params.cacheKey, sourceUrl, error, }); return null; } finally { inflight.delete(params.cacheKey); } })(); inflight.set(params.cacheKey, archiveTask); return archiveTask; } async function archiveRemoteAssetMapIfNeeded(params: { cacheKeyPrefix: string; sourceMap?: Record | null; filenamePrefix: string; fallbackExtension: string; validateAs?: ArchiveValidationKind; }) { const entries = Object.entries(params.sourceMap || {}).filter( (entry): entry is [string, string] => Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0 ); 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, validateAs: params.validateAs, }); return archivedUrl ? ([key, archivedUrl] as const) : null; }) ) ).filter((entry): entry is readonly [string, string] => Boolean(entry)); return archivedEntries.length > 0 ? Object.fromEntries(archivedEntries) : null; } function getStringValue(...values: unknown[]) { for (const value of values) { if (typeof value === "string" && value.trim()) { return value.trim(); } } return null; } 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); return getStringValue( response?.world_id, response?.worldId, metadata?.world_id, metadata?.worldId ); } function extractWorldFromOperation(operation: WorldLabsOperation) { const response = operation.response; if (response && typeof response === "object" && "world_marble_url" in (response as Record)) { return response as WorldLabsWorld; } 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< ReturnType >["worldModels"], assets?: WorldLabsWorldAssets | null, ) { return { worldId: getStringValue(world.world_id), worldUrl: getStringValue(world.world_marble_url), displayName: getStringValue(world.display_name), model: resolveThreeDModelAliasId(getStringValue(world.model), worldModelAliases), caption: getStringValue(assets?.caption), thumbnailUrl: getStringValue(assets?.thumbnail_url), panoUrl: getStringValue(assets?.imagery?.pano_url), meshUrl: getStringValue(assets?.mesh?.collider_mesh_url), spzUrls: assets?.splats?.spz_urls && typeof assets.splats.spz_urls === "object" ? assets.splats.spz_urls : null, semanticsMetadata: assets?.splats?.semantics_metadata && typeof assets.splats.semantics_metadata === "object" ? assets.splats.semantics_metadata : null, }; } interface RouteParams { params: Promise<{ taskId: string }>; } export async function GET(request: Request, { params }: RouteParams) { const startedAt = Date.now(); let normalizedTaskIdForLog: string | null = null; let providerForLog: string | null = null; try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const { taskId } = await params; const normalizedTaskId = String(taskId || "").trim(); normalizedTaskIdForLog = normalizedTaskId; if (!normalizedTaskId) { return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 }); } 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, { method: "GET", path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`, }); if (!operation.done) { return NextResponse.json({ success: true, data: { taskId: normalizedTaskId, type: "world", status: "running", progress: null, output: operation.response ?? null, assetPayload: { providerId: config.providerId || null, providerName: config.providerName || null, operationMetadata: operation.metadata ?? null, }, }, }); } 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, }); logger.warn("task failed status", { elapsedMs: Date.now() - startedAt, taskIdLength: normalizedTaskId.length, providerId: config.providerId || null, error: operation.error.message, }); return NextResponse.json({ success: true, data: { taskId: normalizedTaskId, type: "world", status: "failed", progress: null, errorMessage: operation.error.message, output: operation.response ?? null, }, }); } let world = extractWorldFromOperation(operation); const worldId = extractWorldId(operation); 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) { throw new Error("World Labs 任务已完成,但未返回 world 数据"); } 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, }, }); logger.info("task assets pending", { elapsedMs: Date.now() - startedAt, taskIdLength: normalizedTaskId.length, providerId: config.providerId || null, worldIdLength: String(getStringValue(world.world_id) || worldId || "").length, }); return NextResponse.json({ success: true, data: { taskId: normalizedTaskId, type: "world", status: "running", progress: 95, output: operation.response ?? null, worldId: getStringValue(world.world_id) || worldId || null, worldUrl: getStringValue(world.world_marble_url), previewImageUrl: null, 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, }, }, }); } 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); // 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, filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`, fallbackExtension: "glb", validateAs: "glb", }); const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({ cacheKey: `worldlabs:${normalizedTaskId}:thumb`, sourceUrl: rawThumbnailUrl, filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`, fallbackExtension: "jpg", }); const stablePanoUrl = await archiveRemoteAssetIfNeeded({ cacheKey: `worldlabs:${normalizedTaskId}:pano`, sourceUrl: rawPanoUrl, filenamePrefix: `worldlabs-pano-${normalizedTaskId}`, fallbackExtension: "jpg", }); const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({ cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`, sourceMap: assets?.splats?.spz_urls && typeof assets.splats.spz_urls === "object" ? assets.splats.spz_urls : null, filenamePrefix: `worldlabs-splat-${normalizedTaskId}`, fallbackExtension: "spz", validateAs: "spz", }); const assetPayload = { providerId: config.providerId || null, 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], [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; logger.info("task success status", { elapsedMs: Date.now() - startedAt, taskIdLength: normalizedTaskId.length, providerId: config.providerId || null, worldIdLength: String(assetPayload.worldId || "").length, hasMesh: Boolean(stableModelUrl), hasPano: Boolean(stablePanoUrl), hasThumbnail: Boolean(stableThumbnailUrl), hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0), storageArchivedAt, assetsArchivingPending: Boolean( (rawMeshUrl && !stableModelUrl) || 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: { taskId: normalizedTaskId, type: "world", status: "success", progress: 100, output: operation.response ?? null, modelUrl: stableModelUrl, previewImageUrl: stableThumbnailUrl || stablePanoUrl || null, worldId: assetPayload.worldId, worldUrl: stableWorldUrl, panoUrl: stablePanoUrl, caption: assetPayload.caption, assetPayload: responseAssetPayload, }, }); } catch (error) { logger.error("task status failed", error, { elapsedMs: Date.now() - startedAt, taskIdLength: String(normalizedTaskIdForLog || "").length, providerId: providerForLog, }); if (error instanceof WorldLabsApiError) { return NextResponse.json( { success: false, error: error.message, payload: error.payload, }, { status: error.httpStatus || 502 } ); } return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "查询 World Labs 任务失败", }, { status: 500 } ); } }