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 { isWorldLabsExternalTaskApi, WORLDLABS_EXTERNAL_TASK_MODEL, WorldLabsApiError, worldLabsRequest, } from "@/lib/worldlabs/client"; import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server"; import type { WorldLabsExternalTaskResponse, WorldLabsExternalTaskResultData, WorldLabsOperation, WorldLabsWorld, WorldLabsWorldAssets, } from "@/lib/worldlabs/types"; export const runtime = "nodejs"; export const maxDuration = 180; const logger = createLogger("world-labs-task-status"); const WORLDLABS_TASK_STATUS_TIMEOUT_MS = Number.parseInt(String(process.env.WORLDLABS_TASK_STATUS_TIMEOUT_MS || "").trim(), 10) || 180_000; function resolveWorldLabsStatusTimeoutMs() { if (!Number.isFinite(WORLDLABS_TASK_STATUS_TIMEOUT_MS)) return 180_000; if (WORLDLABS_TASK_STATUS_TIMEOUT_MS <= 0) return 180_000; return WORLDLABS_TASK_STATUS_TIMEOUT_MS; } 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 verifyArchivedAssetUpload(params: { url: string; expectedBytes: number; validateAs?: ArchiveValidationKind; }) { const headResponse = await fetch(params.url, { method: "HEAD", cache: "no-store", }); if (!headResponse.ok) { throw new Error(`归档文件校验失败(HTTP ${headResponse.status})`); } const archivedLength = Number(headResponse.headers.get("content-length")); if ( Number.isFinite(archivedLength) && archivedLength > 0 && archivedLength !== params.expectedBytes ) { throw new Error( `归档文件大小不一致(源文件 ${params.expectedBytes},归档文件 ${archivedLength})` ); } if (!params.validateAs) return; const rangeResponse = await fetch(params.url, { method: "GET", headers: { Range: "bytes=0-63" }, cache: "no-store", }); if (!rangeResponse.ok) { throw new Error(`归档文件头校验失败(HTTP ${rangeResponse.status})`); } const headerBuffer = await rangeResponse.arrayBuffer(); assertArchivedAssetBuffer({ buffer: headerBuffer, contentType: rangeResponse.headers.get("content-type"), validateAs: params.validateAs, }); } 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); await verifyArchivedAssetUpload({ url: uploaded.url, expectedBytes: assetBuffer.byteLength, validateAs: params.validateAs, }); 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)); if (archivedEntries.length !== entries.length) return null; return Object.fromEntries(archivedEntries); } 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); const nestedWorldId = getStringValue(response?.world_id, response?.worldId) || findNestedString(response, ["world_id", "worldId"]); const metadataWorldId = getStringValue(metadata?.world_id, metadata?.worldId) || findNestedString(metadata, ["world_id", "worldId"]); return getStringValue(nestedWorldId, metadataWorldId); } function isWorldLikePayload(value: unknown): value is WorldLabsWorld { const record = getObjectValue(value); if (!record) return false; const hasCoreWorldField = typeof record.world_id === "string" || typeof record.world_marble_url === "string" || typeof record.display_name === "string" || typeof record.worldId === "string"; const hasPossibleWorldContainerFields = record.assets || record.permission || record.tags || record.world_prompt; const hasModelAliasOnly = typeof record.model === "string" && /world/i.test(record.model); return Boolean(hasCoreWorldField || hasPossibleWorldContainerFields || hasModelAliasOnly); } function extractWorldCandidate(value: unknown, depth = 0): WorldLabsWorld | null { if (depth > 8) return null; const record = getObjectValue(value); if (!record) return null; if (isWorldLikePayload(record)) return record; const childCandidates: Array | null> = [ getObjectValue(record.world), getObjectValue(record.result), getObjectValue(record.output), getObjectValue(record.data), getObjectValue(record.payload), ]; for (const child of childCandidates) { if (!child) continue; if (isWorldLikePayload(child)) return child; const nested = extractWorldCandidate(child, depth + 1); if (nested) return nested; } for (const child of Object.values(record)) { const nested = extractWorldCandidate(child, depth + 1); if (nested) return nested; } return null; } function extractWorldFromOperation(operation: WorldLabsOperation) { return extractWorldCandidate(operation.response); } function hasUsableWorldAssets(world?: WorldLabsWorld | null) { const assets = world?.assets || null; if (!assets) return false; const meshRecord = getObjectValue(assets.mesh); const imageryRecord = getObjectValue(assets.imagery); const splatsRecord = getObjectValue(assets.splats); const spzUrls = splatsRecord?.spz_urls || splatsRecord?.spzUrls || splatsRecord?.splat_urls; return Boolean( getStringValue(assets.thumbnail_url) || getStringValue( imageryRecord?.pano_url, imageryRecord?.panoUrl, imageryRecord?.panorama_url, imageryRecord?.panoramaUrl ) || getStringValue( meshRecord?.collider_mesh_url, meshRecord?.colliderMeshUrl, meshRecord?.collider_mesh, meshRecord?.mesh_url, meshRecord?.meshUrl, meshRecord?.model_url, meshRecord?.modelUrl ) || (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, }; } function unwrapExternalTaskData>(payload: unknown) { const root = getObjectValue(payload); const data = getObjectValue(root?.data); return (data || root || {}) as T; } function normalizeExternalTaskStatus(status?: string | null, done?: boolean) { const normalized = String(status || "").trim().toLowerCase(); if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) { return "success" as const; } if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) { return "failed" as const; } return "running" as const; } function readStringRecord(value: unknown) { const record = getObjectValue(value); if (!record) return null; const entries = Object.entries(record).filter( (entry): entry is [string, string] => Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0 ); return entries.length > 0 ? Object.fromEntries(entries) : null; } function findNestedString( value: unknown, keys: string[], depth = 0 ): string | null { if (depth > 8) return null; const record = getObjectValue(value); if (!record) return null; for (const key of keys) { const directValue = getStringValue(record[key]); if (directValue) return directValue; } for (const child of Object.values(record)) { const found = findNestedString(child, keys, depth + 1); if (found) return found; } return null; } function findNestedObject( value: unknown, keys: string[], depth = 0 ): Record | null { if (depth > 8) return null; const record = getObjectValue(value); if (!record) return null; for (const key of keys) { const directValue = getObjectValue(record[key]); if (directValue) return directValue; } for (const child of Object.values(record)) { const found = findNestedObject(child, keys, depth + 1); if (found) return found; } return null; } function findNestedStringRecord( value: unknown, keys: string[], depth = 0 ): Record | null { if (depth > 8) return null; const record = getObjectValue(value); if (!record) return null; for (const key of keys) { const directValue = readStringRecord(record[key]); if (directValue) return directValue; } for (const child of Object.values(record)) { const found = findNestedStringRecord(child, keys, depth + 1); if (found) return found; } return null; } function extractExternalTaskErrorMessage( data: WorldLabsExternalTaskResultData, payload: WorldLabsExternalTaskResponse ) { const dataError = getObjectValue(data.error); const payloadError = getObjectValue(payload.error); return ( getStringValue( data.errorMessage, data.message, dataError?.message, payload.message, payloadError?.message, payload.error ) || "World Labs 外部任务失败" ); } function extractExternalWorldAssetPayload(data: WorldLabsExternalTaskResultData) { const root = getObjectValue(data) || {}; const resultRoot = getObjectValue(root.result) || getObjectValue(root.output) || getObjectValue(root.world) || root; const assets = findNestedObject(resultRoot, ["assets"]); const worldAssets = assets as Partial | null; const splats = getObjectValue(worldAssets?.splats); return { worldId: getStringValue( findNestedString(resultRoot, ["worldId", "world_id"]), findNestedString(root, ["worldId", "world_id"]) ), worldUrl: getStringValue( findNestedString(resultRoot, [ "worldUrl", "world_marble_url", "sourceWorldUrl", "viewerUrl", "marbleUrl", ]), findNestedString(root, [ "worldUrl", "world_marble_url", "sourceWorldUrl", "viewerUrl", "marbleUrl", ]) ), displayName: getStringValue( findNestedString(resultRoot, ["displayName", "display_name"]), findNestedString(root, ["displayName", "display_name"]) ), model: getStringValue( findNestedString(resultRoot, ["model", "modelId", "model_id"]), findNestedString(root, ["model", "modelId", "model_id"]) ) || WORLDLABS_EXTERNAL_TASK_MODEL, caption: getStringValue( worldAssets?.caption, findNestedString(resultRoot, ["caption"]), findNestedString(root, ["caption"]) ), thumbnailUrl: getStringValue( worldAssets?.thumbnail_url, findNestedString(resultRoot, [ "thumbnailUrl", "thumbnail_url", "previewImageUrl", "preview_image_url", ]), findNestedString(root, [ "thumbnailUrl", "thumbnail_url", "previewImageUrl", "preview_image_url", ]) ), panoUrl: getStringValue( worldAssets?.imagery?.pano_url, findNestedString(resultRoot, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"]), findNestedString(root, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"]) ), meshUrl: getStringValue( worldAssets?.mesh?.collider_mesh_url, findNestedString(resultRoot, [ "meshUrl", "mesh_url", "modelUrl", "model_url", "glbUrl", "glb_url", "colliderMeshUrl", "collider_mesh_url", ]), findNestedString(root, [ "meshUrl", "mesh_url", "modelUrl", "model_url", "glbUrl", "glb_url", "colliderMeshUrl", "collider_mesh_url", ]) ), spzUrls: readStringRecord(splats?.spz_urls) || findNestedStringRecord(resultRoot, ["spzUrls", "spz_urls"]) || findNestedStringRecord(root, ["spzUrls", "spz_urls"]), semanticsMetadata: getObjectValue(splats?.semantics_metadata) || findNestedObject(resultRoot, ["semanticsMetadata", "semantics_metadata"]) || findNestedObject(root, ["semanticsMetadata", "semantics_metadata"]), }; } 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(); let persistedAssetPayload = getObjectValue(persistedWorldAsset?.assetPayload) || {}; let shouldUseExternalTaskResultApi = isWorldLabsExternalTaskApi(config.baseUrl) && persistedAssetPayload.externalTaskApi !== false && persistedAssetPayload.marbleApi !== true; if (shouldUseExternalTaskResultApi) { try { const externalPayload = await worldLabsRequest< WorldLabsExternalTaskResponse >(config, { method: "GET", path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`, timeoutMs: resolveWorldLabsStatusTimeoutMs(), }); const externalData = unwrapExternalTaskData(externalPayload); const externalStatusText = getStringValue(externalData.status) || "running"; const externalStatus = normalizeExternalTaskStatus( externalStatusText, externalData.done === true ); const externalAssetPayloadBase = { ...persistedAssetPayload, providerId: config.providerId || null, providerName: config.providerName || null, externalTaskApi: true, externalTaskStatus: externalStatusText, requestId: getStringValue(externalPayload.requestId, externalPayload.request_id), }; if (externalStatus === "running") { return NextResponse.json({ success: true, data: { taskId: normalizedTaskId, type: "world", status: "running", progress: null, output: externalData, assetPayload: externalAssetPayloadBase, }, }); } if (externalStatus === "failed") { const errorMessage = extractExternalTaskErrorMessage( externalData, externalPayload ); await updatePersistedWorldAsset({ id: persistedWorldAsset?.id, status: "failed", progress: null, error: errorMessage, assetPayload: externalAssetPayloadBase, }); logger.warn("external task failed status", { elapsedMs: Date.now() - startedAt, taskIdLength: normalizedTaskId.length, providerId: config.providerId || null, error: errorMessage, }); return NextResponse.json({ success: true, data: { taskId: normalizedTaskId, type: "world", status: "failed", progress: null, errorMessage, output: externalData, assetPayload: externalAssetPayloadBase, }, }); } const extractedAssetPayload = extractExternalWorldAssetPayload(externalData); const rawMeshUrl = extractedAssetPayload.meshUrl; const rawThumbnailUrl = extractedAssetPayload.thumbnailUrl; const rawPanoUrl = extractedAssetPayload.panoUrl; 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: extractedAssetPayload.spzUrls, filenamePrefix: `worldlabs-splat-${normalizedTaskId}`, fallbackExtension: "spz", validateAs: "spz", }); const sourceSpzEntryCount = Object.keys( extractedAssetPayload.spzUrls || {} ).length; const stableSpzEntryCount = Object.keys(stableSpzUrls || {}).length; const assetsArchivingPending = Boolean( (rawMeshUrl && !stableModelUrl) || (rawThumbnailUrl && !stableThumbnailUrl) || (rawPanoUrl && !stablePanoUrl) || (sourceSpzEntryCount > 0 && stableSpzEntryCount !== sourceSpzEntryCount) ); const storageArchivedAt = assetsArchivingPending ? null : new Date().toISOString(); const assetPayload = { ...externalAssetPayloadBase, worldId: extractedAssetPayload.worldId, worldUrl: stableWorldUrl, displayName: extractedAssetPayload.displayName, model: resolveThreeDModelAliasId( extractedAssetPayload.model, modelAliasConfig.worldModels ), caption: extractedAssetPayload.caption, thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl, panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl, meshUrl: stableModelUrl, spzUrls: stableSpzUrls, semanticsMetadata: extractedAssetPayload.semanticsMetadata, spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null, storageArchivedAt, assetsArchivingPending, sourceWorldUrl: extractedAssetPayload.worldUrl || null, sourceMeshUrl: rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl ? rawMeshUrl : null, sourceThumbnailUrl: rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl ? rawThumbnailUrl : null, sourcePanoUrl: rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl ? rawPanoUrl : null, sourceSpzUrls: extractedAssetPayload.spzUrls && stableSpzUrls !== extractedAssetPayload.spzUrls ? extractedAssetPayload.spzUrls : null, }; await updatePersistedWorldAsset({ id: persistedWorldAsset?.id, status: "success", progress: 100, modelUrl: stableModelUrl, previewImageUrl: stableThumbnailUrl || stablePanoUrl || null, error: null, assetPayload, }); logger.info("external task success status", { elapsedMs: Date.now() - startedAt, taskIdLength: normalizedTaskId.length, providerId: config.providerId || null, hasMesh: Boolean(stableModelUrl), hasPano: Boolean(stablePanoUrl), hasThumbnail: Boolean(stableThumbnailUrl), hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0), assetsArchivingPending, }); return NextResponse.json({ success: true, data: { taskId: normalizedTaskId, type: "world", status: "success", progress: 100, output: externalData, modelUrl: stableModelUrl, previewImageUrl: stableThumbnailUrl || stablePanoUrl || null, worldId: assetPayload.worldId, worldUrl: stableWorldUrl, panoUrl: stablePanoUrl, caption: assetPayload.caption, assetPayload, }, }); } catch (error) { const fallbackAssetPayload = { ...persistedAssetPayload, providerId: config.providerId || null, providerName: config.providerName || null, marbleApi: true, externalTaskApi: false, }; shouldUseExternalTaskResultApi = false; persistedAssetPayload = fallbackAssetPayload; await updatePersistedWorldAsset({ id: persistedWorldAsset?.id, status: "running", assetPayload: fallbackAssetPayload, }).catch(() => {}); logger.warn("external task result failed, fallback to marble status", { elapsedMs: Date.now() - startedAt, taskIdLength: normalizedTaskId.length, providerId: config.providerId || null, error: error instanceof Error ? error.message : String(error), }); } } const operation = await worldLabsRequest(config, { method: "GET", path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`, timeoutMs: resolveWorldLabsStatusTimeoutMs(), }); 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) { try { const detailedWorld = await worldLabsRequest(config, { method: "GET", path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`, timeoutMs: resolveWorldLabsStatusTimeoutMs(), }); world = world ? { ...world, ...detailedWorld, assets: detailedWorld.assets || world.assets || null, } : detailedWorld; } catch (error) { logger.warn("world detail fetch failed, retry with running status", { elapsedMs: Date.now() - startedAt, taskIdLength: normalizedTaskId.length, providerId: config.providerId || null, worldId, error: error instanceof Error ? error.message : "unknown", }); } } if (!world) { const pendingPayload = { ...(getObjectValue(persistedWorldAsset?.assetPayload) || {}), providerId: config.providerId || null, providerName: config.providerName || null, worldId: worldId || null, worldUrl: null, displayName: null, model: null, assetsPending: true, operationMetadata: operation.metadata ?? null, }; await updatePersistedWorldAsset({ id: persistedWorldAsset?.id, status: "running", progress: 95, assetPayload: pendingPayload, }); logger.warn("task completed but world payload is missing", { elapsedMs: Date.now() - startedAt, taskIdLength: normalizedTaskId.length, providerId: config.providerId || null, worldId: worldId || null, }); return NextResponse.json({ success: true, data: { taskId: normalizedTaskId, type: "world", status: "running", progress: 95, output: operation.response ?? null, worldId: worldId || null, worldUrl: null, previewImageUrl: null, assetPayload: pendingPayload, }, }); } 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 assetsArchivingPending = Boolean( (rawMeshUrl && !stableModelUrl) || (rawThumbnailUrl && !stableThumbnailUrl) || (rawPanoUrl && !stablePanoUrl) || hasMissingSpzArchive ); const storageArchivedAt = assetsArchivingPending ? null : new Date().toISOString(); 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, }); const responseAssetPayload = { ...assetPayload, worldUrl: stableWorldUrl, thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl, panoUrl: stablePanoUrl || assetPayload.panoUrl, meshUrl: stableModelUrl, spzUrls: stableSpzUrls, spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null, storageArchivedAt, assetsArchivingPending, 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 } ); } }