Your Name 48c6cd0f87
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: stabilize world 3d asset preview
2026-04-30 14:35:17 +08:00

528 lines
17 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 { 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 = 120;
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 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;
}
}
async function archiveRemoteAssetIfNeeded(params: {
cacheKey: string;
sourceUrl?: string | null;
filenamePrefix: string;
fallbackExtension: string;
skipHtml?: boolean;
}) {
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 || ""))) {
console.warn("[WorldLabsArchive] skip html viewer url", {
cacheKey: params.cacheKey,
sourceUrl,
});
return null;
}
const assetBuffer = await upstream.arrayBuffer();
if (!assetBuffer || assetBuffer.byteLength <= 0) {
throw new Error("World Labs 资源文件为空");
}
const ext = 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) {
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);
return archiveTask;
}
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 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 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) {
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 || !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)) {
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",
});
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),
};
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;
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: {
...assetPayload,
worldUrl: stableWorldUrl,
thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl,
panoUrl: stablePanoUrl || assetPayload.panoUrl,
meshUrl: stableModelUrl,
spzUrls: stableSpzUrls,
storageArchivedAt,
assetsArchivingPending: Boolean(
(rawMeshUrl && !stableModelUrl) ||
(assetPayload.spzUrls && !stableSpzUrls)
),
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,
},
},
});
} 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 }
);
}
}