1342 lines
43 KiB
TypeScript
1342 lines
43 KiB
TypeScript
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 }
|
||
);
|
||
}
|
||
}
|