From d76b31952f73c1bc0ff23a3a805941e7ec4ff0af Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 4 Apr 2026 22:06:43 +0800 Subject: [PATCH] feat: add world labs 3d workspace mode --- .../settings/admin-settings-client.tsx | 179 +- app/api/admin/ai-providers/route.ts | 4 + app/api/canvas/[id]/model3d-assets/route.ts | 24 + app/api/canvas/world/tasks/[taskId]/route.ts | 300 +++ app/api/canvas/world/tasks/route.ts | 249 +++ app/api/canvas/world/upload/route.ts | 160 ++ app/canvas/[id]/model3d/page.tsx | 1791 +++++++++++++---- components/canvas/nodes/Model3DNode.tsx | 5 +- lib/ai/provider-defaults.ts | 1 + lib/db/migrate.ts | 3 +- .../0063_add_worldlabs_3d_assets.sql | 5 + lib/db/migrations/meta/_journal.json | 7 + lib/db/queries.ts | 4 +- lib/db/schema.ts | 4 +- lib/worldlabs/client.ts | 117 ++ lib/worldlabs/server.ts | 50 + lib/worldlabs/types.ts | 77 + services/canvas/index.ts | 75 + 18 files changed, 2678 insertions(+), 377 deletions(-) create mode 100644 app/api/canvas/world/tasks/[taskId]/route.ts create mode 100644 app/api/canvas/world/tasks/route.ts create mode 100644 app/api/canvas/world/upload/route.ts create mode 100644 lib/db/migrations/0063_add_worldlabs_3d_assets.sql create mode 100644 lib/worldlabs/client.ts create mode 100644 lib/worldlabs/server.ts create mode 100644 lib/worldlabs/types.ts diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 01847df77..b58451c29 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -188,7 +188,7 @@ type ToolConfig = { type AiProvider = { id: string; name: string; - type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "runway" | "volcano-multimodal"; + type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "runway" | "volcano-multimodal"; baseUrl: string; apiKey: string; isEnabled: boolean; @@ -510,6 +510,7 @@ const PROVIDER_TYPES = [ { value: "openai-image", label: "OpenAI 图片兼容" }, { value: "midjourney", label: "Midjourney" }, { value: "tripo-3d", label: "Tripo3D 模型生成" }, + { value: "worldlabs-3d", label: "World Labs 3D 世界生成" }, { value: "runway", label: "Runway API" }, { value: "zhipu", label: "智普官方 4.7" }, { value: "volcano-multimodal", label: "火山引擎多模态" }, @@ -537,6 +538,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record = { midjourney: "https://your-midjourney-api.example.com", "fal-video": "https://fal.run", "tripo-3d": "https://api.tripo3d.ai/v2/openapi", + "worldlabs-3d": "https://api.worldlabs.ai", runway: "https://api.dev.runwayml.com", zhipu: "https://open.bigmodel.cn/api/paas/v4", "volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3", @@ -1096,6 +1098,13 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({ apiKey: "", isEnabled: true, }); + const [worldlabs3dConfig, setWorldlabs3dConfig] = useState({ + providerId: "", + name: "World Labs", + baseUrl: getProviderDefaultBaseUrl("worldlabs-3d"), + apiKey: "", + isEnabled: true, + }); const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({ providerId: "", modelEntryId: "", @@ -1463,6 +1472,31 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({ }); }, [providersData?.providers]); + useEffect(() => { + const worldlabsProvider = (providersData?.providers || []).find( + (provider) => provider.type === "worldlabs-3d" + ); + if (worldlabsProvider) { + setWorldlabs3dConfig({ + providerId: worldlabsProvider.id, + name: worldlabsProvider.name || "World Labs", + baseUrl: + worldlabsProvider.baseUrl || getProviderDefaultBaseUrl("worldlabs-3d"), + apiKey: "", + isEnabled: worldlabsProvider.isEnabled, + }); + return; + } + + setWorldlabs3dConfig({ + providerId: "", + name: "World Labs", + baseUrl: getProviderDefaultBaseUrl("worldlabs-3d"), + apiKey: "", + isEnabled: true, + }); + }, [providersData?.providers]); + // 导航链接状态 const [navLinkForm, setNavLinkForm] = useState({ id: "", @@ -1736,7 +1770,7 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({ const [providerForm, setProviderForm] = useState({ id: "", name: "", - type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "runway" | "volcano-multimodal", + type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "runway" | "volcano-multimodal", baseUrl: "", apiKey: "", isEnabled: true, @@ -3250,6 +3284,58 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({ } }; + const handleSaveWorldlabs3dConfig = async () => { + try { + const trimmedName = worldlabs3dConfig.name.trim() || "World Labs"; + const trimmedBaseUrl = + worldlabs3dConfig.baseUrl.trim() || + getProviderDefaultBaseUrl("worldlabs-3d"); + const hasProvider = Boolean(worldlabs3dConfig.providerId); + + if (!hasProvider && !worldlabs3dConfig.apiKey.trim()) { + toast.error("请填写 World Labs API Key"); + return; + } + + const requestData: Record = hasProvider + ? { + id: worldlabs3dConfig.providerId, + name: trimmedName, + type: "worldlabs-3d", + baseUrl: trimmedBaseUrl, + isEnabled: worldlabs3dConfig.isEnabled, + } + : { + name: trimmedName, + type: "worldlabs-3d", + baseUrl: trimmedBaseUrl, + apiKey: worldlabs3dConfig.apiKey.trim(), + isEnabled: worldlabs3dConfig.isEnabled, + }; + + if (hasProvider && worldlabs3dConfig.apiKey.trim()) { + requestData.apiKey = worldlabs3dConfig.apiKey.trim(); + } + + const response = await fetch("/api/admin/ai-providers", { + method: hasProvider ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestData), + }); + + if (!response.ok) { + throw new Error("保存失败"); + } + + toast.success("World Labs 配置保存成功"); + setWorldlabs3dConfig((prev) => ({ ...prev, apiKey: "" })); + mutateProviders(); + } catch (error) { + console.error("保存 World Labs 配置失败:", error); + toast.error("保存 World Labs 配置失败"); + } + }; + const handleSaveMoonshotDetailAgentConfig = async () => { try { const trimmedBaseUrl = @@ -4728,6 +4814,7 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({ "openai-image": "bg-green-600", "midjourney": "bg-fuchsia-600", "tripo-3d": "bg-teal-500", + "worldlabs-3d": "bg-sky-600", "openai": "bg-green-500", "zhipu": "bg-lime-500", "volcano-multimodal": "bg-fuchsia-500", @@ -9375,6 +9462,94 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({ + + + World Labs API 配置 + + 配置无限画布 3D 世界模式使用的 World Labs 接口,保存后写入数据库 AI 提供商配置。 + + + +
+ + + setWorldlabs3dConfig({ + ...worldlabs3dConfig, + name: e.target.value, + }) + } + placeholder="World Labs" + /> +
+
+ + + setWorldlabs3dConfig({ + ...worldlabs3dConfig, + baseUrl: e.target.value, + }) + } + placeholder={getProviderDefaultBaseUrl("worldlabs-3d")} + /> +
+ 默认:{getProviderDefaultBaseUrl("worldlabs-3d")} +
+
+
+ + + setWorldlabs3dConfig({ + ...worldlabs3dConfig, + apiKey: e.target.value, + }) + } + placeholder={ + worldlabs3dConfig.providerId + ? "留空则不修改当前 Key" + : "请输入 World Labs API Key" + } + /> +
+
+
+ +
+ 关闭后 3D 世界模式接口将不可用 +
+
+
+ + setWorldlabs3dConfig({ + ...worldlabs3dConfig, + isEnabled: checked, + }) + } + /> + + {worldlabs3dConfig.isEnabled ? "已开启" : "已关闭"} + +
+
+ +
+
+ {/* 抠图API配置 */} diff --git a/app/api/admin/ai-providers/route.ts b/app/api/admin/ai-providers/route.ts index 0ade5f1aa..170608b62 100644 --- a/app/api/admin/ai-providers/route.ts +++ b/app/api/admin/ai-providers/route.ts @@ -30,6 +30,7 @@ const providerTypes = [ "fal-video", "zhipu", "tripo-3d", + "worldlabs-3d", "runway", "volcano-multimodal", ] as const; @@ -62,6 +63,7 @@ const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1"; const BFL_IMAGE_BASE_URL = "https://api.bfl.ai"; const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com"; const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi"; +const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai"; const RUNWAY_BASE_URL = "https://api.dev.runwayml.com"; const RUNWAY_API_KEY_PATTERN = /^key_[0-9a-f]{128}$/; @@ -208,6 +210,8 @@ export async function POST(request: Request) { ? data.baseUrl || MIDJOURNEY_BASE_URL : data.type === "tripo-3d" ? data.baseUrl || TRIPO_3D_BASE_URL + : data.type === "worldlabs-3d" + ? data.baseUrl || WORLDLABS_3D_BASE_URL : data.type === "runway" ? data.baseUrl || RUNWAY_BASE_URL : data.baseUrl, diff --git a/app/api/canvas/[id]/model3d-assets/route.ts b/app/api/canvas/[id]/model3d-assets/route.ts index 14ffa467d..efa9c7aef 100644 --- a/app/api/canvas/[id]/model3d-assets/route.ts +++ b/app/api/canvas/[id]/model3d-assets/route.ts @@ -19,6 +19,10 @@ type Model3DTaskMode = | "image" | "multiview" | "batch" + | "world-text" + | "world-image" + | "world-multi-image" + | "world-video" | "convert" | "refine" | "texture" @@ -46,6 +50,10 @@ const TASK_MODE_SET = new Set([ "image", "multiview", "batch", + "world-text", + "world-image", + "world-multi-image", + "world-video", "convert", "refine", "texture", @@ -75,6 +83,16 @@ function readOptionalText(input: unknown): string | null { return normalized ? normalized : null; } +function readProvider(input: unknown): "tripo-3d" | "worldlabs-3d" { + return String(input || "").trim() === "worldlabs-3d" ? "worldlabs-3d" : "tripo-3d"; +} + +function readJsonObject(input: unknown): Record | null { + return input && typeof input === "object" && !Array.isArray(input) + ? (input as Record) + : null; +} + function readProgress(input: unknown): number | null { if (input === undefined || input === null || input === "") return null; const value = Number(input); @@ -165,6 +183,7 @@ export async function POST( const status = normalizeTaskStatus(rawAsset.status); const mode = normalizeTaskMode(rawAsset.mode); + const provider = readProvider(rawAsset.provider); const prompt = String(rawAsset.prompt || "").trim(); const modelVersion = String(rawAsset.modelVersion || "").trim(); const progress = readProgress(rawAsset.progress); @@ -173,6 +192,7 @@ export async function POST( const error = readOptionalText(rawAsset.error); const sourceTaskId = readOptionalText(rawAsset.sourceTaskId); const exportFormat = readOptionalText(rawAsset.exportFormat); + const assetPayload = readJsonObject(rawAsset.assetPayload); const createdAt = readDate(rawAsset.createdAt); const now = new Date(); @@ -182,6 +202,7 @@ export async function POST( userId: session.user.id, projectId, taskId, + provider, status, mode, prompt, @@ -192,12 +213,14 @@ export async function POST( error, sourceTaskId, exportFormat, + assetPayload, createdAt: createdAt || now, }) .onConflictDoUpdate({ target: [model3dAsset.projectId, model3dAsset.taskId], set: { userId: session.user.id, + provider, status, mode, prompt, @@ -208,6 +231,7 @@ export async function POST( error, sourceTaskId, exportFormat, + assetPayload, updatedAt: now, }, }) diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts new file mode 100644 index 000000000..8a2cae585 --- /dev/null +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -0,0 +1,300 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { uploadFile } from "@/lib/storage/service"; +import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client"; +import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server"; +import type { + WorldLabsOperation, + WorldLabsWorld, + WorldLabsWorldAssets, +} from "@/lib/worldlabs/types"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type ArchiveCacheItem = { + sourceUrl: string; + archivedUrl: string; +}; + +function getArchiveCache() { + const globalState = globalThis as any; + if (!globalState.__worldLabsAssetArchiveCache) { + globalState.__worldLabsAssetArchiveCache = new Map(); + } + return globalState.__worldLabsAssetArchiveCache as Map; +} + +function getArchiveInflight() { + const globalState = globalThis as any; + if (!globalState.__worldLabsAssetArchiveInflight) { + globalState.__worldLabsAssetArchiveInflight = new Map>(); + } + return globalState.__worldLabsAssetArchiveInflight as Map>; +} + +function resolveRemoteFileExtension(url: string, fallback: string) { + try { + const parsed = new URL(url); + const pathname = parsed.pathname.toLowerCase(); + const ext = pathname.split(".").pop() || ""; + return ext || fallback; + } catch { + return fallback; + } +} + +async function archiveRemoteAssetIfNeeded(params: { + cacheKey: string; + sourceUrl?: string | null; + filenamePrefix: string; + fallbackExtension: string; +}) { + const sourceUrl = String(params.sourceUrl || "").trim(); + if (!sourceUrl) return null; + + const cache = getArchiveCache(); + const cached = cache.get(params.cacheKey); + if (cached && cached.sourceUrl === sourceUrl) { + return cached.archivedUrl; + } + + const inflight = getArchiveInflight(); + const existingTask = inflight.get(params.cacheKey); + if (existingTask) { + const result = await existingTask; + return result || sourceUrl; + } + + const archiveTask = (async () => { + try { + const upstream = await fetch(sourceUrl, { + method: "GET", + headers: { + "User-Agent": "AI-Chatbot-WorldLabs-Archiver/1.0", + }, + cache: "no-store", + }); + if (!upstream.ok) { + throw new Error(`下载 World Labs 资源失败(HTTP ${upstream.status})`); + } + + const assetBuffer = await upstream.arrayBuffer(); + if (!assetBuffer || assetBuffer.byteLength <= 0) { + throw new Error("World Labs 资源文件为空"); + } + + const ext = resolveRemoteFileExtension(sourceUrl, params.fallbackExtension); + const filename = `${params.filenamePrefix}.${ext}`; + const uploaded = await uploadFile(filename, assetBuffer); + cache.set(params.cacheKey, { + sourceUrl, + archivedUrl: uploaded.url, + }); + return uploaded.url; + } catch (error) { + console.error("[WorldLabsArchive] failed", { + cacheKey: params.cacheKey, + sourceUrl, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } finally { + inflight.delete(params.cacheKey); + } + })(); + + inflight.set(params.cacheKey, archiveTask); + const archivedUrl = await archiveTask; + return archivedUrl || sourceUrl; +} + +function getStringValue(...values: unknown[]) { + for (const value of values) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return null; +} + +function getObjectValue(value: unknown) { + return value && typeof value === "object" ? (value as Record) : null; +} + +function extractWorldId(operation: WorldLabsOperation) { + const response = getObjectValue(operation.response); + const metadata = getObjectValue(operation.metadata); + return getStringValue( + response?.world_id, + response?.worldId, + metadata?.world_id, + metadata?.worldId + ); +} + +function extractWorldFromOperation(operation: WorldLabsOperation) { + const response = operation.response; + if (response && typeof response === "object" && "world_marble_url" in (response as Record)) { + return response as WorldLabsWorld; + } + return null; +} + +function buildAssetPayload(world: WorldLabsWorld, assets?: WorldLabsWorldAssets | null) { + return { + worldId: getStringValue(world.world_id), + worldUrl: getStringValue(world.world_marble_url), + displayName: getStringValue(world.display_name), + model: getStringValue(world.model), + caption: getStringValue(assets?.caption), + thumbnailUrl: getStringValue(assets?.thumbnail_url), + panoUrl: getStringValue(assets?.imagery?.pano_url), + meshUrl: getStringValue(assets?.mesh?.collider_mesh_url), + spzUrls: + assets?.splats?.spz_urls && typeof assets.splats.spz_urls === "object" + ? assets.splats.spz_urls + : null, + semanticsMetadata: + assets?.splats?.semantics_metadata && + typeof assets.splats.semantics_metadata === "object" + ? assets.splats.semantics_metadata + : null, + }; +} + +interface RouteParams { + params: Promise<{ taskId: string }>; +} + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const { taskId } = await params; + const normalizedTaskId = String(taskId || "").trim(); + if (!normalizedTaskId) { + return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 }); + } + + const config = await getWorldLabsProviderConfig(); + const operation = await worldLabsRequest(config, { + method: "GET", + path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`, + }); + + if (!operation.done) { + return NextResponse.json({ + success: true, + data: { + taskId: normalizedTaskId, + type: "world", + status: "running", + progress: null, + output: operation.response ?? null, + assetPayload: { + operationMetadata: operation.metadata ?? null, + }, + }, + }); + } + + if (operation.error?.message) { + return NextResponse.json({ + success: true, + data: { + taskId: normalizedTaskId, + type: "world", + status: "failed", + progress: null, + errorMessage: operation.error.message, + output: operation.response ?? null, + }, + }); + } + + let world = extractWorldFromOperation(operation); + const worldId = extractWorldId(operation); + if (!world && worldId) { + world = await worldLabsRequest(config, { + method: "GET", + path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`, + }); + } + + if (!world) { + throw new Error("World Labs 任务已完成,但未返回 world 数据"); + } + + const assets = world.assets || null; + const rawMeshUrl = getStringValue(assets?.mesh?.collider_mesh_url); + const rawThumbnailUrl = getStringValue(assets?.thumbnail_url); + const rawPanoUrl = getStringValue(assets?.imagery?.pano_url); + + const stableModelUrl = await archiveRemoteAssetIfNeeded({ + cacheKey: `worldlabs:${normalizedTaskId}:mesh`, + sourceUrl: rawMeshUrl, + filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`, + fallbackExtension: "glb", + }); + const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({ + cacheKey: `worldlabs:${normalizedTaskId}:thumb`, + sourceUrl: rawThumbnailUrl, + filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`, + fallbackExtension: "jpg", + }); + const stablePanoUrl = await archiveRemoteAssetIfNeeded({ + cacheKey: `worldlabs:${normalizedTaskId}:pano`, + sourceUrl: rawPanoUrl, + filenamePrefix: `worldlabs-pano-${normalizedTaskId}`, + fallbackExtension: "jpg", + }); + + const assetPayload = buildAssetPayload(world, assets); + + return NextResponse.json({ + success: true, + data: { + taskId: normalizedTaskId, + type: "world", + status: "success", + progress: 100, + output: operation.response ?? null, + modelUrl: stableModelUrl, + previewImageUrl: stableThumbnailUrl || stablePanoUrl || null, + worldId: assetPayload.worldId, + worldUrl: assetPayload.worldUrl, + panoUrl: stablePanoUrl, + caption: assetPayload.caption, + assetPayload: { + ...assetPayload, + thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl, + panoUrl: stablePanoUrl || assetPayload.panoUrl, + meshUrl: stableModelUrl || assetPayload.meshUrl, + }, + }, + }); + } catch (error) { + if (error instanceof WorldLabsApiError) { + return NextResponse.json( + { + success: false, + error: error.message, + payload: error.payload, + }, + { status: error.httpStatus || 502 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "查询 World Labs 任务失败", + }, + { status: 500 } + ); + } +} diff --git a/app/api/canvas/world/tasks/route.ts b/app/api/canvas/world/tasks/route.ts new file mode 100644 index 000000000..9fa04fc7c --- /dev/null +++ b/app/api/canvas/world/tasks/route.ts @@ -0,0 +1,249 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { + consumeUserPoints, + getPointsSystemConfig, + getUserById, +} from "@/lib/db/queries"; +import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client"; +import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server"; +import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type WorldTaskMode = "text" | "image" | "multi-image" | "video"; + +type CreateWorldTaskRequest = { + mode?: WorldTaskMode; + displayName?: string; + prompt?: string; + model?: WorldLabsModel | string; + disableRecaption?: boolean; + isPano?: boolean; + makePublic?: boolean; + reconstructImages?: boolean; + mediaAssetId?: string; + mediaAssets?: Array<{ + mediaAssetId?: string; + azimuth?: number | null; + }>; +}; + +function readOptionalTrimmedText(input: unknown) { + const value = String(input || "").trim(); + return value || null; +} + +function shouldCharge3DPoints(mode: WorldTaskMode) { + return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video"; +} + +function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) { + const textPrompt = readOptionalTrimmedText(body.prompt); + + if (mode === "text") { + if (!textPrompt) { + throw new Error("请输入世界描述"); + } + return { + type: "text", + text_prompt: textPrompt, + ...(body.disableRecaption !== undefined + ? { disable_recaption: body.disableRecaption } + : {}), + }; + } + + if (mode === "image") { + const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId); + if (!mediaAssetId) { + throw new Error("图生世界缺少 mediaAssetId"); + } + return { + type: "image", + image_prompt: { + source: "media_asset", + media_asset_id: mediaAssetId, + }, + ...(textPrompt ? { text_prompt: textPrompt } : {}), + ...(body.disableRecaption !== undefined + ? { disable_recaption: body.disableRecaption } + : {}), + ...(body.isPano !== undefined ? { is_pano: body.isPano } : {}), + }; + } + + if (mode === "multi-image") { + const mediaAssets = Array.isArray(body.mediaAssets) ? body.mediaAssets : []; + const multiImagePrompt = mediaAssets + .map((item) => { + const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId); + if (!mediaAssetId) return null; + const azimuth = + item?.azimuth === null || item?.azimuth === undefined + ? null + : Number.isFinite(Number(item.azimuth)) + ? Number(item.azimuth) + : null; + return { + ...(azimuth === null ? {} : { azimuth }), + content: { + source: "media_asset", + media_asset_id: mediaAssetId, + }, + }; + }) + .filter(Boolean); + + if (multiImagePrompt.length < 2) { + throw new Error("多图生成世界至少需要 2 张图片"); + } + + return { + type: "multi-image", + multi_image_prompt: multiImagePrompt, + ...(textPrompt ? { text_prompt: textPrompt } : {}), + ...(body.disableRecaption !== undefined + ? { disable_recaption: body.disableRecaption } + : {}), + ...(body.reconstructImages !== undefined + ? { reconstruct_images: body.reconstructImages } + : {}), + }; + } + + const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId); + if (!mediaAssetId) { + throw new Error("视频生成世界缺少 mediaAssetId"); + } + + return { + type: "video", + video_prompt: { + source: "media_asset", + media_asset_id: mediaAssetId, + }, + ...(textPrompt ? { text_prompt: textPrompt } : {}), + }; +} + +export async function POST(request: Request) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const body = (await request.json()) as CreateWorldTaskRequest; + const mode = String(body.mode || "").trim() as WorldTaskMode; + if (!["text", "image", "multi-image", "video"].includes(mode)) { + return NextResponse.json({ success: false, error: "缺少有效的 mode" }, { status: 400 }); + } + + const pointsConfig = await getPointsSystemConfig(); + const shouldChargePoints = shouldCharge3DPoints(mode); + const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3D)) + ? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3D))) + : 0; + + if ( + shouldChargePoints && + pointsConfig.enabled && + unitCost > 0 && + pointsConfig.blockOnInsufficient !== false + ) { + const userData = await getUserById({ id: session.user.id }); + if (!userData?.isHighVip) { + const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0)); + if (currentBalance < unitCost) { + return NextResponse.json( + { + success: false, + error: "积分不足", + code: "insufficient_points", + message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`, + balance: currentBalance, + expectedCost: unitCost, + unitCost, + }, + { status: 402 } + ); + } + } + } + + const config = await getWorldLabsProviderConfig(); + const displayName = + readOptionalTrimmedText(body.displayName) || + readOptionalTrimmedText(body.prompt) || + `World ${new Date().toLocaleString("zh-CN")}`; + const worldPrompt = buildWorldPrompt(body, mode); + const result = await worldLabsRequest(config, { + method: "POST", + path: "/marble/v1/worlds:generate", + json: { + display_name: displayName, + model: readOptionalTrimmedText(body.model) || "marble-1.1", + permission: { + public: body.makePublic === true, + }, + world_prompt: worldPrompt, + }, + }); + + const taskId = String(result.operation_id || "").trim(); + if (!taskId) { + return NextResponse.json( + { + success: false, + error: "World Labs 返回缺少 operation_id", + raw: result, + }, + { status: 502 } + ); + } + + if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) { + try { + await consumeUserPoints({ + userId: session.user.id, + amount: unitCost, + reason: "model3d_generation", + modelId: "worldlabs-3d", + note: "3D世界生成消耗积分", + }); + } catch (error) { + console.error("[Points] Failed to consume World Labs 3D points:", error); + } + } + + return NextResponse.json({ + success: true, + data: { + taskId, + status: result.done ? "success" : "running", + raw: result, + }, + }); + } catch (error) { + if (error instanceof WorldLabsApiError) { + return NextResponse.json( + { + success: false, + error: error.message, + payload: error.payload, + }, + { status: error.httpStatus || 502 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "创建 World Labs 世界任务失败", + }, + { status: 500 } + ); + } +} diff --git a/app/api/canvas/world/upload/route.ts b/app/api/canvas/world/upload/route.ts new file mode 100644 index 000000000..81812e531 --- /dev/null +++ b/app/api/canvas/world/upload/route.ts @@ -0,0 +1,160 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client"; +import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server"; +import type { WorldLabsPrepareUploadResponse } from "@/lib/worldlabs/types"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +const ALLOWED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); +const ALLOWED_VIDEO_TYPES = new Set([ + "video/mp4", + "video/quicktime", + "video/webm", + "video/x-m4v", +]); +const MAX_IMAGE_UPLOAD_SIZE = 20 * 1024 * 1024; +const MAX_VIDEO_UPLOAD_SIZE = 100 * 1024 * 1024; + +function guessExtension(file: File) { + const fromName = String(file.name || "") + .split(".") + .pop() + ?.trim() + .toLowerCase(); + if (fromName) return fromName; + + const byType: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "video/mp4": "mp4", + "video/quicktime": "mov", + "video/webm": "webm", + "video/x-m4v": "m4v", + }; + return byType[file.type] || "bin"; +} + +function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) { + return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim(); +} + +export async function POST(request: Request) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ success: false, error: "请上传文件" }, { status: 400 }); + } + + const requestedKind = String(formData.get("kind") || "").trim(); + const isImage = requestedKind + ? requestedKind === "image" + : ALLOWED_IMAGE_TYPES.has(file.type); + const isVideo = requestedKind + ? requestedKind === "video" + : ALLOWED_VIDEO_TYPES.has(file.type); + + if (!isImage && !isVideo) { + return NextResponse.json( + { success: false, error: "仅支持 PNG/JPEG/WEBP 图片或 MP4/MOV/WEBM 视频" }, + { status: 400 } + ); + } + + if (file.size <= 0) { + return NextResponse.json({ success: false, error: "文件不能为空" }, { status: 400 }); + } + + const maxSize = isImage ? MAX_IMAGE_UPLOAD_SIZE : MAX_VIDEO_UPLOAD_SIZE; + if (file.size > maxSize) { + return NextResponse.json( + { + success: false, + error: isImage ? "图片大小不能超过 20MB" : "视频大小不能超过 100MB", + }, + { status: 400 } + ); + } + + const config = await getWorldLabsProviderConfig(); + const extension = guessExtension(file); + const prepareUpload = await worldLabsRequest(config, { + method: "POST", + path: "/marble/v1/media-assets:prepare_upload", + json: { + file_name: file.name || `upload.${extension}`, + extension, + kind: isImage ? "image" : "video", + metadata: { + mime_type: file.type || null, + uploaded_from: "canvas_world_mode", + }, + }, + }); + + const mediaAssetId = normalizeMediaAssetId(prepareUpload); + const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim(); + if (!mediaAssetId || !uploadUrl) { + throw new Error("World Labs 返回的上传信息不完整"); + } + + const uploadHeaders = new Headers(); + uploadHeaders.set("Content-Type", file.type || "application/octet-stream"); + const requiredHeaders = prepareUpload.upload_info?.required_headers || {}; + for (const [headerName, headerValue] of Object.entries(requiredHeaders)) { + if (!headerName || headerValue === undefined || headerValue === null) continue; + uploadHeaders.set(headerName, String(headerValue)); + } + + const uploadMethod = String(prepareUpload.upload_info?.upload_method || "PUT").toUpperCase(); + const uploadResponse = await fetch(uploadUrl, { + method: uploadMethod === "PUT" ? "PUT" : uploadMethod, + headers: uploadHeaders, + body: file, + cache: "no-store", + }); + + if (!uploadResponse.ok) { + const uploadError = await uploadResponse.text().catch(() => ""); + throw new Error( + uploadError || `World Labs 文件上传失败(HTTP ${uploadResponse.status})` + ); + } + + return NextResponse.json({ + success: true, + data: { + mediaAssetId, + mediaAsset: prepareUpload.media_asset, + uploadInfo: prepareUpload.upload_info, + }, + }); + } catch (error) { + if (error instanceof WorldLabsApiError) { + return NextResponse.json( + { + success: false, + error: error.message, + payload: error.payload, + }, + { status: error.httpStatus || 502 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "上传 World Labs 媒体失败", + }, + { status: 500 } + ); + } +} diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index b2e3ee2cb..e591bc277 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -32,6 +32,11 @@ import { Settings2, Sparkles, Sun, + Globe, + ImageIcon, + Link2, + PanelTop, + Video, WandSparkles, X, } from "lucide-react"; @@ -86,12 +91,21 @@ import { canvasService, type Model3DTaskStatus, model3DGenerationService, + type WorldLabsTaskAssetPayload, + worldLabsGenerationService, } from "@/services/canvas"; import { canvasSelectors, useCanvasStore } from "@/store/canvas"; type GenerationMode = "text" | "image" | "multiview" | "batch"; +type WorkspaceMode = "model" | "world"; +type WorldGenerationMode = "text" | "image" | "multi-image" | "video"; +type TaskProvider = "tripo-3d" | "worldlabs-3d"; type TaskMode = | GenerationMode + | "world-text" + | "world-image" + | "world-multi-image" + | "world-video" | "convert" | "refine" | "texture" @@ -105,6 +119,7 @@ type TaskMode = type TaskRecord = { taskId: string; + provider: TaskProvider; status: Model3DTaskStatus; mode: TaskMode; prompt: string; @@ -116,10 +131,12 @@ type TaskRecord = { error?: string | null; sourceTaskId?: string | null; exportFormat?: string | null; + assetPayload?: WorldLabsTaskAssetPayload | null; }; type TaskRecordApiEntity = { taskId?: unknown; + provider?: unknown; status?: unknown; mode?: unknown; prompt?: unknown; @@ -130,6 +147,7 @@ type TaskRecordApiEntity = { error?: unknown; sourceTaskId?: unknown; exportFormat?: unknown; + assetPayload?: unknown; createdAt?: unknown; }; @@ -139,6 +157,13 @@ type MultiViewSlot = { previewUrl: string | null; }; +type WorldImageSlot = { + id: string; + file: File | null; + previewUrl: string | null; + azimuth: number; +}; + const MODEL_VERSION_OPTIONS = [ "Turbo-v1.0-20250506", "v3.1-20260211", @@ -148,6 +173,13 @@ const MODEL_VERSION_OPTIONS = [ "v1.4-20240625", ] as const; +const WORLDLABS_MODEL_OPTIONS = [ + "marble-1.1", + "marble-1.1-plus", + "marble-1.0", + "marble-1.0-draft", +] as const; + const ENV_PRESET_OPTIONS: Model3DViewerSettings["envPreset"][] = [ "city", "sunset", @@ -206,6 +238,10 @@ const TASK_MODE_SET = new Set([ "image", "multiview", "batch", + "world-text", + "world-image", + "world-multi-image", + "world-video", "convert", "refine", "texture", @@ -220,6 +256,8 @@ const TASK_MODE_SET = new Set([ const TASK_HISTORY_LIMIT = 40; const TASK_HISTORY_STORAGE_PREFIX = "canvas-model3d-task-history-v2"; +const WORLD_MULTI_IMAGE_DEFAULTS = [0, 90, 180, 270]; + function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus { switch (status) { case "queued": @@ -265,6 +303,15 @@ function createBatchImageSlots(count = 10): MultiViewSlot[] { })); } +function createWorldImageSlots(values = WORLD_MULTI_IMAGE_DEFAULTS): WorldImageSlot[] { + return values.map((azimuth, index) => ({ + id: `world_slot_${index + 1}`, + file: null, + previewUrl: null, + azimuth, + })); +} + function modeLabel(mode: TaskMode) { switch (mode) { case "text": @@ -275,6 +322,14 @@ function modeLabel(mode: TaskMode) { return "多视图3D"; case "batch": return "批量图生3D"; + case "world-text": + return "文生世界"; + case "world-image": + return "图生世界"; + case "world-multi-image": + return "多图世界"; + case "world-video": + return "视频世界"; case "convert": return "格式转换"; case "refine": @@ -325,6 +380,18 @@ function makeStorageKey(projectId: string) { return `${TASK_HISTORY_STORAGE_PREFIX}:${projectId}`; } +function normalizeTaskProvider(provider: unknown): TaskProvider { + return String(provider || "").trim() === "worldlabs-3d" ? "worldlabs-3d" : "tripo-3d"; +} + +function normalizeAssetPayload( + payload: unknown +): WorldLabsTaskAssetPayload | null { + return payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as WorldLabsTaskAssetPayload) + : null; +} + function normalizeTaskMode(mode: unknown): TaskMode { const value = String(mode || "").trim() as TaskMode; return TASK_MODE_SET.has(value) ? value : "text"; @@ -344,6 +411,7 @@ function normalizeTaskRecord(raw: TaskRecord): TaskRecord { return { ...raw, taskId: String(raw.taskId || "").trim(), + provider: normalizeTaskProvider(raw.provider), status: normalizeTaskStatus(raw.status), mode: normalizeTaskMode(raw.mode), prompt: String(raw.prompt || "").trim(), @@ -362,6 +430,7 @@ function normalizeTaskRecord(raw: TaskRecord): TaskRecord { error: raw.error ? String(raw.error).trim() : null, sourceTaskId: raw.sourceTaskId ? String(raw.sourceTaskId).trim() : null, exportFormat: raw.exportFormat ? String(raw.exportFormat).trim() : null, + assetPayload: normalizeAssetPayload(raw.assetPayload), }; } @@ -371,6 +440,7 @@ function mapApiEntityToTaskRecord(entity: TaskRecordApiEntity): TaskRecord | nul return normalizeTaskRecord({ taskId, + provider: normalizeTaskProvider(entity?.provider), status: normalizeTaskStatus(String(entity?.status || "")), mode: normalizeTaskMode(entity?.mode), prompt: String(entity?.prompt || "").trim(), @@ -387,6 +457,7 @@ function mapApiEntityToTaskRecord(entity: TaskRecordApiEntity): TaskRecord | nul error: entity?.error ? String(entity.error).trim() : null, sourceTaskId: entity?.sourceTaskId ? String(entity.sourceTaskId).trim() : null, exportFormat: entity?.exportFormat ? String(entity.exportFormat).trim() : null, + assetPayload: normalizeAssetPayload(entity?.assetPayload), }); } @@ -400,10 +471,37 @@ function taskRecordChanged(prev: TaskRecord, next: TaskRecord) { prev.prompt !== next.prompt || prev.modelVersion !== next.modelVersion || prev.sourceTaskId !== next.sourceTaskId || - prev.exportFormat !== next.exportFormat + prev.exportFormat !== next.exportFormat || + JSON.stringify(prev.assetPayload || null) !== JSON.stringify(next.assetPayload || null) || + prev.provider !== next.provider ); } +function inferWorkspaceModeFromTask(task: TaskRecord | null): WorkspaceMode { + return task?.provider === "worldlabs-3d" ? "world" : "model"; +} + +function taskMatchesWorkspace(task: TaskRecord, workspaceMode: WorkspaceMode) { + return workspaceMode === "world" + ? task.provider === "worldlabs-3d" + : task.provider !== "worldlabs-3d"; +} + +function worldModeToTaskMode(mode: WorldGenerationMode): TaskMode { + switch (mode) { + case "text": + return "world-text"; + case "image": + return "world-image"; + case "multi-image": + return "world-multi-image"; + case "video": + return "world-video"; + default: + return "world-image"; + } +} + export default function CanvasModel3DPage() { const router = useRouter(); const routeParams = useParams(); @@ -416,6 +514,7 @@ export default function CanvasModel3DPage() { const addNode = useCanvasStore((state) => state.addNode); const getCanvasState = useCanvasStore((state) => state.getCanvasState); + const [workspaceMode, setWorkspaceMode] = useState("model"); const [mode, setMode] = useState("text"); const [prompt, setPrompt] = useState(""); const [negativePrompt, setNegativePrompt] = useState(""); @@ -456,6 +555,27 @@ export default function CanvasModel3DPage() { const [batchImageSlots, setBatchImageSlots] = useState(() => createBatchImageSlots() ); + const [worldMode, setWorldMode] = useState("text"); + const [worldPrompt, setWorldPrompt] = useState(""); + const [worldDisplayName, setWorldDisplayName] = useState(""); + const [worldModel, setWorldModel] = useState< + (typeof WORLDLABS_MODEL_OPTIONS)[number] + >("marble-1.1"); + const [worldDisableRecaption, setWorldDisableRecaption] = useState(false); + const [worldIsPano, setWorldIsPano] = useState(false); + const [worldMakePublic, setWorldMakePublic] = useState(false); + const [worldReconstructImages, setWorldReconstructImages] = useState(false); + const [worldImageFile, setWorldImageFile] = useState(null); + const [worldImagePreviewUrl, setWorldImagePreviewUrl] = useState( + null + ); + const [worldImageSlots, setWorldImageSlots] = useState(() => + createWorldImageSlots() + ); + const [worldVideoFile, setWorldVideoFile] = useState(null); + const [worldVideoPreviewUrl, setWorldVideoPreviewUrl] = useState( + null + ); const [taskHistory, setTaskHistory] = useState([]); const [currentTask, setCurrentTask] = useState(null); @@ -589,6 +709,7 @@ export default function CanvasModel3DPage() { body: JSON.stringify({ asset: { taskId: task.taskId, + provider: task.provider, status: task.status, mode: task.mode, prompt: task.prompt, @@ -599,6 +720,7 @@ export default function CanvasModel3DPage() { error: task.error ?? null, sourceTaskId: task.sourceTaskId ?? null, exportFormat: task.exportFormat ?? null, + assetPayload: task.assetPayload ?? null, createdAt: task.createdAt, }, }), @@ -688,7 +810,9 @@ export default function CanvasModel3DPage() { const pending = migrated.find( (item) => !FINAL_TASK_STATUS.has(item.status) ); - setCurrentTask(pending || migrated[0] || null); + const initialTask = pending || migrated[0] || null; + setCurrentTask(initialTask); + setWorkspaceMode(inferWorkspaceModeFromTask(initialTask)); } }; @@ -716,6 +840,42 @@ export default function CanvasModel3DPage() { }; }, [referenceImage]); + useEffect(() => { + if (!worldImageFile) { + if (worldImagePreviewUrl) { + URL.revokeObjectURL(worldImagePreviewUrl); + } + setWorldImagePreviewUrl(null); + return; + } + const objectUrl = URL.createObjectURL(worldImageFile); + setWorldImagePreviewUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return objectUrl; + }); + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [worldImageFile]); + + useEffect(() => { + if (!worldVideoFile) { + if (worldVideoPreviewUrl) { + URL.revokeObjectURL(worldVideoPreviewUrl); + } + setWorldVideoPreviewUrl(null); + return; + } + const objectUrl = URL.createObjectURL(worldVideoFile); + setWorldVideoPreviewUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return objectUrl; + }); + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [worldVideoFile]); + useEffect(() => { multiViewSlotsRef.current = multiViewSlots; }, [multiViewSlots]); @@ -732,8 +892,11 @@ export default function CanvasModel3DPage() { for (const slot of batchImageSlotsRef.current) { if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl); } + for (const slot of worldImageSlots) { + if (slot.previewUrl) URL.revokeObjectURL(slot.previewUrl); + } }; - }, []); + }, [worldImageSlots]); const upsertTaskHistory = useCallback( (next: TaskRecord, options?: { persist?: boolean }) => { @@ -765,18 +928,49 @@ export default function CanvasModel3DPage() { return next; }, [isolatedLayerId, layerVisibility, viewerLayers]); + const visibleTaskHistory = useMemo( + () => taskHistory.filter((task) => taskMatchesWorkspace(task, workspaceMode)), + [taskHistory, workspaceMode] + ); + const currentModelUrl = currentTask?.modelUrl || null; const currentPreviewImage = currentTask?.previewImageUrl || - (mode === "image" ? referencePreviewUrl : null) || - (mode === "multiview" - ? multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl - : null) || - (mode === "batch" - ? batchImageSlots.find((slot) => slot.previewUrl)?.previewUrl - : null) || + (workspaceMode === "model" + ? mode === "image" + ? referencePreviewUrl + : mode === "multiview" + ? multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl + : mode === "batch" + ? batchImageSlots.find((slot) => slot.previewUrl)?.previewUrl + : null + : worldMode === "image" + ? worldImagePreviewUrl + : worldMode === "multi-image" + ? worldImageSlots.find((slot) => slot.previewUrl)?.previewUrl + : worldMode === "video" + ? worldVideoPreviewUrl + : null) || null; + useEffect(() => { + if (currentTask && taskMatchesWorkspace(currentTask, workspaceMode)) { + return; + } + + const fallbackTask = taskHistory.find((task) => + taskMatchesWorkspace(task, workspaceMode) + ); + if (fallbackTask) { + setCurrentTask(fallbackTask); + return; + } + + if (currentTask) { + setCurrentTask(null); + } + }, [currentTask, taskHistory, workspaceMode]); + // Polling logic useEffect(() => { if (!currentTask || FINAL_TASK_STATUS.has(currentTask.status)) { @@ -790,7 +984,11 @@ export default function CanvasModel3DPage() { if (cancelled) return; pollAttemptsRef.current += 1; - const result = await model3DGenerationService.getTask(currentTask.taskId); + const isWorldTask = currentTask.provider === "worldlabs-3d"; + const result = + isWorldTask + ? await worldLabsGenerationService.getTask(currentTask.taskId) + : await model3DGenerationService.getTask(currentTask.taskId); if (!result.success || !result.data) { const failMessage = result.error || "任务查询失败"; const failedRecord: TaskRecord = { @@ -805,6 +1003,11 @@ export default function CanvasModel3DPage() { } const status = normalizeTaskStatus(result.data.status); + const worldResultData = isWorldTask + ? (result.data as NonNullable< + Awaited>["data"] + >) + : null; const nextRecord: TaskRecord = { ...currentTask, status, @@ -812,6 +1015,14 @@ export default function CanvasModel3DPage() { modelUrl: result.data.modelUrl ?? currentTask.modelUrl ?? null, previewImageUrl: result.data.previewImageUrl ?? currentTask.previewImageUrl ?? null, + sourceTaskId: + isWorldTask + ? worldResultData?.worldId ?? currentTask.sourceTaskId ?? null + : currentTask.sourceTaskId ?? null, + assetPayload: + isWorldTask + ? worldResultData?.assetPayload ?? currentTask.assetPayload ?? null + : currentTask.assetPayload ?? null, error: status === "failed" ? result.data.errorMessage || "任务失败" : null, }; @@ -829,7 +1040,9 @@ export default function CanvasModel3DPage() { ) { const link = document.createElement("a"); link.href = nextRecord.modelUrl; - link.download = `tripo-model-${nextRecord.taskId}.${extractModelFormat(nextRecord.modelUrl)}`; + link.download = `${ + nextRecord.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model" + }-${nextRecord.taskId}.${extractModelFormat(nextRecord.modelUrl)}`; link.click(); setAutoDownloadTaskId(null); } @@ -919,6 +1132,34 @@ export default function CanvasModel3DPage() { handleBatchFileChange(index, null); }; + const handleWorldImageSlotChange = (index: number, file: File | null) => { + setWorldImageSlots((prev) => { + const next = [...prev]; + const target = next[index]; + if (!target) return prev; + if (target.previewUrl) URL.revokeObjectURL(target.previewUrl); + next[index] = { + ...target, + file, + previewUrl: file ? URL.createObjectURL(file) : null, + }; + return next; + }); + }; + + const handleWorldImageAzimuthChange = (index: number, azimuth: number) => { + setWorldImageSlots((prev) => { + const next = [...prev]; + const target = next[index]; + if (!target) return prev; + next[index] = { + ...target, + azimuth, + }; + return next; + }); + }; + // Task submission functions const buildGenerationPayload = ( taskType: "text_to_model" | "image_to_model" | "multiview_to_model" @@ -949,28 +1190,34 @@ export default function CanvasModel3DPage() { }; const submitTask = async (params: { + provider: TaskProvider; payload: Record; mode: TaskMode; promptText: string; + modelLabel: string; sourceTaskId?: string | null; exportFormat?: string | null; + assetPayload?: WorldLabsTaskAssetPayload | null; }) => { - const createResult = await model3DGenerationService.createTask( - params.payload - ); + const createResult = + params.provider === "worldlabs-3d" + ? await worldLabsGenerationService.createTask(params.payload) + : await model3DGenerationService.createTask(params.payload); if (!createResult.success || !createResult.data?.taskId) { throw new Error(createResult.error || "创建任务失败"); } const newTask: TaskRecord = { taskId: createResult.data.taskId, + provider: params.provider, status: normalizeTaskStatus(createResult.data.status), prompt: params.promptText, mode: params.mode, - modelVersion, + modelVersion: params.modelLabel, createdAt: Date.now(), sourceTaskId: params.sourceTaskId || null, exportFormat: params.exportFormat || null, + assetPayload: params.assetPayload ?? null, }; setCurrentTask(newTask); upsertTaskHistory(newTask); @@ -995,9 +1242,11 @@ export default function CanvasModel3DPage() { setIsSubmitting(true); try { await submitTask({ + provider: "tripo-3d", payload: params.payload, mode: params.mode, promptText: params.promptText, + modelLabel: modelVersion, sourceTaskId: params.sourceTaskId, }); toast.success(params.successMessage); @@ -1038,9 +1287,11 @@ export default function CanvasModel3DPage() { try { if (mode === "text") { await submitTask({ + provider: "tripo-3d", payload: buildGenerationPayload("text_to_model"), mode: "text", promptText: normalizedPrompt, + modelLabel: modelVersion, }); toast.success("已提交文生3D任务"); return; @@ -1063,9 +1314,11 @@ export default function CanvasModel3DPage() { throw new Error("参考图上传未返回可用 token"); } await submitTask({ + provider: "tripo-3d", payload, mode: "image", promptText: normalizedPrompt || "图生3D", + modelLabel: modelVersion, }); toast.success("已提交图生3D任务"); return; @@ -1088,9 +1341,11 @@ export default function CanvasModel3DPage() { throw new Error(`图片未返回可用 token: ${file.name}`); } await submitTask({ + provider: "tripo-3d", payload, mode: "batch", promptText: `${normalizedPrompt || "批量图生3D"} · ${file.name}`, + modelLabel: modelVersion, }); successCount += 1; } @@ -1124,9 +1379,11 @@ export default function CanvasModel3DPage() { const payload = buildGenerationPayload("multiview_to_model"); payload.files = filesPayload; await submitTask({ + provider: "tripo-3d", payload, mode: "multiview", promptText: normalizedPrompt || "多视图3D", + modelLabel: modelVersion, }); toast.success("已提交多视图3D任务"); } catch (error) { @@ -1137,6 +1394,149 @@ export default function CanvasModel3DPage() { } }; + const handleWorldGenerate = async () => { + const normalizedPrompt = worldPrompt.trim(); + const selectedWorldImages = worldImageSlots.filter((slot) => slot.file); + + if (worldMode === "text" && !normalizedPrompt) { + toast.error("请输入世界描述"); + return; + } + if (worldMode === "image" && !worldImageFile) { + toast.error("图生世界请先上传参考图"); + return; + } + if (worldMode === "multi-image" && selectedWorldImages.length < 2) { + toast.error("多图世界至少上传 2 张图片"); + return; + } + if (worldMode === "video" && !worldVideoFile) { + toast.error("视频世界请先上传视频"); + return; + } + + setIsSubmitting(true); + setUploadingImage(false); + + try { + if (worldMode === "text") { + await submitTask({ + provider: "worldlabs-3d", + payload: { + mode: "text", + prompt: normalizedPrompt, + displayName: worldDisplayName.trim() || undefined, + model: worldModel, + disableRecaption: worldDisableRecaption, + makePublic: worldMakePublic, + }, + mode: "world-text", + promptText: normalizedPrompt || "文生世界", + modelLabel: worldModel, + }); + toast.success("已提交文生世界任务"); + return; + } + + if (worldMode === "image") { + setUploadingImage(true); + const uploadResult = await worldLabsGenerationService.uploadMedia( + worldImageFile as File, + "image" + ); + if (!uploadResult.success || !uploadResult.data?.mediaAssetId) { + throw new Error(uploadResult.error || "上传世界参考图失败"); + } + await submitTask({ + provider: "worldlabs-3d", + payload: { + mode: "image", + mediaAssetId: uploadResult.data.mediaAssetId, + prompt: normalizedPrompt || undefined, + displayName: worldDisplayName.trim() || undefined, + model: worldModel, + disableRecaption: worldDisableRecaption, + isPano: worldIsPano, + makePublic: worldMakePublic, + }, + mode: "world-image", + promptText: normalizedPrompt || "图生世界", + modelLabel: worldModel, + }); + toast.success("已提交图生世界任务"); + return; + } + + if (worldMode === "video") { + setUploadingImage(true); + const uploadResult = await worldLabsGenerationService.uploadMedia( + worldVideoFile as File, + "video" + ); + if (!uploadResult.success || !uploadResult.data?.mediaAssetId) { + throw new Error(uploadResult.error || "上传世界视频失败"); + } + await submitTask({ + provider: "worldlabs-3d", + payload: { + mode: "video", + mediaAssetId: uploadResult.data.mediaAssetId, + prompt: normalizedPrompt || undefined, + displayName: worldDisplayName.trim() || undefined, + model: worldModel, + makePublic: worldMakePublic, + }, + mode: "world-video", + promptText: normalizedPrompt || worldVideoFile?.name || "视频世界", + modelLabel: worldModel, + }); + toast.success("已提交视频世界任务"); + return; + } + + setUploadingImage(true); + const uploads = await Promise.all( + selectedWorldImages.map(async (slot) => ({ + slot, + result: await worldLabsGenerationService.uploadMedia(slot.file as File, "image"), + })) + ); + + const mediaAssets = uploads.map(({ slot, result }) => { + if (!result.success || !result.data?.mediaAssetId) { + throw new Error(result.error || "上传多图世界图片失败"); + } + return { + mediaAssetId: result.data.mediaAssetId, + azimuth: slot.azimuth, + }; + }); + + await submitTask({ + provider: "worldlabs-3d", + payload: { + mode: "multi-image", + mediaAssets, + prompt: normalizedPrompt || undefined, + displayName: worldDisplayName.trim() || undefined, + model: worldModel, + disableRecaption: worldDisableRecaption, + reconstructImages: worldReconstructImages, + makePublic: worldMakePublic, + }, + mode: "world-multi-image", + promptText: normalizedPrompt || "多图世界", + modelLabel: worldModel, + }); + toast.success("已提交多图世界任务"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "创建世界任务失败"); + } finally { + setUploadingImage(false); + setIsSubmitting(false); + } + }; + const handleImportToCanvas = async () => { if (!currentModelUrl || !currentTask || currentTask.status !== "success") { toast.error("暂无可导入的 3D 模型"); @@ -1168,12 +1568,14 @@ export default function CanvasModel3DPage() { status: "completed", metadata: { taskId: currentTask.taskId, + provider: currentTask.provider, mode: currentTask.mode, sourceTaskId: currentTask.sourceTaskId, progress: currentTask.progress, triangles: viewerStats.triangles, vertices: viewerStats.vertices, meshCount: viewerStats.meshCount, + assetPayload: currentTask.assetPayload ?? null, }, }, connections: [], @@ -1194,12 +1596,30 @@ export default function CanvasModel3DPage() { } const link = document.createElement("a"); link.href = currentModelUrl; - link.download = `tripo-model-${currentTask?.taskId || "result"}.${extractModelFormat( - currentModelUrl - )}`; + link.download = `${ + currentTask?.provider === "worldlabs-3d" ? "worldlabs-world" : "tripo-model" + }-${currentTask?.taskId || "result"}.${extractModelFormat(currentModelUrl)}`; link.click(); }; + const handleOpenWorldUrl = () => { + const worldUrl = currentTask?.assetPayload?.worldUrl; + if (!worldUrl) { + toast.error("当前任务暂无可打开的 World 链接"); + return; + } + window.open(worldUrl, "_blank", "noopener,noreferrer"); + }; + + const handleOpenPanoUrl = () => { + const panoUrl = currentTask?.assetPayload?.panoUrl; + if (!panoUrl) { + toast.error("当前任务暂无可打开的全景图"); + return; + } + window.open(panoUrl, "_blank", "noopener,noreferrer"); + }; + const handleConvertAndDownload = async () => { const readyTask = getReadyTask(); if (!readyTask) return; @@ -1215,9 +1635,11 @@ export default function CanvasModel3DPage() { textureFormat: "JPEG", }; const task = await submitTask({ + provider: "tripo-3d", payload, mode: "convert", promptText: `${modeLabel(readyTask.mode)} -> ${exportFormat}`, + modelLabel: readyTask.modelVersion, sourceTaskId: baseTaskId, exportFormat, }); @@ -1241,9 +1663,11 @@ export default function CanvasModel3DPage() { draftModelTaskId: readyTask.taskId, }; await submitTask({ + provider: "tripo-3d", payload, mode: "refine", promptText: `精修任务(来源 ${readyTask.taskId.slice(0, 8)})`, + modelLabel: readyTask.modelVersion, sourceTaskId: readyTask.taskId, }); toast.success("已提交模型精修任务"); @@ -1413,6 +1837,7 @@ export default function CanvasModel3DPage() { }; const handleSelectTask = (task: TaskRecord) => { + setWorkspaceMode(inferWorkspaceModeFromTask(task)); setCurrentTask(task); if (task.status === "success" && task.modelUrl) { setViewerLayers([]); @@ -1424,19 +1849,41 @@ export default function CanvasModel3DPage() { const handleClearHistory = async () => { const prevTaskHistory = taskHistory; const prevCurrentTask = currentTask; - setTaskHistory([]); - setCurrentTask(null); + const tasksToDelete = visibleTaskHistory; + setTaskHistory((prev) => + prev.filter((task) => !tasksToDelete.some((candidate) => candidate.taskId === task.taskId)) + ); + setCurrentTask((prev) => + prev && tasksToDelete.some((task) => task.taskId === prev.taskId) ? null : prev + ); try { - const response = await fetch(`/api/canvas/${id}/model3d-assets`, { - method: "DELETE", - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok || payload?.success === false) { - throw new Error(payload?.error || "清空资产库失败"); + if (tasksToDelete.length === taskHistory.length) { + const response = await fetch(`/api/canvas/${id}/model3d-assets`, { + method: "DELETE", + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.success === false) { + throw new Error(payload?.error || "清空资产库失败"); + } + } else { + for (const task of tasksToDelete) { + const response = await fetch( + `/api/canvas/${id}/model3d-assets?taskId=${encodeURIComponent(task.taskId)}`, + { + method: "DELETE", + } + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.success === false) { + throw new Error(payload?.error || "清空资产库失败"); + } + } } if (typeof window !== "undefined") { - window.localStorage.removeItem(legacyHistoryStorageKey); + if (tasksToDelete.length === taskHistory.length) { + window.localStorage.removeItem(legacyHistoryStorageKey); + } } toast.success("资产库已清空"); } catch (error) { @@ -1466,6 +1913,19 @@ export default function CanvasModel3DPage() { ? statusLabel(currentTask.status) : "未开始"; const activePromptSummary = currentTask?.prompt?.trim() || "尚未创建任务"; + const activeProviderLabel = + currentTask?.provider === "worldlabs-3d" ? "World Labs" : "Tripo3D"; + const activeRelativeTime = currentTask + ? formatDistanceToNow(new Date(currentTask.createdAt), { + addSuffix: true, + locale: zhCN, + }) + : null; + const currentWorldAsset = + currentTask?.provider === "worldlabs-3d" ? currentTask.assetPayload ?? null : null; + const currentWorldId = + currentWorldAsset?.worldId || currentTask?.sourceTaskId || null; + const currentSpzEntries = Object.entries(currentWorldAsset?.spzUrls || {}); return (
@@ -1575,7 +2035,9 @@ export default function CanvasModel3DPage() { - 生成模型 + + {workspaceMode === "world" ? "生成世界" : "生成模型"} + @@ -1594,7 +2056,9 @@ export default function CanvasModel3DPage() { - 编辑与优化 + + {workspaceMode === "world" ? "世界资产" : "编辑与优化"} + @@ -1613,7 +2077,9 @@ export default function CanvasModel3DPage() { - 场景层级 + + {workspaceMode === "world" ? "世界详情" : "场景层级"} +
@@ -1632,392 +2098,962 @@ export default function CanvasModel3DPage() { {/* 2. Left Tool Panel (Expandable Content) */}
-
+
+
+ + +

- {activeTab === "create" && "生成模型"} - {activeTab === "edit" && "模型编辑"} - {activeTab === "scene" && "场景管理"} + {activeTab === "create" && + (workspaceMode === "world" ? "生成世界" : "生成模型")} + {activeTab === "edit" && + (workspaceMode === "world" ? "世界资产" : "模型编辑")} + {activeTab === "scene" && + (workspaceMode === "world" ? "世界详情" : "场景管理")}

{activeTab === "create" && ( - <> -
- {[ - { id: "text", label: "文生", icon: PenLine }, - { id: "image", label: "图生", icon: ImagePlus }, - { id: "multiview", label: "多视", icon: Box }, - { id: "batch", label: "批量", icon: Images }, - ].map((m) => ( - - ))} -
- -
-
- -