fix: stabilize 3d world canvas assets

This commit is contained in:
Your Name 2026-04-24 18:12:00 +08:00
parent b4529c860c
commit 314884dc77
8 changed files with 386 additions and 100 deletions

View File

@ -11,15 +11,20 @@ import type { TripoTaskData } from "@/lib/tripo/types";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 30;
export const maxDuration = 120;
type TripoModelArchiveCacheItem = {
type RemoteAssetArchiveCacheItem = {
sourceUrl: string;
archivedUrl: string;
};
function shouldArchiveTripoModelUrl(modelUrl?: string | null) {
const raw = String(modelUrl || "").trim();
function isLikelyArchivedStorageUrl(parsed: URL) {
const hostname = parsed.hostname.toLowerCase();
return hostname.includes("blob.vercel-storage.com");
}
function shouldArchiveRemoteAssetUrl(assetUrl?: string | null) {
const raw = String(assetUrl || "").trim();
if (!raw) return false;
let parsed: URL;
try {
@ -27,32 +32,68 @@ function shouldArchiveTripoModelUrl(modelUrl?: string | null) {
} catch {
return false;
}
const hostname = parsed.hostname.toLowerCase();
return (
hostname === "tripo3d.com" ||
hostname.endsWith(".tripo3d.com")
);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
return !isLikelyArchivedStorageUrl(parsed);
}
function resolveModelExtension(modelUrl: string) {
function resolveExtensionFromContentType(contentType: string | null) {
const normalized = String(contentType || "").toLowerCase().split(";")[0]?.trim() || "";
const map: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"model/gltf-binary": "glb",
"model/gltf+json": "gltf",
"model/stl": "stl",
"model/3mf": "3mf",
"model/vnd.usdz+zip": "usdz",
"application/zip": "zip",
"application/json": "json",
};
return map[normalized] || "";
}
function resolveRemoteAssetExtension(assetUrl: string, fallback: string, contentType?: string | null) {
const contentTypeExtension = resolveExtensionFromContentType(contentType || null);
if (contentTypeExtension) return contentTypeExtension;
try {
const parsed = new URL(modelUrl);
const parsed = new URL(assetUrl);
const pathname = parsed.pathname.toLowerCase();
const ext = pathname.split(".").pop() || "";
const allowed = new Set(["glb", "gltf", "usdz", "fbx", "obj", "stl", "3mf"]);
const allowed = new Set([
"glb",
"gltf",
"usdz",
"fbx",
"obj",
"stl",
"3mf",
"ply",
"spz",
"splat",
"ksplat",
"zip",
"jpg",
"jpeg",
"png",
"webp",
"gif",
]);
if (allowed.has(ext)) return ext;
} catch {
// ignore
}
return "glb";
return fallback;
}
function getTripoArchiveCache() {
const globalState = globalThis as any;
if (!globalState.__tripoModelArchiveCache) {
globalState.__tripoModelArchiveCache = new Map<string, TripoModelArchiveCacheItem>();
globalState.__tripoModelArchiveCache = new Map<string, RemoteAssetArchiveCacheItem>();
}
return globalState.__tripoModelArchiveCache as Map<string, TripoModelArchiveCacheItem>;
return globalState.__tripoModelArchiveCache as Map<string, RemoteAssetArchiveCacheItem>;
}
function getTripoArchiveInflight() {
@ -65,29 +106,32 @@ function getTripoArchiveInflight() {
async function archiveTripoModelIfNeeded(params: {
taskId: string;
modelUrl?: string | null;
cacheKey: string;
sourceUrl?: string | null;
filenamePrefix: string;
fallbackExtension: string;
}) {
const modelUrl = String(params.modelUrl || "").trim();
if (!shouldArchiveTripoModelUrl(modelUrl)) {
return modelUrl || null;
const sourceUrl = String(params.sourceUrl || "").trim();
if (!shouldArchiveRemoteAssetUrl(sourceUrl)) {
return sourceUrl || null;
}
const cache = getTripoArchiveCache();
const cached = cache.get(params.taskId);
if (cached && cached.sourceUrl === modelUrl) {
const cached = cache.get(params.cacheKey);
if (cached && cached.sourceUrl === sourceUrl) {
return cached.archivedUrl;
}
const inflight = getTripoArchiveInflight();
const existingTask = inflight.get(params.taskId);
const existingTask = inflight.get(params.cacheKey);
if (existingTask) {
const result = await existingTask;
return result || modelUrl;
return result || sourceUrl;
}
const archiveTask = (async () => {
try {
const upstream = await fetch(modelUrl, {
const upstream = await fetch(sourceUrl, {
method: "GET",
headers: {
"User-Agent": "AI-Chatbot-3D-Archiver/1.0",
@ -95,45 +139,74 @@ async function archiveTripoModelIfNeeded(params: {
cache: "no-store",
});
if (!upstream.ok) {
throw new Error(`下载 Tripo 模型失败(HTTP ${upstream.status})`);
throw new Error(`下载 Tripo 资源失败(HTTP ${upstream.status})`);
}
const modelBuffer = await upstream.arrayBuffer();
if (!modelBuffer || modelBuffer.byteLength <= 0) {
throw new Error("Tripo 模型文件为空");
const contentType = upstream.headers.get("content-type");
if (/\btext\/html\b/i.test(String(contentType || ""))) {
throw new Error("Tripo 资源返回 HTML,已跳过归档以避免保存错误文件");
}
const ext = resolveModelExtension(modelUrl);
const filename = `tripo-model-${params.taskId}.${ext}`;
const uploaded = await uploadFile(filename, modelBuffer);
cache.set(params.taskId, {
sourceUrl: modelUrl,
const assetBuffer = await upstream.arrayBuffer();
if (!assetBuffer || assetBuffer.byteLength <= 0) {
throw new Error("Tripo 资源文件为空");
}
const ext = resolveRemoteAssetExtension(
sourceUrl,
params.fallbackExtension,
contentType
);
const filename = `${params.filenamePrefix}.${ext}`;
const uploaded = await uploadFile(filename, assetBuffer);
cache.set(params.cacheKey, {
sourceUrl,
archivedUrl: uploaded.url,
});
console.log("[TripoModelArchive] success", {
console.log("[TripoAssetArchive] success", {
taskId: params.taskId,
sourceHost: new URL(modelUrl).hostname,
bytes: modelBuffer.byteLength,
cacheKey: params.cacheKey,
sourceHost: new URL(sourceUrl).hostname,
bytes: assetBuffer.byteLength,
archivedUrl: uploaded.url,
});
return uploaded.url;
} catch (error) {
console.error("[TripoModelArchive] failed", {
console.error("[TripoAssetArchive] failed", {
taskId: params.taskId,
sourceUrl: modelUrl,
cacheKey: params.cacheKey,
sourceUrl,
error: error instanceof Error ? error.message : String(error),
});
return null;
} finally {
inflight.delete(params.taskId);
inflight.delete(params.cacheKey);
}
})();
inflight.set(params.taskId, archiveTask);
inflight.set(params.cacheKey, archiveTask);
const archivedUrl = await archiveTask;
return archivedUrl || modelUrl;
return archivedUrl || sourceUrl;
}
function replaceArchivedUrls(value: unknown, replacements: Map<string, string>): unknown {
if (typeof value === "string") {
return replacements.get(value) || value;
}
if (Array.isArray(value)) {
return value.map((item) => replaceArchivedUrls(item, replacements));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
replaceArchivedUrls(item, replacements),
])
);
}
return value;
}
interface RouteParams {
@ -166,8 +239,35 @@ export async function GET(request: Request, { params }: RouteParams) {
const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>);
const stableModelUrl = await archiveTripoModelIfNeeded({
taskId: normalizedTaskId,
modelUrl,
cacheKey: `tripo:${normalizedTaskId}:model`,
sourceUrl: modelUrl,
filenamePrefix: `tripo-model-${normalizedTaskId}`,
fallbackExtension: "glb",
});
const stablePreviewImageUrl = await archiveTripoModelIfNeeded({
taskId: normalizedTaskId,
cacheKey: `tripo:${normalizedTaskId}:preview`,
sourceUrl: previewImageUrl,
filenamePrefix: `tripo-preview-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const replacements = new Map<string, string>();
if (modelUrl && stableModelUrl && modelUrl !== stableModelUrl) {
replacements.set(modelUrl, stableModelUrl);
}
if (
previewImageUrl &&
stablePreviewImageUrl &&
previewImageUrl !== stablePreviewImageUrl
) {
replacements.set(previewImageUrl, stablePreviewImageUrl);
}
const archivedOutput =
replacements.size > 0
? replaceArchivedUrls(output, replacements)
: output;
const storageArchivedAt =
replacements.size > 0 ? new Date().toISOString() : null;
return NextResponse.json({
success: true,
@ -176,9 +276,24 @@ export async function GET(request: Request, { params }: RouteParams) {
type: result.data.type,
status: result.data.status,
progress: result.data.progress ?? null,
output,
output: archivedOutput,
modelUrl: stableModelUrl,
previewImageUrl,
previewImageUrl: stablePreviewImageUrl,
assetPayload: {
providerId: config.providerId || null,
providerName: config.providerName || null,
storageArchivedAt,
sourceModelUrl:
modelUrl && stableModelUrl && modelUrl !== stableModelUrl
? modelUrl
: null,
sourcePreviewImageUrl:
previewImageUrl &&
stablePreviewImageUrl &&
previewImageUrl !== stablePreviewImageUrl
? previewImageUrl
: null,
},
errorCode: result.data.error_code ?? null,
errorMessage: result.data.error_msg ?? null,
},

View File

@ -12,7 +12,7 @@ import type {
} from "@/lib/worldlabs/types";
export const runtime = "nodejs";
export const maxDuration = 60;
export const maxDuration = 120;
type ArchiveCacheItem = {
sourceUrl: string;
@ -35,12 +35,71 @@ function getArchiveInflight() {
return globalState.__worldLabsAssetArchiveInflight as Map<string, Promise<string | null>>;
}
function resolveRemoteFileExtension(url: string, fallback: string) {
function isLikelyArchivedStorageUrl(parsed: URL) {
const hostname = parsed.hostname.toLowerCase();
return hostname.includes("blob.vercel-storage.com");
}
function shouldArchiveRemoteAssetUrl(sourceUrl?: string | null) {
const raw = String(sourceUrl || "").trim();
if (!raw) return false;
try {
const parsed = new URL(raw);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
return !isLikelyArchivedStorageUrl(parsed);
} catch {
return false;
}
}
function resolveExtensionFromContentType(contentType: string | null) {
const normalized = String(contentType || "").toLowerCase().split(";")[0]?.trim() || "";
const map: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"model/gltf-binary": "glb",
"model/gltf+json": "gltf",
"model/stl": "stl",
"model/3mf": "3mf",
"model/vnd.usdz+zip": "usdz",
"application/zip": "zip",
"application/json": "json",
};
return map[normalized] || "";
}
function resolveRemoteFileExtension(url: string, fallback: string, contentType?: string | null) {
const contentTypeExtension = resolveExtensionFromContentType(contentType || null);
if (contentTypeExtension) return contentTypeExtension;
try {
const parsed = new URL(url);
const pathname = parsed.pathname.toLowerCase();
const ext = pathname.split(".").pop() || "";
return ext || fallback;
const allowed = new Set([
"glb",
"gltf",
"usdz",
"fbx",
"obj",
"stl",
"3mf",
"ply",
"spz",
"splat",
"ksplat",
"zip",
"world",
"json",
"jpg",
"jpeg",
"png",
"webp",
"gif",
]);
return allowed.has(ext) ? ext : fallback;
} catch {
return fallback;
}
@ -51,9 +110,10 @@ async function archiveRemoteAssetIfNeeded(params: {
sourceUrl?: string | null;
filenamePrefix: string;
fallbackExtension: string;
skipHtml?: boolean;
}) {
const sourceUrl = String(params.sourceUrl || "").trim();
if (!sourceUrl) return null;
if (!shouldArchiveRemoteAssetUrl(sourceUrl)) return sourceUrl || null;
const cache = getArchiveCache();
const cached = cache.get(params.cacheKey);
@ -81,12 +141,29 @@ async function archiveRemoteAssetIfNeeded(params: {
throw new Error(`下载 World Labs 资源失败(HTTP ${upstream.status})`);
}
const contentType = upstream.headers.get("content-type");
if (params.skipHtml && /\btext\/html\b/i.test(String(contentType || ""))) {
console.warn("[WorldLabsArchive] skip html viewer url", {
cacheKey: params.cacheKey,
sourceUrl,
});
cache.set(params.cacheKey, {
sourceUrl,
archivedUrl: sourceUrl,
});
return sourceUrl;
}
const assetBuffer = await upstream.arrayBuffer();
if (!assetBuffer || assetBuffer.byteLength <= 0) {
throw new Error("World Labs 资源文件为空");
}
const ext = resolveRemoteFileExtension(sourceUrl, params.fallbackExtension);
const ext = resolveRemoteFileExtension(
sourceUrl,
params.fallbackExtension,
contentType
);
const filename = `${params.filenamePrefix}.${ext}`;
const uploaded = await uploadFile(filename, assetBuffer);
cache.set(params.cacheKey, {
@ -171,6 +248,20 @@ function extractWorldFromOperation(operation: WorldLabsOperation) {
return null;
}
function hasUsableWorldAssets(world?: WorldLabsWorld | null) {
const assets = world?.assets || null;
if (!assets) return false;
const spzUrls = assets.splats?.spz_urls;
return Boolean(
getStringValue(assets.thumbnail_url) ||
getStringValue(assets.imagery?.pano_url) ||
getStringValue(assets.mesh?.collider_mesh_url) ||
(spzUrls &&
typeof spzUrls === "object" &&
Object.values(spzUrls).some((value) => typeof value === "string" && value.trim()))
);
}
function buildAssetPayload(
world: WorldLabsWorld,
worldModelAliases: Awaited<
@ -258,11 +349,18 @@ export async function GET(request: Request, { params }: RouteParams) {
let world = extractWorldFromOperation(operation);
const worldId = extractWorldId(operation);
if (!world && worldId) {
world = await worldLabsRequest<WorldLabsWorld>(config, {
if ((!world || !hasUsableWorldAssets(world)) && worldId) {
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
method: "GET",
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
});
world = world
? {
...world,
...detailedWorld,
assets: detailedWorld.assets || world.assets || null,
}
: detailedWorld;
}
if (!world) {
@ -270,10 +368,18 @@ export async function GET(request: Request, { params }: RouteParams) {
}
const assets = world.assets || null;
const rawWorldUrl = getStringValue(world.world_marble_url);
const rawMeshUrl = getStringValue(assets?.mesh?.collider_mesh_url);
const rawThumbnailUrl = getStringValue(assets?.thumbnail_url);
const rawPanoUrl = getStringValue(assets?.imagery?.pano_url);
const stableWorldUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:world`,
sourceUrl: rawWorldUrl,
filenamePrefix: `worldlabs-world-${normalizedTaskId}`,
fallbackExtension: "world",
skipHtml: true,
});
const stableModelUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
sourceUrl: rawMeshUrl,
@ -308,6 +414,24 @@ export async function GET(request: Request, { params }: RouteParams) {
providerName: config.providerName || null,
...buildAssetPayload(world, modelAliasConfig.worldModels, assets),
};
const storageArchivedAt =
[
[rawWorldUrl, stableWorldUrl],
[rawMeshUrl, stableModelUrl],
[rawThumbnailUrl, stableThumbnailUrl],
[rawPanoUrl, stablePanoUrl],
].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) ||
Boolean(
stableSpzUrls &&
assetPayload.spzUrls &&
Object.entries(stableSpzUrls).some(
([key, value]) =>
typeof assetPayload.spzUrls?.[key] === "string" &&
assetPayload.spzUrls[key] !== value
)
)
? new Date().toISOString()
: null;
return NextResponse.json({
success: true,
@ -320,15 +444,33 @@ export async function GET(request: Request, { params }: RouteParams) {
modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
worldId: assetPayload.worldId,
worldUrl: assetPayload.worldUrl,
worldUrl: stableWorldUrl || assetPayload.worldUrl,
panoUrl: stablePanoUrl,
caption: assetPayload.caption,
assetPayload: {
...assetPayload,
worldUrl: stableWorldUrl || assetPayload.worldUrl,
thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl,
panoUrl: stablePanoUrl || assetPayload.panoUrl,
meshUrl: stableModelUrl || assetPayload.meshUrl,
spzUrls: stableSpzUrls || assetPayload.spzUrls,
storageArchivedAt,
sourceWorldUrl:
rawWorldUrl && stableWorldUrl && rawWorldUrl !== stableWorldUrl
? rawWorldUrl
: null,
sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
? rawMeshUrl
: null,
sourceThumbnailUrl:
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
? rawThumbnailUrl
: null,
sourcePanoUrl:
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
? rawPanoUrl
: null,
},
},
});

View File

@ -1353,6 +1353,11 @@ export default function CanvasModel3DPage() {
Awaited<ReturnType<typeof worldLabsGenerationService.getTask>>["data"]
>)
: null;
const tripoResultData = !isWorldTask
? (result.data as NonNullable<
Awaited<ReturnType<typeof model3DGenerationService.getTask>>["data"]
>)
: null;
const nextRecord: TaskRecord = {
...currentTask,
status,
@ -1367,7 +1372,12 @@ export default function CanvasModel3DPage() {
assetPayload:
isWorldTask
? worldResultData?.assetPayload ?? currentTask.assetPayload ?? null
: currentTask.assetPayload ?? null,
: tripoResultData?.assetPayload
? {
...(currentTask.assetPayload || {}),
...tripoResultData.assetPayload,
}
: currentTask.assetPayload ?? null,
error:
status === "failed" ? result.data.errorMessage || "任务失败" : null,
};

View File

@ -288,7 +288,8 @@ export function CanvasWorkspace({
return () => window.removeEventListener('resize', updateSize);
}, []);
// macOS 触控板横向双指滑动会触发浏览器前进/后退,必须使用非 passive 监听阻止默认行为
// Browser zoom/history gestures can fire before React handlers if nested viewers stop propagation.
// Capture-phase non-passive handling keeps wheel gestures scoped to the canvas.
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
@ -299,9 +300,14 @@ export function CanvasWorkspace({
}
};
container.addEventListener("wheel", preventTrackpadHistorySwipe, { passive: false });
const wheelListenerOptions: AddEventListenerOptions = {
capture: true,
passive: false,
};
container.addEventListener("wheel", preventTrackpadHistorySwipe, wheelListenerOptions);
return () => {
container.removeEventListener("wheel", preventTrackpadHistorySwipe);
container.removeEventListener("wheel", preventTrackpadHistorySwipe, wheelListenerOptions);
};
}, []);

View File

@ -201,15 +201,21 @@ export function World3DNode({
[node.size.height, node.size.width]
);
const overlayChromeScale = useMemo(
() => clampNumber(getNodeContentScale(node.size, { width: 420, height: 320 }, 0.48), 0.48, 1),
[node.size.height, node.size.width]
() =>
clampNumber(
getNodeContentScale(node.size, { width: 420, height: 320 }, 0.48) *
Math.max(viewportZoom, 0.1),
0.1,
2.4
),
[node.size.height, node.size.width, viewportZoom]
);
const placeholderViewportScale = useMemo(
() =>
clampNumber(
placeholderSizeScale / Math.max(viewportZoom, 0.42),
0.72,
1.55
placeholderSizeScale * Math.max(viewportZoom, 0.1),
0.1,
2.4
),
[placeholderSizeScale, viewportZoom]
);

View File

@ -1064,24 +1064,13 @@ function WorldSplatViewer({
}, [loadState]);
if (!splatUrl) {
if (worldUrl) {
return (
<div className="h-full w-full overflow-hidden bg-zinc-950">
<iframe
allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking"
allowFullScreen
className="h-full w-full border-0"
referrerPolicy="no-referrer-when-downgrade"
src={worldUrl}
title="3D 世界在线预览"
/>
</div>
);
}
return (
<WorldEmptyState
description="当前结果没有返回可用的世界数据。"
description={
worldUrl
? "官方在线预览页不能稳定嵌入画布,正在等待可本地渲染的 World 资产。"
: "当前结果没有返回可用的世界数据。"
}
icon={Sparkles}
previewImageUrl={previewImageUrl}
title="准备加载 World 视图"
@ -1090,24 +1079,13 @@ function WorldSplatViewer({
}
if (loadState === "error") {
if (worldUrl) {
return (
<div className="h-full w-full overflow-hidden bg-zinc-950">
<iframe
allow="accelerometer; autoplay; clipboard-read; clipboard-write; fullscreen; gyroscope; xr-spatial-tracking"
allowFullScreen
className="h-full w-full border-0"
referrerPolicy="no-referrer-when-downgrade"
src={worldUrl}
title="3D 世界在线预览"
/>
</div>
);
}
return (
<WorldEmptyState
description="当前世界数据暂时无法加载,可以切换到图像或 Mesh 视图继续查看。"
description={
worldUrl
? "World 资产加载失败,已避免退回不稳定的官方 iframe。可以切换到图像或 Mesh 视图继续查看。"
: "当前世界数据暂时无法加载,可以切换到图像或 Mesh 视图继续查看。"
}
icon={AlertCircle}
previewImageUrl={previewImageUrl}
title="World 视图加载失败"

View File

@ -73,6 +73,18 @@ function getContentType(filename: string): string {
otf: "font/otf",
woff: "font/woff",
woff2: "font/woff2",
glb: "model/gltf-binary",
gltf: "model/gltf+json",
usdz: "model/vnd.usdz+zip",
fbx: "application/octet-stream",
obj: "text/plain",
stl: "model/stl",
"3mf": "model/3mf",
ply: "application/octet-stream",
spz: "application/octet-stream",
splat: "application/octet-stream",
ksplat: "application/octet-stream",
zip: "application/zip",
};
return types[ext ?? ""] ?? "application/octet-stream";
}

View File

@ -1,6 +1,7 @@
// 画布服务层
import type {
CanvasProject,
CanvasProjectListItem,
CanvasState,
CanvasNode,
CanvasWorkspaceType,
@ -18,6 +19,11 @@ interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
pagination?: {
page: number;
limit: number;
hasMore: boolean;
};
}
export interface NormalizedCropRect {
@ -83,14 +89,17 @@ export interface EnhanceProRequestParams {
export const canvasService = {
// 获取画布列表
async getProjects(
workspaceType: CanvasWorkspaceType = 'canvas'
): Promise<ApiResponse<CanvasProject[]>> {
workspaceType: CanvasWorkspaceType = 'canvas',
options: { page?: number; limit?: number } = {}
): Promise<ApiResponse<CanvasProjectListItem[]>> {
try {
const query =
workspaceType && workspaceType !== 'canvas'
? `?workspaceType=${encodeURIComponent(workspaceType)}`
: '';
const response = await fetch(`/api/canvas${query}`);
const params = new URLSearchParams();
if (workspaceType && workspaceType !== 'canvas') {
params.set('workspaceType', workspaceType);
}
params.set('page', String(Math.max(1, Math.round(options.page || 1))));
params.set('limit', String(Math.max(1, Math.round(options.limit || 24))));
const response = await fetch(`/api/canvas?${params.toString()}`);
const data = await response.json();
return data;
} catch (error) {
@ -808,6 +817,7 @@ export const model3DGenerationService = {
output?: Record<string, any>;
modelUrl?: string | null;
previewImageUrl?: string | null;
assetPayload?: Record<string, any> | null;
errorCode?: number | null;
errorMessage?: string | null;
}>> {
@ -853,6 +863,13 @@ export type WorldLabsTaskAssetPayload = {
meshUrl?: string | null;
spzUrls?: Record<string, string> | null;
semanticsMetadata?: Record<string, any> | null;
storageArchivedAt?: string | null;
sourceModelUrl?: string | null;
sourcePreviewImageUrl?: string | null;
sourceWorldUrl?: string | null;
sourceMeshUrl?: string | null;
sourceThumbnailUrl?: string | null;
sourcePanoUrl?: string | null;
};
export const worldLabsGenerationService = {