fix: stabilize 3d world canvas assets
This commit is contained in:
parent
b4529c860c
commit
314884dc77
@ -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,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@ -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]
|
||||
);
|
||||
|
||||
@ -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 视图加载失败"
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user