356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { auth } from "@/app/(auth)/auth";
|
||
import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
|
||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||
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 = 60;
|
||
|
||
type ArchiveCacheItem = {
|
||
sourceUrl: string;
|
||
archivedUrl: string;
|
||
};
|
||
|
||
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 resolveRemoteFileExtension(url: string, fallback: string) {
|
||
try {
|
||
const parsed = new URL(url);
|
||
const pathname = parsed.pathname.toLowerCase();
|
||
const ext = pathname.split(".").pop() || "";
|
||
return ext || fallback;
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
async function archiveRemoteAssetIfNeeded(params: {
|
||
cacheKey: string;
|
||
sourceUrl?: string | null;
|
||
filenamePrefix: string;
|
||
fallbackExtension: string;
|
||
}) {
|
||
const sourceUrl = String(params.sourceUrl || "").trim();
|
||
if (!sourceUrl) return 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) {
|
||
const result = await existingTask;
|
||
return result || sourceUrl;
|
||
}
|
||
|
||
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 assetBuffer = await upstream.arrayBuffer();
|
||
if (!assetBuffer || assetBuffer.byteLength <= 0) {
|
||
throw new Error("World Labs 资源文件为空");
|
||
}
|
||
|
||
const ext = resolveRemoteFileExtension(sourceUrl, params.fallbackExtension);
|
||
const filename = `${params.filenamePrefix}.${ext}`;
|
||
const uploaded = await uploadFile(filename, assetBuffer);
|
||
cache.set(params.cacheKey, {
|
||
sourceUrl,
|
||
archivedUrl: uploaded.url,
|
||
});
|
||
return uploaded.url;
|
||
} catch (error) {
|
||
console.error("[WorldLabsArchive] failed", {
|
||
cacheKey: params.cacheKey,
|
||
sourceUrl,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
});
|
||
return null;
|
||
} finally {
|
||
inflight.delete(params.cacheKey);
|
||
}
|
||
})();
|
||
|
||
inflight.set(params.cacheKey, archiveTask);
|
||
const archivedUrl = await archiveTask;
|
||
return archivedUrl || sourceUrl;
|
||
}
|
||
|
||
async function archiveRemoteAssetMapIfNeeded(params: {
|
||
cacheKeyPrefix: string;
|
||
sourceMap?: Record<string, string> | null;
|
||
filenamePrefix: string;
|
||
fallbackExtension: string;
|
||
}) {
|
||
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,
|
||
});
|
||
return [key, archivedUrl || value] as const;
|
||
})
|
||
);
|
||
|
||
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 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 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) {
|
||
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();
|
||
if (!normalizedTaskId) {
|
||
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
|
||
}
|
||
|
||
const providerId = new URL(request.url).searchParams.get("providerId");
|
||
const config = await getWorldLabsProviderConfig(providerId);
|
||
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) {
|
||
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 && worldId) {
|
||
world = await worldLabsRequest<WorldLabsWorld>(config, {
|
||
method: "GET",
|
||
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
|
||
});
|
||
}
|
||
|
||
if (!world) {
|
||
throw new Error("World Labs 任务已完成,但未返回 world 数据");
|
||
}
|
||
|
||
const assets = world.assets || null;
|
||
const rawMeshUrl = getStringValue(assets?.mesh?.collider_mesh_url);
|
||
const rawThumbnailUrl = getStringValue(assets?.thumbnail_url);
|
||
const rawPanoUrl = getStringValue(assets?.imagery?.pano_url);
|
||
|
||
const stableModelUrl = await archiveRemoteAssetIfNeeded({
|
||
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
|
||
sourceUrl: rawMeshUrl,
|
||
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
|
||
fallbackExtension: "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",
|
||
});
|
||
|
||
const assetPayload = {
|
||
providerId: config.providerId || null,
|
||
providerName: config.providerName || null,
|
||
...buildAssetPayload(world, modelAliasConfig.worldModels, assets),
|
||
};
|
||
|
||
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: assetPayload.worldUrl,
|
||
panoUrl: stablePanoUrl,
|
||
caption: assetPayload.caption,
|
||
assetPayload: {
|
||
...assetPayload,
|
||
thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl,
|
||
panoUrl: stablePanoUrl || assetPayload.panoUrl,
|
||
meshUrl: stableModelUrl || assetPayload.meshUrl,
|
||
spzUrls: stableSpzUrls || assetPayload.spzUrls,
|
||
},
|
||
},
|
||
});
|
||
} catch (error) {
|
||
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 }
|
||
);
|
||
}
|
||
}
|