Add canvas 3D generation actions
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-23 12:38:33 +08:00
parent 40ec8c412e
commit ccb62d6f7d
6 changed files with 1273 additions and 70 deletions

View File

@ -20,13 +20,45 @@ const ALLOWED_VIDEO_TYPES = new Set([
]);
const MAX_IMAGE_UPLOAD_SIZE = 20 * 1024 * 1024;
const MAX_VIDEO_UPLOAD_SIZE = 100 * 1024 * 1024;
const MIME_BY_EXTENSION: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
mp4: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
m4v: "video/x-m4v",
};
function getFilenameFromUrl(sourceUrl: string, fallback = "canvas-source") {
try {
const parsed = new URL(sourceUrl);
const filename = parsed.pathname.split("/").filter(Boolean).pop();
return filename ? decodeURIComponent(filename) : fallback;
} catch {
return fallback;
}
}
function normalizeMimeType(value?: string | null) {
return String(value || "").split(";")[0]?.trim().toLowerCase() || "";
}
function inferMimeType(filename: string, fallback?: string | null) {
const normalizedFallback = normalizeMimeType(fallback);
if (normalizedFallback) return normalizedFallback;
const extension = filename.split(".").pop()?.trim().toLowerCase() || "";
return MIME_BY_EXTENSION[extension] || "application/octet-stream";
}
function guessExtension(file: File) {
const fromName = String(file.name || "")
.split(".")
.pop()
?.trim()
.toLowerCase();
const fileName = String(file.name || "");
const lastDotIndex = fileName.lastIndexOf(".");
const fromName =
lastDotIndex >= 0 && lastDotIndex < fileName.length - 1
? fileName.slice(lastDotIndex + 1).trim().toLowerCase()
: "";
if (fromName) return fromName;
const byType: Record<string, string> = {
@ -41,6 +73,38 @@ function guessExtension(file: File) {
return byType[file.type] || "bin";
}
async function fetchRemoteFileFromFormData(formData: FormData) {
const sourceUrl = String(formData.get("sourceUrl") || "").trim();
if (!sourceUrl) return null;
let parsed: URL;
try {
parsed = new URL(sourceUrl);
} catch {
throw new Error("sourceUrl 格式无效");
}
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("sourceUrl 仅支持 HTTP/HTTPS 图片或视频");
}
const response = await fetch(sourceUrl, {
cache: "no-store",
headers: {
"User-Agent": "AI-Chatbot-WorldLabs-Upload/1.0",
},
});
if (!response.ok) {
throw new Error(`读取画布图片失败(HTTP ${response.status})`);
}
const buffer = await response.arrayBuffer();
const fallbackFilename = `canvas-source-${Date.now()}`;
const filename = getFilenameFromUrl(sourceUrl, fallbackFilename);
const contentType = inferMimeType(filename, response.headers.get("content-type"));
return new File([buffer], filename, { type: contentType });
}
function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
}
@ -60,7 +124,9 @@ export async function POST(request: Request) {
}
const formData = await request.formData();
const file = formData.get("file");
const formFile = formData.get("file");
const file =
formFile instanceof File ? formFile : await fetchRemoteFileFromFormData(formData);
if (!(file instanceof File)) {
return NextResponse.json({ success: false, error: "请上传文件" }, { status: 400 });
}

View File

@ -1959,7 +1959,7 @@ export default function CanvasModel3DPage() {
const handleImportToCanvas = async () => {
if (!currentTask || currentTask.status !== "success") {
toast.error("暂无可导入的世界资产");
toast.error("暂无可添加的 3D 资产");
return;
}
@ -1982,7 +1982,7 @@ export default function CanvasModel3DPage() {
);
if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) {
toast.error(isWorldTask ? "暂无可导入的 3D 世界" : "暂无可导入的 3D 模型");
toast.error(isWorldTask ? "暂无可添加的 3D 世界" : "暂无可添加的 3D 模型");
return;
}
@ -2070,7 +2070,7 @@ export default function CanvasModel3DPage() {
} catch (error) {
console.error("[Model3DPage] 保存画布失败:", error);
}
toast.success(isWorldTask ? "3D 世界已导入画布" : "3D 模型已导入画布");
toast.success(isWorldTask ? "3D 世界已添加到画布" : "3D 模型已添加到画布");
};
const handleDownloadCurrent = () => {
@ -3354,7 +3354,7 @@ export default function CanvasModel3DPage() {
onClick={handleImportToCanvas}
variant="secondary"
>
<PlusCircle className="h-4 w-4" /> 导入画布
<PlusCircle className="h-4 w-4" /> 添加到画布
</Button>
<Button
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"
@ -3470,7 +3470,7 @@ export default function CanvasModel3DPage() {
onClick={handleImportToCanvas}
variant="secondary"
>
<PlusCircle className="h-4 w-4" /> 导入画布
<PlusCircle className="h-4 w-4" /> 添加到画布
</Button>
<Button
className="w-full gap-2 border border-white/10 bg-zinc-800 text-zinc-200 hover:bg-zinc-700"

View File

@ -8,6 +8,7 @@ import {
Download,
Eraser,
Expand,
Globe2,
ImagePlus,
Layers,
Loader2,
@ -69,6 +70,11 @@ import {
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url";
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId,
} from "@/lib/model3d/model-alias-config";
import { pickBestWorldLabsSplatUrl } from "@/lib/worldlabs/splat-utils";
import {
getNodeContentScale,
getScaledNodeContentStyle,
@ -78,7 +84,12 @@ import type {
CanvasTextEditPayloadItem,
NormalizedCropRect,
} from "@/services/canvas";
import { imageGenerationService } from "@/services/canvas";
import {
canvasService,
imageGenerationService,
model3DGenerationService,
worldLabsGenerationService,
} from "@/services/canvas";
import type { PickedObject } from "@/store/canvas";
import { useCanvasStore } from "@/store/canvas";
import type {
@ -164,6 +175,40 @@ const normalizeTextEditItems = (
};
const isHttpImageUrl = (url?: string) => !!url && /^https?:\/\//i.test(url);
const CANVAS_3D_POLL_INTERVAL_MS = 2000;
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
const DEFAULT_CANVAS_TRIPO_MODEL = getDefaultThreeDModelAliasId(
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels
);
const DEFAULT_CANVAS_WORLD_MODEL = getDefaultThreeDModelAliasId(
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.worldModels
);
const sleep = (ms: number) =>
new Promise((resolve) => window.setTimeout(resolve, ms));
const isTerminal3DFailureStatus = (status?: string | null) =>
["failed", "banned", "expired", "cancelled"].includes(String(status || ""));
function inferModelFormat(modelUrl?: string | null) {
const value = String(modelUrl || "").toLowerCase();
if (!value) return "glb";
if (value.includes(".gltf")) return "gltf";
if (value.includes(".glb")) return "glb";
if (value.includes(".usdz")) return "usdz";
if (value.includes(".fbx")) return "fbx";
if (value.includes(".obj")) return "obj";
if (value.includes(".stl")) return "stl";
if (value.includes(".3mf")) return "3mf";
return "glb";
}
function inferTripoImageType(imageUrl?: string | null) {
const value = String(imageUrl || "").toLowerCase();
if (value.includes(".png")) return "png";
if (value.includes(".webp")) return "webp";
return "jpg";
}
const normalizeMediaUrl = (url: string): string => {
const raw = String(url || "").trim();
@ -451,6 +496,9 @@ export function ImageNode({
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const addNode = useCanvasStore((state) => state.addNode);
const selectNode = useCanvasStore((state) => state.selectNode);
const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const addPendingChatAttachment = useCanvasStore(
(state) => state.addPendingChatAttachment
);
@ -2361,6 +2409,14 @@ export function ImageNode({
setPromptDialogOpen(true);
return;
}
if (action === "generate-3d-model") {
await handleGenerateCanvas3DModel();
return;
}
if (action === "generate-3d-world") {
await handleGenerateCanvas3DWorld();
return;
}
const actionName = getActionName(action);
@ -2757,6 +2813,837 @@ export function ImageNode({
]
);
const persistCanvasStateSnapshot = useCallback(() => {
if (!projectId || typeof window === "undefined") return;
window.setTimeout(() => {
void canvasService.saveCanvasState(projectId, getCanvasState());
}, 0);
}, [getCanvasState, projectId]);
const persistCanvas3DAsset = useCallback(
async (asset: Record<string, any>) => {
if (!projectId) return;
try {
const response = await fetch(`/api/canvas/${projectId}/model3d-assets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ asset }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload?.error || `HTTP ${response.status}`);
}
} catch (error) {
console.error("[ImageNode] 保存画布 3D 资产失败:", error);
}
},
[projectId]
);
const pollCanvasModel3DTask = useCallback(
async (params: {
nodeId: string;
taskId: string;
providerId?: string | null;
providerName?: string | null;
promptText: string;
modelVersion: string;
sourceImageUrl: string;
previewImageUrl?: string | null;
}) => {
for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) {
await sleep(CANVAS_3D_POLL_INTERVAL_MS);
const result = await model3DGenerationService.getTask(params.taskId, {
providerId: params.providerId,
});
const status = String(result.data?.status || "unknown");
const progress = result.data?.progress ?? null;
if (!result.success || !result.data) {
const error = result.error || "3D 模型任务查询失败";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
if (status === "success") {
const modelUrl = String(result.data.modelUrl || "").trim();
const previewImageUrl =
result.data.previewImageUrl ||
params.previewImageUrl ||
params.sourceImageUrl;
if (!modelUrl) {
const error = "3D 模型已完成,但没有返回模型文件";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: previewImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
updateNode(params.nodeId, {
data: {
src: modelUrl,
previewImage: previewImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
format: inferModelFormat(modelUrl),
status: "completed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress: 100,
output: result.data.output || null,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "success",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: 100,
modelUrl,
previewImageUrl,
error: null,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("3D 模型已生成到画布");
return;
}
if (isTerminal3DFailureStatus(status)) {
const error = result.data.errorMessage || "3D 模型生成失败";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "generating",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress,
},
},
});
persistCanvasStateSnapshot();
}
const error = "3D 模型任务轮询超时,请稍后重试";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
},
[persistCanvas3DAsset, persistCanvasStateSnapshot, updateNode]
);
const pollCanvasWorld3DTask = useCallback(
async (params: {
nodeId: string;
taskId: string;
providerId?: string | null;
providerName?: string | null;
promptText: string;
modelVersion: string;
sourceImageUrl: string;
}) => {
for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) {
await sleep(CANVAS_3D_POLL_INTERVAL_MS);
const result = await worldLabsGenerationService.getTask(params.taskId, {
providerId: params.providerId,
});
const status = String(result.data?.status || "unknown");
const progress = result.data?.progress ?? null;
if (!result.success || !result.data) {
const error = result.error || "3D 世界任务查询失败";
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "failed",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
if (status === "success") {
const assetPayload = (result.data.assetPayload || {}) as Record<string, any>;
const splatUrl = pickBestWorldLabsSplatUrl(
assetPayload.spzUrls as Record<string, string> | null | undefined
);
const imageUrl =
(typeof assetPayload.panoUrl === "string" && assetPayload.panoUrl) ||
(typeof assetPayload.thumbnailUrl === "string" && assetPayload.thumbnailUrl) ||
result.data.previewImageUrl ||
params.sourceImageUrl;
const meshUrl =
result.data.modelUrl ||
(typeof assetPayload.meshUrl === "string" ? assetPayload.meshUrl : null);
const worldUrl =
typeof assetPayload.worldUrl === "string" ? assetPayload.worldUrl : null;
const previewImageUrl = result.data.previewImageUrl || imageUrl;
updateNode(params.nodeId, {
data: {
worldUrl: worldUrl || undefined,
imageUrl: imageUrl || undefined,
meshUrl: meshUrl || undefined,
splatUrl: splatUrl || undefined,
previewImage: previewImageUrl || undefined,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "completed",
activeView: splatUrl ? "splat" : imageUrl ? "image" : "mesh",
firstPerson: false,
hotspots: [],
semanticsMetadata:
assetPayload.semanticsMetadata &&
typeof assetPayload.semanticsMetadata === "object"
? assetPayload.semanticsMetadata
: undefined,
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
progress: 100,
assetPayload: {
...assetPayload,
providerId: params.providerId || assetPayload.providerId || null,
providerName:
params.providerName || assetPayload.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "success",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: 100,
modelUrl: meshUrl,
previewImageUrl,
error: null,
assetPayload: {
...assetPayload,
providerId: params.providerId || assetPayload.providerId || null,
providerName: params.providerName || assetPayload.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("3D 世界已生成到画布");
return;
}
if (isTerminal3DFailureStatus(status)) {
const error = result.data.errorMessage || "3D 世界生成失败";
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
progress,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "failed",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "generating",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
progress,
},
},
});
persistCanvasStateSnapshot();
}
const error = "3D 世界任务轮询超时,请稍后重试";
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "failed",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
},
[persistCanvas3DAsset, persistCanvasStateSnapshot, updateNode]
);
const handleGenerateCanvas3DModel = useCallback(async () => {
if (!data.src || !isHttpImageUrl(data.src)) {
toast.error("请选择一张可访问的 URL 图片");
return;
}
if (!projectId) {
toast.error("画布还在加载,请稍后再试");
return;
}
const promptText = (data.prompt || displayTitle || "图生 3D 模型").trim();
const modelVersion = DEFAULT_CANVAS_TRIPO_MODEL || "tripo-standard";
const sourceImageUrl = data.src;
const newNodeId = addNode({
type: "model3d",
position: {
x: node.position.x + node.size.width + 24,
y: node.position.y,
},
size: { width: Math.max(360, node.size.width), height: 300 },
data: {
src: "",
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
format: "glb",
status: "generating",
metadata: {
provider: "tripo-3d",
mode: "image",
sourceImageUrl,
},
},
connections: [node.id],
});
selectNode(newNodeId);
persistCanvasStateSnapshot();
setIsProcessing(true);
try {
const createResult = await model3DGenerationService.createTask({
taskType: "image_to_model",
imageUrl: sourceImageUrl,
imageType: inferTripoImageType(sourceImageUrl),
prompt: promptText,
modelVersion,
faceLimit: 120000,
texture: true,
pbr: true,
autoSize: false,
quad: false,
generateParts: false,
smartLowPoly: false,
geometryQuality: "standard",
textureQuality: "standard",
textureAlignment: "original_image",
orientation: "default",
});
if (!createResult.success || !createResult.data?.taskId) {
throw new Error(createResult.error || "创建图生 3D 模型任务失败");
}
const providerId = createResult.data.providerId || null;
const providerName = createResult.data.providerName || null;
updateNode(newNodeId, {
data: {
src: "",
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
format: "glb",
status: "generating",
metadata: {
taskId: createResult.data.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl,
providerId,
providerName,
},
},
});
await persistCanvas3DAsset({
taskId: createResult.data.taskId,
provider: "tripo-3d",
status: createResult.data.status || "running",
mode: "image",
prompt: promptText,
modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: sourceImageUrl,
error: null,
assetPayload: { providerId, providerName, sourceImageUrl },
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("已在画布创建 3D 模型任务");
void pollCanvasModel3DTask({
nodeId: newNodeId,
taskId: createResult.data.taskId,
providerId,
providerName,
promptText,
modelVersion,
sourceImageUrl,
previewImageUrl: sourceImageUrl,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "创建图生 3D 模型任务失败";
updateNode(newNodeId, {
data: {
src: "",
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
format: "glb",
status: "failed",
metadata: {
provider: "tripo-3d",
mode: "image",
sourceImageUrl,
error: message,
},
},
});
persistCanvasStateSnapshot();
toast.error(message);
} finally {
setIsProcessing(false);
}
}, [
addNode,
data.prompt,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.width,
persistCanvas3DAsset,
persistCanvasStateSnapshot,
pollCanvasModel3DTask,
projectId,
selectNode,
updateNode,
]);
const handleGenerateCanvas3DWorld = useCallback(async () => {
if (!data.src || !isHttpImageUrl(data.src)) {
toast.error("请选择一张可访问的 URL 图片");
return;
}
if (!projectId) {
toast.error("画布还在加载,请稍后再试");
return;
}
const promptText = (data.prompt || displayTitle || "图生 3D 世界").trim();
const modelVersion = DEFAULT_CANVAS_WORLD_MODEL || "world-standard";
const sourceImageUrl = data.src;
const newNodeId = addNode({
type: "world3d",
position: {
x: node.position.x + node.size.width + 24,
y: node.position.y + 24,
},
size: { width: Math.max(420, node.size.width), height: 320 },
data: {
imageUrl: sourceImageUrl,
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
status: "generating",
activeView: "image",
firstPerson: false,
hotspots: [],
metadata: {
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl,
},
},
connections: [node.id],
});
selectNode(newNodeId);
persistCanvasStateSnapshot();
setIsProcessing(true);
try {
const uploadResult = await worldLabsGenerationService.uploadMediaFromUrl(
sourceImageUrl,
"image"
);
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
throw new Error(uploadResult.error || "上传世界参考图失败");
}
const providerId = uploadResult.data.providerId || null;
const providerName = uploadResult.data.providerName || null;
const createResult = await worldLabsGenerationService.createTask({
mode: "image",
mediaAssetId: uploadResult.data.mediaAssetId,
providerId,
prompt: promptText || undefined,
displayName: displayTitle || promptText || "画布图生世界",
model: modelVersion,
disableRecaption: false,
makePublic: false,
});
if (!createResult.success || !createResult.data?.taskId) {
throw new Error(createResult.error || "创建图生 3D 世界任务失败");
}
const createAssetPayload = {
...(createResult.data.assetPayload || {}),
providerId,
providerName,
sourceImageUrl,
};
updateNode(newNodeId, {
data: {
imageUrl: sourceImageUrl,
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
status: "generating",
activeView: "image",
firstPerson: false,
hotspots: [],
metadata: {
taskId: createResult.data.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl,
assetPayload: createAssetPayload,
},
},
});
await persistCanvas3DAsset({
taskId: createResult.data.taskId,
provider: "worldlabs-3d",
status: createResult.data.status || "running",
mode: "world-image",
prompt: promptText,
modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: sourceImageUrl,
error: null,
assetPayload: createAssetPayload,
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("已在画布创建 3D 世界任务");
void pollCanvasWorld3DTask({
nodeId: newNodeId,
taskId: createResult.data.taskId,
providerId,
providerName,
promptText,
modelVersion,
sourceImageUrl,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "创建图生 3D 世界任务失败";
updateNode(newNodeId, {
data: {
imageUrl: sourceImageUrl,
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
status: "failed",
activeView: "image",
firstPerson: false,
hotspots: [],
metadata: {
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl,
error: message,
},
},
});
persistCanvasStateSnapshot();
toast.error(message);
} finally {
setIsProcessing(false);
}
}, [
addNode,
data.prompt,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.width,
persistCanvas3DAsset,
persistCanvasStateSnapshot,
pollCanvasWorld3DTask,
projectId,
selectNode,
updateNode,
]);
// 基于当前图片+新提示词重绘
const handlePromptRegenerate = async () => {
if (!data.src) return;
@ -3602,6 +4489,26 @@ export function ImageNode({
多视角
</Button>
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("generate-3d-model")}
variant="ghost"
>
<Box className="mr-1.5 h-4 w-4" />
3D模型
</Button>
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("generate-3d-world")}
variant="ghost"
>
<Globe2 className="mr-1.5 h-4 w-4" />
3D世界
</Button>
{isLayerSplitEnabled ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"

View File

@ -14,6 +14,7 @@ import {
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import {
DropdownMenu,
DropdownMenuContent,
@ -28,6 +29,14 @@ import {
resolveThreeDModelDisplayName,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
Model3DViewer,
type Model3DViewerSettings,
} from "@/components/canvas/model3d/Model3DViewer";
import {
getCanvasToolbarItem,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
import { cn, fetcher } from "@/lib/utils";
@ -35,8 +44,18 @@ import { cn, fetcher } from "@/lib/utils";
interface Model3DNodeProps {
node: CanvasNode;
isSelected: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
}
const CANVAS_MODEL_VIEWER_SETTINGS: Model3DViewerSettings = {
cameraPreset: "iso",
envPreset: "studio",
lightIntensity: 1,
autoRotate: false,
showGrid: true,
wireframe: false,
};
function inferModelFileName(data: Model3DNodeData, nodeId: string) {
const raw = String(data.src || "").trim();
if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`;
@ -70,10 +89,46 @@ function readOptionalUrl(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
export function Model3DNode({
node,
isSelected,
canvasToolbarConfig,
}: Model3DNodeProps) {
const data = node.data as Model3DNodeData;
const [isHovered, setIsHovered] = useState(false);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const model3dOpen3dItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dOpen3d"
);
const model3dOpenWorldItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dOpenWorld"
);
const model3dMoreItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dMore"
);
const model3dOpenMeshItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dOpenMesh"
);
const model3dOpenImageItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dOpenImage"
);
const model3dDownloadItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dDownload"
);
const model3dCopyItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dCopy"
);
const model3dDeleteItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dDelete"
);
const { data: modelAliasConfigResponse } = useSWR<{
success?: boolean;
data?: PublicThreeDModelAliasConfig;
@ -98,7 +153,10 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
readOptionalUrl(worldAssetPayload?.thumbnailUrl) ||
readOptionalUrl(data.previewImage);
const primaryOpenUrl = worldUrl || data.src || null;
const primaryOpenLabel = worldUrl ? "打开 World" : "打开 3D";
const primaryOpenItem = worldUrl ? model3dOpenWorldItem : model3dOpen3dItem;
const primaryOpenLabel = primaryOpenItem.label;
const shouldRenderLiveViewer =
isSelected && data.status === "completed" && Boolean(data.src);
const handleOpen = () => {
if (!primaryOpenUrl) {
@ -177,8 +235,21 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
</div>
) : (
<div className="flex h-full w-full flex-col">
<div className="relative flex-1 overflow-hidden bg-muted/30">
{data.previewImage ? (
<div className="relative min-h-0 flex-1 overflow-hidden bg-muted/30">
{shouldRenderLiveViewer ? (
<div
className="h-full w-full"
data-canvas-no-drag="true"
onWheel={(event) => event.stopPropagation()}
>
<Model3DViewer
emptyStateTitle="准备查看 3D 模型"
modelUrl={data.src}
previewImageUrl={data.previewImage}
settings={CANVAS_MODEL_VIEWER_SETTINGS}
/>
</div>
) : data.previewImage ? (
<img
src={data.previewImage}
alt={data.prompt || "3D 预览"}
@ -214,47 +285,80 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
onClick={handleOpen}
title={primaryOpenLabel}
>
<ExternalLink className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={worldUrl ? Globe : ExternalLink}
item={primaryOpenItem}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="h-7 w-7">
<MoreVertical className="h-4 w-4" />
<Button
variant="secondary"
size="icon"
className="h-7 w-7"
title={model3dMoreItem.label}
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={MoreVertical}
item={model3dMoreItem}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleOpen}>
{worldUrl ? (
<Globe className="mr-2 h-4 w-4" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={worldUrl ? Globe : ExternalLink}
item={primaryOpenItem}
/>
{primaryOpenLabel}
</DropdownMenuItem>
{worldUrl && data.src && data.src !== worldUrl && (
<DropdownMenuItem onClick={handleOpenMesh}>
<Box className="mr-2 h-4 w-4" />
打开 Mesh
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Box}
item={model3dOpenMeshItem}
/>
{model3dOpenMeshItem.label}
</DropdownMenuItem>
)}
{worldImageUrl && (
<DropdownMenuItem onClick={handleOpenImage}>
<ImageIcon className="mr-2 h-4 w-4" />
打开图像
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={ImageIcon}
item={model3dOpenImageItem}
/>
{model3dOpenImageItem.label}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
下载
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={model3dDownloadItem}
/>
{model3dDownloadItem.label}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopy}>
<Copy className="mr-2 h-4 w-4" />
复制链接
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Copy}
item={model3dCopyItem}
/>
{model3dCopyItem.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
<Trash2 className="mr-2 h-4 w-4" />
删除
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Trash2}
item={model3dDeleteItem}
/>
{model3dDeleteItem.label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -13,7 +13,9 @@ import {
Sparkles,
Trash2,
Waypoints,
type LucideIcon,
} from "lucide-react";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { World3DViewer } from "@/components/canvas/world3d/World3DViewer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -30,6 +32,11 @@ import {
resolveThreeDModelDisplayName,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
getCanvasToolbarItem,
type CanvasToolbarConfig,
type CanvasToolbarItemKey,
} from "@/lib/canvas-toolbar-config";
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas";
@ -38,16 +45,18 @@ import { cn, fetcher } from "@/lib/utils";
interface World3DNodeProps {
node: CanvasNode;
isSelected: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
}
const VIEW_META: Array<{
id: World3DViewMode;
label: string;
icon: React.ComponentType<{ className?: string }>;
icon: LucideIcon;
toolbarKey: CanvasToolbarItemKey;
}> = [
{ id: "splat", label: "W", icon: Sparkles },
{ id: "image", label: "I", icon: ImageIcon },
{ id: "mesh", label: "M", icon: Box },
{ id: "splat", label: "W", icon: Sparkles, toolbarKey: "world3dViewSplat" },
{ id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" },
{ id: "mesh", label: "M", icon: Box, toolbarKey: "world3dViewMesh" },
];
function getWorldNodeViewLabel(view: World3DViewMode) {
@ -116,12 +125,42 @@ function resolvePrimaryUrl(data: World3DNodeData, activeView: World3DViewMode) {
}
}
export function World3DNode({ node, isSelected }: World3DNodeProps) {
export function World3DNode({
node,
isSelected,
canvasToolbarConfig,
}: World3DNodeProps) {
const data = node.data as World3DNodeData;
const [isHovered, setIsHovered] = useState(false);
const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate");
const deleteNode = useCanvasStore((state) => state.deleteNode);
const updateNode = useCanvasStore((state) => state.updateNode);
const world3dFirstPersonItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dFirstPerson"
);
const world3dHotspotItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dHotspot"
);
const world3dMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dMore");
const world3dOpenMeshItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dOpenMesh"
);
const world3dOpenImageItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dOpenImage"
);
const world3dDownloadItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dDownload"
);
const world3dCopyItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dCopy");
const world3dDeleteItem = getCanvasToolbarItem(
canvasToolbarConfig,
"world3dDelete"
);
const { data: modelAliasConfigResponse } = useSWR<{
success?: boolean;
data?: PublicThreeDModelAliasConfig;
@ -248,20 +287,26 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) {
</div>
) : (
<>
<World3DViewer
className="h-full w-full"
firstPerson={Boolean(data.firstPerson) && activeView === "splat"}
hotspots={data.hotspots}
imageUrl={data.imageUrl}
interactionMode={interactionMode}
meshUrl={data.meshUrl}
mode={activeView}
onPick={handlePick}
previewImageUrl={data.previewImage}
semanticsMetadata={data.semanticsMetadata}
splatUrl={data.splatUrl}
worldUrl={data.worldUrl}
/>
<div
className="absolute inset-0"
data-canvas-no-drag="true"
onWheel={(event) => event.stopPropagation()}
>
<World3DViewer
className="h-full w-full"
firstPerson={Boolean(data.firstPerson) && activeView === "splat"}
hotspots={data.hotspots}
imageUrl={data.imageUrl}
interactionMode={interactionMode}
meshUrl={data.meshUrl}
mode={activeView}
onPick={handlePick}
previewImageUrl={data.previewImage}
semanticsMetadata={data.semanticsMetadata}
splatUrl={data.splatUrl}
worldUrl={data.worldUrl}
/>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent px-3 pb-3 pt-12">
<div className="flex items-end justify-between gap-3">
@ -294,16 +339,27 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) {
<div className="absolute left-3 top-3 z-20 flex items-center gap-2">
<div className="flex rounded-full border border-white/10 bg-black/55 p-1 backdrop-blur-sm">
{VIEW_META.filter((view) => availableViews.includes(view.id)).map((view) => (
(() => {
const viewItem = getCanvasToolbarItem(canvasToolbarConfig, view.toolbarKey);
return (
<Button
className="h-7 min-w-7 rounded-full px-2 text-[10px]"
key={view.id}
onClick={() => handleSetView(view.id)}
size="sm"
title={viewItem.label}
type="button"
variant={activeView === view.id ? "secondary" : "ghost"}
>
{view.label}
<CanvasToolbarIcon
active={activeView !== view.id}
className="h-3.5 w-3.5"
fallback={view.icon}
item={viewItem}
/>
</Button>
);
})()
))}
</div>
@ -313,21 +369,31 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) {
className="h-7 rounded-full px-2"
onClick={handleToggleFirstPerson}
size="sm"
title={data.firstPerson ? "退出漫游" : "进入漫游"}
title={world3dFirstPersonItem.label}
type="button"
variant={data.firstPerson ? "secondary" : "ghost"}
>
<Waypoints className="h-3.5 w-3.5" />
<CanvasToolbarIcon
active={!data.firstPerson}
className="h-3.5 w-3.5"
fallback={Waypoints}
item={world3dFirstPersonItem}
/>
</Button>
<Button
className="h-7 rounded-full px-2"
onClick={handleToggleHotspotMode}
size="sm"
title={interactionMode === "hotspot" ? "退出热点标注" : "添加热点"}
title={world3dHotspotItem.label}
type="button"
variant={interactionMode === "hotspot" ? "secondary" : "ghost"}
>
<Crosshair className="h-3.5 w-3.5" />
<CanvasToolbarIcon
active={interactionMode !== "hotspot"}
className="h-3.5 w-3.5"
fallback={Crosshair}
item={world3dHotspotItem}
/>
</Button>
</div>
) : null}
@ -337,35 +403,64 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) {
<div className="absolute right-3 top-3 z-20 flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="h-7 w-7 rounded-full" size="icon" variant="secondary">
<MoreVertical className="h-4 w-4" />
<Button
className="h-7 w-7 rounded-full"
size="icon"
title={world3dMoreItem.label}
variant="secondary"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={MoreVertical}
item={world3dMoreItem}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{data.meshUrl ? (
<DropdownMenuItem onClick={() => window.open(data.meshUrl, "_blank", "noopener,noreferrer")}>
<Box className="mr-2 h-4 w-4" />
打开 Mesh
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Box}
item={world3dOpenMeshItem}
/>
{world3dOpenMeshItem.label}
</DropdownMenuItem>
) : null}
{data.imageUrl ? (
<DropdownMenuItem onClick={() => window.open(data.imageUrl, "_blank", "noopener,noreferrer")}>
<ImageIcon className="mr-2 h-4 w-4" />
打开图像
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={ImageIcon}
item={world3dOpenImageItem}
/>
{world3dOpenImageItem.label}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
下载当前资产
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={world3dDownloadItem}
/>
{world3dDownloadItem.label}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopy}>
<Copy className="mr-2 h-4 w-4" />
复制链接
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Copy}
item={world3dCopyItem}
/>
{world3dCopyItem.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
<Trash2 className="mr-2 h-4 w-4" />
删除
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Trash2}
item={world3dDeleteItem}
/>
{world3dDeleteItem.label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -887,6 +887,37 @@ export const worldLabsGenerationService = {
}
},
async uploadMediaFromUrl(
sourceUrl: string,
kind: 'image' | 'video',
options?: { providerId?: string | null }
): Promise<
ApiResponse<{
mediaAssetId?: string;
mediaAsset?: Record<string, any>;
uploadInfo?: Record<string, any>;
providerId?: string | null;
providerName?: string | null;
}>
> {
try {
const formData = new FormData();
formData.append('sourceUrl', sourceUrl);
formData.append('kind', kind);
if (options?.providerId) {
formData.append('providerId', options.providerId);
}
const response = await fetch('/api/canvas/world/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '上传 World Labs 媒体失败' };
}
},
async createTask(params: Record<string, any>): Promise<
ApiResponse<{
taskId: string;