diff --git a/app/api/canvas/world/upload/route.ts b/app/api/canvas/world/upload/route.ts index d2dcc2abc..d607962f0 100644 --- a/app/api/canvas/world/upload/route.ts +++ b/app/api/canvas/world/upload/route.ts @@ -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 = { + 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 = { @@ -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 }); } diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index c928de041..eac3b8812 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -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" > - 导入画布 + 添加到画布 + + + + {isLayerSplitEnabled ? ( - - {worldUrl ? ( - - ) : ( - - )} + {primaryOpenLabel} {worldUrl && data.src && data.src !== worldUrl && ( - - 打开 Mesh + + {model3dOpenMeshItem.label} )} {worldImageUrl && ( - - 打开图像 + + {model3dOpenImageItem.label} )} - - 下载 + + {model3dDownloadItem.label} - - 复制链接 + + {model3dCopyItem.label} deleteNode(node.id)}> - - 删除 + + {model3dDeleteItem.label} diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index 68ec41be0..246bd393e 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -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) { ) : ( <> - +
event.stopPropagation()} + > + +
@@ -294,16 +339,27 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) {
{VIEW_META.filter((view) => availableViews.includes(view.id)).map((view) => ( + (() => { + const viewItem = getCanvasToolbarItem(canvasToolbarConfig, view.toolbarKey); + return ( + ); + })() ))}
@@ -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"} > - +
) : null} @@ -337,35 +403,64 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) {
- {data.meshUrl ? ( window.open(data.meshUrl, "_blank", "noopener,noreferrer")}> - - 打开 Mesh + + {world3dOpenMeshItem.label} ) : null} {data.imageUrl ? ( window.open(data.imageUrl, "_blank", "noopener,noreferrer")}> - - 打开图像 + + {world3dOpenImageItem.label} ) : null} - - 下载当前资产 + + {world3dDownloadItem.label} - - 复制链接 + + {world3dCopyItem.label} deleteNode(node.id)}> - - 删除 + + {world3dDeleteItem.label} diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 008094511..02dda3b51 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -887,6 +887,37 @@ export const worldLabsGenerationService = { } }, + async uploadMediaFromUrl( + sourceUrl: string, + kind: 'image' | 'video', + options?: { providerId?: string | null } + ): Promise< + ApiResponse<{ + mediaAssetId?: string; + mediaAsset?: Record; + uploadInfo?: Record; + 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): Promise< ApiResponse<{ taskId: string;