fix: harden worldlabs asset loading
This commit is contained in:
parent
48f222998b
commit
dd2b06f10f
@ -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");
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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>
|
||||
|
||||
316
docs/worldlabs-proxy-provider-requirements.md
Normal file
316
docs/worldlabs-proxy-provider-requirements.md
Normal 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/...`,我方系统就可以在官方接口和中转接口之间无缝切换。
|
||||
@ -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 =
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user