1342 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, ArchiveCacheItem>();
}
return globalState.__worldLabsAssetArchiveCache as Map<string, ArchiveCacheItem>;
}
function getArchiveInflight() {
const globalState = globalThis as any;
if (!globalState.__worldLabsAssetArchiveInflight) {
globalState.__worldLabsAssetArchiveInflight = new Map<string, Promise<string | null>>();
}
return globalState.__worldLabsAssetArchiveInflight as Map<string, Promise<string | null>>;
}
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<string, string> = {
"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<string, string> | 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<string, unknown>) : 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<string, unknown> | 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<Record<string, unknown> | 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<typeof getThreeDModelAliasConfigForRuntime>
>["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<T extends Record<string, unknown>>(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<string, unknown> | 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<string, string> | 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<WorldLabsExternalTaskResultData>
) {
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<WorldLabsWorldAssets> | 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<WorldLabsExternalTaskResultData>
>(config, {
method: "GET",
path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`,
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
});
const externalData =
unwrapExternalTaskData<WorldLabsExternalTaskResultData>(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<WorldLabsOperation>(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<WorldLabsWorld>(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 }
);
}
}