Add canvas 3D generation actions
This commit is contained in:
parent
40ec8c412e
commit
ccb62d6f7d
@ -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 });
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user