2026-05-27 11:10:23 +08:00

754 lines
24 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 { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
import type {
WorldLabsOperation,
WorldLabsWorld,
WorldLabsWorldAssets,
} from "@/lib/worldlabs/types";
export const runtime = "nodejs";
export const maxDuration = 120;
const logger = createLogger("world-labs-task-status");
type ArchiveCacheItem = {
sourceUrl: string;
archivedUrl: string;
};
type ArchiveValidationKind = "spz" | "glb";
function getArchiveCache() {
const globalState = globalThis as any;
if (!globalState.__worldLabsAssetArchiveCache) {
globalState.__worldLabsAssetArchiveCache = new Map<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,
};
}
interface RouteParams {
params: Promise<{ taskId: string }>;
}
export async function GET(request: Request, { params }: RouteParams) {
const startedAt = Date.now();
let normalizedTaskIdForLog: string | null = null;
let providerForLog: string | null = null;
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { taskId } = await params;
const normalizedTaskId = String(taskId || "").trim();
normalizedTaskIdForLog = normalizedTaskId;
if (!normalizedTaskId) {
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
}
const persistedWorldAsset = await getPersistedWorldAsset(
normalizedTaskId,
session.user.id
);
const explicitProviderId = new URL(request.url).searchParams.get("providerId");
const providerId =
explicitProviderId || readProviderIdFromAssetPayload(persistedWorldAsset?.assetPayload);
const config = await getWorldLabsProviderConfig(providerId, {
includeDisabledPreferred: true,
});
providerForLog = config.providerId || null;
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
method: "GET",
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
});
if (!operation.done) {
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "running",
progress: null,
output: operation.response ?? null,
assetPayload: {
providerId: config.providerId || null,
providerName: config.providerName || null,
operationMetadata: operation.metadata ?? null,
},
},
});
}
if (operation.error?.message) {
const failedAssetPayload = {
...(getObjectValue(persistedWorldAsset?.assetPayload) || {}),
providerId: config.providerId || null,
providerName: config.providerName || null,
operationMetadata: operation.metadata ?? null,
};
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "failed",
progress: null,
error: operation.error.message,
assetPayload: failedAssetPayload,
});
logger.warn("task failed status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
error: operation.error.message,
});
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "failed",
progress: null,
errorMessage: operation.error.message,
output: operation.response ?? null,
},
});
}
let world = extractWorldFromOperation(operation);
const worldId = extractWorldId(operation);
if ((!world || !hasUsableWorldAssets(world)) && worldId) {
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
method: "GET",
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
});
world = world
? {
...world,
...detailedWorld,
assets: detailedWorld.assets || world.assets || null,
}
: detailedWorld;
}
if (!world) {
throw new Error("World Labs 任务已完成,但未返回 world 数据");
}
if (!hasUsableWorldAssets(world)) {
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "running",
progress: 95,
assetPayload: {
...(getObjectValue(persistedWorldAsset?.assetPayload) || {}),
providerId: config.providerId || null,
providerName: config.providerName || null,
worldId: getStringValue(world.world_id) || worldId || null,
worldUrl: getStringValue(world.world_marble_url),
displayName: getStringValue(world.display_name),
model: resolveThreeDModelAliasId(
getStringValue(world.model),
modelAliasConfig.worldModels
),
assetsPending: true,
operationMetadata: operation.metadata ?? null,
},
});
logger.info("task assets pending", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
worldIdLength: String(getStringValue(world.world_id) || worldId || "").length,
});
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "running",
progress: 95,
output: operation.response ?? null,
worldId: getStringValue(world.world_id) || worldId || null,
worldUrl: getStringValue(world.world_marble_url),
previewImageUrl: null,
assetPayload: {
providerId: config.providerId || null,
providerName: config.providerName || null,
worldId: getStringValue(world.world_id) || worldId || null,
worldUrl: getStringValue(world.world_marble_url),
displayName: getStringValue(world.display_name),
model: resolveThreeDModelAliasId(
getStringValue(world.model),
modelAliasConfig.worldModels
),
assetsPending: true,
operationMetadata: operation.metadata ?? null,
},
},
});
}
const assets = world.assets || null;
const rawWorldUrl = getStringValue(world.world_marble_url);
const rawMeshUrl = getStringValue(assets?.mesh?.collider_mesh_url);
const rawThumbnailUrl = getStringValue(assets?.thumbnail_url);
const rawPanoUrl = getStringValue(assets?.imagery?.pano_url);
// world_marble_url is an interactive HTML viewer. It is intentionally not embedded in
// canvas because the third-party iframe is unstable and can expire independently.
const stableWorldUrl: string | null = null;
const stableModelUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
sourceUrl: rawMeshUrl,
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
fallbackExtension: "glb",
validateAs: "glb",
});
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
sourceUrl: rawThumbnailUrl,
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
sourceUrl: rawPanoUrl,
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
sourceMap:
assets?.splats?.spz_urls && typeof assets.splats.spz_urls === "object"
? assets.splats.spz_urls
: null,
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
fallbackExtension: "spz",
validateAs: "spz",
});
const assetPayload = {
providerId: config.providerId || null,
providerName: config.providerName || null,
...buildAssetPayload(world, modelAliasConfig.worldModels, assets),
};
const rawSpzUrls =
assetPayload.spzUrls && typeof assetPayload.spzUrls === "object"
? Object.entries(assetPayload.spzUrls).filter(
(entry): entry is [string, string] =>
Boolean(entry[0]) &&
typeof entry[1] === "string" &&
entry[1].trim().length > 0
)
: [];
const stableSpzEntryCount = stableSpzUrls
? Object.keys(stableSpzUrls).length
: 0;
const hasMissingSpzArchive =
rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length;
const storageArchivedAt =
[
[rawWorldUrl, stableWorldUrl],
[rawMeshUrl, stableModelUrl],
[rawThumbnailUrl, stableThumbnailUrl],
[rawPanoUrl, stablePanoUrl],
].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) ||
Boolean(
stableSpzUrls &&
assetPayload.spzUrls &&
Object.entries(stableSpzUrls).some(
([key, value]) =>
typeof assetPayload.spzUrls?.[key] === "string" &&
assetPayload.spzUrls[key] !== value
)
)
? new Date().toISOString()
: null;
logger.info("task success status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
worldIdLength: String(assetPayload.worldId || "").length,
hasMesh: Boolean(stableModelUrl),
hasPano: Boolean(stablePanoUrl),
hasThumbnail: Boolean(stableThumbnailUrl),
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
storageArchivedAt,
assetsArchivingPending: Boolean(
(rawMeshUrl && !stableModelUrl) ||
hasMissingSpzArchive
),
});
const responseAssetPayload = {
...assetPayload,
worldUrl: stableWorldUrl,
thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl,
panoUrl: stablePanoUrl || assetPayload.panoUrl,
meshUrl: stableModelUrl,
spzUrls: stableSpzUrls,
spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null,
storageArchivedAt,
assetsArchivingPending: Boolean(
(rawMeshUrl && !stableModelUrl) ||
hasMissingSpzArchive
),
sourceWorldUrl: rawWorldUrl || null,
sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
? rawMeshUrl
: null,
sourceThumbnailUrl:
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
? rawThumbnailUrl
: null,
sourcePanoUrl:
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
? rawPanoUrl
: null,
sourceSpzUrls:
assetPayload.spzUrls && stableSpzUrls !== assetPayload.spzUrls
? assetPayload.spzUrls
: null,
};
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "success",
progress: 100,
modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
error: null,
assetPayload: responseAssetPayload,
});
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "success",
progress: 100,
output: operation.response ?? null,
modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
worldId: assetPayload.worldId,
worldUrl: stableWorldUrl,
panoUrl: stablePanoUrl,
caption: assetPayload.caption,
assetPayload: responseAssetPayload,
},
});
} catch (error) {
logger.error("task status failed", error, {
elapsedMs: Date.now() - startedAt,
taskIdLength: String(normalizedTaskIdForLog || "").length,
providerId: providerForLog,
});
if (error instanceof WorldLabsApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询 World Labs 任务失败",
},
{ status: 500 }
);
}
}