1161 lines
37 KiB
TypeScript
1161 lines
37 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 archiveRemoteAssetIfNeeded(params: {
|
||
cacheKey: string;
|
||
sourceUrl?: string | null;
|
||
filenamePrefix: string;
|
||
fallbackExtension: string;
|
||
skipHtml?: boolean;
|
||
validateAs?: ArchiveValidationKind;
|
||
}) {
|
||
const sourceUrl = String(params.sourceUrl || "").trim();
|
||
if (!shouldArchiveRemoteAssetUrl(sourceUrl)) return sourceUrl || null;
|
||
|
||
const cache = getArchiveCache();
|
||
const cached = cache.get(params.cacheKey);
|
||
if (cached && cached.sourceUrl === sourceUrl) {
|
||
return cached.archivedUrl;
|
||
}
|
||
|
||
const inflight = getArchiveInflight();
|
||
const existingTask = inflight.get(params.cacheKey);
|
||
if (existingTask) {
|
||
return existingTask;
|
||
}
|
||
|
||
const archiveTask = (async () => {
|
||
try {
|
||
const upstream = await fetch(sourceUrl, {
|
||
method: "GET",
|
||
headers: {
|
||
"User-Agent": "AI-Chatbot-WorldLabs-Archiver/1.0",
|
||
},
|
||
cache: "no-store",
|
||
});
|
||
if (!upstream.ok) {
|
||
throw new Error(`下载 World Labs 资源失败(HTTP ${upstream.status})`);
|
||
}
|
||
|
||
const contentType = upstream.headers.get("content-type");
|
||
if (params.skipHtml && /\btext\/html\b/i.test(String(contentType || ""))) {
|
||
logger.warn("archive skipped html viewer", {
|
||
cacheKey: params.cacheKey,
|
||
sourceUrl,
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const assetBuffer = await upstream.arrayBuffer();
|
||
assertArchivedAssetBuffer({
|
||
buffer: assetBuffer,
|
||
contentType,
|
||
validateAs: params.validateAs,
|
||
});
|
||
|
||
const ext = params.validateAs || resolveRemoteFileExtension(
|
||
sourceUrl,
|
||
params.fallbackExtension,
|
||
contentType
|
||
);
|
||
const filename = `${params.filenamePrefix}.${ext}`;
|
||
const uploaded = await uploadFile(filename, assetBuffer);
|
||
cache.set(params.cacheKey, {
|
||
sourceUrl,
|
||
archivedUrl: uploaded.url,
|
||
});
|
||
return uploaded.url;
|
||
} catch (error) {
|
||
logger.warn("archive failed", {
|
||
cacheKey: params.cacheKey,
|
||
sourceUrl,
|
||
error,
|
||
});
|
||
return null;
|
||
} finally {
|
||
inflight.delete(params.cacheKey);
|
||
}
|
||
})();
|
||
|
||
inflight.set(params.cacheKey, archiveTask);
|
||
return archiveTask;
|
||
}
|
||
|
||
async function archiveRemoteAssetMapIfNeeded(params: {
|
||
cacheKeyPrefix: string;
|
||
sourceMap?: Record<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));
|
||
|
||
return archivedEntries.length > 0 ? Object.fromEntries(archivedEntries) : null;
|
||
}
|
||
|
||
function getStringValue(...values: unknown[]) {
|
||
for (const value of values) {
|
||
if (typeof value === "string" && value.trim()) {
|
||
return value.trim();
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getObjectValue(value: unknown) {
|
||
return value && typeof value === "object" ? (value as Record<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);
|
||
return getStringValue(
|
||
response?.world_id,
|
||
response?.worldId,
|
||
metadata?.world_id,
|
||
metadata?.worldId
|
||
);
|
||
}
|
||
|
||
function extractWorldFromOperation(operation: WorldLabsOperation) {
|
||
const response = operation.response;
|
||
if (response && typeof response === "object" && "world_marble_url" in (response as Record<string, unknown>)) {
|
||
return response as WorldLabsWorld;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function hasUsableWorldAssets(world?: WorldLabsWorld | null) {
|
||
const assets = world?.assets || null;
|
||
if (!assets) return false;
|
||
const spzUrls = assets.splats?.spz_urls;
|
||
return Boolean(
|
||
getStringValue(assets.thumbnail_url) ||
|
||
getStringValue(assets.imagery?.pano_url) ||
|
||
getStringValue(assets.mesh?.collider_mesh_url) ||
|
||
(spzUrls &&
|
||
typeof spzUrls === "object" &&
|
||
Object.values(spzUrls).some((value) => typeof value === "string" && value.trim()))
|
||
);
|
||
}
|
||
|
||
function buildAssetPayload(
|
||
world: WorldLabsWorld,
|
||
worldModelAliases: Awaited<
|
||
ReturnType<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();
|
||
const persistedAssetPayload =
|
||
getObjectValue(persistedWorldAsset?.assetPayload) || {};
|
||
const shouldUseExternalTaskResultApi =
|
||
isWorldLabsExternalTaskApi(config.baseUrl) &&
|
||
persistedAssetPayload.externalTaskApi !== false &&
|
||
persistedAssetPayload.marbleApi !== true;
|
||
if (shouldUseExternalTaskResultApi) {
|
||
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 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,
|
||
storageArchivedAt: new Date().toISOString(),
|
||
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),
|
||
});
|
||
|
||
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,
|
||
},
|
||
});
|
||
}
|
||
|
||
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) {
|
||
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;
|
||
}
|
||
|
||
if (!world) {
|
||
throw new Error("World Labs 任务已完成,但未返回 world 数据");
|
||
}
|
||
|
||
if (!hasUsableWorldAssets(world)) {
|
||
await updatePersistedWorldAsset({
|
||
id: persistedWorldAsset?.id,
|
||
status: "running",
|
||
progress: 95,
|
||
assetPayload: {
|
||
...(getObjectValue(persistedWorldAsset?.assetPayload) || {}),
|
||
providerId: config.providerId || null,
|
||
providerName: config.providerName || null,
|
||
worldId: getStringValue(world.world_id) || worldId || null,
|
||
worldUrl: getStringValue(world.world_marble_url),
|
||
displayName: getStringValue(world.display_name),
|
||
model: resolveThreeDModelAliasId(
|
||
getStringValue(world.model),
|
||
modelAliasConfig.worldModels
|
||
),
|
||
assetsPending: true,
|
||
operationMetadata: operation.metadata ?? null,
|
||
},
|
||
});
|
||
logger.info("task assets pending", {
|
||
elapsedMs: Date.now() - startedAt,
|
||
taskIdLength: normalizedTaskId.length,
|
||
providerId: config.providerId || null,
|
||
worldIdLength: String(getStringValue(world.world_id) || worldId || "").length,
|
||
});
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
taskId: normalizedTaskId,
|
||
type: "world",
|
||
status: "running",
|
||
progress: 95,
|
||
output: operation.response ?? null,
|
||
worldId: getStringValue(world.world_id) || worldId || null,
|
||
worldUrl: getStringValue(world.world_marble_url),
|
||
previewImageUrl: null,
|
||
assetPayload: {
|
||
providerId: config.providerId || null,
|
||
providerName: config.providerName || null,
|
||
worldId: getStringValue(world.world_id) || worldId || null,
|
||
worldUrl: getStringValue(world.world_marble_url),
|
||
displayName: getStringValue(world.display_name),
|
||
model: resolveThreeDModelAliasId(
|
||
getStringValue(world.model),
|
||
modelAliasConfig.worldModels
|
||
),
|
||
assetsPending: true,
|
||
operationMetadata: operation.metadata ?? null,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
const assets = world.assets || null;
|
||
const rawWorldUrl = getStringValue(world.world_marble_url);
|
||
const rawMeshUrl = getStringValue(assets?.mesh?.collider_mesh_url);
|
||
const rawThumbnailUrl = getStringValue(assets?.thumbnail_url);
|
||
const rawPanoUrl = getStringValue(assets?.imagery?.pano_url);
|
||
|
||
// world_marble_url is an interactive HTML viewer. It is intentionally not embedded in
|
||
// canvas because the third-party iframe is unstable and can expire independently.
|
||
const stableWorldUrl: string | null = null;
|
||
const stableModelUrl = await archiveRemoteAssetIfNeeded({
|
||
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
|
||
sourceUrl: rawMeshUrl,
|
||
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
|
||
fallbackExtension: "glb",
|
||
validateAs: "glb",
|
||
});
|
||
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
|
||
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
|
||
sourceUrl: rawThumbnailUrl,
|
||
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
|
||
fallbackExtension: "jpg",
|
||
});
|
||
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
|
||
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
|
||
sourceUrl: rawPanoUrl,
|
||
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
|
||
fallbackExtension: "jpg",
|
||
});
|
||
|
||
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
|
||
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
|
||
sourceMap:
|
||
assets?.splats?.spz_urls && typeof assets.splats.spz_urls === "object"
|
||
? assets.splats.spz_urls
|
||
: null,
|
||
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
|
||
fallbackExtension: "spz",
|
||
validateAs: "spz",
|
||
});
|
||
|
||
const assetPayload = {
|
||
providerId: config.providerId || null,
|
||
providerName: config.providerName || null,
|
||
...buildAssetPayload(world, modelAliasConfig.worldModels, assets),
|
||
};
|
||
const rawSpzUrls =
|
||
assetPayload.spzUrls && typeof assetPayload.spzUrls === "object"
|
||
? Object.entries(assetPayload.spzUrls).filter(
|
||
(entry): entry is [string, string] =>
|
||
Boolean(entry[0]) &&
|
||
typeof entry[1] === "string" &&
|
||
entry[1].trim().length > 0
|
||
)
|
||
: [];
|
||
const stableSpzEntryCount = stableSpzUrls
|
||
? Object.keys(stableSpzUrls).length
|
||
: 0;
|
||
const hasMissingSpzArchive =
|
||
rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length;
|
||
const storageArchivedAt =
|
||
[
|
||
[rawWorldUrl, stableWorldUrl],
|
||
[rawMeshUrl, stableModelUrl],
|
||
[rawThumbnailUrl, stableThumbnailUrl],
|
||
[rawPanoUrl, stablePanoUrl],
|
||
].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) ||
|
||
Boolean(
|
||
stableSpzUrls &&
|
||
assetPayload.spzUrls &&
|
||
Object.entries(stableSpzUrls).some(
|
||
([key, value]) =>
|
||
typeof assetPayload.spzUrls?.[key] === "string" &&
|
||
assetPayload.spzUrls[key] !== value
|
||
)
|
||
)
|
||
? new Date().toISOString()
|
||
: null;
|
||
|
||
logger.info("task success status", {
|
||
elapsedMs: Date.now() - startedAt,
|
||
taskIdLength: normalizedTaskId.length,
|
||
providerId: config.providerId || null,
|
||
worldIdLength: String(assetPayload.worldId || "").length,
|
||
hasMesh: Boolean(stableModelUrl),
|
||
hasPano: Boolean(stablePanoUrl),
|
||
hasThumbnail: Boolean(stableThumbnailUrl),
|
||
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
||
storageArchivedAt,
|
||
assetsArchivingPending: Boolean(
|
||
(rawMeshUrl && !stableModelUrl) ||
|
||
hasMissingSpzArchive
|
||
),
|
||
});
|
||
|
||
const responseAssetPayload = {
|
||
...assetPayload,
|
||
worldUrl: stableWorldUrl,
|
||
thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl,
|
||
panoUrl: stablePanoUrl || assetPayload.panoUrl,
|
||
meshUrl: stableModelUrl,
|
||
spzUrls: stableSpzUrls,
|
||
spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null,
|
||
storageArchivedAt,
|
||
assetsArchivingPending: Boolean(
|
||
(rawMeshUrl && !stableModelUrl) ||
|
||
hasMissingSpzArchive
|
||
),
|
||
sourceWorldUrl: rawWorldUrl || null,
|
||
sourceMeshUrl:
|
||
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
||
? rawMeshUrl
|
||
: null,
|
||
sourceThumbnailUrl:
|
||
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
|
||
? rawThumbnailUrl
|
||
: null,
|
||
sourcePanoUrl:
|
||
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
|
||
? rawPanoUrl
|
||
: null,
|
||
sourceSpzUrls:
|
||
assetPayload.spzUrls && stableSpzUrls !== assetPayload.spzUrls
|
||
? assetPayload.spzUrls
|
||
: null,
|
||
};
|
||
await updatePersistedWorldAsset({
|
||
id: persistedWorldAsset?.id,
|
||
status: "success",
|
||
progress: 100,
|
||
modelUrl: stableModelUrl,
|
||
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
||
error: null,
|
||
assetPayload: responseAssetPayload,
|
||
});
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
taskId: normalizedTaskId,
|
||
type: "world",
|
||
status: "success",
|
||
progress: 100,
|
||
output: operation.response ?? null,
|
||
modelUrl: stableModelUrl,
|
||
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
||
worldId: assetPayload.worldId,
|
||
worldUrl: stableWorldUrl,
|
||
panoUrl: stablePanoUrl,
|
||
caption: assetPayload.caption,
|
||
assetPayload: responseAssetPayload,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
logger.error("task status failed", error, {
|
||
elapsedMs: Date.now() - startedAt,
|
||
taskIdLength: String(normalizedTaskIdForLog || "").length,
|
||
providerId: providerForLog,
|
||
});
|
||
if (error instanceof WorldLabsApiError) {
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: error.message,
|
||
payload: error.payload,
|
||
},
|
||
{ status: error.httpStatus || 502 }
|
||
);
|
||
}
|
||
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: error instanceof Error ? error.message : "查询 World Labs 任务失败",
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|