fix: harden worldlabs asset loading
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-25 17:27:25 +08:00
parent 48f222998b
commit dd2b06f10f
8 changed files with 932 additions and 111 deletions

View File

@ -7,25 +7,74 @@ export const maxDuration = 60;
const THREE_ASSET_BROWSER_CACHE_CONTROL =
"private, max-age=86400, stale-while-revalidate=604800";
const ALLOWED_MODEL_HOST_DOMAINS = [
"bbyy.ai",
"tripo3d.ai",
"tripo3d.com",
"worldlabs.ai",
"s3.1080p.hk",
"img.yydd.ai",
"blob.vercel-storage.com",
"aliyuncs.com",
"amazonaws.com",
"googleapis.com",
"googleusercontent.com",
"cloudfront.net",
];
const ALLOWED_HTTP_MODEL_HOST_DOMAINS = [
"bbyy.ai",
"s3.1080p.hk",
"img.yydd.ai",
];
function isHostnameOrSubdomain(hostname: string, domain: string) {
return hostname === domain || hostname.endsWith(`.${domain}`);
}
function isPrivateNetworkHostname(hostname: string) {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (normalized === "localhost") return true;
const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const octets = ipv4Match.slice(1).map((value) => Number(value));
if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
return true;
}
const [a, b] = octets;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
);
}
if (!normalized.includes(":")) return false;
return (
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe80:")
);
}
function isAllowedModelHost(hostname: string) {
const normalized = hostname.toLowerCase();
return (
normalized === "bbyy.ai" ||
normalized.endsWith(".bbyy.ai") ||
normalized === "tripo3d.ai" ||
normalized.endsWith(".tripo3d.ai") ||
normalized === "tripo3d.com" ||
normalized.endsWith(".tripo3d.com") ||
normalized.includes("worldlabs") ||
normalized.includes("marble") ||
normalized.includes("s3.1080p.hk") ||
normalized.includes("img.yydd.ai") ||
normalized.includes("blob.vercel-storage.com") ||
normalized.includes("aliyuncs.com") ||
normalized.includes("amazonaws.com") ||
normalized.includes("googleapis.com") ||
normalized.includes("googleusercontent.com") ||
normalized.includes("cloudfront.net")
if (isPrivateNetworkHostname(normalized)) return false;
return ALLOWED_MODEL_HOST_DOMAINS.some((domain) =>
isHostnameOrSubdomain(normalized, domain)
);
}
function isAllowedHttpModelHost(hostname: string) {
const normalized = hostname.toLowerCase();
return ALLOWED_HTTP_MODEL_HOST_DOMAINS.some((domain) =>
isHostnameOrSubdomain(normalized, domain)
);
}
@ -85,7 +134,28 @@ function rewriteGltfPayload(raw: string, sourceUrl: URL) {
}
}
export async function GET(request: Request) {
function normalizeRangeHeader(range: string | null) {
const normalized = String(range || "").trim();
if (!normalized) return null;
const match = normalized.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return "";
const [, start, end] = match;
if (!start && !end) return "";
if (start && end && Number(start) > Number(end)) return "";
return normalized;
}
function inferProxyContentType(targetUrl: URL, upstreamContentType: string | null) {
const pathname = targetUrl.pathname.toLowerCase();
if (pathname.endsWith(".spz")) return "application/octet-stream";
if (pathname.endsWith(".glb")) return "model/gltf-binary";
if (pathname.endsWith(".gltf")) return "model/gltf+json";
return upstreamContentType || "application/octet-stream";
}
async function handleRequest(request: Request, method: "GET" | "HEAD") {
try {
const session = await auth();
if (!session?.user?.id) {
@ -105,28 +175,42 @@ export async function GET(request: Request) {
return NextResponse.json({ success: false, error: "url 参数无效" }, { status: 400 });
}
if (targetUrl.protocol !== "https:") {
return NextResponse.json({ success: false, error: "仅支持 https 模型地址" }, { status: 400 });
if (targetUrl.protocol !== "https:" && targetUrl.protocol !== "http:") {
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
}
if (!isAllowedModelHost(targetUrl.hostname)) {
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
}
if (targetUrl.protocol === "http:" && !isAllowedHttpModelHost(targetUrl.hostname)) {
return NextResponse.json(
{ success: false, error: "HTTP 模型地址仅允许已配置的公开存储域名" },
{ status: 400 }
);
}
const forwardedHeaders = new Headers();
const range = request.headers.get("range");
const range = normalizeRangeHeader(request.headers.get("range"));
if (range === "") {
return NextResponse.json(
{ success: false, error: "Range 请求格式无效" },
{ status: 400 }
);
}
if (range) {
forwardedHeaders.set("range", range);
}
const upstream = await fetch(targetUrl.toString(), {
method: "GET",
method,
headers: forwardedHeaders,
cache: "no-store",
});
if (!upstream.ok && upstream.status !== 206) {
const upstreamText = await upstream.text().catch(() => "");
const upstreamText =
method === "HEAD" ? "" : await upstream.text().catch(() => "");
return NextResponse.json(
{
success: false,
@ -137,8 +221,12 @@ export async function GET(request: Request) {
}
const responseHeaders = new Headers();
const contentType = upstream.headers.get("content-type") || "application/octet-stream";
const shouldRewriteGltf = !range && isGltfAssetRequest(targetUrl, contentType);
const contentType = inferProxyContentType(
targetUrl,
upstream.headers.get("content-type")
);
const shouldRewriteGltf =
method === "GET" && !range && isGltfAssetRequest(targetUrl, contentType);
responseHeaders.set("content-type", contentType);
const contentLength = upstream.headers.get("content-length");
@ -157,6 +245,14 @@ export async function GET(request: Request) {
if (lastModified) responseHeaders.set("last-modified", lastModified);
responseHeaders.set("cache-control", THREE_ASSET_BROWSER_CACHE_CONTROL);
responseHeaders.set("x-content-type-options", "nosniff");
if (method === "HEAD") {
return new NextResponse(null, {
status: upstream.status,
headers: responseHeaders,
});
}
if (shouldRewriteGltf) {
const rewrittenPayload = rewriteGltfPayload(await upstream.text(), targetUrl);
@ -183,3 +279,11 @@ export async function GET(request: Request) {
);
}
}
export async function GET(request: Request) {
return handleRequest(request, "GET");
}
export async function HEAD(request: Request) {
return handleRequest(request, "HEAD");
}

View File

@ -1,5 +1,8 @@
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 { uploadFile } from "@/lib/storage/service";
@ -19,6 +22,8 @@ type ArchiveCacheItem = {
archivedUrl: string;
};
type ArchiveValidationKind = "spz" | "glb";
function getArchiveCache() {
const globalState = globalThis as any;
if (!globalState.__worldLabsAssetArchiveCache) {
@ -109,12 +114,70 @@ function resolveRemoteFileExtension(url: string, fallback: string, contentType?:
}
}
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;
@ -154,11 +217,13 @@ async function archiveRemoteAssetIfNeeded(params: {
}
const assetBuffer = await upstream.arrayBuffer();
if (!assetBuffer || assetBuffer.byteLength <= 0) {
throw new Error("World Labs 资源文件为空");
}
assertArchivedAssetBuffer({
buffer: assetBuffer,
contentType,
validateAs: params.validateAs,
});
const ext = resolveRemoteFileExtension(
const ext = params.validateAs || resolveRemoteFileExtension(
sourceUrl,
params.fallbackExtension,
contentType
@ -191,6 +256,7 @@ async function archiveRemoteAssetMapIfNeeded(params: {
sourceMap?: Record<string, string> | null;
filenamePrefix: string;
fallbackExtension: string;
validateAs?: ArchiveValidationKind;
}) {
const entries = Object.entries(params.sourceMap || {}).filter(
(entry): entry is [string, string] =>
@ -207,6 +273,7 @@ async function archiveRemoteAssetMapIfNeeded(params: {
sourceUrl: value,
filenamePrefix: `${params.filenamePrefix}-${key}`,
fallbackExtension: params.fallbackExtension,
validateAs: params.validateAs,
});
return archivedUrl ? ([key, archivedUrl] as const) : null;
})
@ -229,6 +296,56 @@ 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);
@ -311,8 +428,16 @@ export async function GET(request: Request, { params }: RouteParams) {
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
}
const providerId = new URL(request.url).searchParams.get("providerId");
const config = await getWorldLabsProviderConfig(providerId);
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, {
@ -339,6 +464,19 @@ export async function GET(request: Request, { params }: RouteParams) {
}
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,
});
console.warn("[WorldLabsTask] failed status", {
elapsedMs: Date.now() - startedAt,
taskId: normalizedTaskId,
@ -379,6 +517,25 @@ export async function GET(request: Request, { params }: RouteParams) {
}
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,
},
});
console.info("[WorldLabsTask] assets pending", {
elapsedMs: Date.now() - startedAt,
taskId: normalizedTaskId,
@ -427,6 +584,7 @@ export async function GET(request: Request, { params }: RouteParams) {
sourceUrl: rawMeshUrl,
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
fallbackExtension: "glb",
validateAs: "glb",
});
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
@ -449,6 +607,7 @@ export async function GET(request: Request, { params }: RouteParams) {
: null,
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
fallbackExtension: "spz",
validateAs: "spz",
});
const assetPayload = {
@ -456,6 +615,20 @@ export async function GET(request: Request, { params }: RouteParams) {
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],
@ -487,10 +660,51 @@ export async function GET(request: Request, { params }: RouteParams) {
storageArchivedAt,
assetsArchivingPending: Boolean(
(rawMeshUrl && !stableModelUrl) ||
(assetPayload.spzUrls && !stableSpzUrls)
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: {
@ -505,36 +719,7 @@ export async function GET(request: Request, { params }: RouteParams) {
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,
},
assetPayload: responseAssetPayload,
},
});
} catch (error) {

View File

@ -57,6 +57,19 @@ import {
} from "@/lib/canvas/local-upload-state";
import { inferOriginalImageUrl } from "@/lib/image-url";
const CANVAS_NO_DRAG_SELECTOR =
'[data-canvas-no-drag="true"], [data-canvas-no-drag="true"] *';
const CANVAS_DRAG_CANCEL_SELECTOR = [
"input",
"textarea",
"select",
"button",
"a",
'[contenteditable="true"]',
'[role="textbox"]',
CANVAS_NO_DRAG_SELECTOR,
].join(", ");
const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record<string, LucideIcon> = {
idea: Lightbulb,
question: HelpCircle,
@ -96,11 +109,7 @@ function extractImageFilesFromDataTransfer(dataTransfer?: DataTransfer | null) {
function isEditablePasteTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
return Boolean(
target.closest(
'input, textarea, select, [contenteditable="true"], [role="textbox"], [data-canvas-no-drag="true"]'
)
);
return Boolean(target.closest(CANVAS_DRAG_CANCEL_SELECTOR));
}
function getNodeVideoCanvasMarkers(node: CanvasNode) {
@ -1554,7 +1563,7 @@ export function CanvasWorkspace({
enableResizing={enableImageResizeHandles}
resizeHandleStyles={imageResizeHandleStyles}
disableDragging={tool !== 'select'}
cancel={'input, textarea, button, a, [contenteditable="true"], [data-canvas-no-drag="true"]'}
cancel={CANVAS_DRAG_CANCEL_SELECTOR}
className={cn(
"absolute",
tool === "select" && "cursor-pointer"

View File

@ -7,6 +7,7 @@ import {
Camera,
Copy,
Crosshair,
Cuboid,
Download,
ImageIcon,
Loader2,
@ -33,8 +34,8 @@ import {
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
getPreferredInlineWorldLabsSplatUrls,
isArchivedWorldLabsAssetUrl,
pickBestArchivedWorldLabsSplatUrl,
pickPreferredInlineWorldLabsSplatUrl,
} from "@/lib/worldlabs/splat-utils";
import { getNodeContentScale } from "@/lib/canvas/node-content-scale";
@ -67,7 +68,7 @@ const VIEW_META: Array<{
}> = [
{ id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
{ id: "splat", label: "3D", icon: Box, toolbarKey: "world3dViewSplat" },
{ id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" },
{ id: "mesh", label: "M", icon: Cuboid, toolbarKey: "world3dViewMesh" },
];
const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000;
@ -81,11 +82,8 @@ function resolveAvailableViews(data: World3DNodeData) {
const hasMesh = isArchivedWorldLabsAssetUrl(data.meshUrl);
if (data.imageUrl || data.previewImage) views.push("image");
if (hasSplat) {
views.push("splat");
} else if (hasMesh) {
views.push("mesh");
}
if (hasSplat) views.push("splat");
if (hasMesh) views.push("mesh");
return views;
}
@ -94,8 +92,7 @@ function resolveActiveView(data: World3DNodeData) {
const hasSplat = availableViews.includes("splat");
if (
data.activeView &&
availableViews.includes(data.activeView) &&
!(data.activeView === "mesh" && hasSplat)
availableViews.includes(data.activeView)
) {
return data.activeView;
}
@ -224,17 +221,27 @@ export function World3DNode({
const activeView = useMemo(() => resolveActiveView(data), [data]);
const availableViews = useMemo(() => resolveAvailableViews(data), [data]);
const renderableSplatUrl = useMemo(() => {
const assetPayloadSplatUrl = pickPreferredInlineWorldLabsSplatUrl(
const renderableSplatUrls = useMemo(() => {
const assetPayloadSplatUrls = getPreferredInlineWorldLabsSplatUrls(
metadataAssetPayload.spzUrls as Record<string, string> | null | undefined
);
const urls = [...assetPayloadSplatUrls];
if (isArchivedWorldLabsAssetUrl(assetPayloadSplatUrl)) {
return assetPayloadSplatUrl;
const fallbackSplatUrl = readStringValue(data.splatUrl);
if (fallbackSplatUrl && isArchivedWorldLabsAssetUrl(fallbackSplatUrl)) {
urls.push(fallbackSplatUrl);
}
return isArchivedWorldLabsAssetUrl(data.splatUrl) ? data.splatUrl : undefined;
const seen = new Set<string>();
return urls.filter((url) => {
const normalized = String(url || "").trim();
if (!normalized || !isArchivedWorldLabsAssetUrl(normalized)) return false;
if (seen.has(normalized)) return false;
seen.add(normalized);
return true;
});
}, [data.splatUrl, metadataAssetPayload.spzUrls]);
const renderableSplatUrl = renderableSplatUrls[0];
const renderableMeshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl)
? data.meshUrl
: undefined;
@ -476,7 +483,7 @@ export function World3DNode({
if (status === "success") {
const assetPayload = readRecordValue(result.data.assetPayload);
const splatUrl = pickBestArchivedWorldLabsSplatUrl(
const splatUrl = pickPreferredInlineWorldLabsSplatUrl(
assetPayload.spzUrls as Record<string, string> | null | undefined
);
const panoUrl = readStringValue(assetPayload.panoUrl);
@ -625,7 +632,7 @@ export function World3DNode({
}
const assetPayload = readRecordValue(result.data.assetPayload);
const splatUrl = pickBestArchivedWorldLabsSplatUrl(
const splatUrl = pickPreferredInlineWorldLabsSplatUrl(
assetPayload.spzUrls as Record<string, string> | null | undefined
);
const panoUrl = readStringValue(assetPayload.panoUrl);
@ -798,6 +805,18 @@ export function World3DNode({
[]
);
const stopViewerCanvasEvent = useCallback((event: React.SyntheticEvent) => {
event.stopPropagation();
}, []);
const handleViewerPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
selectNode(node.id, event.shiftKey);
event.stopPropagation();
},
[node.id, selectNode]
);
const handleCaptureSnapshot = useCallback(async () => {
if (!viewerCapture) {
toast.error("当前视图暂不支持拍照");
@ -929,8 +948,21 @@ export function World3DNode({
) : (
<>
<div
className="absolute inset-0"
className="absolute inset-0 touch-none"
data-canvas-no-drag="true"
onClick={stopViewerCanvasEvent}
onContextMenu={stopViewerCanvasEvent}
onDoubleClick={stopViewerCanvasEvent}
onMouseDown={stopViewerCanvasEvent}
onMouseMove={stopViewerCanvasEvent}
onMouseUp={stopViewerCanvasEvent}
onPointerCancel={stopViewerCanvasEvent}
onPointerDown={handleViewerPointerDown}
onPointerMove={stopViewerCanvasEvent}
onPointerUp={stopViewerCanvasEvent}
onTouchEnd={stopViewerCanvasEvent}
onTouchMove={stopViewerCanvasEvent}
onTouchStart={stopViewerCanvasEvent}
onWheel={(event) => event.stopPropagation()}
>
<World3DViewer
@ -946,6 +978,7 @@ export function World3DNode({
previewImageUrl={data.previewImage}
semanticsMetadata={data.semanticsMetadata}
splatUrl={renderableSplatUrl}
splatUrls={renderableSplatUrls}
worldUrl={data.worldUrl}
onCaptureReady={handleViewerCaptureReady}
showMovementControls={

View File

@ -66,6 +66,7 @@ type World3DViewerProps = {
imageUrl?: string | null;
meshUrl?: string | null;
splatUrl?: string | null;
splatUrls?: string[] | null;
previewImageUrl?: string | null;
emptyStateTitle?: string;
minimalFrame?: boolean;
@ -699,11 +700,12 @@ function SplatParticleCloud({
function WorldSplatLoadingOverlay({
previewImageUrl,
phase,
progress,
}: {
previewImageUrl?: string | null;
phase: Exclude<SplatLoadingPhase, "hidden">;
progress?: number | null;
}) {
const isRevealing = phase === "revealing";
const isLoading = phase === "loading";
return (
@ -749,6 +751,12 @@ function WorldSplatLoadingOverlay({
</div>
) : null}
{isLoading ? (
<div className="pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full border border-white/12 bg-black/36 px-3 py-2 text-xs font-medium text-white/78 shadow-[0_12px_32px_rgba(0,0,0,0.24)] backdrop-blur-md">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{typeof progress === "number" ? <span>{progress}%</span> : null}
</div>
) : null}
</>
);
}
@ -1071,6 +1079,7 @@ function WorldSplatScene({
onHotspotSelect,
semanticsMetadata,
onLoadStateChange,
onLoadProgress,
onRevealComplete,
resetViewToken,
showInfoOverlay = true,
@ -1086,7 +1095,8 @@ function WorldSplatScene({
onPick?: (pick: World3DPickResult) => void;
onHotspotSelect?: (hotspotId: string) => void;
semanticsMetadata?: Record<string, unknown> | null;
onLoadStateChange?: (state: "ready" | "error") => void;
onLoadStateChange?: (state: "ready" | "error", detail?: string) => void;
onLoadProgress?: (event: ProgressEvent) => void;
onRevealComplete?: () => void;
resetViewToken?: number;
showInfoOverlay?: boolean;
@ -1143,8 +1153,9 @@ function WorldSplatScene({
lodScale: minimalFrame
? SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE
: SPARK_INLINE_LOD_SPLAT_SCALE,
onProgress: onLoadProgress,
}),
[minimalFrame, splatUrl]
[minimalFrame, onLoadProgress, splatUrl]
);
const groundPlaneOffset =
typeof semanticsMetadata?.ground_plane_offset === "number"
@ -1190,7 +1201,10 @@ function WorldSplatScene({
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
const loadTimeoutId = globalThis.setTimeout(() => {
if (cancelled) return;
onLoadStateChange?.("error");
onLoadStateChange?.(
"error",
`SPZ 加载超时(${SPARK_SPLAT_LOAD_TIMEOUT_MS}ms)`
);
}, SPARK_SPLAT_LOAD_TIMEOUT_MS);
const markReady = () => {
@ -1238,8 +1252,12 @@ function WorldSplatScene({
.catch((error) => {
globalThis.clearTimeout(loadTimeoutId);
if (!cancelled) {
console.warn("[WorldSplatScene] Failed to load SPZ", error);
onLoadStateChange?.("error");
const message = error instanceof Error ? error.message : String(error);
console.warn("[WorldSplatScene] Failed to load SPZ", {
error: message,
splatUrl,
});
onLoadStateChange?.("error", message);
}
});
return () => {
@ -1499,6 +1517,7 @@ function WorldSplatCaptureBridge({
function WorldSplatViewer({
splatUrl,
splatUrls,
worldUrl,
previewImageUrl,
firstPerson,
@ -1514,6 +1533,7 @@ function WorldSplatViewer({
showMovementControls = false,
}: {
splatUrl?: string | null;
splatUrls?: string[] | null;
worldUrl?: string | null;
previewImageUrl?: string | null;
minimalFrame?: boolean;
@ -1530,12 +1550,40 @@ function WorldSplatViewer({
}) {
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("loading");
const resolvedSplatUrl = useMemo(() => buildThreeAssetUrl(splatUrl), [splatUrl]);
const [activeSplatIndex, setActiveSplatIndex] = useState(0);
const [failedSplatLoads, setFailedSplatLoads] = useState<
Array<{ url: string; error: string }>
>([]);
const [loadProgress, setLoadProgress] = useState<number | null>(null);
const resolvedSplatUrls = useMemo(() => {
const rawUrls = [
splatUrl,
...(Array.isArray(splatUrls) ? splatUrls : []),
];
const seen = new Set<string>();
return rawUrls
.map((url) => String(buildThreeAssetUrl(url) || "").trim())
.filter((url) => {
if (!url || seen.has(url)) return false;
seen.add(url);
return true;
});
}, [splatUrl, splatUrls]);
const resolvedSplatSignature = resolvedSplatUrls.join("|");
const resolvedSplatUrl = resolvedSplatUrls[activeSplatIndex] || "";
const loadStartedAtRef = useRef(0);
const movementStateRef = useRef<MovementState>(createMovementState());
useEffect(() => {
setActiveSplatIndex(0);
setFailedSplatLoads([]);
}, [resolvedSplatSignature]);
useEffect(() => {
if (!resolvedSplatUrl) return;
loadStartedAtRef.current = performance.now();
setLoadProgress(null);
setLoadState("loading");
setOverlayPhase("loading");
}, [resolvedSplatUrl]);
@ -1549,6 +1597,7 @@ function WorldSplatViewer({
useEffect(() => {
if (loadState === "ready") {
const elapsed = performance.now() - loadStartedAtRef.current;
setLoadProgress(100);
setOverlayPhase(
elapsed <= SPLAT_FAST_LOAD_SKIP_REVEAL_MS ? "hidden" : "revealing"
);
@ -1559,10 +1608,59 @@ function WorldSplatViewer({
}
if (loadState === "error") {
setLoadProgress(null);
setOverlayPhase("hidden");
}
}, [loadState]);
const handleSplatProgress = React.useCallback((event: ProgressEvent) => {
if (!event.lengthComputable || event.total <= 0) return;
const progress = Math.round((event.loaded / event.total) * 100);
setLoadProgress(THREE.MathUtils.clamp(progress, 1, 99));
}, []);
const handleSplatLoadStateChange = React.useCallback(
(state: "ready" | "error", detail?: string) => {
if (state === "ready") {
setLoadState("ready");
return;
}
const message = detail || "SPZ 加载失败";
const failedUrl = resolvedSplatUrl;
const nextIndex = activeSplatIndex + 1;
const nextUrl = resolvedSplatUrls[nextIndex] || "";
if (failedUrl) {
setFailedSplatLoads((items) => [
...items,
{ url: failedUrl, error: message },
]);
}
if (nextUrl) {
console.warn("[WorldSplatViewer] Falling back to next SPZ asset", {
error: message,
failedUrl,
nextUrl,
});
setActiveSplatIndex(nextIndex);
setLoadProgress(null);
setLoadState("loading");
setOverlayPhase("loading");
return;
}
console.error("[WorldSplatViewer] All SPZ assets failed", {
error: message,
failedUrl,
attemptedUrls: resolvedSplatUrls,
});
setLoadState("error");
},
[activeSplatIndex, resolvedSplatUrl, resolvedSplatUrls]
);
if (!resolvedSplatUrl) {
return (
<WorldEmptyState
@ -1580,12 +1678,13 @@ function WorldSplatViewer({
}
if (loadState === "error") {
const attemptedCount = Math.max(failedSplatLoads.length, resolvedSplatUrls.length);
return (
<WorldEmptyState
description={
worldUrl
? "World 资产加载失败,已避免退回不稳定的官方 iframe。可以切换到图像或 Mesh 视图继续查看。"
: "当前世界数据暂时无法加载,可以切换到图像或 Mesh 视图继续查看。"
? `World 资产加载失败,已自动尝试 ${attemptedCount} 个 SPZ 档位。可以切换到图像或 Mesh 视图继续查看。`
: `当前世界数据暂时无法加载,已自动尝试 ${attemptedCount} 个 SPZ 档位。可以切换到图像或 Mesh 视图继续查看。`
}
fillFrame={minimalFrame}
icon={AlertCircle}
@ -1611,12 +1710,14 @@ function WorldSplatViewer({
>
<WorldSplatCaptureBridge onCaptureReady={onCaptureReady} />
<WorldSplatScene
key={resolvedSplatUrl}
firstPerson={firstPerson}
hotspots={hotspots}
interactionMode={interactionMode}
minimalFrame={minimalFrame}
onHotspotSelect={onHotspotSelect}
onLoadStateChange={setLoadState}
onLoadProgress={handleSplatProgress}
onLoadStateChange={handleSplatLoadStateChange}
onPick={onPick}
onRevealComplete={() => setOverlayPhase("hidden")}
revealPhase={overlayPhase}
@ -1632,7 +1733,11 @@ function WorldSplatViewer({
<WorldMovementPad movementStateRef={movementStateRef} />
) : null}
{overlayPhase !== "hidden" ? (
<WorldSplatLoadingOverlay phase={overlayPhase} previewImageUrl={previewImageUrl} />
<WorldSplatLoadingOverlay
phase={overlayPhase}
progress={loadProgress}
previewImageUrl={previewImageUrl}
/>
) : null}
</div>
);
@ -1644,6 +1749,7 @@ export function World3DViewer({
imageUrl,
meshUrl,
splatUrl,
splatUrls,
previewImageUrl,
emptyStateTitle = "准备生成 3D 世界",
minimalFrame = false,
@ -1763,6 +1869,7 @@ export function World3DViewer({
showInfoOverlay={!minimalFrame}
showMovementControls={showMovementControls}
splatUrl={splatUrl}
splatUrls={splatUrls}
worldUrl={worldUrl}
/>
</div>

View File

@ -0,0 +1,316 @@
# World Labs 3D 世界生成中转接口兼容要求
本文档用于说明我方系统接入 World Labs 3D 世界生成功能时,对第三方中转服务的接口兼容要求。
## 核心原则
我方系统会按照 World Labs 官方 API 协议发起请求:
```text
{baseUrl} + /marble/v1/...
```
中转服务需要兼容官方路径、请求体、响应体和错误格式。请不要要求我方把 `baseUrl` 配置成某个完整接口地址。
正确示例:
```text
http://your-proxy-domain.com
```
错误示例:
```text
http://your-proxy-domain.com/marble/v1/worlds:generate
```
## 我方请求方式
我方后台会在请求中携带:
```http
WLT-Api-Key: {配置的 API Key}
Accept: application/json
Content-Type: application/json
```
中转服务应支持 `WLT-Api-Key` 请求头。如果中转服务内部自行管理官方 Key,也可以将该字段作为租户标识或鉴权凭据,但不应要求我方改成其他 header。
## 必须兼容的接口
### 1. 准备上传素材
```http
POST {baseUrl}/marble/v1/media-assets:prepare_upload
```
请求体示例:
```json
{
"file_name": "input.jpg",
"extension": "jpg",
"kind": "image",
"metadata": {
"mime_type": "image/jpeg",
"uploaded_from": "canvas_world_mode"
}
}
```
响应必须包含可用的上传信息:
```json
{
"media_asset": {},
"upload_info": {
"upload_url": "https://...",
"upload_method": "PUT",
"required_headers": {}
}
}
```
要求:
- `upload_url` 必须可由我方服务器直接访问。
- `upload_method` 如为 `PUT`,必须支持 `PUT` 上传文件二进制。
- `required_headers` 中返回的 header,我方会原样带上。
- 上传成功时,上传地址必须返回 `2xx`。
### 2. 创建世界生成任务
```http
POST {baseUrl}/marble/v1/worlds:generate
```
请求体示例:
```json
{
"display_name": "",
"model": "marble-1.1",
"permission": {
"public": false
},
"world_prompt": {
"type": "image",
"image_prompt": {
"source": "media_asset",
"media_asset_id": "media_asset_id_here"
}
}
}
```
响应需兼容官方 operation 结构,至少包含:
```json
{
"operation_id": "task-id",
"done": false,
"metadata": {
"progress": {
"status": "RUNNING"
}
}
}
```
要求:
- `operation_id` 必须稳定,后续查询状态必须使用同一个 id。
- 不要在创建成功但任务未完成时返回业务失败。
- 如果余额不足、Key 无效,应返回明确 HTTP 状态码和错误信息。
### 3. 查询任务状态
```http
GET {baseUrl}/marble/v1/operations/{operation_id}
```
运行中响应示例:
```json
{
"operation_id": "task-id",
"done": false,
"metadata": {
"progress": {
"status": "RUNNING",
"description": "World generation in progress"
}
}
}
```
成功响应示例:
```json
{
"operation_id": "task-id",
"done": true,
"error": null,
"metadata": {
"progress": {
"status": "SUCCEEDED"
},
"world_id": "world-id"
},
"response": {
"world_id": "world-id",
"world_marble_url": "https://marble.worldlabs.ai/world/...",
"assets": {
"mesh": {
"collider_mesh_url": "https://cdn.marble.worldlabs.ai/.../model.glb"
},
"imagery": {
"pano_url": "https://cdn.marble.worldlabs.ai/.../rgb_0.png"
},
"thumbnail_url": "https://cdn.marble.worldlabs.ai/.../thumbnail.webp",
"splats": {
"spz_urls": {
"100k": "https://cdn.marble.worldlabs.ai/...100k.spz",
"150k": "https://cdn.marble.worldlabs.ai/...150k.spz",
"500k": "https://cdn.marble.worldlabs.ai/...500k.spz",
"full_res": "https://cdn.marble.worldlabs.ai/...full.spz"
}
}
}
}
}
```
失败响应示例:
```json
{
"operation_id": "task-id",
"done": true,
"error": {
"message": "Insufficient account balance"
}
}
```
要求:
- `done=false` 表示任务仍在运行。
- `done=true` 且 `error=null` 表示任务成功。
- `done=true` 且 `error.message` 存在表示任务失败。
- 不要把运行中的任务返回为 HTTP 404。
- 不要因为任务未完成就返回 `Operation not found`。
### 4. 查询世界详情
```http
GET {baseUrl}/marble/v1/worlds/{world_id}
```
当 `operations` 成功响应中资产不完整时,我方会调用该接口补充详情。响应应包含与 `response` 内 world 对象兼容的数据结构。
## 错误码要求
建议按以下语义返回:
| 场景 | HTTP 状态码 | message 示例 |
| --- | --- | --- |
| API Key 缺失或无效 | `401` | `Unauthorized` |
| 权限不足 | `403` | `Forbidden` |
| 余额不足 | `402` 或 `403` | `Insufficient account balance` |
| 请求体错误 | `400` | `Invalid request body` |
| 任务不存在 | `404` | `Operation not found` |
| 上游超时 | `504` | `Upstream timeout` |
| 上游异常 | `502` | `Upstream error` |
响应体建议:
```json
{
"message": "Insufficient account balance"
}
```
或:
```json
{
"error": {
"message": "Insufficient account balance"
}
}
```
## 超时与稳定性要求
- `prepare_upload` 建议 30 秒内返回。
- `worlds:generate` 建议 60 秒内返回 operation,而不是等待生成完成。
- `operations/{id}` 建议 10 秒内返回当前状态。
- 生成任务本身可异步运行,前端会轮询状态。
- 同一个 `operation_id` 多次查询必须返回同一个任务状态,不应随机切换账号或路由。
## 上传地址要求
`prepare_upload` 返回的 `upload_url` 可以是官方签名地址,也可以是中转服务自己的上传地址,但必须满足:
- 我方服务器可直接访问。
- 支持返回的 `upload_method`。
- 支持上传原始文件二进制。
- 上传成功返回 `2xx`。
- 上传完成后,后续 `worlds:generate` 使用的 `media_asset_id` 必须可用。
## 安全要求
- 不要在错误信息中返回完整官方 API Key。
- 日志中建议只记录 Key 前后少量字符,例如 `sk-****abcd`。
- 中转服务如保存官方 Key,应加密存储。
- 上传 URL 如有签名,应设置合理过期时间。
- 不要允许用户通过 `baseUrl` 或 path 注入访问内网地址。
## 我方验收用例
中转服务上线前,请至少通过以下链路:
1. `POST /marble/v1/media-assets:prepare_upload` 返回 `media_asset_id` 和可上传 URL。
2. 使用返回的 `upload_url` 上传图片,返回 `2xx`。
3. `POST /marble/v1/worlds:generate` 返回稳定 `operation_id`。
4. `GET /marble/v1/operations/{operation_id}` 在运行中返回 `done=false`。
5. 任务完成后返回 `done=true`、`error=null`、`world_id` 和资产 URL。
6. 资产 URL 中至少应包含 thumbnail、pano、mesh 或 spz 中的一种,最好完整返回。
7. `GET /marble/v1/worlds/{world_id}` 可返回完整 world 详情。
## 配置示例
我方系统中的 API 地址应填写中转根地址:
```text
http://23.169.168.133:8080
```
最终我方会请求:
```text
http://23.169.168.133:8080/marble/v1/media-assets:prepare_upload
http://23.169.168.133:8080/marble/v1/worlds:generate
http://23.169.168.133:8080/marble/v1/operations/{operation_id}
http://23.169.168.133:8080/marble/v1/worlds/{world_id}
```
如果切回官方接口,配置为:
```text
https://api.worldlabs.ai
```
最终请求:
```text
https://api.worldlabs.ai/marble/v1/media-assets:prepare_upload
https://api.worldlabs.ai/marble/v1/worlds:generate
https://api.worldlabs.ai/marble/v1/operations/{operation_id}
https://api.worldlabs.ai/marble/v1/worlds/{world_id}
```
## 结论
中转服务需要做的是兼容 World Labs 官方 API 协议,而不是要求我方修改接口路径。只要中转根地址完整支持 `/marble/v1/...`,我方系统就可以在官方接口和中转接口之间无缝切换。

View File

@ -1,6 +1,7 @@
import "server-only";
import {
getAiProviderById,
getAiProvidersByType,
updateAiProvider,
} from "@/lib/db/queries";
@ -24,6 +25,22 @@ export function sanitizeWorldLabsBaseUrl(value: string): string {
.trim();
}
function providerToWorldLabsConfig(
provider: Awaited<ReturnType<typeof getAiProvidersByType>>[number] | undefined
): WorldLabsProviderConfig | null {
if (!provider) return null;
const providerApiKey = sanitizeWorldLabsApiKey(String(provider.apiKey || ""));
if (!providerApiKey) return null;
return {
apiKey: providerApiKey,
baseUrl:
sanitizeWorldLabsBaseUrl(String(provider.baseUrl || "")) ||
DEFAULT_WORLDLABS_BASE_URL,
providerId: provider.id,
providerName: provider.name,
} satisfies WorldLabsProviderConfig;
}
export async function getWorldLabsProviderConfigs(): Promise<
WorldLabsProviderConfig[]
> {
@ -33,20 +50,7 @@ export async function getWorldLabsProviderConfigs(): Promise<
});
const normalizedProviders = providers
.map((provider): WorldLabsProviderConfig | null => {
const providerApiKey = sanitizeWorldLabsApiKey(
String(provider.apiKey || "")
);
if (!providerApiKey) return null;
return {
apiKey: providerApiKey,
baseUrl:
sanitizeWorldLabsBaseUrl(String(provider.baseUrl || "")) ||
DEFAULT_WORLDLABS_BASE_URL,
providerId: provider.id,
providerName: provider.name,
};
})
.map((provider) => providerToWorldLabsConfig(provider))
.filter((item): item is WorldLabsProviderConfig => Boolean(item));
if (normalizedProviders.length > 0) {
@ -70,7 +74,8 @@ export async function getWorldLabsProviderConfigs(): Promise<
}
export async function getWorldLabsProviderConfig(
preferredProviderId?: string | null
preferredProviderId?: string | null,
options?: { includeDisabledPreferred?: boolean }
): Promise<WorldLabsProviderConfig> {
const providers = await getWorldLabsProviderConfigs();
const preferredId = String(preferredProviderId || "").trim();
@ -80,6 +85,14 @@ export async function getWorldLabsProviderConfig(
if (matchedProvider) {
return matchedProvider;
}
if (options?.includeDisabledPreferred) {
const disabledOrEnabledProvider = providerToWorldLabsConfig(
await getAiProviderById({ id: preferredId })
);
if (disabledOrEnabledProvider) {
return disabledOrEnabledProvider;
}
}
}
const worldLabsProvider =

View File

@ -96,6 +96,39 @@ function pickClosestResolutionEntry(
})[0]?.entry || null;
}
function getResolutionDistance(entry: [string, string], targetScore: number) {
const score = parseResolutionScore(entry[0]);
if (
!Number.isFinite(score) ||
score <= 0 ||
score === Number.MAX_SAFE_INTEGER
) {
return Number.MAX_SAFE_INTEGER;
}
return Math.abs(score - targetScore);
}
function compareInlineSplatEntries(
left: [string, string],
right: [string, string]
) {
const leftDistance = getResolutionDistance(
left,
INLINE_WORLDLABS_SPLAT_TARGET_SCORE
);
const rightDistance = getResolutionDistance(
right,
INLINE_WORLDLABS_SPLAT_TARGET_SCORE
);
if (leftDistance !== rightDistance) return leftDistance - rightDistance;
const leftScore = parseResolutionScore(left[0]);
const rightScore = parseResolutionScore(right[0]);
if (leftScore !== rightScore) return leftScore - rightScore;
return left[0].localeCompare(right[0], "en");
}
const KNOWN_ARCHIVED_ASSET_HOSTS = [
"blob.vercel-storage.com",
"s3.1080p.hk",
@ -189,3 +222,24 @@ export function pickPreferredInlineWorldLabsSplatUrl(
) {
return pickPreferredInlineWorldLabsSplatEntry(spzUrls)?.[1] || null;
}
export function getPreferredInlineWorldLabsSplatUrls(
spzUrls?: Record<string, string> | null
) {
const archivedEntries = getArchivedWorldLabsSplatEntries(spzUrls);
const sourceEntries =
archivedEntries.length > 0
? archivedEntries
: getSortedWorldLabsSplatEntries(spzUrls);
const orderedEntries = sourceEntries.slice().sort(compareInlineSplatEntries);
const seen = new Set<string>();
return orderedEntries
.map(([, url]) => url)
.filter((url) => {
const normalized = String(url || "").trim();
if (!normalized || seen.has(normalized)) return false;
seen.add(normalized);
return true;
});
}