import { useCallback, useEffect, useRef, useState, type ChangeEvent, type Dispatch, type DragEvent, type SetStateAction } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { App } from "antd"; import { CANVAS_IMAGE_ASSET_DND_TYPE } from "@/components/canvas/canvas-asset-tray"; import type { InsertAssetPayload } from "@/components/canvas/asset-picker-modal"; import { CANVAS_PROJECT_CHAPTER_DND_TYPE, type CanvasProjectChapterPayload } from "@/components/canvas/canvas-project-sidebar"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils"; import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync"; import { createCanvasNode } from "@/lib/canvas/canvas-project-domain"; import { isAudioFile } from "@/lib/canvas/canvas-project-generation"; import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size"; import { uploadMediaFile } from "@/services/file-storage"; import { resolveImageUrl, uploadImage } from "@/services/image-storage"; import { getProjectUnit } from "@/services/api/projects"; import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; import { useAssetStore, type ImageAsset } from "@/stores/use-asset-store"; import { CanvasNodeType, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas"; import type { TimelineDirectMedia } from "@/types/timeline"; import type { CanvasUploadStatus } from "./canvas-project-feedback"; type UseCanvasUploadOptions = { canvasId: string; domainProjectId?: string; nodesRef: { current: CanvasNodeData[] }; selectedNodeIdsRef: { current: Set }; getCanvasCenter: () => Position; screenToCanvas: (clientX: number, clientY: number) => Position; setNodes: Dispatch>; setSelectedNodeIds: Dispatch>>; setSelectedConnectionId: Dispatch>; setContextMenu: Dispatch>; setDialogNodeId: Dispatch>; }; export type StartCanvasUploadStatus = (title: string, detail: string, total?: number) => { update: (detail: string, step: number) => void; done: (detail?: string) => void; fail: (detail?: string) => void; }; const NODE_STATUS_SUCCESS = "success" as const; const BATCH_UPLOAD_COLUMNS = 3; const BATCH_UPLOAD_COLUMN_GAP = 380; const BATCH_UPLOAD_ROW_GAP = 300; export function useCanvasUpload({ canvasId, domainProjectId, nodesRef, selectedNodeIdsRef, getCanvasCenter, screenToCanvas, setNodes, setSelectedNodeIds, setSelectedConnectionId, setContextMenu, setDialogNodeId, }: UseCanvasUploadOptions) { const { message } = App.useApp(); const queryClient = useQueryClient(); const imageInputRef = useRef(null); const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null); const assetInsertPositionRef = useRef(null); const uploadStatusIdRef = useRef(0); const statusTimersRef = useRef>(new Set()); const fileDragDepthRef = useRef(0); const [assetPickerOpen, setAssetPickerOpen] = useState(false); const [uploadModalOpen, setUploadModalOpen] = useState(false); const [uploadStatus, setUploadStatus] = useState(null); const [fileDropActive, setFileDropActive] = useState(false); useEffect(() => () => { statusTimersRef.current.forEach((timer) => window.clearTimeout(timer)); }, []); const startUploadStatus = useCallback((title, detail, total = 3) => { const id = (uploadStatusIdRef.current += 1); setUploadStatus({ id, title, detail, step: 1, total }); const dismiss = (delay: number) => { const timer = window.setTimeout(() => { statusTimersRef.current.delete(timer); setUploadStatus((current) => (current?.id === id ? null : current)); }, delay); statusTimersRef.current.add(timer); }; return { update: (nextDetail: string, step: number) => setUploadStatus((current) => (current?.id === id ? { ...current, detail: nextDetail, step: Math.min(Math.max(step, 1), total) } : current)), done: (nextDetail = "处理完成") => { setUploadStatus((current) => (current?.id === id ? { ...current, detail: nextDetail, step: total, done: true } : current)); dismiss(850); }, fail: (nextDetail = "处理失败") => { setUploadStatus((current) => (current?.id === id ? { ...current, detail: nextDetail, error: true } : current)); dismiss(1800); }, }; }, []); const selectInsertedNode = useCallback((nodeId: string, dialog: "open" | "close" | "preserve") => { setSelectedNodeIds(new Set([nodeId])); setSelectedConnectionId(null); if (dialog !== "preserve") setDialogNodeId(dialog === "open" ? nodeId : null); }, [setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]); const persistMediaNode = useCallback(async (node: CanvasNodeData) => { try { const result = await ensureCanvasNodeAsset({ canvasId, domainProjectId, node, source: "canvas-upload" }); setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item)); if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] }); return true; } catch (error) { message.warning(error instanceof Error ? `媒体已添加到画布,但素材同步失败:${error.message}` : "媒体已添加到画布,但素材同步失败"); return false; } }, [canvasId, domainProjectId, message, queryClient, setNodes]); const createImageFileNode = useCallback(async (file: File, position: Position) => { const progress = startUploadStatus("上传图片", "读取图片文件", domainProjectId ? 4 : 3); try { progress.update("上传到服务器并同步资源", 2); const image = await uploadImage(file); progress.update("更新画布节点", 3); const size = fitNodeSize(image.width, image.height); const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const node: CanvasNodeData = { id, type: CanvasNodeType.Image, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: imageMetadata(image), }; setNodes((current) => [...current, node]); selectInsertedNode(id, "open"); if (domainProjectId) progress.update("写入项目资产", 4); const persisted = await persistMediaNode(node); progress.done(persisted ? "图片已添加到画布" : "图片已添加,项目资产待重试"); return id; } catch (error) { const details = error instanceof Error ? error.message : "图片上传失败"; progress.fail(details); message.error(details); return null; } }, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]); const createImageAssetNode = useCallback(async (asset: ImageAsset, position?: Position) => { try { const content = asset.data.storageKey ? await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl || asset.coverUrl) : asset.data.dataUrl || asset.coverUrl; if (!content) { message.error("素材图片不可用"); return; } const size = fitNodeSize(asset.data.width || NODE_DEFAULT_SIZE[CanvasNodeType.Image].width, asset.data.height || NODE_DEFAULT_SIZE[CanvasNodeType.Image].height); const center = position || getCanvasCenter(); const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const node: CanvasNodeData = { id, type: CanvasNodeType.Image, title: asset.title || "素材图片", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { content, storageKey: asset.data.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: asset.data.width, naturalHeight: asset.data.height, bytes: asset.data.bytes || getDataUrlByteSize(content.startsWith("data:") ? content : ""), mimeType: asset.data.mimeType || "image/png", prompt: typeof asset.metadata?.prompt === "string" ? asset.metadata.prompt : asset.title, assetId: asset.id, assetTags: asset.tags || [], }, }; setNodes((current) => [...current, node]); selectInsertedNode(id, "close"); } catch (error) { message.error(error instanceof Error ? error.message : "素材图片读取失败"); } }, [getCanvasCenter, message, selectInsertedNode, setNodes]); const createVideoFileNode = useCallback(async (file: File, position: Position) => { const progress = startUploadStatus("上传视频", "读取视频文件", domainProjectId ? 4 : 3); try { progress.update("上传到服务器并同步资源", 2); const video = await uploadMediaFile(file, "video"); progress.update("更新画布节点", 3); const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height); const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const node = { id, type: CanvasNodeType.Video, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: videoMetadata(video) } satisfies CanvasNodeData; setNodes((current) => [...current, node]); selectInsertedNode(id, "open"); if (domainProjectId) progress.update("写入项目资产", 4); const persisted = await persistMediaNode(node); progress.done(persisted ? "视频已添加到画布" : "视频已添加,项目资产待重试"); return id; } catch (error) { const details = error instanceof Error ? error.message : "视频上传失败"; progress.fail(details); message.error(details); return null; } }, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]); const createAudioFileNode = useCallback(async (file: File, position: Position) => { const progress = startUploadStatus("上传音频", "读取音频文件", domainProjectId ? 4 : 3); try { progress.update("上传到服务器并同步资源", 2); const audio = await uploadMediaFile(file, "audio"); progress.update("更新画布节点", 3); const size = NODE_DEFAULT_SIZE[CanvasNodeType.Audio]; const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const node = { id, type: CanvasNodeType.Audio, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: audioMetadata(audio) } satisfies CanvasNodeData; setNodes((current) => [...current, node]); selectInsertedNode(id, "preserve"); if (domainProjectId) progress.update("写入项目资产", 4); const persisted = await persistMediaNode(node); progress.done(persisted ? "音频已添加到画布" : "音频已添加,项目资产待重试"); return id; } catch (error) { const details = error instanceof Error ? error.message : "音频上传失败"; progress.fail(details); message.error(details); return null; } }, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]); const createTextNodeFromClipboard = useCallback((text: string, position?: Position) => { const trimmed = text.trim(); if (!trimmed) return false; const node = { ...createCanvasNode(CanvasNodeType.Text, position || getCanvasCenter(), { content: trimmed, status: NODE_STATUS_SUCCESS }), title: trimmed.slice(0, 32) || "剪切板文本", }; setNodes((current) => [...current, node]); selectInsertedNode(node.id, "open"); setContextMenu(null); return true; }, [getCanvasCenter, selectInsertedNode, setContextMenu, setNodes]); const handleProjectChapterInsert = useCallback(async (chapter: CanvasProjectChapterPayload, position?: Position) => { let sourceText = chapter.sourceText; if (sourceText === undefined) { try { sourceText = (await getProjectUnit(chapter.projectId, chapter.id)).unit.sourceText; } catch (error) { message.error(error instanceof Error ? `章节正文读取失败:${error.message}` : "章节正文读取失败"); return; } } const content = htmlToPlainText(sourceText); const node = createCanvasNode(CanvasNodeType.Text, position || getCanvasCenter(), { content, prompt: content, status: NODE_STATUS_SUCCESS, workflowKind: "free", workflowTitle: "项目章节", workflowDescription: `第 ${chapter.position + 1} 章`, chapterId: chapter.id, chapterTitle: chapter.title, fontSize: 14, }); node.title = `章节 · ${chapter.title}`; node.width = 460; node.height = 280; setNodes((current) => [...current, node]); selectInsertedNode(node.id, "preserve"); setContextMenu(null); message.success(`已添加“${chapter.title}”`); }, [getCanvasCenter, message, selectInsertedNode, setContextMenu, setNodes]); const handleUploadRequest = useCallback((nodeId?: string, position?: Position) => { uploadTargetRef.current = { nodeId, position }; if (!nodeId) { setUploadModalOpen(true); return; } const target = nodeId ? nodesRef.current.find((node) => node.id === nodeId) : null; if (imageInputRef.current) { imageInputRef.current.accept = target?.type === CanvasNodeType.Image ? "image/*" : target?.type === CanvasNodeType.Video ? "video/*" : target?.type === CanvasNodeType.Audio ? "audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav" : "image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav"; } imageInputRef.current?.click(); }, [nodesRef]); const closeUploadModal = useCallback(() => { uploadTargetRef.current = null; setUploadModalOpen(false); }, []); const handleUploadFiles = useCallback(async (files: File[]) => { const supportedFiles = files.filter((file) => file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file)); if (!supportedFiles.length) { message.warning("请选择图片、视频、MP3 或 WAV 文件"); return false; } const center = uploadTargetRef.current?.position || getCanvasCenter(); const columns = Math.min(BATCH_UPLOAD_COLUMNS, supportedFiles.length); const originX = center.x - ((columns - 1) * BATCH_UPLOAD_COLUMN_GAP) / 2; const createdIds: string[] = []; for (let index = 0; index < supportedFiles.length; index += 1) { const file = supportedFiles[index]; const position = { x: originX + (index % columns) * BATCH_UPLOAD_COLUMN_GAP, y: center.y + Math.floor(index / columns) * BATCH_UPLOAD_ROW_GAP, }; const createdId = await (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position)); if (createdId) createdIds.push(createdId); } if (!createdIds.length) return false; setSelectedNodeIds(new Set(createdIds)); setSelectedConnectionId(null); setDialogNodeId(null); const failedCount = supportedFiles.length - createdIds.length; if (failedCount) message.warning(`已添加 ${createdIds.length} 个文件,${failedCount} 个上传失败`); else message.success(`已添加 ${createdIds.length} 个文件到画布`); return true; }, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]); // 时间线专用:把本地音视频文件上传为直连媒体(仅时间线作用域,不创建画布节点),返回媒体描述数组。 const uploadTimelineMedia = useCallback(async (files: File[]): Promise => { const supportedFiles = files.filter((file) => file.type.startsWith("video/") || isAudioFile(file)); if (!supportedFiles.length) { message.warning("请选择视频、MP3 或 WAV 文件"); return []; } const created: TimelineDirectMedia[] = []; for (const file of supportedFiles) { try { if (isAudioFile(file)) { const audio = await uploadMediaFile(file, "audio"); created.push({ id: `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, kind: "audio", title: file.name, storageKey: audio.storageKey, url: audio.url, durationMs: audio.durationMs, bytes: audio.bytes, mimeType: audio.mimeType, }); } else { const video = await uploadMediaFile(file, "video"); created.push({ id: `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, kind: "video", title: file.name, storageKey: video.storageKey, url: video.url, width: video.width, height: video.height, durationMs: video.durationMs, bytes: video.bytes, mimeType: video.mimeType, }); } } catch (error) { message.error(error instanceof Error ? `素材上传失败:${error.message}` : "素材上传失败"); } } if (created.length) message.success(`已上传 ${created.length} 个素材到时间线`); return created; }, [message]); // 组装能力闭环:把时间线合成结果(MP4 Blob)上传并创建为新的视频节点放回画布, // 复用上传/持久化/选中逻辑,新节点可继续编辑字幕与样式。 const createVideoNodeFromBlob = useCallback(async (blob: Blob, title: string): Promise => { const progress = startUploadStatus("合成视频片段", "上传合成结果", domainProjectId ? 4 : 3); try { progress.update("上传到服务器并同步资源", 2); const video = await uploadMediaFile(blob, "video"); progress.update("更新画布节点", 3); const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height); const center = getCanvasCenter(); const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const node = { id, type: CanvasNodeType.Video, title, position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...videoMetadata(video), status: NODE_STATUS_SUCCESS }, } satisfies CanvasNodeData; setNodes((current) => [...current, node]); selectInsertedNode(id, "preserve"); if (domainProjectId) progress.update("写入项目资产", 4); const persisted = await persistMediaNode(node); progress.done(persisted ? "已生成新视频片段并加入项目资产" : "已生成新视频片段,项目资产待重试"); return node; } catch (error) { const details = error instanceof Error ? error.message : "合成视频片段失败"; progress.fail(details); message.error(details); return null; } }, [domainProjectId, getCanvasCenter, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]); const replaceNodeMedia = useCallback(async (nodeId: string, file: File) => { const currentNode = nodesRef.current.find((node) => node.id === nodeId); if (!currentNode) return false; if (isAudioFile(file)) { const progress = startUploadStatus("替换音频", "读取音频文件"); try { progress.update("上传到服务器并同步资源", 2); const audio = await uploadMediaFile(file, "audio"); progress.update("更新画布节点", 3); const node = { ...currentNode, type: CanvasNodeType.Audio, title: file.name, metadata: { ...currentNode.metadata, ...audioMetadata(audio), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData; setNodes((current) => current.map((item) => item.id === nodeId ? node : item)); selectInsertedNode(nodeId, "preserve"); const persisted = await persistMediaNode(node); progress.done(persisted ? "音频已替换,可撤销恢复" : "音频已替换,项目资产待重试"); return true; } catch (error) { const details = error instanceof Error ? error.message : "音频替换失败"; progress.fail(details); message.error(details); return false; } } if (file.type.startsWith("video/")) { const progress = startUploadStatus("替换视频", "读取视频文件"); try { progress.update("上传到服务器并同步资源", 2); const video = await uploadMediaFile(file, "video"); progress.update("更新画布节点", 3); const node = { ...currentNode, type: CanvasNodeType.Video, title: file.name, metadata: { ...currentNode.metadata, ...videoMetadata(video), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData; setNodes((current) => current.map((item) => item.id === nodeId ? node : item)); selectInsertedNode(nodeId, "open"); const persisted = await persistMediaNode(node); progress.done(persisted ? "视频已替换,可撤销恢复" : "视频已替换,项目资产待重试"); return true; } catch (error) { const details = error instanceof Error ? error.message : "视频替换失败"; progress.fail(details); message.error(details); return false; } } const progress = startUploadStatus("替换图片", "读取图片文件"); try { progress.update("上传到服务器并同步资源", 2); const image = await uploadImage(file); progress.update("更新画布节点", 3); const node = { ...currentNode, type: CanvasNodeType.Image, title: file.name, metadata: { ...currentNode.metadata, ...imageMetadata(image), assetId: undefined, taskId: undefined, errorDetails: undefined, freeResize: false, isBatchRoot: undefined, batchRootId: undefined, batchChildIds: undefined, batchFailedCount: undefined, batchUsesReferenceImages: undefined, generationType: undefined, model: undefined, size: undefined, quality: undefined, transparentBackground: undefined, count: undefined, references: undefined, primaryImageId: undefined, imageBatchExpanded: undefined, }, } satisfies CanvasNodeData; setNodes((current) => current.map((item) => item.id === nodeId ? node : item)); selectInsertedNode(nodeId, "open"); const persisted = await persistMediaNode(node); progress.done(persisted ? "图片已替换,可撤销恢复" : "图片已替换,项目资产待重试"); return true; } catch (error) { const details = error instanceof Error ? error.message : "图片替换失败"; progress.fail(details); message.error(details); return false; } }, [message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]); const pasteSystemClipboard = useCallback(async (position?: Position, clipboardEvent?: ClipboardEvent | null) => { const isNodeMarker = (value: string) => { const trimmed = value.trim(); return trimmed.startsWith("open-ai-canvas-nodes:") || trimmed.startsWith("open-ai-canvas-nodes-json:"); }; const pasteImageFile = async (file: File) => { const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id)); if (selected.length === 1 && selected[0].type === CanvasNodeType.Image) { if (await replaceNodeMedia(selected[0].id, file)) message.success("已用剪切板图片替换,可撤销恢复"); return true; } const inserted = await createImageFileNode(file, position || getCanvasCenter()); if (inserted) message.success("已从剪切板添加图片"); return Boolean(inserted); }; // 1) paste 事件里的图片文件(截图/资源管理器复制)优先。 const filesFromEvent = clipboardEvent ? Array.from(clipboardEvent.clipboardData?.items || []) .filter((item) => item.kind === "file" && item.type.startsWith("image/")) .map((item) => item.getAsFile()) .filter((file): file is File => Boolean(file)) : []; if (filesFromEvent.length) return pasteImageFile(filesFromEvent[0]); // 2) 若系统文本是节点标记,说明最近一次复制是画布节点,不要再读旧图片。 const eventText = clipboardEvent?.clipboardData?.getData("text/plain") || ""; if (isNodeMarker(eventText)) return false; // 3) 异步读系统剪贴板:有图片才导入;若文本是节点标记则让位给节点粘贴。 if (navigator.clipboard?.read) { try { const items = await navigator.clipboard.read(); const textItem = items.find((item) => item.types.includes("text/plain")); if (textItem) { const textBlob = await textItem.getType("text/plain"); const text = await textBlob.text(); if (isNodeMarker(text)) return false; } const imageItem = items.find((item) => item.types.some((type) => type.startsWith("image/"))); if (imageItem) { const imageType = imageItem.types.find((type) => type.startsWith("image/")); if (!imageType) return false; const blob = await imageItem.getType(imageType); return pasteImageFile(new File([blob], "clipboard-image.png", { type: imageType })); } } catch { // 无权限时继续文本分支。 } } try { const text = eventText || (navigator.clipboard?.readText ? await navigator.clipboard.readText() : ""); if (isNodeMarker(text)) return false; if (createTextNodeFromClipboard(text, position)) { message.success("已从剪切板添加文本"); return true; } } catch { // ignore } return false; }, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]); const handleImageInputChange = useCallback(async (event: ChangeEvent) => { const file = event.target.files?.[0]; const target = uploadTargetRef.current; try { if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/") && !isAudioFile(file))) return; if (target?.nodeId) { const targetNode = nodesRef.current.find((node) => node.id === target.nodeId); const compatible = !targetNode || (targetNode.type === CanvasNodeType.Image && file.type.startsWith("image/")) || (targetNode.type === CanvasNodeType.Video && file.type.startsWith("video/")) || (targetNode.type === CanvasNodeType.Audio && isAudioFile(file)) || (targetNode.type !== CanvasNodeType.Image && targetNode.type !== CanvasNodeType.Video && targetNode.type !== CanvasNodeType.Audio); if (!compatible) { message.warning("请选择与当前节点相同类型的媒体文件"); return; } await replaceNodeMedia(target.nodeId, file); return; } const position = target?.position || getCanvasCenter(); await (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position)); } finally { uploadTargetRef.current = null; event.target.value = ""; } }, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]); const handleDrop = useCallback((event: DragEvent) => { event.preventDefault(); fileDragDepthRef.current = 0; setFileDropActive(false); const chapterPayload = parseProjectChapterPayload(event.dataTransfer.getData(CANVAS_PROJECT_CHAPTER_DND_TYPE)); if (chapterPayload) { void handleProjectChapterInsert(chapterPayload, screenToCanvas(event.clientX, event.clientY)); return; } const imageAssetId = event.dataTransfer.getData(CANVAS_IMAGE_ASSET_DND_TYPE); if (imageAssetId) { const asset = useAssetStore.getState().assets.find((item): item is ImageAsset => item.kind === "image" && item.id === imageAssetId); if (!asset) { message.warning("素材不存在"); return; } void createImageAssetNode(asset, screenToCanvas(event.clientX, event.clientY)); return; } const files = Array.from(event.dataTransfer.files).filter((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item)); if (!files.length) return; if (files.length > 1) { void handleUploadFiles(files); return; } const file = files[0]; const position = screenToCanvas(event.clientX, event.clientY); const target = [...nodesRef.current].reverse().find((node) => { const compatible = (node.type === CanvasNodeType.Image && file.type.startsWith("image/")) || (node.type === CanvasNodeType.Video && file.type.startsWith("video/")) || (node.type === CanvasNodeType.Audio && isAudioFile(file)); return compatible && position.x >= node.position.x && position.x <= node.position.x + node.width && position.y >= node.position.y && position.y <= node.position.y + node.height; }); if (target) { void replaceNodeMedia(target.id, file).then((replaced) => { if (replaced) message.success("媒体已替换,可撤销恢复"); }); return; } void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position)); }, [createAudioFileNode, createImageAssetNode, createImageFileNode, createVideoFileNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]); const handleFileDragEnter = useCallback((event: DragEvent) => { if (!hasDraggedFiles(event)) return; event.preventDefault(); fileDragDepthRef.current += 1; setFileDropActive(true); }, []); const handleFileDragOver = useCallback((event: DragEvent) => { if (!hasDraggedFiles(event) && !Array.from(event.dataTransfer.types).includes(CANVAS_PROJECT_CHAPTER_DND_TYPE)) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }, []); const handleFileDragLeave = useCallback((event: DragEvent) => { if (!hasDraggedFiles(event)) return; fileDragDepthRef.current = Math.max(0, fileDragDepthRef.current - 1); if (fileDragDepthRef.current === 0) setFileDropActive(false); }, []); const pasteAssistantImage = useCallback((file: File) => { void createImageFileNode(file, getCanvasCenter()).then((inserted) => { if (inserted) message.success("已从剪切板添加图片"); }); }, [createImageFileNode, getCanvasCenter, message]); const openAssetsAtPosition = useCallback((position?: Position) => { assetInsertPositionRef.current = position || null; setAssetPickerOpen(true); }, []); const closeAssetPicker = useCallback(() => { assetInsertPositionRef.current = null; setAssetPickerOpen(false); }, []); const createAssetPayloadNode = useCallback(async (payload: InsertAssetPayload, center: Position) => { if (payload.kind === "character") { const width = 320; const height = 260; return { id: `character-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, type: CanvasNodeType.Text, title: payload.title, position: { x: center.x - width / 2, y: center.y - height / 2 }, width, height, metadata: { workflowKind: "character", characterAssetId: payload.assetId, characterVersionId: payload.versionId, characterVersionPolicy: "current", characterName: payload.title, characterPrompt: payload.prompt, characterAliases: payload.aliases, characterDefinition: payload.definition, characterCoverUrl: payload.coverUrl, characterVisualStatus: payload.visualStatus, characterVoiceStatus: payload.voiceStatus, characterVoiceName: payload.voiceName, characterVoiceProfile: payload.voiceProfile, characterVoiceInstructions: payload.voiceInstructions, assetId: payload.assetId, status: NODE_STATUS_SUCCESS, fontSize: 14, }, } satisfies CanvasNodeData; } if (payload.kind === "text") { const node = { ...createCanvasNode(CanvasNodeType.Text, center, { content: payload.content, status: NODE_STATUS_SUCCESS, assetId: payload.assetId }), title: payload.content.slice(0, 32) || "Assistant Text" }; return node; } if (payload.kind === "audio") { const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio]; const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; return { id, type: CanvasNodeType.Audio, title: payload.title, position: { x: center.x - spec.width / 2, y: center.y - spec.height / 2 }, width: spec.width, height: spec.height, metadata: { content: payload.url, storageKey: payload.storageKey, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType || "audio/mpeg", assetId: payload.assetId, status: NODE_STATUS_SUCCESS } } satisfies CanvasNodeData; } if (payload.kind === "video") { const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video]; const size = fitNodeSize(payload.width || spec.width, payload.height || spec.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height); const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; return { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType || "video/mp4", assetId: payload.assetId } } satisfies CanvasNodeData; } const storedImage = payload.url ? { url: payload.url, storageKey: undefined, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" } : payload.storageKey ? { url: payload.dataUrl, storageKey: payload.storageKey, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" } : await uploadImage(payload.dataUrl); const meta = !payload.storageKey && (!payload.width || !payload.height) ? await readImageMeta(storedImage.url) : storedImage; const size = fitNodeSize(meta.width, meta.height); const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const metadata = storedImage.storageKey ? imageMetadata({ ...storedImage, storageKey: storedImage.storageKey, width: meta.width, height: meta.height }) : { content: storedImage.url, status: NODE_STATUS_SUCCESS, naturalWidth: meta.width, naturalHeight: meta.height, bytes: storedImage.bytes, mimeType: storedImage.mimeType }; return { id, type: CanvasNodeType.Image, title: payload.title.slice(0, 32) || "Generated Image", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...metadata, prompt: payload.title, assetId: payload.assetId } } satisfies CanvasNodeData; }, []); const insertAssetPayloads = useCallback(async (payloads: InsertAssetPayload[], origin: Position, successMessage: string, failureMessage: string): Promise => { try { const created = await Promise.all(payloads.map((payload, index) => createAssetPayloadNode(payload, { x: origin.x + (index % BATCH_UPLOAD_COLUMNS) * BATCH_UPLOAD_COLUMN_GAP, y: origin.y + Math.floor(index / BATCH_UPLOAD_COLUMNS) * BATCH_UPLOAD_ROW_GAP, }))); setNodes((current) => [...current, ...created]); setSelectedNodeIds(new Set(created.map((node) => node.id))); setSelectedConnectionId(null); setDialogNodeId(null); message.success(successMessage); return created; } catch (error) { message.error(error instanceof Error ? error.message : failureMessage); throw error; } }, [createAssetPayloadNode, message, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]); const handleAssetsInsert = useCallback(async (payloads: InsertAssetPayload[]): Promise => { const origin = assetInsertPositionRef.current || getCanvasCenter(); return insertAssetPayloads(payloads, origin, `已插入 ${payloads.length} 项素材`, "素材插入失败"); }, [getCanvasCenter, insertAssetPayloads]); const handleProjectAssetsInsert = useCallback(async (payloads: InsertAssetPayload[], position?: Position): Promise => { const origin = position || getCanvasCenter(); return insertAssetPayloads(payloads, origin, `已引入 ${payloads.length} 项项目资产`, "项目资产引入失败"); }, [getCanvasCenter, insertAssetPayloads]); return { assetPickerOpen, closeAssetPicker, createVideoNodeFromBlob, createAssetPayloadNode, createImageAssetNode, fileDropActive, handleAssetsInsert, handleDrop, handleFileDragEnter, handleFileDragLeave, handleFileDragOver, handleImageInputChange, handleProjectAssetsInsert, handleProjectChapterInsert, handleUploadFiles, handleUploadRequest, imageInputRef, closeUploadModal, openAssetsAtPosition, pasteAssistantImage, pasteSystemClipboard, startUploadStatus, uploadModalOpen, uploadStatus, uploadTimelineMedia, }; } function hasDraggedFiles(event: DragEvent) { return Array.from(event.dataTransfer.types).includes("Files"); } function parseProjectChapterPayload(value: string): CanvasProjectChapterPayload | null { if (!value) return null; try { const payload = JSON.parse(value) as Partial; const validSource = payload.sourceText === undefined || typeof payload.sourceText === "string"; return typeof payload.id === "string" && typeof payload.projectId === "string" && typeof payload.title === "string" && validSource && typeof payload.position === "number" ? payload as CanvasProjectChapterPayload : null; } catch { return null; } } function htmlToPlainText(value: string) { if (!value) return ""; const document = new DOMParser().parseFromString(value, "text/html"); return document.body.textContent?.trim() || ""; }