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 =
|
const THREE_ASSET_BROWSER_CACHE_CONTROL =
|
||||||
"private, max-age=86400, stale-while-revalidate=604800";
|
"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) {
|
function isAllowedModelHost(hostname: string) {
|
||||||
const normalized = hostname.toLowerCase();
|
const normalized = hostname.toLowerCase();
|
||||||
return (
|
if (isPrivateNetworkHostname(normalized)) return false;
|
||||||
normalized === "bbyy.ai" ||
|
return ALLOWED_MODEL_HOST_DOMAINS.some((domain) =>
|
||||||
normalized.endsWith(".bbyy.ai") ||
|
isHostnameOrSubdomain(normalized, domain)
|
||||||
normalized === "tripo3d.ai" ||
|
);
|
||||||
normalized.endsWith(".tripo3d.ai") ||
|
}
|
||||||
normalized === "tripo3d.com" ||
|
|
||||||
normalized.endsWith(".tripo3d.com") ||
|
function isAllowedHttpModelHost(hostname: string) {
|
||||||
normalized.includes("worldlabs") ||
|
const normalized = hostname.toLowerCase();
|
||||||
normalized.includes("marble") ||
|
return ALLOWED_HTTP_MODEL_HOST_DOMAINS.some((domain) =>
|
||||||
normalized.includes("s3.1080p.hk") ||
|
isHostnameOrSubdomain(normalized, domain)
|
||||||
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")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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 {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@ -105,28 +175,42 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ success: false, error: "url 参数无效" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "url 参数无效" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetUrl.protocol !== "https:") {
|
if (targetUrl.protocol !== "https:" && targetUrl.protocol !== "http:") {
|
||||||
return NextResponse.json({ success: false, error: "仅支持 https 模型地址" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAllowedModelHost(targetUrl.hostname)) {
|
if (!isAllowedModelHost(targetUrl.hostname)) {
|
||||||
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
|
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 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) {
|
if (range) {
|
||||||
forwardedHeaders.set("range", range);
|
forwardedHeaders.set("range", range);
|
||||||
}
|
}
|
||||||
|
|
||||||
const upstream = await fetch(targetUrl.toString(), {
|
const upstream = await fetch(targetUrl.toString(), {
|
||||||
method: "GET",
|
method,
|
||||||
headers: forwardedHeaders,
|
headers: forwardedHeaders,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!upstream.ok && upstream.status !== 206) {
|
if (!upstream.ok && upstream.status !== 206) {
|
||||||
const upstreamText = await upstream.text().catch(() => "");
|
const upstreamText =
|
||||||
|
method === "HEAD" ? "" : await upstream.text().catch(() => "");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
@ -137,8 +221,12 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responseHeaders = new Headers();
|
const responseHeaders = new Headers();
|
||||||
const contentType = upstream.headers.get("content-type") || "application/octet-stream";
|
const contentType = inferProxyContentType(
|
||||||
const shouldRewriteGltf = !range && isGltfAssetRequest(targetUrl, contentType);
|
targetUrl,
|
||||||
|
upstream.headers.get("content-type")
|
||||||
|
);
|
||||||
|
const shouldRewriteGltf =
|
||||||
|
method === "GET" && !range && isGltfAssetRequest(targetUrl, contentType);
|
||||||
responseHeaders.set("content-type", contentType);
|
responseHeaders.set("content-type", contentType);
|
||||||
|
|
||||||
const contentLength = upstream.headers.get("content-length");
|
const contentLength = upstream.headers.get("content-length");
|
||||||
@ -157,6 +245,14 @@ export async function GET(request: Request) {
|
|||||||
if (lastModified) responseHeaders.set("last-modified", lastModified);
|
if (lastModified) responseHeaders.set("last-modified", lastModified);
|
||||||
|
|
||||||
responseHeaders.set("cache-control", THREE_ASSET_BROWSER_CACHE_CONTROL);
|
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) {
|
if (shouldRewriteGltf) {
|
||||||
const rewrittenPayload = rewriteGltfPayload(await upstream.text(), targetUrl);
|
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 { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
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 { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
|
||||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
@ -19,6 +22,8 @@ type ArchiveCacheItem = {
|
|||||||
archivedUrl: string;
|
archivedUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ArchiveValidationKind = "spz" | "glb";
|
||||||
|
|
||||||
function getArchiveCache() {
|
function getArchiveCache() {
|
||||||
const globalState = globalThis as any;
|
const globalState = globalThis as any;
|
||||||
if (!globalState.__worldLabsAssetArchiveCache) {
|
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: {
|
async function archiveRemoteAssetIfNeeded(params: {
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
sourceUrl?: string | null;
|
sourceUrl?: string | null;
|
||||||
filenamePrefix: string;
|
filenamePrefix: string;
|
||||||
fallbackExtension: string;
|
fallbackExtension: string;
|
||||||
skipHtml?: boolean;
|
skipHtml?: boolean;
|
||||||
|
validateAs?: ArchiveValidationKind;
|
||||||
}) {
|
}) {
|
||||||
const sourceUrl = String(params.sourceUrl || "").trim();
|
const sourceUrl = String(params.sourceUrl || "").trim();
|
||||||
if (!shouldArchiveRemoteAssetUrl(sourceUrl)) return sourceUrl || null;
|
if (!shouldArchiveRemoteAssetUrl(sourceUrl)) return sourceUrl || null;
|
||||||
@ -154,11 +217,13 @@ async function archiveRemoteAssetIfNeeded(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assetBuffer = await upstream.arrayBuffer();
|
const assetBuffer = await upstream.arrayBuffer();
|
||||||
if (!assetBuffer || assetBuffer.byteLength <= 0) {
|
assertArchivedAssetBuffer({
|
||||||
throw new Error("World Labs 资源文件为空");
|
buffer: assetBuffer,
|
||||||
}
|
contentType,
|
||||||
|
validateAs: params.validateAs,
|
||||||
|
});
|
||||||
|
|
||||||
const ext = resolveRemoteFileExtension(
|
const ext = params.validateAs || resolveRemoteFileExtension(
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
params.fallbackExtension,
|
params.fallbackExtension,
|
||||||
contentType
|
contentType
|
||||||
@ -191,6 +256,7 @@ async function archiveRemoteAssetMapIfNeeded(params: {
|
|||||||
sourceMap?: Record<string, string> | null;
|
sourceMap?: Record<string, string> | null;
|
||||||
filenamePrefix: string;
|
filenamePrefix: string;
|
||||||
fallbackExtension: string;
|
fallbackExtension: string;
|
||||||
|
validateAs?: ArchiveValidationKind;
|
||||||
}) {
|
}) {
|
||||||
const entries = Object.entries(params.sourceMap || {}).filter(
|
const entries = Object.entries(params.sourceMap || {}).filter(
|
||||||
(entry): entry is [string, string] =>
|
(entry): entry is [string, string] =>
|
||||||
@ -207,6 +273,7 @@ async function archiveRemoteAssetMapIfNeeded(params: {
|
|||||||
sourceUrl: value,
|
sourceUrl: value,
|
||||||
filenamePrefix: `${params.filenamePrefix}-${key}`,
|
filenamePrefix: `${params.filenamePrefix}-${key}`,
|
||||||
fallbackExtension: params.fallbackExtension,
|
fallbackExtension: params.fallbackExtension,
|
||||||
|
validateAs: params.validateAs,
|
||||||
});
|
});
|
||||||
return archivedUrl ? ([key, archivedUrl] as const) : null;
|
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;
|
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) {
|
function extractWorldId(operation: WorldLabsOperation) {
|
||||||
const response = getObjectValue(operation.response);
|
const response = getObjectValue(operation.response);
|
||||||
const metadata = getObjectValue(operation.metadata);
|
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 });
|
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerId = new URL(request.url).searchParams.get("providerId");
|
const persistedWorldAsset = await getPersistedWorldAsset(
|
||||||
const config = await getWorldLabsProviderConfig(providerId);
|
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;
|
providerForLog = config.providerId || null;
|
||||||
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||||
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
||||||
@ -339,6 +464,19 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (operation.error?.message) {
|
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", {
|
console.warn("[WorldLabsTask] failed status", {
|
||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
taskId: normalizedTaskId,
|
taskId: normalizedTaskId,
|
||||||
@ -379,6 +517,25 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasUsableWorldAssets(world)) {
|
if (!hasUsableWorldAssets(world)) {
|
||||||
|
await updatePersistedWorldAsset({
|
||||||
|
id: persistedWorldAsset?.id,
|
||||||
|
status: "running",
|
||||||
|
progress: 95,
|
||||||
|
assetPayload: {
|
||||||
|
...(getObjectValue(persistedWorldAsset?.assetPayload) || {}),
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
providerName: config.providerName || null,
|
||||||
|
worldId: getStringValue(world.world_id) || worldId || null,
|
||||||
|
worldUrl: getStringValue(world.world_marble_url),
|
||||||
|
displayName: getStringValue(world.display_name),
|
||||||
|
model: resolveThreeDModelAliasId(
|
||||||
|
getStringValue(world.model),
|
||||||
|
modelAliasConfig.worldModels
|
||||||
|
),
|
||||||
|
assetsPending: true,
|
||||||
|
operationMetadata: operation.metadata ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
console.info("[WorldLabsTask] assets pending", {
|
console.info("[WorldLabsTask] assets pending", {
|
||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
taskId: normalizedTaskId,
|
taskId: normalizedTaskId,
|
||||||
@ -427,6 +584,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
sourceUrl: rawMeshUrl,
|
sourceUrl: rawMeshUrl,
|
||||||
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
|
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
|
||||||
fallbackExtension: "glb",
|
fallbackExtension: "glb",
|
||||||
|
validateAs: "glb",
|
||||||
});
|
});
|
||||||
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
|
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
|
||||||
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
|
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
|
||||||
@ -449,6 +607,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
: null,
|
: null,
|
||||||
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
|
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
|
||||||
fallbackExtension: "spz",
|
fallbackExtension: "spz",
|
||||||
|
validateAs: "spz",
|
||||||
});
|
});
|
||||||
|
|
||||||
const assetPayload = {
|
const assetPayload = {
|
||||||
@ -456,6 +615,20 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
providerName: config.providerName || null,
|
providerName: config.providerName || null,
|
||||||
...buildAssetPayload(world, modelAliasConfig.worldModels, assets),
|
...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 =
|
const storageArchivedAt =
|
||||||
[
|
[
|
||||||
[rawWorldUrl, stableWorldUrl],
|
[rawWorldUrl, stableWorldUrl],
|
||||||
@ -487,10 +660,51 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
storageArchivedAt,
|
storageArchivedAt,
|
||||||
assetsArchivingPending: Boolean(
|
assetsArchivingPending: Boolean(
|
||||||
(rawMeshUrl && !stableModelUrl) ||
|
(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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@ -505,36 +719,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
worldUrl: stableWorldUrl,
|
worldUrl: stableWorldUrl,
|
||||||
panoUrl: stablePanoUrl,
|
panoUrl: stablePanoUrl,
|
||||||
caption: assetPayload.caption,
|
caption: assetPayload.caption,
|
||||||
assetPayload: {
|
assetPayload: responseAssetPayload,
|
||||||
...assetPayload,
|
|
||||||
worldUrl: stableWorldUrl,
|
|
||||||
thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl,
|
|
||||||
panoUrl: stablePanoUrl || assetPayload.panoUrl,
|
|
||||||
meshUrl: stableModelUrl,
|
|
||||||
spzUrls: stableSpzUrls,
|
|
||||||
storageArchivedAt,
|
|
||||||
assetsArchivingPending: Boolean(
|
|
||||||
(rawMeshUrl && !stableModelUrl) ||
|
|
||||||
(assetPayload.spzUrls && !stableSpzUrls)
|
|
||||||
),
|
|
||||||
sourceWorldUrl: rawWorldUrl || null,
|
|
||||||
sourceMeshUrl:
|
|
||||||
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
|
||||||
? rawMeshUrl
|
|
||||||
: null,
|
|
||||||
sourceThumbnailUrl:
|
|
||||||
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
|
|
||||||
? rawThumbnailUrl
|
|
||||||
: null,
|
|
||||||
sourcePanoUrl:
|
|
||||||
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
|
|
||||||
? rawPanoUrl
|
|
||||||
: null,
|
|
||||||
sourceSpzUrls:
|
|
||||||
assetPayload.spzUrls && stableSpzUrls !== assetPayload.spzUrls
|
|
||||||
? assetPayload.spzUrls
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -57,6 +57,19 @@ import {
|
|||||||
} from "@/lib/canvas/local-upload-state";
|
} from "@/lib/canvas/local-upload-state";
|
||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
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> = {
|
const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
idea: Lightbulb,
|
idea: Lightbulb,
|
||||||
question: HelpCircle,
|
question: HelpCircle,
|
||||||
@ -96,11 +109,7 @@ function extractImageFilesFromDataTransfer(dataTransfer?: DataTransfer | null) {
|
|||||||
function isEditablePasteTarget(target: EventTarget | null) {
|
function isEditablePasteTarget(target: EventTarget | null) {
|
||||||
if (!(target instanceof HTMLElement)) return false;
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
if (target.isContentEditable) return true;
|
if (target.isContentEditable) return true;
|
||||||
return Boolean(
|
return Boolean(target.closest(CANVAS_DRAG_CANCEL_SELECTOR));
|
||||||
target.closest(
|
|
||||||
'input, textarea, select, [contenteditable="true"], [role="textbox"], [data-canvas-no-drag="true"]'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeVideoCanvasMarkers(node: CanvasNode) {
|
function getNodeVideoCanvasMarkers(node: CanvasNode) {
|
||||||
@ -1554,7 +1563,7 @@ export function CanvasWorkspace({
|
|||||||
enableResizing={enableImageResizeHandles}
|
enableResizing={enableImageResizeHandles}
|
||||||
resizeHandleStyles={imageResizeHandleStyles}
|
resizeHandleStyles={imageResizeHandleStyles}
|
||||||
disableDragging={tool !== 'select'}
|
disableDragging={tool !== 'select'}
|
||||||
cancel={'input, textarea, button, a, [contenteditable="true"], [data-canvas-no-drag="true"]'}
|
cancel={CANVAS_DRAG_CANCEL_SELECTOR}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute",
|
"absolute",
|
||||||
tool === "select" && "cursor-pointer"
|
tool === "select" && "cursor-pointer"
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
Copy,
|
Copy,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
|
Cuboid,
|
||||||
Download,
|
Download,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
@ -33,8 +34,8 @@ import {
|
|||||||
type PublicThreeDModelAliasConfig,
|
type PublicThreeDModelAliasConfig,
|
||||||
} from "@/lib/model3d/model-alias-config";
|
} from "@/lib/model3d/model-alias-config";
|
||||||
import {
|
import {
|
||||||
|
getPreferredInlineWorldLabsSplatUrls,
|
||||||
isArchivedWorldLabsAssetUrl,
|
isArchivedWorldLabsAssetUrl,
|
||||||
pickBestArchivedWorldLabsSplatUrl,
|
|
||||||
pickPreferredInlineWorldLabsSplatUrl,
|
pickPreferredInlineWorldLabsSplatUrl,
|
||||||
} from "@/lib/worldlabs/splat-utils";
|
} from "@/lib/worldlabs/splat-utils";
|
||||||
import { getNodeContentScale } from "@/lib/canvas/node-content-scale";
|
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: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
|
||||||
{ id: "splat", label: "3D", icon: Box, toolbarKey: "world3dViewSplat" },
|
{ 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;
|
const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000;
|
||||||
@ -81,11 +82,8 @@ function resolveAvailableViews(data: World3DNodeData) {
|
|||||||
const hasMesh = isArchivedWorldLabsAssetUrl(data.meshUrl);
|
const hasMesh = isArchivedWorldLabsAssetUrl(data.meshUrl);
|
||||||
|
|
||||||
if (data.imageUrl || data.previewImage) views.push("image");
|
if (data.imageUrl || data.previewImage) views.push("image");
|
||||||
if (hasSplat) {
|
if (hasSplat) views.push("splat");
|
||||||
views.push("splat");
|
if (hasMesh) views.push("mesh");
|
||||||
} else if (hasMesh) {
|
|
||||||
views.push("mesh");
|
|
||||||
}
|
|
||||||
return views;
|
return views;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,8 +92,7 @@ function resolveActiveView(data: World3DNodeData) {
|
|||||||
const hasSplat = availableViews.includes("splat");
|
const hasSplat = availableViews.includes("splat");
|
||||||
if (
|
if (
|
||||||
data.activeView &&
|
data.activeView &&
|
||||||
availableViews.includes(data.activeView) &&
|
availableViews.includes(data.activeView)
|
||||||
!(data.activeView === "mesh" && hasSplat)
|
|
||||||
) {
|
) {
|
||||||
return data.activeView;
|
return data.activeView;
|
||||||
}
|
}
|
||||||
@ -224,17 +221,27 @@ export function World3DNode({
|
|||||||
|
|
||||||
const activeView = useMemo(() => resolveActiveView(data), [data]);
|
const activeView = useMemo(() => resolveActiveView(data), [data]);
|
||||||
const availableViews = useMemo(() => resolveAvailableViews(data), [data]);
|
const availableViews = useMemo(() => resolveAvailableViews(data), [data]);
|
||||||
const renderableSplatUrl = useMemo(() => {
|
const renderableSplatUrls = useMemo(() => {
|
||||||
const assetPayloadSplatUrl = pickPreferredInlineWorldLabsSplatUrl(
|
const assetPayloadSplatUrls = getPreferredInlineWorldLabsSplatUrls(
|
||||||
metadataAssetPayload.spzUrls as Record<string, string> | null | undefined
|
metadataAssetPayload.spzUrls as Record<string, string> | null | undefined
|
||||||
);
|
);
|
||||||
|
const urls = [...assetPayloadSplatUrls];
|
||||||
|
|
||||||
if (isArchivedWorldLabsAssetUrl(assetPayloadSplatUrl)) {
|
const fallbackSplatUrl = readStringValue(data.splatUrl);
|
||||||
return assetPayloadSplatUrl;
|
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]);
|
}, [data.splatUrl, metadataAssetPayload.spzUrls]);
|
||||||
|
const renderableSplatUrl = renderableSplatUrls[0];
|
||||||
const renderableMeshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl)
|
const renderableMeshUrl = isArchivedWorldLabsAssetUrl(data.meshUrl)
|
||||||
? data.meshUrl
|
? data.meshUrl
|
||||||
: undefined;
|
: undefined;
|
||||||
@ -476,7 +483,7 @@ export function World3DNode({
|
|||||||
|
|
||||||
if (status === "success") {
|
if (status === "success") {
|
||||||
const assetPayload = readRecordValue(result.data.assetPayload);
|
const assetPayload = readRecordValue(result.data.assetPayload);
|
||||||
const splatUrl = pickBestArchivedWorldLabsSplatUrl(
|
const splatUrl = pickPreferredInlineWorldLabsSplatUrl(
|
||||||
assetPayload.spzUrls as Record<string, string> | null | undefined
|
assetPayload.spzUrls as Record<string, string> | null | undefined
|
||||||
);
|
);
|
||||||
const panoUrl = readStringValue(assetPayload.panoUrl);
|
const panoUrl = readStringValue(assetPayload.panoUrl);
|
||||||
@ -625,7 +632,7 @@ export function World3DNode({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assetPayload = readRecordValue(result.data.assetPayload);
|
const assetPayload = readRecordValue(result.data.assetPayload);
|
||||||
const splatUrl = pickBestArchivedWorldLabsSplatUrl(
|
const splatUrl = pickPreferredInlineWorldLabsSplatUrl(
|
||||||
assetPayload.spzUrls as Record<string, string> | null | undefined
|
assetPayload.spzUrls as Record<string, string> | null | undefined
|
||||||
);
|
);
|
||||||
const panoUrl = readStringValue(assetPayload.panoUrl);
|
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 () => {
|
const handleCaptureSnapshot = useCallback(async () => {
|
||||||
if (!viewerCapture) {
|
if (!viewerCapture) {
|
||||||
toast.error("当前视图暂不支持拍照");
|
toast.error("当前视图暂不支持拍照");
|
||||||
@ -929,8 +948,21 @@ export function World3DNode({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0 touch-none"
|
||||||
data-canvas-no-drag="true"
|
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()}
|
onWheel={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<World3DViewer
|
<World3DViewer
|
||||||
@ -946,6 +978,7 @@ export function World3DNode({
|
|||||||
previewImageUrl={data.previewImage}
|
previewImageUrl={data.previewImage}
|
||||||
semanticsMetadata={data.semanticsMetadata}
|
semanticsMetadata={data.semanticsMetadata}
|
||||||
splatUrl={renderableSplatUrl}
|
splatUrl={renderableSplatUrl}
|
||||||
|
splatUrls={renderableSplatUrls}
|
||||||
worldUrl={data.worldUrl}
|
worldUrl={data.worldUrl}
|
||||||
onCaptureReady={handleViewerCaptureReady}
|
onCaptureReady={handleViewerCaptureReady}
|
||||||
showMovementControls={
|
showMovementControls={
|
||||||
|
|||||||
@ -66,6 +66,7 @@ type World3DViewerProps = {
|
|||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
meshUrl?: string | null;
|
meshUrl?: string | null;
|
||||||
splatUrl?: string | null;
|
splatUrl?: string | null;
|
||||||
|
splatUrls?: string[] | null;
|
||||||
previewImageUrl?: string | null;
|
previewImageUrl?: string | null;
|
||||||
emptyStateTitle?: string;
|
emptyStateTitle?: string;
|
||||||
minimalFrame?: boolean;
|
minimalFrame?: boolean;
|
||||||
@ -699,11 +700,12 @@ function SplatParticleCloud({
|
|||||||
function WorldSplatLoadingOverlay({
|
function WorldSplatLoadingOverlay({
|
||||||
previewImageUrl,
|
previewImageUrl,
|
||||||
phase,
|
phase,
|
||||||
|
progress,
|
||||||
}: {
|
}: {
|
||||||
previewImageUrl?: string | null;
|
previewImageUrl?: string | null;
|
||||||
phase: Exclude<SplatLoadingPhase, "hidden">;
|
phase: Exclude<SplatLoadingPhase, "hidden">;
|
||||||
|
progress?: number | null;
|
||||||
}) {
|
}) {
|
||||||
const isRevealing = phase === "revealing";
|
|
||||||
const isLoading = phase === "loading";
|
const isLoading = phase === "loading";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -749,6 +751,12 @@ function WorldSplatLoadingOverlay({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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,
|
onHotspotSelect,
|
||||||
semanticsMetadata,
|
semanticsMetadata,
|
||||||
onLoadStateChange,
|
onLoadStateChange,
|
||||||
|
onLoadProgress,
|
||||||
onRevealComplete,
|
onRevealComplete,
|
||||||
resetViewToken,
|
resetViewToken,
|
||||||
showInfoOverlay = true,
|
showInfoOverlay = true,
|
||||||
@ -1086,7 +1095,8 @@ function WorldSplatScene({
|
|||||||
onPick?: (pick: World3DPickResult) => void;
|
onPick?: (pick: World3DPickResult) => void;
|
||||||
onHotspotSelect?: (hotspotId: string) => void;
|
onHotspotSelect?: (hotspotId: string) => void;
|
||||||
semanticsMetadata?: Record<string, unknown> | null;
|
semanticsMetadata?: Record<string, unknown> | null;
|
||||||
onLoadStateChange?: (state: "ready" | "error") => void;
|
onLoadStateChange?: (state: "ready" | "error", detail?: string) => void;
|
||||||
|
onLoadProgress?: (event: ProgressEvent) => void;
|
||||||
onRevealComplete?: () => void;
|
onRevealComplete?: () => void;
|
||||||
resetViewToken?: number;
|
resetViewToken?: number;
|
||||||
showInfoOverlay?: boolean;
|
showInfoOverlay?: boolean;
|
||||||
@ -1143,8 +1153,9 @@ function WorldSplatScene({
|
|||||||
lodScale: minimalFrame
|
lodScale: minimalFrame
|
||||||
? SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE
|
? SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE
|
||||||
: SPARK_INLINE_LOD_SPLAT_SCALE,
|
: SPARK_INLINE_LOD_SPLAT_SCALE,
|
||||||
|
onProgress: onLoadProgress,
|
||||||
}),
|
}),
|
||||||
[minimalFrame, splatUrl]
|
[minimalFrame, onLoadProgress, splatUrl]
|
||||||
);
|
);
|
||||||
const groundPlaneOffset =
|
const groundPlaneOffset =
|
||||||
typeof semanticsMetadata?.ground_plane_offset === "number"
|
typeof semanticsMetadata?.ground_plane_offset === "number"
|
||||||
@ -1190,7 +1201,10 @@ function WorldSplatScene({
|
|||||||
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
|
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||||
const loadTimeoutId = globalThis.setTimeout(() => {
|
const loadTimeoutId = globalThis.setTimeout(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
onLoadStateChange?.("error");
|
onLoadStateChange?.(
|
||||||
|
"error",
|
||||||
|
`SPZ 加载超时(${SPARK_SPLAT_LOAD_TIMEOUT_MS}ms)`
|
||||||
|
);
|
||||||
}, SPARK_SPLAT_LOAD_TIMEOUT_MS);
|
}, SPARK_SPLAT_LOAD_TIMEOUT_MS);
|
||||||
|
|
||||||
const markReady = () => {
|
const markReady = () => {
|
||||||
@ -1238,8 +1252,12 @@ function WorldSplatScene({
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
globalThis.clearTimeout(loadTimeoutId);
|
globalThis.clearTimeout(loadTimeoutId);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.warn("[WorldSplatScene] Failed to load SPZ", error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
onLoadStateChange?.("error");
|
console.warn("[WorldSplatScene] Failed to load SPZ", {
|
||||||
|
error: message,
|
||||||
|
splatUrl,
|
||||||
|
});
|
||||||
|
onLoadStateChange?.("error", message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@ -1499,6 +1517,7 @@ function WorldSplatCaptureBridge({
|
|||||||
|
|
||||||
function WorldSplatViewer({
|
function WorldSplatViewer({
|
||||||
splatUrl,
|
splatUrl,
|
||||||
|
splatUrls,
|
||||||
worldUrl,
|
worldUrl,
|
||||||
previewImageUrl,
|
previewImageUrl,
|
||||||
firstPerson,
|
firstPerson,
|
||||||
@ -1514,6 +1533,7 @@ function WorldSplatViewer({
|
|||||||
showMovementControls = false,
|
showMovementControls = false,
|
||||||
}: {
|
}: {
|
||||||
splatUrl?: string | null;
|
splatUrl?: string | null;
|
||||||
|
splatUrls?: string[] | null;
|
||||||
worldUrl?: string | null;
|
worldUrl?: string | null;
|
||||||
previewImageUrl?: string | null;
|
previewImageUrl?: string | null;
|
||||||
minimalFrame?: boolean;
|
minimalFrame?: boolean;
|
||||||
@ -1530,12 +1550,40 @@ function WorldSplatViewer({
|
|||||||
}) {
|
}) {
|
||||||
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
const [loadState, setLoadState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
const [overlayPhase, setOverlayPhase] = useState<SplatLoadingPhase>("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 loadStartedAtRef = useRef(0);
|
||||||
const movementStateRef = useRef<MovementState>(createMovementState());
|
const movementStateRef = useRef<MovementState>(createMovementState());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setActiveSplatIndex(0);
|
||||||
|
setFailedSplatLoads([]);
|
||||||
|
}, [resolvedSplatSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resolvedSplatUrl) return;
|
||||||
loadStartedAtRef.current = performance.now();
|
loadStartedAtRef.current = performance.now();
|
||||||
|
setLoadProgress(null);
|
||||||
setLoadState("loading");
|
setLoadState("loading");
|
||||||
setOverlayPhase("loading");
|
setOverlayPhase("loading");
|
||||||
}, [resolvedSplatUrl]);
|
}, [resolvedSplatUrl]);
|
||||||
@ -1549,6 +1597,7 @@ function WorldSplatViewer({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadState === "ready") {
|
if (loadState === "ready") {
|
||||||
const elapsed = performance.now() - loadStartedAtRef.current;
|
const elapsed = performance.now() - loadStartedAtRef.current;
|
||||||
|
setLoadProgress(100);
|
||||||
setOverlayPhase(
|
setOverlayPhase(
|
||||||
elapsed <= SPLAT_FAST_LOAD_SKIP_REVEAL_MS ? "hidden" : "revealing"
|
elapsed <= SPLAT_FAST_LOAD_SKIP_REVEAL_MS ? "hidden" : "revealing"
|
||||||
);
|
);
|
||||||
@ -1559,10 +1608,59 @@ function WorldSplatViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loadState === "error") {
|
if (loadState === "error") {
|
||||||
|
setLoadProgress(null);
|
||||||
setOverlayPhase("hidden");
|
setOverlayPhase("hidden");
|
||||||
}
|
}
|
||||||
}, [loadState]);
|
}, [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) {
|
if (!resolvedSplatUrl) {
|
||||||
return (
|
return (
|
||||||
<WorldEmptyState
|
<WorldEmptyState
|
||||||
@ -1580,12 +1678,13 @@ function WorldSplatViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loadState === "error") {
|
if (loadState === "error") {
|
||||||
|
const attemptedCount = Math.max(failedSplatLoads.length, resolvedSplatUrls.length);
|
||||||
return (
|
return (
|
||||||
<WorldEmptyState
|
<WorldEmptyState
|
||||||
description={
|
description={
|
||||||
worldUrl
|
worldUrl
|
||||||
? "World 资产加载失败,已避免退回不稳定的官方 iframe。可以切换到图像或 Mesh 视图继续查看。"
|
? `World 资产加载失败,已自动尝试 ${attemptedCount} 个 SPZ 档位。可以切换到图像或 Mesh 视图继续查看。`
|
||||||
: "当前世界数据暂时无法加载,可以切换到图像或 Mesh 视图继续查看。"
|
: `当前世界数据暂时无法加载,已自动尝试 ${attemptedCount} 个 SPZ 档位。可以切换到图像或 Mesh 视图继续查看。`
|
||||||
}
|
}
|
||||||
fillFrame={minimalFrame}
|
fillFrame={minimalFrame}
|
||||||
icon={AlertCircle}
|
icon={AlertCircle}
|
||||||
@ -1611,12 +1710,14 @@ function WorldSplatViewer({
|
|||||||
>
|
>
|
||||||
<WorldSplatCaptureBridge onCaptureReady={onCaptureReady} />
|
<WorldSplatCaptureBridge onCaptureReady={onCaptureReady} />
|
||||||
<WorldSplatScene
|
<WorldSplatScene
|
||||||
|
key={resolvedSplatUrl}
|
||||||
firstPerson={firstPerson}
|
firstPerson={firstPerson}
|
||||||
hotspots={hotspots}
|
hotspots={hotspots}
|
||||||
interactionMode={interactionMode}
|
interactionMode={interactionMode}
|
||||||
minimalFrame={minimalFrame}
|
minimalFrame={minimalFrame}
|
||||||
onHotspotSelect={onHotspotSelect}
|
onHotspotSelect={onHotspotSelect}
|
||||||
onLoadStateChange={setLoadState}
|
onLoadProgress={handleSplatProgress}
|
||||||
|
onLoadStateChange={handleSplatLoadStateChange}
|
||||||
onPick={onPick}
|
onPick={onPick}
|
||||||
onRevealComplete={() => setOverlayPhase("hidden")}
|
onRevealComplete={() => setOverlayPhase("hidden")}
|
||||||
revealPhase={overlayPhase}
|
revealPhase={overlayPhase}
|
||||||
@ -1632,7 +1733,11 @@ function WorldSplatViewer({
|
|||||||
<WorldMovementPad movementStateRef={movementStateRef} />
|
<WorldMovementPad movementStateRef={movementStateRef} />
|
||||||
) : null}
|
) : null}
|
||||||
{overlayPhase !== "hidden" ? (
|
{overlayPhase !== "hidden" ? (
|
||||||
<WorldSplatLoadingOverlay phase={overlayPhase} previewImageUrl={previewImageUrl} />
|
<WorldSplatLoadingOverlay
|
||||||
|
phase={overlayPhase}
|
||||||
|
progress={loadProgress}
|
||||||
|
previewImageUrl={previewImageUrl}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -1644,6 +1749,7 @@ export function World3DViewer({
|
|||||||
imageUrl,
|
imageUrl,
|
||||||
meshUrl,
|
meshUrl,
|
||||||
splatUrl,
|
splatUrl,
|
||||||
|
splatUrls,
|
||||||
previewImageUrl,
|
previewImageUrl,
|
||||||
emptyStateTitle = "准备生成 3D 世界",
|
emptyStateTitle = "准备生成 3D 世界",
|
||||||
minimalFrame = false,
|
minimalFrame = false,
|
||||||
@ -1763,6 +1869,7 @@ export function World3DViewer({
|
|||||||
showInfoOverlay={!minimalFrame}
|
showInfoOverlay={!minimalFrame}
|
||||||
showMovementControls={showMovementControls}
|
showMovementControls={showMovementControls}
|
||||||
splatUrl={splatUrl}
|
splatUrl={splatUrl}
|
||||||
|
splatUrls={splatUrls}
|
||||||
worldUrl={worldUrl}
|
worldUrl={worldUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 "server-only";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getAiProviderById,
|
||||||
getAiProvidersByType,
|
getAiProvidersByType,
|
||||||
updateAiProvider,
|
updateAiProvider,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
@ -24,6 +25,22 @@ export function sanitizeWorldLabsBaseUrl(value: string): string {
|
|||||||
.trim();
|
.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<
|
export async function getWorldLabsProviderConfigs(): Promise<
|
||||||
WorldLabsProviderConfig[]
|
WorldLabsProviderConfig[]
|
||||||
> {
|
> {
|
||||||
@ -33,20 +50,7 @@ export async function getWorldLabsProviderConfigs(): Promise<
|
|||||||
});
|
});
|
||||||
|
|
||||||
const normalizedProviders = providers
|
const normalizedProviders = providers
|
||||||
.map((provider): WorldLabsProviderConfig | null => {
|
.map((provider) => providerToWorldLabsConfig(provider))
|
||||||
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,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item): item is WorldLabsProviderConfig => Boolean(item));
|
.filter((item): item is WorldLabsProviderConfig => Boolean(item));
|
||||||
|
|
||||||
if (normalizedProviders.length > 0) {
|
if (normalizedProviders.length > 0) {
|
||||||
@ -70,7 +74,8 @@ export async function getWorldLabsProviderConfigs(): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorldLabsProviderConfig(
|
export async function getWorldLabsProviderConfig(
|
||||||
preferredProviderId?: string | null
|
preferredProviderId?: string | null,
|
||||||
|
options?: { includeDisabledPreferred?: boolean }
|
||||||
): Promise<WorldLabsProviderConfig> {
|
): Promise<WorldLabsProviderConfig> {
|
||||||
const providers = await getWorldLabsProviderConfigs();
|
const providers = await getWorldLabsProviderConfigs();
|
||||||
const preferredId = String(preferredProviderId || "").trim();
|
const preferredId = String(preferredProviderId || "").trim();
|
||||||
@ -80,6 +85,14 @@ export async function getWorldLabsProviderConfig(
|
|||||||
if (matchedProvider) {
|
if (matchedProvider) {
|
||||||
return matchedProvider;
|
return matchedProvider;
|
||||||
}
|
}
|
||||||
|
if (options?.includeDisabledPreferred) {
|
||||||
|
const disabledOrEnabledProvider = providerToWorldLabsConfig(
|
||||||
|
await getAiProviderById({ id: preferredId })
|
||||||
|
);
|
||||||
|
if (disabledOrEnabledProvider) {
|
||||||
|
return disabledOrEnabledProvider;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const worldLabsProvider =
|
const worldLabsProvider =
|
||||||
|
|||||||
@ -96,6 +96,39 @@ function pickClosestResolutionEntry(
|
|||||||
})[0]?.entry || null;
|
})[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 = [
|
const KNOWN_ARCHIVED_ASSET_HOSTS = [
|
||||||
"blob.vercel-storage.com",
|
"blob.vercel-storage.com",
|
||||||
"s3.1080p.hk",
|
"s3.1080p.hk",
|
||||||
@ -189,3 +222,24 @@ export function pickPreferredInlineWorldLabsSplatUrl(
|
|||||||
) {
|
) {
|
||||||
return pickPreferredInlineWorldLabsSplatEntry(spzUrls)?.[1] || null;
|
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